mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Compare commits
282
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea35e93bc | ||
|
|
f633b2310e | ||
|
|
852e3d550f | ||
|
|
4b43921b9c | ||
|
|
1374258c90 | ||
|
|
4cbba04137 | ||
|
|
cf6d82be1e | ||
|
|
94d2da21ef | ||
|
|
5503930448 | ||
|
|
b345706b65 | ||
|
|
3929e6d6a6 | ||
|
|
28ced738de | ||
|
|
db3aef8f2a | ||
|
|
85ed428291 | ||
|
|
53f2b5d9d0 | ||
|
|
69d3aa9d49 | ||
|
|
67e81ec9bb | ||
|
|
7748b87d43 | ||
|
|
20b3b48855 | ||
|
|
b89fcad7c0 | ||
|
|
1ed5bcceda | ||
|
|
47ae99d74e | ||
|
|
8bed067878 | ||
|
|
acd31a90b5 | ||
|
|
350eb7ba98 | ||
|
|
d116467692 | ||
|
|
7de30c9592 | ||
|
|
c550bfaa39 | ||
|
|
3dd8bc776f | ||
|
|
ec5e77aaff | ||
|
|
f027fdebb5 | ||
|
|
393297e10c | ||
|
|
4f3b766aa5 | ||
|
|
5862bafdce | ||
|
|
043b1bb3b4 | ||
|
|
a24471af62 | ||
|
|
63838b2ad2 | ||
|
|
7e9452d266 | ||
|
|
835e420608 | ||
|
|
b20af404be | ||
|
|
39ee4751dc | ||
|
|
3650524101 | ||
|
|
f88f590f75 | ||
|
|
050878b027 | ||
|
|
12cef7594d | ||
|
|
c0ca7d8759 | ||
|
|
73e8ef81e5 | ||
|
|
42c60fa466 | ||
|
|
a4eb66f9fb | ||
|
|
c513420164 | ||
|
|
c6e90d69fe | ||
|
|
887063afdd | ||
|
|
e198f97da6 | ||
|
|
4fb037ae20 | ||
|
|
4552b1613f | ||
|
|
1a3ffc245e | ||
|
|
236b1b17b4 | ||
|
|
22d5b62f23 | ||
|
|
72fedeed7a | ||
|
|
ad74c81426 | ||
|
|
1c6de1255c | ||
|
|
8b79355963 | ||
|
|
193253a657 | ||
|
|
e5c5ded7cb | ||
|
|
b4b3c96f17 | ||
|
|
a8c1c37581 | ||
|
|
0d794d0baa | ||
|
|
f0fcc2840e | ||
|
|
a5d9a1cf25 | ||
|
|
b95d976002 | ||
|
|
3be4cd50d4 | ||
|
|
c2329136b8 | ||
|
|
d5a255ef96 | ||
|
|
852b480628 | ||
|
|
1670e6e611 | ||
|
|
f45e0cef51 | ||
|
|
66fe3d2716 | ||
|
|
977537aa00 | ||
|
|
9bd60b8c01 | ||
|
|
0f687e63d9 | ||
|
|
a5b8f84e81 | ||
|
|
595b705f2d | ||
|
|
b6d37bfadc | ||
|
|
eeca3e02a7 | ||
|
|
0a4f761d5d | ||
|
|
9a89e1732d | ||
|
|
35a255237a | ||
|
|
254122872c | ||
|
|
b2bda7443c | ||
|
|
07f3367d8a | ||
|
|
207a7ffbca | ||
|
|
073b93a92d | ||
|
|
3606452a8a | ||
|
|
be95cb035b | ||
|
|
1389de2bab | ||
|
|
4cb9f23248 | ||
|
|
712a61fbcc | ||
|
|
384223e9a5 | ||
|
|
474df87f87 | ||
|
|
c78c980a06 | ||
|
|
2b63d69145 | ||
|
|
b1e271e7ce | ||
|
|
0c24607bcc | ||
|
|
d546d07f04 | ||
|
|
8a2602275a | ||
|
|
f8c30f13fd | ||
|
|
c720e2053f | ||
|
|
8b75297b17 | ||
|
|
ecc936014c | ||
|
|
8135136953 | ||
|
|
783d22b50c | ||
|
|
88165fb77e | ||
|
|
decc0bbe93 | ||
|
|
9a26e3afcb | ||
|
|
447dd828f7 | ||
|
|
ed275c360b | ||
|
|
f4ea676d62 | ||
|
|
bdfc58aeab | ||
|
|
70d0bdbf1d | ||
|
|
658bfcb4f6 | ||
|
|
cd435a5a99 | ||
|
|
6949b0e1a2 | ||
|
|
5e90ad4407 | ||
|
|
f16a0f3376 | ||
|
|
a02e25aed9 | ||
|
|
90970a9275 | ||
|
|
716d13a968 | ||
|
|
098f4848f9 | ||
|
|
23c1c701c0 | ||
|
|
8c242e979b | ||
|
|
d0d1113f7c | ||
|
|
d7cbe27d67 | ||
|
|
cb863a056e | ||
|
|
37dccd6612 | ||
|
|
88f1ef78f7 | ||
|
|
9146d034f1 | ||
|
|
2dd2c3bc61 | ||
|
|
ca3fb37370 | ||
|
|
f463030202 | ||
|
|
c883c721c9 | ||
|
|
bba9648bbd | ||
|
|
79ed7ff588 | ||
|
|
fb31f39650 | ||
|
|
78ab5ceb93 | ||
|
|
bff0142eae | ||
|
|
75dba3a878 | ||
|
|
c9c9e200a5 | ||
|
|
d410075e48 | ||
|
|
b7ec2f8092 | ||
|
|
875d359a3f | ||
|
|
6872516960 | ||
|
|
032a5e2f29 | ||
|
|
ad59ac9d0c | ||
|
|
0b14b07329 | ||
|
|
f37b25f4ef | ||
|
|
e6a391b66c | ||
|
|
0a108305f4 | ||
|
|
ebfd05f13a | ||
|
|
e41f4653dd | ||
|
|
1cb80c3d6b | ||
|
|
72191c5729 | ||
|
|
fed59d6d14 | ||
|
|
264fade906 | ||
|
|
5887d3b996 | ||
|
|
48f6cce716 | ||
|
|
e39060db49 | ||
|
|
b27190f1aa | ||
|
|
263e4dd82d | ||
|
|
62d548a9d9 | ||
|
|
51dc6c97e5 | ||
|
|
74b8b46f5c | ||
|
|
5197fb9483 | ||
|
|
75acf5510e | ||
|
|
4fbf56c0c6 | ||
|
|
907d104339 | ||
|
|
1c89408e38 | ||
|
|
e16076bd3a | ||
|
|
ccf4027e11 | ||
|
|
008c9faada | ||
|
|
5b0a8ccc96 | ||
|
|
1f555fb84b | ||
|
|
d7e716c909 | ||
|
|
34277827c8 | ||
|
|
d000fb2bb8 | ||
|
|
67c4851ea3 | ||
|
|
b5e2947a45 | ||
|
|
81c4d1054f | ||
|
|
68ea8325cf | ||
|
|
570f362748 | ||
|
|
b9ed6bf79b | ||
|
|
14c37af4f6 | ||
|
|
529ccd61ff | ||
|
|
9cb0934846 | ||
|
|
d797d63f8e | ||
|
|
b5bbdfcf2d | ||
|
|
b75e9cde04 | ||
|
|
a46f7312c0 | ||
|
|
50f747bc4a | ||
|
|
e6828b73e1 | ||
|
|
6b60035416 | ||
|
|
b43ff0d6fd | ||
|
|
71e66bfa25 | ||
|
|
71506f6ed9 | ||
|
|
8983aece22 | ||
|
|
fecd5979c1 | ||
|
|
f9e0df86ec | ||
|
|
60ffa7dd8b | ||
|
|
fde9a50eb2 | ||
|
|
5c13e08fd6 | ||
|
|
93f8d4abba | ||
|
|
a7e7598f69 | ||
|
|
8ef26dc009 | ||
|
|
28ca2476a6 | ||
|
|
e0de9f1bc4 | ||
|
|
b83b97fa44 | ||
|
|
a45e18ecf1 | ||
|
|
a2ebf97719 | ||
|
|
95d7a1b878 | ||
|
|
3ff5eff0fc | ||
|
|
4068baf1a2 | ||
|
|
e1baed7b87 | ||
|
|
1d250d7420 | ||
|
|
cdc22865fd | ||
|
|
7d43151ec5 | ||
|
|
aeb8816010 | ||
|
|
2a697d45ba | ||
|
|
a36fa1a4e2 | ||
|
|
bd41d7b181 | ||
|
|
6f7d6a4ee4 | ||
|
|
dc6ab9eed5 | ||
|
|
e5681fc4c8 | ||
|
|
065df99929 | ||
|
|
51676aa204 | ||
|
|
9dc0c3f1e3 | ||
|
|
451e373165 | ||
|
|
6a2b804c23 | ||
|
|
394613e95e | ||
|
|
3fba553a2f | ||
|
|
7ca2724101 | ||
|
|
29a72ad324 | ||
|
|
d0623e06cb | ||
|
|
9648c59fe5 | ||
|
|
9480978770 | ||
|
|
5fe41efa7c | ||
|
|
c40dbac0c1 | ||
|
|
3e1c3b0108 | ||
|
|
bb5d17223a | ||
|
|
8c4b30bee8 | ||
|
|
82dbc01443 | ||
|
|
5f33183055 | ||
|
|
22622b8981 | ||
|
|
809c338843 | ||
|
|
d70c5995a6 | ||
|
|
792ef42e5f | ||
|
|
94a68a3d77 | ||
|
|
274e05fdc2 | ||
|
|
07dab0761d | ||
|
|
d1ba4ee71e | ||
|
|
6963421604 | ||
|
|
b24ef4f6f6 | ||
|
|
15763c46bb | ||
|
|
217b5282f8 | ||
|
|
700e6a05be | ||
|
|
11b14d0150 | ||
|
|
76ef4d9b13 | ||
|
|
02a3ca7bf2 | ||
|
|
0ab94672de | ||
|
|
e1139e2276 | ||
|
|
25ee889495 | ||
|
|
964b904fd2 | ||
|
|
662288d791 | ||
|
|
030227c5d1 | ||
|
|
e0b135ca43 | ||
|
|
bc8c608506 | ||
|
|
b730558413 | ||
|
|
650b96bb4e | ||
|
|
ed9dc62f5a | ||
|
|
b4a05d279a | ||
|
|
1347d825c3 | ||
|
|
3691134c5b | ||
|
|
de9ec4f7ee | ||
|
|
c1ff76635e |
No files matched your search
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: developing-openrig
|
||||
description: "Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
|
||||
---
|
||||
|
||||
# Developing OpenRig
|
||||
|
||||
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
|
||||
code is right, and fixing the map is a welcome contribution.
|
||||
|
||||
## First: which OpenRig are you in?
|
||||
|
||||
If you are an agent running inside OpenRig, two different OpenRigs are in play:
|
||||
|
||||
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
|
||||
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
|
||||
|
||||
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
|
||||
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
|
||||
not by restarting the daemon you live in.
|
||||
|
||||
## Find your way
|
||||
|
||||
| You want to know | Read | How much to trust it |
|
||||
|---|---|---|
|
||||
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
|
||||
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
|
||||
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
|
||||
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
|
||||
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Most modules predate 0.6; check each one's `last-verified-against-source` marker against `git log` before relying on it |
|
||||
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
|
||||
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
|
||||
|
||||
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
|
||||
|
||||
## Before you change an artery
|
||||
|
||||
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
|
||||
projection, process observation, migrations, restore):
|
||||
|
||||
1. Read the artery row: what depends on it, and the past regressions listed there.
|
||||
2. Describe the downstream effect in your pull request, not just the diff.
|
||||
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
|
||||
|
||||
## Test it
|
||||
|
||||
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
|
||||
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
|
||||
|
||||
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
|
||||
it won't touch the daemon your session runs on.
|
||||
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
|
||||
|
||||
## How we judge changes
|
||||
|
||||
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
|
||||
|
||||
- A change that removes friction, or makes state more truthful, is usually welcome.
|
||||
- A change that adds a refusal, a prompt or a required step needs the concrete case in CONTRIBUTING.md: who is
|
||||
harmed, how, and what it costs everyone else.
|
||||
- One concern per pull request. Keep the diff reviewable in one sitting.
|
||||
|
||||
## When the map is wrong
|
||||
|
||||
Fix it in the same pull request, or open an issue. Update a document's `last-verified-against-source` marker only when
|
||||
you have actually checked it against that commit.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: developing-openrig
|
||||
description: "Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
|
||||
---
|
||||
|
||||
# Developing OpenRig
|
||||
|
||||
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
|
||||
code is right, and fixing the map is a welcome contribution.
|
||||
|
||||
## First: which OpenRig are you in?
|
||||
|
||||
If you are an agent running inside OpenRig, two different OpenRigs are in play:
|
||||
|
||||
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
|
||||
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
|
||||
|
||||
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
|
||||
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
|
||||
not by restarting the daemon you live in.
|
||||
|
||||
## Find your way
|
||||
|
||||
| You want to know | Read | How much to trust it |
|
||||
|---|---|---|
|
||||
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
|
||||
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
|
||||
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
|
||||
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
|
||||
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Most modules predate 0.6; check each one's `last-verified-against-source` marker against `git log` before relying on it |
|
||||
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
|
||||
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
|
||||
|
||||
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
|
||||
|
||||
## Before you change an artery
|
||||
|
||||
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
|
||||
projection, process observation, migrations, restore):
|
||||
|
||||
1. Read the artery row: what depends on it, and the past regressions listed there.
|
||||
2. Describe the downstream effect in your pull request, not just the diff.
|
||||
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
|
||||
|
||||
## Test it
|
||||
|
||||
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
|
||||
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
|
||||
|
||||
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
|
||||
it won't touch the daemon your session runs on.
|
||||
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
|
||||
|
||||
## How we judge changes
|
||||
|
||||
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
|
||||
|
||||
- A change that removes friction, or makes state more truthful, is usually welcome.
|
||||
- A change that adds a refusal, a prompt or a required step needs the concrete case in CONTRIBUTING.md: who is
|
||||
harmed, how, and what it costs everyone else.
|
||||
- One concern per pull request. Keep the diff reviewable in one sitting.
|
||||
|
||||
## When the map is wrong
|
||||
|
||||
Fix it in the same pull request, or open an issue. Update a document's `last-verified-against-source` marker only when
|
||||
you have actually checked it against that commit.
|
||||
+8
-2
@@ -22,8 +22,14 @@ notes/*
|
||||
*.sqlite-wal
|
||||
*.sqlite.pre-reboot-backup
|
||||
.openrig/
|
||||
.claude
|
||||
.agents
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.claude/skills/*
|
||||
!.claude/skills/developing-openrig/
|
||||
.agents/*
|
||||
!.agents/skills/
|
||||
.agents/skills/*
|
||||
!.agents/skills/developing-openrig/
|
||||
.openrig
|
||||
.worktrees/
|
||||
|
||||
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
# OpenRig architecture
|
||||
|
||||
> **This map is not the territory.** It is a zoomed-out guide for finding your way around the
|
||||
> code. It is incomplete by design and it may be stale. Where it disagrees with the code, the code
|
||||
> is right; please fix the page. Every path, symbol, count and command below was checked against
|
||||
> commit `1347d825` (`v0.6.3-33-g1347d825c`). Each count shows the command that produced it, so you
|
||||
> can re-run it.
|
||||
|
||||
## What OpenRig is
|
||||
|
||||
OpenRig runs a team of coding agents on your machine as one system. A local daemon keeps the team's
|
||||
state in SQLite and runs each agent as an ordinary Claude Code or Codex session (or a plain
|
||||
terminal, a Pi runner, or a scripted test stub) inside tmux. People and agents drive it through the
|
||||
`rig` CLI, a terminal UI (TUI) and an MCP server, all of which talk to the daemon over local HTTP.
|
||||
The agents themselves are unmodified. What OpenRig adds is the coordination around them: launching
|
||||
and restoring agents, delivering messages, keeping durable work in a queue, and giving each agent
|
||||
the right skills and context.
|
||||
|
||||
## Packages
|
||||
|
||||
| Directory | Package | What it is | How it ships |
|
||||
|---|---|---|---|
|
||||
| `packages/daemon` | `@openrig/daemon` (private) | The daemon: HTTP routes, domain services, SQLite migrations, runtime adapters, built-in specs, skills, plugins, static context-pack sources | Copied into `@openrig/cli` |
|
||||
| `packages/cli` | `@openrig/cli` | The `rig` command, the MCP server, daemon start and stop | The only published package |
|
||||
| `packages/tui` | `@openrig/tui` (private) | The terminal UI | Copied into `@openrig/cli`; opened by `rig tui`, by bare `rig` in a terminal, or by the `openrig-tui` bin |
|
||||
| `packages/ui` | `@openrig/ui` (private) | The React web UI, in **maintenance mode**: it still ships and runs, but gets no new feature work. The CLI and TUI are the supported surfaces | Built files copied into `@openrig/cli` and served by the daemon |
|
||||
| `packages/test-system` | none (not an npm workspace) | The stub-agent scenario library, the CI scenario harness, live-model eval cases | Not shipped |
|
||||
|
||||
**What bundles what.** `packages/cli/package.json` publishes `LICENSE`, `dist`, `daemon`, `ui`,
|
||||
`tui` and `scripts`. `scripts/build-package.sh` (the CLI's `prepublishOnly`) builds the daemon, UI,
|
||||
TUI and CLI, then assembles them inside `packages/cli/`: the daemon's `dist`, `assets`, `specs` and
|
||||
`policies`, the generated context packs and a copy of `docs/reference/` go under `daemon/`; the UI
|
||||
build goes to `ui/dist`; the TUI build goes to `tui/dist`. So `docs/reference/` ships to every
|
||||
user; `docs/as-built/` and this file do not.
|
||||
|
||||
**Sharing code between packages.** The CLI and TUI import daemon code only through the subpaths in
|
||||
the `exports` field of `packages/daemon/package.json` (18 at this commit), for example
|
||||
`@openrig/daemon/attention`, which points at `packages/daemon/src/attention-surface.ts`. The CLI
|
||||
uses 16 of them and the TUI uses 2. In development they resolve through the npm workspace. At
|
||||
package time `scripts/rewrite-daemon-imports.mjs` rewrites them to the shipped `daemon/dist` copy
|
||||
and fails the build on a specifier with no `exports` entry. To share new daemon code, add a
|
||||
`*-surface.ts` file and an `exports` entry.
|
||||
|
||||
## The request path
|
||||
|
||||
```
|
||||
rig (CLI) TUI MCP server (rig mcp serve, stdio)
|
||||
\ | /
|
||||
+---- HTTP to the daemon (port 7433 by default) --------------+
|
||||
|
|
||||
packages/daemon/src/server.ts createApp(): context middleware, /healthz, app.route("/api/...")
|
||||
|
|
||||
packages/daemon/src/routes/ one Hono router per area; reads services with c.get(...)
|
||||
|
|
||||
packages/daemon/src/domain/ services, repositories, orchestrators, event bus
|
||||
/ | \
|
||||
SQLite (db/) tmux (adapters/tmux.ts) runtime adapters (adapters/*-adapter.ts)
|
||||
claude-code, codex, pi, terminal, stub
|
||||
```
|
||||
|
||||
**Inside the daemon** (`packages/daemon/src/`):
|
||||
|
||||
- **Startup.** `index.ts` is the process entry: it resolves where to listen, starts the server and
|
||||
runs the periodic queue sweeps. It calls `createDaemon()` in `startup.ts`, which opens the
|
||||
database (`db/connection.ts`: WAL mode, foreign keys on), runs `migrate(db, ALL_MIGRATIONS)`,
|
||||
constructs every service and adapter, and passes them to `createAppWithWebSocket(deps)` in
|
||||
`server.ts`.
|
||||
- **`server.ts`.** In `createApp(deps)`, the first `app.use("*", ...)` middleware puts each
|
||||
service on the request context (`c.set("<name>" as never, deps.<name>)`), serves `/healthz`, and
|
||||
mounts each router with `app.route("/api/<area>", ...)`. Unknown `/api/*` paths get a JSON 404;
|
||||
other GET requests serve the web UI's built files.
|
||||
- **`routes/`.** Thin handlers. For example, `routes/ps.ts` reads `psProjectionService` from the
|
||||
context and returns its entries as JSON. `routes/terminal-ws.ts` registers the terminal
|
||||
WebSocket, and `routes/require-sender-identity.ts` is a shared helper rather than a router.
|
||||
- **`domain/`.** The behaviour: repositories over SQLite (`rig-repository.ts`,
|
||||
`queue-repository.ts`, `session-registry.ts`), orchestration (`startup-orchestrator.ts`,
|
||||
`node-launcher.ts`, `rigspec-instantiator.ts`), message delivery (`session-transport.ts`,
|
||||
`seat-delivery-guard.ts`) and events (`event-bus.ts`). Larger areas have their own
|
||||
subdirectories, such as `gateway/`, `provider/`, `policies/`, `scope/`, `context-packs/` and
|
||||
`mission-control/`.
|
||||
- **`adapters/`.** The outside world. `tmux.ts` wraps tmux. The five `RuntimeAdapter`
|
||||
implementations launch each runtime and project skills and files into it. `cmux.ts` and
|
||||
`compose-services-adapter.ts` cover other integrations.
|
||||
- **`db/`.** `connection.ts`, `migrate.ts`, `all-migrations.ts` and `migrations/`.
|
||||
- **`middleware/auth-bearer-token.ts`.** The bearer-token check for operator write routes, plus
|
||||
the loopback and Tailscale bind detection used at startup.
|
||||
- **Live updates** go out as server-sent events: `routes/events.ts` uses Hono's `streamSSE`, and
|
||||
the TUI subscribes to `/api/activity/events`.
|
||||
- **State** lives in `$OPENRIG_HOME` (default `~/.openrig`). The database is `openrig.sqlite`
|
||||
there (`daemon-db-path.ts`).
|
||||
|
||||
**The clients:**
|
||||
|
||||
- **CLI.** `createProgram()` in `packages/cli/src/index.ts` registers every top-level command;
|
||||
the commands live in `packages/cli/src/commands/`. Most are thin clients: they call a daemon route
|
||||
through `DaemonClient` (`packages/cli/src/client.ts`) and render the result. A few lifecycle
|
||||
helpers open the database file directly. Bare `rig` in a terminal opens the TUI
|
||||
(`packages/cli/src/front-door.ts`).
|
||||
- **MCP.** `createMcpServer(client)` in `packages/cli/src/mcp-server.ts` registers the tools over
|
||||
the same `DaemonClient`. `rig mcp serve` (`packages/cli/src/commands/mcp.ts`) runs it on stdio.
|
||||
- **TUI.** `packages/tui/src/daemon-client.ts` is its only HTTP module. It reads existing daemon
|
||||
projections, for example `/api/ps`, `/api/rigs/summary` and `/api/queue/list`, and the
|
||||
`/api/activity/events` stream when the daemon offers it. The daemon URL comes from `--url`,
|
||||
`OPENRIG_URL`, or `http://127.0.0.1:7433`. Agents can drive a running TUI through its control
|
||||
socket (`packages/tui/src/socket-server.ts`).
|
||||
- **Web UI.** Served by the daemon from the bundled `ui/dist`.
|
||||
|
||||
**In a development checkout,** `rig daemon start` prefers `packages/daemon/dist` over the bundled
|
||||
copy (`resolveDaemonPath` in `packages/cli/src/daemon-lifecycle.ts`), and the TUI launcher prefers
|
||||
`packages/tui/dist` (`packages/cli/src/front-door.ts`). Rebuild the package you changed. A daemon
|
||||
that is already running keeps running the code it started with.
|
||||
|
||||
## Key counts
|
||||
|
||||
At commit `1347d825`. Run these from the repository root to refresh them.
|
||||
|
||||
| What | Count | Command |
|
||||
|---|---|---|
|
||||
| Database migrations | 89 (latest: `089_classification_identity_provenance.ts`) | `git ls-files packages/daemon/src/db/migrations \| wc -l` |
|
||||
| Files in `routes/` | 67 | `git ls-files packages/daemon/src/routes \| wc -l` |
|
||||
| ... of which create a Hono router | 65 | `git grep -l 'new Hono' -- packages/daemon/src/routes \| wc -l` |
|
||||
| `app.route(...)` mounts in `server.ts` | 69 | `grep -c 'app.route(' packages/daemon/src/server.ts` |
|
||||
| Top-level `rig` commands | 85 | `grep -c 'program.addCommand(' packages/cli/src/index.ts` |
|
||||
| Runtime adapters | 5 (`claude-code`, `codex`, `pi`, `terminal`, `stub`) | `git grep -l 'implements RuntimeAdapter' packages/daemon/src \| wc -l` |
|
||||
| MCP tools | 18 | `grep -c 'server.tool(' packages/cli/src/mcp-server.ts` |
|
||||
| Daemon `exports` subpaths | 18 | `node -p 'Object.keys(require("./packages/daemon/package.json").exports).length'` |
|
||||
| Library scenarios | 11 | `grep -l '^scenario:' packages/test-system/scenarios/*.yaml \| wc -l` |
|
||||
|
||||
## Where to add things
|
||||
|
||||
Each recipe names the files to touch. Read a neighbouring example before you start; the code is
|
||||
the authority.
|
||||
|
||||
### A CLI command
|
||||
|
||||
1. Create `packages/cli/src/commands/<name>.ts` exporting a function that returns a commander
|
||||
`Command` and takes optional injected dependencies. `packages/cli/src/commands/unarchive.ts`
|
||||
is a short example.
|
||||
2. In the action, get the daemon with `getDaemonStatus(...)` and `daemonStatusGuard(status)`
|
||||
(`packages/cli/src/daemon-lifecycle.ts`), then call it with
|
||||
`new DaemonClient(getDaemonUrl(status))` (`packages/cli/src/client.ts`). Offer `--json` for
|
||||
agents, and set `process.exitCode` on failure.
|
||||
3. Register it in `createProgram()` in `packages/cli/src/index.ts` with
|
||||
`program.addCommand(...)`. If it takes injected dependencies, add them to `ProgramDeps` in the
|
||||
same file.
|
||||
4. Add `packages/cli/test/<name>.test.ts`. `packages/cli/test/archive.test.ts` shows how tests
|
||||
inject mocked lifecycle dependencies.
|
||||
5. If the command needs new daemon behaviour, add a route as well.
|
||||
|
||||
### A daemon route
|
||||
|
||||
1. Create `packages/daemon/src/routes/<area>.ts` with a Hono router. Both styles exist: a
|
||||
constant (`export const psRoutes = new Hono()` in `routes/ps.ts`) and a factory
|
||||
(`whoamiRoutes()` in `routes/whoami.ts`).
|
||||
2. Read services from the request context with `c.get("<name>" as never)`. Keep the behaviour in
|
||||
`packages/daemon/src/domain/`.
|
||||
3. Mount it in `createApp()` in `packages/daemon/src/server.ts` with
|
||||
`app.route("/api/<area>", ...)`, above the `/api/*` 404 catch-all.
|
||||
4. A new service needs three more edits: a field on `AppDeps` and a `c.set(...)` line in
|
||||
`server.ts`, and its construction in `createDaemon()` in `packages/daemon/src/startup.ts`.
|
||||
5. Test it the way `packages/daemon/test/whoami-routes.test.ts` does: build a small Hono app,
|
||||
`c.set` the service, mount the router and call `app.request(...)`. `createFullTestDb()` in
|
||||
`packages/daemon/test/helpers/test-app.ts` gives you an in-memory database migrated with the
|
||||
test fixture list (`migrationsForFullTestDb`).
|
||||
|
||||
### A database migration
|
||||
|
||||
1. Create `packages/daemon/src/db/migrations/<NNN>_<name>.ts` exporting a `Migration`
|
||||
(`{ name: "<NNN>_<name>.sql", sql: "..." }`; the type is in `packages/daemon/src/db/migrate.ts`).
|
||||
`089_classification_identity_provenance.ts` is a one-line example.
|
||||
2. Take the next number after the highest on `main` (089 at this commit). Parallel pull requests
|
||||
often pick the same number, so check again and renumber if `main` moved before you merge.
|
||||
3. Import it in `packages/daemon/src/db/all-migrations.ts` and append it to `ALL_MIGRATIONS`.
|
||||
Startup runs exactly that list (`startup-migrations-mirror.test.ts` pins this).
|
||||
4. Add it to `migrationsForFullTestDb` in `packages/daemon/test/helpers/test-app.ts`, or list it in
|
||||
`migrationsForFullTestDbExclusions` with a reason. `migration-fixture-parity.test.ts` fails
|
||||
otherwise.
|
||||
5. `migrate()` sorts migrations by name and records each applied name in `schema_migrations`, so
|
||||
an install that already applied a migration never runs it again. Editing a migration that has
|
||||
shipped changes nothing for those installs; add a new one instead. A long-lived install applies
|
||||
every new migration on its next start, against real data.
|
||||
|
||||
### A runtime adapter
|
||||
|
||||
1. Implement `RuntimeAdapter` from `packages/daemon/src/domain/runtime-adapter.ts`: `runtime`,
|
||||
`listInstalled`, `project`, `deliverStartup`, `launchHarness` and `checkReady`. The smallest
|
||||
implementation is `packages/daemon/src/adapters/terminal-adapter.ts`; the scripted test double
|
||||
is `stub-runtime-adapter.ts`.
|
||||
2. Register it in `packages/daemon/src/startup.ts`. The adapter maps are keyed by runtime name:
|
||||
`adapters:` (for the pod instantiator) and `runtimeAdapters:`. The context monitor has its own
|
||||
map for the runtimes it samples.
|
||||
3. Accept the runtime name in rig spec validation: `SUPPORTED_RUNTIMES` in
|
||||
`packages/daemon/src/domain/rigspec-preflight.ts`. Other runtime lists, such as `RuntimeHint` in
|
||||
`domain/discovery-types.ts` and `LEGACY_KNOWN_RUNTIMES` in `domain/rigspec-schema.ts`, may need
|
||||
it too; search for an existing runtime name such as `"pi"` to find them.
|
||||
4. Launch, projection and readiness are arteries. Read
|
||||
[docs/as-built/arteries.md](docs/as-built/arteries.md) first.
|
||||
|
||||
### A shipped skill
|
||||
|
||||
Skills ship from three copies in this repository (the "edges"), listed in
|
||||
`scripts/skill-edge-layout.generated.json`:
|
||||
|
||||
| Edge | Path | Layout |
|
||||
|---|---|---|
|
||||
| `spec` | `packages/daemon/specs/agents/shared/skills/` | by category (`core`, `pm`, `pods`, `process`) |
|
||||
| `plugin` | `packages/daemon/assets/plugins/openrig-core/skills/` | flat |
|
||||
| `canonical` | `skills/_canonical/` | public mirror of the `spec` copy |
|
||||
|
||||
Each skill in the layout file lists which edges carry it. At this commit, 33 skills are in `spec`
|
||||
and `canonical`, 17 only in `plugin`, and 2 in all three.
|
||||
|
||||
- `npm run mirror-skills:check` (part of `npm test`) hashes every file in each edge and compares the
|
||||
hashes with `scripts/skill-edge-digests.generated.json`. It also fails on a `SKILL.md` the layout
|
||||
does not list.
|
||||
- The full apply, `npm run mirror-skills`, regenerates all three edges from the maintainers' skill
|
||||
source, which lives outside this repository. It needs `OPENRIG_SKILL_CANON_ROOT` and three
|
||||
authority-file environment variables, so an outside contributor cannot run it.
|
||||
- **To fix a skill:** edit the file in every edge that carries it (same bytes), run
|
||||
`node scripts/regen-edge-digests.mjs` to refresh the hashes from disk, then run
|
||||
`npm run mirror-skills:check`. Say in the pull request that you edited mirrored skill files: the
|
||||
next full apply regenerates these copies from the maintainers' source, so the change has to land
|
||||
there too.
|
||||
- **To add a skill:** open an issue first. Its layout entry is generated from files that are not
|
||||
in this repository.
|
||||
- Every shipped skill also becomes a context pack at package time (next recipe).
|
||||
|
||||
### A context pack
|
||||
|
||||
- **Static packs** are hand-written: a directory under `packages/daemon/context-packs-src/<name>/`
|
||||
with a `manifest.yaml` and its files. Keep `version: "0"` in the manifest; the generator stamps
|
||||
the package version. A pack file may be a symlink to a file under `docs/reference/`, so a
|
||||
reference document has one source (for example `help/help.md` points to
|
||||
`docs/reference/help.md`). Any other symlink fails the build. There are 5 static packs at this
|
||||
commit.
|
||||
- **Generated packs** go to `packages/daemon/context-packs/`, which is gitignored and never edited
|
||||
by hand. `scripts/generate-context-packs.mjs` builds it at package time from every shipped skill
|
||||
and every static pack.
|
||||
- `npm run generate-context-packs:check` (part of `npm test`) validates every pack through the
|
||||
daemon's own manifest parser, so build the daemon first. It also runs the leak scan over the
|
||||
static sources, including the `docs/reference/` files they link to.
|
||||
- The daemon serves the shipped packs through `rig context get`; `startup.ts` registers the bundled
|
||||
directory as its built-in root.
|
||||
|
||||
### A scenario
|
||||
|
||||
Scenarios run the real CLI, daemon, tmux and SQLite with scripted stub agents (`runtime: stub`)
|
||||
and no model cost.
|
||||
|
||||
1. Add `packages/test-system/scenarios/<defect-class>.yaml`, named for the class of bug it
|
||||
catches. Topology fixtures sit beside it (`*-stub.yaml`).
|
||||
2. The format is checked by `validateScenario` in
|
||||
`packages/daemon/test/helpers/scenario-schema.ts`. Step verbs: `up`, `down`, `send`, `restart`,
|
||||
`restore`, `emit`, `mutate`, `policy`, `seed_regression`, `daemon`, plus `expect`. `expect` may
|
||||
only read shipped surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`,
|
||||
`tui_socket`, `policy_provenance`.
|
||||
3. Today the runner binds `up`, `down`, `send`, `restart` and `daemon`. `restore`, `emit`,
|
||||
`mutate` and `policy` fail with `UnboundActionError`, and `seed_regression` needs a fault
|
||||
controller (`packages/daemon/test/helpers/scenario-real-deps.ts`).
|
||||
4. Write a header comment naming the defect class and the seed: what a seeded regression plants,
|
||||
and which `expect` must catch it. A scenario counts only as a pair: it passes on healthy code
|
||||
and fails on the seeded run (`packages/test-system/README.md`).
|
||||
5. **Run it on your machine** after `npm run build`:
|
||||
`node --import tsx packages/daemon/scripts/run-scenarios.mjs <file.yaml>`. It uses a private
|
||||
daemon and a private tmux server, and builds its environment from `HOME`, `PATH` and `TERM`
|
||||
only, so your session's `TMUX` and daemon variables are dropped and it is safe to run from
|
||||
inside tmux, including an agent's pane. It supplies no fault controller, so a
|
||||
`seed_regression` step fails there; the seeded pair runs in the container path.
|
||||
6. **CI.** The `installed-scenario` job runs `scripts/run-pr-scenarios.sh` in disposable,
|
||||
network-less containers built from the packed package. It runs two cases, both built around
|
||||
queue durability (`library` is `queue-baton-survives-restart.yaml`). Adding a case today means
|
||||
changing three files: the `--case` list in `scripts/run-pr-scenarios.sh`, `CASES` in
|
||||
`packages/test-system/ci/result.mjs`, and the fault controller in
|
||||
`packages/test-system/ci/run.mjs`. [docs/as-built/test-layers.md](docs/as-built/test-layers.md)
|
||||
covers both run modes, running a case on your own Docker host, and what a stub can and cannot
|
||||
prove.
|
||||
|
||||
## Guards you will meet
|
||||
|
||||
`npm test` runs `test:repo` (builds the daemon, runs `node --test scripts/*.test.mjs`, the docs
|
||||
guard, `mirror-skills --check` and `generate-context-packs --check`) and then the daemon, CLI and
|
||||
TUI suites. `npm run test:ui` runs the web UI suite separately. `npm run lint` typechecks all four
|
||||
packages.
|
||||
|
||||
- **Docs guard** (`scripts/check-docs-guard.mjs`). Under `docs/`, git may track only
|
||||
`docs/as-built/`, `docs/reference/`, `docs/releases/` and exactly `docs/DESIGN.md`;
|
||||
`.gitignore` ignores the rest of `docs/`. Remember that `docs/reference/` ships inside the npm
|
||||
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
|
||||
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
|
||||
frontmatter.
|
||||
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`, with rules in
|
||||
`scripts/internal-tokens.generated.json`). It refuses maintainer-internal names and paths in
|
||||
content that ships. It runs in `generate-context-packs.mjs` (static pack sources, on every
|
||||
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
|
||||
(the daemon `specs/` tree before it is copied into the package), and in the release-time
|
||||
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
|
||||
CLI. No workflow runs it over a pull request's whole diff.
|
||||
- **Portability report** (`.github/workflows/portability-report.yml`, running
|
||||
`scripts/portability-report.mjs`). On each pull request it lists added lines containing
|
||||
machine-specific values: credentials, home and temporary paths, network addresses, email
|
||||
addresses. It never fails the job; you read it and decide.
|
||||
- **Generated files.** Do not hand-edit `scripts/*.generated.json` or anything under
|
||||
`packages/daemon/context-packs/`. The skill-edge digests are the one generated file a contributor
|
||||
refreshes, with `node scripts/regen-edge-digests.mjs` (see "A shipped skill").
|
||||
- **Hosted CI** (`.github/workflows/tests.yml`): `build-and-package`, `typecheck`, `repo-checks`,
|
||||
`package-tests` (macOS; daemon, CLI, TUI and UI suites with no credentials and no external
|
||||
network) and `installed-scenario`.
|
||||
|
||||
## Pointers
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, what a pull request needs, what to expect from review.
|
||||
- [docs/reference/developing.md](docs/reference/developing.md): which checks block and which are
|
||||
advisory. It predates the hosted Tests workflow; `.github/workflows/tests.yml` is current.
|
||||
- [docs/reference/worktree-builds.md](docs/reference/worktree-builds.md): building in a git
|
||||
worktree, with its own `npm install` rather than a symlinked `node_modules`.
|
||||
- [docs/reference/](docs/reference/): user and operator reference; it ships with the package.
|
||||
- [docs/as-built/](docs/as-built/README.md): module-by-module descriptions of the system. **These
|
||||
are being re-verified.** Most modules were last verified against `7eaf524c` (2026-05-16, around
|
||||
v0.3.1); the two pages this change adds, `arteries.md` and `test-layers.md`, are verified against
|
||||
`1347d825`. The older modules' counts are historical: `architecture/daemon-core.md`
|
||||
describes 40 migrations, and there are 89 today. Use them for orientation, then check the code.
|
||||
- [docs/as-built/test-layers.md](docs/as-built/test-layers.md): what each test layer covers and
|
||||
what it cannot catch.
|
||||
- [docs/as-built/arteries.md](docs/as-built/arteries.md): the areas where a small change has a
|
||||
large effect, what depends on them, and what broke there before.
|
||||
- [packages/test-system/ci/README.md](packages/test-system/ci/README.md): the CI scenario job.
|
||||
- [docs/releases/](docs/releases/) and [CHANGELOG.md](CHANGELOG.md): release history, written by
|
||||
the maintainers at release time.
|
||||
+14
-4
@@ -52,10 +52,13 @@ npm run lint # typecheck every package
|
||||
```
|
||||
|
||||
`npm test` builds the daemon and runs repository checks before the package suites. Read the
|
||||
specific failure: `npm run mirror-skills` updates skill mirrors, and
|
||||
`npm run generate-context-packs` updates generated packs. Use them when their source changed and
|
||||
review the generated diff; neither command fixes every documentation failure. The UI unit-test
|
||||
suite is advisory and separate: `npm run test:ui`.
|
||||
specific failure. A shipped skill may exist in more than one copy (see ARCHITECTURE.md): edit every copy, then run
|
||||
`node scripts/regen-edge-digests.mjs` (the full `npm run mirror-skills` apply needs maintainer-only
|
||||
inputs; see "A shipped skill" in [ARCHITECTURE.md](ARCHITECTURE.md#a-shipped-skill)).
|
||||
`npm run generate-context-packs` updates generated packs. Review the generated diff; neither
|
||||
fixes every documentation failure. The UI unit-test suite is advisory and separate:
|
||||
`npm run test:ui`. [docs/as-built/test-layers.md](docs/as-built/test-layers.md) lists every test
|
||||
layer, what CI runs, and what each layer can and cannot prove.
|
||||
|
||||
For hands-on development, read the [check requirements](docs/reference/developing.md),
|
||||
[worktree setup](docs/reference/worktree-builds.md), and
|
||||
@@ -103,6 +106,13 @@ clarifying question or run the reproduction; a maintainer makes the merge decisi
|
||||
|
||||
## Where things live
|
||||
|
||||
- **Start here:** [ARCHITECTURE.md](ARCHITECTURE.md) maps the packages, the request path, and where to
|
||||
add a command, route, migration, adapter, skill, context pack or scenario.
|
||||
[docs/as-built/arteries.md](docs/as-built/arteries.md) lists the areas where a small change has a
|
||||
large effect; if your change touches one, read it first.
|
||||
- **Using a coding agent?** Claude Code and Codex load the repository's `developing-openrig` skill
|
||||
when they work in this checkout (`.claude/skills/` and `.agents/skills/`). It points at the maps
|
||||
above and at what to run before you push.
|
||||
- Repository reference: `docs/reference/`; user documentation: [openrig.dev/docs](https://openrig.dev/docs).
|
||||
- Skills: `packages/daemon/specs/agents/shared/skills/` and plugin skills under
|
||||
`packages/daemon/assets/plugins/`; static context-pack sources: `packages/daemon/context-packs-src/`.
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
|
||||
|
||||
OpenRig turns AI coding agents from a pile of terminal sessions into a persistent, organized team. Talk to a lead agent about the outcome you want; it can coordinate specialists across teams and bring you results and decisions that need your attention. Start with a repository and one useful change, then keep the team's work and context at the same addresses.
|
||||
OpenRig is open-source software for building and running your own network of agents. It turns AI coding agents from a pile of terminal sessions into a persistent, organized team. Talk to a lead agent about the outcome you want; it can coordinate specialists across teams and bring you results and decisions that need your attention. Start with a repository and one useful change, then keep the team's work and context at the same addresses.
|
||||
|
||||
It's the open-source system behind my AI civilization experiments.
|
||||
|
||||
**Guide:** [Getting started](docs/reference/getting-started.md) · **Stuck?** [Help](docs/reference/help.md) · **Questions:** [Q&A](https://github.com/mvschwarz/openrig/discussions/92) · **Updates and demos:** [@_feralmachine on X](https://x.com/_feralmachine)
|
||||
|
||||
@@ -90,7 +92,7 @@ using a published package, since repository guidance can be ahead of npm.
|
||||
| When | What changes and why |
|
||||
| --- | --- |
|
||||
| **npm installation** | Installs the CLI, bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
|
||||
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
|
||||
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. On macOS it can install cmux and enable its automation socket control in `~/.config/cmux/settings.json`; cmux defaults `automation.socketControlMode` to `cmuxOnly`, which only accepts processes started inside cmux and so blocks OpenRig's control, so setup switches it to `automation`. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
|
||||
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. With `runtime.codex.hooks_enabled` enabled (the default), writes Codex hook configuration and trust records as described below—even before a rig launches. |
|
||||
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. |
|
||||
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules. Agent-guided setup recommends Yes and requires your actual answer before the agent [adds rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
|
||||
@@ -98,16 +100,17 @@ using a published package, since repository guidance can be ahead of npm.
|
||||
The provider files are separate from instance state. Here `~` means the daemon
|
||||
user's home; changing `OPENRIG_HOME` alone does not isolate provider configuration.
|
||||
|
||||
- **Claude Code:** managed startup writes workspace trust and onboarding completion
|
||||
to `~/.claude.json`. In the workspace, `.claude/settings.local.json` receives
|
||||
- **Claude Code:** startup writes workspace trust and onboarding completion.
|
||||
With an explicit permission mode, it uses the launch-selected `HOME/.claude.json`,
|
||||
or `<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
|
||||
retains the daemon's `~/.claude.json` path. In the workspace, `.claude/settings.local.json` receives
|
||||
the context collector's `statusLine` command and selected activity hooks;
|
||||
helper scripts live under `.openrig/`. Selected settings/MCP resources can also
|
||||
change that settings file and `.mcp.json`. The shared settings resource sets
|
||||
`permissions.defaultMode` to `acceptEdits` and enables Exa/Context7 MCP entries;
|
||||
selected MCP resources configure those external services. Built-in bootstrap
|
||||
no longer writes a command allowlist to `~/.claude/settings.json` or removes
|
||||
older allowances. The trust writer uses the daemon home, so a custom
|
||||
`CLAUDE_CONFIG_DIR` is not a general relocation of these writes.
|
||||
older allowances. `CLAUDE_CONFIG_DIR` does not relocate the project-local writes.
|
||||
- **Codex:** writes the daemon's `CODEX_HOME/config.toml` (normally
|
||||
`~/.codex/config.toml`). Startup enables hooks, adds the OpenRig activity relay
|
||||
commands and pre-writes trust hashes for those commands. Seat startup adds
|
||||
@@ -221,7 +224,7 @@ Hono HTTP daemon
|
||||
- **CLI**: Commands for both humans and agents to launch teams, inspect state, send messages, track owned work, and manage context.
|
||||
- **TUI**: Topology explorer, table and graph views, seat details, Specs, Projects, Terminals, Feed, and System. Navigate with the keyboard, mouse, or command bar.
|
||||
- **MCP**: Tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
|
||||
- **Runtimes**: Native Claude Code and Codex sessions, terminal nodes, and a Pi adapter using an RPC runner inside a terminal pane.
|
||||
- **Runtimes**: Native Claude Code and Codex sessions, terminal nodes, and Pi and Oh My Pi via RPC runners.
|
||||
|
||||
## Terminal UI and Workspaces
|
||||
|
||||
@@ -329,6 +332,7 @@ The helper never removes the legacy telemetry or library. Retirement follows
|
||||
separate stable runtime, writer, reader, and recovery proof. Daemon, database,
|
||||
seat, plugin, and release lifecycle actions remain agent-owned.
|
||||
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22 or 24 (the supported versions in this release). Node 20 is no
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Roadmap
|
||||
|
||||
These are things we'd welcome help with. Most came from the community, and most are small enough for one pull request.
|
||||
This isn't a schedule or a promise: we release often, and an item ships when a good change for it lands.
|
||||
|
||||
## How to help
|
||||
|
||||
1. Pick an item and comment on its issue to say you're working on it. If it has no issue yet, open one first.
|
||||
2. Read `CONTRIBUTING.md`, and ask your agent to use the `developing-openrig` skill. It's a map of how OpenRig is
|
||||
built, tested and changed.
|
||||
3. Open a pull request that says what a user gets and how you verified it.
|
||||
|
||||
If more than one pull request arrives for the same item, we take the best one, and the first equivalent one breaks a
|
||||
tie. Feel free to coordinate on the issue first.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Behavioural scenarios for more command families.** `docs/as-built/test-layers.md` has a "Help wanted" list of
|
||||
`rig` command families that have no scenario yet, each with a suggested first check. One family per pull request is
|
||||
ideal.
|
||||
|
||||
## Agents and runtimes
|
||||
|
||||
- **Codex seats that can reach the local daemon by default** (#275), with an easy opt-out.
|
||||
- **Custom Codex providers such as Bedrock** (#194): accept providers that authenticate with an environment key, and
|
||||
forward their token.
|
||||
- **Operator recovery for Pi and Oh My Pi seats** (#41).
|
||||
- **New agent types:** OpenCode (#87, #227, #517), the Cursor CLI (#352), the Grok CLI (#522) and Devin (#44) are
|
||||
planned on a shared adapter.
|
||||
Design discussion: #43. Adapter pull requests are welcome once that adapter lands.
|
||||
|
||||
## Slack
|
||||
|
||||
- **A DM or private channel for single-user setups** (#313).
|
||||
- **A channel map** (#192): one channel per seat, keyed by channel ID.
|
||||
|
||||
## Reliability and everyday use
|
||||
|
||||
- **A configurable readiness window for fresh launches** (#182), so seats under load don't time out at a fixed 30
|
||||
seconds.
|
||||
- **Draft-aware delivery** (#48): automated sends wait while someone is typing in a seat, or a seat can be set to
|
||||
inbox-only.
|
||||
- **A host-aware resource budget for seats** (#80).
|
||||
- **Predictable `rig up` over a stopped rig with the same name.**
|
||||
- **A "merged" stage in the project view** for teams that integrate on a branch other than main (#402).
|
||||
- **Codex seats that leave a repository's tracked `AGENTS.md` alone** (related: #64).
|
||||
- **Install and run on Node 26.**
|
||||
|
||||
Bugs aren't listed here. Please file them as issues; anything labelled `good first issue` is a good place to start.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"url": "https://context7.com/mvschwarz/openrig",
|
||||
"public_key": "pk_HcRwWEoFBsmU1mo5VIvWw",
|
||||
"projectTitle": "OpenRig",
|
||||
"description": "OpenRig is an open-source multi-agent harness: define a team of Claude Code and Codex agents in YAML, boot it with the rig CLI, and coordinate the agents through tmux sessions, a durable queue and a local daemon.",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"folders": [],
|
||||
"excludeFolders": [
|
||||
"docs/releases",
|
||||
|
||||
@@ -4,4 +4,5 @@ ARG TESTBED_IMAGE
|
||||
FROM ${TESTBED_IMAGE}
|
||||
COPY runner.mjs /opt/openrig-testbed/runner.mjs
|
||||
COPY scenarios/ /opt/openrig-testbed/scenarios/
|
||||
COPY library/ /opt/openrig-testbed/library/
|
||||
CMD ["node", "/opt/openrig-testbed/runner.mjs", "healthy"]
|
||||
+78
-74
@@ -1,101 +1,105 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Docs — Map of Territory + Module Index
|
||||
title: As-Built Docs — Module Index
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Starting any technical task that needs the shipped OpenRig system as it
|
||||
actually is. Read this first to learn what the as-built tree contains and
|
||||
which module to open; then go to codemap.md for use-case navigation or
|
||||
straight to the named module.
|
||||
siblings: [codemap.md, cli-reference.md, frontmatter-schema.md]
|
||||
Starting a technical task and choosing the as-built module or source entry
|
||||
point to read. This index describes the document tree; each module carries
|
||||
its own source-verification stamp.
|
||||
siblings: [codemap.md, arteries.md, test-layers.md, cli-reference.md, frontmatter-schema.md]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# OpenRig As-Built Docs
|
||||
# OpenRig as-built docs
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies — a
|
||||
multi-agent harness that manages your Claude Code and Codex sessions as a
|
||||
single system, with a daemon (`@openrig/daemon`), a CLI (`@openrig/cli`), a UI
|
||||
(`@openrig/ui`), and an MCP server all sitting on one SQLite-backed core. This
|
||||
tree is the **source-verified** description of that system as it actually
|
||||
ships — every load-bearing claim is grounded to `packages/*/src` at a named
|
||||
commit, not to memory, chat, or older docs.
|
||||
These pages describe the code and point to its owning files. This index was checked against
|
||||
main commit `254122872cf477511514979a4300b695d77cd1f7`. A linked module's
|
||||
`last-verified-against-source` is its own verification boundary; updating this index does not
|
||||
revalidate that module or identify a running daemon or published package.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
|
||||
> packages; HEAD carries 6 commits of unreleased 0.3.2 work, no `v0.3.2` tag.
|
||||
For the repository overview and contributor recipes, start with [ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
Use [codemap.md](codemap.md) to locate source, [arteries.md](arteries.md) to identify dependent
|
||||
paths before a change, and [test-layers.md](test-layers.md) to choose checks.
|
||||
|
||||
## How this tree is organized
|
||||
## Repository surfaces
|
||||
|
||||
The as-built corpus was modularized (slice 08, `context-architecture-v1`) from
|
||||
two monolithic files into a folder of thematic modules, each independently
|
||||
loadable, each frontmatter-tagged for retrieval, each ≤300 lines (author-mode
|
||||
modules with no prior prose may run to ≤400 — see the slice-08 ACK).
|
||||
The npm workspaces are `packages/daemon`, `packages/cli`, `packages/tui`, and `packages/ui`
|
||||
([root package manifest](../../package.json)). The daemon provides the SQLite-backed HTTP
|
||||
services; CLI, TUI and MCP clients consume them. Runtime adapters include Claude Code, Codex,
|
||||
Pi, OMP, terminal and stub implementations under `packages/daemon/src/adapters/`.
|
||||
|
||||
```
|
||||
docs/as-built/
|
||||
├── README.md ← you are here: map-of-territory + index
|
||||
├── codemap.md ← navigation index (use-case lookup, source-root pointers)
|
||||
├── frontmatter-schema.md← the frontmatter convention these docs follow
|
||||
├── cli-reference.md ← full rig CLI surface (kept whole)
|
||||
├── architecture/ ← 14 backend/runtime modules
|
||||
└── ui/ ← 4 operator-surface modules
|
||||
```
|
||||
|
||||
`docs/DESIGN.md` (the canonical visual / brand / design-system spec) **stays
|
||||
at the repo `docs/` root** by design — it is referenced by many existing
|
||||
`docs/DESIGN.md` paths and carries no source drift. This tree points at it
|
||||
(see `ui/library-specs-and-design-system.md`); it is not copied here.
|
||||
The CLI package contains the assembled daemon, TUI and web UI. User reference documents ship
|
||||
from `docs/reference/`; this as-built tree is developer documentation. The assembly is in
|
||||
[scripts/build-package.sh](../../scripts/build-package.sh), with the published file list in
|
||||
[packages/cli/package.json](../../packages/cli/package.json).
|
||||
|
||||
## Module index
|
||||
|
||||
### `architecture/` — backend, daemon, runtime
|
||||
### Daemon and runtime
|
||||
|
||||
| Module | What it covers |
|
||||
| Module | Read for |
|
||||
|---|---|
|
||||
| [daemon-core.md](architecture/daemon-core.md) | How the daemon boots, `createDaemon` wiring, the SQLite schema/40-migration set, the route-mount surface. |
|
||||
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | The five-method RuntimeAdapter contract, the Claude/Codex/Terminal adapters (launch/resume/fork in tmux), and the resume-honesty layer (honest resumed-vs-fresh assessment). |
|
||||
| [coordination-primitive.md](architecture/coordination-primitive.md) | PL-004 Phase A stream/queue/inbox/outbox; the hot-potato closure contract; where queue closure is enforced. |
|
||||
| [workflow-runtime.md](architecture/workflow-runtime.md) | PL-004 Phase D Workflow Runtime — spec cache, instance state, step trails, transactional-scribe projection, watchdog policies. |
|
||||
| [mission-control.md](architecture/mission-control.md) | PL-005 queue-observability surface — seven views, seven write verbs, action audit, bearer-token middleware. |
|
||||
| [agent-spec-and-startup.md](architecture/agent-spec-and-startup.md) | AgentSpec/RigSpec types, profile resolution, additive startup layering, StartupOrchestrator, whoami/materialize/bind/adopt identity. |
|
||||
| [lifecycle-snapshot-restore.md](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, honest restore (resume vs rebuild vs fresh), restore-honesty enforcement, restore-check / restore-packet probes. |
|
||||
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, pipe-pane transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key distinction. |
|
||||
| [workspace-primitive.md](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration (root/repos/defaultRepo/knowledgeRoot), migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. |
|
||||
| [content-surfaces.md](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + JSONL edit audit, PROGRESS.md tree indexer, the one-screen Steering composer. |
|
||||
| [living-notes-review.md](architecture/living-notes-review.md) | **v0.4.4** — the Living Notes review surface: the ONE `ComposedSliceReview` (intent→plan→delivered) projection, pure composer + gatherer, staged-approval locks, C1-bound `verified`, `/api/review/*` routes, freeze export, ranged media serving, and the SDLC on-disk convention it projects. |
|
||||
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | The filesystem-canonical content layer — plugin discovery, agent images, context packs, the Claude auto-compaction policy enforcer. |
|
||||
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 pod bundles + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
|
||||
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules, the RigEvent union + SSE delivery, intentional compatibility limits. |
|
||||
| [daemon-core.md](architecture/daemon-core.md) | Process startup, service wiring, database migrations and HTTP mounts. |
|
||||
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | Runtime adapter methods, harness launch/resume and native-session checks. |
|
||||
| [agent-spec-and-startup.md](architecture/agent-spec-and-startup.md) | Spec resolution, resource projection, startup delivery and identity. |
|
||||
| [coordination-primitive.md](architecture/coordination-primitive.md) | Stream, queue, inbox/outbox, handoff and closure. |
|
||||
| [workflow-runtime.md](architecture/workflow-runtime.md) | Workflow specifications, instances, projection, trails and watchdog policies. |
|
||||
| [mission-control.md](architecture/mission-control.md) | Queue observations, actions and their audit records. |
|
||||
| [lifecycle-snapshot-restore.md](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, restore decisions and continuity checks. |
|
||||
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | Session messaging, capture, transcripts, chat and ask. |
|
||||
| [workspace-primitive.md](architecture/workspace-primitive.md) | Workspace and repository declarations, project resolution and scope. |
|
||||
| [content-surfaces.md](architecture/content-surfaces.md) | File reads/writes, path checks, progress indexing and steering composition. |
|
||||
| [living-notes-review.md](architecture/living-notes-review.md) | Review gathering/composition, proof records, approvals, export and media. |
|
||||
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | Plugin discovery, agent images, context packs and compaction policy. |
|
||||
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Package assembly, reusable topology bundles and bootstrap/install paths. |
|
||||
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | Cross-cutting invariants, events and compatibility notes. |
|
||||
|
||||
### `ui/` — operator surfaces
|
||||
### Navigation, checks and reference
|
||||
|
||||
| Module | What it covers |
|
||||
| Document | Read for |
|
||||
|---|---|
|
||||
| [shell-and-routing.md](ui/shell-and-routing.md) | The UI shell (rail / Explorer / center workspace / drawer / preview stack), the actual route tree the shipped UI mounts, the shared detail drawer + event consumption. |
|
||||
| [topology.md](ui/topology.md) | The topology surface — host hybrid graph, table/terminal views, activity-ring / hot-potato visual language, terminal-preview popovers, navigation/overlay contracts. |
|
||||
| [project-and-for-you.md](ui/project-and-for-you.md) | The operator destination surfaces — the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, the Dashboard landing on the vellum brand system. |
|
||||
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/skills/plugins/agent-images surfaces, the spec-review + spec-library + live-identity flows, the design-system pointer. |
|
||||
| [codemap.md](codemap.md) | Question-to-module navigation and source entry points. |
|
||||
| [arteries.md](arteries.md) | Code paths where changes affect several subsystems. |
|
||||
| [test-layers.md](test-layers.md) | Test scope, isolation, scenarios and hosted CI. |
|
||||
| [cli-reference.md](cli-reference.md) | CLI commands and options; confirm changing details in `packages/cli/src/index.ts` and command help. |
|
||||
| [frontmatter-schema.md](frontmatter-schema.md) | Metadata fields used by these documents. |
|
||||
| [architecture.md](architecture.md) | Compatibility pointer from the former monolithic architecture page. |
|
||||
| [DESIGN.md](../DESIGN.md) | Visual and design-system specification. |
|
||||
|
||||
### Root docs
|
||||
### Web UI reference
|
||||
|
||||
| Doc | What it covers |
|
||||
The following pages remain discoverable at their own historical stamps. Their contents are not
|
||||
reverified by this index update.
|
||||
|
||||
| Document | Topic |
|
||||
|---|---|
|
||||
| [codemap.md](codemap.md) | Navigation index — module map, structural relationship diagram, fast-lookup-by-use-case, source-root pointer table. Start here when you know *what* you need but not *which module*. |
|
||||
| [cli-reference.md](cli-reference.md) | The full `rig` CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. Kept as one doc (slice-08 Q4). |
|
||||
| [frontmatter-schema.md](frontmatter-schema.md) | Which frontmatter convention governs every doc in this tree, plus the one as-built-unique field (`last-verified-against-source`). |
|
||||
| `../DESIGN.md` | The canonical visual / brand / design-system spec (lives at `docs/` root by design; pointer only — not duplicated here). |
|
||||
| [ui.md](ui.md) | Compatibility pointer to the UI modules. |
|
||||
| [shell-and-routing.md](ui/shell-and-routing.md) | Web UI shell and routing. |
|
||||
| [topology.md](ui/topology.md) | Web topology views. |
|
||||
| [project-and-for-you.md](ui/project-and-for-you.md) | Project and attention surfaces. |
|
||||
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | Library/spec views and design-system pointers. |
|
||||
|
||||
## Source-grounding contract
|
||||
For the terminal UI, start at `packages/tui/src/main.ts`, its HTTP client
|
||||
`packages/tui/src/daemon-client.ts`, and the TUI sections of [test-layers.md](test-layers.md).
|
||||
|
||||
Every module declares `last-verified-against-source: <commit-sha>` — the
|
||||
commit its claims were checked against. Load-bearing corrections are recorded
|
||||
inline with an auditable annotation (`> Drift-fix Dx — said X; corrected to Y;
|
||||
slice-00 §z; re-confirmed <file:line> @HEAD`). OPEN items (counts that are
|
||||
definitional or runtime-only) are carried verbatim, never smoothed. The
|
||||
schema is defined in [frontmatter-schema.md](frontmatter-schema.md) and the
|
||||
governing convention is `openrig-work/conventions/frontmatter-for-context/`.
|
||||
## Derive the document inventory
|
||||
|
||||
At the named commit there are **26 Markdown files**: **14** under `architecture/`, **4** under
|
||||
`ui/`, and **8** at the root of this tree (including both compatibility pointers). Reproduce
|
||||
these counts from the repository root:
|
||||
|
||||
```bash
|
||||
source_commit=254122872cf477511514979a4300b695d77cd1f7
|
||||
git ls-tree -r --name-only "$source_commit" -- docs/as-built | grep -E '\.md$' | wc -l
|
||||
git ls-tree -r --name-only "$source_commit" -- docs/as-built/architecture | grep -E '\.md$' | wc -l
|
||||
git ls-tree -r --name-only "$source_commit" -- docs/as-built/ui | grep -E '\.md$' | wc -l
|
||||
git ls-tree -r --name-only "$source_commit" -- docs/as-built | grep -E '^docs/as-built/[^/]+\.md$' | wc -l
|
||||
```
|
||||
|
||||
Read a module's source stamp and prerequisite pointers before relying on it. Source code at the
|
||||
named commit is the authority when prose disagrees; [frontmatter-schema.md](frontmatter-schema.md)
|
||||
describes how the stamp is represented.
|
||||
@@ -1,57 +1,48 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: architecture.md — Reorganized into the Modular As-Built Tree (Redirect Stub)
|
||||
title: Architecture — Module Navigation
|
||||
status: superseded
|
||||
topics: [knowledge-and-context]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
You followed an old reference to docs/as-built/architecture.md. The monolith
|
||||
was reorganized (slice-08, context-architecture-v1) into a folder of thematic
|
||||
modules. Go to README.md (map of territory) or codemap.md (use-case lookup),
|
||||
then the named module.
|
||||
siblings: [README.md, codemap.md, ui.md]
|
||||
Following an older link to docs/as-built/architecture.md. Use the repository
|
||||
overview or the current as-built module index linked here.
|
||||
siblings: [README.md, codemap.md, arteries.md, test-layers.md, ui.md]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# architecture.md was reorganized into the modular as-built tree
|
||||
# Architecture module navigation
|
||||
|
||||
This single-file monolith was split into a folder of independently-loadable,
|
||||
frontmatter-tagged thematic modules (slice-08, `context-architecture-v1`). The
|
||||
canonical as-built description of the shipped daemon/runtime now lives in
|
||||
`architecture/`. This stub is a forwarding pointer so old references still
|
||||
resolve.
|
||||
This compatibility page points to the modular documentation. Its links were checked against main
|
||||
`254122872cf477511514979a4300b695d77cd1f7`; the linked pages retain their own source stamps.
|
||||
|
||||
**Start here:**
|
||||
- [Repository architecture](../../ARCHITECTURE.md): package boundaries, request flow, derived
|
||||
source counts and contributor recipes.
|
||||
- [As-built index](README.md): the complete module inventory, including the existing web UI references.
|
||||
- [Codemap](codemap.md): questions mapped to modules and source entry points.
|
||||
- [Arteries](arteries.md): dependencies to inspect before changing shared runtime paths.
|
||||
- [Test layers](test-layers.md): the checks available and what they establish.
|
||||
|
||||
- [`./README.md`](./README.md) — the map of territory and full module index.
|
||||
- [`./codemap.md`](./codemap.md) — navigation index: use-case lookup table and
|
||||
source-root pointers. Use this when you know *what* you need but not *which*
|
||||
module.
|
||||
## Daemon and runtime starting points
|
||||
|
||||
## Where the major content went
|
||||
|
||||
The architecture content is now the **14 modules under `architecture/`**
|
||||
(13 from the slice-08 reorg + `living-notes-review.md`, added v0.4.4):
|
||||
|
||||
| Module | What moved here |
|
||||
| Topic | Module |
|
||||
|---|---|
|
||||
| [`architecture/daemon-core.md`](architecture/daemon-core.md) | Daemon boot, `createDaemon` wiring, the SQLite schema/migration set, the route-mount surface, system overview, package boundaries. |
|
||||
| [`architecture/adapters-and-runtimes.md`](architecture/adapters-and-runtimes.md) | The five-method RuntimeAdapter contract; Claude/Codex/Terminal adapters; the **Resume honesty** layer (§ Resume honesty). |
|
||||
| [`architecture/coordination-primitive.md`](architecture/coordination-primitive.md) | PL-004 Phase A stream/queue/inbox/outbox; the hot-potato closure contract; durable handoff. |
|
||||
| [`architecture/workflow-runtime.md`](architecture/workflow-runtime.md) | PL-004 Phase D Workflow Runtime — spec cache, instance state, step trails, transactional-scribe, watchdog policies. |
|
||||
| [`architecture/mission-control.md`](architecture/mission-control.md) | PL-005 Mission Control / Queue Observability — the `/api/mission-control/*` surface, seven views, seven write verbs, action audit, bearer-token middleware. |
|
||||
| [`architecture/agent-spec-and-startup.md`](architecture/agent-spec-and-startup.md) | AgentSpec/RigSpec types, profile resolution, additive startup layering, StartupOrchestrator, whoami/materialize/bind/adopt. |
|
||||
| [`architecture/lifecycle-snapshot-restore.md`](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, honest restore (resume vs rebuild vs fresh), the verbatim restore-honesty rules, restore-check / restore-packet probes. |
|
||||
| [`architecture/transport-and-transcripts.md`](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key. |
|
||||
| [`architecture/workspace-primitive.md`](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration, migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. |
|
||||
| [`architecture/content-surfaces.md`](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + edit audit, the PROGRESS.md tree indexer, the Steering composer. |
|
||||
| [`architecture/plugin-agent-image-context-pack.md`](architecture/plugin-agent-image-context-pack.md) | Plugin discovery, agent images, context packs, the Claude auto-compaction enforcer. |
|
||||
| [`architecture/packaging-bootstrap-bundles.md`](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
|
||||
| [`architecture/architecture-rules-and-event-system.md`](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules (incl. **rule 15**, restore honesty), the RigEvent union + SSE delivery, intentional compatibility limits. |
|
||||
| [`architecture/living-notes-review.md`](architecture/living-notes-review.md) | **Added v0.4.4** (not part of the original monolith): the Living Notes review surface — the one intent→plan→delivered projection, staged-approval locks, proof artifacts, freeze export, ranged media serving. |
|
||||
| Boot, HTTP services and database wiring | [Daemon core](architecture/daemon-core.md) |
|
||||
| Harness adapters and native session continuity | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
|
||||
| Spec resolution, projection and launch | [Agent spec and startup](architecture/agent-spec-and-startup.md) |
|
||||
| Durable queue and messaging state | [Coordination](architecture/coordination-primitive.md) |
|
||||
| Workflow execution and watchdog policies | [Workflow runtime](architecture/workflow-runtime.md) |
|
||||
| Queue observations and operator actions | [Mission control](architecture/mission-control.md) |
|
||||
| Snapshot and restore | [Lifecycle](architecture/lifecycle-snapshot-restore.md) |
|
||||
| Messaging, capture and transcripts | [Transport and transcripts](architecture/transport-and-transcripts.md) |
|
||||
| Project and repository scope | [Workspace](architecture/workspace-primitive.md) |
|
||||
| Files, progress and steering | [Content surfaces](architecture/content-surfaces.md) |
|
||||
| Review, proof and approvals | [Living notes and review](architecture/living-notes-review.md) |
|
||||
| Plugin discovery, images and context | [Plugins, agent images and context packs](architecture/plugin-agent-image-context-pack.md) |
|
||||
| Distribution, bundles and bootstrap | [Packaging and bundles](architecture/packaging-bootstrap-bundles.md) |
|
||||
| Events and cross-cutting invariants | [Architecture rules and events](architecture/architecture-rules-and-event-system.md) |
|
||||
|
||||
The UI half of the old `### UI architecture` section is now the **4 modules
|
||||
under `ui/`** (see [`ui.md`](ui.md), also a redirect stub, and the `ui/` index
|
||||
in [`./README.md`](./README.md)).
|
||||
Older web UI links continue through [ui.md](ui.md). For terminal UI source, use the
|
||||
[package entry points in the codemap](codemap.md#package-entry-points).
|
||||
@@ -1,228 +1,265 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Adapters and Runtimes — Claude/Codex/Terminal, tmux/cmux, Resume Honesty
|
||||
title: Adapters and Runtimes — Claude/Codex/Pi/Stub/Terminal, tmux/cmux, Resume Honesty
|
||||
status: active
|
||||
topics: [agent-runtime, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need the runtime-adapter contract — how OpenRig launches and resumes a
|
||||
Claude Code, Codex, or terminal harness inside tmux, what the five adapter
|
||||
methods do, or how the daemon honestly assesses whether a harness actually
|
||||
resumed vs fresh-launched (the resume-honesty layer).
|
||||
Claude Code, Codex, Pi, Oh My Pi, stub or terminal harness inside tmux, what
|
||||
the five required adapter methods do, or how the daemon honestly assesses
|
||||
whether a harness actually resumed vs fresh-launched (the resume-honesty
|
||||
layer).
|
||||
siblings: [daemon-core.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Adapters and Runtimes
|
||||
|
||||
How OpenRig drives the agent harnesses. The daemon never talks to Claude Code,
|
||||
Codex, or a shell directly — it talks to a `RuntimeAdapter`. Three adapters
|
||||
implement one five-method contract; a separate resume-honesty layer answers the
|
||||
question "did this harness *actually* resume, or did it silently fresh-launch?"
|
||||
truthfully rather than optimistically.
|
||||
How OpenRig drives the agent harnesses. Harness launches go through a
|
||||
`RuntimeAdapter`: five adapter classes implement one contract of five required
|
||||
methods. A separate resume-honesty layer answers the question "did this harness
|
||||
*actually* resume, or did it silently fresh-launch?" truthfully rather than
|
||||
optimistically.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). All source in this module is **present at `v0.3.0`**
|
||||
> (`git cat-file -e v0.3.0:<path>` for all 7 files → present) — the adapter
|
||||
> layer is core reboot-era machinery, **not** a 0.3.x feature; do not
|
||||
> back-attribute (§10.8 version-attribution proof technique).
|
||||
|
||||
> Drift-fix (scope) — `architecture.md` §5 "Runtime adapters" carries **no
|
||||
> slice-00 numeric drift** (proposed-structure §4.2: "adapter contract is
|
||||
> current"). The corrections below are *precision* refinements where the source
|
||||
> is more specific than the prose, not stale-count fixes. Each is annotated
|
||||
> inline and re-confirmed at HEAD.
|
||||
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
|
||||
> command that produces it; run the command from the repository root to refresh
|
||||
> it.
|
||||
|
||||
## 1. The five-method RuntimeAdapter contract
|
||||
|
||||
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:127`
|
||||
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:134`
|
||||
(`interface RuntimeAdapter`). Every adapter declares a `readonly runtime`
|
||||
string and implements exactly five methods (`runtime-adapter.ts:128–153`):
|
||||
string (`runtime-adapter.ts:137`) and implements five required methods
|
||||
(`runtime-adapter.ts:143–166`; **5** =
|
||||
`sed -n '/^export interface RuntimeAdapter/,/^}/p' packages/daemon/src/domain/runtime-adapter.ts | grep -c -E '^ [a-zA-Z]+\('`):
|
||||
|
||||
| Method | Signature (`runtime-adapter.ts`) | Responsibility |
|
||||
|---|---|---|
|
||||
| `listInstalled` | `(binding)` `:131` | List currently installed/projected resources for a node. |
|
||||
| `project` | `(plan, binding)` `:134` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
|
||||
| `deliverStartup` | `(files, binding)` `:137` | Deliver resolved startup files to the runtime. |
|
||||
| `launchHarness` | `(binding, opts)` `:147` | Launch the harness inside the bound tmux session; return a resume token. |
|
||||
| `checkReady` | `(binding)` `:153` | Probe whether the harness is responsive and ready. |
|
||||
| `listInstalled` | `(binding)` `:144` | List currently installed/projected resources for a node. |
|
||||
| `project` | `(plan, binding)` `:147` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
|
||||
| `deliverStartup` | `(files, binding)` `:150` | Deliver resolved startup files to the runtime. |
|
||||
| `launchHarness` | `(binding, opts)` `:160` | Launch the harness inside the bound tmux session; return a resume token. |
|
||||
| `checkReady` | `(binding)` `:166` | Probe whether the harness is responsive and ready. |
|
||||
|
||||
The interface also has two optional members: `claudeManagedLaunch` (`:136`)
|
||||
and `skillTargetPath?()` (`:141`, implemented by `OmpRuntimeAdapter`,
|
||||
`omp-runtime-adapter.ts:12`).
|
||||
|
||||
Startup *action* execution (`slash_command` / `send_text`) is explicitly **not**
|
||||
part of this contract — the contract docstring (`runtime-adapter.ts:121–125`)
|
||||
part of this contract — the contract docstring (`runtime-adapter.ts:128–133`)
|
||||
states actions belong to the `StartupOrchestrator` *after* `checkReady()`. The
|
||||
orchestrator delivery split is in `agent-spec-and-startup.md`.
|
||||
|
||||
### `launchHarness` opts and the fork seam
|
||||
|
||||
`launchHarness` opts is `{ name: string; resumeToken?: string; forkSource?:
|
||||
ForkSource }` (`runtime-adapter.ts:147–150`).
|
||||
ForkSource }` (`runtime-adapter.ts:162`).
|
||||
|
||||
> Drift-fix (precision) — `architecture.md` §4 said `launchHarness(binding,
|
||||
> opts: { name, resumeToken? })`. Source adds a third, mutually-exclusive
|
||||
> `forkSource` field. Per the contract docstring (`runtime-adapter.ts:142–146`)
|
||||
> `resumeToken` and `forkSource` are mutually exclusive — if both are provided
|
||||
> the adapter **must refuse** with a clear error, not guess; `forkSource`
|
||||
> triggers a fork and the captured token is the NEW post-fork token, never the
|
||||
> parent. `ForkSource` is `runtime-adapter.ts:116` (`kind: "native_id" |
|
||||
> "artifact_path" | "name" | "last"`; v1 MVP accepts `native_id` only — other
|
||||
> shapes rejected at schema validation, docstring `:106–119`).
|
||||
Per the contract docstring (`runtime-adapter.ts:152–159`) `resumeToken` and
|
||||
`forkSource` are mutually exclusive — if both are provided the adapter **must
|
||||
refuse** with a clear error, not guess; `forkSource` triggers a fork and the
|
||||
captured token is the NEW post-fork token, never the parent. `ForkSource` is
|
||||
`runtime-adapter.ts:123` (`kind: "native_id" | "artifact_path" | "name" |
|
||||
"last"`, `:124`; v1 MVP accepts `native_id` only — other shapes rejected at
|
||||
schema validation, docstring `:113–122`).
|
||||
|
||||
### `HarnessLaunchResult` is a discriminated union with an honest failure arm
|
||||
|
||||
> Drift-fix (precision) — `architecture.md` §4 said `HarnessLaunchResult` is
|
||||
> `{ ok, resumeToken?, resumeType?, error? }` (a single optional-field shape).
|
||||
> Source is a **discriminated union** (`runtime-adapter.ts:81–86`):
|
||||
> `| { ok: true; resumeToken?; resumeType? }`
|
||||
> `| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence? }`.
|
||||
> The failure arm carries a typed `recovery` hint
|
||||
> (`HarnessLaunchRecovery = "retry_fresh" | "attention_required"`,
|
||||
> `:79`) and optional `evidence` (last-N pane lines, flowed through to
|
||||
> `RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
|
||||
> `:83–86`). This is the honest-failure shape, not a smoothed optional `error`.
|
||||
`HarnessLaunchResult` is a **discriminated union** (`runtime-adapter.ts:88–93`):
|
||||
`| { ok: true; resumeToken?; resumeType?; appliedLaunch? }`
|
||||
`| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence? }`.
|
||||
The failure arm carries a typed `recovery` hint
|
||||
(`HarnessLaunchRecovery = "retry_fresh" | "attention_required"`, `:86`) and
|
||||
optional `evidence` (last-N pane lines, flowed through to
|
||||
`RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
|
||||
`:90–93`). This is the honest-failure shape, not a smoothed optional `error`.
|
||||
|
||||
## 2. The three adapters
|
||||
## 2. The runtime adapters
|
||||
|
||||
All three live under `packages/daemon/src/adapters/` and implement
|
||||
`RuntimeAdapter` (Architecture Rule 1: zero Hono in `adapters/`).
|
||||
**5** classes implement `RuntimeAdapter`
|
||||
(`git grep -l 'implements RuntimeAdapter' packages/daemon/src | wc -l`):
|
||||
`ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `PiRuntimeAdapter`,
|
||||
`StubRuntimeAdapter` and `TerminalAdapter`. All live under
|
||||
`packages/daemon/src/adapters/` (Architecture Rule 1: **0** files there import
|
||||
Hono, `git grep -l 'from "hono' -- packages/daemon/src/adapters | wc -l`).
|
||||
`OmpRuntimeAdapter` (Oh My Pi) extends `PiRuntimeAdapter`, so the daemon wires
|
||||
**6** runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
|
||||
(`startup.ts:945`;
|
||||
`grep 'adapters: {' packages/daemon/src/startup.ts | grep -o -E '"[a-z-]+": ' | wc -l`).
|
||||
|
||||
### ClaudeCodeAdapter (`claude-code-adapter.ts:41`)
|
||||
### ClaudeCodeAdapter (`claude-code-adapter.ts:53`)
|
||||
|
||||
- `readonly runtime = "claude-code"` (`:42`).
|
||||
- **Projects** to `.claude/` targets: `guidance_merge` → `<cwd>/CLAUDE.md`
|
||||
(`:127`); `skill_install` → `<cwd>/.claude/skills/<name>/` (`:71,133`);
|
||||
subagents → `.claude/agents`, plugins → `.claude/plugins/<id>`,
|
||||
runtime resources → `.claude/extensions/<id>`, settings fragments merged
|
||||
into `.claude/settings.local.json` (`:388–400`).
|
||||
- **Launches** (`:213–215`): fresh = `claude <permissionMode> --session-id
|
||||
- `readonly runtime = "claude-code"` (`:54`).
|
||||
- **Projects** to `.claude/` targets: guidance managed blocks →
|
||||
`<cwd>/CLAUDE.md` by default (`:187`, `:483`; default at
|
||||
`managed-blocks.ts:16`, and the rig may select `CLAUDE.local.md` instead,
|
||||
`managed-blocks.ts:14`); `skill_install` →
|
||||
`<cwd>/.claude/skills/<name>/` (`:193`, `:564`); subagents → `.claude/agents`,
|
||||
plugins → `.claude/plugins/<id>`, runtime resources →
|
||||
`.claude/extensions/<id>` (`:566–568`); settings fragments merged into
|
||||
`.claude/settings.local.json` (`:576`) and MCP fragments into `<cwd>/.mcp.json`
|
||||
(`:579`).
|
||||
- **Launches** (`:311–312`): fresh = `claude <permissionMode> --session-id
|
||||
<generatedId> --name <name>`; resume = `claude <permissionMode> --resume
|
||||
<token> --name <name>`; fork = `claude <permissionMode> --resume <parentId>
|
||||
--fork-session --name <seat>` (`:188`).
|
||||
> Drift-fix (precision) — `architecture.md` §5 said only "launches via
|
||||
> `claude --name <name>`, resumes via `claude --resume <token>`". Source
|
||||
> shows fresh launch uses an explicit `--session-id` (so a deterministic
|
||||
> resume token exists immediately) and the fork branch exists. Re-confirmed
|
||||
> `claude-code-adapter.ts:188,213–215` @HEAD.
|
||||
- **Readiness** (`checkReady`, `:239`): verifies tmux session alive, captures
|
||||
40 pane lines + pane command, delegates to `assessNativeResumeProbe` (§3);
|
||||
ready only when probe `status === "resumed"`. The resume-launch verification
|
||||
loop `verifyResumeLaunch` retries up to **16 attempts** (`:273`), failing
|
||||
loudly with `recovery: "retry_fresh"` on `no_conversation_found`
|
||||
(`:284–289`) — no silent fresh fallback.
|
||||
--fork-session --name <seat>` (`:283`). Each command may carry a
|
||||
`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1` env prefix (`yolo-mode.ts:98`) and a
|
||||
`--model <model>` argument (`:263`) and `--effort <effort>` (`:265`).
|
||||
When `binding.permissionMode` is set, the command is built by `claudeManagedLaunch` instead (`:243–249`, `:309`).
|
||||
- **Readiness** (`checkReady`, `:338`): verifies tmux session alive (`:343`),
|
||||
captures 40 pane lines + pane command (`:348–349`), and assesses them with
|
||||
`assessNativeResumeProbe` (§3) through `assessManagedProbe` (`:368`); ready
|
||||
only when probe `status === "resumed"` (`:352`). The resume-launch
|
||||
verification loop `verifyResumeLaunch` (`:402`) retries up to **16 attempts**
|
||||
(`:404`), failing loudly with `recovery: "retry_fresh"` on
|
||||
`no_conversation_found` (`:411–416`) and with `recovery:
|
||||
"attention_required"` plus pane evidence on an `attention_required` probe
|
||||
(`:426–432`) — the adapter never relaunches on its own.
|
||||
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:37`)
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:61`)
|
||||
|
||||
- `readonly runtime = "codex"` (`:38`).
|
||||
- `readonly runtime = "codex"` (`:62`).
|
||||
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
|
||||
(`:139,330`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:89,145`);
|
||||
skills resolve under `.agents/skills/<id>` (`:377`).
|
||||
- **Launches/resumes** (`:205,225–226`): fresh launch then capture a fresh
|
||||
thread id; resume = `codex<profileArg> resume<queueStateDirArg> <token>`;
|
||||
fork = `codex<profileArg> fork<queueStateDirArg> <parentId>` (`:205`). On
|
||||
success returns `{ ok: true, resumeToken: threadId, resumeType: "codex_id" }`
|
||||
(`:222,245,250`).
|
||||
> Drift-fix (precision) — `architecture.md` §5 said "launches via `codex`,
|
||||
> resumes via `codex resume <threadId>`". Source confirms the `codex resume
|
||||
> <token>` shape (`:226`) and adds the fork branch + the
|
||||
> `resumeType: "codex_id"` tag. Re-confirmed @HEAD.
|
||||
(`:290`, `:551`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:240`,
|
||||
`:296`); skills resolve under `.agents/skills/<id>` (`:630`).
|
||||
- **Launches/resumes**: fresh = `codex<posture> -C <cwd> …` (`:415`), then
|
||||
capture a fresh thread id (`:431`); resume is built by
|
||||
`buildCodexResumeCore` (`:414`) as `codex<posture> resume
|
||||
[<queueStateDirArg>] <token>` (`native-resume-probe.ts:81`); fork = `codex<posture> fork<queueStateDirArg>
|
||||
<parentId>` (`:386`). `<posture>` is `codexPostureArg(...)`
|
||||
(`yolo-mode.ts:59`): the `-p <profile>` argument, `-s workspace-write`, or
|
||||
`-s danger-full-access`. Each command may also carry `--no-daemon` (`:366`)
|
||||
and `-m <model>` (`:337`), plus `-c 'model_reasoning_effort="<effort>"'`
|
||||
when effort is selected (`codex-runtime-adapter.ts:339`). With a thread id it
|
||||
returns `{ ok: true,
|
||||
resumeToken: threadId, resumeType: "codex_id" }` (`:401`, `:428`, `:434`);
|
||||
a fresh launch whose thread id is not captured returns `{ ok: true }` without
|
||||
a token (`:437`).
|
||||
- Refuses `resumeToken` + `forkSource` together with a clear error
|
||||
(`:180–181`) — honors the mutual-exclusivity contract.
|
||||
(`:331–332`) — honors the mutual-exclusivity contract.
|
||||
|
||||
### TerminalAdapter (`terminal-adapter.ts:19`)
|
||||
|
||||
- `readonly runtime = "terminal"` (`:20`).
|
||||
- **All operations are no-ops** — "the shell IS the harness"
|
||||
(`terminal-adapter.ts:15`): no-op `project`/`deliverStartup`/`launchHarness`
|
||||
(`:34`), and `checkReady` returns ready immediately as soon as the tmux
|
||||
session exists (`:47`). Used for infrastructure nodes — servers, log tails,
|
||||
build watchers. (A terminal node cannot fork; the runtime-adapter docstring
|
||||
notes fork-unsupported adapters refuse with a runtime-mismatch error,
|
||||
`runtime-adapter.ts:113–115`.)
|
||||
- **All operations are no-ops** — "the shell is immediately interactive"
|
||||
(`terminal-adapter.ts:15`): no-op `project`/`deliverStartup`, a
|
||||
`launchHarness` (`:34`) that returns `{ ok: true }`, and a `checkReady` that
|
||||
returns ready unconditionally (`:47–48`). Used for infrastructure nodes —
|
||||
servers, log tails, build watchers. A terminal node cannot fork:
|
||||
`launchHarness` refuses a `forkSource` with a clear error (`:38–42`), as the
|
||||
runtime-adapter docstring requires of adapters without fork
|
||||
(`runtime-adapter.ts:120–121`).
|
||||
|
||||
These three are constructed by `createDaemon` step 4 (`startup.ts`; see
|
||||
`daemon-core.md` §4 "Startup sequence").
|
||||
### Pi, Oh My Pi and stub adapters
|
||||
|
||||
- `PiRuntimeAdapter` (`pi-runtime-adapter.ts:60`), `runtime` `"pi"` (`:61`).
|
||||
- `OmpRuntimeAdapter` (`omp-runtime-adapter.ts:7`) extends it with
|
||||
`runtime = "omp"` (`:8`).
|
||||
- `StubRuntimeAdapter` (`stub-runtime-adapter.ts:69`), `runtime` defaulting to
|
||||
`"stub"` (`:81`).
|
||||
|
||||
Pi and stub launch an OpenRig runner inside the seat's tmux pane (header
|
||||
comments `pi-runtime-adapter.ts:1–10`, `stub-runtime-adapter.ts:1–8`); their
|
||||
projection, launch and readiness behaviour is not detailed here.
|
||||
|
||||
`createDaemon` constructs the adapters (`startup.ts:755`, `:756`, `:759`,
|
||||
`:760`, `:765`) and creates the terminal adapter inline in the runtime adapter
|
||||
map (`startup.ts:945`); see `daemon-core.md` §4 "Startup sequence".
|
||||
|
||||
## 3. Resume honesty
|
||||
|
||||
The daemon does not assume a harness resumed just because the launch command
|
||||
ran. Three domain files (`packages/daemon/src/domain/`, all present @v0.3.0)
|
||||
make resume assessment honest:
|
||||
ran. Three domain files (`packages/daemon/src/domain/`) carry the resume
|
||||
assessment:
|
||||
|
||||
### `native-resume-probe.ts`
|
||||
|
||||
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:43`) reads pane
|
||||
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:84`) reads pane
|
||||
command + pane content and returns one of four honest statuses
|
||||
(`NativeResumeProbeStatus`, `:6`):
|
||||
(`NativeResumeProbeStatus`, `:7`):
|
||||
|
||||
- `resumed` — runtime-specific indicators confirm a resumed session.
|
||||
- `failed` — terminal failure (e.g. Claude printed "No conversation found" →
|
||||
code `no_conversation_found`, `:51–57`).
|
||||
code `no_conversation_found`, `:92–95`).
|
||||
- `inconclusive` — we don't know yet (e.g. Claude trust gate, code
|
||||
`trust_gate`, `:65–70`).
|
||||
`trust_gate`, `:106–109`).
|
||||
- `attention_required` — alive and recoverable but **needs operator action**
|
||||
(e.g. Claude resume-selection prompt, code `claude_resume_selection_prompt`,
|
||||
`:58–64`). This is the proxy for "an operator must choose the conversation";
|
||||
it is *distinct* from `inconclusive` and `failed` (docstring `:3–5`).
|
||||
`:99–102`). This is the proxy for "an operator must choose the conversation";
|
||||
it is *distinct* from `inconclusive` and `failed` (comment `:4–6`).
|
||||
|
||||
`buildNativeResumeCommand` (`:27`) builds the resume command per runtime:
|
||||
claude → `claude --resume <token> [--name <name>]` (`:35`); codex → `codex
|
||||
resume <token>` (`:38`); other runtimes → `null` (`:40`).
|
||||
`buildNativeResumeCommand` (`:32`) builds the resume command per runtime:
|
||||
claude → `claude --resume <token> [--name <name>]` (`:41`); codex →
|
||||
`buildCodexResumeCore` (`:44`), i.e. `codex<posture> resume <token>`
|
||||
(`:81`); other runtimes → `null` (`:46`).
|
||||
|
||||
This is Architecture Rule 15 in code: a failed resume is FAILED loudly; there
|
||||
is no automatic fresh fallback (the adapter's `verifyResumeLaunch` returns
|
||||
`ok:false` with a `retry_fresh` recovery hint, never a silent relaunch).
|
||||
At the adapter level (Architecture Rule 15, §4), the adapter's
|
||||
`verifyResumeLaunch` returns `ok:false` with a `retry_fresh` recovery hint and
|
||||
never relaunches itself. Acting on that hint is the caller's choice:
|
||||
`StartupOrchestrator` retries once fresh on `retry_fresh` unless the caller
|
||||
passes `allowFreshFallback: false` (`startup-orchestrator.ts:260–268`), and the
|
||||
restore orchestrator passes `false` when a pod-aware node requested resume
|
||||
(`restore-orchestrator.ts:1399`).
|
||||
|
||||
### `resume-metadata-refresher.ts`
|
||||
|
||||
`ResumeMetadataRefresher` (`resume-metadata-refresher.ts:37`). Post-launch
|
||||
resume-token capture: `refresh(sessions)` (`:62`) skips sessions that already
|
||||
have a `resumeToken` (`:65`), and for `claude-code` sessions with a token runs
|
||||
a `probeClaudeResume` returning `"resumable" | "not_resumable" |
|
||||
"inconclusive"` (`:31,74–75`) — a real launch of the resume command in a
|
||||
throwaway probe tmux session (`:106–111`), not a metadata guess.
|
||||
`ResumeMetadataRefresher` (`resume-metadata-refresher.ts:52`). Post-launch
|
||||
resume-token capture: `refresh(sessions, opts?)` (`:112`) skips `codex`
|
||||
sessions that already have a `resumeToken` (`:128`, `:144`) and otherwise
|
||||
captures the thread id (`:147–150`). For `claude-code` sessions with a token
|
||||
it runs a `probeClaudeResume` returning `"resumable" | "not_resumable" |
|
||||
"inconclusive"` (`:37`, `:194`) — a real launch of the resume command in a
|
||||
throwaway probe tmux session (`:251–254`), not a metadata guess. In
|
||||
`fillNullOnly` mode (`:121`) it skips that probe (`:177`).
|
||||
|
||||
### `codex-thread-id.ts`
|
||||
|
||||
Codex thread-id extraction (`codex-thread-id.ts`). Reads the Codex thread id
|
||||
from the Codex *logs* SQLite databases under `~/.codex/`:
|
||||
`readCodexThreadIdFromCandidateHomes(...)` (`:22`) →
|
||||
`readCodexThreadIdFromLogs(...)` (`:49`) → `resolveCodexLogDbPaths(homeDir)`
|
||||
(`:79`) which globs `<homeDir>/.codex/logs_<N>.sqlite` (`:84–89`,
|
||||
regex `^logs_(\d+)\.sqlite$`) and falls back to `logs_1.sqlite` (`:97`).
|
||||
Uses `better-sqlite3` (`:5`). Resolves the home dir by the harness PID
|
||||
(`defaultResolveHomeDirByPid`, `:9`).
|
||||
`readCodexThreadIdFromCandidateHomes(...)` (`:32`) →
|
||||
`readCodexThreadIdFromLogs(...)` (`:237`) → `resolveCodexDbPaths(homeDir,
|
||||
kind)` (`:294`) which globs `<homeDir>/.codex/logs_<N>.sqlite` (regex
|
||||
`^${kind}_(\d+)\.sqlite$`, `:300`) and falls back to `logs_1.sqlite` (`:312`).
|
||||
The logged thread ids are then checked against the `threads` table in
|
||||
`state_<N>.sqlite` (`:262–272`); an id is returned only when exactly one CLI
|
||||
conversation matches (`:274`). Uses `better-sqlite3` (`:6`). Resolves the home
|
||||
dir by the harness PID (`defaultResolveHomeDirByPid`, `:16`).
|
||||
|
||||
> Precision note — `architecture.md` §5 "Resume honesty" says codex thread IDs
|
||||
> come from "the Codex SQLite database". Source is more specific: the
|
||||
> per-version Codex *logs* DBs `~/.codex/logs_N.sqlite`. Stated precisely here;
|
||||
> re-confirmed `codex-thread-id.ts:79–97` @HEAD.
|
||||
## 4. Relevant Architecture Rules
|
||||
|
||||
## 4. Relevant Architecture Rules (source-verified at HEAD)
|
||||
The rule texts live in `architecture-rules-and-event-system.md` §1; this
|
||||
section cites them by number with the adapter-level evidence checked for this
|
||||
module.
|
||||
|
||||
From `architecture.md` §7 (re-confirmed against the source cited inline):
|
||||
|
||||
- **Rule 5** — Runtime is member-authoritative in the pod-aware model.
|
||||
- **Rule 13** — Readiness checking is a retry loop with exponential backoff and
|
||||
a configurable timeout, using adapter-specific probes (Claude TUI indicator,
|
||||
Codex ready message, terminal immediate). Re-confirmed: `checkReady`
|
||||
delegates to `assessNativeResumeProbe`; the retry loop is
|
||||
`claude-code-adapter.ts:273` (16 attempts).
|
||||
- **Rule 14** — Resume states are locked: `resumed` / `rebuilt` / `fresh`;
|
||||
`rebuilt` = new process assembled from artifacts. (The probe layer adds
|
||||
`inconclusive` / `attention_required` as honest *transient* states, not
|
||||
outcomes — see §3.)
|
||||
- **Rule 15** — Restore honesty: failed resume is FAILED loudly; no automatic
|
||||
fresh fallback; fresh launch is an explicit follow-up only. Enforced in code
|
||||
by §2/§3 (`verifyResumeLaunch` returns `ok:false`, never relaunches).
|
||||
- **Rule 1** — §2: **0** files in `adapters/` import Hono.
|
||||
- **Rule 5** — restore picks a node's adapter by that node's own `runtime`
|
||||
(`restore-orchestrator.ts:1253`).
|
||||
- **Rule 13** — the readiness loop is `StartupOrchestrator.waitForReady`
|
||||
(`startup-orchestrator.ts:488`; 1 s doubling to a 16 s cap, `:494–495`,
|
||||
`:510`; default timeout 30 s, `:491`), which calls each adapter's
|
||||
`checkReady` (§2).
|
||||
- **Rule 14** — the probe's `attention_required` (§3) reaches the restore
|
||||
result through `HarnessLaunchResult.recovery` and `evidence` (§1), which
|
||||
become `RestoreNodeResult.status` (`types.ts:458`, `:476`) and
|
||||
`attentionEvidence` (`types.ts:479`). A startup's `continuityOutcome` is a
|
||||
separate field that also allows `forked` (`startup-orchestrator.ts:69`).
|
||||
- **Rule 15** — adapters return `ok:false` and never relaunch (§2, §3);
|
||||
restore passes `allowFreshFallback: false` for pod-aware resume
|
||||
(`restore-orchestrator.ts:1399`), and a resume that concludes failed rolls
|
||||
back to zero sessions as `awaiting-decision` (`restore-orchestrator.ts:1230`,
|
||||
`:1417`).
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — where `createDaemon` constructs the three adapters.
|
||||
- `daemon-core.md` — where `createDaemon` constructs the runtime adapters.
|
||||
- `agent-spec-and-startup.md` — the `StartupOrchestrator` that calls these
|
||||
adapters and owns startup-action execution after `checkReady()`.
|
||||
- `lifecycle-snapshot-restore.md` — how persisted resume tokens flow into
|
||||
snapshot/restore (resume vs rebuild vs fresh).
|
||||
- `architecture-rules-and-event-system.md` — the full architecture rule list.
|
||||
- Source roots: `packages/daemon/src/domain/runtime-adapter.ts`,
|
||||
`packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`,
|
||||
`packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,pi-runtime-adapter,omp-runtime-adapter,stub-runtime-adapter,terminal-adapter}.ts`,
|
||||
`packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts`.
|
||||
@@ -11,83 +11,85 @@ applies-when: |
|
||||
resolve and preserve identity.
|
||||
siblings: [daemon-core.md, adapters-and-runtimes.md, lifecycle-snapshot-restore.md, packaging-bootstrap-bundles.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Agent/Rig Spec, Resolution, Startup, Identity
|
||||
|
||||
How the daemon turns authored YAML specs into a resolved, projected, launched,
|
||||
and identity-addressable topology. The spec-and-startup contract: parse →
|
||||
resolve → project → deliver → launch → wait → persist replay context.
|
||||
resolve → project → deliver pre-launch files → persist replay context → launch
|
||||
→ wait → deliver post-launch files.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
|
||||
> packages (slice-00 §1.1). Source feed located by `architecture.md` headings
|
||||
> (§4 Canonical Reboot Types, §5 Domain Services, §6 Execution Flows, §7
|
||||
> Architecture Rules) per slice-08 §10.1 — line numbers are advisory only.
|
||||
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. `domain/…` and `routes/…` paths
|
||||
> are under `packages/daemon/src/`; a bare file name such as
|
||||
> `rigspec-schema.ts:115` is in `packages/daemon/src/domain/`. Each count sits
|
||||
> beside the command that produces it; run the command from the repository
|
||||
> root to refresh it.
|
||||
|
||||
## 1. Canonical spec and topology types
|
||||
|
||||
(`architecture.md` §4 "Spec and topology")
|
||||
- **AgentSpec** (`types.ts:938`) — parsed from `agent.yaml`
|
||||
(`agent-resolver.ts:150`). Owns imports, defaults, startup, resources, and
|
||||
profiles. Canonical parse/normalize/validate is `domain/agent-manifest.ts`.
|
||||
- **RigSpec** (`types.ts:1169`) — canonical pod-aware rig topology. Uses
|
||||
`version: "0.2"` and `pods[]`; owns cross-pod `edges[]`, rig-level startup
|
||||
overlays, and `cultureFile`, plus the optional `summary`, `permissionPolicy`,
|
||||
`managedBlocks`, `docs`, `services` and `workspace` fields.
|
||||
|
||||
- **AgentSpec** — parsed from `agent.yaml`. Owns imports, defaults, startup,
|
||||
resources, and profiles. Canonical parse/normalize/validate is
|
||||
`domain/agent-manifest.ts`.
|
||||
- **RigSpec** — canonical pod-aware rig topology. Uses `version: "0.2"` and
|
||||
`pods[]`; owns cross-pod `edges[]`, rig-level startup overlays, and
|
||||
`cultureFile`.
|
||||
`version` is the spec-schema version, not the package version. It is not a
|
||||
code constant: `RigSpecSchema.validate` requires only a non-empty string
|
||||
(`rigspec-schema.ts:115`) and `RigSpecSchema.normalize` carries the authored
|
||||
value through (`rigspec-schema.ts:243`). `"0.2"` is the canonical authored
|
||||
value, and the pod-aware exporter writes it (`rigspec-exporter.ts:223`).
|
||||
|
||||
> Drift-fix D-spec-version — `architecture.md` §4 says RigSpec
|
||||
> "Uses `version: "0.2"`". This is the **spec-schema version, NOT the
|
||||
> package version** — a different versioning axis from the 0.3.1 package
|
||||
> version. The value is NOT a code constant: `rigspec-schema.ts:52`
|
||||
> validates `version` only as a "required non-empty string"
|
||||
> (`if (!obj["version"] || typeof obj["version"] !== "string")`); `:163`
|
||||
> carries it through as `raw["version"] as string`. `"0.2"` is the
|
||||
> canonical authored value, correctly described as a schema axis. Do NOT
|
||||
> "correct" it toward 0.3.1 (slice-08 §4.6 binding). Re-confirmed
|
||||
> `rigspec-schema.ts:52,163` @HEAD.
|
||||
|
||||
- **RigServicesSpec** — optional `services` block on a pod-aware RigSpec.
|
||||
Shipped kind is Compose-backed env management with `composeFile`,
|
||||
`projectName?`, `profiles?`, `downPolicy?`, `waitFor?`, `surfaces?`,
|
||||
`checkpoints?`.
|
||||
- **RigSpecPod** — pod-local bounded context with `members[]`, pod-local
|
||||
`edges[]`, pod startup, optional continuity policy.
|
||||
- **RigSpecPodMember** — member-level runtime/startup surface: `agentRef`,
|
||||
`profile`, `runtime`, `model?`, `cwd`, `restorePolicy?`, member startup
|
||||
overlays.
|
||||
- **Pod** — persisted DB entity for a pod.
|
||||
- **ContinuityState** — persisted live continuity row keyed by
|
||||
`podId + nodeId`.
|
||||
- **RigServicesSpec** (`types.ts:1216`) — optional `services` block on a
|
||||
pod-aware RigSpec. Shipped kind is Compose-backed env management
|
||||
(`kind: "compose"`) with `composeFile`, `projectName?`, `profiles?`,
|
||||
`downPolicy?`, `waitFor?`, `surfaces?`, `checkpoints?`.
|
||||
- **RigSpecPod** (`types.ts:1125`) — pod-local bounded context with
|
||||
`members[]`, pod-local `edges[]`, pod startup, optional continuity policy.
|
||||
- **RigSpecPodMember** (`types.ts:1069`) — member-level runtime/startup
|
||||
surface: `agentRef`, `profile`, `runtime`, `model?`, `effort?`, `cwd`, `restorePolicy?`,
|
||||
member startup overlays (`startup?`), plus `label?`, `codexConfigProfile?`,
|
||||
`role?`, `permissionPolicy?`, `compactionStrategy?`, `mechanic?`,
|
||||
`sessionSource?` and `starterRef?`.
|
||||
- **Pod** (`types.ts:8`) — persisted DB entity for a pod.
|
||||
- **ContinuityState** (`types.ts:18`) — persisted live continuity row keyed by
|
||||
`podId + nodeId` (`PRIMARY KEY (pod_id, node_id)`,
|
||||
`packages/daemon/src/db/migrations/014_agentspec_reboot.ts:24`).
|
||||
|
||||
## 2. Execution and projection types
|
||||
|
||||
(`architecture.md` §4 "Execution and restore" — restore/snapshot members are
|
||||
detailed in `lifecycle-snapshot-restore.md`; the spec/projection members live
|
||||
here)
|
||||
Restore/snapshot types are detailed in `lifecycle-snapshot-restore.md`; the
|
||||
spec/projection types live here.
|
||||
|
||||
- **ResolvedNodeConfig** — output of profile resolution. Carries effective
|
||||
runtime/model/cwd, narrowed restore policy, selected resources, layered
|
||||
startup block, resolved spec identity.
|
||||
- **ProjectionPlan** — runtime projection plan for a node: runtime, cwd,
|
||||
projection entries, startup block, diagnostics, conflict/no-op
|
||||
classifications.
|
||||
- **RuntimeAdapter** — the five-method contract (adapter detail in
|
||||
`adapters-and-runtimes.md`): `listInstalled(binding)`
|
||||
(`runtime-adapter.ts:131`), `project(plan, binding)` (`:134`),
|
||||
`deliverStartup(files, binding)` (`:137`), `launchHarness(binding, opts)`
|
||||
(`:147`), `checkReady(binding)` (`:153`). Re-confirmed @HEAD — the contract
|
||||
is current.
|
||||
- **HarnessLaunchResult** — `{ ok, resumeToken?, resumeType?, error? }`
|
||||
returned by `launchHarness`.
|
||||
- **StartupOrchestrator** — drives the full startup sequence (§4 below).
|
||||
- **ResolvedNodeConfig** (`profile-resolver.ts:31`) — output of profile
|
||||
resolution. Carries effective runtime/model/effort/cwd, narrowed restore policy,
|
||||
selected resources, layered startup block, resolved spec identity.
|
||||
- **ProjectionPlan** (`projection-planner.ts:41`) — runtime projection plan for
|
||||
a node: runtime, cwd, projection entries, startup block, diagnostics,
|
||||
conflict/no-op classifications.
|
||||
- **RuntimeAdapter** (`runtime-adapter.ts:134`) — the five-method contract
|
||||
(adapter detail in `adapters-and-runtimes.md`): `listInstalled(binding)`
|
||||
(`runtime-adapter.ts:144`), `project(plan, binding)` (`:147`),
|
||||
`deliverStartup(files, binding)` (`:150`), `launchHarness(binding, opts)`
|
||||
(`:160`), `checkReady(binding)` (`:166`). The interface also declares one
|
||||
optional method, `skillTargetPath?(...)` (`:141`). Required methods: **5**
|
||||
(`sed -n '/^export interface RuntimeAdapter /,/^}/p' packages/daemon/src/domain/runtime-adapter.ts | grep -c -E '^ [a-zA-Z]+\('`).
|
||||
- **HarnessLaunchResult** (`runtime-adapter.ts:88`) — returned by
|
||||
`launchHarness`: either `{ ok: true, resumeToken?, resumeType?,
|
||||
appliedLaunch? }` or `{ ok: false, error, recovery?, evidence? }`, where
|
||||
`recovery` is `"retry_fresh"` or `"attention_required"` (`:86`).
|
||||
- **StartupOrchestrator** (`startup-orchestrator.ts:107`) — drives the full
|
||||
startup sequence (§4 below).
|
||||
|
||||
## 3. Parsing, validation, resolution pipeline
|
||||
|
||||
(`architecture.md` §5 "Parsing and validation" + "Resolution pipeline"; all
|
||||
under `packages/daemon/src/domain/`, zero Hono imports per Architecture Rule 1)
|
||||
All of the files below are under `packages/daemon/src/domain/`, and none of
|
||||
them imports Hono: **0**
|
||||
(`git grep -l '"hono' -- packages/daemon/src/domain/{agent-manifest,rigspec-schema,rigspec-codec,startup-validation,path-safety,spec-validation-service,spec-review-service,agent-resolver,agent-preflight,profile-resolver,startup-resolver,projection-planner}.ts | wc -l`).
|
||||
|
||||
**Parse / validate:**
|
||||
|
||||
@@ -110,115 +112,127 @@ under `packages/daemon/src/domain/`, zero Hono imports per Architecture Rule 1)
|
||||
- `startup-resolver.ts` — additive startup layering.
|
||||
- `projection-planner.ts` — runtime resource projection planning.
|
||||
|
||||
All eleven files re-confirmed present in `packages/daemon/src/domain/` @HEAD.
|
||||
|
||||
## 4. Startup orchestration (the spec-startup contract)
|
||||
|
||||
(`architecture.md` §4 "StartupOrchestrator" + §5 "Startup, runtime, and
|
||||
instantiation" + §7 Architecture Rule 6)
|
||||
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:129`) drives, in
|
||||
source order: mark pending (`:156`) → project resources (`:167`) → deliver
|
||||
pre-launch files (`:207`) → persist startup context (`:222`) → launch harness,
|
||||
recording any resume token (`:237`) → wait for ready (`:322`) → deliver
|
||||
interactive files (`:403`) → execute `after_files` then `after_ready` actions
|
||||
(`:426`, `:432`) → mark ready (`:466`). The class doc comment
|
||||
(`startup-orchestrator.ts:90`–`101`) still lists persistence as step 9, after
|
||||
the actions; the code persists the startup context before the harness launch.
|
||||
|
||||
`StartupOrchestrator` (`domain/startup-orchestrator.ts`) drives:
|
||||
mark pending → project resources → deliver pre-launch files → launch harness
|
||||
→ wait for ready → deliver interactive files → execute actions → persist
|
||||
context → mark ready.
|
||||
|
||||
**Pre-launch vs interactive delivery split** — the load-bearing seam,
|
||||
re-confirmed at source:
|
||||
**Pre-launch vs interactive delivery split** — the load-bearing seam:
|
||||
|
||||
- Pre-launch (filesystem, before harness boot): `guidance_merge`,
|
||||
`skill_install` (`startup-orchestrator.ts:78,167` — "Deliver pre-launch
|
||||
files (guidance_merge, skill_install → filesystem)").
|
||||
`skill_install` (`startup-orchestrator.ts:93` — "Deliver pre-launch files
|
||||
(guidance_merge, skill_install → filesystem)"; delivered at `:207`).
|
||||
- Post-launch (TUI, after harness is ready): `send_text`
|
||||
(`startup-orchestrator.ts:82,160,263` — partition by concrete hint at
|
||||
`:141`, deliver after readiness at `:263`).
|
||||
(`startup-orchestrator.ts:97`; partition by concrete hint at `:182`,
|
||||
`send_text` files held for post-launch at `:200`, delivered after readiness
|
||||
at `:403`).
|
||||
|
||||
The orchestrator persists replay context including the resume token for future
|
||||
restores (consumed by `lifecycle-snapshot-restore.md`).
|
||||
The orchestrator persists replay context for future restores (consumed by
|
||||
`lifecycle-snapshot-restore.md`): the projection entries, resolved startup
|
||||
files and startup actions go to `node_startup_context`
|
||||
(`startup-orchestrator.ts:225`), and a resume token returned by the launch is
|
||||
written to the session row (`updateResumeToken`, `:254`).
|
||||
|
||||
**Architecture Rule 6 — startup layering is additive and ordered** (carried
|
||||
verbatim, `architecture.md` §7): (1) agent base, (2) profile, (3) rig culture
|
||||
**Startup layering is additive and ordered** (`resolveStartup`,
|
||||
`startup-resolver.ts:15`–`22`): (1) agent base, (2) profile, (3) rig culture
|
||||
file, (4) rig startup, (5) pod startup, (6) member startup, (7) operator debug
|
||||
append. This is the spec-startup contract's invariant; the full 25-rule list
|
||||
lives in `architecture-rules-and-event-system.md`.
|
||||
append. Files and actions are concatenated in that order with no
|
||||
deduplication (`startup-resolver.ts:24`). This is the spec-startup contract's
|
||||
invariant; the cross-cutting architecture rules are collected in
|
||||
`architecture-rules-and-event-system.md`.
|
||||
|
||||
**Startup action constraints** (`architecture.md` §7 "Current startup action
|
||||
constraints"): no shell startup actions; action types are `slash_command` and
|
||||
`send_text` only; non-idempotent actions must not apply on restore; retrying
|
||||
failed startup is handled as restore.
|
||||
**Startup action constraints** (`startup-validation.ts`): no shell startup
|
||||
actions (`:65`); action types are `slash_command`, `send_text` and
|
||||
`startup_proof` (`:7`); non-idempotent actions must not apply on restore
|
||||
(`:100`). Retrying a failed startup is handled as a restore, which is why the
|
||||
orchestrator skips non-idempotent actions on restore
|
||||
(`startup-orchestrator.ts:556`, "retry-as-restore safety"). The exception is
|
||||
`PodRigInstantiator.retryFirstStart` (`rigspec-instantiator.ts:1146`), which
|
||||
re-runs a first start that failed at projection, before any harness launch.
|
||||
|
||||
**Remote import constraints** (§7): the reboot supports `local:...` and
|
||||
`path:/abs/...`. Remote `agent_ref` sources remain unsupported and fail in
|
||||
preflight.
|
||||
**Remote import constraints**: `agent_ref` and AgentSpec imports accept
|
||||
`local:<relative path>` and `path:<absolute path>` only
|
||||
(`rigspec-schema.ts:41`, `agent-manifest.ts:40`); a terminal member uses the
|
||||
`builtin:terminal` sentinel (`rigspec-schema.ts:519`). Remote `agent_ref`
|
||||
sources remain unsupported: RigSpec validation rejects them
|
||||
(`rigspec-schema.ts:550`), `rigPreflight` runs that validation
|
||||
(`rigspec-preflight.ts:249`), and the resolver refuses a remote import again
|
||||
(`agent-resolver.ts:203`).
|
||||
|
||||
## 5. Instantiation, preflight, export
|
||||
|
||||
(`architecture.md` §5 "Startup, runtime, and instantiation" + §6 "RigSpec
|
||||
import / validate / preflight / export")
|
||||
|
||||
- `runtime-adapter.ts` — adapter contract + bridge types.
|
||||
- `rigspec-preflight.ts` — dual-stack legacy preflight plus rebooted
|
||||
`rigPreflight(...)`.
|
||||
- `rigspec-instantiator.ts` — dual-stack `RigInstantiator` plus
|
||||
`PodRigInstantiator`.
|
||||
- `rigspec-preflight.ts` — dual-stack legacy preflight (`RigSpecPreflight`,
|
||||
`rigspec-preflight.ts:25`) plus rebooted `rigPreflight(...)` (`:241`).
|
||||
- `rigspec-instantiator.ts` — dual-stack `RigInstantiator`
|
||||
(`rigspec-instantiator.ts:33`) plus `PodRigInstantiator` (`:474`).
|
||||
- `rigspec-exporter.ts` — dual-format live rig export to YAML/JSON.
|
||||
- `pod-repository.ts` — pod CRUD plus live continuity-state CRUD.
|
||||
|
||||
`routes/rigspec.ts` is the dual-format seam: validate
|
||||
`routes/rigspec.ts` is the dual-format seam; a parsed spec with a `pods` array
|
||||
takes the pod-aware path (`routes/rigspec.ts:83`): validate
|
||||
(pod-aware → `RigSpecSchema.validate`; legacy → `LegacyRigSpecSchema.validate`),
|
||||
preflight (`rigPreflight({ rigSpecYaml, rigRoot, fsOps })` vs
|
||||
preflight (`rigPreflight({ rigSpecYaml, rigRoot, … })` vs
|
||||
`RigSpecPreflight.check(spec)`), import
|
||||
(`podInstantiator.instantiate(yaml, rigRoot)` vs
|
||||
(`podInstantiator.instantiate(yaml, rigRoot, …)` vs
|
||||
`RigInstantiator.instantiate(spec)`), export (pod-aware exports canonical
|
||||
`version: "0.2"` RigSpec; legacy exports flat-node v1).
|
||||
`version: "0.2"` RigSpec; legacy exports flat-node `schemaVersion: 1`,
|
||||
`rigspec-exporter.ts:105`).
|
||||
|
||||
The import validation route delegates to `validateRigSpecImport`
|
||||
(`routes/rigspec.ts:214–222`, `spec-validation-service.ts:10–19`). The same
|
||||
helper powers local `rig spec validate` without contacting the daemon
|
||||
(`packages/cli/src/commands/rig.ts:204–207`); it selects the pod-aware or legacy
|
||||
validator and distinguishes YAML parse failures from validator exceptions.
|
||||
|
||||
## 6. Identity: whoami, materialize, bind, adopt
|
||||
|
||||
(`architecture.md` §6 "Whoami and adopted-session parity flow" +
|
||||
"Materialize / bind / adopt flow")
|
||||
|
||||
**Whoami resolution** — the daemon owns the truth surface through
|
||||
`/api/whoami` (`routes/whoami.ts`); tmux metadata is an adopted-session
|
||||
anchor, not sovereign truth. `whoami-service.ts:8` declares
|
||||
`resolvedBy: "node_id" | "session_name"`. The route requires `nodeId` or
|
||||
`sessionName` (`routes/whoami.ts:10-15`). Resolution order:
|
||||
explicit `--node-id` → explicit `--session` → env vars → tmux metadata →
|
||||
raw tmux session-name fallback.
|
||||
`sessionName` (`routes/whoami.ts:19`–`23`). The CLI resolves identity
|
||||
(`packages/cli/src/commands/whoami.ts:142`–`148`) in this order: explicit
|
||||
`--node-id` → explicit `--session` → env vars → tmux metadata → raw tmux
|
||||
session-name fallback.
|
||||
|
||||
- Managed sessions prefer projected `OPENRIG_NODE_ID` /
|
||||
`OPENRIG_SESSION_NAME`.
|
||||
- Adopted sessions use tmux-owned metadata written at claim/bind time.
|
||||
`OPENRIG_SESSION_NAME`, set when the tmux session is created
|
||||
(`node-launcher.ts:143`–`144`).
|
||||
- Adopted sessions use tmux-owned metadata written at bind time.
|
||||
|
||||
> Drift-nuance D5 (the careful axis — see also `transport-and-transcripts.md`):
|
||||
> the tmux metadata keys `@rigged_node_id` / `@rigged_session_name` /
|
||||
> `@rigged_rig_id` / `@rigged_rig_name` / `@rigged_logical_id` in
|
||||
> `architecture.md` §6 are **CORRECT as-is** and were verified literally at
|
||||
> source — `claim-service.ts:77-81` writes exactly these `@rigged_*` keys;
|
||||
> `claim-service.ts` and `rig-lifecycle-service.ts` read them. This is a
|
||||
> SEPARATE axis from MCP tool names (which are `rig_*`, slice-00 §1.4). Do
|
||||
> NOT blanket-replace `rigged` → `rig` (banked
|
||||
> `feedback_release_prep_three_layer_depersonalization`: the rename was
|
||||
> scoped, not global). The tmux metadata keys were NOT renamed.
|
||||
> Re-confirmed `claim-service.ts:77-81` @HEAD.
|
||||
The tmux metadata keys use the `@rigged_` prefix:
|
||||
`ClaimService.setRiggedMetadata` writes `@rigged_node_id` /
|
||||
`@rigged_session_name` / `@rigged_rig_id` / `@rigged_rig_name` /
|
||||
`@rigged_logical_id` (`claim-service.ts:180`–`184`), called from `bind`,
|
||||
`reconcileSession` and `createAndBindToPod`;
|
||||
`RigLifecycleService.unclaimSession` clears the same five keys
|
||||
(`rig-lifecycle-service.ts:209`–`213`); and `rig whoami` reads
|
||||
`@rigged_node_id` and `@rigged_session_name`
|
||||
(`packages/cli/src/commands/whoami.ts:173`, `:179`). MCP tool names use a
|
||||
different prefix, `rig_*` (`packages/cli/src/mcp-server.ts`).
|
||||
|
||||
**Materialize / bind / adopt** (`architecture.md` §6): `POST
|
||||
/api/rigs/import/materialize` creates a pod-aware topology without launching
|
||||
sessions; `POST /api/discovery/:id/bind` attaches a discovered live session to
|
||||
an existing logical node; `POST /api/discovery/:id/adopt` is the composite
|
||||
**Materialize / bind / adopt**: `POST /api/rigs/import/materialize`
|
||||
(`routes/rigspec.ts:174`) creates a pod-aware topology without launching
|
||||
sessions and refuses a legacy spec (`routes/rigspec.ts:187`);
|
||||
`POST /api/discovery/:id/bind` (`routes/discovery.ts:77`) attaches a
|
||||
discovered live session to an existing logical node (`logicalId`), or creates
|
||||
a member in a pod (`podNamespace` + `memberName`) and binds it;
|
||||
`POST /api/discovery/:id/adopt` (`routes/discovery.ts:139`) is the composite
|
||||
route (bind to existing node, or create a new member in a target pod and bind
|
||||
immediately). CLI mirrors via `rig bind` / `rig adopt`. Authored pod namespace
|
||||
is preserved through adoption so logical ids stay
|
||||
`${podNamespace}.${memberName}` (Architecture Rule 24: adopted-session parity
|
||||
is tmux-metadata parity, not fake env-var parity).
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D-spec-version (resolved-as-correct):** RigSpec `version: "0.2"` is the
|
||||
spec-schema axis, validated only as a non-empty string in source. Documented,
|
||||
not "corrected".
|
||||
- **D5 (careful, resolved-as-correct for this module):** tmux `@rigged_*`
|
||||
metadata keys are literally still `@rigged_*` at HEAD — architecture.md §6 is
|
||||
accurate. The MCP-tool-name `rigged_*` drift lands in
|
||||
`transport-and-transcripts.md`.
|
||||
immediately). On the CLI, `rig bind` calls the bind route
|
||||
(`packages/cli/src/commands/bind.ts:56`), and `rig adopt` materializes the
|
||||
topology and then calls the bind route for each session
|
||||
(`packages/cli/src/commands/adopt.ts:163`, `:212`). Authored pod namespace is
|
||||
preserved through adoption so logical ids stay `${podNamespace}.${memberName}`
|
||||
(`claim-service.ts:661`). Adopted-session parity is tmux-metadata parity, not
|
||||
fake env-var parity.
|
||||
|
||||
## See also
|
||||
|
||||
@@ -226,7 +240,8 @@ is tmux-metadata parity, not fake env-var parity).
|
||||
- `adapters-and-runtimes.md` — the five-method RuntimeAdapter contract detail.
|
||||
- `lifecycle-snapshot-restore.md` — snapshot/restore consumes persisted replay
|
||||
context.
|
||||
- `architecture-rules-and-event-system.md` — the full 25 architecture rules.
|
||||
- `architecture-rules-and-event-system.md` — the cross-cutting architecture
|
||||
rules.
|
||||
- Source roots: `packages/daemon/src/domain/{agent-manifest,rigspec-schema,
|
||||
profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`,
|
||||
`packages/daemon/src/routes/{rigspec,whoami}.ts`.
|
||||
@@ -11,8 +11,8 @@ applies-when: |
|
||||
compatibility limits that still describe the shipped system.
|
||||
siblings: [daemon-core.md, coordination-primitive.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Architecture Invariants, Event System, Compatibility Notes
|
||||
@@ -21,20 +21,25 @@ This module collects the cross-cutting invariants that do not belong to any
|
||||
single subsystem: the architecture rules the codebase holds itself to, the
|
||||
event-system shape, and the intentional compatibility limits.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`); package version **0.3.1** across all three packages
|
||||
> (slice-00 §1.1). HEAD carries 6 commits of unreleased 0.3.2 work; no
|
||||
> `v0.3.2` tag exists.
|
||||
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
|
||||
> command that produces it; run the command from the repository root to refresh
|
||||
> it.
|
||||
|
||||
## 1. Architecture rules
|
||||
|
||||
These are the invariants the codebase is built to preserve (`architecture.md`
|
||||
§7). Rule 6 (startup layering) and rule 7 (restore-policy narrowing) are the
|
||||
spec/startup contract — see `agent-spec-and-startup.md` for their flow detail;
|
||||
they are restated here as system-level invariants.
|
||||
These are the invariants the codebase is built to preserve. Rule 6 (startup
|
||||
layering) and rule 7 (restore-policy narrowing) are the spec/startup contract —
|
||||
see `agent-spec-and-startup.md` for their flow detail; they are restated here as
|
||||
system-level invariants.
|
||||
|
||||
1. Zero Hono in `domain/` and `adapters/`.
|
||||
2. Routes depend on the domain; the domain never depends on routes.
|
||||
1. No runtime Hono in `domain/` and `adapters/`. `adapters/` has none; `domain/`
|
||||
has type-only Hono imports (`import type`) in **3** files
|
||||
(`git grep -l 'from "hono' -- packages/daemon/src/domain packages/daemon/src/adapters | wc -l`).
|
||||
2. Routes depend on the domain; the domain does not depend on routes at
|
||||
runtime. **1** type-only import crosses back:
|
||||
`domain/project-classifier.ts:1` imports `IdentityProvenance` from
|
||||
`routes/require-sender-identity.js`
|
||||
(`git grep -lE 'from "(\.\./)+routes/' -- packages/daemon/src/domain packages/daemon/src/adapters | wc -l`).
|
||||
3. Shared DB-handle invariants are enforced at construction time.
|
||||
4. The reboot is engine-first: domain services land before public-surface
|
||||
rewiring.
|
||||
@@ -50,31 +55,48 @@ they are restated here as system-level invariants.
|
||||
in the owning artifact.
|
||||
10. Restore replay uses classification-free projection intent, not stale
|
||||
startup-time `no_op` / conflict classifications.
|
||||
11. Startup status is explicit session state: `pending`, `ready`, `failed`.
|
||||
11. Startup status is explicit session state: `pending`, `ready`,
|
||||
`attention_required`, `failed` (`types.ts:100`).
|
||||
12. Session recency depends on monotonic ULIDs: `session-registry.ts` uses
|
||||
`monotonicFactory()`; restore selects the newest session by max ULID.
|
||||
`monotonicFactory()`. Restore does not pick the newest session by max
|
||||
ULID; it resolves the active occupant recorded in the snapshot
|
||||
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
|
||||
`restore-orchestrator.ts:1171`).
|
||||
13. Readiness checking is a retry loop with exponential backoff and
|
||||
configurable timeout, using adapter-specific probes (Claude TUI
|
||||
indicator, Codex ready message, terminal immediate).
|
||||
14. Resume states are locked: `resumed` / `rebuilt` / `fresh`. `rebuilt` =
|
||||
14. Restore outcome states are a fixed set (`RestoreNodeResult.status`,
|
||||
`types.ts:473`): `resumed`, `rebuilt`, `fresh-primed`,
|
||||
`awaiting-decision`, `attention_required`, `failed`, plus
|
||||
`operator_recovered` (set only by later reconciliation) and `fresh`
|
||||
(retained only for the legacy continuity-restoring skip path). `rebuilt` =
|
||||
new process assembled from artifacts.
|
||||
15. Restore honesty: failed resume is FAILED loudly. No automatic fresh
|
||||
fallback. Fresh launch is explicit follow-up only.
|
||||
15. Restore honesty: a failed resume stops loudly as `awaiting-decision`, with
|
||||
the blank session rolled back and no session running
|
||||
(`restore-orchestrator.ts:1230`). No automatic fresh fallback. Fresh launch
|
||||
is explicit follow-up only (`rig up --fresh <logicalId>`,
|
||||
`packages/cli/src/commands/up.ts:85`).
|
||||
16. Post-command handoff required on `up`, `down`, `restore`,
|
||||
`snapshot create`: what happened + current state + next action.
|
||||
17. Session naming: `{pod}-{member}@{rig}` — human-authored,
|
||||
system-validated. No generation, no slugification.
|
||||
18. Communication: tmux is transport, not truth. `send/capture/broadcast`
|
||||
wrap tmux reliably with honest errors.
|
||||
19. Transcripts: raw capture via pipe-pane, ANSI strip on read. `rg`
|
||||
preferred, `grep -E` fallback.
|
||||
19. Transcripts: bounded capture — a periodic `tmux capture-pane` snapshot of
|
||||
the trailing lines (default 1000, every 2 s) overwrites the transcript
|
||||
file, replacing pipe-pane (`transcript-rotation.ts:3`, `:27`–`28`; started
|
||||
at `node-launcher.ts:181`). ANSI strip on read. `rig ask` transcript
|
||||
search: `rg` preferred, `grep -E` fallback.
|
||||
20. Config precedence: CLI flag > env var > config file
|
||||
(`~/.openrig/config.json`, with legacy fallback from
|
||||
`~/.rigged/config.json`) > default.
|
||||
(`~/.openrig/config.json`, or `$OPENRIG_HOME/config.json` when
|
||||
`OPENRIG_HOME` is set; `packages/cli/src/config-store.ts:915`) > default.
|
||||
21. Semi-deterministic calibration: build what agents use constantly. Agent
|
||||
handles edge cases from error messages.
|
||||
22. `rig ask` is context engineering: gathers evidence, does NOT call an
|
||||
external LLM. The agent IS the LLM.
|
||||
external LLM. The agent IS the LLM. The exception is the explicit `--wake`
|
||||
flag (`packages/cli/src/commands/ask.ts:88`), which runs one headless
|
||||
question against an existing agent session (`codex exec resume` or
|
||||
`claude -p --resume`, `packages/cli/src/ask-wake.ts:58`–`60`).
|
||||
23. Spec library truth is YAML on disk; daemon owns the structured
|
||||
review/index/cache layer.
|
||||
24. Adopted-session parity is tmux-metadata parity, not fake env-var parity.
|
||||
@@ -84,133 +106,132 @@ they are restated here as system-level invariants.
|
||||
### Startup action constraints
|
||||
|
||||
- No shell startup actions.
|
||||
- Action types are `slash_command` and `send_text` only.
|
||||
- Action types are `slash_command`, `send_text` and `startup_proof` only
|
||||
(`startup-validation.ts:7`).
|
||||
- Non-idempotent actions must not apply on restore.
|
||||
- Retrying failed startup is handled as restore.
|
||||
|
||||
### Remote import constraints
|
||||
|
||||
The reboot supports `local:...` and `path:/abs/...` agent refs. Remote
|
||||
`agent_ref` sources remain unsupported and fail in preflight
|
||||
(`architecture.md` §7 "Remote import constraints"; restated in compat note 1).
|
||||
`agent_ref` sources remain unsupported and fail in preflight (schema
|
||||
validation, `rigspec-schema.ts:550`; restated in compat note 1).
|
||||
|
||||
## 2. Event system
|
||||
|
||||
The daemon's event surface is the single `RigEvent` discriminated union.
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §8
|
||||
> says "PL-004 Phase A adds 9 coordination events" and (in §3) "Existing 32
|
||||
> PL-004 events" / "Existing 20 PL-004 events" — internally inconsistent
|
||||
> contested PL-004 sub-counts. **Do NOT carry the 9 / 32 / 20 figures.** The
|
||||
> `architecture.md` §8 "Currently emitted in production code" / "Present in
|
||||
> the union but not yet emitted" lists also predate 0.3.x and are stale. The
|
||||
> union shape below is re-derived from source at HEAD, not migrated.
|
||||
`RigEvent` is declared at `packages/daemon/src/domain/types.ts:106`
|
||||
(`export type RigEvent =`) and runs through `types.ts:311`. It has **98 union
|
||||
members** declaring **99** `type` literals: one member (`types.ts:107`) carries
|
||||
both `proof.judged` and `proof.sources_changed`.
|
||||
|
||||
`RigEvent` is declared at `packages/daemon/src/domain/types.ts:94`
|
||||
(`export type RigEvent =`) and runs through `types.ts:218`. It has **73
|
||||
union members** (slice-00 §1.8, re-confirmed at HEAD:
|
||||
`grep -cE '^\s*\| \{ type:'` over L94–218 = 73). Every one of the 73
|
||||
declared `type:` literals is also constructed somewhere in
|
||||
`packages/daemon/src/{domain,routes}` (re-verified at HEAD: the set of
|
||||
`type: "<x>"` literals in domain+routes exactly equals the 73 union members
|
||||
— zero union-only-but-never-referenced types).
|
||||
- Members:
|
||||
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`
|
||||
- Type literals:
|
||||
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | sort -u | wc -l`
|
||||
|
||||
### Per-prefix event families (grep-verified at HEAD)
|
||||
**96** of the 99 literals appear as a `type: "<x>"` literal somewhere in
|
||||
`packages/daemon/src` outside `types.ts` (mostly in `domain/` and `routes/`;
|
||||
`seat.model_divergence` is built in `startup.ts`). **3** are declared but never
|
||||
constructed: `session.status_changed`, `continuity.sync`, and `continuity.degraded`
|
||||
(per #490, `qitem.closure_overdue` is produced by `QueueRepository.recordClosureOverdue`
|
||||
when swept by `queue-stuck-sweep`, and `mission_control.view_refreshed` was retired).
|
||||
List them with:
|
||||
|
||||
Each count below is a fresh, primary-source-grep-verified, explicitly-labeled
|
||||
per-prefix family count over the union body
|
||||
(`types.ts:94–218`) — NOT the contested PL-004 sub-count (OPEN-4 ruling:
|
||||
labeled grep-verified family counts are ground truth; the `9`/`32`/`20`
|
||||
figures are forbidden).
|
||||
`for t in $(sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | tr -d '"'); do git grep -q -F "type: \"$t\"" -- packages/daemon/src ':!packages/daemon/src/domain/types.ts' || echo "$t"; done`
|
||||
|
||||
| Prefix | Members | Sample / role |
|
||||
### Per-prefix event families
|
||||
|
||||
Each count below is the number of `type` literals per prefix in the union body
|
||||
(`types.ts:106`–`311`):
|
||||
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | tr -d '"' | cut -d. -f1 | sort | uniq -c | sort -rn`.
|
||||
|
||||
| Prefix | Types | Sample / role |
|
||||
|---|---|---|
|
||||
| `node.*` | 7 | `node.added` (`types.ts:97`) … `node.startup_failed` (`:139`) — lifecycle/startup |
|
||||
| `workflow.*` | 6 | PL-004 Phase D workflow runtime (detail in `workflow-runtime.md`) |
|
||||
| `watchdog.*` | 5 | `watchdog.evaluation_fired` (`:187`) … `watchdog.job_stopped` (`:191`) — PL-004 Phase C |
|
||||
| `rig.*` | 5 | `rig.created` / `rig.deleted` / `rig.imported` / `rig.stopped` / `rig.expanded` (`:151`) |
|
||||
| `queue.*` | 5 | PL-004 Phase A queue lifecycle (`:156`–`:159`, `:169`; detail in `coordination-primitive.md`) |
|
||||
| `node.*` | 15 | `node.added` (`types.ts:124`) … `node.startup_proof_rejected` (`:227`) — lifecycle/startup |
|
||||
| `workflow.*` | 8 | workflow runtime (detail in `workflow-runtime.md`) |
|
||||
| `session.*` | 8 | session discovery / status / detach / vanish / stop / clean / resume-token audit |
|
||||
| `rig.*` | 7 | `rig.created` / `rig.deleted` / `rig.imported` / `rig.stopped` / `rig.archived` / `rig.unarchived` / `rig.expanded` (`:239`) |
|
||||
| `watchdog.*` | 5 | `watchdog.evaluation_fired` (`:278`) … `watchdog.job_stopped` (`:282`) |
|
||||
| `seat.*` | 5 | `seat.model_divergence` (`:113`) … `seat.handover_completed` (`:198`) — model divergence, fresh launch, attention clear, handover |
|
||||
| `queue.*` | 5 | queue lifecycle (`:247`–`:250`, `:260`; detail in `coordination-primitive.md`) |
|
||||
| `package.*` | 5 | legacy package/install engine events |
|
||||
| `mission_control.*` | 5 | PL-005 audit/notification (`:212`–`:218`; detail in `mission-control.md`) |
|
||||
| `mission_control.*` | 4 | audit/notification (`:306`–`:311`; detail in `mission-control.md`) |
|
||||
| `bootstrap.*` | 5 | legacy bootstrap-run events |
|
||||
| `session.*` | 4 | session discovery / status / detach / vanish |
|
||||
| `classifier.*` | 4 | PL-004 Phase B classifier-lease lifecycle |
|
||||
| `restore.*` | 3 | restore start/complete/reconcile (detail in `lifecycle-snapshot-restore.md`) |
|
||||
| `qitem.*` | 2 | `qitem.fallback_routed` (`:160`), `qitem.closure_overdue` (`:161`) |
|
||||
| `pod.*` | 2 | `pod.created` (`:135`), `pod.deleted` (`:136`) |
|
||||
| `inbox.*` | 2 | `inbox.absorbed` (`:162`), `inbox.denied` (`:163`) |
|
||||
| `continuity.*` | 2 | `continuity.sync` (`:140`), `continuity.degraded` (`:141`) |
|
||||
| singletons | 11 | one member each: `workflow_spec.*`, `view.*`, `stream.*` (`:155`), `snapshot.*`, `seat.*`, `project.*`, `kernel.*`, `chat.*` (`:149`), `bundle.*`, `binding.*`, `agent.*` |
|
||||
| `restore.*` | 4 | restore start/complete/subset-complete/reconcile (detail in `lifecycle-snapshot-restore.md`) |
|
||||
| `classifier.*` | 4 | classifier-lease lifecycle |
|
||||
| `qitem.*` | 2 | `qitem.fallback_routed` (`:251`), `qitem.closure_overdue` (`:252`) |
|
||||
| `proof.*` | 2 | `proof.judged`, `proof.sources_changed` — one union member (`:107`) |
|
||||
| `pod.*` | 2 | `pod.created` (`:213`), `pod.deleted` (`:214`) |
|
||||
| `inbox.*` | 2 | `inbox.absorbed` (`:253`), `inbox.denied` (`:254`) |
|
||||
| `continuity.*` | 2 | `continuity.sync` (`:228`), `continuity.degraded` (`:229`) |
|
||||
| `agent.*` | 2 | `agent.activity` (`:149`), `agent.session_identity` (`:156`) |
|
||||
| singletons | 12 | one type each: `workflow_spec.*`, `view.*`, `transport.*` (`:153`), `topology.*` (`:136`), `stream.*` (`:243`), `snapshot.*`, `project.*`, `kernel.*`, `event.*` (`:108`), `chat.*` (`:237`), `bundle.*`, `binding.*` |
|
||||
|
||||
Family counts sum to 73 (15 multi-member families totalling 62 + 11
|
||||
singletons), re-confirmed at HEAD.
|
||||
Family counts sum to 99 type literals (18 multi-type families totalling 87 +
|
||||
12 singletons).
|
||||
|
||||
### Emission and delivery
|
||||
|
||||
Events are constructed and emitted via `eventBus.emit({ type: ... })` across
|
||||
domain services (`stream-store.ts`, `workflow-runtime.ts`,
|
||||
`restore-orchestrator.ts`, `node-launcher.ts`, etc.) and route handlers. The
|
||||
event log is append-only and SQLite-backed.
|
||||
Events are emitted via `eventBus.emit({ type: ... })` (`event-bus.ts:57`) or,
|
||||
inside a caller-managed transaction, `eventBus.persistWithinTransaction(...)`
|
||||
(`event-bus.ts:70`) with subscribers notified after commit — across domain
|
||||
services (`stream-store.ts`, `workflow-runtime.ts`, `restore-orchestrator.ts`,
|
||||
`node-launcher.ts`, etc.) and route handlers. The event log is append-only and
|
||||
SQLite-backed.
|
||||
|
||||
Three SSE delivery surfaces (re-confirmed at HEAD):
|
||||
SSE delivery surfaces include the following. The daemon has **11**
|
||||
`streamSSE(` call sites across **10** files in `packages/daemon/src/routes/`
|
||||
(`git grep -o 'streamSSE(' -- packages/daemon/src/routes | wc -l`;
|
||||
`git grep -l 'streamSSE(' -- packages/daemon/src/routes | wc -l`).
|
||||
|
||||
- `GET /api/events` — global stream of all events (`server.ts:457`
|
||||
- `GET /api/events` — global stream of all events (`server.ts:731`
|
||||
`app.route("/api/events", eventsRoute)`).
|
||||
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:117`).
|
||||
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:194`).
|
||||
- `GET /api/queue/watch` — queue/inbox coordination events
|
||||
(`routes/queue.ts:357`).
|
||||
(`routes/queue.ts:994`).
|
||||
- The chat SSE stream `GET /api/rigs/:rigId/chat/watch` delivers
|
||||
`chat.message` for one rig (rig-scoped; see compat note 6).
|
||||
|
||||
> OPEN-4 (carried verbatim, slice-00): the precise PL-004-only vs PL-005-only
|
||||
> sub-partition is NOT asserted here. `architecture.md`'s `32`/`20`/`9`
|
||||
> figures are internally inconsistent and not reconcilable without
|
||||
> classifying all 73 members by introducing slice — flagged, not smoothed.
|
||||
> The grep-verified per-prefix family counts above are the substituted ground
|
||||
> truth.
|
||||
`chat.message` for one rig (`routes/chat.ts:68`, mounted at `server.ts:783`;
|
||||
rig-scoped; see compat note 6).
|
||||
|
||||
## 3. Remaining compatibility notes
|
||||
|
||||
Intentional limits that still describe the shipped system (`architecture.md`
|
||||
§11), verified as still-current at HEAD:
|
||||
Intentional limits that still describe the shipped system:
|
||||
|
||||
1. Remote `agent_ref` imports remain unsupported (see §1 remote import
|
||||
constraints).
|
||||
2. Startup actions remain intentionally constrained (`slash_command`,
|
||||
`send_text`).
|
||||
`send_text`, `startup_proof`).
|
||||
3. Legacy compatibility seams still ship for pre-reboot data and v1
|
||||
artifacts.
|
||||
4. `rig ask` gathers context only — does not call an external LLM (rule 22).
|
||||
5. Transcript search prefers `rg`, falls back to `grep -E`; quality/perf
|
||||
varies by backend.
|
||||
4. `rig ask` gathers context only — does not call an external LLM — unless
|
||||
`--wake` is passed (rule 22).
|
||||
5. `rig ask` transcript search prefers `rg`, falls back to `grep -E`
|
||||
(`history-query.ts:252`, `:267`); quality/perf varies by backend.
|
||||
6. Chat is rig-scoped only — no cross-rig channels or DMs.
|
||||
7. `--verify` on `rig send` checks pane content for message visibility but
|
||||
can produce false positives from pre-existing matching content. Known
|
||||
limitation.
|
||||
7. `--verify` on `rig send` checks pane content for message visibility, not
|
||||
agent acknowledgement: it compares occurrences of the message's first 40
|
||||
characters before and after the send (`session-transport.ts:1413`–`1415`).
|
||||
8. Terminal node readiness is shell-ready only — no service health probes.
|
||||
9. `rig env down --volumes` exists in the CLI surface, but the explicit
|
||||
daemon-side override is not fully plumbed through yet.
|
||||
10. Managed-app service surfaces are descriptive only — OpenRig does not
|
||||
auto-inject service URLs/tokens into agent prompts beyond authored
|
||||
startup/context files.
|
||||
11. Specialist delegation is conventional, not automatic — addressed by
|
||||
9. Managed-app service surfaces are descriptive only — OpenRig does not
|
||||
auto-inject service URLs/tokens into agent prompts beyond authored
|
||||
startup/context files.
|
||||
10. Specialist delegation is conventional, not automatic — addressed by
|
||||
session name or normal communication surfaces.
|
||||
|
||||
## 4. Cross-references
|
||||
|
||||
`architecture.md` §12 names itself the architecture-level source of truth and
|
||||
points at `codemap.md` for file-by-file structure. Under the modular
|
||||
as-built, that role is distributed: this module owns the invariants + event
|
||||
shape; the source-of-truth pointer is the rewritten `../codemap.md`
|
||||
navigation index.
|
||||
Under the modular as-built, this module owns the invariants and event shape;
|
||||
`../codemap.md` is the navigation index for file-by-file structure.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — wiring, DB, migrations, startup; footprint drift-fixes.
|
||||
- `coordination-primitive.md` — PL-004 Phase A queue/stream/inbox/outbox
|
||||
events.
|
||||
- `workflow-runtime.md` — PL-004 Phase D `workflow.*` events.
|
||||
- `mission-control.md` — PL-005 `mission_control.*` events.
|
||||
- `daemon-core.md` — wiring, DB, migrations, startup.
|
||||
- `coordination-primitive.md` — queue/stream/inbox/outbox events.
|
||||
- `workflow-runtime.md` — `workflow.*` events.
|
||||
- `mission-control.md` — `mission_control.*` events.
|
||||
- Source roots: `packages/daemon/src/domain/types.ts` (RigEvent union),
|
||||
`packages/daemon/src/domain/event-bus.ts` (emit/persist),
|
||||
`packages/daemon/src/routes/{stream,queue}.ts` (SSE watch),
|
||||
`packages/daemon/src/server.ts` (`/api/events`).
|
||||
@@ -1,320 +1,164 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
title: Content Surfaces — Files, Progress, Steering, and Health
|
||||
status: active
|
||||
topics: [observability, specification-and-bundles]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the operator-allowlisted file browser enforces path
|
||||
safety, how atomic conflict-checked writes + the JSONL edit audit work,
|
||||
how the workspace PROGRESS.md tree is indexed, or how the one-screen
|
||||
Steering surface (priority stack + roadmap rail + lane rails + health
|
||||
gates) is composed. Author-mode module — no prior architecture.md prose;
|
||||
every load-bearing claim is sourced to file:line at HEAD.
|
||||
siblings: [workspace-primitive.md, daemon-core.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, workspace-primitive.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
Tracing allowlisted file access, conflict-checked writes, progress indexing,
|
||||
steering composition, or the daemon health-summary endpoints.
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
# Content Surfaces — Files, Progress, Steering, and Health
|
||||
|
||||
> **See also (v0.4.4):** the Living Notes review surface — the composed
|
||||
> intent→plan→delivered projection over this same allowlisted content layer —
|
||||
> is documented in [`living-notes-review.md`](living-notes-review.md).
|
||||
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
|
||||
it does not establish the version or behavior of a running daemon.
|
||||
|
||||
The daemon exposes filesystem content through the files, progress, and
|
||||
steering route families. Health summaries read existing daemon state. These
|
||||
are separate projections with separate configuration; configuring one root
|
||||
is not a universal filesystem-access policy.
|
||||
|
||||
The **content surfaces** are the operator-allowlisted, filesystem-canonical
|
||||
read/write layer that the Project / Steering UI sits on: a fail-closed file
|
||||
browser, an atomic conflict-checked write service with a JSONL edit audit,
|
||||
the recursive PROGRESS.md tree indexer, and the one-screen Steering composer
|
||||
(+ its compact health-summary gates). None adds SQLite state.
|
||||
## Configuration and wiring
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
|
||||
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
|
||||
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative
|
||||
> to `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
>
|
||||
> **SPLIT NOTE (§10.6).** This is the content-surfaces half of the
|
||||
> `workspace-and-content-primitives` SPLIT (sibling `workspace-primitive.md`
|
||||
> carries the workspace primitive + migrations 038/039 +
|
||||
> missions/projects/slices). See `workspace-primitive.md` § header SPLIT
|
||||
> NOTE for the §10.6 rationale and the transparent-noted-deviation
|
||||
> disclosure.
|
||||
[`startup.ts`](../../../packages/daemon/src/startup.ts) constructs
|
||||
`SettingsStore`, decodes `cfg.filesAllowlistRaw` and `cfg.progressScanRootsRaw`,
|
||||
and injects `FileWriteService`, `ProgressIndexer`, and `SteeringComposer`.
|
||||
The write service exists only when the decoded file allowlist is nonempty.
|
||||
Its audit path is explicitly `${OPENRIG_HOME}/file-edit-audit.jsonl`.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
[`SettingsStore`](../../../packages/daemon/src/domain/user-settings/settings-store.ts)
|
||||
resolves environment overrides, then settings-file values, then defaults.
|
||||
`files.allowlist` and `progress.scan_roots` both default to
|
||||
`workspace:<resolved-workspace-root>`. Their environment overrides are
|
||||
`OPENRIG_FILES_ALLOWLIST` and `OPENRIG_PROGRESS_SCAN_ROOTS`. Thus, an unset
|
||||
environment variable does **not** imply an empty allowlist in normal daemon
|
||||
startup. The standalone `readAllowlistFromEnv` helper has different inputs
|
||||
and retains its legacy environment fallback.
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
Roots use comma-separated `name:/absolute/path` pairs. The decoders skip
|
||||
invalid pairs, canonicalize existing paths, and let the last duplicate name
|
||||
win. See [`decodeAllowlist`](../../../packages/daemon/src/domain/files/path-safety.ts)
|
||||
and [`decodeProgressScanRoots`](../../../packages/daemon/src/domain/progress/progress-indexer.ts).
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| `domain/files/{path-safety,file-write-service}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/progress/progress-indexer.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/steering/{steering-composer,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `routes/{files,progress,steering,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
## Path resolution and reads
|
||||
|
||||
> Source: §10.8 proof re-run at HEAD `7eaf524c` — every listed path
|
||||
> resolves at `v0.3.0` (no failures). All content surfaces are
|
||||
> **≤0.3.0** features (UI Enhancement Pack v0 + Operator Surface
|
||||
> Reconciliation v0; slice-00 0.3.0-GT §1.7 "Files / Markdown /
|
||||
> Progress / Proofs" confirms the cluster shipped 0.3.0). Do **not**
|
||||
> back-attribute any of these to 0.3.1 — the gap is missing
|
||||
> architecture.md prose, not a new feature.
|
||||
[`resolveAllowedPath`](../../../packages/daemon/src/domain/files/path-safety.ts)
|
||||
requires a configured root, rejects `..` path segments and absolute relative
|
||||
paths, resolves existing symlinks, and checks containment with a path-separator
|
||||
boundary. An empty relative path denotes the root. If realpath fails, the
|
||||
helper falls back to the resolved candidate; file/directory helpers then
|
||||
perform a stat check. These are the implemented checks, not a claim of
|
||||
race-free isolation against concurrent filesystem changes.
|
||||
|
||||
## 1. Path safety — the fail-closed allowlist (UI Enhancement Pack v0)
|
||||
[`readAllowedFile`](../../../packages/daemon/src/domain/files/file-read.ts)
|
||||
is shared by the HTTP reader and the local TUI reader. It returns content,
|
||||
absolute and root-relative resolved paths, mtime, full-file SHA-256, size,
|
||||
binary classification, and truncation metadata. The returned text is capped
|
||||
at **1,048,576 bytes**, but the full file is read and hashed. The cap limits
|
||||
the response, not the memory required to read the file.
|
||||
|
||||
The file browser is gated by a daemon-enforced, **fail-closed** allowlist.
|
||||
Roots are decoded from `OPENRIG_FILES_ALLOWLIST` (env), comma-separated
|
||||
`<name>:<absolute-path>` pairs (legacy fallback `RIGGED_FILES_ALLOWLIST`
|
||||
via `||` so an empty string falls through). Invalid pairs (no colon, empty
|
||||
name, non-absolute path) are **silently skipped**; duplicate names →
|
||||
last-wins. Empty/unset → no roots → routes return an empty list with a
|
||||
structured "configure OPENRIG_FILES_ALLOWLIST" hint. The safe default after
|
||||
a fresh install is **nothing allowlisted**.
|
||||
## Writes and audit
|
||||
|
||||
> Source: `domain/files/path-safety.ts:50-51` (env vars),
|
||||
> `:60-93` (`decodeAllowlist` / `readAllowlistFromEnv`; silent-skip
|
||||
> L67-71, last-wins L78, `||` fallback comment L89-91) @HEAD.
|
||||
[`FileWriteService.writeAtomic`](../../../packages/daemon/src/domain/files/file-write-service.ts)
|
||||
reads the existing target and compares **both** the expected mtime and content
|
||||
hash. A mismatch raises `WriteConflictError` with the current values. It then
|
||||
writes a temporary file in the same directory, preserves the target's
|
||||
ordinary permission bits, fsyncs the temporary file, and renames it over the
|
||||
target. This is optimistic conflict detection; it does not hold a lock over
|
||||
the read/compare/replace sequence.
|
||||
|
||||
`resolveAllowedPath(allowlist, rootName, relativePath)` is the load-bearing
|
||||
guard. Algorithm: (1) reject unknown root (`root_unknown`); (2) reject
|
||||
expressed-intent `..` segments **before** filesystem resolution
|
||||
(`path_escape`) — segment-split so `foo..bar` is not a false positive;
|
||||
(3) reject an absolute `relativePath` (`path_invalid`); (4) resolve
|
||||
symlinks via `fs.realpathSync` and reject when the realpath does not start
|
||||
with `<canonicalRoot><sep>` (`path_escape`) — a `path.sep` boundary so
|
||||
`/foo/bar` does not match `/foo/bar-other`; (5) `path === ""` resolves to
|
||||
the root itself (so callers can list the root). Symlinks **inside** the
|
||||
tree resolve normally; symlinks pointing **outside** are escape attempts.
|
||||
A non-existent candidate falls back to the unresolved path so the
|
||||
subsequent stat surfaces the absence with a specific code.
|
||||
`resolveAllowedFile` / `resolveAllowedDirectory` add a stat assertion
|
||||
(`not_a_file` / `not_a_directory`).
|
||||
`createAtomic` handles new files. It creates and fsyncs a temporary file, then
|
||||
uses an exclusive hard link to establish the target. If the target already
|
||||
exists, it returns the `target_exists` error instead of overwriting it.
|
||||
|
||||
> Source: `domain/files/path-safety.ts:95-171` (`resolveAllowedPath`;
|
||||
> `..`-before-fs L124-140, absolute-reject L141-147, sep-boundary
|
||||
> L158-169, empty-path base case in the doc-comment L108-110),
|
||||
> `:173-225` (file/dir convenience asserts) @HEAD.
|
||||
Both operations append a JSONL audit record containing actor/provenance,
|
||||
paths, timestamps, hashes, and byte-count delta. An audit append failure
|
||||
raises `audit_write_failed` **after the content has landed**; the write is not
|
||||
rolled back. The service appends records without implementing rotation.
|
||||
The service's standalone default is under the user's `.openrig` directory;
|
||||
daemon startup overrides it with the active OpenRig home as described above.
|
||||
|
||||
## 2. Atomic write + JSONL audit (UI Enhancement Pack v0 item 4)
|
||||
## HTTP surface
|
||||
|
||||
`FileWriteService.writeAtomic(req)` is the operator-actionable write surface
|
||||
for `STEERING.md` / `PROGRESS.md` / spec YAML / any allowlisted file.
|
||||
Sequence: (1) resolve target under allowlist (reuses §1 path-safety);
|
||||
(2) re-stat + re-hash the target — if `mtime !== expectedMtime` OR
|
||||
`contentHash !== expectedContentHash`, throw `WriteConflictError` carrying
|
||||
the **current** mtime + hash for the UI to surface (optimistic-concurrency,
|
||||
not a lock); (3) write to a temp file in the **same directory** (PID +
|
||||
random suffix so concurrent writes don't collide); (4) `fsync` the temp fd
|
||||
for durability before the rename; (5) atomic `renameSync` over the target
|
||||
(single inode swap on POSIX — readers see old or new, never partial);
|
||||
(6) recompute mtime + hash + byte delta; (7) append one JSONL audit row.
|
||||
[`server.ts`](../../../packages/daemon/src/server.ts) mounts these families.
|
||||
[`filesRoutes`](../../../packages/daemon/src/routes/files.ts) implements:
|
||||
|
||||
> Source: `domain/files/file-write-service.ts:9-25` (documented
|
||||
> atomic-write semantics), `:111-212` (`writeAtomic`; conflict-detect
|
||||
> L129-135, same-dir temp L137-141, fsync L146, atomic rename L161)
|
||||
> @HEAD.
|
||||
| Method and path | Result / important behavior |
|
||||
|---|---|
|
||||
| `GET /api/files/roots` | Configured roots; an empty list carries a setup hint. |
|
||||
| `GET /api/files/list?root=…&path=…` | Directory entries with metadata, including dotfiles. |
|
||||
| `GET /api/files/read?root=…&path=…` | `readAllowedFile` result. |
|
||||
| `GET /api/files/asset?root=…&path=…` | Raw asset; single byte-range support (`206`, invalid range `416`), five-minute cache. HTML defaults to plain text; `render=1` opts into HTML rendering. |
|
||||
| `POST /api/files/write` | Requires root, path, string content, expected mtime and hash. Stale reads return `409 write_conflict`; an absent write service returns `503`. |
|
||||
|
||||
The audit file is `~/.openrig/file-edit-audit.jsonl` by default
|
||||
(`HOME`/`USERPROFILE` → `/tmp` fallback). **Append-only; never rotates at
|
||||
v0.** A failed audit append throws `FileWriteError("audit_write_failed")`
|
||||
but does **NOT** undo the landed write — the user's edit succeeded; an
|
||||
audit-system flake must not silently revert canon. (The route still
|
||||
surfaces it as a 500 so the failure is honest, not swallowed.)
|
||||
Writes resolve actor/provenance through `resolveActorWithDeferral`. Missing
|
||||
route dependencies return `503`; unknown roots and invalid paths return
|
||||
`400`, and failed file stats return `404`. An audit failure can return `500`
|
||||
even though the file write succeeded, so an error is not proof of no effect.
|
||||
|
||||
> Source: `domain/files/file-write-service.ts:87-91` (default path +
|
||||
> fallback), `:24-25` (never-rotates-at-v0), `:182-204` (audit append;
|
||||
> "does NOT undo the write" comment L183-184) @HEAD.
|
||||
## Progress indexing
|
||||
|
||||
## 3. Files routes — browse + read + asset + write
|
||||
[`ProgressIndexer.scan`](../../../packages/daemon/src/domain/progress/progress-indexer.ts)
|
||||
walks configured roots on each request, to a default maximum depth of **6**.
|
||||
It skips dot-prefixed entries and named build/dependency directories, and
|
||||
collects regular `PROGRESS.md` and `STEERING.md` files. Unreadable directories
|
||||
and files are skipped. Consequently, an empty scan does not prove all files
|
||||
were readable.
|
||||
|
||||
`filesRoutes()` (mounted `server.ts:507` as `/api/files`). All routes are
|
||||
**literal** (no `/:param` catchall) so order doesn't matter for shadowing
|
||||
(Phase A R1 SSE lesson):
|
||||
The parser skips frontmatter, takes the first H1 as the title, turns
|
||||
H2–H4 headings into hierarchy rows, and reads checkbox rows as `[x]`/`[X]`
|
||||
= done, `[~]` = blocked, and `[ ]` = active. Checkbox depth uses two-space
|
||||
indentation; headings use heading depth. Per-file and aggregate counts cover
|
||||
checkbox rows. This is a progress-text projection, not proof readiness.
|
||||
|
||||
- `GET /roots` — allowlist root list (or `{roots:[], hint}` when none).
|
||||
- `GET /list?root&path` — directory entries (dirs first, then files,
|
||||
name-sorted; dotfiles always included — allowlisting a root expresses
|
||||
inspection intent).
|
||||
- `GET /read?root&path` — file content + `mtime` + `contentHash` (SHA-256)
|
||||
+ `size`. **Content is capped at `FILE_READ_TRUNCATION_BYTES` (1 MB)** but
|
||||
the hash is computed over the **FULL** file so atomic-write conflict
|
||||
detection stays honest even on a truncated read; response carries
|
||||
`truncated` / `truncatedAtBytes` / `totalBytes`.
|
||||
- `GET /asset?root&path` — raw bytes for embedded images/video/pdf (inferred
|
||||
`Content-Type`, 5-min cache). **v0.4.4 (OPR.0.4.4.20 FR-5):** serves HTTP
|
||||
**byte ranges** — single-range form, `206` + `Content-Range` +
|
||||
`Accept-Ranges: bytes` (malformed/unsatisfiable → `416`) — the
|
||||
iOS-Safari-class media-playback requirement; and `.html` renders as
|
||||
`text/html` ONLY under the explicit `?render=1` opt-in (text/plain stays
|
||||
the default). The slice proof-asset route (`/api/slices/:name/proof-asset/*`)
|
||||
carries the same range semantics (living-notes corrective). See
|
||||
[`living-notes-review.md`](living-notes-review.md) §5.
|
||||
- `POST /write` — atomic write (§2); 503 when no write service
|
||||
(`OPENRIG_FILES_ALLOWLIST` empty); 409 `write_conflict` on stale
|
||||
mtime/hash with the current values for the UI's refresh prompt.
|
||||
[`GET /api/progress/tree`](../../../packages/daemon/src/routes/progress.ts)
|
||||
returns the scan. Missing indexer or empty configured roots returns `503`.
|
||||
|
||||
503 `files_routes_unavailable` when the allowlist dep is unwired.
|
||||
Path-safety errors map to HTTP per code (`root_unknown`/`path_*` → 400,
|
||||
`stat_failed` → 404).
|
||||
## Steering composition
|
||||
|
||||
> Source: `routes/files.ts:51-242` (handlers; route-order comment
|
||||
> L17-19, truncation L139-154, write 503/409 L187-191/L225-231,
|
||||
> path-safety→HTTP `:61-69`); `FILE_READ_TRUNCATION_BYTES = 1_048_576`
|
||||
> `:49`; mounted `server.ts:507`; CLI: no `rig files` verb — files are
|
||||
> a UI-only surface @HEAD.
|
||||
[`steeringOptsFromSettings`](../../../packages/daemon/src/domain/steering/steering-composer.ts)
|
||||
combines resolved workspace settings with `OPENRIG_STEERING_WORKSPACE`,
|
||||
`OPENRIG_STEERING_PATH`, `OPENRIG_ROADMAP_PATH`, and
|
||||
`OPENRIG_DELIVERY_READY_DIR` overrides. `SteeringComposer.compose` reads:
|
||||
|
||||
> **§10.7 route-reality (source-verified at `packages/ui/src/routes.tsx`):**
|
||||
> `/files` is a **REAL route** (`FilesWorkspace`) the Phase-8.1 survey
|
||||
> MISSED. There is **NO `/markdown` route** — markdown rendering is a
|
||||
> *component* inside file / drawer surfaces, not a navigable destination.
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:192-195` (`path: "/files"` +
|
||||
> `FilesWorkspace`); grep-confirmed no `path: "/markdown"` declaration
|
||||
> anywhere in routes.tsx @HEAD.
|
||||
| Field | Source and selection |
|
||||
|---|---|
|
||||
| `priorityStack` | Verbatim `STEERING.md`, mtime, and byte count. |
|
||||
| `roadmapRail` | `roadmap/PROGRESS.md` checkbox rows; the first unchecked row is marked next. |
|
||||
| `laneRails` | Progress files under `delivery-ready`, using a depth-3 `ProgressIndexer`; first non-done/non-blocked checkbox is next pull. Default top 3 prefers non-done rows, then fills with done rows. |
|
||||
|
||||
## 4. Progress tree indexer (UI Enhancement Pack v0 item 1B)
|
||||
Missing/unreadable sections become `unavailableSources`. `isReady` means at
|
||||
least one path resolves; it does not guarantee that every read succeeds.
|
||||
[`GET /api/steering`](../../../packages/daemon/src/routes/steering.ts)
|
||||
returns `503` when the composer is absent or no source path resolves.
|
||||
Queue and health state are fetched through their own endpoints.
|
||||
|
||||
`ProgressIndexer` walks operator-allowlisted scan roots
|
||||
(`OPENRIG_PROGRESS_SCAN_ROOTS`, same `<name>:<abs-path>` shape as the file
|
||||
allowlist; legacy `RIGGED_PROGRESS_SCAN_ROOTS`), finds **`PROGRESS.md` AND
|
||||
`STEERING.md`** files (STEERING.md is anchored as the constraint-frame node
|
||||
per OSR v0 Item 2; the row-machinery is filename-agnostic), and parses each
|
||||
into a checkbox-hierarchy tree. Recursion is depth-bounded (default 6 —
|
||||
enough for mission/lane/slice nesting without descending into
|
||||
`node_modules`/`.git`/`.worktrees`/`dist`/`build`/`.turbo`/`.next` or
|
||||
dotfiles). Checkbox status: `[x]` → `done`, `[~]` → `blocked`, `[ ]` →
|
||||
`active`; headings (`##`–`####`) become hierarchy rows; depth from
|
||||
2-space indent or heading level. Per-file `counts` + a scan-wide
|
||||
`aggregate`. **In-memory walk per request; no caching at v0** (bounded by
|
||||
the operator's allowlist scope).
|
||||
## Health summaries
|
||||
|
||||
> Source: `domain/progress/progress-indexer.ts:79-84` (STEERING anchor +
|
||||
> env vars), `:81` (SKIP_DIRS), `:71` (`DEFAULT_MAX_DEPTH = 6`),
|
||||
> `:218-235` (checkbox status mapping), `:204-215` (heading rows),
|
||||
> `:21-23` (no-caching-at-v0) @HEAD.
|
||||
[`healthSummaryRoutes`](../../../packages/daemon/src/routes/health-summary.ts)
|
||||
exposes four reads under `/api/health-summary`:
|
||||
|
||||
`progressRoutes()` (mounted `server.ts:508` as `/api/progress`):
|
||||
`GET /tree` — the indexed hierarchy; 503 `progress_indexer_unavailable`
|
||||
when unwired; 503 `progress_scan_roots_not_configured` + setup hint when
|
||||
no roots.
|
||||
| Suffix | Source |
|
||||
|---|---|
|
||||
| `/nodes` | `computeNodeHealthSummary`: node inventory aggregated across rigs, grouped by session/lifecycle status, with an attention-required count. |
|
||||
| `/context` | `computeContextHealthSummary`: `context_usage` rows grouped by urgency and sample freshness. |
|
||||
| `/version` | `getDaemonVersion`: the running daemon's version, or `unknown` on read failure. |
|
||||
| `/gateway` | The injected gateway subsystem's `status()`; `503` if unavailable. |
|
||||
|
||||
> Source: `routes/progress.ts:18-40` (handler; 503s L29-35); mounted
|
||||
> `server.ts:508` @HEAD.
|
||||
The [context aggregation](../../../packages/daemon/src/domain/steering/health-summary.ts)
|
||||
uses **80%** for critical, **60%** for warning, and **300 seconds** for
|
||||
freshness. A missing usage value is unknown; a missing timestamp has no
|
||||
freshness sample. The SQL read catches errors and falls back to an empty
|
||||
sample set, so zero counts alone are not a database-health guarantee.
|
||||
|
||||
> **§10.7 route-reality:** `/progress` is a **`<Navigate to="/project" />`
|
||||
> redirect-stub** — the progress system lives at the daemon
|
||||
> `/api/progress` route, but the UI URL folds into Project tabs (Phase 3).
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:464-468` ("folds into Project tabs
|
||||
> (Phase 3) — redirect to /project") @HEAD.
|
||||
## Related source
|
||||
|
||||
## 5. Steering composer (Operator Surface Reconciliation v0 item 1)
|
||||
|
||||
`SteeringComposer.compose()` is the **one-screen composed steering surface**.
|
||||
It is deliberately narrow: it composes only the **filesystem-derived**
|
||||
pieces; the UI fetches PL-005 queue views (in-motion / loop-state) and
|
||||
health gates via their own existing endpoints so the composer stays
|
||||
testable. Three sources, all resolved from a single workspace root
|
||||
(`OPENRIG_STEERING_WORKSPACE`; per-piece overrides
|
||||
`OPENRIG_STEERING_PATH` / `OPENRIG_ROADMAP_PATH` /
|
||||
`OPENRIG_DELIVERY_READY_DIR` trump the root-derived defaults; legacy
|
||||
`RIGGED_STEERING_WORKSPACE`):
|
||||
|
||||
- **priorityStack** — verbatim `STEERING.md` content + mtime + byteCount.
|
||||
- **roadmapRail** — `roadmap/PROGRESS.md` checkbox rows; detects `PL-XXX`
|
||||
rail-item codes; marks the **first unchecked** item as `isNextUnchecked`.
|
||||
- **laneRails** — `delivery-ready/mode-{N}/PROGRESS.md` per-lane: reuses
|
||||
`ProgressIndexer` (maxDepth 3) for identical checkbox semantics to the
|
||||
Progress view; "next pull" = first non-done, non-blocked row (Priority
|
||||
Rail Rule — shelf/queue recency does NOT override); top-N (default 3)
|
||||
prefers active+blocked, falls back to done only to fill N.
|
||||
|
||||
Missing sources are reported in `unavailableSources` (each names the env
|
||||
var that would resolve it) rather than failing the whole payload.
|
||||
`isReady()` is true when **at least one** source resolves.
|
||||
|
||||
> Source: `domain/steering/steering-composer.ts:1-25` (narrow-by-design
|
||||
> rationale + 3-source list), `:103-119` (env vars +
|
||||
> `steeringOptsFromEnv`), `:155-169` (`isReady` / `compose`), `:198-250`
|
||||
> (roadmap rail; next-unchecked L231-235), `:282-316` (lane next-pull
|
||||
> L288-289, top-N L293-296), `RAIL_CODE_REGEX` `:346` @HEAD.
|
||||
|
||||
`steeringRoutes()` (mounted `server.ts:510` as `/api/steering`):
|
||||
`GET /` — the composed payload; 503 `steering_composer_unavailable` when
|
||||
unwired; 503 `steering_workspace_not_configured` + a hint pointing at
|
||||
`rig config init-workspace` / `workspace.steering_path` /
|
||||
`OPENRIG_STEERING_PATH` when no source resolves.
|
||||
|
||||
> Source: `routes/steering.ts:18-34` (handler; 503s L23-29); mounted
|
||||
> `server.ts:510` @HEAD.
|
||||
|
||||
> **§10.7 route-reality:** `/steering` is a **`<Navigate to="/project" />`
|
||||
> redirect-stub** — the composer lives at daemon `/api/steering`; the UI
|
||||
> URL folds into the Project workspace overview tab (Phase 3).
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:470-474` ("folds into Project
|
||||
> workspace overview tab (Phase 3) — redirect to /project") @HEAD.
|
||||
|
||||
## 6. Health-summary aggregator (OSR v0 item 1F)
|
||||
|
||||
`computeNodeHealthSummary` / `computeContextHealthSummary` are the compact
|
||||
health gates on the steering surface — **daemon-side aggregation, not a CLI
|
||||
shell-out** (no per-request subprocess). Node summary: cross-rig roll-up of
|
||||
`sessionStatus` + `lifecycleState` via `getNodeInventory` per rig, with an
|
||||
`attentionRequired` tally. Context summary: reads `context_usage` directly
|
||||
(ContextUsageStore exposes per-node accessors only; listing all rows is a
|
||||
steering-surface concern) and buckets by urgency
|
||||
(`usedPercentage` ≥80 critical / ≥60 warning / else low / null unknown) +
|
||||
freshness (`sampledAt` age vs 300 s → fresh/stale/none). A missing
|
||||
`context_usage` table (test harness without the migration) yields an empty
|
||||
summary, not an error.
|
||||
|
||||
> Source: `domain/steering/health-summary.ts:44-46` (thresholds:
|
||||
> `FRESHNESS_THRESHOLD_S=300`, `URGENCY_CRITICAL_PCT=80`,
|
||||
> `URGENCY_WARNING_PCT=60`), `:48-66` (node roll-up), `:75-117`
|
||||
> (context roll-up; table-absent → empty L81-84) @HEAD.
|
||||
|
||||
`healthSummaryRoutes()` (mounted `server.ts:511` as `/api/health-summary`):
|
||||
`GET /nodes`, `GET /context`; 503 `health_summary_unavailable` when the
|
||||
rigRepo dep is unwired.
|
||||
|
||||
> Source: `routes/health-summary.ts:23-44` (handlers; 503 L33,L40);
|
||||
> mounted `server.ts:511` @HEAD.
|
||||
|
||||
## 7. Cross-cutting properties
|
||||
|
||||
- **Filesystem-canonical, no new SQLite:** all four surfaces read/write the
|
||||
filesystem + the JSONL audit; health-summary reads existing
|
||||
`context_usage` only. No content-surface migration
|
||||
(`progress-indexer.ts:21-23`; `health-summary.ts:81-84`) @HEAD.
|
||||
- **Fail-closed, honest errors:** path-safety rejects every escape attempt
|
||||
before fs resolution; write conflicts surface the current mtime/hash for
|
||||
an explicit refresh; missing config returns 503 + a setup hint naming the
|
||||
exact env var — never a silent empty success
|
||||
(`path-safety.ts:124-169`; `file-write-service.ts:129-135`;
|
||||
`routes/steering.ts:25-29`) @HEAD.
|
||||
- **Atomic-or-nothing writes:** same-directory temp + fsync + atomic rename;
|
||||
a rename failure leaves the target untouched (never a partial write)
|
||||
(`file-write-service.ts:160-172`) @HEAD.
|
||||
- **Composer narrowness is deliberate:** the steering composer owns only
|
||||
filesystem-derived pieces; queue/health come from their own endpoints —
|
||||
keeps it testable (`steering-composer.ts:6-16`) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — the JSONL edit audit at `~/.openrig/file-edit-audit.jsonl`
|
||||
**never rotates at v0** by explicit design
|
||||
(`file-write-service.ts:24-25`); unbounded growth is a known accepted v0
|
||||
state (PRD § Item 4 defers rotation), stated here rather than smoothed.
|
||||
- **OPEN-B** — `OPENRIG_FILES_ALLOWLIST` vs `OPENRIG_PROGRESS_SCAN_ROOTS`
|
||||
vs `OPENRIG_STEERING_WORKSPACE` are three independent operator-config
|
||||
surfaces with the same encoding but separate resolution; there is no
|
||||
single "workspace allowlist" that unifies them at v0. Source-real;
|
||||
noted, not reconciled.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration-count / route-group / PL-004-event-count claim.
|
||||
- [Living Notes review](living-notes-review.md) composes proof and scope content.
|
||||
- [Workspace primitive](workspace-primitive.md) covers workspace identity.
|
||||
- [File-path tests](../../../packages/daemon/test/files-path-safety.test.ts),
|
||||
[progress tests](../../../packages/daemon/test/progress-indexer.test.ts), and
|
||||
[steering tests](../../../packages/daemon/test/steering-composer.test.ts)
|
||||
exercise these helpers; their presence is not a runtime verification claim.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
title: Coordination Primitive — Stream/Queue/Inbox/Outbox
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator]
|
||||
@@ -10,57 +10,74 @@ applies-when: |
|
||||
transactional handoff guarantee, or where queue closure is enforced.
|
||||
siblings: [workflow-runtime.md, mission-control.md, daemon-core.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
# Coordination Primitive — Stream/Queue/Inbox/Outbox
|
||||
|
||||
The PL-004 Phase A coordination primitive is the SQLite-canonical
|
||||
durable-work layer the daemon exposes through `/api/stream` and `/api/queue`.
|
||||
It replaces the POC filesystem `rigx queue` / `rigx stream` path for
|
||||
daemon-backed work; the POC filesystem path remains untouched and the daemon
|
||||
`rig queue` / `rig stream` write only to SQLite (`architecture.md` §5 L651).
|
||||
The coordination primitive is the SQLite-canonical durable-work layer the
|
||||
daemon exposes through `/api/stream` and `/api/queue`. It replaces the POC
|
||||
filesystem `rigx queue` / `rigx stream` path for daemon-backed work; the POC
|
||||
filesystem path remains untouched, and the daemon-backed `rig queue` /
|
||||
`rig stream` commands operate only through the daemon HTTP API, so they write
|
||||
only to SQLite (`packages/cli/src/commands/queue.ts:19`,
|
||||
`packages/cli/src/commands/stream.ts:11`).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
|
||||
> command that produces it; run the command from the repository root to refresh
|
||||
> it.
|
||||
|
||||
## 1. The five host-scoped tables
|
||||
|
||||
Five host-scoped tables back the primitive (`architecture.md` §3 L416–438;
|
||||
migrations re-confirmed at HEAD `packages/daemon/src/db/migrations/`):
|
||||
Five host-scoped tables back the primitive, one per migration `023`–`027` in
|
||||
`packages/daemon/src/db/migrations/` (**5** tables:
|
||||
`cat packages/daemon/src/db/migrations/02[3-7]_*.ts | grep -c 'CREATE TABLE'`):
|
||||
|
||||
- **`stream_items`** (`023_stream_items.ts`) — L1 append-only intake/audit
|
||||
root. Columns: `stream_item_id` (ULID PK), `ts_emitted`,
|
||||
root. Columns: `stream_item_id` (TEXT PK; a ULID unless the caller supplies
|
||||
one, `packages/daemon/src/domain/stream-store.ts:88`), `ts_emitted`,
|
||||
`stream_sort_key`, `source_session`, `body`, `format` (default `text`),
|
||||
`hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON),
|
||||
`interrupt`, `archived_at`. Items immutable after emit (only `archived_at`
|
||||
may be set).
|
||||
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue. `qitem_id`
|
||||
is TEXT PK preserving the POC `qitem-YYYYMMDDHHMMSS-<hex>` shape. State
|
||||
enum (8 values): `pending | in-progress | done | blocked | failed |
|
||||
denied | canceled | handed-off`. Carries `closure_reason`,
|
||||
`closure_target`, `closure_required_at`, `chain_of_record` (JSON),
|
||||
`blocked_on`, `handed_off_to`/`handed_off_from`, nudge/heartbeat fields.
|
||||
`interrupt`, `archived_at`, and `identity_provenance` (added by
|
||||
`067_i3_identity_provenance.ts`). Items immutable after emit (only
|
||||
`archived_at` may be set, `stream-store.ts:217`).
|
||||
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue.
|
||||
Unless `--id` is supplied, the CLI generates the create request's `qitem_id` (TEXT PK) before sending as `qitem-<UTC YYYYMMDDHHMMSS>-<16 hex>` (64 random bits) and prints it to stderr as a request identity, not proof of persistence; reuse that ID with `--id` and the unchanged payload after an unknown outcome, while daemon callers without an ID still use `newQitemId()` and its `qitem-<UTC YYYYMMDDHHMMSS>-<8 hex>` form.
|
||||
State enum (**8** values, `QUEUE_STATES` at `queue-repository.ts:32`;
|
||||
`sed -n '/^export const QUEUE_STATES/,/] as const/p' packages/daemon/src/domain/queue-repository.ts | grep -c '^ "'`):
|
||||
`pending | in-progress | done | blocked | failed | denied | canceled |
|
||||
handed-off`. Carries `closure_reason`, `closure_target`,
|
||||
`closure_required_at`, `chain_of_record` (JSON), `blocked_on`,
|
||||
`handed_off_to`/`handed_off_from`, nudge/heartbeat columns.
|
||||
- **`queue_transitions`** (`025_queue_transitions.ts`) — L3 append-only
|
||||
transition log; authoritative audit trail for state evolution. No
|
||||
UPDATE/DELETE from domain code.
|
||||
transition log; authoritative audit trail for state evolution. Domain code
|
||||
never updates its rows. The one delete is the retention runner, which moves
|
||||
every transition of a terminal item whose last transition is older than the
|
||||
retention window into `queue_transitions_archive`
|
||||
(`054_queue_transitions_archive.ts`), insert and delete in one transaction
|
||||
(`packages/daemon/src/domain/queue-retention.ts:203`).
|
||||
- **`inbox_entries`** (`026_inbox_entries.ts`) — mailbox-style asynchronous
|
||||
deposit; idempotent on `inbox_id`. State: `pending | absorbed | denied`.
|
||||
- **`outbox_entries`** (`027_outbox_entries.ts`) — sender-side audit;
|
||||
symmetric to inbox; idempotent on `outbox_id`. Delivery state:
|
||||
`pending | delivered | failed`.
|
||||
deposit; idempotent on `inbox_id`. State: `pending | absorbed | denied`
|
||||
(`INBOX_STATES`, `inbox-handler.ts:5`).
|
||||
- **`outbox_entries`** (`027_outbox_entries.ts`) — sender-side record;
|
||||
symmetric to inbox; idempotent on `outbox_id`. Delivery state (**7** values,
|
||||
`OUTBOX_DELIVERY_STATES` at `outbox-handler.ts:28`;
|
||||
`grep '^export const OUTBOX_DELIVERY_STATES' packages/daemon/src/domain/outbox-handler.ts | grep -o '"[a-z]*"' | wc -l`):
|
||||
`pending | sending | delivered | failed | indeterminate | retained |
|
||||
retired`.
|
||||
|
||||
> Drift-fix D3 — `architecture.md` §3 L243 frames the schema as "27
|
||||
> migrations (22 existing plus 5 added by PL-004 Phase A)". The 5 PL-004
|
||||
> Phase A coordination tables (`023`–`027`) are still correct as a *range*,
|
||||
> but the headline migration count is **40**, not 27 (slice-00 §1.3,
|
||||
> re-confirmed at HEAD: `startup.ts:206` 40-element `migrate()` array; full
|
||||
> drift-fix authority and the correction live in `daemon-core.md`).
|
||||
These five are migrations `023`–`027` of the daemon's **93**
|
||||
(`git ls-files packages/daemon/src/db/migrations | wc -l`), applied in the
|
||||
order of `ALL_MIGRATIONS` (`packages/daemon/src/db/all-migrations.ts:102`).
|
||||
Later migrations add columns to these tables; `daemon-core.md` covers the
|
||||
migration set.
|
||||
|
||||
## 2. The six host-scoped services
|
||||
|
||||
Six host-scoped domain services implement the layer (`architecture.md` §5
|
||||
L649–658; files re-confirmed in `packages/daemon/src/domain/` at HEAD).
|
||||
Six host-scoped domain services in `packages/daemon/src/domain/` implement the
|
||||
layer (**6** files:
|
||||
`ls packages/daemon/src/domain/{stream-store,queue-repository,queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts | wc -l`).
|
||||
Routes import these; services are Hono-free:
|
||||
|
||||
- **`stream-store.ts`** — L1 stream: idempotent emit (on `stream_item_id`),
|
||||
@@ -68,40 +85,53 @@ Routes import these; services are Hono-free:
|
||||
and inclusive time-window filters, soft archive. `direction=latest` applies
|
||||
every filter before taking the newest bounded page, then returns that page
|
||||
chronologically.
|
||||
- **`queue-repository.ts`** — L3 queue: create, claim/unclaim, update
|
||||
(general state mutator with hot-potato strict-rejection on `done`),
|
||||
transactional handoff (close source as `handed-off` plus create new owned
|
||||
qitem in a single transaction), pod-fallback rerouting, overdue lookup,
|
||||
nudge/heartbeat tracking. Cross-rig validation hook exposed as
|
||||
`validateRig` constructor option.
|
||||
- **`queue-repository.ts`** — L3 queue: create (`:1391`), claim/unclaim
|
||||
(`:2165`, `:2247`), update (general state mutator with hot-potato
|
||||
strict-rejection on `done`, `:2310`), transactional handoff (close source as
|
||||
`handed-off` plus create new owned qitem in a single transaction, `:1634`;
|
||||
`handoffAndComplete`, `:1811`, closes the source as `done` instead),
|
||||
pod-fallback rerouting (`routeToFallback`, `:3580`), overdue lookup
|
||||
(`findOverdue`, `:3333`), nudge-result tracking (`recordNudgeAttempt`,
|
||||
`:3565`). Nothing in the daemon writes the `last_heartbeat` column
|
||||
(`queue-pickup.ts:16`). Cross-rig validation hook exposed as `validateRig`
|
||||
constructor option (`queue-repository.ts:711`).
|
||||
- **`queue-transition-log.ts`** — append-only state-transition log; used by
|
||||
`queue-repository.ts`, exposed read-only on
|
||||
`queue_repository.transitionLog`.
|
||||
`queue-repository.ts`, exposed as the read-only property
|
||||
`QueueRepository.transitionLog` (`queue-repository.ts:660`).
|
||||
- **`hot-potato-enforcer.ts`** — pure validator for the load-bearing API
|
||||
contract (see §3).
|
||||
- **`inbox-handler.ts`** — mailbox handler: authenticated drop (idempotent
|
||||
on `inbox_id`), absorb (promotes a pending entry to a `queue_item`,
|
||||
idempotent), deny (records reason). Auth check is a pluggable constructor
|
||||
hook.
|
||||
- **`outbox-handler.ts`** — sender-side outbox: idempotent record, mark
|
||||
delivered/failed, list. Emits no event-bus events (pure audit).
|
||||
- **`inbox-handler.ts`** — mailbox handler: drop (idempotent on `inbox_id`,
|
||||
`:99`), absorb (promotes a pending entry to a `queue_item`, idempotent,
|
||||
`:154`), deny (records reason, `:214`). The handler has no auth hook: the
|
||||
inbox routes take the sender or receiver from the `X-OpenRig-Session` header
|
||||
through `requireSenderIdentity` (`routes/queue.ts:1034`), never from the body
|
||||
(`inbox-handler.ts:71`).
|
||||
- **`outbox-handler.ts`** — sender-side outbox: idempotent record (`:130`),
|
||||
delivery-state marks (`markDelivered` `:239`, `markFailed` `:256`,
|
||||
`markIndeterminate` `:280`), list (`listForSender` `:339`). It also holds the
|
||||
queue's durable wake intents (ids prefixed `wake-intent-`, `:15`) and the
|
||||
messages the seat delivery guard retains (`retain` `:181`, `retire` `:217`),
|
||||
so it is not audit-only. Emits no event-bus events.
|
||||
|
||||
The standing detector in `queue-stuck-sweep.ts` creates findings through the
|
||||
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
|
||||
to the underlying durable work row. This satisfies the existing human-route
|
||||
evidence contract without changing destination resolution: an admitted finding
|
||||
can still be unroutable. Repeated detections refresh the existing finding;
|
||||
when the source condition resolves, the sweep closes its own finding.
|
||||
queue repository, with `evidenceRef: rig queue show <source-qitem-id>`
|
||||
(`queue-stuck-sweep.ts:523`) pointing to the underlying durable work row. This
|
||||
satisfies the existing human-route evidence contract without changing
|
||||
destination resolution: an admitted finding can still be unroutable. Repeated
|
||||
detections refresh the existing finding; when the source condition resolves,
|
||||
the sweep closes its own finding (`:549`).
|
||||
|
||||
## 3. The hot-potato closure contract (where queue closure is enforced)
|
||||
|
||||
`hot-potato-enforcer.ts` is the pure validator for the load-bearing API
|
||||
contract. Re-confirmed at HEAD (`hot-potato-enforcer.ts:10–24`):
|
||||
contract. `validateClosure` (`hot-potato-enforcer.ts:59`) checks a transition
|
||||
against `CLOSURE_REASONS` (`:18`; **7** values:
|
||||
`sed -n '/^export const CLOSURE_REASONS/,/] as const/p' packages/daemon/src/domain/hot-potato-enforcer.ts | grep -c '^ "'`):
|
||||
|
||||
`state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied,
|
||||
canceled, no-follow-on, escalation}`. The reasons
|
||||
canceled, no-follow-on, escalation, superseded}`. The reasons
|
||||
`handed_off_to | blocked_on | escalation` additionally require
|
||||
`closure_target`:
|
||||
`closure_target` (`:86`):
|
||||
|
||||
- `handed_off_to` — work continues with a different seat (`closure_target` =
|
||||
new owner).
|
||||
@@ -112,31 +142,43 @@ canceled, no-follow-on, escalation}`. The reasons
|
||||
- `no-follow-on` — terminal completion, nothing else needed.
|
||||
- `escalation` — kicked up to a higher tier (`closure_target` = escalation
|
||||
target).
|
||||
- `superseded` — the row was replaced by cancel-and-replace
|
||||
(`closure_target` = the successor qitem). The update path records it on
|
||||
`state=canceled` and refuses it there without a `closure_target`
|
||||
(`queue-repository.ts:2566`).
|
||||
|
||||
Tier→SLA mapping for `closure_required_at` also lives in
|
||||
`hot-potato-enforcer.ts`. This validator is invoked by
|
||||
`QueueRepository.update()` (and `updateWithinTransaction()`), so closure is
|
||||
enforced at the daemon transaction boundary — the workflow runtime
|
||||
*projects* on closure but does not gate it (see `workflow-runtime.md`).
|
||||
`hot-potato-enforcer.ts` (`TIER_SLA_SECONDS`, `:114`). This validator is
|
||||
invoked by `QueueRepository.update()` (and `updateWithinTransaction()`), both
|
||||
through the call at `queue-repository.ts:2484`, so closure is enforced at the
|
||||
daemon transaction boundary — the workflow runtime *projects* on closure but
|
||||
does not otherwise gate it (see `workflow-runtime.md`). The one
|
||||
workflow-aware check is in the queue: `update()` refuses a terminal close of a
|
||||
live workflow frontier packet from a non-workflow verb
|
||||
(`workflow_frontier_packet`, `queue-repository.ts:2510`), through a predicate
|
||||
startup injects.
|
||||
|
||||
## 3b. Cross-host queue routing (v0.4.6 — OPR.0.4.6.MH3)
|
||||
## 3b. Cross-host queue routing
|
||||
|
||||
The queue's two coordination WRITE verbs — **create + handoff(-and-complete)
|
||||
only** — are host-aware: a write body may carry an out-of-band `hostId`
|
||||
envelope (BR-1 — session strings stay `member@rig` everywhere; the 3-part
|
||||
`agent@rig@host` form is CLI input sugar that never leaves the CLI edge).
|
||||
The mechanism **generalizes the shipped mission-control forward-then-strip
|
||||
WRITE template** (`routes/mission-control.ts` § remote action): one shared
|
||||
route-layer helper (`forwardQueueWrite`, `routes/queue.ts`) resolves the
|
||||
host registry daemon-side (bearers never reach the caller), rejects
|
||||
ssh-declared hosts (`unsupported-transport` — the daemon→daemon path is
|
||||
http-only, because it is what fires the remote nudge), strips `hostId`, and
|
||||
forwards the WHOLE body via `remoteJsonRequest` under a named write-class
|
||||
deadline (`QUEUE_FORWARD_TIMEOUT_MS`). The origin's response returns
|
||||
verbatim; transport failures map to a structured host-named taxonomy
|
||||
(`remote_queue_write_failed`: unknown-host / unsupported-transport /
|
||||
unreachable / auth-failed / remote-error). No local row is ever written on
|
||||
the cross-host path.
|
||||
Three queue write routes are host-aware — `POST /create`,
|
||||
`POST /:qitemId/handoff` and `POST /:qitemId/handoff-and-complete` (**3**:
|
||||
`grep -c '!resolvesToLocalHost(body.hostId, getSelfHostId())' packages/daemon/src/routes/queue.ts`):
|
||||
a write body may carry an out-of-band `hostId` envelope. The destination
|
||||
session stays `member@rig`; the 3-part `agent@rig@host` form is CLI input
|
||||
sugar that `resolveQueueHostDestination`
|
||||
(`packages/cli/src/commands/queue.ts:364`) splits into the destination and
|
||||
`hostId` before the request leaves the CLI. The mechanism follows the
|
||||
forward-then-strip shape of mission-control's remote action
|
||||
(`routes/mission-control.ts:353`): one shared route-layer helper
|
||||
(`forwardQueueWrite`, `routes/queue.ts:197`) resolves the host registry
|
||||
daemon-side (bearers never reach the caller), rejects ssh-declared hosts
|
||||
(`unsupported-transport` — the daemon→daemon path is http-only), and forwards
|
||||
the whole body, with `hostId` stripped, via `remoteJsonRequest` (`:231`) under
|
||||
a named write-class deadline (`QUEUE_FORWARD_TIMEOUT_MS`, `:41`). The origin's
|
||||
response returns verbatim; failures map to a structured host-named error
|
||||
(`remote_queue_write_failed`, HTTP 502, `:217`) whose `failureClass` is one of
|
||||
registry / unknown-host / unsupported-transport / unreachable / auth-failed /
|
||||
remote-error. No local row is ever written on the cross-host path.
|
||||
|
||||
**The model: origin-owns-the-record, at-least-once + idempotent,
|
||||
message-passing closure (never 2PC).**
|
||||
@@ -144,99 +186,114 @@ message-passing closure (never 2PC).**
|
||||
- **Origin-owns-the-record.** The qitem lives in the TARGET host's DB; that
|
||||
row is THE record. The target daemon's OWN `maybeNudge` fires on ITS local
|
||||
tmux (the forwarded body includes the `nudge` flag) — the sending daemon
|
||||
never reaches across a host boundary.
|
||||
- **Idempotency (arch Q-a).** The forwarding daemon MINTS the `qitemId`
|
||||
before the first forward, so every retry carries the same id; dedup rides
|
||||
the existing `qitem_id TEXT PRIMARY KEY`. On PK conflict the origin
|
||||
returns the stored row when identity fields match (idempotent absorb) and
|
||||
a structured `qitem_id_reuse` error when they differ
|
||||
(`QueueRepository.create()` catch-path + `isQitemPrimaryKeyConflict`).
|
||||
- **Cross-host handoff choreography (arch Q-c).** The local atomic
|
||||
close+create cannot span two DBs, so the route-layer choreography
|
||||
(`crossHostHandoff`, `routes/queue.ts`) runs: successor-create on the
|
||||
target host FIRST (via the one forward helper), local source-close SECOND
|
||||
(`QueueRepository.closeCrossHostHandoffSource`) — never the reverse. A
|
||||
crash between the two leaves a live duplicate that the idempotent re-drive
|
||||
converges; the reverse order would leave a closed source pointing at a
|
||||
successor that does not exist (a dropped potato — the one forbidden
|
||||
outcome). The successor id is DERIVED, not minted:
|
||||
`deriveCrossHostSuccessorId(source, destination, host)` →
|
||||
`qitem-xh-<sha256[:16]>` — a pure stateless function, so a re-drive
|
||||
re-derives the same id across daemon restarts and absorbs on the target
|
||||
PK. *(Named residual, inherent to the at-least-once/no-2PC fence: a
|
||||
re-drive naming a DIFFERENT destination derives a different id and cannot
|
||||
absorb the earlier successor — that orphan stays visible via the chain +
|
||||
provenance tags; the source-close conflict check surfaces the
|
||||
never reaches across a host boundary. Before forwarding, the forwarding
|
||||
daemon stamps its own host id, when it has one, onto the source session
|
||||
(`stampSelfHostSuffix`, `queue-repository.ts:542`; called at
|
||||
`routes/queue.ts:359` and `:465`), so the target row records the sender as
|
||||
`member@rig@<forwarding host>`.
|
||||
- **Idempotency.** On create, the forwarding daemon mints the `qitemId` before
|
||||
the forward unless the caller supplied one (`routes/queue.ts:457`). The
|
||||
forward is a single request, so a retry dedups only when it carries the same
|
||||
id (a caller re-sending `--id`, or a handoff re-drive, whose successor id is
|
||||
derived). Dedup rides the existing `qitem_id TEXT PRIMARY KEY`. On PK
|
||||
conflict the origin returns the stored row when destination and source
|
||||
match (idempotent absorb) and a structured `qitem_id_reuse` error (409) when
|
||||
they differ (`QueueRepository.create()` catch path,
|
||||
`queue-repository.ts:1440`, with `isQitemPrimaryKeyConflict`, `:455`).
|
||||
- **Cross-host handoff choreography.** The local atomic close+create cannot
|
||||
span two DBs, so the route-layer choreography (`crossHostHandoff`,
|
||||
`routes/queue.ts:299`) runs: successor-create on the target host FIRST (via
|
||||
the one forward helper, `:378`), local source-close SECOND
|
||||
(`QueueRepository.closeCrossHostHandoffSource`, `queue-repository.ts:2000`)
|
||||
— never the reverse. A crash between the two leaves a live duplicate that
|
||||
the idempotent re-drive converges; the reverse order would leave a closed
|
||||
source pointing at a successor that does not exist (a dropped potato — the
|
||||
one forbidden outcome). The successor id is DERIVED, not minted:
|
||||
`deriveCrossHostSuccessorId(source, destination, host)`
|
||||
(`queue-repository.ts:483`) → `qitem-xh-<sha256[:16]>` — a pure stateless
|
||||
function, so a re-drive re-derives the same id across daemon restarts and
|
||||
absorbs on the target PK. *(Residual, inherent to at-least-once delivery
|
||||
without 2PC: a re-drive naming a DIFFERENT destination derives a different
|
||||
id and cannot absorb the earlier successor — that orphan stays visible via
|
||||
the chain + provenance tags; the source-close conflict check surfaces the
|
||||
disagreement.)*
|
||||
- **Closure across the boundary.** The source closes with
|
||||
`closure_reason=handed_off_to` and `closure_target=member@rig@<host>` —
|
||||
the 3-part form is legal THERE because `closure_target` is OPAQUE
|
||||
audit/display metadata, presence-checked and never parsed for routing
|
||||
(arch R1; any PR parsing it as a session string is a spec violation).
|
||||
The exemption spans exactly the `closure_target` column on `queue_items`
|
||||
and its verbatim mirror on `queue_transitions` — nothing else. In
|
||||
particular the transition log's free-text `transition_note` is a durable
|
||||
carrier too: the minted cross-host close note names the 2-part
|
||||
`toSession` only (rev1-r2 B1). `handed_off_to` and every other
|
||||
session-string carrier stay 2-part.
|
||||
`closure_reason=handed_off_to` and
|
||||
`closure_target=<successor qitem id>@<host>` (`routes/queue.ts:332`); a
|
||||
source already closed with the older `member@rig@<host>` target keeps it,
|
||||
and a re-drive matches against it (`:333`). `closure_target` is OPAQUE
|
||||
audit metadata, never parsed for routing; the standing stuck sweep reads it
|
||||
only to check successor custody (`queue-stuck-sweep.ts:468`). The
|
||||
host-qualified key appears only in the `closure_target` column on
|
||||
`queue_items` and its verbatim mirror on `queue_transitions`: the minted
|
||||
cross-host close note names the 2-part `toSession` only
|
||||
(`queue-repository.ts:2056`), and `handed_off_to` and the
|
||||
`queue.handed_off` event stay 2-part.
|
||||
Re-drive semantics: already-terminal + MATCHING `closure_target` = absorb;
|
||||
MISMATCH = structured `cross_host_close_conflict` (409). The successor
|
||||
carries `chain_of_record = [...source.chain, source.qitemId]` — A-side ids
|
||||
are opaque lineage identifiers on B (arch R2b; they do not dereference in
|
||||
B's DB) — plus provenance tags `cross-host` + `from-host:<self-declared
|
||||
name>` (honest best-effort, not authenticated identity).
|
||||
- **Boundary discipline.** Claim / update / inbox stay local-by-principle
|
||||
(after a cross-host handoff the successor lives where its worker lives);
|
||||
sender-side ops on a forwarded item are a named follow-up. The local
|
||||
(no-host) paths are byte-identical to pre-MH-3 behavior; the hot-potato
|
||||
validation contract (§3) is unweakened across the boundary.
|
||||
MISMATCH = structured `cross_host_close_conflict` (409), checked in the route
|
||||
before any forward (`routes/queue.ts:343`) and again in the repository. The
|
||||
successor carries `chain_of_record = [...source.chain, source.qitemId]`
|
||||
(`routes/queue.ts:365`) — A-side ids are opaque lineage identifiers on B
|
||||
(they do not dereference in B's DB) — plus provenance tags `cross-host` +
|
||||
`from-host:<self-declared name>` (the forwarding daemon's OS hostname,
|
||||
`routes/queue.ts:54`; honest best-effort, not authenticated identity).
|
||||
- **Boundary discipline.** Claim, update and inbox routes take no `hostId` and
|
||||
stay local (after a cross-host handoff the successor lives where its worker
|
||||
lives). Without a `hostId`, with `local`, or with this daemon's own resolved
|
||||
host id (an exact, case-sensitive match), create and handoff take the
|
||||
local path; a self-addressed handoff whose deterministic cross-host
|
||||
successor an earlier self-forward already created runs that cross-host
|
||||
create and close in process. The
|
||||
hot-potato validation contract (§3) is unweakened across the boundary: the
|
||||
cross-host close always records `handed_off_to` with a target.
|
||||
|
||||
## 4. Coordination events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md`
|
||||
> §3 L394 says "Existing 32 PL-004 events untouched" and L410 says "Existing
|
||||
> 20 PL-004 events are unchanged" — these two body claims are internally
|
||||
> inconsistent with each other and predate 0.3.x. **Do NOT carry either
|
||||
> number.** The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
|
||||
> has **73 union members** total (slice-00 §1.8, re-confirmed at HEAD:
|
||||
> `grep -cE '^\s*\| \{ type: ' = 73`). A precise PL-004-only vs PL-005-only
|
||||
> sub-partition is not reconcilable from static inspection without
|
||||
> classifying all 73 members by introducing slice; slice-00 OPEN-4 flags
|
||||
> this and it is carried, not smoothed.
|
||||
The `RigEvent` union (`packages/daemon/src/domain/types.ts:106`) has **98**
|
||||
members
|
||||
(`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`);
|
||||
this module covers only the coordination families below.
|
||||
|
||||
Coordination events emitted by these services (re-confirmed in
|
||||
`domain/types.ts` at HEAD): `stream.emitted` (StreamStore.emit,
|
||||
`types.ts:155`); `queue.created` / `queue.handed_off` / `queue.claimed` /
|
||||
`queue.unclaimed` / `qitem.fallback_routed` / `qitem.closure_overdue`
|
||||
(QueueRepository); `inbox.absorbed` (`types.ts:162`) / `inbox.denied`
|
||||
(InboxHandler). `architecture.md` §8 L987–990 listed these as "9
|
||||
coordination events"; the count of the broader `stream|queue|inbox|qitem`
|
||||
event family at HEAD is 10 (`stream` 1 + `queue` 5 + `inbox` 2 + `qitem` 2)
|
||||
— stated as a re-derived family count, not as a contested PL-004 sub-count
|
||||
(OPEN-4).
|
||||
Coordination events emitted by these services: `stream.emitted`
|
||||
(`StreamStore.emit`, declared at `types.ts:243`); `queue.created` /
|
||||
`queue.handed_off` / `queue.claimed` / `queue.unclaimed` / `queue.updated` /
|
||||
`qitem.fallback_routed` / `qitem.closure_overdue` (QueueRepository); `inbox.absorbed` (`types.ts:253`) /
|
||||
`inbox.denied` (InboxHandler). `qitem.closure_overdue` (`types.ts:252`) is
|
||||
emitted by `QueueRepository.recordClosureOverdue` when swept by `queue-stuck-sweep`
|
||||
and passed through the queue watch filter (**2** files contain its literal:
|
||||
`git grep -l -F 'type: "qitem.closure_overdue"' -- packages/daemon/src | wc -l`).
|
||||
The `stream|queue|inbox|qitem` families declare **10** event types (`stream` 1
|
||||
+ `queue` 5 + `inbox` 2 + `qitem` 2;
|
||||
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"(stream|queue|inbox|qitem)\.[a-z_]+"' | sort -u | wc -l`).
|
||||
|
||||
Two SSE surfaces stream coordination events: `/api/stream/watch`
|
||||
(aliased `/api/stream/sse`, `routes/stream.ts:117–118`) for new stream
|
||||
items, and `/api/queue/watch` for queue/inbox events. The event log remains
|
||||
append-only and SQLite-backed. `rig stream watch` is a thin, single-connection
|
||||
consumer of `/api/stream/sse`; it does not add a daemon route or reconnect
|
||||
policy.
|
||||
Two SSE surfaces stream coordination events (**2**:
|
||||
`cat packages/daemon/src/routes/stream.ts packages/daemon/src/routes/queue.ts | grep -c 'return streamSSE('`):
|
||||
`/api/stream/watch` (aliased `/api/stream/sse`, `routes/stream.ts:194`–`195`)
|
||||
for new stream items, and `/api/queue/watch` (aliased `/api/queue/sse`,
|
||||
`routes/queue.ts:994`–`995`) for queue/inbox events — every coordination type
|
||||
except `stream.emitted` and `queue.updated` (filter at
|
||||
`routes/queue.ts:971`–`978`). The event log remains append-only and
|
||||
SQLite-backed. `rig stream watch` is a thin, single-connection consumer of
|
||||
`/api/stream/sse` (`packages/cli/src/commands/stream.ts:191`); it does not add
|
||||
a daemon route or reconnect policy.
|
||||
|
||||
## 5. Route surface
|
||||
|
||||
- `/api/stream` (`server.ts:489`) — `POST /emit` (`routes/stream.ts:24`),
|
||||
`GET /list` (`:56`, including `sourceSession`, `hintDestination`, `hintTag`,
|
||||
`since`, and `until` filters), `GET /watch` + `/sse` SSE (`:117`),
|
||||
`GET /:streamItemId` (`:121`), `POST /:streamItemId/archive` (`:130`).
|
||||
- `/api/queue` (`server.ts:490`) — `POST /create` (`routes/queue.ts:99`),
|
||||
`POST /:qitemId/claim` (`:144`), `POST /:qitemId/unclaim` (`:157`),
|
||||
`POST /:qitemId/update` (`:170`), plus handoff/list/watch surfaces.
|
||||
- `/api/stream` (`server.ts:784`) — `POST /emit` (`routes/stream.ts:56`),
|
||||
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
|
||||
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
|
||||
(`:194`), `GET /:streamItemId` (`:198`), `POST /:streamItemId/archive`
|
||||
(`:207`).
|
||||
- `/api/queue` (`server.ts:785`) — `POST /create` (`routes/queue.ts:402`),
|
||||
`POST /:qitemId/claim` (`:501`), `POST /:qitemId/unclaim` (`:517`),
|
||||
`POST /:qitemId/update` (`:556`), plus handoff (`:606`, `:682`), list
|
||||
(`:842`) and watch (`:994`) surfaces.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — daemon wiring, the 40-migration set, route surface.
|
||||
- `workflow-runtime.md` — PL-004 Phase D runtime that projects on closure.
|
||||
- `mission-control.md` — PL-005 queue observability over `queue_items`.
|
||||
- `daemon-core.md` — daemon wiring, the migration set, route surface.
|
||||
- `workflow-runtime.md` — the workflow runtime that projects on closure.
|
||||
- `mission-control.md` — queue observability over `queue_items`.
|
||||
- Source roots: `packages/daemon/src/domain/{stream-store,queue-repository,
|
||||
queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`,
|
||||
`packages/daemon/src/routes/{stream,queue}.ts`.
|
||||
@@ -9,143 +9,106 @@ applies-when: |
|
||||
graph, the SQLite schema/migration set, or the route-mount surface.
|
||||
siblings: [coordination-primitive.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Daemon Core — Wiring, DB, Migrations, Startup
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies. The daemon
|
||||
(`@openrig/daemon`) is the framework-free SQLite-backed core that the CLI
|
||||
(`@openrig/cli`), UI (`@openrig/ui`), and MCP server all sit on top of.
|
||||
(`@openrig/cli`), the terminal UI (`@openrig/tui`), the web UI (`@openrig/ui`)
|
||||
and the MCP server all sit on top of.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Current package version is **0.3.1** across all three
|
||||
> packages (`package.json` root `"version": "0.3.1"`; slice-00 §1.1). HEAD
|
||||
> carries 6 commits of unreleased release-0.3.2 work; no `v0.3.2` tag exists.
|
||||
> Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count below sits beside the
|
||||
> command that produces it; run the command from the repository root to refresh
|
||||
> it.
|
||||
|
||||
## 1. System overview
|
||||
|
||||
The system has six architectural layers (`architecture.md` §1 L23–30):
|
||||
For what OpenRig is and how its packages fit together, read `ARCHITECTURE.md` at
|
||||
the repository root. This module covers the daemon's own wiring.
|
||||
|
||||
1. **AgentSpec / pod-aware core** — spec parsing, resolution, precedence,
|
||||
startup orchestration, snapshot/restore, bundles.
|
||||
2. **Operator and topology layer** — harness auto-launch, node inventory,
|
||||
session naming, infrastructure nodes, explorer UI, existing-rig power-on,
|
||||
auto-snapshot, post-command handoff.
|
||||
3. **Communication and history layer** — transcript capture (pipe-pane),
|
||||
communication primitives (send/capture/broadcast), config/preflight,
|
||||
`rig ask` context packs, durable rig chat.
|
||||
4. **Authoring and identity layer** — spec review + spec library, `whoami`,
|
||||
adopted-session tmux-metadata parity, bind/materialize/adopt workflows.
|
||||
5. **Rig environment layer** — rig-scoped services records, Compose-backed
|
||||
service orchestration, readiness gates, env snapshot/restore integration.
|
||||
6. **Agent-managed software layer** — managed-app classification, app-focused
|
||||
browse/review/runtime UI, and the canonical `secrets-manager` example.
|
||||
### Source footprint at `b6d37bfadcc189be8fd61e35abfc09676437bf48`
|
||||
|
||||
Legacy flat-node/package flows remain for backward compatibility.
|
||||
The footprint counts use non-test TypeScript files under each package's `src/`
|
||||
(tests live in separate `packages/*/test/` directories):
|
||||
`find <dir> -type f \( -name '*.ts' -o -name '*.tsx' \) ! -name '*.test.*' | wc -l`.
|
||||
|
||||
### Verified source footprint at HEAD `7eaf524c`
|
||||
|
||||
> Drift-fix D1/D6 — `architecture.md` L6,11 said `OpenRig v0.2.0` /
|
||||
> "Current v0.2.0 release verification" and L7 said `376` source files. Both
|
||||
> are a frozen pre-v0.2.0-era header (slice-00 §1.9 — last edited `72982bb2`,
|
||||
> 2026-03-30). Corrected to the values below.
|
||||
|
||||
| Metric | Value @ HEAD | Source (independently re-confirmed) |
|
||||
| Metric | Count | Directory or command |
|
||||
|---|---|---|
|
||||
| Package version | **0.3.1** (HEAD +6 unreleased 0.3.2 commits) | slice-00 §1.1; `package.json:version` |
|
||||
| Total source footprint | **601** files | slice-00 §1.6; `find packages/*/src` non-test = 279+87+235 |
|
||||
| Daemon footprint | **279** total / **173** domain / **49** route mounts (46 route files) / **11** adapters / **40** migrations | slice-00 §1.5/§1.6; `find packages/daemon/src` non-test |
|
||||
| CLI footprint | **87** files | slice-00 §1.6; `find packages/cli/src` non-test |
|
||||
| UI footprint | **235** files | slice-00 §1.6; `find packages/ui/src` `.ts`+`.tsx` non-test |
|
||||
| All packages | **1158** | `packages/*/src` |
|
||||
| Daemon | **629** (**409** under `domain/`, **26** under `adapters/`) | `packages/daemon/src`, `…/src/domain`, `…/src/adapters` |
|
||||
| CLI | **162** | `packages/cli/src` |
|
||||
| Web UI | **304** | `packages/ui/src` |
|
||||
| TUI | **63** | `packages/tui/src` |
|
||||
| Database migrations | **93** (`001_core_schema.ts` … `094_usage_samples_latest_indexes.ts`; no `093`) | `git ls-files packages/daemon/src/db/migrations \| wc -l` |
|
||||
| Files in `routes/` | **67**, of which **65** create a Hono router | `git ls-files packages/daemon/src/routes \| wc -l`; `git grep -l 'new Hono' -- packages/daemon/src/routes \| wc -l` |
|
||||
| `app.route(...)` mounts in `server.ts` | **69** | `grep -c 'app.route(' packages/daemon/src/server.ts` |
|
||||
| Top-level `rig` commands | **85** | `grep -c 'program.addCommand(' packages/cli/src/index.ts` |
|
||||
| MCP tools | **18**, all named `rig_*` | `grep -c 'server.tool(' packages/cli/src/mcp-server.ts` |
|
||||
| Runtime adapter classes | **5**: Claude Code, Codex, Pi, Stub, Terminal | `git grep -l 'implements RuntimeAdapter' packages/daemon/src \| wc -l` |
|
||||
|
||||
> Footprint counts use the `find packages/*/src -type f \( -name '*.ts' -o
|
||||
> -name '*.tsx' \) ! -name '*.test.*'` predicate (tests live in separate
|
||||
> `packages/*/test/` dirs). Counts are reproducible with that predicate; a
|
||||
> different counting convention shifts absolute numbers (slice-00 OPEN-5).
|
||||
`OmpRuntimeAdapter` (Oh My Pi) extends `PiRuntimeAdapter`, so the daemon wires
|
||||
six runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
|
||||
(`startup.ts:945`).
|
||||
|
||||
### The stack
|
||||
|
||||
> Drift-fix D2 — `architecture.md` L37,125 said `CLI (53 command groups)`.
|
||||
> Corrected to **58** at HEAD (slice-00 §1.2: 53 at v0.2.0 tag, 56 at v0.3.0,
|
||||
> 57 at v0.3.1, **58 at HEAD** — the 58th is `scopeCommand()` added post-0.3.1
|
||||
> by `0b77cba4`, re-confirmed `index.ts:20,187`).
|
||||
>
|
||||
> Drift-fix D4 — `architecture.md` L40 said `31 route groups` and L76 said
|
||||
> "`createApp()` now mounts 22 route groups". Corrected to **49 `app.route()`
|
||||
> mounts + 4 dedicated handlers** (slice-00 §1.5; re-confirmed `server.ts`
|
||||
> 49×`app.route(` L450–513, plus `app.get("/healthz")` :446,
|
||||
> `handleExportYaml` :461, `handleExportJson` :462, `app.get("*")` :519).
|
||||
> Carry slice-00 **OPEN-2**: "route-group count is definitional" — there is
|
||||
> no single canonical "route group" definition in source; reported as mount
|
||||
> count + dedicated handlers.
|
||||
>
|
||||
> Drift-fix D3 — `architecture.md` L54 said `SQLite state (36 migrations)`.
|
||||
> Corrected to **40** (slice-00 §1.3; re-confirmed below).
|
||||
|
||||
```text
|
||||
CLI (58 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
CLI (85 top-level commands) / TUI / web UI / MCP (18 tools)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (49 app.route() mounts + 4 dedicated health/export/static handlers)
|
||||
Hono daemon routes (69 app.route() mounts + direct handlers for /healthz,
|
||||
spec export, unknown /api paths and the static UI)
|
||||
|
|
||||
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
|
||||
+-- rigspec routes (pod-aware and legacy spec formats)
|
||||
+-- env routes (status / logs / down)
|
||||
+-- transport routes (send/capture/broadcast)
|
||||
+-- transcript routes (tail/grep)
|
||||
+-- ask routes (context evidence packs)
|
||||
+-- transport routes (send / capture / broadcast)
|
||||
+-- transcript routes (tail / grep)
|
||||
+-- ask routes (context evidence)
|
||||
+-- chat routes (durable rig messaging + SSE)
|
||||
+-- spec review/library routes (managed-app enrichment + compose preview)
|
||||
+-- whoami identity + context-usage route
|
||||
+-- coordination routes (stream / queue / workflow / mission-control)
|
||||
+-- spec review / spec library routes
|
||||
+-- whoami identity route
|
||||
+-- coordination routes (stream / queue / workflow / mission control)
|
||||
|
|
||||
v
|
||||
Framework-free domain services (173 daemon domain files)
|
||||
Framework-free domain services (409 files under packages/daemon/src/domain)
|
||||
|
|
||||
+-- SQLite state (40 migrations)
|
||||
+-- SQLite state (93 migrations)
|
||||
+-- tmux / cmux / resume adapters
|
||||
+-- runtime adapters (Claude Code / Codex / Terminal)
|
||||
+-- RigEnv substrate (compose adapter, readiness, orchestrator)
|
||||
+-- runtime adapters (Claude Code / Codex / Pi / Oh My Pi / Stub / Terminal)
|
||||
+-- rig environment services (compose adapter, service readiness, orchestrator)
|
||||
+-- transport / transcript / chat / ask layers
|
||||
+-- whoami identity service
|
||||
+-- whoami identity service (including context usage)
|
||||
```
|
||||
|
||||
The core product loop: `down (auto-snapshot) → up <rig-name> (auto-restore) →
|
||||
handoff → inspect/attach → work → repeat`.
|
||||
|
||||
> MCP tool count is **17** (`rig_*`) — slice-00 §1.4 confirms the count is
|
||||
> correct and the names are `rig_*` (NOT `rigged_*`; the `rigged_*` text in
|
||||
> `architecture.md` §2 is stale — the rename predates v0.2.0). Per-occurrence
|
||||
> verification of any `rigged_*` reference lands in `transport-and-transcripts.md`
|
||||
> (D5); the tmux `@rigged_*` metadata keys are a separate axis, not blanket-replaced.
|
||||
|
||||
## 2. Database schema
|
||||
|
||||
> Drift-fix D3 — `architecture.md` L243 said "27 migrations (22 existing plus
|
||||
> 5 added by PL-004 Phase A)" and L1002 repeated "27 migrations"; the §1 stack
|
||||
> diagram L54 said "36 migrations". All corrected to **40** migrations
|
||||
> (`001`–`040`). Triangulated (slice-00 §1.3, re-confirmed at HEAD): (1)
|
||||
> filesystem `packages/daemon/src/db/migrations/[0-9][0-9][0-9]_*.ts` → 40
|
||||
> files, `001_core_schema.ts` … `040_workflow_specs_diagnostic.ts`; (2)
|
||||
> `startup.ts:206` `migrate(db, [...])` passes a 40-element schema array
|
||||
> (`coreSchema` … `workflowSpecsDiagnosticSchema`); (3) `migrate.ts:13`
|
||||
> applies them sorted by name, tracked in `schema_migrations`.
|
||||
The migrations live in `packages/daemon/src/db/migrations/` (93 files,
|
||||
`001_core_schema.ts` … `094_usage_samples_latest_indexes.ts`, with no `093`).
|
||||
`ALL_MIGRATIONS` (`packages/daemon/src/db/all-migrations.ts:102`) lists all 93, and `createDaemon`
|
||||
applies them with `migrate(db, ALL_MIGRATIONS)` (`startup.ts:283`). `migrate.ts`
|
||||
sorts them by name (`:29`) and records each applied name in `schema_migrations`
|
||||
(`:15`).
|
||||
|
||||
### Core state tables (`001_core_schema.ts`)
|
||||
### Core state tables
|
||||
|
||||
`rigs` (topology container, `001_core_schema.ts:7`), `nodes` (logical node
|
||||
identity, `:17`), `edges` (logical topology relationships, `:30`), plus
|
||||
`bindings` (physical tmux/cmux surface attachment), `sessions` (live execution
|
||||
state), `events` (append-only event log), `snapshots` (serialized rig state),
|
||||
`checkpoints` (per-node recovery state). `architecture.md` §3 L247–289.
|
||||
identity, `:17`) and `edges` (logical topology relationships, `:30`); then
|
||||
`bindings` (physical tmux/cmux surface attachment) and `sessions` (live
|
||||
execution state) in `002_bindings_sessions.ts`, `events` (append-only event log)
|
||||
in `003_events.ts`, `snapshots` (serialized rig state) in `004_snapshots.ts`, and
|
||||
`checkpoints` (per-node recovery state) in `005_checkpoints.ts`.
|
||||
|
||||
### Reboot-era schema
|
||||
### Pod-aware schema
|
||||
|
||||
- `014_agentspec_reboot.ts` — reboot schema shape; adds `pods`,
|
||||
`continuity_state`, and reboot columns on `nodes`/`sessions`/`checkpoints`
|
||||
(`pod_id`, `agent_ref`, `resolved_spec_*`, `startup_status`,
|
||||
`continuity_source`). `architecture.md` §3 L253–289.
|
||||
- `014_agentspec_reboot.ts` — the pod-aware schema; adds the `pods` and
|
||||
`continuity_state` tables and pod-aware columns on `nodes`, `sessions` and
|
||||
`checkpoints`.
|
||||
- `015_startup_context.ts` — persisted startup replay context for restore.
|
||||
- `016_chat_messages.ts` — durable rig-scoped chat (SQLite-backed; transcripts
|
||||
remain filesystem-backed via pipe-pane).
|
||||
@@ -155,106 +118,105 @@ state), `events` (append-only event log), `snapshots` (serialized rig state),
|
||||
- `020_rig_services.ts` — rig-scoped environment record for service-backed rigs.
|
||||
- `021_seat_handover_observability.ts`, `022_node_codex_config_profile.ts`.
|
||||
|
||||
### Coordination / PL-004 / PL-005 / workspace migrations
|
||||
### Coordination, workflow, mission control and workspace migrations
|
||||
|
||||
- `023_stream_items.ts` … `027_outbox_entries.ts` — PL-004 Phase A
|
||||
coordination tables (detail in `coordination-primitive.md`).
|
||||
- `023_stream_items.ts` … `027_outbox_entries.ts` — the coordination tables
|
||||
(stream, queue, queue transitions, inbox, outbox; detail in
|
||||
`coordination-primitive.md`).
|
||||
- `028_project_classifications.ts`, `029_classifier_leases.ts`,
|
||||
`030_views_custom.ts` — PL-004 Phase B classifier + view tables. (Note:
|
||||
`architecture.md` L361 said "028 through 030"; the middle migration is
|
||||
literally `029_classifier_leases.ts` — re-confirmed at HEAD.)
|
||||
- `031_watchdog_jobs.ts`, `032_watchdog_history.ts` — PL-004 Phase C watchdog.
|
||||
`030_views_custom.ts` — classifier and view tables.
|
||||
- `031_watchdog_jobs.ts`, `032_watchdog_history.ts` — watchdog tables.
|
||||
- `033_workflow_specs.ts`, `034_workflow_instances.ts`,
|
||||
`035_workflow_step_trails.ts` — PL-004 Phase D Workflow Runtime tables
|
||||
(detail in `workflow-runtime.md`). `036_watchdog_policy_enum_extension.ts`
|
||||
is a documenting no-op.
|
||||
- `037_mission_control_actions.ts` — PL-005 Phase A audit table
|
||||
(`037_mission_control_actions.ts:54` `CREATE TABLE … mission_control_actions`;
|
||||
detail in `mission-control.md`).
|
||||
- `038_workspace_primitive.ts`, `039_queue_target_repo.ts` — PL-007 typed
|
||||
workspace primitive (`bab24bf7`).
|
||||
- `040_workflow_specs_diagnostic.ts` — slice-11 (`f68f453a`); an
|
||||
`ALTER TABLE ADD COLUMN` adding parser/validator diagnostic columns to
|
||||
`workflow_specs` (no new table). **Net-new since `architecture.md` was last
|
||||
edited** — slice-00 §1.3 provenance; carried into `workflow-runtime.md`.
|
||||
`035_workflow_step_trails.ts` — workflow runtime tables (detail in
|
||||
`workflow-runtime.md`). `036_watchdog_policy_enum_extension.ts` runs no SQL; it
|
||||
documents an enum extension.
|
||||
- `037_mission_control_actions.ts` — the mission control audit table
|
||||
(`037_mission_control_actions.ts:58`; detail in `mission-control.md`).
|
||||
- `038_workspace_primitive.ts`, `039_queue_target_repo.ts` — the typed workspace
|
||||
primitive.
|
||||
- `040_workflow_specs_diagnostic.ts` — `ALTER TABLE workflow_specs ADD COLUMN`
|
||||
for parser/validator diagnostics (no new table).
|
||||
|
||||
Legacy package/bootstrap/discovery tables (`packages`, `package_installs`,
|
||||
`install_journal`, `bootstrap_runs`, `bootstrap_actions`,
|
||||
`runtime_verifications`, `discovered_sessions`) remain active.
|
||||
Migrations `041`–`092` and `094` continue in the same directory; list them with
|
||||
`git ls-files packages/daemon/src/db/migrations`.
|
||||
|
||||
The package, bootstrap and discovery tables remain: `packages`
|
||||
(`008_packages.ts`), `package_installs` and `install_journal`
|
||||
(`009_install_journal.ts`), `bootstrap_runs`, `bootstrap_actions` and
|
||||
`runtime_verifications` (`011_bootstrap.ts`), and `discovered_sessions`
|
||||
(`012_discovery.ts`).
|
||||
|
||||
## 3. Route-mount surface
|
||||
|
||||
`createApp(deps)` (`packages/daemon/src/server.ts:295`) mounts **49**
|
||||
`app.route()` route-group mounts (`server.ts` L450–513) plus 4 dedicated
|
||||
non-route handlers: `GET /healthz` (`:446`), `GET /api/rigs/:rigId/spec`
|
||||
(`handleExportYaml`, `:461`), `GET /api/rigs/:rigId/spec.json`
|
||||
(`handleExportJson`, `:462`), and the static/deep-link `app.get("*")`
|
||||
catch-all (`:519`).
|
||||
`createApp(deps)` (`packages/daemon/src/server.ts:441`) mounts **69**
|
||||
`app.route()` route groups (`server.ts:724`–`839`) plus direct handlers:
|
||||
`GET /healthz` (`:665`), `GET /api/rigs/:rigId/spec` (`handleExportYaml`,
|
||||
`:736`), `GET /api/rigs/:rigId/spec.json` (`handleExportJson`, `:737`), a JSON
|
||||
`404 not_found` for any other `/api/*` path (`:844`), and the static/deep-link
|
||||
`app.get("*")` catch-all (`:853`).
|
||||
|
||||
> OPEN-2 (carried verbatim, slice-00): the route-group "count" is definitional
|
||||
> — "49" is the count of `app.route()` mounts in `server.ts`. The older
|
||||
> "31 route groups" / "createApp now mounts 22 route groups" framing
|
||||
> (`architecture.md` L40,76) used a different, smaller accounting. Independent
|
||||
> corroboration: `packages/daemon/src/routes/` has 46 non-test route `.ts`
|
||||
> files (some groups composed from shared modules; the 49 mounts are the
|
||||
> authoritative count of mounted groups — slice-00 §1.5).
|
||||
The 69 is the count of `app.route(` lines in `server.ts`; `packages/daemon/src/routes/`
|
||||
has 67 files, 65 of which create a Hono router.
|
||||
|
||||
Mount families include reboot-era rig/session/spec routes plus the coordination
|
||||
routes (`/api/stream` `server.ts:489`, `/api/queue` `:490`,
|
||||
`/api/workflow` `:495`, `missionControlRoutes(...)` `:498`),
|
||||
`/api/health-summary` (`:511`), `/api/rigs/:rigId/env` (`:512`), and
|
||||
`/api/restore-check` (`:513`).
|
||||
Mount families include the rig, session and spec routes plus the coordination
|
||||
routes (`/api/stream` `server.ts:784`, `/api/queue` `:785`, `/api/workflow`
|
||||
`:790`, `missionControlRoutes(...)` `:793`), `/api/health-summary` (`:829`),
|
||||
`/api/rigs/:rigId/env` (`:835`) and `/api/restore-check` (`:836`).
|
||||
|
||||
`createAppWithWebSocket(deps)` (`server.ts:893`) sets `enableNodeWebSocket` and
|
||||
calls `createApp`; the terminal WebSocket is registered only when the web UI is
|
||||
also enabled (`server.ts:757`).
|
||||
|
||||
## 4. Startup sequence (`createDaemon`)
|
||||
|
||||
`createDaemon(opts?)` is `packages/daemon/src/startup.ts:203` (async,
|
||||
returns `DaemonResult`). It returns `{ app, db, deps, contextMonitor }`
|
||||
(`startup.ts:1204`). The sequence (`architecture.md` §9 L1000–1028,
|
||||
corrected against source):
|
||||
`createDaemon(opts?)` is `packages/daemon/src/startup.ts:274` (async, returns
|
||||
`DaemonResult`). It returns `{ app, db, deps, contextMonitor, eventLoopMonitor,
|
||||
injectWebSocket }` (`startup.ts:2450`). In source order, it:
|
||||
|
||||
1. Open SQLite and run **all 40 migrations** (`startup.ts:206`
|
||||
`migrate(db, [coreSchema … workflowSpecsDiagnosticSchema])` — a 40-element
|
||||
array; `architecture.md` L1002 said "27 migrations (22 existing plus the 5
|
||||
PL-004 Phase A coordination tables)" — corrected to 40, drift-fix D3).
|
||||
2. Construct core repositories and legacy services.
|
||||
3. Construct package/bootstrap/discovery services.
|
||||
4. Construct rebooted startup/runtime services: `StartupOrchestrator`,
|
||||
`ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`,
|
||||
`PodRigInstantiator`, `PodBundleSourceResolver`.
|
||||
5. Construct rig environment services: `ComposeServicesAdapter`,
|
||||
`ServiceOrchestrator`.
|
||||
6. Construct operator/transport/history services: `TranscriptStore`,
|
||||
`SessionTransport`, `ChatRepository`, `AskService` (with `HistoryQuery`),
|
||||
`ResumeMetadataRefresher`, `ContextUsageStore`, `ContextMonitor`,
|
||||
`NodeInventory`.
|
||||
7. Construct authoring/identity/managed-app services: `SpecReviewService`,
|
||||
`SpecLibraryService`, `WhoamiService`.
|
||||
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
9. Construct PL-004 Phase A coordination services from a shared
|
||||
`QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue`
|
||||
write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`,
|
||||
`OutboxHandler`.
|
||||
10. Build `AppDeps`, enforce shared-DB invariants, and call
|
||||
`createApp(deps)` (`startup.ts:1202`) to mount the full route tree.
|
||||
1. Opens SQLite and applies all 93 migrations (`migrate(db, ALL_MIGRATIONS)`,
|
||||
`startup.ts:283`).
|
||||
2. Constructs the coordination stores early: `StreamStore` (`:315`),
|
||||
`QueueRepository` (`:413`) and `OutboxHandler` (`:428`).
|
||||
3. Constructs `TranscriptStore` (`:540`) and the rig environment services
|
||||
`ComposeServicesAdapter` and `ServiceOrchestrator` (`:621`–`622`).
|
||||
4. Constructs `StartupOrchestrator` (`:753`) and the runtime adapters:
|
||||
`ClaudeCodeAdapter` (`:755`), `CodexRuntimeAdapter` (`:756`),
|
||||
`PiRuntimeAdapter` (`:759`), `OmpRuntimeAdapter` (`:760`) and
|
||||
`StubRuntimeAdapter` (`:765`); the terminal adapter is created inline in the
|
||||
runtime adapter map (`:945`).
|
||||
5. Constructs `PodRigInstantiator` (`:941`), `PodBundleSourceResolver` (`:970`)
|
||||
and `BootstrapOrchestrator` (`:972`).
|
||||
6. Constructs `ContextUsageStore` (`:1046`), `ResumeMetadataRefresher`
|
||||
(`:1077`), `SpecReviewService` (`:1102`) and `WhoamiService` (`:1105`).
|
||||
7. Constructs `SessionTransport` (`:1210`), `ChatRepository` (`:1244`),
|
||||
`InboxHandler` (`:1248`), `AskService` (`:1297`) and `SpecLibraryService`
|
||||
(`:1335`). `InboxHandler` receives the same queue repository instance
|
||||
(`queueRepoInstance`) that serves `/api/queue`, so absorbed inbox items and the
|
||||
queue route write to one store.
|
||||
8. Constructs `ContextMonitor` (`:2353`).
|
||||
9. Builds `AppDeps` and calls `createAppWithWebSocket(deps)` (`startup.ts:2448`)
|
||||
to mount the full route tree.
|
||||
|
||||
The daemon entrypoint `packages/daemon/src/index.ts:36` calls
|
||||
`createDaemon({ dbPath, bearerToken })`.
|
||||
Node inventory is a set of functions (`getNodeInventory` and friends in
|
||||
`domain/node-inventory.ts`), imported where needed rather than constructed at
|
||||
startup.
|
||||
|
||||
## 5. Test and verification state
|
||||
The daemon entrypoint `packages/daemon/src/index.ts:298` calls
|
||||
`createDaemon({ dbPath, bearerToken, terminalBearerToken, … })`.
|
||||
|
||||
> Drift-fix D7 / OPEN-3 (carried verbatim, slice-00): `architecture.md` L12,13
|
||||
> and §10 L1036–1046 assert daemon `2561/2561` / CLI `794/794` / total
|
||||
> `2422/2422` test pass counts and per-package file counts (`127`/`37`/`37`).
|
||||
> **These are runtime claims and are NOT re-run here** — a read-only static
|
||||
> trace counted `*.test.ts` files only: daemon **255**, cli **234**
|
||||
> (slice-00 §1.7; ui test-file count not separately gathered). Pass tallies
|
||||
> are marked `unverified-runtime-claim`; do not assert them as current. To
|
||||
> assert pass counts, run `pnpm/npm test` and re-verify.
|
||||
## 5. Test files
|
||||
|
||||
A static count of tracked test files at `b6d37bfadcc189be8fd61e35abfc09676437bf48` (no pass counts are claimed
|
||||
here; CI runs the suites in `.github/workflows/tests.yml`): daemon **821**,
|
||||
CLI **216**, web UI **198**, TUI **96**
|
||||
(`git ls-files 'packages/<package>/**/*.test.ts' 'packages/<package>/**/*.test.tsx' | wc -l`).
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — PL-004 Phase A stream/queue/inbox/outbox.
|
||||
- `ARCHITECTURE.md` (repository root) — what OpenRig is and where to add things.
|
||||
- `coordination-primitive.md` — the stream/queue/inbox/outbox coordination layer.
|
||||
- `agent-spec-and-startup.md` — spec parsing/resolution/startup contract.
|
||||
- `lifecycle-snapshot-restore.md` — snapshot/restore/continuity.
|
||||
- Source roots: `packages/daemon/src/{startup.ts,server.ts,index.ts}`,
|
||||
`packages/daemon/src/db/{all-migrations.ts,migrate.ts}`,
|
||||
`packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts`.
|
||||
@@ -6,12 +6,13 @@ topics: [continuity, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how OpenRig captures a snapshot, restores a rig (resume vs
|
||||
rebuild vs fresh), enforces restore honesty, consults live continuity state,
|
||||
or how the daemon-side restore-check / restore-packet readiness probes work.
|
||||
rebuild vs fresh-primed vs stop-and-ask), enforces restore honesty, consults
|
||||
live continuity state, or how the daemon-side restore-check readiness probe
|
||||
and the CLI restore-packet command work.
|
||||
siblings: [daemon-core.md, agent-spec-and-startup.md, transport-and-transcripts.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Lifecycle — Snapshot, Restore, Continuity
|
||||
@@ -21,194 +22,265 @@ The durable-state half of the core product loop:
|
||||
captures serialized rig state; restore replays it honestly (no silent
|
||||
fresh-fallback); restore-check is a separate read-only readiness probe.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§4 Execution and restore, §5
|
||||
> Snapshot/restore/continuity, §6 Snapshot/restore + Auto-snapshot, §7 rules)
|
||||
> per slice-08 §10.1 — line numbers advisory only. This module is a pure
|
||||
> split EXCEPT §6 (restore-check/restore-packet) which is the D15
|
||||
> author-from-source piece — flagged explicitly there.
|
||||
> Verified against source at main `254122872cf477511514979a4300b695d77cd1f7`. Each count below sits beside the
|
||||
> command that produces it; run the command from the repository root to refresh
|
||||
> it.
|
||||
|
||||
## 1. Snapshot / restore / continuity types
|
||||
|
||||
(`architecture.md` §4 "Execution and restore" — spec/projection members live
|
||||
in `agent-spec-and-startup.md`; the snapshot/restore members live here)
|
||||
All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
|
||||
`agent-spec-and-startup.md`; the snapshot/restore types live here.
|
||||
|
||||
- **NodeRestoreOutcome** — `"resumed" | "rebuilt" | "fresh" | "failed" |
|
||||
"n-a"` — the locked restore vocabulary. Re-confirmed at source:
|
||||
`restore-orchestrator.ts:725-726` sets `baseStatus = "resumed"` on a
|
||||
resumed harness; `:763` sets `"rebuilt"` when a checkpoint is replayed; the
|
||||
outcome union appears at `:944` (`{ kind: "resumed" }` … ).
|
||||
- **SnapshotData** — current serialized snapshot payload. Reboot extensions
|
||||
are optional for older-snapshot compatibility: `pods?`,
|
||||
`continuityStates?`, `nodeStartupContext?`.
|
||||
- **NodeStartupSnapshot** — persisted restore replay input:
|
||||
- **NodeRestoreOutcome** (`types.ts:501`) — the per-node restore outcome
|
||||
projected into `rig ps`. **9** values
|
||||
(`grep '^export type NodeRestoreOutcome' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
|
||||
`resumed`, `rebuilt`, `fresh`, `fresh-primed`, `awaiting-decision`,
|
||||
`failed`, `attention_required`, `operator_recovered`, `n-a`. The restore
|
||||
result itself, `RestoreNodeResult.status` (`types.ts:476`), has the same
|
||||
values without `n-a`: **8**
|
||||
(`grep -E '^ status: "resumed"' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`).
|
||||
Where `restore-orchestrator.ts` sets each one:
|
||||
- `resumed` — `baseStatus = "resumed"` after a legacy resume (`:1212`); the
|
||||
status is returned only by `finishJoinedResume` (`:1499`, `:1561`) after
|
||||
it has rebound and verified the pane, otherwise that becomes
|
||||
`attention_required` (`:1533`).
|
||||
- `rebuilt` — a checkpoint file was written (`:1272`).
|
||||
- `fresh-primed` — the default for a deliberate non-resume launch
|
||||
(`:1194`).
|
||||
- `fresh` — only the skip path for a pod node whose live continuity state
|
||||
is already `restoring` (`:992`).
|
||||
- `awaiting-decision` — the seat could not resume and nothing is running:
|
||||
before launch (`:1027`), or after launch once the blank session is rolled
|
||||
back (`:1208`, `:1230`, `:1243`, `:1259`, `:1417`).
|
||||
- `attention_required` — a live session waiting on a runtime prompt
|
||||
(`:1221`, `:1443`) or a harness that never started (`:1307`).
|
||||
- `failed` — occupant ambiguity (`:979`), launch failure (`:1118`), a
|
||||
checkpoint that cannot be delivered (`:1268`, `:1274`), a missing
|
||||
required startup file (`:1343`), or a startup error.
|
||||
- `operator_recovered` — only from later reconciliation,
|
||||
`reconcileNodeRuntimeTruth` (`:1759`, event at `:1902`).
|
||||
- **RestoreRigResult** (`types.ts:445`) — the rig-level rollup, **4** values
|
||||
(`grep '^export type RestoreRigResult' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
|
||||
`fully_restored`, `partially_restored`, `failed`, `not_attempted`
|
||||
(`rollupRestoreRigResult`, `restore-orchestrator.ts:82`).
|
||||
- **SnapshotData** (`types.ts:352`) — the serialized snapshot payload. It has
|
||||
**7** optional fields, kept optional so older snapshots still load
|
||||
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
|
||||
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `topologyRoster?`,
|
||||
`pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
|
||||
- **NodeStartupSnapshot** (`types.ts:345`) — persisted restore replay input:
|
||||
classification-free projection entries, resolved startup files, startup
|
||||
actions, runtime.
|
||||
- **PersistedProjectionEntry** — the classification-free restore replay seam:
|
||||
persists only entry identity + source metadata, NOT stale `classification`,
|
||||
`conflicts`, or `noOps` (Architecture Rule 10).
|
||||
- **PersistedProjectionEntry** (`types.ts:333`) — the classification-free
|
||||
restore replay seam: persists only entry identity + source metadata, NOT
|
||||
stale `classification`, `conflicts`, or `noOps` (Architecture Rule 10).
|
||||
Restore rebuilds each entry as `safe_projection` with empty `conflicts` and
|
||||
`noOps` (`restore-orchestrator.ts:1352`, `:1357`, `:1358`).
|
||||
|
||||
## 2. Snapshot, restore, continuity domain services
|
||||
|
||||
(`architecture.md` §5 "Snapshot, restore, and continuity")
|
||||
All under `packages/daemon/src/domain/`:
|
||||
|
||||
- `checkpoint-store.ts` — checkpoint persistence with pod/continuity context.
|
||||
- `snapshot-capture.ts` — captures pods, continuity state, startup replay
|
||||
context, and latest env receipt. Re-confirmed: `snapshot-capture.ts:66-77`
|
||||
pulls `pods`, `continuity_state`, and `node_startup_context` rows per rig.
|
||||
- `snapshot-repository.ts` — snapshot CRUD.
|
||||
context, the latest env receipt, the active-occupant maps and the intended
|
||||
topology roster. `captureSnapshot` (`:60`) derives the occupant maps
|
||||
(`:73–79`), resolves the roster (`:81–90`), then reads `pods` (`:96`),
|
||||
`continuity_state` (`:100`) and `node_startup_context` (`:106`) rows per rig
|
||||
and the env receipt (`:116–123`).
|
||||
- `snapshot-repository.ts` — snapshot CRUD and restore-source selection:
|
||||
`findLatestRestoreUsable` (`:72`) and `selectRestoreUsable` (`:94`), which
|
||||
accept only snapshots that pass `isRestoreUsableSnapshotData` (`:283`).
|
||||
- `restore-orchestrator.ts` — resume, checkpoint delivery, startup replay,
|
||||
live continuity consultation, topology ordering, and RigEnv boot gating
|
||||
before agent restore.
|
||||
live continuity consultation, topology ordering (`computeRestorePlan`,
|
||||
`:876`, ordering only `delegates_to` and `spawned_by` edges, `:72`), and
|
||||
service boot gating before agent restore (`serviceOrchestrator.boot`,
|
||||
`:328`).
|
||||
- `active-occupant.ts` — the shared rule for which snapshot session row is a
|
||||
node's occupant (`resolveActiveSnapshotSession`, `:74`).
|
||||
|
||||
## 3. Restore flow and restore honesty
|
||||
|
||||
(`architecture.md` §6 "Snapshot / restore" + §7 rules 7/14/15/16)
|
||||
Restore behavior, each point checked in `restore-orchestrator.ts`:
|
||||
|
||||
Restore behavior, each point re-confirmed at `restore-orchestrator.ts`:
|
||||
|
||||
- Reads the newest session by **monotonic ULID**, not timestamp alone
|
||||
(`:700` — "Find the NEWEST session for this node. ULIDs are monotonic, so
|
||||
latest = max id").
|
||||
- Resolves each node's occupant from the snapshot's recorded active-occupant
|
||||
maps, not by picking the newest session row
|
||||
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
|
||||
`restore-orchestrator.ts:974` and `:1171`). An ambiguous occupant fails the
|
||||
node loudly (`activeOccupantAmbiguityError`, `active-occupant.ts:97`).
|
||||
- Consults live `continuity_state`; preserves state when a node is already
|
||||
`restoring` (`:611-615` — `SELECT status FROM continuity_state …`; if
|
||||
`status === "restoring"` the node is skipped with a warning).
|
||||
`restoring` (`:985–998` — `SELECT status FROM continuity_state …` at
|
||||
`:987`; if `status === "restoring"` the node is skipped with a warning and
|
||||
reported as `fresh`, `:990–992`; `degraded` only adds a warning).
|
||||
- Replays restore-safe startup using persisted startup context;
|
||||
prefilters missing optional artifacts into warnings; **hard-fails a node if
|
||||
a required startup file is missing** (`:799` — status `"failed"`, error
|
||||
"Missing required startup files: …").
|
||||
- Writes a **transcript boundary marker before re-launch** (`:652` — "Write
|
||||
transcript boundary marker BEFORE launch (before pipe-pane attaches)").
|
||||
- Refuses to restore over live sessions (`:168` — `rig_not_stopped`: "Rig …
|
||||
has live sessions. Stop the rig with 'rig down' before restoring").
|
||||
- Uses `nativeResumeProbe` to honestly assess whether the harness actually
|
||||
resumed (`:857` continuity-outcome reconciliation).
|
||||
a required startup file is missing** (`:1343` — status `"failed"`, error
|
||||
"Missing required startup files: …"). Before any mutation,
|
||||
`validatePreRestore` (`:585`) already blocks the whole restore when a node
|
||||
that will consume replay is missing a required startup file
|
||||
(`required_startup_file_missing`, `:729`; outcome
|
||||
`pre_restore_validation_failed`, `:290`). A node that resumes its exact
|
||||
native session replays nothing (`replayContained`, `:1288`).
|
||||
- Writes a **transcript boundary marker before re-launch**
|
||||
(`writeBoundaryMarker`, `:1100`, called before `launchNode`).
|
||||
- Refuses to restore over live sessions (`:258–261` — `rig_not_stopped`: "Rig …
|
||||
has live sessions. Stop the rig with 'rig down' before restoring"). A tmux
|
||||
probe that fails also blocks it (`classifyRunningSessions`, `:812`).
|
||||
- Checks that the harness actually resumed. The resume adapters judge the
|
||||
pane with `assessNativeResumeProbe` (`domain/native-resume-probe.ts:84`). A
|
||||
pod-aware resume counts as resumed only when startup reports `resumed`
|
||||
continuity or the launched session row carries the snapshot's resume type
|
||||
and token (`launchedSessionMatchesSnapshotResume`,
|
||||
`restore-orchestrator.ts:1564`); a reported `fresh` continuity without that
|
||||
proof rolls back to `awaiting-decision` (`:1405–1419`).
|
||||
|
||||
**Restore-honesty rules (carried verbatim, `architecture.md` §7):**
|
||||
**Restore-honesty rules** (texts in `architecture-rules-and-event-system.md`;
|
||||
cited here by number with the code that carries them):
|
||||
|
||||
- Rule 7 — restore-policy narrowing is one-way only:
|
||||
`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`.
|
||||
- Rule 14 — resume states are locked: `resumed` / `rebuilt` / `fresh`.
|
||||
`rebuilt` = new process assembled from artifacts.
|
||||
- Rule 15 — failed resume is FAILED loudly. No automatic fresh fallback.
|
||||
Fresh launch is explicit follow-up only.
|
||||
- Rule 16 — post-command handoff required on `up`, `down`, `restore`,
|
||||
`snapshot create`: what happened + current state + next action.
|
||||
- Rule 7 — `RESTORE_POLICY_LEVEL`
|
||||
(`packages/daemon/src/domain/profile-resolver.ts:103`) orders the three
|
||||
policies, and `resolveRestorePolicy` (`profile-resolver.ts:488`) rejects a
|
||||
profile or member value that broadens it.
|
||||
- Rule 14 — the outcome set is `RestoreNodeResult.status` (`types.ts:476`,
|
||||
§1); `rebuilt` is set only when a checkpoint is written
|
||||
(`restore-orchestrator.ts:1272`).
|
||||
- Rule 15 — in `restore-orchestrator.ts`, a `resume_if_possible` seat with a
|
||||
session but no token stops as `awaiting-decision` before launch unless
|
||||
`--fresh` names it (`:1020–1029`); a failed resume kills the blank session
|
||||
(`rollbackToZeroSession`, `:1140`) and returns `awaiting-decision`
|
||||
(`:1230`); pod-aware resume passes
|
||||
`allowFreshFallback: !(isPodAware && resumeRequested)` (`:1399`). Fresh
|
||||
launch is explicit: `rig up --existing <rig> --fresh <seat...>`
|
||||
(`packages/cli/src/commands/up.ts:85`).
|
||||
- Rule 16 — `rig down` prints the snapshot id and a restore command
|
||||
(`packages/cli/src/commands/down.ts:236–246`); `rig up` on an existing rig
|
||||
prints per-node statuses and the attach command
|
||||
(`packages/cli/src/commands/up.ts:540–560`).
|
||||
|
||||
(The full 25-rule list lives in `architecture-rules-and-event-system.md`.)
|
||||
(The full rule list lives in `architecture-rules-and-event-system.md`.)
|
||||
|
||||
## 4. Auto-snapshot and existing-rig power-on
|
||||
|
||||
(`architecture.md` §6 "Auto-snapshot and existing-rig power-on")
|
||||
- `rig down <rig>` (name or id, `packages/cli/src/commands/down.ts:104`)
|
||||
auto-captures an `auto-pre-down` snapshot before teardown when the rig has
|
||||
live sessions (`packages/daemon/src/domain/rig-teardown.ts:116`; an
|
||||
already-stopped rig returns at `:89–109` without one).
|
||||
- `rig up <rig-name>`: a source with no `/` and no
|
||||
`.yaml`/`.yml`/`.rigbundle`/`.rigtopology` extension is a rig name
|
||||
(`packages/daemon/src/domain/up-command-router.ts:49–50`). The CLI first
|
||||
checks the spec library unless `--existing` is given, and refuses a name
|
||||
that matches both (`packages/cli/src/commands/up.ts:287–302`). The daemon
|
||||
finds the rig by name (`packages/daemon/src/routes/up.ts:222`) and
|
||||
restores from `selectRestoreUsable` (`routes/up.ts:105`), which prefers the
|
||||
newest `auto-pre-down` or `auto-periodic` snapshot, then the newest other
|
||||
usable one (`snapshot-repository.ts:75`).
|
||||
- If no usable snapshot exists, or the chosen one names an older occupant
|
||||
(`snapshotMatchesCurrentOccupants`, `routes/up.ts:109`), `rig up` captures
|
||||
an `auto-rehydrate` snapshot of current DB state when that state is
|
||||
eligible (`routes/up.ts:136–145`). Otherwise it errors with code
|
||||
`no_snapshot` and guidance ("… current DB state is insufficient for
|
||||
rehydrate. Start fresh with: rig up <spec-path>", `routes/up.ts:117–122`).
|
||||
- Post-command handoff: `down` output includes the snapshot ID and
|
||||
`To restore: rig up <name>` (or `rig restore <snapshotId> --rig <rigId>`
|
||||
when the name is not unique); `up` output includes node statuses and an
|
||||
`Attach:` command (anchors under Rule 16 above).
|
||||
|
||||
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before
|
||||
teardown.
|
||||
- `rig up <rig-name>` (no file extension) searches for an existing rig by
|
||||
name and restores from the latest `auto-pre-down` snapshot.
|
||||
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot
|
||||
from a spec or bundle path.").
|
||||
- Post-command handoff: `down` output includes snapshot ID + restore command;
|
||||
`up` output includes node statuses + attach command.
|
||||
|
||||
## 5. Daemon-side restore-check / restore-packet — AUTHORED FROM SOURCE (D15)
|
||||
|
||||
> **D15 author bit (flagged per slice-08 §4.7 binding):** `restore-check` and
|
||||
> `restore-packet` have NO `architecture.md` narrative. This section is the
|
||||
> one author-from-source piece in this otherwise pure-split module — authored
|
||||
> at the slice-00 forensic bar from `routes/restore-check.ts` +
|
||||
> `domain/restore-check-service.ts` + `commands/restore-packet.ts`, file:line
|
||||
> cited, version-attribution forensically checked.
|
||||
>
|
||||
> **Version attribution (forensic):** restore-check is NOT a 0.3.1 feature.
|
||||
> `routes/restore-check.ts` first-created `277e279c` ("feat: native rig
|
||||
> restore-check command", 2026-04-23, pre-0.3.0); present at both `v0.3.0`
|
||||
> and `v0.3.1` git trees (`git ls-tree` confirmed). `restore-packet`
|
||||
> first-created `23f2921e` ("Restore-Packet vertical M2a", 2026-05-01),
|
||||
> likewise present at v0.3.0 and v0.3.1. The gap is missing
|
||||
> architecture.md *prose*, not a version-attribution drift.
|
||||
## 5. Daemon-side restore-check and CLI restore-packet
|
||||
|
||||
### 5.1 `rig restore-check` — readiness probe
|
||||
|
||||
`GET /api/restore-check?rig=<name>&noQueue=<bool>&noHooks=<bool>`
|
||||
(`routes/restore-check.ts:131`). The route assembles a framework-free
|
||||
`RestoreCheckDeps` (`:138-168`) over existing daemon projections —
|
||||
`listRigs` from `rigRepo`, `getNodeInventory` (joining `node_id` by
|
||||
`logical_id`, `:21-26,143-149`), `getStartupContext` (reads
|
||||
`GET /api/restore-check?rig=<name>&noQueue=true&noHooks=true&compact=1&ready=1`
|
||||
(`routes/restore-check.ts:205–206`). The route calls
|
||||
`createRestoreCheckService` (`:149`), which assembles a framework-free
|
||||
`RestoreCheckDeps` (`:153–201`) over existing daemon projections —
|
||||
`listRigs` from `rigRepo` (`:172–175`), `getNodeInventory` (joining
|
||||
`node_id` by `logical_id`, `:24–29`, `:176–182`), `getStartupContext` (reads
|
||||
`node_startup_context`, parses `projection_entries_json` /
|
||||
`resolved_files_json` / `startup_actions_json`, `:44-128`), `hasSnapshot`
|
||||
/ `getLatestSnapshot` from `snapshotRepo`, and `probeDaemonHealth`
|
||||
(self-evident: "We're inside the daemon — if this route is responding,
|
||||
daemon is healthy", `:160-163`). It then runs
|
||||
`new RestoreCheckService(deps).check(...)` (`:170-171`).
|
||||
`resolved_files_json` / `startup_actions_json`, `:47–141`), `hasSnapshot`
|
||||
/ `getLatestSnapshot` from `snapshotRepo` (`:186–192`), `probeQueueStore`
|
||||
(`:155–167`), `getClaudeActivityHookEvents` (`:168–171`), and
|
||||
`probeDaemonHealth` (self-evident: "We're inside the daemon — if this route
|
||||
is responding, daemon is healthy", `:193–196`). It then runs
|
||||
`service.check(...)` (`:216–217`).
|
||||
|
||||
`RestoreCheckService.check()` (`restore-check-service.ts:244`) layers:
|
||||
`RestoreCheckService.check()` (`restore-check-service.ts:243`) layers:
|
||||
|
||||
1. **Host checks** — `checkDaemonReachable` (probe-throw → `verdict:
|
||||
unknown`, NOT `not_restorable`, `:252-259`), `checkStateDirWritable`
|
||||
(`:261`), `checkHostInfraDeclaration` (`:262`, impl `:406`).
|
||||
2. **Rig enumeration** — `listRigs()` throw → `buildUnknown` (`:267-274`);
|
||||
`--rig` filter; unknown rig → red `rig.<name>.exists` (`:276-284`).
|
||||
3. **Per-rig checks** — `checkSnapshot` (`:290`, impl `:675`),
|
||||
`checkSpecPresent` (`:295`).
|
||||
4. **Per-seat checks** — `checkSeatReadiness` (`:311`), `checkStartupContext`
|
||||
(`:315`, impl `:763`; `unknownChecks` → `buildUnknown`),
|
||||
`checkTranscript` (`:325`), `checkResumePath` (`:329`), and unless opted
|
||||
out: `checkQueueFile` (`:334`, gated by `--no-queue`, impl `:870`) and
|
||||
`checkHooks` (`:339`, gated by `--no-hooks`, impl `:896`).
|
||||
5. **Verdict** — `buildResult` aggregates: any red → `not_restorable`; any
|
||||
yellow → `restorable_with_caveats`; else `restorable`; probe-uninspectable
|
||||
→ `unknown` (`:1074-1085`, `:1362-1379`). Plus a `RecoveryPlan`
|
||||
(`buildRecovery`, `:1186`) and a `RepairStep[]` packet
|
||||
(`buildRepairPacket`, `:1392`; `null` when fully restorable).
|
||||
unknown`, NOT `not_restorable`, `:256–263`), `checkStateDirWritable`
|
||||
(`:265`), `checkHostInfraDeclaration` (`:266`, impl `:432`).
|
||||
2. **Rig enumeration** — `listRigs()` throw → `buildUnknown` (`:271–278`);
|
||||
`--rig` filter; unknown rig → red `rig.<name>.exists` (`:280–287`).
|
||||
3. **Per-rig checks** — `checkSnapshot` (`:294`, impl `:701`),
|
||||
`checkSpecPresent` (`:299`, impl `:1064`).
|
||||
4. **Per-seat checks** — `checkSeatReadiness` (`:315`), `checkStartupContext`
|
||||
(`:329`, impl `:789`; `unknownChecks` → `buildUnknown`, `:330–335`),
|
||||
`checkTranscript` (`:351`), `checkResumePath` (`:355`), and unless opted
|
||||
out: the daemon SQLite `queue_items` availability probe `checkQueueStore`
|
||||
(`:360`, gated by `--no-queue`; impl `:910`) and `checkHooks` (`:365`,
|
||||
gated by `--no-hooks`; impl `:933`). Queue continuity is represented by
|
||||
the shared daemon store; an empty queue is valid
|
||||
(`routes/restore-check.ts:159–160`). Claude hook readiness follows the
|
||||
persisted `claude_activity_hooks` runtime-resource selection
|
||||
(`restore-check-service.ts:957`) and the adapter's activity-relay
|
||||
projection in the seat CWD (`restore-check-service.ts:974–975`). A seat
|
||||
that deliberately omits that resource is not applicable. In compact mode
|
||||
without `ready=1`, a seat whose readiness check is green gets only the
|
||||
startup-context check (`restore-check-service.ts:327`, `:347–349`); that
|
||||
check still counts toward the verdict (`:337–340`).
|
||||
5. **Verdict** — `buildResult` (`:1106`) aggregates: any red →
|
||||
`not_restorable`; any yellow → `restorable_with_caveats`; else
|
||||
`restorable` (`:1121–1128`); probe-uninspectable → `unknown`
|
||||
(`buildUnknown`, `:1137`). Plus a `RecoveryPlan` (`buildRecovery`,
|
||||
`:1241`) and a `RepairStep[]` packet (`buildRepairPacket`, `:1475`;
|
||||
`null` when fully restorable, `:1476`).
|
||||
|
||||
The result shape is `RestoreCheckResult` (`restore-check-service.ts:120-130`):
|
||||
The result shape is `RestoreCheckResult` (`restore-check-service.ts:144–156`):
|
||||
`verdict`, `readiness`, `continuity`, `rigs[]`, `hostInfra`, `recovery`,
|
||||
`counts {red,yellow,green}`, `checks[]`, `repairPacket`.
|
||||
`counts {red,yellow,green}`, `classCounts`, `checks[]`, `repairPacket`. With
|
||||
`compact=1` the route returns a reduced body (`routes/restore-check.ts:219–245`).
|
||||
|
||||
**Honest-error design (slice-00 bar):** a daemon-probe *exception* produces
|
||||
**Honest-error design:** a daemon-probe *exception* produces
|
||||
`verdict: unknown` (uninspectable state), distinct from a daemon
|
||||
definitely-down state which is `red` / `not_restorable`
|
||||
(`restore-check-service.ts:249-259`). The route's catch-all returns the same
|
||||
`unknown`-shaped body with HTTP 500 + a `probe.error` red check
|
||||
(`routes/restore-check.ts:174-222`). `CheckEntry.remediationSafe` defaults to
|
||||
`false` (conservative — unclassified remediations are NOT
|
||||
auto-execution-safe, `restore-check-service.ts:18-24`).
|
||||
(`restore-check-service.ts:253–263`, `checkDaemonReachable` `:393–413`). The
|
||||
route's catch-all returns the same `unknown`-shaped body with HTTP 500 + a
|
||||
`probe.error` red check (`routes/restore-check.ts:249–297`).
|
||||
`CheckEntry.remediationSafe` defaults to `false` (conservative —
|
||||
unclassified remediations are NOT auto-execution-safe,
|
||||
`restore-check-service.ts:19–24`, applied at `:1490`).
|
||||
|
||||
CLI surface (`cli-reference.md` `### rig restore-check`):
|
||||
`rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`. Exit
|
||||
codes: `0` restorable (or with caveats), `1` not restorable (red), `2`
|
||||
unknown / probe error.
|
||||
CLI surface (`../cli-reference.md` `### rig restore-check`; options in
|
||||
`packages/cli/src/commands/restore-check.ts:222–227`):
|
||||
`rig restore-check [--rig <name>] [--full] [--ready] [--no-queue] [--no-hooks] [--json]`.
|
||||
Output is compact unless `--full` (`:313`). Exit codes: `0` restorable (or
|
||||
with caveats), `1` not restorable (red), `2` unknown / probe error
|
||||
(`:214–217`).
|
||||
|
||||
### 5.2 `rig restore-packet` — cross-runtime restore packet
|
||||
|
||||
CLI-side, no daemon route. `commands/restore-packet.ts` (528 lines)
|
||||
implements three subcommands (`cli-reference.md` `### rig restore-packet`):
|
||||
`write [options]` (generate a packet directory from a source session or JSONL
|
||||
file, with `omitted-records` accounting), `read <packet-dir> [--json]`
|
||||
(render contents; non-mutating), `validate <packet-dir> [--json]` (validate
|
||||
against the v0 schema; non-mutating). Packet shape is the cross-runtime v0
|
||||
standard — Claude Code and Codex transcripts both supported via runtime
|
||||
parsers + redaction.
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D15 (author-from-source, completed):** restore-check / restore-packet
|
||||
authored from source above; flagged as the single author bit in this
|
||||
module. Version attribution forensically resolved (both pre-0.3.0; the gap
|
||||
was missing prose, not drift).
|
||||
- No slice-00 numeric drift applies to this module's split content (the
|
||||
footprint/migration/route counts land in `daemon-core.md`).
|
||||
CLI-side, no restore-packet daemon route. `commands/restore-packet.ts`
|
||||
(**539** lines, `wc -l < packages/cli/src/commands/restore-packet.ts`)
|
||||
implements **3** subcommands
|
||||
(`grep -c 'cmd.command(' packages/cli/src/commands/restore-packet.ts`;
|
||||
`../cli-reference.md` `### rig restore-packet`): `write [options]` (`:216`;
|
||||
generate a packet directory from a source session or JSONL file, with
|
||||
`omitted-records` accounting; `--source-session` reads the transcript from the
|
||||
daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]`
|
||||
(`:371`; render contents; non-mutating), `validate <packet-dir> [--json]`
|
||||
(`:450`; validate against the v0 schema; non-mutating). Packet shape is the
|
||||
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
|
||||
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
|
||||
|
||||
## See also
|
||||
|
||||
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
|
||||
context that restore consumes.
|
||||
- `daemon-core.md` — `/api/restore-check` is one of the 49 route mounts
|
||||
(`server.ts:513`).
|
||||
- `daemon-core.md` — `/api/restore-check` is one of the **69** route mounts
|
||||
in `server.ts` (`grep -c 'app.route(' packages/daemon/src/server.ts`),
|
||||
mounted at `server.ts:836`.
|
||||
- `transport-and-transcripts.md` — transcript boundary markers written on
|
||||
restore.
|
||||
- Source roots: `packages/daemon/src/domain/{restore-orchestrator,
|
||||
snapshot-capture,snapshot-repository,checkpoint-store,
|
||||
restore-check-service}.ts`, `packages/daemon/src/routes/restore-check.ts`,
|
||||
`packages/cli/src/commands/restore-packet.ts`.
|
||||
snapshot-capture,snapshot-repository,checkpoint-store,active-occupant,
|
||||
restore-check-service}.ts`, `packages/daemon/src/routes/{restore-check,up}.ts`,
|
||||
`packages/cli/src/commands/{restore-packet,restore-check}.ts`.
|
||||
@@ -1,205 +1,175 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Living Notes Review Surface — the intent→plan→delivered projection
|
||||
title: Living Notes — Composed Review and Frozen Exports
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability, sdlc]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
topics: [observability, knowledge-and-context]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Working on or consuming the composed slice/mission review surface — the
|
||||
/api/review/* routes, the ComposedSliceReview contract, the on-disk SDLC
|
||||
convention it projects, staged-approval locks, proof artifacts and the C1
|
||||
header, the freeze export, media serving for review evidence, or the
|
||||
cross-host FLEET aggregate (/api/review/fleet, the fleet composer's
|
||||
union/one-count seam, v0.4.6 MH-5).
|
||||
siblings: [content-surfaces.md, workspace-primitive.md, mission-control.md]
|
||||
prerequisite-reads: [workspace-primitive.md]
|
||||
last-verified-against-source: c8341f72
|
||||
last-updated: 2026-07-08
|
||||
Tracing slice, mission, rig, or fleet review data back to scope documents,
|
||||
proof artifacts, judgments, queue state, or frozen exports.
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Living Notes review surface (v0.4.4)
|
||||
# Living Notes — Composed Review and Frozen Exports
|
||||
|
||||
The review surface is a **pure projection of on-disk markdown into one
|
||||
reviewable structure per slice**: INTENT → PLAN → DELIVERED. Agents change
|
||||
files; the daemon re-projects; nothing here writes product state except the
|
||||
two deliberate approval stamps and the freeze export. Shipped across the
|
||||
0.4.4 living-notes slices (19 signal layer, 20 composer/surfaces, 22 rig
|
||||
agents altitude, 23 SDLC operationalization) and the **corrective rebuild**
|
||||
(2026-07-05/06) that collapsed the surface to the one structure described
|
||||
here. Everything below is verified at `bb5ad219`.
|
||||
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
|
||||
it does not establish the version or behavior of a running daemon.
|
||||
|
||||
## 1. The ONE contract — `ComposedSliceReview`
|
||||
Living Notes builds review payloads from existing documents and recorded
|
||||
state. The composition layer does not make an agent judgment or contact an
|
||||
agent to obtain one. Its output distinguishes intent, plan, delivered proof,
|
||||
attention, agents, and source lineage.
|
||||
|
||||
`packages/daemon/src/domain/review/types.ts` defines the read contract every
|
||||
consumer shares (slice Review tab, mission U5 row expansion, For-You
|
||||
expansion — never a second endpoint per consumer):
|
||||
## Read and compose boundaries
|
||||
|
||||
- **identity + phase**: `slice`, `sliceId`, `title`, `missionId`, `phase`
|
||||
(five-way, derived read-time: `locked > review > building > spec > intent`,
|
||||
`derivePhase` in `compose.ts`), `laneLabel` (SS14 vocabulary INTENT / PLAN /
|
||||
BUILD / REVIEW / LOCKED).
|
||||
- **`intent`**: `{text, media[], ssotPath, degrade}` — the slice README's
|
||||
`## Intent` section verbatim (`extractSection`), plus any media it embeds.
|
||||
- **`plan`**: `{concise: {text, media[]}, lockedArtifacts[], lock, ssotPath}` —
|
||||
the PRD's pinned `## Mini-requirements` tier + planned mockups; the pinned
|
||||
artifact set is a **frontmatter READ** (`locked-artifacts:` list on the
|
||||
slice README — no new write machinery); `lock` is the spec-scope
|
||||
staged-approval stamp (§3).
|
||||
- **`delivered`**: `{items[], extraProof[], lock, proofDirPath}` — the
|
||||
redesigned join (§4): each `## Proof contract` deliverable paired with its
|
||||
curated proof media and QA's recorded comparison signal. `lock` is the
|
||||
delivery-scope stamp; `proofDirPath` is the drill-in door to the full
|
||||
fix-loop history.
|
||||
- **KEPT orthogonal bands**: `needsYou` (two sources, one queue: agent-routed
|
||||
items ∪ derived ▲ exceptions with inline evidence), `agents`
|
||||
(scope-parameterized `slice:<id> | mission:<id> | rig` — one contract, all
|
||||
scopes), `lineage` (`VerifyLineage`: candidate/merge/tip facts + the four
|
||||
gate `VerdictCell`s rendering recorded tokens verbatim), `defects`
|
||||
(out-of-slice media refs surfaced, never silently dropped), `composedAt`.
|
||||
[`ReviewGatherer`](../../../packages/daemon/src/domain/review/gather.ts)
|
||||
performs filesystem, database, telemetry, and optional Git reads.
|
||||
[`compose.ts`](../../../packages/daemon/src/domain/review/compose.ts) turns
|
||||
those inputs into the contracts defined in
|
||||
[`types.ts`](../../../packages/daemon/src/domain/review/types.ts), including
|
||||
`ComposedSliceReview`, `ComposedMissionReview`, and `ComposedRigAgents`.
|
||||
|
||||
**Deleted by the corrective (2026-07-05, founder-blocked original):** the
|
||||
four parallel renderable structures (`sections`, `acceptance`, `compare`,
|
||||
`join`) and the coequal `green` field. They do not exist in the types, the
|
||||
composer output, the UI hook, or the fixtures; the daemon route tests assert
|
||||
the keys are ABSENT on the wire (`test/review-composer.test.ts`,
|
||||
`test/review-routes.test.ts`). Recorded-verdict green survives in exactly two
|
||||
places: the per-deliverable `verified` signal (§4) and the mission ledger's
|
||||
completion fact (§6) — never a slice-review structure.
|
||||
`gatherSlice` uses `resolveNodeFileVia` to select the scope node document,
|
||||
reads `IMPLEMENTATION-PRD.md` and `PROOF.md`, and loads the `proof/` artifacts.
|
||||
It also gathers approval stamps, locked artifacts, tagged queue attention,
|
||||
agents, workflow exceptions, and `readSliceReadiness`. A current `SPEC.md`
|
||||
is carried distinctly from a legacy `README.md`; the composer does not
|
||||
blindly treat every file as an interchangeable source.
|
||||
|
||||
## 2. Composer + gatherer split
|
||||
[`readProofArtifacts`](../../../packages/daemon/src/domain/review/proof-io.ts)
|
||||
reads sorted top-level `.md` files in `proof/`, parses their headers with
|
||||
`parseC1Header`, and uses file mtime as `droppedAt`. Missing directories and
|
||||
unreadable entries yield no artifact for those entries. No claim that all
|
||||
possible evidence was discovered follows from that read.
|
||||
|
||||
- `compose.ts` is **PURE**: `(gathered inputs) → composed doc`; identical
|
||||
inputs (including caller-supplied `nowIso`/git facts) produce byte-identical
|
||||
output — idempotence holds by construction and is pinned by tests.
|
||||
- `gather.ts` (`ReviewGatherer`) is the impure shell: reads slice docs +
|
||||
`proof/*.md` artifacts from disk, attention/agent rows from SQLite,
|
||||
approval stamps from frontmatter (cross-checked against the audit log, §3),
|
||||
git lineage facts from the workspace default repo. Every unreadable source
|
||||
degrades honestly (nulls / "unknown") — the composer renders the named
|
||||
degrade, never invented content.
|
||||
`composeMission` batches slice-index membership reads, gathers each slice,
|
||||
and composes a mission board, completion ledger, attention union, agents,
|
||||
and brief spine. `composeRig` reads queue/activity state and reports current
|
||||
holders, recent holders, overdue work, settled work, and workflow exceptions.
|
||||
|
||||
## 3. The two locks = the shipped staged-approval stamps
|
||||
## Slice source selection and phase
|
||||
|
||||
`plan.lock` and `delivered.lock` are projections of the **staged-approval
|
||||
verb** (`rig scope slice|mission approve --scope spec|delivery` — see
|
||||
cli-reference "SDLC control plane verbs"): frontmatter stamps
|
||||
(`approved-spec-by/-at`, `approved-by/-at`) written daemon-side together with
|
||||
an append-only `mission_control_actions` audit row (no half-stamp). The
|
||||
gatherer cross-checks each stamp against the pinned scope-approval
|
||||
`audit_notes_json` shape (`approval_scope` + scope identity); a stamp with no
|
||||
matching row projects `auditVerified: false` and renders as an UNVERIFIED
|
||||
stamp — visible, never a block. Approval is freeze/sign-off, **never**
|
||||
proven-green (BR-6).
|
||||
In [`composeSliceReview`](../../../packages/daemon/src/domain/review/compose.ts),
|
||||
intent comes from the node document's `Intent` section. Placeholder-only
|
||||
intent is treated as absent. Mini-requirements use current `SPEC.md` when
|
||||
present; legacy selection can fall back from a pristine scaffold PRD section
|
||||
to authored README material. Proof-contract selection delegates to
|
||||
`selectProofContractBody` through `extractProofContractSelected`.
|
||||
|
||||
## 4. DELIVERED — promised ↔ curated proof ↔ verified
|
||||
The phase is derived by `derivePhase`, in this precedence order:
|
||||
|
||||
`composeDelivered` joins the PRD's `## Proof contract` checkbox items
|
||||
(`extractProofContract`; an optional markdown image on the checkbox line
|
||||
becomes the deliverable's `plannedRef` mockup) to the C1-headed proof
|
||||
artifacts in `<slice>/proof/`:
|
||||
| Phase | Signal |
|
||||
|---|---|
|
||||
| `locked` | Delivery approval stamp. |
|
||||
| `review` | Recorded verdict or claimed proof pass. |
|
||||
| `building` | Real proof artifacts, or active queue work paired with an authored/locked spec. |
|
||||
| `spec` | Authored structure or a spec approval stamp. |
|
||||
| `intent` | None of the above. |
|
||||
|
||||
- An artifact **covers** a deliverable when its C1 `evidences:` list names the
|
||||
item (exact text or 1-based index).
|
||||
- `verified` binds to the shipped C1 fields, never mere presence:
|
||||
**verified** = a covering `qa|adjudication` artifact records the comparison
|
||||
(`self_check`) AND its recorded verdict is passing; **unverified** = some
|
||||
covering artifact without a passing recorded QA comparison (QA's
|
||||
why-kicked-back `note` still surfaces); **missing** = promised, nothing
|
||||
delivered. All three are render states — fail-open by construction.
|
||||
- The curated proof set is the covering artifacts' body media refs
|
||||
(`ProofArtifact.mediaRefs`, populated by `rig proof add --media` or markdown
|
||||
refs in the artifact body), normalized slice-relative; refs escaping the
|
||||
slice dir become `defects` findings and are never served or inlined.
|
||||
Unmapped-artifact media renders bounded as `extraProof`.
|
||||
- The ▲ insufficient-proof NEEDS-YOU exception fires from the delivered
|
||||
MISSING count (`deriveExceptions`).
|
||||
File presence alone is insufficient: a scaffolded `PROOF.md` is not build
|
||||
evidence, and a tracking queue item alone does not promote an unplanned
|
||||
slice. Phase and proof readiness are separate output fields.
|
||||
|
||||
## 5. Routes, freeze, media serving
|
||||
## Delivered items and current judgments
|
||||
|
||||
- `packages/daemon/src/routes/review.ts`, mounted at `/api/review`
|
||||
(`server.ts` route mounts): `GET /slice/:name`, `GET /mission/:name`,
|
||||
`GET /rig` (the OPR.0.4.4.22 rig-agents altitude root), `GET
|
||||
/agents?scope=slice:<id>|mission:<id>|rig`, `POST /freeze`.
|
||||
- **Freeze** (`freeze.ts`): the ONE synchronous compose-and-freeze path,
|
||||
invoked after the delivery stamp + audit row commit. Renders the composed
|
||||
review to a single self-contained HTML file (`REVIEW-<id>-<date>.html` in
|
||||
the slice dir): CSS inline, images data-URI-inlined under slice-dir
|
||||
containment (resolve-prefix + realpath — a traversal/symlink ref renders
|
||||
the muted outside-slice branch, never inlined), video by link + poster.
|
||||
Exclusive-create through the allowlist-governed atomic write service;
|
||||
re-invocation is an idempotent no-op; a failed render never un-approves.
|
||||
Legacy missions carrying `MISSION_BRIEF.md` may still receive the
|
||||
section-scoped brief-spine fold; new scaffolds do not create that retired
|
||||
web-UI-era file.
|
||||
- **Media serving** for review evidence is ranged on BOTH asset route
|
||||
families (iOS-Safari-class players require `206`): `GET /api/files/asset`
|
||||
(`routes/files.ts` — single-range parse, `206`/`416`, `Accept-Ranges:
|
||||
bytes`; `.html` renders as text/html only under the explicit `?render=1`
|
||||
opt-in) and `GET /api/slices/:name/proof-asset/*` (`routes/slices.ts` —
|
||||
same range semantics via `fileAssetResponse`, added by the corrective QA
|
||||
fixback; immutable-cache posture retained).
|
||||
`extractProofContract` reads logical checkbox items, retaining authored raw
|
||||
text for identity. `proofItemIdentity` uses an explicit `proof-item` marker
|
||||
when supplied, otherwise an ID derived from the item's text. In the legacy
|
||||
artifact join, `evidences` references match a promised item's exact display
|
||||
text or its one-based index.
|
||||
|
||||
## 6. Mission + rig altitudes
|
||||
`composeDelivered` pairs each promise with curated media and a verification
|
||||
state:
|
||||
|
||||
`composeMissionReview` consumes `{review, green}` entries per slice: the
|
||||
board (stage-specific cells re-bound to the collapsed contract — spec-stamp
|
||||
state, delivered n/m, GREEN·merge pair, stamp), the completion **ledger**
|
||||
(a query over the slice set, never an authored list; `green` here is the
|
||||
recorded-verdict fact from `computeRecordedGreen` — regime 1 = four passing
|
||||
gate verdicts, regime 2 = passing adjudication), `cutComplete` (TRUE only
|
||||
when every in-cut slice is green AND merged AND has zero open needs-human
|
||||
items), and the union NEEDS-YOU (distinct identities — one item seen from N
|
||||
altitudes is one item). `composeRigAgents` serves the standalone rig
|
||||
altitude (roster ∪ recently-holding, park/health/settled from the queue
|
||||
transitions log).
|
||||
- With configured [proof readiness](../../../packages/daemon/src/domain/proof/judgments.ts),
|
||||
the matching current item must be `accepted`, and readiness must have no
|
||||
issues, to render `verified`. The note reports the current judgment state
|
||||
or why it is unavailable.
|
||||
- Without configured readiness, a winning covering `qa` or `adjudication`
|
||||
artifact with a non-null `self_check` and a passing verdict supplies legacy
|
||||
verification. The note explicitly labels it **item revision unbound**.
|
||||
- Other covering artifacts are `unverified`; no covering evidence/judgment
|
||||
is `missing`. Unmapped artifact media appears separately as `extraProof`.
|
||||
|
||||
## 7. The FLEET altitude — the cross-host aggregate SIBLING (v0.4.6, OPR.0.4.6.MH5)
|
||||
This distinction matters after a proof contract changes: a legacy PASS
|
||||
header is not automatically a current revision-bound judgment. Approval
|
||||
stamps are also not a substitute for the delivered-item join.
|
||||
|
||||
`GET /api/review/fleet` aggregates every registered host's composed ▲/● set
|
||||
into one manage-by-exception glance. It is a **sibling aggregate endpoint**,
|
||||
never a fourth `AgentsScope` value (arch Q2): the scope grammar stays
|
||||
exactly `slice:* | mission:* | rig`, and the local composer stays a pure
|
||||
function over local state. `domain/review/fleet-compose.ts` is the ONE
|
||||
review-domain module allowed to import hosts transport/registry — a
|
||||
boundary enforced mechanically by the `review-import-audit` static test
|
||||
(the zero-I/O `fanout-contract` types module is the sole allowlisted
|
||||
hosts import elsewhere).
|
||||
## Approval, attention, and lineage
|
||||
|
||||
**Fan-out-the-composed-set (arch Q1):** the ▲ kinds are time-derived on
|
||||
their own host's clock, so the fleet root fans out each host's
|
||||
ALREADY-COMPOSED rig root (`GET /api/review/rig` over `remoteJsonRequest`,
|
||||
concurrent under the named read-class deadline; the local host joins
|
||||
in-process via the same gatherer — zero self-transport) and only **unions +
|
||||
host-dimensions + counts**. It never recomputes exception truth (the
|
||||
composer is clock-free by test pin), so cross-host clock skew can never
|
||||
distort a ▲. Per-host failures degrade to the closed `PerHostStatus`
|
||||
honesty contract; a failed host's counts are ABSENT from its
|
||||
`FleetHostRollup` — absent-not-zero, in the type.
|
||||
`ReviewGatherer.gatherApproval` reads `approved-spec-by`/`approved-spec-at`
|
||||
and `approved-by`/`approved-at`, alongside matching `mission_control_actions`
|
||||
approval audit records. The resulting locks preserve who/when/audit facts.
|
||||
The approval writer is the separate
|
||||
[`scope-approve` route](../../../packages/daemon/src/routes/scope-approve.ts).
|
||||
|
||||
**One-count across the fleet (arch Q4):** the dedup `Set` lives in the
|
||||
fleet composer — the one place — keyed `hostId|identity` (rendered verbatim
|
||||
on the expanded drawer); within-host multi-altitude visibility collapses to
|
||||
one row with `seenFrom` provenance; an MH-3-forwarded qitem lives only in
|
||||
its origin host's DB, so no double-count by construction.
|
||||
`composeNeedsYou` combines recorded queue attention with derived exceptions;
|
||||
`deriveWorkflowExceptions` adds workflow-specific observations. Mission
|
||||
composition deduplicates attention identities across slice and mission views.
|
||||
Media references outside the slice's relative-path contract produce defect
|
||||
entries instead of disappearing silently.
|
||||
|
||||
**Composition pins the seam (arch Q3, the rule of thumb):** sidedness and
|
||||
caller pick the TRANSPORT seam (MH-4's CLI-direct verbs); when COMPOSITION
|
||||
exists — the fleet union/one-count is a correctness rule — the seam pins
|
||||
daemon-side: ONE composer, one endpoint, every consumer (the `/fleet` route
|
||||
page and the FLEET band today via the shared `useFleet` hook; the TUI/CLI
|
||||
fleet consumers as named follow-ups) reads the same one. UI cadence is the
|
||||
bounded named `FLEET_POLL_INTERVAL_MS` with the feed-cadence-class floor,
|
||||
and the ambient band gates its FETCH on fleet existence (the FS-1 amplifier
|
||||
discipline; enable-at-scale remains the FS-1 release-validation gate). Read
|
||||
+ surface only: acting on a remote host's item rides MH-3/MH-4.
|
||||
Git facts are optional. [`startup.ts`](../../../packages/daemon/src/startup.ts)
|
||||
passes `OPENRIG_REVIEW_GIT_REPO` to the gatherer. The gatherer reads that
|
||||
repository's `HEAD`, searches merge messages keyed by scope ID, and computes
|
||||
ancestry/behind facts when possible. Unavailable facts remain unknown; this
|
||||
is not a fresh remote GitHub merge-status query.
|
||||
|
||||
## 8. The on-disk convention it projects (SDLC control plane)
|
||||
`composeMissionReview` derives `cutComplete` from a nonempty ledger in which
|
||||
every slice is green, has a merge SHA, and has no needs-human items. Configured
|
||||
slice readiness supplies the green predicate; otherwise recorded verdicts
|
||||
do. That computed field is not itself a publication or higher-level outcome
|
||||
decision.
|
||||
|
||||
The surface projects the markdown-control-plane shapes operationalized by
|
||||
OPR.0.4.4.23 — conventions SSOT `docs/reference/sdlc-conventions.md`:
|
||||
`## Intent` (README) / `## Mini-requirements` + `## Proof contract` (PRD) /
|
||||
`proof/` C1-headed artifacts + `PROOF.md`, scaffolded for every template kind
|
||||
by `rig scope slice create` and checked advisory-only by `rig scope audit`
|
||||
(+ the `rig doctor` SDLC row). The UI half of this surface (the slice Review
|
||||
tab, mission board, For-You approve+chat) is documented in
|
||||
[`../ui/project-and-for-you.md`](../ui/project-and-for-you.md).
|
||||
## HTTP routes
|
||||
|
||||
[`reviewRoutes`](../../../packages/daemon/src/routes/review.ts), mounted at
|
||||
`/api/review` by [`server.ts`](../../../packages/daemon/src/server.ts):
|
||||
|
||||
| Method and suffix | Payload / behavior |
|
||||
|---|---|
|
||||
| `GET /slice/:name` | Composed slice plus proof-source observation. |
|
||||
| `GET /mission/:name` | Composed mission plus proof-source observation. |
|
||||
| `GET /agents?scope=…` | Agents band; scope is `slice:<id>`, `mission:<id>`, or `rig`. |
|
||||
| `GET /rig` | Local rig review root. |
|
||||
| `GET /fleet` | Local and registered remote host review union. |
|
||||
| `POST /freeze` | Compose and export an approved slice; body requires `scope: slice` and `name`. |
|
||||
|
||||
Missing gatherer returns `503`; invalid agents scope returns `400`; unknown
|
||||
slice/mission returns `404`. Fleet is a sibling aggregate, not another value
|
||||
of the agents-scope parameter.
|
||||
|
||||
## Freeze and media
|
||||
|
||||
[`freezeSliceExport`](../../../packages/daemon/src/domain/review/freeze.ts)
|
||||
requires a delivery lock and an allowlisted slice directory. It writes
|
||||
`REVIEW-<scope-id-or-name>-<approval-date>.html` through
|
||||
`FileWriteService.createAtomic`. An existing target is treated as already
|
||||
frozen; it is not rewritten. A failed export does not undo approval.
|
||||
|
||||
The export embeds contained images as data URIs and links videos with an
|
||||
optional poster. `containedMediaPath` checks both resolved paths and existing
|
||||
symlink targets against the slice directory. Unavailable/outside media gets
|
||||
an explicit rendered message.
|
||||
|
||||
After a new freeze, the route may update the generated spine in a conforming
|
||||
`MISSION_BRIEF.md` through the conflict-checked file writer. A malformed brief
|
||||
is skipped, and brief-write failures are reported without undoing the export.
|
||||
The [`slices` routes](../../../packages/daemon/src/routes/slices.ts) separately
|
||||
serve proof assets; [`files` asset serving](content-surfaces.md) has its own
|
||||
allowlist and range behavior.
|
||||
|
||||
## Fleet composition
|
||||
|
||||
[`composeFleet`](../../../packages/daemon/src/domain/review/fleet-compose.ts)
|
||||
composes local data in-process and reads registered hosts' composed rig
|
||||
payloads. Its default fan-out concurrency is **4**, with a shared
|
||||
**4-second** read budget. Synchronous local work cannot be preempted by that
|
||||
timer; elapsed local work reduces the remaining remote budget.
|
||||
|
||||
`unionFleet` keys attention by host plus item identity and retains host
|
||||
status. Unavailable hosts have absent counts, not synthetic zeros. A missing
|
||||
registry produces a local-only result; an unreadable existing registry is
|
||||
reported. Host-derived time/status facts are carried through, not recomputed
|
||||
as if every host shared one clock.
|
||||
@@ -1,164 +1,221 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
title: Mission Control — Queue Observability + Verb Contract
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
|
||||
applies-when: |
|
||||
Need to know how the daemon-backed Mission Control surface works — the
|
||||
seven views, the seven write verbs, the action audit table, the
|
||||
bearer-token middleware, or how queue observability maps to PL-004 sources.
|
||||
seven views, the eight write verbs, the action audit table, the
|
||||
bearer-token middleware, or how queue observability maps to the
|
||||
coordination primitive's sources.
|
||||
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, coordination-primitive.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
# Mission Control — Queue Observability + Verb Contract
|
||||
|
||||
Mission Control (PL-005) is the daemon-backed queue-observability surface: an
|
||||
integrated product UI inside the existing shell (a top-level `/mission-control`
|
||||
route, NOT a new managed app) over the PL-004 Phase A coordination primitive.
|
||||
Per the PRD acceptance criteria: seven views, seven verbs, first-class human
|
||||
seats, recent-ships=10, and a daemon-backed action audit table; no
|
||||
old-dashboard migration/cutover (`architecture.md` §3 L368, L390).
|
||||
Mission Control is the daemon's queue-observability and operator-action
|
||||
surface, mounted at `/api/mission-control` over the coordination primitive's
|
||||
`queue_items` and `stream_items` (see `coordination-primitive.md`). It has
|
||||
seven views, eight verbs, a recent-ships cap of 10
|
||||
(`mission-control-read-layer.ts:107`), and a daemon-backed action audit
|
||||
table. In the web UI the old `/mission-control` page now redirects to
|
||||
`/for-you`, and the For You feed carries the verb actions (§6).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`. Mission Control is PL-005 (an
|
||||
> early-0.3.0 graft); plugins and Claude auto-compaction are 0.3.1 features
|
||||
> and are NOT back-attributed here (slice-00 0.3.0-ground-truth seams a/row-7).
|
||||
> Verified against source at main `254122872cf477511514979a4300b695d77cd1f7`. A bare file name such as
|
||||
> `mission-control-read-layer.ts:31` is in
|
||||
> `packages/daemon/src/domain/mission-control/`; `domain/…`, `routes/…`,
|
||||
> `middleware/…`, `db/…`, `index.ts`, `server.ts` and `startup.ts` are under
|
||||
> `packages/daemon/src/`. Each count sits beside the command that produces
|
||||
> it; run the command from the repository root to refresh it.
|
||||
|
||||
## 1. The seven views
|
||||
|
||||
`MISSION_CONTROL_VIEWS` is the canonical view list (re-confirmed at HEAD
|
||||
`packages/daemon/src/domain/mission-control/mission-control-read-layer.ts:32–42`):
|
||||
`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`,
|
||||
`recently-active`, `recent-observations`.
|
||||
`MISSION_CONTROL_VIEWS` is the canonical view list
|
||||
(`mission-control-read-layer.ts:31–39`): `my-queue`, `human-gate`, `fleet`,
|
||||
`active-work`, `recent-ships`, `recently-active`, `recent-observations`.
|
||||
|
||||
All seven return rows in the load-bearing 9-field phone-friendly content
|
||||
model (rig/mission name, current phase,
|
||||
active|idle|attention|blocked|degraded, next-action,
|
||||
Views: **7** — `sed -n '/^export const MISSION_CONTROL_VIEWS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-read-layer.ts | grep -c '^ "'`
|
||||
|
||||
All seven return rows in the 9-field phone-friendly content model,
|
||||
`CompactStatusRow` (`mission-control-read-layer.ts:60`): rig/mission name,
|
||||
current phase, active|idle|attention|blocked|degraded, next-action,
|
||||
pending-human-decision, read-cost, last-update timestamp,
|
||||
confidence/freshness, evidence link). The model is non-negotiable across all
|
||||
7 views; UI may render compact, JSON preserves all 9 (`architecture.md` §3
|
||||
L370).
|
||||
confidence/freshness, evidence link. The type's doc comment
|
||||
(`mission-control-read-layer.ts:43–59`) makes the model non-negotiable across
|
||||
all seven views: UI may render compact, JSON preserves all nine. Four further
|
||||
optional fields carry the queue item's id and context.
|
||||
|
||||
`MissionControlReadLayer` maps each view to its source-of-truth path
|
||||
(`mission-control-read-layer.ts:4–14`, re-confirmed at HEAD):
|
||||
Required row fields: **9** — `sed -n '/^export interface CompactStatusRow {/,/^}/p' packages/daemon/src/domain/mission-control/mission-control-read-layer.ts | grep -c -E '^ [a-zA-Z]+:'`
|
||||
|
||||
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004
|
||||
Phase A `queue_items` via `QueueRepository`.
|
||||
- `fleet` consumes the per-rig CLI capability cache + queue summary.
|
||||
- `recently-active` delegates to PL-004 Phase B
|
||||
`ViewProjector.show("recently-active")`.
|
||||
- `recent-observations` reads PL-004 Phase A `stream_items` via
|
||||
`StreamStore`.
|
||||
`MissionControlReadLayer.readView` (`mission-control-read-layer.ts:143`) maps
|
||||
each view to its source:
|
||||
|
||||
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep)
|
||||
are graceful-degradation aids, NOT the primary path (`architecture.md` §3
|
||||
L378).
|
||||
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query
|
||||
`queue_items` via `QueueRepository`.
|
||||
- `fleet` calls `MissionControlFleetCliCapability.rollupFleet()`
|
||||
(`mission-control-read-layer.ts:197`;
|
||||
`mission-control-fleet-cli-capability.ts:109`), which walks the rig
|
||||
registry (`:110`), summarises each rig's `queue_items` (`:135`), and runs a
|
||||
per-rig CLI capability probe (`makeLocalCliCapabilityProbe`, `:77`, wired
|
||||
at `startup.ts:1547`).
|
||||
- `recently-active` delegates to the view projector's built-in
|
||||
`ViewProjector.show("recently-active")` (`mission-control-read-layer.ts:246`).
|
||||
- `recent-observations` reads the latest 50 `stream_items` via `StreamStore`
|
||||
with `direction: "latest"` (`mission-control-read-layer.ts:265–268`).
|
||||
|
||||
> Scope note (slice-00 0.3.0-ground-truth OPEN-3, carried): the exact
|
||||
> For-You verb subset is an unresolved velocity slice-01 ruling. This module
|
||||
> describes the **system-level** 7-verb vocabulary (proven, slice-00
|
||||
> §1.6/seam-c). It does NOT enumerate a For-You-surface subset — that
|
||||
> surface is described in `../ui/project-and-for-you.md` when authored.
|
||||
There is no filesystem fallback. When no `StreamStore` is wired,
|
||||
`recent-observations` returns no rows with
|
||||
`sourceFallback: "stream-store-not-wired"`
|
||||
(`mission-control-read-layer.ts:258–262`), and `fleet` always reports
|
||||
`sourceFallback: "daemon-internal-projection"`
|
||||
(`mission-control-fleet-cli-capability.ts:156`).
|
||||
|
||||
## 2. The seven verbs (the write contract)
|
||||
> Scope note: this module describes the daemon's verb vocabulary. It does
|
||||
> NOT enumerate which verbs the For You cards offer; that surface is
|
||||
> described in `../ui/project-and-for-you.md`.
|
||||
|
||||
The seven verbs execute through the load-bearing
|
||||
`MissionControlWriteContract` (re-confirmed at HEAD
|
||||
`packages/daemon/src/domain/mission-control/mission-control-write-contract.ts:27–36`):
|
||||
## 2. The eight verbs (the write contract)
|
||||
|
||||
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–26`) is the verb
|
||||
list. The verbs execute through `MissionControlWriteContract.act`
|
||||
(`mission-control-write-contract.ts:131`); the closure mapping is
|
||||
`verbToClosure` (`:509`).
|
||||
|
||||
Verbs: **8** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
|
||||
|
||||
| Verb | Effect |
|
||||
|---|---|
|
||||
| `approve` | `state="done"`, `closure_reason="no-follow-on"` |
|
||||
| `deny` | `state="done"`, `closure_reason="denied"` |
|
||||
| `route` | `state="done"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates new qitem at the route target (1-hop) |
|
||||
| `route` | `state="handed-off"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates a new queue item at the route target (1-hop) |
|
||||
| `annotate` | no queue mutation; audit record only |
|
||||
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
|
||||
| `drop` | `state="done"`, `closure_reason="canceled"` |
|
||||
| `handoff` | the 4-step shape (see below) |
|
||||
| `handoff` | same closure and new destination item as `route`; the 4-step shape (see below) |
|
||||
| `resolve` | only for a `blocked` item parked on a human seat: moves it to `in-progress` with the required decision text as the transition note; no closure and no new item (`:369`, `:386`, `:410–411`) |
|
||||
|
||||
Each verb is one atomic daemon transaction: queue mutation via
|
||||
`QueueRepository.updateWithinTransaction()` (which preserves Phase A
|
||||
hot-potato closure validation — see `coordination-primitive.md` §3) + an
|
||||
audit row in `mission_control_actions` + a persisted
|
||||
`mission_control.action_executed` event, all in one `db.transaction`. The
|
||||
4-step `handoff` shape (source-update + destination-create + opt-in
|
||||
best-effort notify + audit-record append) is verified atomic; notify failure
|
||||
does NOT roll back durable mutations (PRD invariant). Failure-injection rolls
|
||||
back source closure + audit row + new qitem together (`architecture.md` §3
|
||||
L380; `mission-control-write-contract.ts:5,15`).
|
||||
Each verb is one atomic daemon transaction: the queue mutation via
|
||||
`QueueRepository.updateWithinTransaction()` (`domain/queue-repository.ts:2337`,
|
||||
which keeps the hot-potato closure validation — see
|
||||
`coordination-primitive.md` §3), an audit row in `mission_control_actions`
|
||||
(`mission-control-write-contract.ts:207`), and a persisted
|
||||
`mission_control.action_executed` event (`:225`), all in one
|
||||
`db.transaction`. For every verb except `annotate`, that transaction is
|
||||
opened by `EventBus.withNotifyEnvelope` (`:157`; `domain/event-bus.ts:112`,
|
||||
`:126`); `annotate` opens `db.transaction` itself
|
||||
(`mission-control-write-contract.ts:308`).
|
||||
|
||||
The 4-step `handoff` shape (source-update + destination-create + best-effort
|
||||
notify + audit-record append) is shared by `route`. The destination item is
|
||||
created in the same transaction (`createWithinTransaction`, `:173`), along
|
||||
with the successor's wake intent (`stageWakeIntent`, `:195`, checked by
|
||||
`assertTerminalClosureHasIntent`, `:242`). The wake itself runs after commit
|
||||
(`deliverWakeForSuccessor`, `:264`). It is on unless the caller passes
|
||||
`notify: false` (`domain/queue-repository.ts:1174`), and a notify failure
|
||||
does NOT roll back durable mutations (`mission-control-write-contract.ts:272–275`).
|
||||
A failed destination create rolls back the source closure, the audit row and
|
||||
the new item together
|
||||
(`packages/daemon/test/mission-control-write-contract.test.ts:220`).
|
||||
|
||||
## 3. The action audit table
|
||||
|
||||
`mission_control_actions` (`037_mission_control_actions.ts:54` `CREATE TABLE
|
||||
IF NOT EXISTS mission_control_actions`) is append-only at the API surface:
|
||||
it records every operator action through Mission Control with before/after
|
||||
qitem snapshots for forensic reconstruction. Columns include `action_verb`
|
||||
(TEXT, app-layer enum enforcement, `:56`) and `acted_at` (TEXT NOT NULL ISO
|
||||
timestamp); indexes `(acted_at DESC, action_verb)`,
|
||||
`(qitem_id, acted_at DESC)`, `(actor_session, acted_at DESC)`
|
||||
(`037_mission_control_actions.ts:28–44`). Phase B added NO migrations — this
|
||||
Phase A table is the only data source (`architecture.md` §3 L364, L392).
|
||||
`mission_control_actions` (`db/migrations/037_mission_control_actions.ts:58`
|
||||
`CREATE TABLE IF NOT EXISTS mission_control_actions`) is append-only at the
|
||||
API surface: `record()` is the only writer `MissionControlActionLog` exposes
|
||||
(`mission-control-action-log.ts:113`). It records every operator action
|
||||
through Mission Control with before/after queue item snapshots for forensic
|
||||
reconstruction. Columns include `action_verb` (TEXT, app-layer enum
|
||||
enforcement, `037_mission_control_actions.ts:60`) and `acted_at` (TEXT NOT
|
||||
NULL ISO timestamp, `037_mission_control_actions.ts:63`);
|
||||
indexes `(acted_at DESC, action_verb)`, `(qitem_id, acted_at DESC)`,
|
||||
`(actor_session, acted_at DESC)` (`037_mission_control_actions.ts:72–77`).
|
||||
One later migration adds a nullable `identity_provenance` column
|
||||
(`db/migrations/065_identity_provenance.ts:18`). The audit browse (§4) reads
|
||||
only this table.
|
||||
|
||||
## 4. PL-005 Phase B — bearer middleware, notifications, audit browse
|
||||
## 4. Bearer middleware, notifications, audit browse
|
||||
|
||||
Phase B extends the daemon Mission Control surface:
|
||||
|
||||
- **Bearer-token middleware** —
|
||||
`packages/daemon/src/middleware/auth-bearer-token.ts` (re-confirmed at
|
||||
HEAD: header `auth-bearer-token.ts:1–9` "PL-005 Phase B"). Constant-time
|
||||
bearer comparison via Node `crypto.timingSafeEqual`; the daemon refuses to
|
||||
start with a non-loopback bind interface AND an empty bearer config
|
||||
(startup-side check). Bearer enforced on the write verbs:
|
||||
`app.post("/action", requireAuth)` (`routes/mission-control.ts:294`) and
|
||||
`app.post("/notifications/test", requireAuth)` (`:295`). v0 is bearer-on-write;
|
||||
no OAuth/SSO/per-user model.
|
||||
- **Bearer-token middleware** — `middleware/auth-bearer-token.ts`.
|
||||
Constant-time comparison via Node `crypto.timingSafeEqual`
|
||||
(`constantTimeEqual`, `:36`, `:50`). `authBearerTokenMiddleware` (`:93`)
|
||||
passes every request through when no token is configured (`:98`). The
|
||||
token comes from `OPENRIG_AUTH_BEARER_TOKEN` (`index.ts:280`). The daemon
|
||||
refuses to start when the bind host is set explicitly, is neither loopback
|
||||
nor Tailscale (a hostname is resolved first), and no bearer token is set
|
||||
(`assertBindAuthInvariant`, `auth-bearer-token.ts:240`, called at
|
||||
`index.ts:288`). Bearer enforced on the write routes:
|
||||
`app.post("/action", requireAuth)` (`routes/mission-control.ts:307`) and
|
||||
`app.post("/notifications/test", requireAuth)` (`:308`). Reads are not
|
||||
gated. It is one static token; no OAuth/SSO/per-user model
|
||||
(`auth-bearer-token.ts:11–14`).
|
||||
- **Notification dispatcher** — two adapters
|
||||
(`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts`
|
||||
alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env) plus
|
||||
`notification-dispatcher.ts` (re-confirmed in
|
||||
`domain/mission-control/`). ntfy.sh recommended for the operator's phone
|
||||
via tailnet.
|
||||
- **Read-only audit-history browse** — `audit-browse.ts` over
|
||||
`mission_control_actions`, exposed at `GET /api/mission-control/audit`
|
||||
(`routes/mission-control.ts:346`) with filters and `(limit, before_id)`
|
||||
pagination cursored on SQLite `rowid` (`architecture.md` §3 L392).
|
||||
(`notification-adapter-ntfy.ts`, `notification-adapter-webhook.ts`) plus
|
||||
`notification-dispatcher.ts`. `OPENRIG_NOTIFICATIONS_MECHANISM` selects
|
||||
`ntfy`, `webhook` or `none`, and the default is `none`
|
||||
(`startup.ts:1592`). A dispatcher starts only when
|
||||
`OPENRIG_NOTIFICATIONS_TARGET` is also set (`:1593`, `:1600`) and the
|
||||
target URL passes validation (`:1611`). The ntfy adapter posts to a topic
|
||||
URL that the ntfy phone app subscribes to
|
||||
(`notification-adapter-ntfy.ts:1–6`).
|
||||
|
||||
Adapters: **2** — `git grep -l 'implements NotificationAdapter' -- packages/daemon/src | wc -l`
|
||||
- **Read-only audit-history browse** — `MissionControlAuditBrowse.query`
|
||||
(`audit-browse.ts:78`) over `mission_control_actions`, exposed at
|
||||
`GET /api/mission-control/audit` (`routes/mission-control.ts:441`) with
|
||||
filters and `(limit, before_id)` pagination cursored on SQLite `rowid`
|
||||
(`audit-browse.ts:134`).
|
||||
|
||||
## 5. Mission Control events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
|
||||
> L394 says "Existing 32 PL-004 events untouched" — internally inconsistent
|
||||
> with §3 L410's "20" and predates 0.3.x. **Do NOT carry either number.**
|
||||
> The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
|
||||
> has **73 members** total (slice-00 §1.8, re-confirmed at HEAD); the
|
||||
> additive `mission_control.*` events are described below WITHOUT asserting
|
||||
> a contested PL-004 sub-count.
|
||||
The `RigEvent` union starts at `domain/types.ts:106`; its four
|
||||
`mission_control.*` members are at `domain/types.ts:306–311`.
|
||||
|
||||
Re-confirmed `domain/types.ts:212–218`: Phase A added
|
||||
`mission_control.action_executed`, `mission_control.cli_drift_detected`,
|
||||
`mission_control.view_refreshed`; Phase B added
|
||||
`mission_control.notification_sent`, `mission_control.notification_failed`
|
||||
(5 `mission_control.*` members at HEAD).
|
||||
`RigEvent` members: **98** — `sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`
|
||||
|
||||
`mission_control.*` members: **4** — `sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "mission_control\.'`
|
||||
|
||||
- `mission_control.action_executed` — written by the write contract
|
||||
(`mission-control-write-contract.ts:225`).
|
||||
- `mission_control.cli_drift_detected` — emitted by the fleet capability
|
||||
probe (`mission-control-fleet-cli-capability.ts:171`).
|
||||
- `mission_control.notification_sent` / `mission_control.notification_failed`
|
||||
— emitted by the notification dispatcher (`notification-dispatcher.ts:174`,
|
||||
`:184`).
|
||||
- *(retired)* `mission_control.view_refreshed` — retired per #490; views are
|
||||
read-on-demand queries without background refresh caching.
|
||||
|
||||
## 6. Route surface
|
||||
|
||||
`missionControlRoutes({ bearerToken })` is mounted at `server.ts:498`. Key
|
||||
routes (`routes/mission-control.ts`): `GET /views` (`:245`),
|
||||
`GET /cli-capabilities` (`:250`), `POST /action` (`:298`, auth-gated),
|
||||
`GET /audit` (`:346`), `POST /notifications/test` (auth-gated), plus an SSE
|
||||
surface. The integrated UI is the `/mission-control` top-level route in
|
||||
`packages/ui/src/routes.tsx` mounting `MissionControlSurface`
|
||||
(`architecture.md` §3 L390); UI detail lands in
|
||||
`../ui/project-and-for-you.md`.
|
||||
`missionControlRoutes({ bearerToken })` is mounted at `/api/mission-control`
|
||||
(`server.ts:791–794`). Routes (`routes/mission-control.ts`): `GET /views`
|
||||
(`:259`), `GET /cli-capabilities` (`:264`), `GET /destinations` (`:276`),
|
||||
`GET /sse` and its alias `GET /watch` (`:303–304`; they forward
|
||||
`action_executed` and `cli_drift_detected` events,
|
||||
`:289–291`), `POST /action` (`:314`, auth-gated), `GET /audit` (`:441`),
|
||||
`POST /notifications/test` (`:481`, auth-gated), and `GET /views/:view-name`
|
||||
(`:509`).
|
||||
|
||||
In the web UI (`packages/ui/src/routes.tsx`), `/mission-control` is a
|
||||
redirect to `/for-you` (`:495–500`). `/for-you` renders the For You `Feed`
|
||||
(`:130–134`), whose cards call `POST /action` through `VerbActions`
|
||||
(`packages/ui/src/components/for-you/FeedCard.tsx:13`), and `/search` renders
|
||||
`AuditHistoryView` over `GET /audit` (`routes.tsx:304–308`).
|
||||
`MissionControlSurface`
|
||||
(`packages/ui/src/components/mission-control/MissionControlSurface.tsx:9`)
|
||||
still exists and renders the same `Feed`, but nothing imports it. UI detail
|
||||
lands in `../ui/project-and-for-you.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — the PL-004 Phase A `queue_items`/`stream_items`
|
||||
sources Mission Control reads, and the hot-potato closure contract verbs honor.
|
||||
- `workflow-runtime.md` — PL-004 Phase D transactional-scribe runtime.
|
||||
- `../ui/project-and-for-you.md` — the UI surface counterpart (authored phase).
|
||||
- `coordination-primitive.md` — the `queue_items`/`stream_items` sources
|
||||
Mission Control reads, and the hot-potato closure contract verbs honor.
|
||||
- `workflow-runtime.md` — the workflow transactional-scribe runtime.
|
||||
- `../ui/project-and-for-you.md` — the For You feed that carries the verb
|
||||
actions in the web UI.
|
||||
- Source roots: `packages/daemon/src/domain/mission-control/`,
|
||||
`packages/daemon/src/middleware/auth-bearer-token.ts`,
|
||||
`packages/daemon/src/routes/mission-control.ts`,
|
||||
|
||||
@@ -5,139 +5,183 @@ status: active
|
||||
topics: [specification-and-bundles, release-and-versioning]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how rig/pod bundles are assembled (schema-version-2 vs legacy
|
||||
v1), how bundle create/inspect/install and /api/up route across source kinds,
|
||||
the staged BootstrapOrchestrator plan/apply flow, or which legacy install-
|
||||
engine seams still ship for pre-reboot data.
|
||||
Locate npm package assembly, topology-bundle creation and validation,
|
||||
source routing, bootstrap plan/apply, or the retained package install engine.
|
||||
siblings: [agent-spec-and-startup.md, plugin-agent-image-context-pack.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Packaging, Bootstrap, Bundles, Legacy Install Engine
|
||||
# Packaging, bootstrap and bundles
|
||||
|
||||
How OpenRig packages a topology into a shareable bundle and reconstitutes it
|
||||
on another host. Fully dual-format: schema-version-2 pod bundles plus legacy
|
||||
v1 artifacts, routed deterministically by the bootstrap orchestrator.
|
||||
This module describes source at main commit
|
||||
`254122872cf477511514979a4300b695d77cd1f7`. Source paths below are repository-relative.
|
||||
An npm CLI artifact and a `.rigbundle` have different builders and consumers; neither
|
||||
source verification nor archive integrity establishes that a daemon has adopted an artifact.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§5 Bundles/bootstrap/legacy
|
||||
> compatibility, §6 Bundle create/inspect/install + /api/up, §11 Compat
|
||||
> note 3) per slice-08 §10.1 — line numbers advisory only.
|
||||
## npm CLI assembly
|
||||
|
||||
## 1. Bundle, bootstrap, legacy domain services
|
||||
`scripts/build-package.sh` builds the daemon, web UI, TUI and CLI, then assembles their
|
||||
outputs under `packages/cli`. The publishable file list and binary declarations live in
|
||||
`packages/cli/package.json`.
|
||||
|
||||
(`architecture.md` §5 "Bundles, bootstrap, and legacy compatibility")
|
||||
The assembler writes generated `BUILD_INFO` modules into the daemon and CLI outputs with
|
||||
the package version, Git commit, dirty flag and build time. The dirty check covers tracked
|
||||
and untracked inputs under `packages/` and `scripts/`; it is not a whole-worktree status
|
||||
claim.
|
||||
|
||||
- `pod-bundle-assembler.ts` — schema-version-2 bundle assembler.
|
||||
Re-confirmed: emits `schemaVersion: 2` (`pod-bundle-assembler.ts:167`).
|
||||
- `bundle-types.ts` — v1 and v2 manifest types plus parse/validate/serialize.
|
||||
Re-confirmed: v2 `PodBundleManifest` type carries `schemaVersion: 2`
|
||||
(`:23`); `validatePodBundleManifest` rejects unless `schema_version === 2`
|
||||
(`:38`); `serializePodBundleManifest` writes `schema_version: 2` (`:62`);
|
||||
`parsePodBundleManifest` (`:87`); legacy path
|
||||
`validateLegacyBundleManifest` (`:143`) — both formats live in one file.
|
||||
- `bundle-source-resolver.ts` — `LegacyBundleSourceResolver`
|
||||
(`bundle-source-resolver.ts:25`) plus `PodBundleSourceResolver` (`:132`).
|
||||
- `bootstrap-orchestrator.ts` — staged bootstrap flow with direct pod-aware
|
||||
rig and v2 bundle delegation. `BootstrapMode = "plan" | "apply"`
|
||||
(`bootstrap-orchestrator.ts:26`).
|
||||
- `up-command-router.ts` — spec/bundle source classification for `/api/up`.
|
||||
`SourceKind = "rig_spec" | "rig_bundle" | "rig_name"`
|
||||
(`up-command-router.ts:6`).
|
||||
The same script:
|
||||
|
||||
All re-confirmed present in `packages/daemon/src/domain/` @HEAD.
|
||||
- Generates shipped context packs through `scripts/generate-context-packs.mjs`.
|
||||
- Copies daemon output, assets, specs, context packs, policies and `docs/reference/`.
|
||||
- Checks the specs staging input with `scripts/check-internal-leak-guard.mjs` and writes
|
||||
the staged substance-root inventory into the package.
|
||||
- Copies the web UI and TUI outputs.
|
||||
- Runs `scripts/rewrite-daemon-imports.mjs` so staged CLI/TUI JavaScript resolves the
|
||||
shipped daemon output rather than an unpublished workspace package.
|
||||
|
||||
## 2. Bundle create / inspect / install
|
||||
This is the build layout. Native dependency installation and consumer runtime behavior need
|
||||
their own checks; this document does not infer them from a successful assembly.
|
||||
|
||||
(`architecture.md` §6 "Bundle create / inspect / install")
|
||||
## Topology-bundle creation
|
||||
|
||||
`routes/bundles.ts` is fully dual-format:
|
||||
`packages/daemon/src/routes/bundles.ts` mounts create, inspect, install and history handlers
|
||||
under `/api/bundles` (the mount is in `packages/daemon/src/server.ts`).
|
||||
|
||||
- **create** — detects a pod-aware RigSpec and uses `PodBundleAssembler`
|
||||
(accepts an optional `rigRoot`); legacy create still uses
|
||||
`LegacyBundleAssembler`.
|
||||
- **inspect** — safely extracts the archive, detects `schema_version`; v2
|
||||
returns `schemaVersion: 2`, `agents[]`, and integrity data; v1 returns the
|
||||
legacy manifest shape.
|
||||
- **install** — uses the full bootstrap plan/apply; bootstrap peeks the
|
||||
manifest and routes deterministically to `pod_bundle` or `rig_bundle`.
|
||||
Re-confirmed at source: `bootstrap-orchestrator.ts:134-143` — when
|
||||
`sourceKind === "rig_bundle"` it unpacks to a temp peek dir, reads
|
||||
`bundle.yaml`, and calls `parsePodBundleManifest(...)` to detect the
|
||||
schema version before routing.
|
||||
The create handler validates the source spec before comparing it with recorded live topology.
|
||||
A divergent spec is refused unless the request explicitly allows drift; an allowed divergence
|
||||
is recorded in the bundle's provenance notes. The exported spec is not silently rewritten
|
||||
from live state.
|
||||
|
||||
## 3. `/api/up` source routing
|
||||
| Source shape | Builder and output |
|
||||
|---|---|
|
||||
| Pod-aware rig spec | `packages/daemon/src/domain/pod-bundle-assembler.ts`: `PodBundleAssembler.assemble()` produces schema-version-2 metadata, vendors resolved agent trees, and rewrites agent references to local paths. |
|
||||
| Legacy rig spec | `packages/daemon/src/domain/bundle-assembler.ts`: `LegacyBundleAssembler` (imported as `BundleAssembler` by the route) collects the referenced legacy packages and produces the legacy manifest shape. |
|
||||
|
||||
(`architecture.md` §6 "`/api/up`")
|
||||
The pod builder collects culture, docs and startup material, preserves file bytes and available
|
||||
mode metadata, and skips vendoring the terminal sentinel as an agent. Required document
|
||||
collection failures stop assembly; missing declared skills can instead produce warnings.
|
||||
Inspect the builder's collection helpers when changing these distinctions.
|
||||
|
||||
`UpCommandRouter` + `BootstrapOrchestrator` own:
|
||||
Both route branches can consume a source-root `bundle.yaml` to carry declared skills,
|
||||
plugins, workflow specs, context packs and agent images into staging before computing
|
||||
integrity. Manifest types, validation and serialization are in
|
||||
`packages/daemon/src/domain/bundle-types.ts`. Provenance and compatibility are metadata;
|
||||
provenance is not an author signature.
|
||||
|
||||
- direct pod-aware rig specs,
|
||||
- legacy rig specs,
|
||||
- v1 bundle installs,
|
||||
- v2 pod-bundle installs.
|
||||
### Archive checks
|
||||
|
||||
Plan mode and apply mode both work across those source kinds.
|
||||
`packages/daemon/src/domain/bundle-archive.ts` owns `pack()` and `unpack()`;
|
||||
`packages/daemon/src/domain/bundle-integrity.ts` owns the per-file integrity map.
|
||||
|
||||
> Definitional note (carried, not a numeric drift) — `architecture.md` §6
|
||||
> describes bootstrap routing to `pod_bundle` / `rig_bundle`. The
|
||||
> `UpCommandRouter` *classification* enum is `SourceKind = "rig_spec" |
|
||||
> "rig_bundle" | "rig_name"` (`up-command-router.ts:6`); the
|
||||
> `pod_bundle`-vs-`rig_bundle` distinction is resolved one layer deeper in
|
||||
> `bootstrap-orchestrator.ts` by peeking the manifest schema version
|
||||
> (`:134-143`). Both statements are accurate at different layers; documented
|
||||
> here so the layering is explicit rather than appearing contradictory.
|
||||
Packing sorts entries, uses portable tar metadata and a fixed tar timestamp, and writes a
|
||||
sibling SHA-256 file. Unpacking checks that digest, rejects archive links and unsafe entry
|
||||
paths before extraction, requires `bundle.yaml` and its integrity section, and verifies
|
||||
the extracted file inventory. Hashes establish consistency with the supplied manifest and
|
||||
digest; they do not authenticate whoever supplied both.
|
||||
|
||||
## 4. Legacy install engine (still ships)
|
||||
The inspect handler unpacks through this same path before reporting the appropriate manifest
|
||||
shape. A syntactically valid manifest alone is not an inspected archive.
|
||||
|
||||
(`architecture.md` §5 "Legacy systems that still ship" + §11 compat note 3)
|
||||
## Source routing and bootstrap
|
||||
|
||||
These pre-reboot seams remain active for backward compatibility:
|
||||
`packages/daemon/src/domain/up-command-router.ts` classifies a source as
|
||||
`rig_spec`, `rig_bundle`, `rig_name` or `topology`. It recognizes named rigs,
|
||||
spec paths, `.rigbundle`, `.rigtopology`, and YAML with a top-level rigs list;
|
||||
extensionless paths also have content detection.
|
||||
|
||||
- package install engine: `package-install-service.ts`,
|
||||
`package-manifest.ts`, `package-repository.ts`, `install-engine.ts`,
|
||||
`conflict-detector.ts`, `role-resolver.ts` (all re-confirmed present
|
||||
@HEAD).
|
||||
- bootstrap and requirement-probe support.
|
||||
- discovery and claim services.
|
||||
- tmux/cmux adapters and resume adapters.
|
||||
`packages/daemon/src/routes/up.ts` then selects the execution path:
|
||||
|
||||
> Drift-check D-pkg — `architecture.md` §5/§6 carries no slice-00 numeric
|
||||
> drift in this content; the v2 bundle-assembler + dual-format flows were
|
||||
> verified accurate at HEAD (§1–§3 above). The footprint/migration counts
|
||||
> that ARE stale land in `daemon-core.md`, not here.
|
||||
| Input | Consumer |
|
||||
|---|---|
|
||||
| Existing rig name | Existing-rig restore path. |
|
||||
| Single rig spec or bundle | `BootstrapOrchestrator.bootstrap()` in `packages/daemon/src/domain/bootstrap-orchestrator.ts`. |
|
||||
| Topology manifest | `MultiRigLauncher`, with a single-rig bootstrap or remote-up leaf per entry. |
|
||||
|
||||
Compat note 3 (carried verbatim, `architecture.md` §11): "Legacy
|
||||
compatibility seams still ship for pre-reboot data and v1 artifacts." (The
|
||||
full §11 list lives in `architecture-rules-and-event-system.md`.)
|
||||
Topology plan mode is rejected. Placement belongs on the individual topology entries; a
|
||||
top-level host flag is rejected. The selected topology parser/route also rejects nested
|
||||
topologies and existing-rig-name entries. This is narrower than the single-rig `up` surface.
|
||||
|
||||
> Provenance note (carried, not asserted): `bootstrap-orchestrator.ts:3-5`
|
||||
> still imports `LegacyRigSpec` / `LegacyRigSpecCodec` / `LegacyRigSpecSchema`
|
||||
> with in-source `TODO: AS-T08b — migrate to pod-aware RigSpec` markers, and
|
||||
> `:16` `TODO: AS-T12 — migrate to pod-aware bundle source resolver`. The
|
||||
> legacy seam is intentional, in-progress migration scaffolding — recorded
|
||||
> as-is, not smoothed.
|
||||
Bootstrap distinguishes pod-aware and legacy specs, and inspects a bundle manifest to choose
|
||||
`PodBundleSourceResolver` or `LegacyBundleSourceResolver` from
|
||||
`packages/daemon/src/domain/bundle-source-resolver.ts`. Thus the router's
|
||||
`rig_bundle` kind does not imply a legacy manifest.
|
||||
|
||||
## OPEN / carried items
|
||||
For pod-aware specs, plan mode validates and runs preflight probes; apply delegates to
|
||||
`PodRigInstantiator`. Plan mode still records a bootstrap run/result: it is not a pure
|
||||
file read. Apply reports partial completion when nodes fail or require attention, preserving
|
||||
the created rig identity rather than claiming every member launched.
|
||||
|
||||
- **D-pkg (resolved-as-current):** no slice-00 numeric drift in this
|
||||
module's split content; v2 assembler + dual-format flows verified accurate
|
||||
at HEAD. Bundle-routing layering documented explicitly to pre-empt an
|
||||
apparent §6-vs-source contradiction.
|
||||
- Legacy-migration TODOs carried from source verbatim (AS-T08b / AS-T12).
|
||||
### Durable install target
|
||||
|
||||
## See also
|
||||
Bundle apply requires an explicit target root at the HTTP boundary. For a pod bundle,
|
||||
`materializePodBundle()` copies the verified extraction into that durable root before
|
||||
instantiation, so local agent references and relative working directories survive removal
|
||||
of the extraction directory. It checks destination conflicts before copying, preserves
|
||||
identical files, and refuses differing files or incompatible destination types.
|
||||
|
||||
- `agent-spec-and-startup.md` — `RigInstantiator` / `PodRigInstantiator` and
|
||||
the dual-format spec seam that bundles wrap.
|
||||
- `plugin-agent-image-context-pack.md` — the 0.3.0/0.3.1 reusable
|
||||
starter-state / content-provenance cluster (authored separately in 8.4b).
|
||||
- `daemon-core.md` — `/api/up` + `/api/bundles` are among the 49 route
|
||||
mounts; `BootstrapOrchestrator` is constructed in the createDaemon
|
||||
sequence.
|
||||
- Source roots: `packages/daemon/src/domain/{pod-bundle-assembler,
|
||||
bundle-types,bundle-source-resolver,bootstrap-orchestrator,
|
||||
up-command-router}.ts`, `packages/daemon/src/routes/{bundles,up}.ts`.
|
||||
Pod bundles containing service definitions are refused by bootstrap; their service path
|
||||
requires a stable spec directory. Direct spec bootstrap has separate service prelaunch
|
||||
handling. See [agent-spec-and-startup.md](agent-spec-and-startup.md) for instantiation.
|
||||
|
||||
## Install checks and content routing
|
||||
|
||||
The install handler in `packages/daemon/src/routes/bundles.ts` obtains a source-path
|
||||
lock, reads validated metadata, checks compatibility, and checks a declared rig name
|
||||
against the repository's rig list before bootstrap. The compatibility override
|
||||
`skipVersionCheck` and name-conflict override `force` are explicit request fields.
|
||||
|
||||
`packages/daemon/src/domain/bundle-conflict-detector.ts` implements the rig-name check.
|
||||
It is not a complete agent, port or filesystem collision audit; a missing rig name supplies
|
||||
no name comparison. Target-file conflicts are handled separately by materialization.
|
||||
Skipping these prechecks does not skip archive validation in bootstrap.
|
||||
|
||||
After a completed bundle install, the route separately unpacks and routes declared content:
|
||||
|
||||
| Manifest content | Destination selected by the route |
|
||||
|---|---|
|
||||
| `skills` | The OpenRig `packages` cache, stripping the legacy `packages/` prefix; this does not import a complete harness skill into the managed skill catalog. |
|
||||
| `plugins` | The OpenRig `plugins` root, through local plugin references. |
|
||||
| `workflow_specs` | `workflows/` beneath resolved `workspaceSpecsRoot`; unresolved settings skip this routing. |
|
||||
| `context_packs` | The configured `context.root`, matching context-library discovery. |
|
||||
| `agent_images` | The OpenRig `agent-images` root; declarations address image directories containing a manifest. |
|
||||
|
||||
The individual implementations are the `bundle-skills-router.ts`,
|
||||
`bundle-plugins-router.ts`, `bundle-workflow-specs-router.ts`,
|
||||
`bundle-context-packs-router.ts` and `bundle-agent-images-router.ts` files under
|
||||
`packages/daemon/src/domain/`. The route treats these post-bootstrap calls as best effort:
|
||||
their failure does not roll back or turn an already completed rig install into failure.
|
||||
Inspect their returned results and warnings separately from bootstrap success.
|
||||
|
||||
Install audit records are appended best effort to `bundle-audit.jsonl` under the OpenRig
|
||||
home; the history endpoint reads that audit. Bootstrap run state and action journals are
|
||||
separate, repository-backed records.
|
||||
|
||||
## Legacy package install path
|
||||
|
||||
The legacy path remains executable, not just an archive parser.
|
||||
`packages/daemon/src/routes/packages.ts` is mounted at `/api/packages`, and legacy
|
||||
bootstrap calls `PackageInstallService` in
|
||||
`packages/daemon/src/domain/package-install-service.ts`.
|
||||
|
||||
The source chain is package resolution/manifest validation, planning and conflict policy,
|
||||
then apply/verification with repository records:
|
||||
|
||||
- `packages/daemon/src/domain/package-resolver.ts` and `package-manifest.ts`.
|
||||
- `packages/daemon/src/domain/install-planner.ts`, `conflict-detector.ts` and
|
||||
`install-policy.ts`.
|
||||
- `packages/daemon/src/domain/install-engine.ts`, `install-verifier.ts`,
|
||||
`install-repository.ts` and `package-repository.ts`.
|
||||
|
||||
Legacy bootstrap also probes requirements and records staged outcomes before rig
|
||||
instantiation. A failed package installation prevents that path from importing the rig.
|
||||
This compatibility implementation does not imply that pod-aware bundles use the same
|
||||
package-install stages.
|
||||
|
||||
## Related maps
|
||||
|
||||
- [plugin-agent-image-context-pack.md](plugin-agent-image-context-pack.md): content library
|
||||
consumers and their state boundaries.
|
||||
- [daemon-core.md](daemon-core.md): bootstrap construction and HTTP wiring.
|
||||
- [agent-spec-and-startup.md](agent-spec-and-startup.md): spec resolution, projection and launch.
|
||||
@@ -5,353 +5,212 @@ status: active
|
||||
topics: [extension-and-user-workspace, continuity, skill-management]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how OpenRig discovers plugins, captures/forks agent images,
|
||||
assembles/sends context packs, or how the Claude auto-compaction enforcer
|
||||
decides to send /compact. Author-mode module — no prior architecture.md
|
||||
prose; every claim is sourced to file:line at HEAD.
|
||||
Locate plugin discovery and vendoring, context-pack addressing/composition,
|
||||
agent-image capture/fork/protection, or Claude guided-compaction behavior.
|
||||
siblings: [packaging-bootstrap-bundles.md, agent-spec-and-startup.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
|
||||
# Content libraries and compaction
|
||||
|
||||
Four filesystem-canonical content primitives the daemon discovers and
|
||||
serves: **plugins**, **agent images**, **context packs**, and the **Claude
|
||||
auto-compaction policy enforcer**. None adds SQLite state — all are
|
||||
filesystem-canonical with in-memory daemon caches.
|
||||
This module describes source at main commit
|
||||
`254122872cf477511514979a4300b695d77cd1f7`. Source paths below are repository-relative.
|
||||
Context packs, plugins and agent images have filesystem-backed content and daemon-side
|
||||
discovery. Their consumers can read database identity, mutate files, deliver messages or
|
||||
launch sessions; the whole layer is not a read-only catalog.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists
|
||||
> for this. Every load-bearing claim carries `> Source: <file:line>
|
||||
> @HEAD`; ambiguity is declared as an OPEN item, never smoothed. Paths are
|
||||
> relative to `packages/daemon/src/` unless prefixed `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
## Context packs
|
||||
|
||||
## 0. Release attribution (forensic seam — read first)
|
||||
`packages/daemon/src/domain/context-packs/context-pack-library-service.ts` implements
|
||||
`ContextPackLibraryService`. A pack contains `manifest.yaml` and declared members.
|
||||
Its path-like ref is the lookup identity, independently of the manifest name/version;
|
||||
the entry ID is `context-pack:<ref>`.
|
||||
|
||||
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
### Discovery and mutation
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| Context packs | **0.3.0** | `v0.3.0:domain/context-packs/context-pack-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
|
||||
| Agent images | **0.3.0** | `v0.3.0:domain/agent-images/agent-image-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
|
||||
| Plugins | **0.3.1, NOT 0.3.0** | `v0.3.0:domain/plugin-discovery-service.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT seam (a); map row 5) |
|
||||
| Claude auto-compaction | **0.3.1 headline, no 0.3.0 antecedent** | `v0.3.0:domain/claude-compaction-enforcer.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT map row 7) |
|
||||
Startup in `packages/daemon/src/startup.ts` configures the shipped context-pack root,
|
||||
the resolved `context.root` and its system root, followed by an existing workspace-local
|
||||
`.openrig/context-packs` root. The library scans recursively, stops descending when it
|
||||
finds a pack manifest, and does not follow directory symlinks.
|
||||
|
||||
> Source: re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/daemon/src/domain/plugin-discovery-service.ts`
|
||||
> and `...claude-compaction-enforcer.ts` both fail ("exists on disk, but
|
||||
> not in 'v0.3.0'"); the two 0.3.0 library services resolve at `v0.3.0`.
|
||||
> Code present on disk at HEAD does NOT make plugins/compaction a 0.3.0
|
||||
> feature — ancestry is truth; the same-calendar-day commit date is the
|
||||
> forensic trap slice-00 0.3.0-GT seam (a) warns about.
|
||||
The first configured root to encounter a physical pack owns that directory. For distinct
|
||||
physical packs with the same ref, the later root wins. This distinction prevents overlapping
|
||||
roots from giving the same physical pack a second address. Scan replaces the in-memory ref
|
||||
index and reports malformed packs instead of indexing them.
|
||||
|
||||
**Do not back-attribute plugins or compaction to 0.3.0.** The CLI
|
||||
reference already encodes this ("## Plugin Inspection (v0.3.1)").
|
||||
`composeFromFiles()` creates a durable pack after validating its ref, destination and
|
||||
sources. `removeByRef()` removes the selected pack directory and rescans, but refuses
|
||||
shipped builtin packs. Library mutation and lookup share ref validation.
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:943` @HEAD.
|
||||
`packages/daemon/src/domain/context-packs/manifest-parser.ts` parses manifests.
|
||||
Its supported file suffixes include Markdown/YAML/text and `.sh`, `.ts`, `.mjs`,
|
||||
`.py` helper assets. Those assets are served as text; pack assembly does not execute them.
|
||||
The parser also validates atom metadata and named profiles.
|
||||
|
||||
## 1. Context packs (PL-014) — 0.3.0
|
||||
### Assembly, addresses and profiles
|
||||
|
||||
A **context pack** is a directory of `manifest.yaml` + included markdown /
|
||||
yaml / txt files: operator-authored, library-discoverable, reviewable, and
|
||||
composable. The `rig context` noun is delivery-free. No SQLite tables;
|
||||
in-memory cache at daemon scope.
|
||||
These are separate projections:
|
||||
|
||||
> Source: `domain/context-packs/context-pack-types.ts:1-11` @HEAD.
|
||||
| Operation | Source and behavior |
|
||||
|---|---|
|
||||
| Framed pack preview | `packages/daemon/src/domain/context-packs/bundle-assembler.ts`, `assembleBundle()`: pack/file headers, file metadata, token estimates and `missingFiles`. Missing members are reported and skipped; a failed read of a present member raises an error. |
|
||||
| Plain file composition | The same file's `assemblePlainFiles()`: present files in declared order, separated by two newlines, without framing or trimming their bytes. |
|
||||
| Addressed read | `packages/daemon/src/routes/context-packs.ts`, `/library/resolve-address`: longest matching pack-ref prefix, a declared file, then optional H2/H3 heading selection through `packages/daemon/src/domain/markdown-address.ts`. |
|
||||
| Situation profile | `packages/daemon/src/domain/context-packs/profile-composer.ts`, `composeProfile()`: select atoms, close dependencies, order and resolve each piece. |
|
||||
| Named profile | The same file's `composeNamedProfile()`: declared phases select atoms or supplied work/seat context, preserving phase IDs in the result. |
|
||||
|
||||
`ContextPackLibraryService.scan()` walks roots, parses each
|
||||
`manifest.yaml`, replaces the in-memory index; collisions are
|
||||
**last-wins** in discovery order (workspace > user_file > builtin).
|
||||
Startup wires the builtin root (`../context-packs`, first), the configured
|
||||
user-file root (`context.root`, default `$OPENRIG_HOME/context`), its
|
||||
`system/` child as the canonical System World root, and the workspace-local
|
||||
`<workspaceRoot>/.openrig/context-packs` root when present + distinct. A pack is
|
||||
addressed by its path-like **ref** (e.g. `packs/compaction-restore`), which is
|
||||
its unique identity; the opaque entry id is `context-pack:<ref>` (a UI routing
|
||||
key). Colon-id `context-pack:<name>:<version>` addressing and the `/library/:id`
|
||||
routes were removed in Slice-03 Atom 5. Resolution is by ref only through
|
||||
`getByRef` and the read/delete/preview/pieces route family. Startup accepts
|
||||
ordinary files only and rejects context-pack expansion; compose a durable ref
|
||||
first, then use a dedicated delivery verb when delivery is intended.
|
||||
Situation composition selects fresh atoms for `fresh`, fresh plus handover atoms for
|
||||
`handover`, and post-compaction plus handover atoms for `post-compaction`. Runtime
|
||||
selection is Claude or Codex, with `any` atoms applicable to either. Required atoms join
|
||||
the selection; missing dependencies or runtime-incompatible dependencies fail composition.
|
||||
The output is ordered by atom order, then ID. Profile-only atoms are excluded from ordinary
|
||||
situation selection.
|
||||
|
||||
> Source: `domain/context-packs/context-pack-library-service.ts:59-94`
|
||||
> (scan; last-wins L79-81), `:31-33` (id); `startup.ts`
|
||||
> (`contextPackLibrary` construction) @HEAD.
|
||||
The profile route resolves configured tree sources through
|
||||
`packages/daemon/src/domain/context-packs/profile-source-resolver.ts`.
|
||||
Seat context requires explicit rig and seat selectors. Mission/slice selectors and named
|
||||
profile context requirements are validated before reading those trees. The default
|
||||
project/mission/slice walk is bounded to the conventional single-project workspace layout
|
||||
and fresh situation; it is not an arbitrary project-catalog resolver.
|
||||
|
||||
The parser is pure (no fs; caller passes raw YAML). It rejects malformed
|
||||
YAML, missing `name`/`version`, non-array `files`, per-file path traversal
|
||||
(`..`/leading `/`), and unsupported suffixes (`.md .markdown .yaml .yml
|
||||
.txt`); `version` coerced to string. Per-file token estimate is
|
||||
`ceil(bytes/4)`.
|
||||
Each resolved piece names its address/source and reports estimated tokens. A token budget
|
||||
reports overage and drop candidates without silently truncating the profile. Atom
|
||||
`regions` are metadata; the profile endpoint has no region-subset selector.
|
||||
|
||||
> Source: `domain/context-packs/manifest-parser.ts:11` (suffixes),
|
||||
> `:13-121` (rejects; traversal `:83-89`); token estimate
|
||||
> `context-pack-library-service.ts:46-48` @HEAD.
|
||||
### HTTP and CLI boundaries
|
||||
|
||||
`assembleBundle` concatenates files into one paste-ready string framed
|
||||
`# OpenRig Context Pack: <name> v<version>` + optional purpose + `## File:
|
||||
<path> (role: <role>)` headers. Missing files are **skipped and surfaced
|
||||
in `missingFiles`** (operator repair), not a hard fail; a present-but-
|
||||
unreadable file throws `file_read_failed`.
|
||||
`packages/daemon/src/server.ts` mounts `contextPacksRoutes()` at
|
||||
`/api/context-packs`. Its library handlers cover list, sync, compose, read/delete by ref,
|
||||
preview, pieces, addressed reads and profile composition.
|
||||
|
||||
> Source: `domain/context-packs/bundle-assembler.ts:38-39` (prefixes),
|
||||
> `:73-100` (missing skip L74-76; read-fail throw L81-87) @HEAD.
|
||||
`packages/cli/src/commands/context.ts` implements `rig context`, including
|
||||
`get`, `profile`, `compose`, `add`, `rm` and `sync`, plus work-install,
|
||||
trace, source and recap operations. The library read/compose routes do not themselves send
|
||||
a pack to a running seat. Delivery and installation consumers have their own behavior;
|
||||
inspect the selected CLI verb before treating a context command as read-only.
|
||||
|
||||
`contextPacksRoutes()` exposes a ref-primary, delivery-free library surface:
|
||||
`GET /library`, `POST /library/sync`, `POST /library/compose`,
|
||||
`GET/DELETE /library/by-ref`, `GET /library/by-ref/preview`, and
|
||||
`GET /library/by-ref/pieces`. Preview returns the assembled review shape;
|
||||
pieces returns ordered member contents plus the whole plain text and byte size.
|
||||
There is no pack-owned delivery route. CLI library verbs are
|
||||
`rig context compose|list|show|preview|sync|add|rm`; delivery belongs to
|
||||
`rig send --context`, `rig broadcast --context`, `rig walk --through`, and
|
||||
`rig queue create --body-context`.
|
||||
## Agent images and forks
|
||||
|
||||
> Source: `routes/context-packs.ts` (complete route table);
|
||||
> `domain/startup-validation.ts` (file-only startup contract);
|
||||
> `packages/cli/src/commands/context.ts`, `send.ts`, `broadcast.ts`,
|
||||
> `walk.ts`, and `queue.ts` @HEAD.
|
||||
`packages/daemon/src/domain/agent-images/agent-image-library-service.ts` implements
|
||||
`AgentImageLibraryService`. Startup registers the OpenRig `agent-images` root and,
|
||||
when present, the workspace-local `.openrig/agent-images` root. Entries are keyed by
|
||||
`agent-image:<name>:<version>`, with later discovery roots replacing identical IDs.
|
||||
Content is a manifest plus optional files; `stats.json` holds usage data and `.pinned`
|
||||
marks protection from ordinary deletion/pruning.
|
||||
|
||||
## 2. Agent images (PL-016) — 0.3.0
|
||||
Capture and consumption depend on live identity as well as library files:
|
||||
|
||||
An **agent image** is a snapshot bundle of a productive seat's resumable
|
||||
state: runtime-specific resume token (Claude `resume_token` / Codex
|
||||
`thread_id`), source-seat lineage, optional cwd-deltas + notes. Consumable
|
||||
by AgentSpec `session_source: mode: agent_image`. Filesystem-canonical at
|
||||
`~/.openrig/agent-images/<name>/` + workspace-local; **no SQLite tables**.
|
||||
- `packages/daemon/src/domain/agent-images/resume-token-discovery.ts`:
|
||||
`discoverResumeToken()` reads session/node/binding state. It supports Claude Code and
|
||||
Codex. Claude prefers matching context-usage identity; Codex prefers the persisted resume
|
||||
token, then an external-CLI binding. Absent native identity remains absent.
|
||||
- `packages/daemon/src/domain/agent-images/snapshot-capturer.ts`:
|
||||
`SnapshotCapturer.capture()` refuses unsupported or missing native identity, writes
|
||||
an image manifest/files and rescans. It records the source working directory when known.
|
||||
- `packages/daemon/src/routes/agent-images.ts`:
|
||||
`POST /fork` composes native identity discovery with an add-member operation. The
|
||||
default path makes no image. The keep-image path captures and pins one before launch,
|
||||
and reports that retained image even if launch fails.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-types.ts:1-13` @HEAD.
|
||||
Library/snapshot responses redact `sourceResumeToken`; the fork composer builds its
|
||||
native-ID session source inside the daemon rather than returning that field to the caller.
|
||||
This describes these response projections, not secrecy of every field in an image manifest.
|
||||
|
||||
`AgentImageLibraryService` mirrors `ContextPackLibraryService` (scan / list
|
||||
/ get / last-wins) with three source-stated differences: `sourceResumeToken`
|
||||
passes through to consumers (instantiator consumes; operator surfaces
|
||||
redact); `stats.json` is a separate mutable file updated atomically on
|
||||
fork-count increment; a `.pinned` sentinel pins from prune. Id
|
||||
`agent-image:<name>:<version>`.
|
||||
`recordConsumption()` accepts a separate fork-count flag: consumption intent can update
|
||||
last-use time without incrementing successful forks. Inspect the instantiation caller as
|
||||
well as library statistics when assessing whether a launch succeeded.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-library-service.ts:5-14`
|
||||
> (3-difference comment), `:38-41` (id) @HEAD.
|
||||
`packages/daemon/src/domain/agent-images/evidence-guard.ts` implements
|
||||
`evaluateProtection()`: pins, parsed spec references and descendants of protected images
|
||||
contribute protection reasons. The route applies the guard to deletion/pruning unless
|
||||
`force` is explicit. Prune defaults to dry-run. Its protection view depends on the supplied
|
||||
spec roots; it is not an inventory of every reference anywhere on the host.
|
||||
|
||||
`discoverResumeToken(db, sourceSession)` queries `sessions` then `nodes`
|
||||
and returns a typed failure (`session_not_found`/`runtime_unsupported`)
|
||||
rather than fabricating a token; only `claude-code`/`codex` have a native
|
||||
fork primitive. Claude prefers `context_usage.session_id` over persisted
|
||||
`sessions.resume_token`; Codex uses `resume_token`, falling back to a
|
||||
binding's `external_session_name` for `external_cli` seats; returns
|
||||
`nativeId: null` honestly when none.
|
||||
The routes mount at `/api/agent-images` and expose list/sync, snapshot/fork,
|
||||
entry/preview, pin/unpin, delete and prune.
|
||||
`packages/cli/src/commands/agent-image.ts` owns the image library commands.
|
||||
|
||||
> Source: `domain/agent-images/resume-token-discovery.ts:37-85` (honest
|
||||
> null L84; Claude pref L64-69; external_cli L78-83); honesty comment
|
||||
> `:9-10` @HEAD.
|
||||
## Plugins
|
||||
|
||||
`SnapshotCapturer.capture()` routes through `discoverResumeToken`, throws
|
||||
`AgentImageError` on failure or missing native id (no fabricated token, no
|
||||
auto-fallback to fresh), writes manifest + empty `stats.json` via
|
||||
`install()`, re-scans. Captured `nodeCwd` → manifest `source_cwd` so the
|
||||
Use-as-starter snippet emits `cwd:` (fork starts in the parent's directory
|
||||
where Claude's project-dir-scoped jsonl lives); the daemon does NOT
|
||||
override cwd at fork dispatch.
|
||||
### Discovery and inspection
|
||||
|
||||
> Source: `domain/agent-images/snapshot-capturer.ts:60-104` (throw
|
||||
> L65-79; source_cwd L88-93; install+scan L101-102);
|
||||
> `routes/agent-images.ts:256-265` (no-cwd-override contract) @HEAD.
|
||||
`packages/daemon/src/domain/plugin-discovery-service.ts` discovers plugins from:
|
||||
|
||||
**Evidence guard (CATASTROPHIC-bounce; fails closed).**
|
||||
`evaluateProtection()` protects an image from deletion if ANY: pinned;
|
||||
referenced by an active `agent.yaml`; referenced by a rig spec; or a
|
||||
lineage descendant of a protected image (transitive, fixed-point
|
||||
iteration). Spec-root scanning parses YAML structure (not string-grep)
|
||||
and is deliberately conservative — false positives (over-protection)
|
||||
acceptable; false negatives = catastrophic data loss. `--force` /
|
||||
`?force=true` overrides.
|
||||
| Source kind | Directory shape |
|
||||
|---|---|
|
||||
| `vendored` | OpenRig plugins root, one plugin directory per entry. |
|
||||
| `claude-cache` | Claude cache, grouped by marketplace/plugin/version. |
|
||||
| `codex-cache` | Codex cache, grouped by marketplace/plugin/version. |
|
||||
| `rig-cwd` | `.claude/plugins` and `.codex/plugins` beneath supplied working-directory roots. |
|
||||
|
||||
> Source: `domain/agent-images/evidence-guard.ts:1-16` (bounce +
|
||||
> fail-closed), `:53-115` (direct + transitive; fixed point L92-112),
|
||||
> `:117-122` (conservative) @HEAD.
|
||||
Detection uses the Claude/Codex plugin manifests. `listPlugins()` returns runtime/source
|
||||
metadata; `getPlugin()` adds manifests, skills, hooks and MCP detail.
|
||||
`findUsedBy()` scans agent YAML in configured spec-library roots for plugin declarations
|
||||
and profile references. These references describe authored specs, not proof of a running
|
||||
harness loading a plugin.
|
||||
|
||||
**Resume-token redaction at route boundary (LOAD-BEARING).**
|
||||
`/api/agent-images` redacts `sourceResumeToken` → `"(redacted)"` on every
|
||||
operator-facing path; tokens are **never returned over the wire**; only
|
||||
the in-process rigspec-instantiator consumes the real token.
|
||||
The service constructor is not read-only: it retires obsolete refocus hook registrations
|
||||
from the older bundled plugin and rejects duplicate vendored hook registrations across
|
||||
providers. Cached plugin versions are excluded from that duplicate-provider check.
|
||||
|
||||
> Source: `routes/agent-images.ts:15-17` + `:44-46` (`redactResumeToken`),
|
||||
> applied `:60`,`:67`,`:94`,`:161`; slice-00 0.3.0-GT §1.9 flags this
|
||||
> LOAD-BEARING @HEAD.
|
||||
`packages/daemon/src/routes/plugins.ts` exposes GET-only inspection at `/api/plugins`:
|
||||
list, detail, used-by and file list/read. The file routes use the discovered plugin root as
|
||||
their path allowlist. `?cwd=` supplies rig-cwd discovery roots.
|
||||
At this pin, the route's source filter accepts vendored, Claude-cache and Codex-cache only;
|
||||
`source=rig-cwd` is not applied as a filter, even though the service supports that kind.
|
||||
|
||||
`agentImagesRoutes()` (mounted `server.ts:483-485`): `GET /library`,
|
||||
`POST /library/sync`, `GET /library/:id`, `GET /library/:id/preview`,
|
||||
`POST /library/:id/pin`, `POST /library/:id/unpin`,
|
||||
`DELETE /library/:id` (evidence-guarded unless `force=true`),
|
||||
`POST /snapshot`, `POST /prune` (**dry-run by default**,
|
||||
`dryRun !== false`); guard `specRoots` injected via
|
||||
`deps.agentImageSpecRoots`. CLI: `rig agent-image
|
||||
list|show|preview|create|delete|pin|unpin|prune|sync`.
|
||||
### Vendoring and projection
|
||||
|
||||
> Source: `routes/agent-images.ts:54-250` (table; prune default L112;
|
||||
> delete-guard L224-239); `server.ts:483-485`;
|
||||
> `docs/as-built/cli-reference.md:895-912` @HEAD.
|
||||
`packages/daemon/src/domain/plugin-vendor-service.ts` owns `PluginVendorService`.
|
||||
`ensureVendored()` seeds absent plugins or advances an older numeric manifest version.
|
||||
Equal/newer installed versions preserve installed bytes; equal versions can reconcile file
|
||||
modes on byte-identical files. Missing target version authority is preserved rather than
|
||||
overwritten; invalid or conflicting manifest versions are errors.
|
||||
|
||||
## 3. Plugins (plugin-primitive Phase 3a) — **0.3.1, NOT 0.3.0**
|
||||
`ensureSkillGlobally()` projects a named plugin skill using a vendor-version marker.
|
||||
An existing unversioned global skill remains externally owned. Startup wires this service
|
||||
and its filesystem implementation; inspect that wiring when changing projection roots.
|
||||
|
||||
> **Forensic trap (slice-00 0.3.0-GT seam (a)):** the plugin-discovery
|
||||
> file's first-creating commit is dated the same day as the 0.3.0 release
|
||||
> commit but is NOT an ancestor of `v0.3.0`. Plugins are **0.3.1**.
|
||||
> Re-confirmed at HEAD by the §0 tag-presence test.
|
||||
`ensureLatest()` performs local vendoring before `attemptAutoFetch()`. The latter
|
||||
requests a release asset with a bounded timeout and logs failures; even its successful
|
||||
response path does not extract/install the fetched archive at this pin. This does not
|
||||
establish whether an upstream release asset currently exists.
|
||||
|
||||
`PluginDiscoveryService` is a read-only filesystem aggregator — no SQLite,
|
||||
no mutation routes (SC-29 EXCEPTION #8, declared verbatim in the source
|
||||
header). It scans four source kinds, unioned with provenance labels:
|
||||
vendored (`~/.openrig/plugins/<id>/`), Claude cache
|
||||
(`~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`), Codex cache
|
||||
(same under `~/.codex`), and rig-cwd (`<cwd>/.claude/plugins/*` +
|
||||
`<cwd>/.codex/plugins/*`). Detection = presence of
|
||||
`.claude-plugin/plugin.json` and/or `.codex-plugin/plugin.json` (declares
|
||||
`runtimes`). Slice 28 added `skillCount` (readdir of `<plugin>/skills/`)
|
||||
to the list response so the index page avoids an N+1 detail fetch
|
||||
(SC-29 EXCEPTION #11, verbatim).
|
||||
`packages/cli/src/commands/plugin.ts` implements `rig plugin
|
||||
list|show|used-by|validate`. Discovery, vendoring and a runtime's projected plugin tree
|
||||
are distinct steps; see [agent-spec-and-startup.md](agent-spec-and-startup.md) for launch.
|
||||
|
||||
> Source: `domain/plugin-discovery-service.ts:1-29` (SC-29 #8 + scan
|
||||
> desc), `:39` (4-kind `PluginSourceKind`), `listPlugins` `:205-276`
|
||||
> (4 scan blocks), `scanCwdBundledPlugins` `:283-307`, `detectPlugin`
|
||||
> `:393-453` (marker rule L399-404; `skillCount` L433-451 + #11
|
||||
> L75-80) @HEAD.
|
||||
## Claude guided compaction
|
||||
|
||||
`getPlugin(id)` returns detail (manifests + skills + hooks + MCP servers);
|
||||
`rig-cwd:` ids are self-resolvable (parses the cwd out of the id prefix
|
||||
and re-scans so `/api/plugins/:id` does not 404 on an id `?cwd=`
|
||||
returned). `findUsedBy(id)` walks `agent.yaml` files parsing YAML
|
||||
structure (not string-grep — comments don't false-positive) to collect
|
||||
`resources.plugins[].id` + referencing profiles.
|
||||
`packages/daemon/src/domain/claude-compaction-enforcer.ts` implements
|
||||
`ClaudeCompactionEnforcer`. Startup shares one instance between `ContextMonitor`
|
||||
and the manual-compaction route consumers.
|
||||
`packages/daemon/src/domain/user-settings/settings-store.ts` resolves the
|
||||
`policies.claude_compaction.*` settings; automatic compaction defaults to disabled.
|
||||
|
||||
> Source: `domain/plugin-discovery-service.ts:309-369` (getPlugin;
|
||||
> rig-cwd self-resolve L310-321), `extractCwdFromRigCwdId` `:466-479`,
|
||||
> `findUsedBy` `:371-389`, `readResourcesPlugins` `:599-615`,
|
||||
> `readProfilesUsingPlugin` `:617-633` (structure-not-grep `:20-23`)
|
||||
> @HEAD.
|
||||
`maybeAutoCompact()` applies the delivery guard when available. Its implementation
|
||||
requires Claude Code, observed usage and a valid integer threshold. Above-threshold
|
||||
automatic work additionally checks enablement, deduplication, cooldown and the
|
||||
already-triggered latch. It sends prep, then compact, and queues the below-threshold
|
||||
continuation: turn boundary, restore prompt, then compliance prompt.
|
||||
|
||||
`PluginVendorService.ensureLatest()` runs `ensureVendored` (hash-skip
|
||||
idempotent copy from `packages/daemon/assets/plugins/<name>/` to
|
||||
`~/.openrig/plugins/<name>/`) then `attemptAutoFetch`
|
||||
(`github.com/mvschwarz/openrig-plugins`, 5s timeout,
|
||||
**404/network/timeout tolerated silently** — vendored is ALWAYS the
|
||||
fallback; upstream repo intentionally empty at v0 so 404 is normal-state;
|
||||
NO tarball extraction at v0). Wired at startup for `openrig-core`.
|
||||
The below-threshold continuation checks policy enablement too, except for an explicitly
|
||||
operator-initiated manual sequence. A known occupant-generation mismatch invalidates the
|
||||
queued stages; an unknown generation does not prove a match. Failed or retained sends
|
||||
do not advance the stage. The manual trigger shares this continuation while having its
|
||||
own initiation checks.
|
||||
|
||||
> Source: `domain/plugin-vendor-service.ts:1-24`, `ensureVendored`
|
||||
> `:80-103` (hash-skip L97-99), `attemptAutoFetch` `:111-131`
|
||||
> (404-tolerant L116-117; no-extract L123-125), `ensureLatest`
|
||||
> `:138-141`; `startup.ts:434-472` @HEAD.
|
||||
Stage, deduplication and cooldown maps are in memory. Startup supplies live generation
|
||||
resolution and a post-restore callback that records a managed-width receipt, so the wider
|
||||
operation also has persistent effects. Do not infer durable lifecycle completion from
|
||||
a library read or one successfully queued message.
|
||||
|
||||
`pluginsRoutes()` (mounted `server.ts:478`, read-only): `GET /` (filters
|
||||
`?runtime=`, `?source=`, `?cwd=`), `GET /:id/used-by`,
|
||||
`GET /:id/files/list?path=`, `GET /:id/files/read?path=` (slice 28
|
||||
docs-browser), `GET /:id`. Literal sub-paths are mounted **before** the
|
||||
bare `/:id` catchall (route-order discipline). The files endpoints reuse
|
||||
the path-safety machinery with the discovered plugin's absolute path as a
|
||||
synthetic single-root allowlist (operator need not declare plugin paths
|
||||
in `OPENRIG_FILES_ALLOWLIST`; plugin folders read-only at v0 so the
|
||||
content hash is informational). CLI: `rig plugin
|
||||
list|show|used-by|validate`; no `install` verb at v0 (deferred to 0.3.2).
|
||||
## Related maps
|
||||
|
||||
> Source: `routes/plugins.ts:29-39` + `:107-231` (table; before-catchall
|
||||
> L128-130/L141-142; `pluginRootAllowlist` :74-92; read-only-hash
|
||||
> L182-184); `server.ts:478`;
|
||||
> `docs/as-built/cli-reference.md:943-963` @HEAD.
|
||||
|
||||
> **OPEN-A** — `parseSourceFilter` (`routes/plugins.ts:69-72`) accepts
|
||||
> only `vendored|claude-cache|codex-cache`; it does NOT accept `rig-cwd`
|
||||
> though `PluginSourceKind` (`domain/plugin-discovery-service.ts:39`) and
|
||||
> `ListPluginsOpts.sourceFilter` include it. `?source=rig-cwd` is
|
||||
> silently dropped at the route while `?cwd=` still surfaces rig-cwd
|
||||
> entries unfiltered. Stated as-is; intent (rig-cwd reachable only via
|
||||
> `?cwd=`) vs filter-gap is not resolvable from source alone.
|
||||
|
||||
## 4. Claude auto-compaction enforcer (slice 27) — **0.3.1 headline**
|
||||
|
||||
> **No 0.3.0 antecedent (slice-00 0.3.0-GT map row 7).** The enforcer
|
||||
> file is absent at `v0.3.0`, present at `v0.3.1` (§0).
|
||||
|
||||
`ClaudeCompactionEnforcer.maybeAutoCompact()` decides per seat whether
|
||||
`ContextMonitor` should send `/compact`, driven by operator
|
||||
`policies.claude_compaction.*` settings. Decoupled from `ContextMonitor`
|
||||
scheduling; `ContextMonitor` is constructed with the enforcer at startup.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:7-12`, `maybeAutoCompact`
|
||||
> `:205-331`; `startup.ts:1191-1199` (enforcer →
|
||||
> `new ContextMonitor(db, contextUsageStore, claudeAdapter,
|
||||
> compactionEnforcer)`) @HEAD.
|
||||
|
||||
**Defensive contract** (source classifies compaction lifecycle as
|
||||
load-bearing per the banked permission-layer foot-gun rule):
|
||||
|
||||
- **Opt-in, default-off:** `enabled=false` → never triggers.
|
||||
- **Runtime filter:** triggers only when `runtime === "claude-code"`.
|
||||
- **Invalid-policy = disabled:** a hand-edited non-integer / out-of-
|
||||
`[1,100]` `thresholdPercent` is treated as disabled (safer-failure).
|
||||
- **Re-arm via threshold crossing:** session must drop below threshold
|
||||
before another auto-compact fires; window state NOT persisted (restart
|
||||
resets — safer-failure direction).
|
||||
- **Send-failure graceful-degrade:** returns `{ triggered: false,
|
||||
reason: "send_failed" }`, never throws; dedup timestamp set only on
|
||||
successful send so a transient failure retries next tick.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:14-44` (risk-class +
|
||||
> contract), runtime `:206-208`, invalid-policy `:224-232`,
|
||||
> dedup/threshold `:285-300`, send-fail `:312-314`/`:323-325` @HEAD.
|
||||
|
||||
**Active handshake state machine** (Claude hooks can supply context but
|
||||
do not create a new assistant turn): (1) **pre-compact prep** — first
|
||||
eligible above-threshold tick sends a normal user-channel prep prompt;
|
||||
state → `prep_prompt_sent`. (2) **/compact** — next eligible tick sends
|
||||
`/compact <instruction> + trust-channel bridge note`; sets dedup
|
||||
timestamp + `triggeredAboveThreshold`; queues post stage
|
||||
`turn_boundary`. (3) **post-compact, below threshold** — staged sends
|
||||
`turn_boundary` (ack-only) → `restore_prompt` (points at the pending
|
||||
marker `<openrigHome>/compaction/restore-pending/<key>.json`, transcript
|
||||
/ session-id fallbacks) → `compliance_prompt` (read-depth audit); final
|
||||
stage sets post-restore cooldown + clears the above-threshold latch.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:71-77` (compact+bridge),
|
||||
> `:79-98` (prep), `:108-145` (restore + marker path), `:147-163`
|
||||
> (compliance), `:165-171` (turn boundary), state machine `:233-330`
|
||||
> (below-threshold L233-283; above-threshold L302-330) @HEAD.
|
||||
|
||||
Policy fields via `SettingsStore.resolveClaudeCompactionPolicy()`:
|
||||
`enabled`, `thresholdPercent`, `preCompactInstruction`,
|
||||
`compactInstruction`, `messageInline`, `messageFilePath`,
|
||||
`postRestoreAuditInstruction` — each backed by a
|
||||
`policies.claude_compaction.*` key + `OPENRIG_POLICIES_CLAUDE_COMPACTION_*`
|
||||
env override. Defaults: dedup `60_000` ms; post-compact restore cooldown
|
||||
`10 * 60_000` ms.
|
||||
|
||||
> Source: `domain/user-settings/settings-store.ts:553-565` (resolver),
|
||||
> keys `:97-103`, env `:145-151`;
|
||||
> `domain/claude-compaction-enforcer.ts:45-46` (default windows) @HEAD.
|
||||
|
||||
## 5. Cross-cutting properties
|
||||
|
||||
- **Filesystem-canonical, no new SQLite** across all four; daemon caches
|
||||
in-memory; compaction window state intentionally not persisted
|
||||
(context-pack-types.ts:8-11; agent-image-types.ts:12-13;
|
||||
routes/plugins.ts:6; claude-compaction-enforcer.ts:26-29) @HEAD.
|
||||
- **Honest failure, no fabrication** — discovery returns null; capturer
|
||||
throws; enforcer degrades + never throws; evidence guard fails closed
|
||||
(resume-token-discovery.ts:9-10; snapshot-capturer.ts:11-12;
|
||||
claude-compaction-enforcer.ts:30-33; evidence-guard.ts:16) @HEAD.
|
||||
- **503-when-unprovisioned** is the uniform route pattern when the
|
||||
backing service is absent from context (routes/context-packs.ts:31;
|
||||
routes/agent-images.ts:59; routes/plugins.ts:113) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — plugin `?source=` filter omits `rig-cwd` (§3); intent vs
|
||||
filter-gap unresolvable from source.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration / route-group / PL-004-event counts. Slice-00 0.3.0-GT
|
||||
OPEN-3 (For-You verb subset) is out of scope (UI surface, not
|
||||
content-layer).
|
||||
- [packaging-bootstrap-bundles.md](packaging-bootstrap-bundles.md): bundle export and
|
||||
routing into content libraries.
|
||||
- [agent-spec-and-startup.md](agent-spec-and-startup.md): context/resource projection
|
||||
and image consumption during launch.
|
||||
- [lifecycle-snapshot-restore.md](lifecycle-snapshot-restore.md): broader continuity flows.
|
||||
@@ -5,238 +5,265 @@ status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how rig send/capture/broadcast works, how pipe-pane transcript
|
||||
capture and rg/grep search behave, how durable rig chat (SQLite + SSE) is
|
||||
modeled, what rig ask gathers, or the exact MCP-tool-name vs tmux-metadata-key
|
||||
naming distinction.
|
||||
Need to know how rig send/capture/broadcast works, how transcript capture
|
||||
(periodic tmux capture-pane) and rg/grep search behave, how durable rig chat
|
||||
(SQLite + SSE) is modeled, what rig ask gathers, or the exact MCP-tool-name
|
||||
vs tmux-metadata-key naming distinction.
|
||||
siblings: [daemon-core.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Transport, Transcripts, Chat, Ask
|
||||
|
||||
The communication-and-history layer: tmux is transport, not truth. Send/
|
||||
capture/broadcast wrap tmux with honest errors; transcripts are raw pipe-pane
|
||||
captures; chat is daemon-backed SQLite; `rig ask` gathers evidence but never
|
||||
calls an LLM.
|
||||
capture/broadcast wrap tmux with honest errors; transcripts are bounded
|
||||
`tmux capture-pane` snapshots written to files; chat is daemon-backed SQLite;
|
||||
the daemon's `rig ask` service gathers evidence and never calls an LLM.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§5 Transport and communication, §6
|
||||
> Communication/Transcript/Chat flows, §11 Compat notes) per slice-08 §10.1
|
||||
> — line numbers advisory only.
|
||||
> Verified against source at main `254122872cf477511514979a4300b695d77cd1f7`. Each count below sits beside the
|
||||
> command that produces it; run the command from the repository root to refresh
|
||||
> it.
|
||||
|
||||
## 1. The naming axes (D5 — read this first)
|
||||
## 1. The naming axes (read this first)
|
||||
|
||||
This module carries the D5 drift, which has **two independent axes** that
|
||||
must NOT be conflated or blanket-replaced (banked
|
||||
`feedback_release_prep_three_layer_depersonalization`: the rename was scoped,
|
||||
not global). Per-occurrence source verification:
|
||||
Two independent naming axes meet in this layer. They must not be conflated or
|
||||
blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
|
||||
substitution.
|
||||
|
||||
**Axis 1 — MCP tool names: `rig_*` (architecture.md is STALE → corrected).**
|
||||
slice-00 §1.4 confirms all 17 MCP tools are `rig_*`. The `rigged_*`
|
||||
references in `architecture.md` are stale text from a rename that predates
|
||||
v0.2.0:
|
||||
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
|
||||
registers **18** tools, all named `rig_*`
|
||||
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
|
||||
`rig_rig_nodes` (`mcp-server.ts:288`), `rig_send` (`:305`),
|
||||
`rig_capture` (`:339`), `rig_chatroom_send` (`:364`) and `rig_chatroom_watch`
|
||||
(`:400`). No `rigged_*` name other than the tmux keys below remains in product
|
||||
source: **0** hits
|
||||
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
|
||||
|
||||
> Drift-fix D5 (MCP-tool-name axis) — `architecture.md` §5 `node-inventory.ts`
|
||||
> description says MCP `rigged_rig_nodes`; §6 chat flow says MCP
|
||||
> `rigged_chatroom_send` + `rigged_chatroom_watch`. Corrected to **`rig_*`**.
|
||||
> Re-confirmed at source: `mcp-server.ts:288` registers `"rig_rig_nodes"`,
|
||||
> `:305` `"rig_send"`, `:339` `"rig_capture"`, `:364` `"rig_chatroom_send"`,
|
||||
> `"rig_chatroom_watch"` (tool #17). Zero `rigged_chatroom_send` /
|
||||
> `rigged_send` / `rigged_rig_nodes` in non-test product source (grep clean
|
||||
> @HEAD). slice-00 §1.4; the rename landed in `b183c50c` pre-v0.2.0.
|
||||
|
||||
**Axis 2 — tmux metadata keys: `@rigged_*` (architecture.md is CORRECT → do
|
||||
NOT change).** The tmux metadata keys written at claim/bind time are a
|
||||
SEPARATE thing from MCP tool names and were NOT renamed:
|
||||
|
||||
> Drift-nuance D5 (tmux-metadata-key axis) — `architecture.md` §6 "Whoami
|
||||
> and adopted-session parity flow" lists `@rigged_node_id`,
|
||||
> `@rigged_session_name`, `@rigged_rig_id`, `@rigged_rig_name`,
|
||||
> `@rigged_logical_id`. These are **CORRECT as-is** — verified literally at
|
||||
> source: `claim-service.ts:77-81` writes exactly these five `@rigged_*`
|
||||
> keys. Do NOT blanket-sed `rigged` → `rig`; this is a per-occurrence
|
||||
> verify, not a global substitution. (The metadata-key axis is detailed in
|
||||
> `agent-spec-and-startup.md` §6.) Re-confirmed `claim-service.ts:77-81`
|
||||
> @HEAD.
|
||||
**Axis 2 — tmux metadata keys are `@rigged_*`, and are correct as-is.** The
|
||||
tmux metadata keys written at claim/bind time are a separate thing from MCP
|
||||
tool names and were not renamed. `setRiggedMetadata` in
|
||||
`packages/daemon/src/domain/claim-service.ts:180`–`184` writes exactly **5**
|
||||
keys (`grep -c '@rigged_' packages/daemon/src/domain/claim-service.ts`):
|
||||
`@rigged_node_id`, `@rigged_session_name`, `@rigged_rig_id`,
|
||||
`@rigged_rig_name`, `@rigged_logical_id`. Do not blanket-sed `rigged` → `rig`.
|
||||
The metadata-key axis is detailed in `agent-spec-and-startup.md` (identity:
|
||||
whoami, bind, adopt).
|
||||
|
||||
## 2. Transport and communication domain services
|
||||
|
||||
(`architecture.md` §5 "Transport and communication")
|
||||
All under `packages/daemon/src/domain/`:
|
||||
|
||||
- `session-transport.ts` — communication primitives: send/capture/broadcast
|
||||
with session resolution (canonical + legacy names), mid-work detection,
|
||||
honest error reporting, pod/rig/global targeting.
|
||||
- `transcript-store.ts` — pipe-pane transcript management: ANSI stripping on
|
||||
read, boundary markers, readTail, grep. Filesystem-backed, NOT SQLite.
|
||||
- `history-query.ts` — transcript + chat search. Prefers `rg` when
|
||||
available, falls back to `grep -E`, surfaces which backend was used.
|
||||
Re-confirmed: `history-query.ts:7` `backend: "rg" | "grep" | "none"`;
|
||||
`:103` execs `rg -i --no-filename -e <pattern>`; `:108` returns
|
||||
`backend: "rg"`.
|
||||
with session resolution, send-readiness classification (interactive-prompt
|
||||
and mid-work detection), honest error reporting, and list/pod/rig/global
|
||||
targeting.
|
||||
- `transcript-store.ts` — transcript file management: path convention, ANSI
|
||||
stripping on read, boundary markers, `readTail`, `readFull`, `grep`.
|
||||
Filesystem-backed, NOT SQLite. The capture that writes the files is
|
||||
`transcript-rotation.ts` (§4).
|
||||
- `history-query.ts` — transcript + chat search. Prefers `rg`, falls back to
|
||||
`grep -E`, surfaces which backend was used: `history-query.ts:8`
|
||||
`backend: "rg" | "grep" | "none"`; `:252` runs
|
||||
`rg -i --no-filename -e <pattern>` over the rig's transcript directory;
|
||||
`:267` falls back to `grep -E -i -h -e <pattern>`.
|
||||
- `ask-service.ts` — context-engineering evidence pack: gathers rig summary
|
||||
plus transcript excerpts, chat excerpts, insufficiency state, guidance.
|
||||
Does NOT call an external LLM.
|
||||
- `chat-repository.ts` — durable rig-scoped chat: CRUD for `chat_messages`
|
||||
table, SSE-compatible event emission.
|
||||
- `chat-repository.ts` — durable rig-scoped chat over the `chat_messages`
|
||||
table (`send`, `sendTopic`, `history`, `latest`, `searchChat`, `clear`). The
|
||||
repository emits no events: `routes/chat.ts` emits `chat.message` on the
|
||||
event bus after each send or topic, and `/watch` streams those as SSE.
|
||||
|
||||
Routes: `routes/{transport,transcripts,ask,chat,whoami}.ts` — all confirmed
|
||||
present @HEAD.
|
||||
Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
|
||||
— all present.
|
||||
|
||||
## 3. Communication flow
|
||||
|
||||
(`architecture.md` §6 "Communication flow")
|
||||
|
||||
`rig send <session> "message"` → CLI → `POST /api/transport/send` →
|
||||
`SessionTransport`:
|
||||
`SessionTransport.send()`:
|
||||
|
||||
1. Resolve session name (canonical or legacy; by session/rig/pod/global).
|
||||
2. Check mid-work state (unless `--force`) — re-confirmed
|
||||
`session-transport.ts:136-141` (`findPatternEvidence(recentLines,
|
||||
MID_WORK_PATTERNS)`); legacy mid-work check at `:666`.
|
||||
3. Two-step tmux send: a unique file/buffer pasted with `paste-buffer -d -r -p` at every payload size → ~200ms delay → separate `C-m`. Bracketed paste preserves multiline input in supporting TUIs; the payload never enters a shell argument. A successful paste proves transport execution, not runtime consumption.
|
||||
(`session-transport.ts:717` submits `C-m`).
|
||||
4. Optional `--verify`: capture post-send pane, check message visibility
|
||||
(`session-transport.ts:305` `verify?`; `:694` `if (opts?.verify)`).
|
||||
1. Resolve the session name (`resolveBySessionName`,
|
||||
`session-transport.ts:729`): not found → 404; the same name in more than
|
||||
one rig → 409. Pod, rig, global and `--to` list targets go through
|
||||
`POST /api/transport/broadcast`, which resolves them with
|
||||
`resolveSessions`.
|
||||
2. Classify send readiness. Only a positive interactive-prompt reading
|
||||
(`needs_input`) refuses, with `target_needs_input`
|
||||
(`session-transport.ts:1272`), unless the caller passes
|
||||
`--dangerously-interact --reason`. A mid-work reading (`running`, from a
|
||||
fresh runtime hook or the pane's mid-work patterns,
|
||||
`findPatternEvidence(recentLines, MID_WORK_PATTERNS)`, `:225`) or an
|
||||
`unknown` one proceeds with an advisory `warning` (`:1321`). `--force` has
|
||||
no effect on this path.
|
||||
3. Two-step tmux send: a unique file/buffer pasted with `paste-buffer -d -r -p`
|
||||
at every payload size (`packages/daemon/src/adapters/tmux.ts:604`) →
|
||||
~200ms delay (`session-transport.ts:1378`) → separate named `Enter` (`:1385`).
|
||||
Bracketed paste preserves multiline input in supporting TUIs; the payload
|
||||
never enters a shell argument. A successful paste proves transport
|
||||
execution, not runtime consumption.
|
||||
4. Optional `--verify`: capture the last 30 pane lines before and after the
|
||||
send and count the message's first 40 characters in each. A higher count
|
||||
after the send reports `outcome: "delivered"`; otherwise the outcome is
|
||||
`rendered-unconfirmed` — text and Enter landed but the capture could not
|
||||
re-confirm the render, which is not a failure (`session-transport.ts:472`
|
||||
`verify?`; `:1403` `if (opts?.verify)`; `:1415`
|
||||
`verified = postCount > preCount`).
|
||||
5. Honest result with reason on failure.
|
||||
|
||||
Architecture Rule 18 (carried, `architecture.md` §7): tmux is transport, not
|
||||
truth — `send/capture/broadcast` wrap tmux reliably with honest errors.
|
||||
Architecture rule 18 (`architecture-rules-and-event-system.md`): tmux is
|
||||
transport, not truth — `send/capture/broadcast` wrap tmux reliably with honest
|
||||
errors.
|
||||
|
||||
### 3b. Cross-host coordination verbs (v0.4.6 — OPR.0.4.6.MH4)
|
||||
### 3b. Cross-host coordination verbs
|
||||
|
||||
`rig send/capture/transcript/broadcast --host <id>` (and the `agent@rig@host`
|
||||
target sugar on the session-target verbs) cross the host boundary with **zero
|
||||
daemon-side changes** — the remote daemon's existing local routes do all the
|
||||
work; the only net-new is the CLI transport branch.
|
||||
target sugar on the session-target verbs) cross the host boundary through the
|
||||
remote daemon's ordinary local routes; the cross-host logic is in the CLI.
|
||||
|
||||
- **The seam principle (arch-ruled): SIDEDNESS + CALLER pick the seam.**
|
||||
ONE-SIDED remote ops from a BEARER-CAPABLE caller (the CLI resolves the
|
||||
registry bearer locally) go **CLI-DIRECT** via the shipped
|
||||
`runRemoteHttpOp` — one hop, no local-daemon involvement (the
|
||||
`ps --all-hosts` precedent). TWO-SIDED ops (MH-3's queue handoff closes the
|
||||
local source and creates the remote successor — the local daemon owns half
|
||||
the transaction) or BEARER-INCAPABLE callers (the browser: MC-action,
|
||||
MH-2's read-through) use the **daemon-side forward-then-strip**. MH-4's
|
||||
four verbs are pure one-sided remote ops from the CLI.
|
||||
- **Transport is dictated by the host entry (ssh XOR http), never a
|
||||
per-call choice:** ssh hosts keep the shipped shell-out byte-verbatim for
|
||||
send/capture; http hosts (the `pair` front door's kind) take the
|
||||
CLI-direct branch to `POST /api/transport/send|capture|broadcast` /
|
||||
`GET /api/transcripts/*` with the SAME bodies/paths the local CLI builds
|
||||
(one route, two callers = wrap parity by construction). transcript and
|
||||
broadcast are http-only (no ssh path exists for them); a wrong-transport
|
||||
verb dies with the structured requirement error — never a fallback.
|
||||
- **Terminal-bearer posture (named limitation, v0 — `/api/transport/*`
|
||||
ONLY):** the remote's transport routes (send/capture/broadcast) gate on
|
||||
ITS TERMINAL bearer class (`OPENRIG_TERMINAL_BEARER_TOKEN`, default null →
|
||||
pass-through; the tailnet is the auth boundary by design), while the CLI
|
||||
presents the REGISTRY bearer from `hosts.yaml` when one is configured;
|
||||
for a URL-only anonymous host the `Authorization` header is omitted.
|
||||
A remote enforcing a
|
||||
DIFFERENT terminal bearer surfaces as the structured `permission-gate`
|
||||
step (never a hang, never silent). Remedy: set the remote terminal bearer
|
||||
equal to the paired registry bearer, or rely on the tailnet boundary.
|
||||
**The transcript read is DELIBERATELY outside this class (arch n2):**
|
||||
`/api/transcripts/*` mounts UNGATED (`server.ts` — the shipped open-route
|
||||
posture; daemon-local trust boundary with route-level credential
|
||||
redaction as the protective primitive), so a wrong terminal bearer that
|
||||
permission-gates `send --host` does NOT gate `transcript --host` — the
|
||||
read keeps succeeding, and the proof matrix must not treat the auth-fail
|
||||
class as uniform across the four verbs. A coherent transcript-read auth
|
||||
policy across tail/grep/full is a named future slice per
|
||||
`routes/transcripts.ts`'s own comment (orch approved-option-a).
|
||||
Bearer-class unification is likewise a NAMED follow-up, not v0 (no
|
||||
un-asked auth machinery).
|
||||
- **BR-1 holds:** the 3-part form is CLI-edge sugar only (suffix must match
|
||||
a REGISTERED host id, else passthrough + a loud host hint); every session
|
||||
string that reaches any daemon stays `member@rig`; the host travels
|
||||
out-of-band. Durable cross-host coordination stays MH-3's queue — MH-4
|
||||
adds no queue surface.
|
||||
- **Sidedness and caller pick the seam.** One-sided remote operations from a
|
||||
caller that can resolve the registry bearer locally (the CLI) go
|
||||
**CLI-direct** through `runRemoteHttpOp`
|
||||
(`packages/cli/src/remote-host-ops.ts:25`) — one hop, no local-daemon
|
||||
involvement, as `rig ps --all-hosts` does. Two-sided operations (a
|
||||
cross-host queue handoff closes the local source and creates the remote
|
||||
successor, so the local daemon owns half the transaction) and callers
|
||||
without the bearer (the browser: Mission Control actions and the `?host=`
|
||||
read-through) go through the local daemon, which forwards to the remote.
|
||||
These four verbs are one-sided remote operations from the CLI.
|
||||
- **The host entry dictates the transport (ssh or http), never a per-call
|
||||
choice.** ssh hosts shell out over ssh for send/capture
|
||||
(`packages/cli/src/cross-host-executor.ts`); http hosts (the kind
|
||||
`rig host pair` registers) go CLI-direct to
|
||||
`POST /api/transport/send|capture|broadcast` / `GET /api/transcripts/*` with
|
||||
the same bodies and paths the local CLI uses. transcript and broadcast are
|
||||
http-only (no ssh path exists for them); on an ssh host they fail with a
|
||||
structured error naming the transport requirement
|
||||
(`remote-host-ops.ts:47`), never a fallback.
|
||||
- **Terminal-bearer posture (`/api/transport/*` only).** The remote's
|
||||
transport routes (send/capture/broadcast) gate on its terminal bearer
|
||||
(`routes/transport.ts:26`; `OPENRIG_TERMINAL_BEARER_TOKEN`,
|
||||
`packages/daemon/src/index.ts:283`). With no token configured the
|
||||
middleware passes every request through
|
||||
(`packages/daemon/src/middleware/auth-bearer-token.ts:99`) — the tailnet is
|
||||
the auth boundary by design — except that a daemon bound to an explicit
|
||||
address that is neither local nor tailnet falls back to
|
||||
`OPENRIG_AUTH_BEARER_TOKEN`
|
||||
(`index.ts:289`). The CLI presents the registry bearer from `hosts.yaml`
|
||||
when one is configured; for a URL-only anonymous host the `Authorization`
|
||||
header is omitted. A remote enforcing a different terminal bearer answers
|
||||
401, which surfaces as the structured `permission-gate` step
|
||||
(`packages/cli/src/host-registry.ts:53`) — never a hang, never silent.
|
||||
Remedy: set the remote terminal bearer equal to the paired registry bearer,
|
||||
or rely on the tailnet boundary. **The transcript read is deliberately
|
||||
outside this class:** `/api/transcripts/*` mounts with no bearer middleware
|
||||
(`packages/daemon/src/server.ts:749`, beside the gated `/api/transport` at
|
||||
`:750`), and credential-shaped text is redacted on the `/full` route only
|
||||
(`routes/transcripts.ts:292`). So a wrong terminal bearer that
|
||||
permission-gates `send --host` does NOT gate `transcript --host` — the read
|
||||
keeps succeeding, and the auth-failure class is not uniform across the four
|
||||
verbs. The route's own comment (`routes/transcripts.ts:250`) leaves a
|
||||
coherent transcript-read auth policy across tail/grep/full for later.
|
||||
- **The 3-part form is CLI-edge sugar only.** `resolveCrossHostTarget`
|
||||
(`packages/cli/src/cross-host-target.ts:58`) strips the suffix only when it
|
||||
resolves to a registered host, or equals this host's own id, which routes
|
||||
home (`:95`); otherwise the string passes through unchanged with a loud
|
||||
host hint. Every session string that reaches any daemon stays
|
||||
`member@rig`; the host travels out-of-band. Durable cross-host
|
||||
coordination stays the queue's cross-host routing
|
||||
(`coordination-primitive.md`); these four verbs add no queue surface.
|
||||
|
||||
## 4. Transcript flow
|
||||
|
||||
(`architecture.md` §6 "Transcript flow")
|
||||
|
||||
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation
|
||||
(before harness boot).
|
||||
2. Raw terminal output streams to
|
||||
`~/.openrig/transcripts/{rig-name}/{session-name}.log`.
|
||||
1. `NodeLauncher` starts transcript rotation right after tmux session
|
||||
creation, before the harness boots
|
||||
(`packages/daemon/src/domain/node-launcher.ts:181`).
|
||||
2. Each rotation tick runs `tmux capture-pane -p -S -<lines>`
|
||||
(`adapters/tmux.ts:1020`) and atomically overwrites (temp file + rename,
|
||||
`transcript-rotation.ts:146`)
|
||||
`~/.openrig/transcripts/{rig-name}/{session-name}.log`. Defaults: 1000
|
||||
trailing lines every 2 s (`transcript-rotation.ts:27`–`28`), tunable with
|
||||
`OPENRIG_TRANSCRIPTS_LINES` and `OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS`.
|
||||
This bounded capture replaced the earlier `tmux pipe-pane` stream
|
||||
(`transcript-rotation.ts:3`), so the file holds a trailing window, not the
|
||||
whole session.
|
||||
3. `TranscriptStore` owns path convention, ANSI stripping on read, boundary
|
||||
markers, `readTail`, `grep`.
|
||||
markers, `readTail`, `readFull`, `grep`.
|
||||
4. `rig transcript <session> --tail N / --grep "pattern"` provides
|
||||
agent-facing access.
|
||||
5. On restore: a boundary marker is written before re-launch; pipe-pane
|
||||
reconnects to the same file (append). (Restore-side detail in
|
||||
`lifecycle-snapshot-restore.md` §3.)
|
||||
5. On restore: a `--- SESSION BOUNDARY: … ---` marker is written before
|
||||
re-launch (`restore-orchestrator.ts:1100`); each rotation tick keeps every
|
||||
boundary line as a header above the fresh capture
|
||||
(`transcript-rotation.ts:122`). (Restore-side detail in
|
||||
`lifecycle-snapshot-restore.md`.)
|
||||
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts,
|
||||
insufficiency state, and guidance.
|
||||
|
||||
Architecture Rule 19 (carried): transcripts are raw capture via pipe-pane,
|
||||
ANSI strip on read; `rg` preferred, `grep -E` fallback. Rule 22: `rig ask`
|
||||
is context engineering — gathers evidence, does NOT call an external LLM;
|
||||
the agent IS the LLM.
|
||||
Architecture rule 19, as the code implements it: transcripts are a bounded
|
||||
`capture-pane` snapshot (not pipe-pane), ANSI-stripped on read; `rg`
|
||||
preferred, `grep -E` fallback. Rule 22: the daemon's `rig ask` is context
|
||||
engineering — it gathers evidence and does NOT call an external LLM; the
|
||||
agent IS the LLM. The one exception is the explicit CLI flag `rig ask --wake`
|
||||
(`packages/cli/src/commands/ask.ts:88`), which runs a headless resume of a
|
||||
Claude or Codex session (`claude -p --resume` / `codex exec resume`,
|
||||
`packages/cli/src/ask-wake.ts:58`–`60`) to answer one question.
|
||||
|
||||
## 5. Chat flow
|
||||
|
||||
(`architecture.md` §6 "Chat flow")
|
||||
|
||||
1. `rig chatroom send <rig> "message"` → `POST
|
||||
/api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`.
|
||||
/api/rigs/:rigId/chat/send` → `ChatRepository.send()`
|
||||
(`routes/chat.ts:35`); the route then emits `chat.message` (`:37`).
|
||||
2. SSE: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages.
|
||||
3. History: `GET /api/rigs/:rigId/chat/history` returns full channel
|
||||
history; `POST /api/rigs/:rigId/chat/topic` persists topic markers.
|
||||
4. UI: chat-room tab in the rig drawer.
|
||||
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (D5 axis-1 correction
|
||||
— `architecture.md` §6 said `rigged_*`; source `mcp-server.ts:364` +
|
||||
tool #17).
|
||||
3. History: `GET /api/rigs/:rigId/chat/history` returns channel history in id
|
||||
order, 100 messages unless `limit` says otherwise
|
||||
(`chat-repository.ts:68`), filterable by `topic`, `after`, `since` and
|
||||
`sender`; `POST /api/rigs/:rigId/chat/topic` persists topic markers.
|
||||
4. UI: a `RigChatPanel` component exists
|
||||
(`packages/ui/src/components/RigChatPanel.tsx`), but no UI view mounts it:
|
||||
**1** file under `packages/ui/src` names it, its own
|
||||
(`git grep -l 'RigChatPanel' -- packages/ui/src | wc -l`).
|
||||
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (`mcp-server.ts:364`,
|
||||
`:400`).
|
||||
6. Source of truth: daemon-backed SQLite (`chat_messages` table), NOT tmux
|
||||
scrollback.
|
||||
|
||||
**ChatMessage** type (`architecture.md` §4): durable rig-scoped message —
|
||||
**ChatMessage** type (`chat-repository.ts:6`): durable rig-scoped message —
|
||||
`id`, `rigId`, `sender`, `kind`, `body`, `topic`, `createdAt`.
|
||||
|
||||
## 6. Compatibility notes (carried verbatim, `architecture.md` §11)
|
||||
## 6. Compatibility notes
|
||||
|
||||
The intentional limits in this layer:
|
||||
The intentional limits in this layer (numbered as in
|
||||
`architecture-rules-and-event-system.md`):
|
||||
|
||||
- Note 4 — `rig ask` gathers context only; does not call an external LLM.
|
||||
The agent reasons about the gathered evidence.
|
||||
- Note 4 — the daemon's `rig ask` gathers context only; it does not call an
|
||||
external LLM. The agent reasons about the gathered evidence. Only the
|
||||
explicit `--wake` flag executes a runtime (§4).
|
||||
- Note 5 — transcript search prefers `rg` but falls back to `grep -E`;
|
||||
search quality/performance varies by backend.
|
||||
- Note 6 — chat is rig-scoped only: no cross-rig channels or DMs.
|
||||
- Note 7 — `--verify` on `rig send` checks pane content for message
|
||||
visibility but can produce false positives from pre-existing matching
|
||||
content. Known limitation.
|
||||
- Note 7 — `--verify` on `rig send` checks the pane for the message by
|
||||
comparing snippet counts before and after the send (§3 step 4); when the
|
||||
capture cannot re-confirm the render it reports `rendered-unconfirmed`,
|
||||
not a failure.
|
||||
|
||||
(The full §11 compatibility-notes list lives in
|
||||
(The full compatibility-notes list lives in
|
||||
`architecture-rules-and-event-system.md`.)
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D5 (the careful one — resolved per-occurrence, NOT blanket-replaced):**
|
||||
Axis 1 (MCP tool names) corrected `rigged_*` → `rig_*` (3 sites: §5
|
||||
node-inventory, §6 chat flow ×2). Axis 2 (tmux `@rigged_*` metadata keys)
|
||||
verified literally correct at `claim-service.ts:77-81` and left unchanged.
|
||||
No slice-00 numeric drift applies to this module's split content.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — transport/transcript/chat/ask are among the 49 route
|
||||
mounts; MCP tool count (17, `rig_*`) is anchored there.
|
||||
- `agent-spec-and-startup.md` §6 — the tmux `@rigged_*` metadata-key axis
|
||||
detail (whoami/adopt).
|
||||
- `lifecycle-snapshot-restore.md` §3 — restore-side transcript boundary
|
||||
- `daemon-core.md` — the route-mount surface these routes sit in, and the MCP
|
||||
tool count.
|
||||
- `agent-spec-and-startup.md` — the tmux `@rigged_*` metadata-key axis detail
|
||||
(whoami/adopt).
|
||||
- `lifecycle-snapshot-restore.md` — restore-side transcript boundary
|
||||
markers.
|
||||
- `coordination-primitive.md` §3b — MH-3's cross-host QUEUE routing (the
|
||||
two-sided daemon-forward twin of §3b's one-sided CLI-direct verbs).
|
||||
- `cli-reference.md` § Cross-host execution — the per-verb transport
|
||||
capability table, parse rules, and http failure taxonomy (MH-3 + MH-4).
|
||||
- Source roots: `packages/daemon/src/domain/{session-transport,
|
||||
transcript-store,history-query,ask-service,chat-repository}.ts`,
|
||||
- `coordination-primitive.md` — cross-host queue routing (the two-sided
|
||||
daemon-forward twin of §3b's one-sided CLI-direct verbs).
|
||||
- `../cli-reference.md` § Cross-host execution (`--host <id>`) — the per-verb
|
||||
transport table, the `agent@rig@host` parse rules, and the structured
|
||||
failure modes.
|
||||
- Source roots: `packages/daemon/src/domain/{session-transport,transcript-store,transcript-rotation,history-query,ask-service,chat-repository}.ts`,
|
||||
`packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`,
|
||||
`packages/cli/src/mcp-server.ts`,
|
||||
`packages/cli/src/{remote-host-ops,cross-host-target,cross-host-executor}.ts` (v0.4.6 MH-4).
|
||||
`packages/cli/src/{remote-host-ops,cross-host-target,cross-host-executor,ask-wake}.ts`.
|
||||
@@ -1,509 +1,219 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
title: Workflow Runtime — Specs, Packets, and Lifecycle Graphs
|
||||
status: active
|
||||
topics: [coordination, runtime-control]
|
||||
topics: [orchestration, coordination]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the daemon-native Workflow Runtime works — workflow specs
|
||||
cache, instance state, step trails, the transactional-scribe projection
|
||||
contract, or the watchdog policy set including workflow-keepalive.
|
||||
siblings: [coordination-primitive.md, mission-control.md]
|
||||
prerequisite-reads: [../README.md, coordination-primitive.md]
|
||||
last-verified-against-source: slice/opr-0.4.6.wf2-spec-language tip (base d18907ed — WF-1 merged)
|
||||
last-updated: 2026-07-06
|
||||
Following workflow validation, instantiation, projection, routing, failure
|
||||
recovery, lifecycle graph revisions, or the corresponding CLI/API.
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
# Workflow Runtime — Specs, Packets, and Lifecycle Graphs
|
||||
|
||||
The daemon-native Workflow Runtime (PL-004 Phase D) turns an intended
|
||||
sequence of work into durable SQLite state: declarative workflow specs, live
|
||||
instance state, append-only step trails, and the load-bearing
|
||||
transactional-scribe contract. The PRD §L4 operating model is
|
||||
owner-as-author semantically plus workflow-as-transactional-scribe
|
||||
mechanically (`architecture.md` §3 L398).
|
||||
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
|
||||
it does not establish the version or behavior of a running daemon.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
A workflow binds a specification to durable queue packets. The daemon
|
||||
records the instance, its current frontier, and the step trail; agents supply
|
||||
step outcomes and evidence. Serial workflows and dependency graphs share
|
||||
this runtime. The engine checks declared fields and recorded state; it does
|
||||
not judge the substance of a referenced proof document.
|
||||
|
||||
## 1. The three Phase D tables (+ the diagnostic addition)
|
||||
## Source map
|
||||
|
||||
Re-confirmed at HEAD in `packages/daemon/src/db/migrations/`:
|
||||
All paths in this table are under `packages/daemon/src/`.
|
||||
|
||||
- **`workflow_specs`** (`033_workflow_specs.ts:33` `CREATE TABLE … workflow_specs`)
|
||||
— a read-through cache of human-authored markdown/YAML spec files. Sources
|
||||
are workspace-surface; the daemon caches by `(name, version)` with a
|
||||
content `source_hash` so valid operator edits to the spec file win at next
|
||||
read (workspace-surface reconciliation). Spec authoring stays
|
||||
markdown-authoritative; the cache exists for fast lookup and runtime
|
||||
resolution (`architecture.md` §3 L400).
|
||||
- **`workflow_instances`** (`034_workflow_instances.ts:45` `CREATE TABLE …
|
||||
workflow_instances`) — live state per running workflow: `status`
|
||||
(`active|waiting|completed|failed`), `current_frontier_json` (active qitem
|
||||
ids), `hop_count` (loop-guard counter), `last_continuation_decision_json`.
|
||||
Instances survive daemon restart from SQLite — no filesystem
|
||||
reconciliation (`architecture.md` §3 L402).
|
||||
- **`workflow_step_trails`** (`035_workflow_step_trails.ts:32` `CREATE TABLE …
|
||||
workflow_step_trails`) — append-only history of meaningful step
|
||||
transitions. Every closure produces one trail row pairing the prior qitem
|
||||
with the next qitem (null on terminal). `WorkflowStepTrailLog.record()` is
|
||||
the only writer (`architecture.md` §3 L404).
|
||||
- **`040_workflow_specs_diagnostic.ts`** — slice-11 (`f68f453a`,
|
||||
slice-00 §1.3 provenance). An `ALTER TABLE ADD COLUMN` adding
|
||||
parser/validator diagnostic columns to `workflow_specs` (**no new table,
|
||||
no constraint changes beyond a DEFAULT**; the cache carries the
|
||||
parse/validation diagnostic for the UI to render — `040_..._diagnostic.ts:5–25`).
|
||||
**This migration is net-new since `architecture.md` was last edited** and
|
||||
is not described in any prior as-built doc (slice-00 §1.3 — `f68f453a`
|
||||
postdates the §3 body) — authored here from source per the slice-08
|
||||
drift-to-fix register.
|
||||
| File / symbol | Responsibility |
|
||||
|---|---|
|
||||
| [`domain/workflow-types.ts`](../../../packages/daemon/src/domain/workflow-types.ts) | Spec, instance, trail, frontier, and failure-occurrence contracts. |
|
||||
| [`WorkflowSpecCache`](../../../packages/daemon/src/domain/workflow-spec-cache.ts) | YAML parsing, source-hash cache, stored specs, diagnostics and retention. |
|
||||
| [`WorkflowValidator`](../../../packages/daemon/src/domain/workflow-validator.ts) | Roles, exits, routing/dependency edges, pins, cycles, and advisories. |
|
||||
| [`WorkflowRuntime`](../../../packages/daemon/src/domain/workflow-runtime.ts) | Instantiation, inspection, routing, resume, abort, and lifecycle facade. |
|
||||
| [`WorkflowProjector`](../../../packages/daemon/src/domain/workflow-projector.ts) | Close the current step and update its successor/frontier state. |
|
||||
| [`WorkflowInstanceStore`](../../../packages/daemon/src/domain/workflow-instance-store.ts) | Instances, guarded updates, frontier bindings, and failure occurrences. |
|
||||
| [`WorkflowStepTrailLog`](../../../packages/daemon/src/domain/workflow-step-trail-log.ts) | Append/read step history. |
|
||||
| [`workflow-reconciliation.ts`](../../../packages/daemon/src/domain/workflow-reconciliation.ts) | Inspect graph changes, apply explicit revisions, recover operation receipts. |
|
||||
| [`workflow-keepalive.ts`](../../../packages/daemon/src/domain/policies/workflow-keepalive.ts) | Watchdog evaluation and reminders. |
|
||||
|
||||
`036_watchdog_policy_enum_extension.ts` is a documenting no-op that records
|
||||
the Phase D watchdog enum extension (Phase C uses application-layer
|
||||
enforcement via the `PHASE_D_POLICIES` array, so no DDL is needed —
|
||||
`architecture.md` §3 L363).
|
||||
## Specification and cache
|
||||
|
||||
## 2. The transactional-scribe contract
|
||||
`parseWorkflowSpec` parses the `workflow` document and checks known key sets.
|
||||
`WorkflowValidator.validate` then checks relationships between roles, steps,
|
||||
exits, and targets. The exits are `handoff`, `waiting`, `done`, and `failed`.
|
||||
An exit-specific `next_hop.on` mapping takes precedence over structural
|
||||
routing; without a mapping, `resolveNextStep` considers `forbid`, suggested
|
||||
roles, `require`, and then the next array element.
|
||||
|
||||
The load-bearing Phase D guarantee is implemented in
|
||||
`WorkflowProjector.project()` (`packages/daemon/src/domain/workflow-projector.ts`).
|
||||
Re-confirmed at HEAD: the projector header (`workflow-projector.ts:1–20`)
|
||||
declares "transactional-scribe contract" and `project()` runs a single
|
||||
`db.transaction` (`workflow-projector.ts:184` `const txn = this.db.transaction(...)`).
|
||||
Inside that one transaction:
|
||||
The language includes `harness`, `host`, singular `gate`, `depends_on`, typed
|
||||
`acceptance`, and waiting re-presentation settings. At this pin,
|
||||
`WORKFLOW_AGENT_HARNESSES` contains only `claude-code` and `codex`; support for
|
||||
another runtime elsewhere in OpenRig does not add it to this pin's workflow
|
||||
harness grammar. Nonlocal host pins can validate as registered names but
|
||||
`instantiate` still rejects execution with `host_pin_remote_unsupported`.
|
||||
This is a workflow-step restriction, not a claim that all queue traffic is
|
||||
local-only.
|
||||
|
||||
1. Close the current packet (state mutation on `queue_items`).
|
||||
2. Create the next-step packet (`QueueRepository.createWithinTransaction()`,
|
||||
`workflow-projector.ts:230`).
|
||||
3. Record the trail entry.
|
||||
4. Update the instance frontier + status.
|
||||
5. Persist workflow events.
|
||||
`allowed_exits` and `max_hops` affect validation/projection. In contrast,
|
||||
`continuation_required`, `preserve_lineage`, `closure_required`, closure
|
||||
messages, `skill_refs`, and `spawn_budget` produce
|
||||
`declared_not_enforced_v1` advisories. Legacy `gates: []` and
|
||||
`next_hop.mode: prefer` are rejected by the parser rather than silently
|
||||
accepted. Prerequisite cycles are rejected; a routing cycle requires a
|
||||
`max_hops` declaration and is distinct from a prerequisite cycle.
|
||||
|
||||
Either everything commits or everything rolls back; lost handoffs are
|
||||
impossible by design. Post-commit, subscribers are notified and the next
|
||||
owner is nudged (`architecture.md` §3 L406).
|
||||
`WorkflowSpecCache.readThrough` parses the source file, hashes it, and
|
||||
updates the `(name, version)` cache row. Full `spec_json` preserves fields
|
||||
that the older scalar columns cannot represent. A same-name/same-version
|
||||
source edit can update that cache row; the version label alone is not an
|
||||
immutable content pin. `getByNameVersion` is the runtime lookup.
|
||||
Diagnostic-only rows, moved sources, and versions retained for unfinished
|
||||
work have separate paths in `readThrough`, `writeDiagnostic`, and
|
||||
`isPinnedByUnfinishedWork`.
|
||||
|
||||
`WorkflowRuntime` (`packages/daemon/src/domain/workflow-runtime.ts:61`
|
||||
`export class WorkflowRuntime`) is the orchestration class above the
|
||||
projector.
|
||||
## Durable state
|
||||
|
||||
**Phase D scope boundary** (`architecture.md` §3 L412): excludes multi-hop
|
||||
chaining, gate-return-sweep, and the closure-enforcement path. The daemon's
|
||||
transactional state remains the closure authority via Phase A's hot-potato
|
||||
strict-rejection (see `coordination-primitive.md` §3); the workflow runtime
|
||||
**projects on closure, it does not gate closure**.
|
||||
The migration files record the storage evolution; they are not a count of
|
||||
all database migrations:
|
||||
|
||||
## 3. The workflow-keepalive watchdog policy
|
||||
| Migration | Workflow state introduced |
|
||||
|---|---|
|
||||
| [`033`](../../../packages/daemon/src/db/migrations/033_workflow_specs.ts), [`034`](../../../packages/daemon/src/db/migrations/034_workflow_instances.ts), [`035`](../../../packages/daemon/src/db/migrations/035_workflow_step_trails.ts) | Spec cache, instances/frontier/current step, and append-only trail API. |
|
||||
| [`040`](../../../packages/daemon/src/db/migrations/040_workflow_specs_diagnostic.ts), [`050`](../../../packages/daemon/src/db/migrations/050_workflow_spec_json.ts) | Cache diagnostics and complete parsed spec JSON. |
|
||||
| [`049`](../../../packages/daemon/src/db/migrations/049_workflow_instance_version.ts), [`051`](../../../packages/daemon/src/db/migrations/051_workflow_resume.ts), [`052`](../../../packages/daemon/src/db/migrations/052_workflow_instance_bound_rig.ts) | Optimistic version guard, resume/hop baseline, and bound rig. |
|
||||
| [`079`](../../../packages/daemon/src/db/migrations/079_workflow_lifecycle_parallel.ts) | Lifecycle operation key/digest/binding, packet-to-step frontier bindings, and failure occurrences. |
|
||||
|
||||
`workflow-keepalive` is the Phase C-deferred watchdog policy, a TypeScript
|
||||
port of the POC `lib/policies/workflow-keepalive.mjs` adapted to read SQLite
|
||||
(`packages/daemon/src/domain/policies/workflow-keepalive.ts:1–5`).
|
||||
Re-confirmed at HEAD (`workflow-keepalive.ts:5–16`):
|
||||
Current instance states include `active`, `waiting`, `completed`, `failed`,
|
||||
and `aborted`. A multi-packet frontier needs packet-to-step bindings; a
|
||||
single `current_step_id` cannot describe all parallel work.
|
||||
|
||||
- **LOAD-BEARING:** it MUST read `workflow_instances` directly via SQLite —
|
||||
never the markdown source.
|
||||
- Eligibility: `status === "active" || status === "waiting"`. Else
|
||||
`action=terminal, reason="workflow_not_active"`.
|
||||
- Frontier empty + no fallback target: skip with `reason="empty_frontier"`.
|
||||
- Resolves frontier qitem owners by querying `queue_items`; combines with
|
||||
explicit observer/created-by targets; sends to the first resolved target.
|
||||
## Instantiation and owner selection
|
||||
|
||||
The watchdog supervision tree itself (PL-004 Phase C, `031_watchdog_jobs.ts` /
|
||||
`032_watchdog_history.ts`) records only meaningful evaluations; quiet skip
|
||||
reasons (`not_due`, `no_actionable_artifacts`, `active_wake_not_due`) are
|
||||
NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are
|
||||
not woken about scheduler polls (`architecture.md` §3 L362). The Phase D
|
||||
policy enum extends Phase C's three values with `workflow-keepalive`.
|
||||
`WorkflowRuntime.instantiate` resolves a cached spec name or explicit path,
|
||||
validates it, resolves the rig and entry owners, then creates the instance
|
||||
and entry packets through the transactional event/queue path. An explicit
|
||||
unknown `targetRig` fails; an unknown spec-default rig produces an advisory
|
||||
and falls back to unbound operation. A bound rig without a required declared
|
||||
role is a different error.
|
||||
|
||||
## 4. Workflow events
|
||||
`resolveDefaultOwner` uses declared `preferred_targets` first, applying a
|
||||
harness match when pinned. With no preferred targets and a bound rig, it uses
|
||||
[`selectRoleSeat`](../../../packages/daemon/src/domain/workflow-role-resolver.ts):
|
||||
a running managed agent with the declared role and required runtime, ordered
|
||||
by pending-only backlog and then canonical coordinate. Infrastructure and
|
||||
unresolvable/adopted coordinates do not become implicit replacement agents.
|
||||
Explicit owner overrides are reconciled with harness pins. A structured gate
|
||||
resolves either a human destination or a handler role.
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
|
||||
> L410 says "Existing 20 PL-004 events are unchanged" — internally
|
||||
> inconsistent with L394's "32 PL-004 events untouched". **Do NOT carry
|
||||
> either number.** The current `RigEvent` union
|
||||
> (`packages/daemon/src/domain/types.ts:94`) has **73 members** total
|
||||
> (slice-00 §1.8, re-confirmed at HEAD). The additive Phase D `workflow.*`
|
||||
> events are described below WITHOUT asserting a contested PL-004 sub-count.
|
||||
Instantiation also emits advisories for missing members in otherwise
|
||||
registered target rigs. That member check is advisory and does not imply
|
||||
all preferred targets received a live-readiness test.
|
||||
|
||||
Phase D extends `RigEvent` with the additive `workflow.*` events
|
||||
(re-confirmed `domain/types.ts:196–201`): `workflow.instantiated`,
|
||||
`workflow.step_closed`, `workflow.next_qitem_projected`,
|
||||
`workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`
|
||||
(6 members; a separate `workflow_spec` event also exists in the union).
|
||||
## Projection, waiting, and concurrency
|
||||
|
||||
## 5. Route surface
|
||||
`WorkflowProjector.project` checks active/waiting status, frontier membership,
|
||||
packet-to-step identity, allowed exits, and applicable evidence requirements.
|
||||
For a successful advance, queue closure, successor creation, trail entries,
|
||||
frontier/version updates, durable events, and wake intent staging share the
|
||||
transactional notification envelope. Terminal delivery happens after the
|
||||
commit. A committed packet or staged wake is not evidence that its recipient
|
||||
consumed the message.
|
||||
|
||||
`/api/workflow` (`server.ts:495`) — `POST /validate`
|
||||
(`routes/workflow.ts:82`), `POST /instantiate` (`:93`,
|
||||
`getRuntime(c).instantiate(...)`), `POST /project` (`:118`,
|
||||
`getRuntime(c).project(...)` — the transactional-scribe entry),
|
||||
`GET /:instance_id/trace` (instance + trail), `POST /:instance_id/continue`
|
||||
(idempotent inspect). Surface enumerated `routes/workflow.ts:21–28`.
|
||||
Cross-ref: the `rig workflow` CLI surface — see `../cli-reference.md`.
|
||||
The serial path absorbs an identical waiting replay by matching the stored
|
||||
closure intent; changed wait data is a new decision. A consumed terminal
|
||||
packet is no longer on the frontier and is refused. `max_hops` constrains
|
||||
routing relative to the current drive's baseline; resume establishes a new
|
||||
bounded drive rather than erasing history.
|
||||
|
||||
## 6. The WF-1 failure envelope (OPR.0.4.6.WF1)
|
||||
Presence of `depends_on` selects `projectDependencyGraph`. It uses durable
|
||||
packet bindings to advance eligible successors and record per-occurrence
|
||||
failures without treating an unrelated live branch as completed. Inspection
|
||||
reports unknown bindings rather than inventing a step from trail order.
|
||||
|
||||
Added on top of the kept Phase D core (nothing above was re-specced;
|
||||
FR-1 regression tests pin it):
|
||||
The injected [`createWorkflowFrontierPredicate`](../../../packages/daemon/src/domain/workflow-frontier-guard.ts)
|
||||
lets the queue refuse ordinary terminal closure of live workflow packets.
|
||||
Workflow mutations use the explicit workflow path; the queue does not need
|
||||
to import the workflow domain to evaluate that predicate.
|
||||
|
||||
- **Step deadlines (FR-2, derived — never stored):**
|
||||
`workflow-deadline.ts` classifies every active|waiting instance's
|
||||
frontier packet by anchor — claimed w/ `closure_required_at` ·
|
||||
claimed w/ NULL deadline (`claimed_at` + threshold; workflow packets
|
||||
ship tier `mode2`, which has no SLA entry) · never-claimed
|
||||
(`created_at` + threshold) · unclaimed-after-claim (`created_at`;
|
||||
unclaim NULLs `claimed_at`). `WORKFLOW_STEP_STUCK_THRESHOLD_SECONDS`
|
||||
(4h, = the routine-tier SLA) is THE single threshold home; WF-5
|
||||
binds to it. Stuck self-clears on normal re-projection.
|
||||
- **Keepalive auto-arm (FR-3):** instantiate + every handoff ensure
|
||||
ONE per-instance `workflow-keepalive` watchdog job INSIDE the scribe
|
||||
transaction; terminal exits disarm it. Auto-armed jobs carry
|
||||
`context.deadline_gated: true` — quiet while healthy, and their
|
||||
overdue send targets the stuck packet's owner with re-project
|
||||
steering. Operator-registered jobs keep exact POC always-send parity.
|
||||
- **Boot sweep (FR-4):** `workflow-boot-sweep.ts` at daemon startup —
|
||||
re-arms missing keepalives, reissues LOST post-commit nudges
|
||||
(pending frontier packet with `last_nudge_attempt` NULL = the
|
||||
commit-then-crash window, detected from the nudge ledger), surfaces
|
||||
stuck instances; one summary log line.
|
||||
- **Real idempotency (FR-5):** waiting-replay ABSORPTION under the
|
||||
full closure-intent identity (exit/packet/step/actor/resultNote/
|
||||
effective-blocker/evidence deep-equal) — exact replay = zero writes;
|
||||
any mismatch = a new decision via the normal path. Migration 049
|
||||
adds `workflow_instances.version`: every guarded advance bumps it
|
||||
`WHERE version = ?`; a stale writer gets structured
|
||||
`instance_version_conflict` and its whole transaction rolls back.
|
||||
- **max_hops enforced (FR-6):** compared at projection via
|
||||
`exceedsMaxHops(hopCount, baseline, maxHops)` (v1 baseline = 0; the
|
||||
baseline is WF-5's resume seam). Exceeding converts the handoff to
|
||||
an honest structured failure (packet closed, instance failed, guard
|
||||
evidence in trail + `workflow.failed`). Migration 050 adds
|
||||
`workflow_specs.spec_json` — before it, `loop_guards`/`invariants`/
|
||||
`closure`/`entry` were silently DROPPED at projection-time
|
||||
rehydration (column-only rebuild); legacy rows self-heal on
|
||||
readThrough and degrade VISIBLY (named once-per-spec advisory).
|
||||
- **Validation (FR-7):** `parseWorkflowSpec` rejects unknown keys loud
|
||||
at every level against EXPORTED closed keysets (WF-2 extends them);
|
||||
the validator walks reachability/cycles over the projector's own
|
||||
exported `resolveNextStep` — unreachable steps fail; a cycle without
|
||||
`max_hops` fails naming the fix; with it, sanctioned.
|
||||
- **`continue` honesty (FR-8):** relabeled to its real read-only
|
||||
inspector semantics everywhere (CLI description/outcome, route
|
||||
comment); `project` remains the sole advance write path.
|
||||
- **The v2 dispositions (FR-9):** every declared-but-unenforced key —
|
||||
`invariants.{continuation_required,preserve_lineage,closure_required}`,
|
||||
`closure.*`, step `gates[]`, role `skill_refs`, `next_hop.mode:
|
||||
prefer`, `loop_guards.spawn_budget` — produces the fail-open
|
||||
`declared_not_enforced_v1` validator advisory (warning; never
|
||||
blocks). `spawn_budget`'s advisory names its WF-2/WF-6
|
||||
parallel-frontier acceptance pointer (arch ruling 2026-07-06).
|
||||
`fallbackSynthesis` (instance column, never written) is dispositioned
|
||||
in the `workflow-types.ts` JSDoc.
|
||||
## Recovery operations
|
||||
|
||||
## 7. The WF-2 spec language (OPR.0.4.6.WF2)
|
||||
`WorkflowRuntime` distinguishes these operations:
|
||||
|
||||
WF-2 grows the language the ratified WF-1 engine speaks. ONE named
|
||||
engine extension (branch execution); everything else is language +
|
||||
compilation onto shipped seams.
|
||||
| Operation | Meaning |
|
||||
|---|---|
|
||||
| `route` | Replace a current packet's owner while retaining the step. This is not step advancement. Multiple frontier packets require an explicit packet selection. |
|
||||
| `resume` | Redrive failed work with a recorded decision and newly resolved owner. Dependency failures use occurrence identity; multiple unresolved failures require selection. Identical occurrence/decision replay returns the recorded redrive, while changed decision bytes conflict. |
|
||||
| `abort` | Explicitly stop unfinished work with an actor and reason. |
|
||||
| `continue` | Read-only inspection of instance, trail, guidance, reconciliation, frontier, failures, unknowns, and boundary obligations. It does not mechanically advance a step. |
|
||||
|
||||
**Conditional-on-outcome branching (FR-1).** A step may declare
|
||||
`next_hop.on: {<exit>: <step-id>}` — branch keys are the recorded exit
|
||||
enum ONLY (`handoff|waiting|done|failed`; closed set, enforced at
|
||||
parse — `spec_branch_key_invalid`). A MAPPED exit routes to its target
|
||||
INSIDE the same scribe transaction: next qitem created in-txn, instance
|
||||
stays ACTIVE bound to the target, hop count + version guard bumped
|
||||
identically to a linear advance, and the taken branch recorded
|
||||
ADDITIVELY (`lastContinuationDecision.branchTaken` + the trail row's
|
||||
`closure_evidence.branch_taken`) — never in `closure_reason` (closed
|
||||
Phase-A enum). An UNMAPPED `failed`/`done` stays terminal exactly as
|
||||
before; unmapped `waiting` stays a park. The `max_hops` guard fires on
|
||||
ANY route (branch routes create the canonical remediation cycles);
|
||||
cycle detection at validation runs over the structural ∪ branch edge
|
||||
union and requires a declared `max_hops` to sanction any cycle.
|
||||
Routing seam: `resolveNextStep(spec, step, recordedExit?)` — one
|
||||
exported function, structural default when no exit supplied (the
|
||||
validator's path).
|
||||
## Deadlines and exceptions
|
||||
|
||||
**Per-step `harness:` pin (FR-2).** `claude-code | codex` (agent
|
||||
harnesses only — `terminal` rejected at parse with a teaching error; Pi
|
||||
joins in 0.4.7). Owner resolution picks the first `preferred_target`
|
||||
whose node `runtime` column matches (`nodeRuntimeOf`: latest session →
|
||||
node join); no match = structured `harness_pin_unsatisfied` naming the
|
||||
pin + every candidate's runtime. Explicit owner overrides are
|
||||
reconciled too — an override can never silently defeat a pin. Static
|
||||
check at instantiate for every pinned step; re-checked at each route.
|
||||
[`evaluateStepDeadline`](../../../packages/daemon/src/domain/workflow-deadline.ts)
|
||||
derives overdue state from frontier queue rows. Claimed work uses its closure
|
||||
deadline, otherwise its claim time plus **4 hours** (creation time if the
|
||||
claim time is absent). Pending work uses creation time plus **4 hours**.
|
||||
Blocked waits are not overdue under this evaluator. This is a diagnostic,
|
||||
not a substitute for packet existence or binding checks.
|
||||
|
||||
**Per-step `host:` pin (FR-3).** `local`/absent = full execution today.
|
||||
A registry id validates against `~/.openrig/hosts.yaml` (daemon
|
||||
read-only twin; unknown id = `host_not_registered` naming registered
|
||||
ids) but a remote pin fails loud at INSTANTIATE with
|
||||
`host_pin_remote_unsupported` naming the MH-3 boundary + workaround —
|
||||
the queue is local-only until MH-3; no qitem is ever minted into a
|
||||
queue that cannot route it, and there is no silent local fallback.
|
||||
[`resolveExceptionRoute`](../../../packages/daemon/src/domain/workflow-exception-router.ts)
|
||||
selects per-class configuration, then workflow default, host default, and
|
||||
finally orchestrator-first behavior. An unresolved orchestrator route falls
|
||||
back to registered-human selection. A human gate is intrinsically human
|
||||
routed. Agent-routed exceptions use the ordinary workflow tier; human-routed
|
||||
ones use `human-gate`.
|
||||
|
||||
**Structured step-level `gate:` (FR-5 — the socket; WF-5 owns
|
||||
semantics).** Singular per step, closed keyset `{target, summary,
|
||||
evidence_ref}`. HUMAN target (the shipped human-seat predicate) →
|
||||
compiles to a human-routed item (tier `human-gate` + summary +
|
||||
evidence_ref — the shipped 0.4.4 write path), resolved by the shipped
|
||||
`resolve` verb; HANDLER-ROLE target → an ordinary agent item to the
|
||||
role's resolved seat. Routing INTO a gated step creates the gate item
|
||||
as the frontier packet and parks the instance `waiting`; resolve/close
|
||||
continues the flow from that step (the WF-1 unpark — no restart). A
|
||||
gated ENTRY step parks from birth.
|
||||
[`workflow-human-destination.ts`](../../../packages/daemon/src/domain/workflow-human-destination.ts)
|
||||
handles human selection. A valid agent route need not have a human fallback
|
||||
configured. If fallback is needed, missing/ambiguous selection is an explicit
|
||||
error, not an invented destination. Failure admission is transactional;
|
||||
overdue detection is handled by boot/keepalive paths. Runtime reconciliation
|
||||
closes recovered overdue occurrences against their own packets, preserving
|
||||
unresolved siblings and unknown provenance.
|
||||
|
||||
**Dispositions (FR-4) — the inert third state is dead.** The legacy
|
||||
step `gates: [...]` string list is REMOVED at parse
|
||||
(`spec_gates_removed`, what/why/fix naming the new `gate:` object);
|
||||
`next_hop.mode: prefer` is REMOVED at parse
|
||||
(`spec_prefer_mode_removed` — it never had distinct behavior).
|
||||
`skill_refs` / `closure.*` / `invariants.{continuation_required,
|
||||
preserve_lineage,closure_required}` keep their WF-1 FR-9 explicitly-v2
|
||||
advisories; `spawn_budget` stays explicitly-v2 (WF-6/parallel-frontier
|
||||
acceptance pointer). Every key is consumed, removed, or
|
||||
machine-readably advisory — zero silently-inert keys.
|
||||
## Project lifecycle graphs
|
||||
|
||||
**Versioning honesty (FR-6).** New strictness lands at
|
||||
`parseWorkflowSpec` (the only seam that sees raw keys; the WF-1
|
||||
exported closed keysets extended with `harness`/`host`/`gate` +
|
||||
`next_hop.on`) and applies at validate/instantiate/re-parse. A LIVE
|
||||
instance pinned to a pre-WF-2 spec version keeps executing un-failed
|
||||
(project() has no validation gate, by design; stored `spec_json` blobs
|
||||
missing the new optional fields read fine); the same spec FILE
|
||||
re-validated fails under the new rules.
|
||||
`compileLifecycle` delegates to
|
||||
[`compileProjectLifecycle`](../../../packages/daemon/src/domain/project-lifecycle-compiler.ts).
|
||||
`instantiateLifecycle` requires an operation key and compiled input digest.
|
||||
An exact replay retrieves the recorded instance/entry packet; changed input
|
||||
under the same key conflicts. Compilation and an eligible result do not by
|
||||
themselves launch work.
|
||||
|
||||
**Hand-authorability (FR-6).** Three shipped example shapes at
|
||||
`packages/daemon/src/builtins/workflow-specs/`: `linear-build.yaml`
|
||||
(zero WF-2 features — the zero-regression reference),
|
||||
`gated-release.yaml` (human gate + harness pins),
|
||||
`branched-remediation.yaml` (bounded failed-path remediation loop).
|
||||
`inspectGraph`, `reviseGraph`, and `recoverGraphOperation` in
|
||||
[`workflow-reconciliation.ts`](../../../packages/daemon/src/domain/workflow-reconciliation.ts)
|
||||
separate inspection from explicit graph revision and operation recovery.
|
||||
`guidance` reads context and current packet information without interpreting
|
||||
an agent's substantive judgment.
|
||||
|
||||
**The composed RSI example (OPR.0.4.6.FAC2).** `factory-rsi.yaml` is the
|
||||
single-rig recursive-self-improvement factory MVP: it composes the branch +
|
||||
gate + guard primitives into the inner loop — `plan → implement → qa_check →
|
||||
review → release_prep` — where `qa_check`/`review` branch `failed` back to
|
||||
`implement` (bounded remediation) and `release_prep` (the release-manager
|
||||
prepares artifacts, un-gated) hands off to a human-gated `release_signoff`.
|
||||
Dogfood is decoupled from this gated loop: the dogfood seat runs out-of-band
|
||||
against the shipped product and feeds its findings into the next plan (the RSI
|
||||
edge, ungated). The remediation loops are sanctioned only by the enforceable
|
||||
`loop_guards.max_hops`; a trip is a WF-5 exception (orchestrator-first via
|
||||
`exception_routing`). It targets the shipped `factory-rsi` launch starter
|
||||
(`specs/rigs/launch/factory-rsi/`) whose seats pin 1:1 to its roles via
|
||||
`preferred_targets` — the v0 hardcode seam, no binding layer.
|
||||
For lifecycle steps that require a receipt, a `done`/`handoff` projection
|
||||
requires an `evidence_ref`. Typed acceptance gates additionally compare the
|
||||
submitted candidate, allowed verdict, and evidence reference against the
|
||||
step declaration. These are mechanical identity/presence checks; the
|
||||
projector does not read the referenced prose to decide whether the work is
|
||||
good or ready to publish.
|
||||
|
||||
## The CLI surface (OPR.0.4.6.WF3)
|
||||
## CLI and HTTP surface
|
||||
|
||||
WF-3 made the CLI the primary human/agent driving surface. Render-side
|
||||
by rule (BR-2): `run`/`watch` consume the shipped SSE endpoints
|
||||
(snapshot-first-then-stream, priorQitemId dedup, reconnect → announced
|
||||
poll fallback, outcome-as-exit-code: 0 completed / 3 failed);
|
||||
`trace`/`list`/`show` human modes are formatted (argo-shape tree, ps-
|
||||
mechanics columns, ATTN markers) while `--json` stays byte-stable;
|
||||
`status` composes the needs-attention rollup CLI-SIDE from the
|
||||
API-carried `instance.deadline` classification (one threshold home —
|
||||
the CLI never recomputes a class). The two daemon additions:
|
||||
The [CLI inventory](../cli-reference.md#workflow) lists every registered
|
||||
`rig workflow` subcommand and option. The implementation is
|
||||
[`packages/cli/src/commands/workflow.ts`](../../../packages/cli/src/commands/workflow.ts).
|
||||
The [`workflow` routes](../../../packages/daemon/src/routes/workflow.ts)
|
||||
are mounted under `/api/workflow`:
|
||||
|
||||
- **`route`** (`POST /api/workflow/:id/route`, runtime `route()`):
|
||||
close+recreate+rebind in ONE scribe transaction — honest
|
||||
`handed_off_to` closure with provenance, successor recreate (same
|
||||
step; `current_step_id` unchanged; no hop bump — route is not an
|
||||
advance), frontier rebind under the version guard, keepalive
|
||||
re-target in-txn. Advance-authority revocation is STRUCTURAL: the
|
||||
old packet leaves the frontier in the transaction, so a zombie
|
||||
owner's stale `project` hits the shipped `packet_not_on_frontier`
|
||||
409.
|
||||
- **The frontier close-path guard** (`workflow-frontier-guard.ts` →
|
||||
INJECTED into `QueueRepository` at startup; the queue never imports
|
||||
the workflow domain): terminal closure of a live frontier packet
|
||||
from non-workflow verbs rejects `workflow_frontier_packet` with a
|
||||
what/why/fix naming `rig workflow project` / `route`. Workflow
|
||||
writers pass `viaWorkflowVerb`; non-workflow qitems see zero new
|
||||
behavior.
|
||||
| Method | Suffixes |
|
||||
|---|---|
|
||||
| `POST` | `/validate`, `/compile`, `/instantiate`, `/instantiate-lifecycle`, `/project` |
|
||||
| `GET` | `/list`, `/specs`, `/operations/:key`, `/:instance_id`, `/:instance_id/trace`, `/:instance_id/guidance`, `/:instance_id/revision` |
|
||||
| `POST` | `/:instance_id/route`, `/:instance_id/resume`, `/:instance_id/abort`, `/:instance_id/continue`, `/:instance_id/revision` |
|
||||
| `GET` | `/sse`, `/watch` (same live event stream handler) |
|
||||
|
||||
## The exception + human-gate model (OPR.0.4.6.WF5)
|
||||
The stream filters the named workflow events; it is not a general queue
|
||||
stream or a stored-history replay. Route error mapping distinguishes missing
|
||||
records, authoring errors, state/version conflicts, and internal failures.
|
||||
See the source mapping before assigning a meaning to a nonzero CLI result.
|
||||
|
||||
The deterministic engine's exception layer: the happy path stays
|
||||
orchestrator-free and PROVEN so; every exception becomes exactly one
|
||||
durable attention item the moment it exists; the responder resolves it
|
||||
and the flow resumes from where it stopped.
|
||||
|
||||
- **The taxonomy** (`workflow-exception.ts`): three closed classes as
|
||||
pure predicates over recorded state — `unmapped_failed` (recorded
|
||||
`status=failed`; a WF-2-mapped `failed` routes to remediation and is
|
||||
NOT an exception), `stuck_overdue` (the WF-1 deadline evaluator's
|
||||
verdict consumed verbatim — the single threshold home), and
|
||||
`human_gate_trip` (WF-2 HUMAN gates; the compiled park IS the item).
|
||||
Handler-role gate-trips are deterministic handoffs, not exceptions —
|
||||
classes (a)/(b) backstop the handler's own step. The occurrence key
|
||||
is the recorded packet id of the episode: re-detections dedupe,
|
||||
resolve+resume closes, a fresh packet is a NEW occurrence.
|
||||
- **The maturity dial** (`workflow-exception-router.ts` + the
|
||||
`exception_routing` spec grammar + the `workflow.exception_routing`
|
||||
settings key): target resolution = spec per-class → spec default →
|
||||
host dynamic key → ORCHESTRATOR-FIRST (the declared orchestrator
|
||||
role via the same `preferred_targets` pick step owners use) →
|
||||
registered-human selection (`workflow-human-destination.ts`). THE TIER SPLIT: `human-gate` rides
|
||||
ONLY human-routed positions — an orchestrator-routed item carries
|
||||
the ordinary tier so the shipped attention union (which matches on
|
||||
tier regardless of destination) never leaks it into NEEDS-YOU. The
|
||||
shipped attention predicate is untouched.
|
||||
- **Class (a) born-in-txn**: serial failure and unhandled dependency-branch
|
||||
failure share exception admission. The failed packet, dependency occurrence,
|
||||
owning item and staged wake commit together, even while an unrelated frontier
|
||||
keeps the instance active. Mapped remediation remains ordinary workflow work;
|
||||
an unmapped or max-hop failure requires an owner. An agent destination rejected
|
||||
as an unknown rig tries registered-human selection. Selection, admission,
|
||||
or storage failure rolls back the close; it never commits a phantom
|
||||
human alert. Other errors retain their original diagnosis. **Class (b) at detection**: the boot sweep and the keepalive
|
||||
evaluation call the injected ensurer (`workflow-exception-
|
||||
escalation.ts`) — occurrence-deduped against OPEN items by tag
|
||||
query, scoped to the exact workflow, instance and packet; the crash-surviving
|
||||
sweep re-creates a missed item. Normal projection, keepalive (including
|
||||
healthy/terminal returns), and boot (including completed instances) reconcile
|
||||
each overdue item's own packet. A resolved wait, completion or obsolete
|
||||
frontier packet closes only that occurrence with a retained transition;
|
||||
an overdue sibling stays open. Unknown provenance is retained. A later
|
||||
overdue episode can create a fresh item even when it reuses the same packet.
|
||||
- **Human selection and failure**: the existing `workspace.operator_seat_name`
|
||||
setting selects a registered human; when unset, exactly one registered human
|
||||
is required. No `human@host` alias is invented and no arbitrary choice is made
|
||||
among several humans. The setting and registry are read at fallback time,
|
||||
so a valid configured agent route needs no human registry. Missing, ambiguous,
|
||||
invalid or unavailable selection produces `workflow_human_destination_unavailable`
|
||||
(HTTP 409 for failed projection). Fix the registration/selection and retry.
|
||||
Detection-time admission failures are logged by boot and included in the
|
||||
keepalive's existing owner nudge and evaluation notes; later detection retries.
|
||||
A registered destination still uses the gateway's ordinary delivery rules and
|
||||
receipt ledger. Admission is not proof of posting or readership.
|
||||
A capability inventory or detector instance-binding read failure propagates
|
||||
instead of becoming a no-match:
|
||||
failed projection returns HTTP 500 and rolls back its packet, instance and
|
||||
history writes; overdue detection surfaces the read error without an exception
|
||||
item. An evidenced no-match still uses registered-human selection.
|
||||
- **`resume`** (`POST /api/workflow/:id/resume`, runtime `resume()`,
|
||||
`rig workflow resume`): redrive semantics in ONE scribe transaction —
|
||||
failed→active REBOUND to the recorded failed step; the owner is
|
||||
RE-RESOLVED through the projection resolver (never copied from the
|
||||
stale destination — resume is the one sanctioned re-resolution
|
||||
point); `--decision` lands durably in the redrive packet; the
|
||||
occurrence's open items close with provenance; the trail is
|
||||
preserved, never rewritten. For a dependency graph, `--occurrence <failed-qitem-id>`
|
||||
selects the episode (required with several unresolved failures). Its resolution,
|
||||
redrive packet/wake and exact instance/occurrence exception closure share one
|
||||
transaction; siblings remain intact. An identical occurrence/decision retry
|
||||
returns the existing redrive; changed decision bytes conflict without mutation.
|
||||
THE LIVELOCK RAIL (migration 051):
|
||||
`hops_baseline` re-anchors the max_hops guard at resume so each
|
||||
redrive gets exactly one bounded window; `resume_count` is the
|
||||
recorded redrive fact; re-exceeding raises an honest NEW occurrence.
|
||||
- **The workflow-aware ▲ band** (`review/compose.ts
|
||||
deriveWorkflowExceptions` + the gatherer source): the missing-item
|
||||
backstop row, the stuck row with evaluator evidence, the
|
||||
frontier-non-open ANOMALY row (detection behind WF-3 FR-6's
|
||||
prevention), and THE AWARENESS ROW — an orchestrator-routed
|
||||
exception renders holder + age in the human band (one identity, two
|
||||
projections, count = 1); human-routed items render nothing there
|
||||
(the ● item is the row). Recomposition clears on state-exit.
|
||||
|
||||
## The workflow ↔ rig binding layer (OPR.0.4.6.FAC1)
|
||||
|
||||
Run any workflow on any rig that can support it, without editing the
|
||||
spec — the self-driving factory's binding substrate. Three seams:
|
||||
|
||||
- **A1 — bind at instantiation** (migration 052,
|
||||
`workflow_instances.bound_rig`): the effective binding =
|
||||
`--rig`/`targetRig` override `?? spec.target.rig ?? null`; the spec
|
||||
field stays a DEFAULT (no routing path reads it — display only). The
|
||||
rig NAME persists (the durable operator-space coordinate); name→id
|
||||
re-resolves fresh at each resolution site — a vanished rig fails loud
|
||||
(`bound_rig_not_found`), never silently. NULL = unbound =
|
||||
byte-identical pre-FAC-1 behavior. **Unknown-rig validation SPLITS by
|
||||
PROVENANCE** (arch ruling 2026-07-07, target-rig zero-regression):
|
||||
an explicit operator `--rig`/`targetRig` is AUTHORITATIVE — unknown →
|
||||
`bound_rig_unknown` HARD-FAIL before any mutation; a spec-default
|
||||
`target.rig` is ADVISORY (authored under the pre-FAC-1 regime where the
|
||||
field was ignored at runtime) — unknown → DEGRADE to unbound + a LOUD
|
||||
`advisories` notice on the instantiate result (surfaced by route + CLI
|
||||
stderr), never a hard-fail. This preserves AC-1 zero-regression for
|
||||
shipped/example specs that declare a descriptive `target.rig` AND route
|
||||
via `preferred_targets` (e.g. `conveyor`): they instantiate exactly as
|
||||
pre-FAC-1. A spec that genuinely needs a bound rig still fails loudly,
|
||||
per-step (entry at instantiate, later role-only steps at projection) —
|
||||
the degrade is to honest per-step failure with a heads-up, never to
|
||||
silence.
|
||||
- **A2 — role→seat capability resolution**
|
||||
(`workflow-role-resolver.ts` pure policy +
|
||||
`workflow-role-context.ts` lazy sync snapshot): TIER 3 inside
|
||||
`resolveDefaultOwner`, activating ONLY when a role declares zero
|
||||
`preferred_targets` AND the instance is bound. Tier order is sacred:
|
||||
explicit owner → gate compile → declared `preferred_targets`
|
||||
(byte-identical, never inventory-filtered) → capability → loud null.
|
||||
The CLOSED fact set: role (`nodes.role`, declared per pod member —
|
||||
seat-side, opt-in) · nodeKind · lifecycleState (`running` only) ·
|
||||
runtime (harness-pin aware) · sync `pendingWorkCount` (pending-only
|
||||
backlog) · the derived canonical coordinate. Async
|
||||
`attachAgentActivity`/tmux probes are structurally absent from the
|
||||
transaction. Managed seats only: adopted seats are excluded LOUDLY
|
||||
(`adopted_seat_not_role_resolvable_v1`). Selection = least backlog,
|
||||
plain-codepoint coordinate tiebreak (`driver10@rig < driver2@rig`).
|
||||
Resolution runs ONCE at step-close (after the frontier + absorption
|
||||
guards — replays perform zero inventory reads) and records as the
|
||||
packet destination; the WF-5 resume is the one re-resolution point,
|
||||
now capability-aware. All SIX owner-resolution sites carry the
|
||||
context: projector next-step, human-gate parked-packet owner,
|
||||
handler-role gate destination (no-targets + bound → capability),
|
||||
runtime entry (live + recorded), the eager instantiate loop
|
||||
(STRUCTURAL zero-role-coverage check only — `bound_rig_role_uncovered`;
|
||||
no live resolution of future steps; a warming rig instantiates), and
|
||||
resume. The WF-5 exception dial's orchestrator-role position resolves
|
||||
capability-aware on the bound rig at both homes (in-txn class-(a) +
|
||||
detection-time class-(b)), then uses registered-human selection if no
|
||||
agent resolves. Failures are loud-with-candidates: structured
|
||||
per-candidate disqualifiers + a named zero-candidate message; never a
|
||||
spawn, never auto-`add_member`, never a dead-seat route. Additive
|
||||
`owner_resolution` trail evidence records `{mode, role, boundRig?,
|
||||
seat}` per routing decision.
|
||||
- **A3 — roles bind to SEATS, never occupants**: the ONE string rule —
|
||||
the derived canonical coordinate `{pod}-{member}@{rig}` is both the
|
||||
tiebreak key and the recorded destination, so an agent handover
|
||||
behind the seat never strands the workflow (raw occupant-era session
|
||||
names are never recorded as role-resolution destinations).
|
||||
|
||||
## The member-exists instantiate advisory (OPR.0.4.6.FAC3 — engine bit)
|
||||
|
||||
The queue transport validates the RIG of a destination, never the member
|
||||
(`topologyValidateRig` is rig-exists-only by design — hardening it would
|
||||
gate every queue write and break legitimate non-managed destinations),
|
||||
so a declared `preferred_target` naming a registered rig but a typo'd or
|
||||
stale MEMBER would mint an orphan packet, visible only later as a WF-5
|
||||
stuck exception. FAC-3 catches it at the earliest knowable moment: at
|
||||
instantiate, for every step-referenced role (each step's `actor_role`
|
||||
plus a handler gate's target role — the same reference set the
|
||||
structural coverage check walks), each declared `preferred_target` that
|
||||
parses CANONICAL and names a rig registered on this daemon is probed
|
||||
for member existence (`rigMemberExists` beside `rigDeclaresRole` —
|
||||
sync SQL, the derived canonical coordinate per the FAC-1 Q5 one-string
|
||||
rule, existence at ANY lifecycle state and node kind; liveness stays
|
||||
projection's business). An unknown member yields ONE aggregated
|
||||
advisory per unique target — naming every declaring step/role pair, the
|
||||
consequence (the work will not be claimed; it will surface as a stuck
|
||||
exception), and the fix hint — pushed into the shipped
|
||||
`InstantiateResult.advisories` list (the FAC-1 `target.rig`-degrade
|
||||
surface: one list, now two producers; rendered by the route body + CLI
|
||||
stderr, zero new surface). ADVISORY-NEVER-DENY: instantiate always
|
||||
proceeds. Skips (in order): human-seat refs (classified BEFORE parse,
|
||||
the queue-gate archetype), non-canonical raw/adopted destinations (the
|
||||
inventory cannot vouch for them), unregistered rigs (the transport
|
||||
already rejects those loudly at queue-write — no double advisory).
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — PL-004 Phase A; the closure authority the
|
||||
runtime projects against.
|
||||
- `mission-control.md` — PL-005 queue observability + 7-verb contract.
|
||||
- Source roots: `packages/daemon/src/domain/{workflow-projector,
|
||||
workflow-runtime,workflow-instance-store,workflow-spec-cache,
|
||||
workflow-step-trail-log,workflow-validator}.ts`,
|
||||
`packages/daemon/src/domain/policies/workflow-keepalive.ts`,
|
||||
`packages/daemon/src/domain/{workflow-exception,workflow-exception-router,
|
||||
workflow-exception-escalation}.ts`, `packages/daemon/src/routes/workflow.ts`.
|
||||
For authoring examples and project-level boundaries, use
|
||||
[product journey SDLC](../../reference/product-journey-sdlc.md) and
|
||||
[release boundaries](../../reference/release-boundary.md).
|
||||
@@ -9,92 +9,72 @@ applies-when: |
|
||||
defaultRepo / knowledgeRoot), how that block is persisted and resolved into
|
||||
whoami / node-inventory, how per-item target_repo scope is validated, or how
|
||||
the file-backed missions/slices tree is indexed and projected into the
|
||||
Project UI. Author-mode module — no prior architecture.md prose; every
|
||||
load-bearing claim is sourced to file:line at HEAD.
|
||||
Project UI.
|
||||
siblings: [content-surfaces.md, daemon-core.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: b6d37bfadcc189be8fd61e35abfc09676437bf48
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
|
||||
|
||||
The **workspace primitive (PL-007)** is the typed declaration that lets a rig
|
||||
name *where its work lives* — a workspace root, a set of named repos with
|
||||
kinds, an optional default repo, and an optional knowledge root — and have
|
||||
that surface through `whoami` / node-inventory and gate per-item repo scope.
|
||||
Alongside it, a **file-backed missions/slices tree** is indexed read-only and
|
||||
projected into the Project UI's mission / slice surfaces.
|
||||
The **workspace primitive** is the typed declaration that lets a rig name
|
||||
*where its work lives* — a workspace root, a set of named repos with kinds, an
|
||||
optional default repo, and an optional knowledge root — and have that surface
|
||||
through `whoami` / node-inventory and gate per-item repo scope. Alongside it, a
|
||||
**file-backed missions/slices tree** is indexed read-only and projected into
|
||||
the Project UI's mission / slice surfaces.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
|
||||
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
|
||||
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative to
|
||||
> `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2 commits;
|
||||
> no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
>
|
||||
> **SPLIT NOTE (§10.6).** The honest source-grounded scope of
|
||||
> `workspace-and-content-primitives` (proposed-structure.md §4.9 / D13)
|
||||
> exceeds ~400 lines across two distinct subsystem clusters with distinct
|
||||
> source roots. Per the §10.6 SPLIT DIRECTIVE it is split by subsystem
|
||||
> cluster into this module (workspace primitive + migrations 038/039 +
|
||||
> missions/projects/slices) and the sibling `content-surfaces.md`
|
||||
> (files/markdown/progress/steering). This is a transparent noted deviation
|
||||
> from ratified Q7 "18", a faithful application of the founder's own
|
||||
> ratified Q3 principle ("distinct primitive / distinct source root →
|
||||
> split"); surfaced at the slice-08 review gate, not a mid-execution
|
||||
> interrupt.
|
||||
> Paths are relative to `packages/daemon/src/` unless prefixed `packages/` or
|
||||
> `docs/`. Verified against source at main `b6d37bfadcc189be8fd61e35abfc09676437bf48`. Each count sits beside
|
||||
> the command that produces it; run the commands from the repository root.
|
||||
> The files / markdown / progress / steering surfaces are in the sibling
|
||||
> `content-surfaces.md`.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
## 0. Release attribution
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
Checked with `git cat-file -e <tag>:packages/daemon/src/<path>`. The table
|
||||
records when each file first appeared; most have changed since.
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
| Subsystem | First release | Check |
|
||||
|---|---|---|
|
||||
| `domain/workspace/{workspace-resolver,frontmatter-validator,default-workspace-scaffold}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| migrations `038_workspace_primitive.ts`, `039_queue_target_repo.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/slices/{slice-indexer,slice-detail-projector}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `routes/{workspace,slices,projects}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/workspace/getting-started-narrative.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
|
||||
| `routes/missions.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
|
||||
| `domain/workspace/{workspace-resolver,frontmatter-validator,default-workspace-scaffold}.ts` | **≤0.3.0** | present at `v0.3.0` |
|
||||
| migrations `038_workspace_primitive.ts`, `039_queue_target_repo.ts` | **≤0.3.0** | present at `v0.3.0` |
|
||||
| `domain/slices/{slice-indexer,slice-detail-projector}.ts` | **≤0.3.0** | present at `v0.3.0` |
|
||||
| `routes/{workspace,slices,projects}.ts` | **≤0.3.0** | present at `v0.3.0` |
|
||||
| `domain/workspace/getting-started-narrative.ts` | **0.3.1** | absent at `v0.3.0`; present at `v0.3.1` |
|
||||
| `routes/missions.ts` | **0.3.1** | absent at `v0.3.0`; present at `v0.3.1` |
|
||||
| `domain/workspace/workspace-doctor.ts` | **0.3.2** | absent at `v0.3.1`; present at `v0.3.2` |
|
||||
|
||||
> Source: §10.8 proof re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/daemon/src/domain/workspace/getting-started-narrative.ts`
|
||||
> and `...routes/missions.ts` both fail ("exists on disk, but not in
|
||||
> 'v0.3.0'"); all other listed paths resolve at `v0.3.0`. The workspace
|
||||
> primitive itself is a **0.3.0** feature (slice-00 0.3.0-GT §1.4, PL-007,
|
||||
> migrations 038/039) — do not back-attribute it to 0.3.1. The
|
||||
> `missions` route + the getting-started narrative scaffold are the
|
||||
> **0.3.1** additions layered on top (slice 12 mission scope; slice 21
|
||||
> onboarding-conveyor).
|
||||
The workspace primitive itself (declaration, resolver, validator, migrations
|
||||
038/039) is a **0.3.0** feature — do not back-attribute it to 0.3.1. The
|
||||
`missions` route and the getting-started narrative are **0.3.1** additions;
|
||||
the workspace doctor is **0.3.2**.
|
||||
|
||||
slice-00 0.3.0-GT §1.4 independently confirms the primitive shipped 0.3.0:
|
||||
workspace-primitive merge `2ce54abc` (PL-007) with migrations
|
||||
`038_workspace_primitive` + `039_queue_target_repo`, "fresh databases apply
|
||||
migrations through `039_queue_target_repo`".
|
||||
`getting-started-narrative.ts` is still in the tree, but nothing imports it:
|
||||
**0** files reference it (`git grep -l 'workspace/getting-started-narrative' -- packages | wc -l`).
|
||||
Workspace initialization no longer seeds a getting-started mission (§3).
|
||||
|
||||
## 1. The typed workspace declaration (PL-007) — 0.3.0
|
||||
## 1. The typed workspace declaration — 0.3.0
|
||||
|
||||
`RigSpec.workspace` is an **optional** typed block. Rigs without it stay valid;
|
||||
`whoami` / node-inventory return a null workspace block in that case.
|
||||
|
||||
> Source: `domain/types.ts:762-770` (`WorkspaceSpec`), `:751-758`
|
||||
> (`WorkspaceRepoSpec`), `:780` (`RigSpec.workspace?`) @HEAD.
|
||||
> Source: `domain/types.ts:1160–1167` (`WorkspaceSpec`), `:1150–1156`
|
||||
> (`WorkspaceRepoSpec`), `:1183` (`RigSpec.workspace?`).
|
||||
|
||||
| Field | Shape | Notes |
|
||||
|---|---|---|
|
||||
| `workspaceRoot` | string | Verbatim from spec |
|
||||
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time; authors may declare relative to `workspaceRoot` in YAML |
|
||||
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time (`domain/rigspec-schema.ts:350–352`); authors may declare it relative to `workspaceRoot` in YAML |
|
||||
| `defaultRepo?` | string | active repo when no env override / cwd match |
|
||||
| `knowledgeRoot?` | string | treated as `kind=knowledge` when surfaced |
|
||||
|
||||
`WorkspaceKind` is a closed 5-member union: `user`, `project`, `knowledge`,
|
||||
`lab`, `delivery`.
|
||||
`WorkspaceKind` is a closed union of **5** members
|
||||
(`grep '^export const WORKSPACE_KINDS' packages/daemon/src/domain/types.ts | grep -o '"[a-z]*"' | wc -l`):
|
||||
`user`, `project`, `knowledge`, `lab`, `delivery`.
|
||||
|
||||
> Source: `domain/types.ts:748-749` (`WORKSPACE_KINDS` /
|
||||
> `WorkspaceKind`) @HEAD.
|
||||
> Source: `domain/types.ts:1146–1147` (`WORKSPACE_KINDS` / `WorkspaceKind`).
|
||||
|
||||
### 1.1 Persistence — migration 038 + RigRepository
|
||||
|
||||
@@ -102,9 +82,9 @@ Migration **038** adds `workspace_json TEXT` to the `rigs` table. It holds the
|
||||
typed `RigSpec.workspace` block as JSON when a rig declares one; NULL for rigs
|
||||
without a workspace block.
|
||||
|
||||
> Source: `db/migrations/038_workspace_primitive.ts:16-21`
|
||||
> (`ALTER TABLE rigs ADD COLUMN workspace_json TEXT`); doc-comment
|
||||
> `:3-14` @HEAD.
|
||||
> Source: `db/migrations/038_workspace_primitive.ts:16–21`
|
||||
> (`ALTER TABLE rigs ADD COLUMN workspace_json TEXT` at `:19`); doc comment
|
||||
> `:3–15`.
|
||||
|
||||
`RigRepository.setRigWorkspace(rigId, workspace)` persists it (UPDATE
|
||||
`workspace_json` + `updated_at`); `getRigWorkspace(rigId)` reads it back,
|
||||
@@ -113,302 +93,421 @@ are **defensive no-ops** when the column is absent (`hasRigColumn` probe) —
|
||||
older test fixtures that bypass the canonical migration list don't have the
|
||||
column; the setter's contract is "best-effort persistence".
|
||||
|
||||
> Source: `domain/rig-repository.ts:98-105` (setter, column probe
|
||||
> L99), `:106-117` (getter; parse-fail → null L114-116) @HEAD.
|
||||
> Source: `domain/rig-repository.ts:174–179` (setter; column probe `:175`),
|
||||
> `:182–192` (getter; probe `:183`; parse failure → null `:189–191`);
|
||||
> `hasRigColumn` `:324`.
|
||||
|
||||
The instantiator persists the block at rig-create time **only when declared**:
|
||||
The block is written only when declared. **3** call sites write it
|
||||
(`git grep -n 'setRigWorkspace(' -- packages/daemon/src | grep -v 'rig-repository.ts' | wc -l`):
|
||||
the instantiator's two rig-persist paths, `materializeValidatedSpec` and
|
||||
`instantiateOnce`, each guarded by `if (rigSpec.workspace)`; and the
|
||||
workspace-only import route `POST /api/rigs/import/workspace`
|
||||
(`rig import --workspace-only --target-rig <rigId>`), which validates and
|
||||
normalizes the block and writes it only when it differs from the stored one.
|
||||
|
||||
> Source: `domain/rigspec-instantiator.ts:653-655` (`if
|
||||
> (rigSpec.workspace) … setRigWorkspace(rigId, rigSpec.workspace)`) @HEAD.
|
||||
> Source: `domain/rigspec-instantiator.ts:618` (`materializeValidatedSpec`),
|
||||
> `:654–656`; `:1259` (`instantiateOnce`), `:1346–1348`;
|
||||
> `routes/rigspec.ts:133–171` (validation `:156`; compare-and-write
|
||||
> `:166–168`), mounted `server.ts:735`; CLI
|
||||
> `packages/cli/src/commands/import.ts:82`.
|
||||
|
||||
### 1.2 Runtime resolution — workspace-resolver
|
||||
|
||||
`resolveWorkspaceContext({ spec, cwd, envOverride })` (consumed by
|
||||
`whoami-service`) returns the `WhoamiWorkspaceBlock` or `null` when no spec.
|
||||
`activeRepo` resolution: an `envOverride` (non-empty, trimmed) wins
|
||||
**verbatim** — even an unknown repo name is honored, because operators set
|
||||
`OPENRIG_TARGET_REPO` consciously (PL-007 PRD § Item 3); otherwise
|
||||
`defaultRepo` is used **only if it names a declared repo**. `knowledgeKind`
|
||||
is `"knowledge"` when `knowledgeRoot` is declared, else `null`.
|
||||
`activeRepo` resolution: a non-blank `envOverride` wins **verbatim** — even an
|
||||
unknown repo name is honored, because operators set `OPENRIG_TARGET_REPO`
|
||||
consciously (the comment at `workspace-resolver.ts:35–36`); otherwise `defaultRepo` is used **only
|
||||
if it names a declared repo**. `knowledgeKind` is `"knowledge"` when
|
||||
`knowledgeRoot` is declared, else `null`.
|
||||
|
||||
> Source: `domain/workspace/workspace-resolver.ts:26-52` (resolver;
|
||||
> env-override-verbatim L34-43, including the "honored verbatim"
|
||||
> comment L35-36) @HEAD.
|
||||
> Source: `domain/workspace/workspace-resolver.ts:26–52` (resolver; null
|
||||
> without a spec `:32`; env override `:39–40`; declared `defaultRepo`
|
||||
> `:41–42`; `knowledgeKind` `:50`).
|
||||
|
||||
`whoami-service` reads the persisted spec and resolves with the query's
|
||||
`targetRepoOverride`, falling back to `process.env["OPENRIG_TARGET_REPO"]`:
|
||||
`targetRepoOverride`, falling back to the daemon's own
|
||||
`process.env["OPENRIG_TARGET_REPO"]`. `rig whoami` forwards the caller's
|
||||
`OPENRIG_TARGET_REPO` as `?targetRepo=`, which the route passes on as
|
||||
`targetRepoOverride`.
|
||||
|
||||
> Source: `domain/whoami-service.ts:319-324` (`getRigWorkspace` +
|
||||
> `resolveWorkspaceContext`, env fallback L323); `whoami` returns
|
||||
> `workspace` in its payload `:335` @HEAD.
|
||||
> Source: `domain/whoami-service.ts:333–338` (`getRigWorkspace` +
|
||||
> `resolveWorkspaceContext`, env fallback `:337`); `whoami` returns
|
||||
> `workspace` in its payload `:350`; `routes/whoami.ts:29`;
|
||||
> `packages/cli/src/commands/whoami.ts:261–262`.
|
||||
|
||||
`resolveNodeWorkspace({ spec, cwd })` (consumed by `node-inventory`) derives a
|
||||
per-node `NodeWorkspaceInfo` by walking the node's `cwd` up the directory
|
||||
tree for the **longest-prefix repo path** that contains it; falls back to
|
||||
`knowledge` when cwd is under `knowledgeRoot`, then to the rig's
|
||||
`defaultRepo` when cwd doesn't resolve. Containment uses a `path.relative`
|
||||
boundary check (not a string `startsWith`) so `/foo/bar` does not match
|
||||
`/foo/bar-other`.
|
||||
per-node `NodeWorkspaceInfo` by picking the **longest declared repo path that
|
||||
contains** the node's `cwd`. When no repo contains it, `kind` falls back to
|
||||
`knowledge` if cwd is under `knowledgeRoot`; `activeRepo` then falls back to
|
||||
the rig's `defaultRepo` (taking that repo's kind if `kind` is still unset).
|
||||
Containment uses a `path.relative` boundary check (not a string `startsWith`)
|
||||
so `/foo/bar` does not match `/foo/bar-other`. Node-inventory reads
|
||||
`workspace_json` with its own queries rather than through `RigRepository`.
|
||||
|
||||
> Source: `domain/workspace/workspace-resolver.ts:57-95`
|
||||
> (longest-prefix L67-73; knowledge fallback L77-79; default-repo
|
||||
> fallback L82-88), `isInside` `:97-103`;
|
||||
> `domain/node-inventory.ts:397` (`workspace: resolveNodeWorkspace(...)`),
|
||||
> `:434-437` (`NodeWorkspaceInfo`) @HEAD.
|
||||
> Source: `domain/workspace/workspace-resolver.ts:57–95` (longest match
|
||||
> `:67–73`; knowledge fallback `:77–79`; default-repo fallback `:82–88`),
|
||||
> `isInside` `:97–103`; `domain/node-inventory.ts:626`
|
||||
> (`workspace: resolveNodeWorkspace(...)`), readers `:711–720`
|
||||
> (`readRigWorkspaceJson`) and `:637–647` (`readAllRigWorkspaceJson`,
|
||||
> batched); `NodeWorkspaceInfo` `domain/types.ts:792–796`.
|
||||
|
||||
### 1.3 Per-item repo scope — migration 039 + queue validation
|
||||
|
||||
Migration **039** adds `target_repo TEXT` to `queue_items` plus
|
||||
`idx_queue_items_target_repo`. It carries the per-item typed repo scope when
|
||||
an operator passes `--target-repo <name>`; NULL when the qitem is
|
||||
unambiguous against the rig's `default_repo` or no workspace is declared.
|
||||
Mission Control views surface the field for cross-rig handoff clarity.
|
||||
`idx_queue_items_target_repo`. Per the migration's comment it carries the
|
||||
per-item typed repo scope when an operator passes `--target-repo <name>`; it
|
||||
is NULL when the item is unambiguous against the rig's `default_repo` or no
|
||||
workspace is declared, and Mission Control views surface the field for
|
||||
cross-rig handoff clarity.
|
||||
|
||||
> Source: `db/migrations/039_queue_target_repo.ts:16-22`
|
||||
> (`ALTER TABLE queue_items ADD COLUMN target_repo TEXT` +
|
||||
> `CREATE INDEX … idx_queue_items_target_repo`); doc-comment
|
||||
> `:3-14` @HEAD.
|
||||
> Source: `db/migrations/039_queue_target_repo.ts:16–22`
|
||||
> (`ALTER TABLE queue_items ADD COLUMN target_repo TEXT` at `:19`;
|
||||
> `CREATE INDEX … idx_queue_items_target_repo` at `039_queue_target_repo.ts:20`); doc comment
|
||||
> `:3–15`; CLI flag `packages/cli/src/commands/queue.ts:431`.
|
||||
|
||||
The queue route validates `target_repo` at the route layer against the
|
||||
**source rig's** `RigSpec.workspace.repos[]`: it parses the rig name out of
|
||||
`source_session` (`<member>@<rig>`), looks up the rig, reads
|
||||
`getRigWorkspace`, and rejects an unknown repo with `unknown_target_repo` +
|
||||
the known-repo list. It **fails open** (`{ ok: true }`) when there is no
|
||||
rigRepo, no parseable rig, the rig is unknown, or no workspace is declared —
|
||||
validation only bites when a workspace actively declares repos.
|
||||
`validateTargetRepo` checks an explicit `targetRepo` against the **source
|
||||
rig's** `RigSpec.workspace.repos[]`: it parses the source session with
|
||||
`parseSessionName`, looks the rig up by name, reads `getRigWorkspace`, and
|
||||
rejects an unknown repo with 400 `unknown_target_repo` plus the `knownRepos`
|
||||
list. It **fails open** (`{ ok: true }`) when there is no rigRepo, the session
|
||||
is not a canonical `<member>@<rig>` name, the rig is unknown, or no workspace
|
||||
is declared — validation only bites when a workspace actively declares repos.
|
||||
|
||||
> Source: `routes/queue.ts:49-57` (fail-open guards), `:55`
|
||||
> (`getRigWorkspace`), `:58-66` (`unknown_target_repo` +
|
||||
> `knownRepos`) @HEAD.
|
||||
It runs on **3** routes (`grep -c 'validateTargetRepo(c,' packages/daemon/src/routes/queue.ts`):
|
||||
`POST /create`, `POST /:qitemId/handoff` and
|
||||
`POST /:qitemId/handoff-and-complete`, each before any cross-host forwarding,
|
||||
because the source rig's workspace lives on the source host. A handoff that
|
||||
inherits the source row's `targetRepo` without an override is not
|
||||
re-validated.
|
||||
|
||||
## 2. Workspace HTTP route — frontmatter validator (0.3.0)
|
||||
> Source: `routes/queue.ts:96–123` (fail-open guards `:102`, `:106`, `:109`,
|
||||
> `:112`; `getRigWorkspace` `:111`; `unknown_target_repo` + `knownRepos`
|
||||
> `:114–120`); calls `:452`, `:635`, `:708` in handlers `:402`, `:606`,
|
||||
> `:682`; ordering note `:444–450`; inherited value `:632–633`; mounted
|
||||
> `server.ts:785`.
|
||||
|
||||
`workspaceRoutes()` (mounted `server.ts:491` as `/api/workspace`) exposes a
|
||||
**single read-only endpoint** at v0:
|
||||
## 2. Workspace HTTP route — frontmatter validator and doctor
|
||||
|
||||
`POST /api/workspace/validate` — body `{ root, workspaceKind?, recursive?,
|
||||
requireFrontmatter?, maxFiles? }`; returns a `FrontmatterValidationReport`.
|
||||
`root` is required (400 `root_required`); an out-of-vocabulary
|
||||
`workspaceKind` is rejected 400 `invalid_workspace_kind`; validator throws
|
||||
→ 500 `validate_failed`. **No filesystem mutation.**
|
||||
`workspaceRoutes()` (mounted `server.ts:786` as `/api/workspace`) exposes **2**
|
||||
endpoints (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/workspace.ts`).
|
||||
**Neither mutates the filesystem.**
|
||||
|
||||
> Source: `routes/workspace.ts:23-65` (handler; root-required L35-37;
|
||||
> kind-enum L39-47; 500 L59-62); mounted `server.ts:491` @HEAD.
|
||||
- `POST /api/workspace/validate` — body `{ root, workspaceKind?, recursive?,
|
||||
requireFrontmatter?, maxFiles? }`; returns a `FrontmatterValidationReport`.
|
||||
`root` is required (400 `root_required`); an out-of-vocabulary
|
||||
`workspaceKind` is rejected 400 `invalid_workspace_kind`; validator throws →
|
||||
500 `validate_failed`.
|
||||
- `POST /api/workspace/doctor` — runs `runWorkspaceDoctor` against the
|
||||
daemon's resolved `workspace.root`, or a `workspaceRoot` given in the body,
|
||||
and returns its report. 503 `settings_unavailable` without a settings
|
||||
store; 500 `doctor_failed` when it throws.
|
||||
|
||||
`validateWorkspaceFrontmatter()` walks a root, parses each `.md` file's
|
||||
YAML frontmatter (delimited `---` on the first line), and emits a structured
|
||||
gap report — **advisory only, never modifies files**. Gap kinds:
|
||||
> Source: `routes/workspace.ts:16` (no mutation), `:37–74` (validate;
|
||||
> root-required `:46–48`; kind check `:50–58`; 500 `:72`), `:85–160`
|
||||
> (doctor; 503 `:87`; `runWorkspaceDoctor` call `:145`; 500 `:158`);
|
||||
> `domain/workspace/workspace-doctor.ts:643`; mounted `server.ts:786`.
|
||||
|
||||
`validateWorkspaceFrontmatter()` walks a root, parses each `.md` file's YAML
|
||||
frontmatter (delimited `---` on the first line), and emits a structured gap
|
||||
report — **advisory only, never modifies files**. There are **4** gap kinds
|
||||
(`sed -n '/^export type FrontmatterGapKind/,/;$/p' packages/daemon/src/domain/workspace/frontmatter-validator.ts | grep -c '^ | '`):
|
||||
`missing-required-field`, `unrecognized-status-value`, `parse-error`,
|
||||
`missing-frontmatter`. Per-kind required fields: `user`/`project` →
|
||||
`["doc"]`; `knowledge`/`lab`/`delivery` → `["doc","status","created","owner"]`.
|
||||
Valid `status` enum: `active|draft|archived|superseded`. Default behavior
|
||||
skips files without `---` silently (informal notes) unless
|
||||
`requireFrontmatter`; recurses by default; skips `node_modules` / `.git` /
|
||||
`.worktrees` / `dist` / `build`; hard cap `maxFiles` default 10000.
|
||||
`missing-frontmatter`. Per-kind required fields (checked only when a kind is
|
||||
given): `user`/`project` → `["doc"]`; `knowledge`/`lab`/`delivery` →
|
||||
`["doc","status","created","owner"]`. The `status` enum has **4** values
|
||||
(`grep '^const VALID_STATUS_VALUES' packages/daemon/src/domain/workspace/frontmatter-validator.ts | grep -o '"[a-z]*"' | wc -l`):
|
||||
`active|draft|archived|superseded`. Default behavior skips files without
|
||||
`---` silently (informal notes) unless `requireFrontmatter`; recurses by
|
||||
default; skips `node_modules` / `.git` / `.worktrees` / `dist` / `build`;
|
||||
hard cap `maxFiles` default 10000.
|
||||
|
||||
> Source: `domain/workspace/frontmatter-validator.ts:53` (status enum),
|
||||
> `:56-62` (per-kind required), `:23-27` (gap kinds), `:90-122` (walk;
|
||||
> skip-dirs L105-111), `:160-172` (missing-frontmatter behavior),
|
||||
> `:201-226` (required + status check) @HEAD.
|
||||
> Source: `domain/workspace/frontmatter-validator.ts:12` (advisory), `:23–27`
|
||||
> (gap kinds), `:53` (status enum), `:56–62` (per-kind required), `:81–83`
|
||||
> (defaults), `:90–121` (walk; skip-dirs `:105–111`), `:160–172`
|
||||
> (missing-frontmatter behavior), `:201–226` (required + status check), `:239`
|
||||
> (opening delimiter).
|
||||
|
||||
CLI surface: `rig workspace validate [root]` — v0 is intentionally narrow
|
||||
(`validate` only); future versions add typed-kind authoring on the same
|
||||
walker.
|
||||
CLI surface: `rig workspace validate [root]` and `rig workspace doctor`.
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:931-941` @HEAD.
|
||||
> Source: `packages/cli/src/commands/workspace.ts:86` (`validate`), `:146`
|
||||
> (`doctor`); `docs/as-built/cli-reference.md:593–594`.
|
||||
|
||||
## 3. Default project-workspace scaffold
|
||||
|
||||
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the
|
||||
repo-ready default workspace (`~/.openrig/workspace/` or `--root`). The same
|
||||
scaffold is used **idempotently by daemon startup**. It emits exactly two
|
||||
directories (`missions/`, `exhaust/`) and four files (`SPEC.md`,
|
||||
`project.yaml`, `workspace.yaml`, `.gitignore`). The catalog points its
|
||||
single default project at `.`; `project.yaml` owns project intent and mission
|
||||
discovery and exposes empty `install.context` / `install.skills` selectors.
|
||||
The ignore file excludes `exhaust/` and local `.openrig/` projection state
|
||||
while keeping authored project context versionable.
|
||||
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the repo-ready
|
||||
default workspace (`~/.openrig/workspace/`, the default `workspace.root`, or
|
||||
`--root`). It emits exactly **2** directories
|
||||
(`grep '^const WORKSPACE_DIRS' packages/daemon/src/domain/workspace/default-workspace-scaffold.ts | grep -o '"[a-z]*"' | wc -l`):
|
||||
`missions/`, `exhaust/`; and **4** files
|
||||
(`sed -n '/^export function workspaceScaffoldFiles/,/^}/p' packages/daemon/src/domain/workspace/default-workspace-scaffold.ts | grep -c 'relPath: "'`):
|
||||
`SPEC.md`, `project.yaml`, `workspace.yaml`, `.gitignore`. The catalog
|
||||
(`workspace.yaml`) points its single `default` project at `.`; `project.yaml`
|
||||
names `SPEC.md` as project intent, roots mission discovery at `missions`, and
|
||||
exposes empty `install.context` / `install.skills` selectors. The ignore file
|
||||
excludes `exhaust/` and local `.openrig/` projection state while keeping
|
||||
authored project context versionable.
|
||||
|
||||
The workspace owner is called by the shared instance initializer used by CLI
|
||||
daemon start and direct daemon first start. The initializer is additive. It
|
||||
never deletes or overwrites an existing file;
|
||||
the retained `--force` spelling is a compatibility no-op for overwrite
|
||||
behavior. CLI and daemon scaffolds are byte-parity pinned. Mission and slice
|
||||
content is created explicitly through `rig scope`, not seeded by workspace
|
||||
initialization. Instance context, System World, skill source, runtime state,
|
||||
and the retired `artifacts/`, `evidence/`, `progress/`, `field-notes/`,
|
||||
`dogfood-evidence/`, `README.md`, and `STEERING.md` entries are not emitted.
|
||||
`ensureDefaultWorkspace()` applies the scaffold additively: it creates only
|
||||
missing paths and never deletes or overwrites an existing file. It checks
|
||||
every managed path first; if one exists with the wrong kind (a file where a
|
||||
directory belongs, or the reverse) it writes nothing and returns `ok: false`
|
||||
with the conflicts. Its callers are the shared instance initializer
|
||||
`ensureOpenRigInstance()`, which both CLI daemon start and direct daemon start
|
||||
run (so the scaffold is reconciled on every start); `rig config
|
||||
init-workspace`; and `POST /api/config/init-workspace` (409
|
||||
`init_workspace_conflict`). The retained `--force` spelling is a
|
||||
compatibility no-op. The CLI command re-exports the daemon's owner from
|
||||
`@openrig/daemon/instance-initialization` rather than keeping its own copy,
|
||||
and a parity test pins the CLI and daemon layouts as byte-identical. Mission
|
||||
and slice content is created explicitly through `rig scope`, not seeded by
|
||||
workspace initialization. Instance context, System World, skill source,
|
||||
runtime state, and the retired `artifacts/`, `evidence/`, `progress/`,
|
||||
`field-notes/`, `dogfood-evidence/`, `README.md`, and `STEERING.md` entries
|
||||
are not emitted.
|
||||
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts`
|
||||
> (`workspaceScaffoldDirs`, `workspaceScaffoldFiles`); CLI
|
||||
> `domain/instance-initialization.ts`; daemon `index.ts`; CLI
|
||||
> `packages/cli/src/daemon-lifecycle.ts` and
|
||||
> `packages/cli/src/commands/config-init-workspace.ts` (`runInitWorkspace`);
|
||||
> parity: `packages/daemon/test/getting-started-narrative-parity.test.ts`
|
||||
> @HEAD.
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts:7` (dirs),
|
||||
> `:20–30` (`project.yaml`), `:32–36` (`workspace.yaml`), `:38–41`
|
||||
> (`.gitignore`), `:43–54` (`workspaceScaffoldDirs`,
|
||||
> `workspaceScaffoldFiles`), `:100–153` (`ensureDefaultWorkspace`; conflicts
|
||||
> checked before writes `:117–135`; write-if-missing `:140`);
|
||||
> `domain/instance-initialization.ts:39` (`ensureOpenRigInstance`; default
|
||||
> workspace root `:44`; dry-run plan `:83`; apply `:103`); default
|
||||
> `workspace.root` `domain/user-settings/settings-store.ts:27–30`; daemon
|
||||
> `index.ts:242`; CLI `packages/cli/src/daemon-lifecycle.ts:658`;
|
||||
> `packages/cli/src/commands/config-init-workspace.ts:10` (import), `:13`
|
||||
> (re-export), `:28` (`--force`), `:56–67` (`runInitWorkspace`);
|
||||
> `routes/config.ts:56–65`, mounted `server.ts:773`; `rig scope`
|
||||
> `packages/cli/src/commands/scope.ts:2071`; parity
|
||||
> `packages/daemon/test/getting-started-narrative-parity.test.ts:15–25`,
|
||||
> retired entries `:43–57`.
|
||||
|
||||
## 4. File-backed missions / slices tree
|
||||
|
||||
### 4.1 SliceIndexer (Slice Story View v0) — 0.3.0
|
||||
### 4.1 SliceIndexer — 0.3.0
|
||||
|
||||
`SliceIndexer` reads slice folders from a configured filesystem root. The
|
||||
**default workspace contract** is
|
||||
`workspace/missions/<mission>/slices/<slice>`; explicitly configured flat
|
||||
roots (`workspace/slices/<slice>`) remain supported for compatibility.
|
||||
`SliceIndexer` reads slice folders from configured filesystem roots. The
|
||||
**default workspace contract** is `workspace/missions/<mission>/slices/<slice>`;
|
||||
flat roots (`workspace/slices/<slice>`) remain supported for compatibility.
|
||||
A nested `slices/` child folder makes the parent a mission
|
||||
(`missionId = <mission-folder>`); a bare folder is a flat slice
|
||||
(`missionId = null`). NO new SQLite migration, NO new event type: read-only
|
||||
projection over existing tables (`queue_items`, `queue_transitions`,
|
||||
`mission_control_actions`) + dogfood-evidence directories. Time-bounded
|
||||
listing + detail caches (`invalidate()` drops both). `isReady()` is true
|
||||
when any configured slice root exists on disk.
|
||||
(`missionId = null`). Startup builds the indexer on `workspace.slices_root`
|
||||
(default `<workspace.root>/missions`) and adds `<workspace.root>/missions` and
|
||||
`<workspace.root>/slices` as extra roots. NO new SQLite migration, NO new event
|
||||
type: read-only projection over existing tables (`queue_items`,
|
||||
`queue_transitions`, `mission_control_actions`) + dogfood-evidence
|
||||
directories. Listing, detail and mission-status caches are time-bounded (60 s
|
||||
default); `invalidate()` drops all three plus the queue-membership index.
|
||||
`isReady()` is true when any configured slice root exists on disk.
|
||||
|
||||
> Source: `domain/slices/slice-indexer.ts:1-14` (contract + no-new-state),
|
||||
> `:174-176` (`isReady`), `:179-182` (`invalidate`), `:212-256`
|
||||
> (`readSliceLocations`; nested-vs-flat L233-253), `:62-89`
|
||||
> (`SliceListEntry` shape: `missionId` / `slicePath` / `qitemIds` /
|
||||
> `proofPacket`) @HEAD.
|
||||
> Source: `domain/slices/slice-indexer.ts:2–15` (contract + no new state),
|
||||
> `:238` (TTL), `:297–299` (`isReady`), `:331–336` (`invalidate`), `:476–521`
|
||||
> (`readSliceLocations`; nested-vs-flat `:495–517`), `:61–88` (`SliceRecord`:
|
||||
> `missionId` `:65`, `slicePath` `:67`, `qitemIds` `:77`, `proofPacket`
|
||||
> `:81`), `:90–111` (`SliceListEntry`, which carries `qitemCount` /
|
||||
> `hasProofPacket` instead); startup `startup.ts:1668`, `:1675–1680`,
|
||||
> `:1693–1698`; default `domain/user-settings/settings-store.ts:479`.
|
||||
|
||||
### 4.2 SliceDetailProjector (Slice Story View v0 + v1) — 0.3.0
|
||||
### 4.2 SliceDetailProjector — 0.3.0
|
||||
|
||||
Given a `SliceRecord`, the projector assembles the full per-slice payload
|
||||
across six tabs (Story, Acceptance, Decisions, Docs, Tests/Verification,
|
||||
Topology). Read-only; composes already-shipped tables +
|
||||
Topology) — **6** tab keys in `SliceDetailPayload`
|
||||
(`sed -n '/^export interface SliceDetailPayload/,/^}/p' packages/daemon/src/domain/slices/slice-detail-projector.ts | grep -c -E '^ (story|acceptance|decisions|docs|tests|topology):'`),
|
||||
plus a `readiness` field. Read-only; composes already-shipped tables +
|
||||
`workflow_specs`/`workflow_instances`/`workflow_step_trails` + slice docs on
|
||||
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase enum**
|
||||
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase taxonomy**
|
||||
(`discovery`/`product-lab`/`delivery`/…): `StoryEvent.phase` is now an
|
||||
open-ended string-or-null — the spec-defined `step.id` when bound to a
|
||||
`workflow_instance`, else `null` (UI groups under "Untagged"). When no
|
||||
workflow runtime is constructed the projector silently degrades to v0
|
||||
behavior (`workflowBinding=null`, `specGraph=null`).
|
||||
behavior (`workflowBinding`, `specGraph`, `phaseDefinitions` and
|
||||
`currentStep` all `null`).
|
||||
|
||||
> Source: `domain/slices/slice-detail-projector.ts:1-21` (six-tab
|
||||
> contract + v1 enrichment), `:18-21` (v0 phase enum REMOVED at v1);
|
||||
> startup degrade-path `startup.ts:1063-1076` (projector built with
|
||||
> `workflowRuntime?.specCache`; comment L1064-1071) @HEAD.
|
||||
> Source: `domain/slices/slice-detail-projector.ts:1–20` (six-tab contract +
|
||||
> v1 enrichment), `:16–20` (v0 phase taxonomy removed), `:64`
|
||||
> (`StoryEvent.phase`), `:177–203` (`SliceDetailPayload`), `:205–213`
|
||||
> (optional `workflowSpecCache`); startup degrade path
|
||||
> `startup.ts:1699–1710` (comment `:1699–1705`;
|
||||
> `workflowSpecCache: workflowRuntime?.specCache` `:1709`).
|
||||
|
||||
### 4.3 Slices routes — 0.3.0
|
||||
|
||||
`slicesRoutes()` (mounted `server.ts:501` as `/api/slices`):
|
||||
`GET /` (filter `all|active|done|blocked`, default `all`; `?refresh=1`
|
||||
invalidates; optional `?boundToWorkflow=<name>:<version>` lens narrows to
|
||||
slices bound to a workflow instance), `POST /refresh` (drops both indexer
|
||||
caches — no daemon restart), `GET /:name/proof-asset/*` (path-traversal
|
||||
guarded; immutable 1-day cache), `GET /:name/doc/*` (markdown for the Docs
|
||||
tab; traversal guarded), `GET /:name` (full per-tab payload). 503
|
||||
`slices_indexer_unavailable` when unwired; 503 `slices_root_not_configured`
|
||||
+ setup hint when not ready. **Route-order discipline:** literal `/` /
|
||||
`/refresh` / `/:name/proof-asset/*` / `/:name/doc/*` are registered BEFORE
|
||||
the dynamic `/:name` so they are not shadowed.
|
||||
`slicesRoutes()` (mounted `server.ts:804` as `/api/slices`) registers **5**
|
||||
handlers (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/slices.ts`):
|
||||
|
||||
> Source: `routes/slices.ts:31-177` (handlers; route-order L34-35,
|
||||
> L101-102, L109-113, L166-167; 503s L38-44; `boundToWorkflow`
|
||||
> L60-86); mounted `server.ts:501` @HEAD.
|
||||
- `GET /` — filter `all|active|done|blocked` (default `all`; anything else is
|
||||
400 `filter_invalid`); `?refresh=1` (or `true`) invalidates first; optional
|
||||
`?boundToWorkflow=<name>:<version>` lens narrows to slices bound to a
|
||||
workflow instance (400 `boundToWorkflow_invalid` without the colon). Each
|
||||
slice row carries `readiness`, and a `missions` sidecar carries authored
|
||||
mission status and readiness. Rows sort by last activity, newest first.
|
||||
- `POST /refresh` — drops the indexer caches, no daemon restart.
|
||||
- `GET /:name/proof-asset/*` — serves a proof asset from the slice's matched
|
||||
dogfood-evidence directory; rejects `..` (400); `Cache-Control: public,
|
||||
max-age=86400`; byte ranges (206 / 416).
|
||||
- `GET /:name/doc/*` — markdown for the Docs tab; rejects `..`.
|
||||
- `GET /:name` — full per-tab payload. With `?project=<id>` it also requires
|
||||
`?mission=<id>` (400 `exact_mission_and_slice_required`) and reads through
|
||||
an indexer scoped to that project's missions root.
|
||||
|
||||
### 4.4 Missions route — **0.3.1** (slice 12 + slice 13 + slice 18)
|
||||
503 `slices_indexer_unavailable` when unwired; on `GET /`, 503
|
||||
`slices_root_not_configured` + setup hint when not ready. **Route-order
|
||||
discipline:** literal `/` / `/refresh` / `/:name/proof-asset/*` /
|
||||
`/:name/doc/*` are registered BEFORE the dynamic `/:name` so they are not
|
||||
shadowed.
|
||||
|
||||
`missionsRoutes()` (mounted `server.ts:505` as `/api/missions`) is the
|
||||
mission scope data layer. `GET /:missionId` returns
|
||||
`{ missionId, missionPath, slices, workflow_spec, topology, status }` —
|
||||
slices filtered from the SliceIndexer by `missionId`; `missionPath` derived
|
||||
by going up two levels from any slice's `slicePath`; `workflow_spec` parsed
|
||||
lazily from `<missionPath>/README.md` frontmatter (same `parseWorkflowSpecRef`
|
||||
helper the slice-indexer uses); `topology.specGraph` projected via
|
||||
`projectSpecGraph(spec, null)` when the spec is cached, `{ specGraph: null }`
|
||||
when declared-but-not-cached, `null` when nothing declared; `status` read
|
||||
from README frontmatter. `POST /:missionId/complete` writes
|
||||
`status: complete` to the mission README frontmatter (idempotent; preserves
|
||||
unrelated fields) — the daemon is the audit-trail surface, the UI keeps an
|
||||
optimistic localStorage mirror. 404 `mission_not_found` when no slices match.
|
||||
> Source: `routes/slices.ts:33–198` (handlers; route-order comments
|
||||
> `:36–37`, `:118`, `:126–127`, `:175`; 503s `:40–46`; filters `:31`,
|
||||
> `:47–53`; refresh `:54–57`; `boundToWorkflow` `:67–93`; readiness +
|
||||
> `missions` sidecar `:99–110`; `/refresh` `:119–124`; proof asset
|
||||
> `:131–154`, cache header `:235`, ranges `:237–266`; doc `:158–173`;
|
||||
> project scope `:180–190`); mounted `server.ts:804`.
|
||||
|
||||
> Source: `routes/missions.ts:39-118` (handlers), `:124-142`
|
||||
> (`writeMissionStatusComplete`), `:148-150` (`computeMissionPath`
|
||||
> up-two-levels), `:171-177` (`readMissionWorkflowSpec`), `:205-215`
|
||||
> (`computeMissionTopology`); mounted `server.ts:505`; §0 proof:
|
||||
> `routes/missions.ts` [email protected] ⇒ **0.3.1** @HEAD.
|
||||
### 4.4 Missions route — 0.3.1
|
||||
|
||||
### 4.5 Projects route — Coordination L2 classifier (PL-004 Phase B) — 0.3.0
|
||||
`missionsRoutes()` (mounted `server.ts:815` as `/api/missions`) is the
|
||||
mission scope data layer, with **2** handlers
|
||||
(`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/missions.ts`).
|
||||
|
||||
`projectsRoutes()` (mounted `server.ts:492` as `/api/projects`) backs the
|
||||
`rig project` CLI verb — this is the **PL-004 Phase B project *classifier***
|
||||
(lease lifecycle + idempotent classify + operator-verb reclaim + SSE), NOT
|
||||
the Project *workspace UI* (that is the slices/missions surface above; the
|
||||
naming overlap is a documented seam). Endpoints: `POST /lease/acquire`
|
||||
(optional `evaluateDeadnessFirst` clears stale/dead leases first),
|
||||
`POST /lease/heartbeat`, `POST /reclaim-classifier`, `POST /project`
|
||||
(idempotent on `stream_item_id`), `GET /lease`, `GET /list`,
|
||||
`GET /sse` + `GET /watch` (SSE), `GET /:projectId`. **Route-order
|
||||
discipline:** literal `/lease`, `/list`, `/sse`, `/watch` are registered
|
||||
BEFORE the bare `/:projectId` catchall (Phase A R1 SSE lesson).
|
||||
`GET /:missionId` returns
|
||||
`{ missionId, missionPath, readiness, slices, workflow_spec, topology, status }`:
|
||||
|
||||
> Source: `routes/projects.ts:18-194` (handlers; route-order note
|
||||
> L15-17, L140-141, L158-159, L185); error-code → HTTP mapping
|
||||
> `:31-52`; mounted `server.ts:492` @HEAD.
|
||||
- `slices` — filtered from the SliceIndexer by `missionId`, each with its
|
||||
`readiness`;
|
||||
- `missionPath` — two levels up from any slice's `slicePath`;
|
||||
- `workflow_spec` — parsed from the frontmatter of the mission's node file
|
||||
(`SPEC.md`, else legacy `README.md`) with the same `parseWorkflowSpecRef`
|
||||
helper the slice-indexer exports;
|
||||
- `topology.specGraph` — projected via `projectSpecGraph(spec, null)` when the
|
||||
spec is cached; `{ specGraph: null }` when declared but not cached (or no
|
||||
cache is wired); `null` when nothing is declared;
|
||||
- `readiness` — the mission's proof readiness;
|
||||
- `status` — `metadata.status` from `mission.yaml` when present, else the
|
||||
node file's `status` frontmatter.
|
||||
|
||||
## 5. UI route-reality (§10.7 — source-verified at routes.tsx@HEAD)
|
||||
`POST /:missionId/complete` sets the mission status to `complete`:
|
||||
`metadata.status` in `mission.yaml` when the manifest exists (other fields and
|
||||
comments kept), otherwise the `status` line in the node file's frontmatter
|
||||
(creating `SPEC.md` when the mission has no node file). It is idempotent.
|
||||
After a successful write it drops the indexer's mission-status cache so the
|
||||
next `/api/slices` read sees the new status. The daemon is the audit-trail surface; the UI keeps an
|
||||
optimistic localStorage mirror. 404 `mission_not_found` when no slices match;
|
||||
500 `mission_complete_write_failed` on a write error.
|
||||
|
||||
`packages/ui/src/routes.tsx` is **542 lines** and uses TanStack Router
|
||||
(`createRoute({ path, component })` objects), NOT JSX `<Route>`. The
|
||||
Phase-8.1 survey's grep-derived missions/projects expectations are
|
||||
corrected here against routes.tsx reality:
|
||||
> Source: `routes/missions.ts:45–88` (GET; 503s `:46–64`; 404 `:68–70`;
|
||||
> readiness + status `:77–78`; response `:79–87`), `:95–125` (complete; 500
|
||||
> `:110–117`; cache drop `:123`), `:132–163` (`writeMissionStatusComplete`;
|
||||
> manifest `:133–141`; node file `:145–162`), `:169–171`
|
||||
> (`computeMissionPath` up two levels), `:179–186` (`readMissionStatus`),
|
||||
> `:192–198` (`readMissionWorkflowSpec`), `:226–236`
|
||||
> (`computeMissionTopology`); node-file precedence
|
||||
> `domain/scope/node-file.ts:17`; readiness
|
||||
> `domain/proof/judgments.ts:288–295`; UI mirror
|
||||
> `packages/ui/src/hooks/useCompletedMissions.ts:15`; mounted `server.ts:815`;
|
||||
> §0: `routes/missions.ts` absent at `v0.3.0` ⇒ **0.3.1**.
|
||||
|
||||
| Survey expectation | routes.tsx@HEAD reality | routes.tsx:NN |
|
||||
### 4.5 Projects route — stream classifier — 0.3.0
|
||||
|
||||
`projectsRoutes()` (mounted `server.ts:787` as `/api/projects`) backs the
|
||||
`rig project` CLI verb — the **stream-item classifier** (lease lifecycle +
|
||||
idempotent classify + operator-verb reclaim + attempt ledger + SSE), NOT the
|
||||
Project *workspace UI* (that is the slices/missions surface above; the naming
|
||||
overlap is a documented seam). Endpoints:
|
||||
|
||||
- `GET /worker-sources` — requires `?project=<id>`, a workspace catalog
|
||||
project;
|
||||
- `GET /shadow`, `POST /shadow/drain`, `POST /shadow/stop`;
|
||||
- `POST /lease/acquire` (optional `evaluateDeadnessFirst` clears stale/dead
|
||||
leases first), `POST /lease/heartbeat`, `POST /reclaim-classifier`;
|
||||
- `POST /project` (idempotent on `stream_item_id`);
|
||||
- `POST /attempts/begin`, `POST /attempts/:attemptId/abstain`,
|
||||
`POST /attempts/:attemptId/fail`, `GET /eligible`;
|
||||
- `GET /lease`, `GET /list`, `GET /sse` + `GET /watch` (SSE);
|
||||
- `GET /:projectId`.
|
||||
|
||||
**Route-order discipline:** every literal path is registered BEFORE the bare
|
||||
`/:projectId` catchall, which comes last.
|
||||
|
||||
> Source: `routes/projects.ts:27–373` (handlers; route-order notes `:25`,
|
||||
> `:238`, `:307`, `:320`, `:337–338`, `:364`); worker sources `:98–109`;
|
||||
> shadow `:111–125`; lease / reclaim / project `:135–235`
|
||||
> (`evaluateDeadnessFirst` `:140–142`); attempts and eligible `:242–304`;
|
||||
> lease and list `:308–334`; SSE `:339–362`; `/:projectId` `:365–370`;
|
||||
> error-code → HTTP mapping `:58–95`; CLI
|
||||
> `packages/cli/src/commands/project.ts:46`; mounted `server.ts:787`.
|
||||
|
||||
## 5. UI route reality — source-verified at routes.tsx
|
||||
|
||||
`packages/ui/src/routes.tsx` is **605** lines (`wc -l < packages/ui/src/routes.tsx`)
|
||||
and uses TanStack Router (`createRoute({ path, component })` objects), NOT JSX
|
||||
`<Route>` (**0** matches: `grep -c '<Route' packages/ui/src/routes.tsx`). The
|
||||
mission / project / slice destinations as they exist:
|
||||
|
||||
| Path | routes.tsx reality | routes.tsx:NN |
|
||||
|---|---|---|
|
||||
| `/project` (workspace) | **REAL route** — `WorkspaceScopePage` | `:128-131` |
|
||||
| `/project/mission/$missionId` | **REAL route** — `MissionScopePage` | `:134-137` |
|
||||
| `/project/slice/$sliceId` | **REAL route** — `SliceScopePage` | `:140-143` |
|
||||
| `/files` | **REAL route** — `FilesWorkspace` (see content-surfaces.md) | `:192-195` |
|
||||
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (DELETED per SC-18; the Mission Control *system* lives at daemon/PL-005, not a UI destination) | `:440-444` |
|
||||
| `/slices` | **`<Navigate to="/project" />`** redirect-stub (DELETED per project-tree.md) | `:447-451` |
|
||||
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId" />`** redirect-stub | `:453-460` |
|
||||
| `/progress` | **`<Navigate to="/project" />`** redirect-stub (folds into Project tabs, Phase 3) | `:464-468` |
|
||||
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into Project workspace overview tab, Phase 3) | `:470-474` |
|
||||
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | (absent) |
|
||||
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md §4) | (absent) |
|
||||
| `/project` (workspace) | **REAL route** — `WorkspaceScopePage` | `:136–140` |
|
||||
| `/project/mission/$missionId` | **REAL route** — `MissionScopePage` | `:142–146` |
|
||||
| `/project/slice/$sliceId` | **REAL route** — `SliceScopePage` | `:148–152` |
|
||||
| `/files` | **REAL route** — `FilesWorkspace` (see content-surfaces.md) | `:248–252` |
|
||||
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (the Mission Control *system* lives in the daemon at `/api/mission-control`, not as a UI destination) | `:496–500` |
|
||||
| `/slices` | **`<Navigate to="/project" />`** redirect-stub | `:503–507` |
|
||||
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId" />`** redirect-stub | `:509–516` |
|
||||
| `/progress` | **`<Navigate to="/project" />`** redirect-stub (folds into Project tabs) | `:519–523` |
|
||||
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into the Project workspace overview tab) | `:526–530` |
|
||||
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | absent: **0** (`grep -c 'path: "/missions"' packages/ui/src/routes.tsx`) |
|
||||
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md) | absent: **0** (`grep -c 'path: "/markdown"' packages/ui/src/routes.tsx`) |
|
||||
|
||||
> Source: `packages/ui/src/routes.tsx:90-195` (real routes),
|
||||
> `:435-474` (redirect-stub block; each `<Navigate>` + DELETED/folds
|
||||
> comment), `:476-490` (route tree) @HEAD. Grep-confirmed absent:
|
||||
> no `path: "/missions"` / `path: "/markdown"` declaration anywhere
|
||||
> in routes.tsx @HEAD.
|
||||
> Source: `packages/ui/src/routes.tsx:136–152` (project routes), `:248–252`
|
||||
> (`/files`), `:484–530` (redirect block, which also redirects `/context` to
|
||||
> `/topology`), `:539–597` (route tree); `/api/mission-control` mounted
|
||||
> `packages/daemon/src/server.ts:791–794`.
|
||||
|
||||
Net §10.7 ruling: the daemon `/api/missions` + `/api/slices` +
|
||||
`/api/projects` routes are real and load-bearing; the **operator-facing UI**
|
||||
consumes them through `/project*` destinations only. `/mission-control`,
|
||||
`/slices`, `/progress`, `/steering` are redirect-stubs (the systems exist at
|
||||
the daemon layer, the URLs were collapsed into `/project` / `/for-you`).
|
||||
Net: the daemon `/api/missions` + `/api/slices` + `/api/projects` routes are
|
||||
real. The **operator-facing UI** consumes the missions and slices routes
|
||||
through the `/project*` destinations; it does not call `/api/projects`
|
||||
(**0** files: `git grep -l '/api/projects' -- packages/ui/src | wc -l`).
|
||||
`/mission-control`, `/slices`, `/progress`, `/steering` are redirect-stubs
|
||||
(the systems exist at the daemon layer; the URLs were collapsed into
|
||||
`/project` / `/for-you`).
|
||||
|
||||
> Source: `packages/ui/src/hooks/useMission.ts:51` (`/api/missions`),
|
||||
> `packages/ui/src/hooks/useSlices.ts:84` (`/api/slices`).
|
||||
|
||||
## 6. Cross-cutting properties
|
||||
|
||||
- **Optional + valid-without:** rigs without a `workspace` block stay valid;
|
||||
whoami / node-inventory / queue-target-repo all return null / fail-open
|
||||
(`workspace-resolver.ts:32`; `rig-repository.ts:99,107`;
|
||||
`routes/queue.ts:55`) @HEAD.
|
||||
- **Defensive column probes:** every `workspace_json` / `target_repo` access
|
||||
guards on column presence so partial test fixtures don't crash
|
||||
(`rig-repository.ts:99,107`; migrations 038/039 ship separately so a
|
||||
fixture can apply only the half it needs — `038.ts:11-14`) @HEAD.
|
||||
whoami / node-inventory return null and queue target-repo validation fails
|
||||
open (`workspace-resolver.ts:32`, `:62`; `rig-repository.ts:175`, `:183`;
|
||||
`routes/queue.ts:112`).
|
||||
- **Defensive column access:** `RigRepository` probes for `workspace_json`
|
||||
(`rig-repository.ts:175`, `:183`), node-inventory's own reads fall back to
|
||||
null on error (`node-inventory.ts:645`, `:717–719`), and the queue
|
||||
repository detects `target_repo` (`queue-repository.ts:752`), so partial
|
||||
test fixtures don't crash. Migrations 038/039 ship separately so a fixture
|
||||
can apply only the half it needs (`038_workspace_primitive.ts:12–14`).
|
||||
- **Read-only projection, no new state for the tree:** SliceIndexer /
|
||||
SliceDetailProjector add NO migration / event type; only the workspace
|
||||
declaration (038) + per-item scope (039) touch SQLite
|
||||
(`slice-indexer.ts:12-14`) @HEAD.
|
||||
- **Advisory, never mutates:** the frontmatter validator and the
|
||||
init-workspace scaffold never delete operator content
|
||||
(`frontmatter-validator.ts:12-13`; `config-init-workspace.ts:12-16`) @HEAD.
|
||||
(`slice-indexer.ts:14–15`).
|
||||
- **Advisory, never deletes:** the frontmatter validator never modifies files
|
||||
(`frontmatter-validator.ts:12`), and the workspace scaffold never deletes or
|
||||
overwrites operator content (`default-workspace-scaffold.ts:140`;
|
||||
`packages/cli/src/commands/config-init-workspace.ts:18`).
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
## Open items
|
||||
|
||||
- **OPEN-A** — `resolveWorkspaceContext` honors an `envOverride`
|
||||
**verbatim even when it names no declared repo** (`workspace-resolver.ts:34-43`,
|
||||
by explicit PL-007 PRD § Item 3 design), whereas the queue route
|
||||
**rejects** an unknown `target_repo` against the same `repos[]`
|
||||
(`routes/queue.ts:58-66`). These two surfaces apply opposite policies to
|
||||
an unknown repo name (whoami trusts the operator; queue validates). Stated
|
||||
as-is — the asymmetry is source-real and intentional per the cited
|
||||
comments, but the divergence is not reconciled in source.
|
||||
- **OPEN-B** — the `/api/projects` *classifier* (PL-004 Phase B) and the
|
||||
`/project*` *workspace UI* (slices/missions) share the word "project" but
|
||||
are unrelated subsystems. Documented as a naming seam; no source defect.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration-count / route-group / PL-004-event-count claim (those land in
|
||||
daemon-core / coordination-primitive / architecture-rules).
|
||||
- `resolveWorkspaceContext` honors an `envOverride` **verbatim even when it
|
||||
names no declared repo** (`workspace-resolver.ts:39–40`), whereas the queue
|
||||
route **rejects** an unknown `target_repo` against the same `repos[]`
|
||||
(`routes/queue.ts:114–120`). These two surfaces apply opposite policies to
|
||||
an unknown repo name (whoami trusts the operator; queue validates). The
|
||||
resolver's comment at `workspace-resolver.ts:35–36` states the verbatim behavior is intended, but
|
||||
the comment line before it (`workspace-resolver.ts:34`) and `whoami-service.ts:331–332` describe
|
||||
a repo-name check that the code does not perform. Stated as-is; the
|
||||
divergence is not reconciled in source.
|
||||
- The `/api/projects` *classifier* and the `/project*` *workspace UI*
|
||||
(slices/missions) share the word "project" but are different subsystems.
|
||||
Both accept a workspace catalog project as `?project=<id>`
|
||||
(`routes/projects.ts:105`; `routes/slices.ts:181`). Documented as a naming
|
||||
seam; no source defect.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Arteries — where a small change has a large effect
|
||||
status: active
|
||||
applies-when: |
|
||||
You are about to change, or review a change to, an area that many other paths depend on:
|
||||
message delivery, launch and resume, the queue, rig identity, skill projection, native
|
||||
process observation, migrations, or restore. Read this to see what depends on the area and
|
||||
what broke there before, then go and read the code.
|
||||
siblings: [README.md, test-layers.md]
|
||||
prerequisite-reads: [../../ARCHITECTURE.md]
|
||||
last-verified-against-source: 1347d825
|
||||
last-updated: 2026-10-01
|
||||
---
|
||||
|
||||
# Arteries: where a small change has a large effect
|
||||
|
||||
## Read this first: this map is not the territory
|
||||
|
||||
- **This page is a zoomed-out view, not a description of OpenRig.** It names a few areas where
|
||||
small changes caused large regressions before. OpenRig is much larger than this page. Code,
|
||||
behaviour and risk that are not listed here exist, and some of it is riskier than anything below.
|
||||
- **It is incomplete by construction.** A pull request is not safe because its files are missing
|
||||
from this list.
|
||||
- **It may be stale.** It was checked against commit `1347d825`. Files and dependencies move. If
|
||||
this page and the code disagree, the code is right; fix the page.
|
||||
- **Use it to change altitude, then go to the ground:** read the code, run the commands, check the
|
||||
behaviour. When you are lost, go and look rather than leaning on this page.
|
||||
|
||||
## The arteries
|
||||
|
||||
Paths are relative to `packages/`. The key files are entry points, not the full extent of each
|
||||
area.
|
||||
|
||||
| Artery | Key files | What depends on it | Past regressions and lessons |
|
||||
|---|---|---|---|
|
||||
| **Message delivery to seats** | `daemon/src/domain/session-transport.ts`, `daemon/src/domain/seat-delivery-guard.ts`, `daemon/src/adapters/tmux.ts` | Every `rig send`, queue wake, nudge, watchdog wake and handoff | #150 (the fix for #142) stopped typing into a bare shell. It then refused managed Codex seats (fixed by #171 before release) and every Claude seat launched with an explicit permission mode, which shipped in 0.6.2 as #197 (fixed by #220). The delivery guard's rule that a name must resolve to exactly one target, added in #109 (0.6.0), was involved in #141 (fixed by #151), the delivery half of #174 (fixed by #181) and #188 (fixed by #189). |
|
||||
| **Launch, relaunch, resume** | `daemon/src/adapters/claude-code-adapter.ts`, `daemon/src/domain/claude-managed-launch.ts`, `daemon/src/adapters/codex-runtime-adapter.ts`, `daemon/src/domain/codex-daemon-support.ts`, `daemon/src/domain/native-resume-probe.ts`, `daemon/src/domain/startup-orchestrator.ts` | Every seat start, restore and permission mode | Always setting `CLAUDE_CONFIG_DIR` gave explicit-mode Claude seats a blank config (#154; #225 fixes its config-selection part). Codex seats launch with `--no-daemon` only when `codex --help` shows support (#69, #77); when support is unknown, OpenRig refuses to launch, so a failing or slow `codex --help` blocks the seat. The fixed 30-second readiness window times out under load (#182, open). A git worktree's `.git` file broke the Codex sandbox (#121; see #126). |
|
||||
| **Queue claim, pickup, wakes** | `daemon/src/domain/queue-repository.ts`, `daemon/src/domain/queue-pickup.ts`, `daemon/src/domain/queue-wake-ladder.ts`, `daemon/src/routes/require-sender-identity.ts`, `cli/src/client.ts` | All durable work between seats | After a slow `/healthz`, the CLI host-qualified a local sender and claims failed (#131, fixed by #135). Done-and-closed items read as stalled forever (#164, fixed by #176). |
|
||||
| **Rig identity: same names, archived and deleted rigs** | `daemon/src/domain/rig-repository.ts`, `daemon/src/domain/rig-lifecycle-service.ts`, `daemon/src/domain/rig-teardown.ts`, `daemon/src/domain/seat-handover-service.ts`, `daemon/src/domain/running-name-guard.ts` | Seat resolution, `rig remove`, restore, importing a rig from YAML | Archived rigs still matched seat references, and `rig remove` killed another rig's live seat with the same name (#174, fixed by #181). Replacing stopped same-name rig generations on YAML import followed in #196. |
|
||||
| **Projection of skills and files** | `daemon/src/domain/rigspec-instantiator.ts`, `daemon/src/domain/projection-planner.ts`, and each runtime adapter's `project()` | What every agent is given at start | Codex seats skipped skills that a Claude sibling had already projected (#159, fixed by #162). File-shaped entries failed with ENOTDIR (fixed by #185). |
|
||||
| **Native process observation** | `daemon/src/domain/native-process-lineage.ts` (parses `ps -Ao pid,ppid,pgid,tpgid,ucomm,lstart,command`) | Liveness, identity verdicts, delivery checks | Processes whose command name contains a space were dropped (#134, fixed by #82). Non-English locales broke the date parsing (fixed by #239, which runs `ps` with `LC_ALL=C`). #220 also changed this file to recognise managed Claude seats behind launch wrappers. |
|
||||
| **Schema and migrations** | `daemon/src/db/migrations/`, `daemon/src/db/all-migrations.ts`, `daemon/src/db/migrate.ts` | Every install, and every upgrade of a long-lived install | Parallel pull requests pick the same next number: open #112 adds `086_rig_kernel_variant` while `main` already has `086_classification_fields_and_attempts`, and open #155 and #195 take 090 and 091. A long-lived install upgrading across several releases applies many migrations in one start, against real data. |
|
||||
| **Restore and recovery** | `daemon/src/domain/reconciler.ts`, `daemon/src/domain/restore-check-service.ts`, `daemon/src/domain/startup-orchestrator.ts`, `daemon/src/adapters/tmux.ts` | Reboots, power loss, daemon restarts | Detached seats kept stale tmux pane ids after a reboot (#141, fixed by #151 in `tmux.ts`). `restore-check` ignores the configured shared-docs root (#130, open). After a fresh relaunch, a stale Codex restore attention flag never clears (#116, open). |
|
||||
|
||||
## How reviews use this page
|
||||
|
||||
- **Find where the effect lands.** A reviewer notes which artery a pull request touches, if any,
|
||||
and what depends on it.
|
||||
- **At an artery, review the effect, not only the diff.** Whatever the diff size, the reviewer
|
||||
states the downstream effects and checks behaviour with a scratch reproduction or a test.
|
||||
- **Away from the arteries,** small pull requests keep a normal review. Scrutiny goes where the
|
||||
effect is.
|
||||
|
||||
## The scenario rule
|
||||
|
||||
**A pull request that touches an artery adds or extends a stub-agent scenario, or says in its
|
||||
description why it cannot.** Reviewers check for one or the other. Scenarios live in
|
||||
`packages/test-system/scenarios/`; [ARCHITECTURE.md](../../ARCHITECTURE.md#a-scenario) says how to
|
||||
write and run one, and [test-layers.md](test-layers.md) says what each test layer can and cannot
|
||||
catch.
|
||||
|
||||
The goal is that every artery has at least one scenario whose seeded regression fails, so a small
|
||||
change to an artery meets a real end-to-end check before it merges, not only a reviewer's reading.
|
||||
|
||||
### Scenario coverage today
|
||||
|
||||
At commit `1347d825`. For why the stub cannot reach some of these yet, see "What a stub can and
|
||||
cannot prove" in [test-layers.md](test-layers.md).
|
||||
|
||||
- **Queue durability across a daemon restart: covered.** CI runs
|
||||
`queue-baton-survives-restart.yaml` healthy, then with a seeded `baton-drop` fault that must
|
||||
fail, then healthy again (#243). It runs `rig queue claim` and checks the destination after the
|
||||
restart, but pickup, wakes and claim-identity edge cases (such as the #131 sender case) have no
|
||||
scenario yet.
|
||||
- **Delivery (the #197 class): not covered.** The stub runtime has no input behaviour, so it cannot
|
||||
exercise delivery to a managed wrapper; `send-verify-means-rendered.yaml` is not admitted to CI
|
||||
for that reason (`packages/test-system/ci/README.md`).
|
||||
- **Restore (the #141 class): not covered.** The scenario runner does not bind the `restore` verb
|
||||
yet; it fails with `UnboundActionError`.
|
||||
- **Rig identity (the #174 class): not covered.** No library scenario sets up a same-name archived
|
||||
or removed rig.
|
||||
- **Projection (the #159 class): not coverable with `runtime: stub`,** which projects skills into its
|
||||
own `.openrig/stub/skills/` directory. Use adapter-level unit tests.
|
||||
- **Native process observation: unit tests only** (for example
|
||||
`packages/daemon/test/codex-process-observation.test.ts`, `native-ps-locale.test.ts` and
|
||||
`send-runtime-not-running.test.ts`).
|
||||
- **Migrations: unit guards only** (`migration-fixture-parity.test.ts`,
|
||||
`startup-migrations-mirror.test.ts`). Every scenario starts from an empty database, so upgrading
|
||||
an existing database is not covered.
|
||||
- **Launch:** every scenario's `up` launches stub seats through the real launch path. Native Claude
|
||||
Code and Codex launches are not exercised; the CI containers have no provider CLIs or
|
||||
credentials.
|
||||
|
||||
## Regression log
|
||||
|
||||
When a regression reaches a release, add one line: date, issue, artery, and which layer missed it
|
||||
(the layers are named in [test-layers.md](test-layers.md)). Each line should end up as a scenario.
|
||||
|
||||
- 2026-09-30, #197 (0.6.2), delivery: review saw a small diff in isolation; CI had no managed
|
||||
explicit-mode Claude seat; the project's own day-to-day seats use the default launch path.
|
||||
- 2026-09-29/30, #141, #174, #188 (after 0.6.0), delivery and rig identity: reviews of #109 did not
|
||||
see the downstream effect of the exactly-one-target rule.
|
||||
+809
-1849
File diff suppressed because it is too large.
Load diff
+73
-178
@@ -1,197 +1,92 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: OpenRig Codemap — Navigation Index / Map of Territory
|
||||
title: OpenRig Codemap — Modules and Source Entry Points
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
You know what you need to learn about the shipped OpenRig system but not
|
||||
which as-built module holds it. Use the module map, the use-case lookup,
|
||||
or the source-root table to route to the right doc — or jump straight to
|
||||
the source root the codemap points at.
|
||||
siblings: [README.md, cli-reference.md]
|
||||
You know the behavior you need to inspect but not its owning source files.
|
||||
Follow the module and source entry points below, then read the implementation.
|
||||
siblings: [README.md, arteries.md, test-layers.md, cli-reference.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# OpenRig Codemap — Navigation Index
|
||||
# OpenRig codemap
|
||||
|
||||
This is a **map of territory**, not a content dump. It tells you which
|
||||
as-built module answers a question and which `packages/*/src` root that
|
||||
module is grounded in. The deep content lives in the modules; the code lives
|
||||
in the source roots; this file points at both.
|
||||
The paths and named entry points below were checked at main
|
||||
`254122872cf477511514979a4300b695d77cd1f7`. This is a navigation map, not a replacement for
|
||||
reading the implementation. Linked documents retain their individual verification stamps.
|
||||
[README.md](README.md) lists the complete document tree and commands to derive its inventory.
|
||||
|
||||
> The old codemap was a 425-line file-by-file `Exports:`/`Related:` flat dump
|
||||
> that drifted fastest (its own header carried a stale v0.2.0 footprint and an
|
||||
> internally-contradictory command-group count) and never fit a context
|
||||
> window. That anti-pattern is retired (slice-08 Q5, founder-ratified). The
|
||||
> genuinely-useful part — the source-root index — is preserved below as the
|
||||
> source-root pointer table; the per-file transcription is not.
|
||||
## Package entry points
|
||||
|
||||
## (a) Product frame
|
||||
Paths in backticks are relative to the repository root.
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies — a
|
||||
multi-agent harness that manages your Claude Code and Codex sessions as a
|
||||
single system. The daemon (`@openrig/daemon`) is the framework-free
|
||||
SQLite-backed core; the CLI (`@openrig/cli`), UI (`@openrig/ui`), and MCP
|
||||
server all sit on top of it.
|
||||
|
||||
> Source-verified at HEAD `7eaf524c` (`v0.3.1-6-g7eaf524c`); package version
|
||||
> **0.3.1**, 6 unreleased 0.3.2 commits on HEAD, no `v0.3.2` tag. Frame
|
||||
> sentence triangulated against `architecture/daemon-core.md` §1 +
|
||||
> `openrig-internal/product/positioning.md` ("a multi-agent harness, a local
|
||||
> control plane that manages your Claude Code and Codex sessions as a single
|
||||
> system").
|
||||
|
||||
## (b) Module map
|
||||
|
||||
One row per module. "Reach for this when…" mirrors each module's actual
|
||||
`applies-when` frontmatter.
|
||||
|
||||
### architecture/
|
||||
|
||||
| Module | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `daemon-core.md` | The framework-free SQLite-backed core the CLI/UI/MCP all sit on. | You need to know how the daemon boots, how `createDaemon` wires the dependency graph, the SQLite schema/migration set, or the route-mount surface. |
|
||||
| `adapters-and-runtimes.md` | The five-method runtime-adapter contract + the resume-honesty layer. | You need the runtime-adapter contract, how OpenRig launches/resumes a Claude Code, Codex, or terminal harness inside tmux, or how the daemon honestly assesses whether a harness actually resumed vs fresh-launched. |
|
||||
| `coordination-primitive.md` | PL-004 Phase A: the SQLite-canonical durable-work layer (`/api/stream`, `/api/queue`). | You need the stream/queue/inbox/outbox tables, the hot-potato closure contract, the transactional handoff guarantee, or where queue closure is enforced. |
|
||||
| `workflow-runtime.md` | PL-004 Phase D: turns an intended sequence of work into durable SQLite state. | You need workflow specs cache, instance state, step trails, the transactional-scribe projection contract, or the watchdog policy set including workflow-keepalive. |
|
||||
| `mission-control.md` | PL-005: the daemon-backed queue-observability surface inside the existing shell. | You need the seven views, the seven write verbs, the action audit table, the bearer-token middleware, or how queue observability maps to PL-004 sources. |
|
||||
| `agent-spec-and-startup.md` | How authored YAML specs become a resolved, launched, identity-addressable topology. | You need the AgentSpec/RigSpec/pod-aware reboot types, profile resolution + additive startup layering, the StartupOrchestrator delivery split, or how whoami/materialize/bind/adopt resolve identity. |
|
||||
| `lifecycle-snapshot-restore.md` | The durable-state half of `down → up → handoff`. | You need how OpenRig captures a snapshot, restores a rig (resume vs rebuild vs fresh), enforces restore honesty, consults live continuity, or how restore-check / restore-packet readiness probes work. |
|
||||
| `transport-and-transcripts.md` | The communication-and-history layer; tmux is transport, not truth. | You need how rig send/capture/broadcast works, pipe-pane transcript capture + search, durable SQLite chat, what `rig ask` gathers, or the MCP-tool-name vs tmux-metadata-key distinction. |
|
||||
| `workspace-primitive.md` | PL-007: the typed declaration of where a rig's work lives. | You need how a rig declares a typed workspace (root/repos/defaultRepo/knowledgeRoot), how it persists/resolves into whoami / node-inventory, how per-item `target_repo` scope is validated, or how the file-backed missions/slices tree is indexed and projected into Project. |
|
||||
| `content-surfaces.md` | The operator-allowlisted, filesystem-canonical read/write layer Project/Steering sits on. | You need how the file browser enforces path safety, how atomic conflict-checked writes + the JSONL edit audit work, how the PROGRESS.md tree is indexed, or how the one-screen Steering surface is composed. |
|
||||
| `plugin-agent-image-context-pack.md` | Four filesystem-canonical content primitives the daemon discovers and serves. | You need how OpenRig discovers plugins, captures/forks agent images, assembles/sends context packs, or how the Claude auto-compaction enforcer decides to send `/compact`. |
|
||||
| `packaging-bootstrap-bundles.md` | How a topology is packaged into a shareable bundle and reconstituted elsewhere. | You need how rig/pod bundles are assembled (schema-v2 vs legacy v1), how bundle create/inspect/install + `/api/up` route across source kinds, the staged BootstrapOrchestrator flow, or which legacy install-engine seams still ship. |
|
||||
| `architecture-rules-and-event-system.md` | The cross-cutting invariants that belong to no single subsystem. | You need the 25 architecture rules + startup/import constraints, the shape of the RigEvent union and its SSE delivery, or the intentional compatibility limits that still describe the shipped system. |
|
||||
| `living-notes-review.md` | **v0.4.4** — the one intent→plan→delivered review projection over on-disk SDLC markdown. | You need the `ComposedSliceReview` contract, the `/api/review/*` routes, staged-approval locks (`--scope spec|delivery`), the `## Proof contract` ↔ proof-artifact join and C1-bound `verified`, the freeze export, or ranged media serving for review evidence. |
|
||||
|
||||
### ui/
|
||||
|
||||
| Module | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `shell-and-routing.md` | The shell-first, route-first, primitive-driven operator surface. | You need how the UI shell (rail / Explorer / center workspace / drawer / preview stack) is assembled, the actual route tree the shipped UI mounts, or how the shared detail drawer and event consumption work. |
|
||||
| `topology.md` | The operator's live picture of host → rig → pod → seat. | You need how the topology surface is built — the host hybrid graph, the table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, and the navigation/overlay contracts. |
|
||||
| `project-and-for-you.md` | The three operator-facing destination surfaces (For You / Project / Dashboard). | You need how the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, and the Dashboard landing on the vellum brand system are built. |
|
||||
| `library-specs-and-design-system.md` | The Library (`/specs`) destination + the design-system pointer. | You need how the Library UI is assembled — the spec/skills/plugins surfaces, the spec-review + spec-library + live-identity flows that feed it — or where the canonical visual/design-system spec lives. |
|
||||
|
||||
### root
|
||||
|
||||
| Doc | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `cli-reference.md` | The full `rig` CLI surface, kept as one doc. | You need the exact rig CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. |
|
||||
| `frontmatter-schema.md` | The frontmatter convention these docs follow + the as-built-unique field. | You are authoring or updating a doc under `docs/as-built/`. |
|
||||
| `../DESIGN.md` | The canonical visual / brand / design-system spec (repo `docs/` root). | You need the visual system, brand identity, or design-system tokens. (Stays at root by design; pointer only.) |
|
||||
|
||||
## (c) Structural relationship diagram
|
||||
|
||||
Built from each module's `siblings` / `prerequisite-reads` frontmatter.
|
||||
`daemon-core.md` is the spine (prerequisite for almost every architecture
|
||||
module); `shell-and-routing.md` is the UI spine.
|
||||
|
||||
```
|
||||
README.md (entry — prerequisite for all)
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
architecture/ ui/
|
||||
│ │
|
||||
daemon-core.md ◀── (prerequisite spine for the column below)
|
||||
│ │ │ │
|
||||
│ │ │ └──▶ adapters-and-runtimes.md
|
||||
│ │ │ (five-method contract + resume honesty;
|
||||
│ │ │ consumed by agent-spec startup orchestration)
|
||||
│ │ └────────────────────────────┐
|
||||
│ └──────────────┐ │
|
||||
▼ ▼ ▼
|
||||
coordination- agent-spec-and- transport-and-
|
||||
primitive.md startup.md transcripts.md
|
||||
│ │ │ │
|
||||
▼ ▼ │ ▼
|
||||
workflow- mission- │ lifecycle-snapshot-restore.md
|
||||
runtime.md control.md │ ▲ (consumes persisted replay context)
|
||||
│ │ │
|
||||
└────┬─────┘ ├──▶ packaging-bootstrap-bundles.md
|
||||
▼ │ ▲
|
||||
architecture-rules- │ │ (0.3.x reusable starter-state cluster)
|
||||
and-event-system.md │ ▼
|
||||
(consumes all └──▶ plugin-agent-image-context-pack.md
|
||||
PL-004/005 events)
|
||||
|
||||
workspace-primitive.md ──▶ content-surfaces.md
|
||||
(PL-007 declaration) (filesystem read/write layer on top)
|
||||
│ │
|
||||
└──────────┬───────────────┘
|
||||
▼ (UI counterpart)
|
||||
ui/project-and-for-you.md
|
||||
|
||||
shell-and-routing.md ──▶ topology.md
|
||||
│ └──▶ project-and-for-you.md ◀──▶ architecture/mission-control.md
|
||||
└──────────────▶ library-specs-and-design-system.md ──▶ ../DESIGN.md (pointer)
|
||||
```
|
||||
|
||||
Reading-order rule: open a module's `prerequisite-reads` first
|
||||
(`README.md`, then `daemon-core.md` for most architecture modules;
|
||||
`README.md` then `shell-and-routing.md` for most ui modules).
|
||||
|
||||
## (d) Fast lookup by use-case
|
||||
|
||||
| I need to know… | → see |
|
||||
| Surface | Start here |
|
||||
|---|---|
|
||||
| How the daemon boots / `createDaemon` wiring / migration set / route mounts | `architecture/daemon-core.md` |
|
||||
| The runtime-adapter contract / how a harness is launched or resumed in tmux | `architecture/adapters-and-runtimes.md` |
|
||||
| Where Claude resume honesty lives / resumed-vs-fresh assessment | `architecture/adapters-and-runtimes.md` |
|
||||
| Where queue closure is enforced / hot-potato contract / durable handoff | `architecture/coordination-primitive.md` |
|
||||
| How workflow specs project on closure / transactional-scribe / watchdog policies | `architecture/workflow-runtime.md` |
|
||||
| The Mission Control views/verbs / queue-observability / action audit | `architecture/mission-control.md` |
|
||||
| AgentSpec/RigSpec types, profile resolution, startup layering, whoami/bind/adopt | `architecture/agent-spec-and-startup.md` |
|
||||
| Snapshot/restore, restore honesty, restore-check / restore-packet probes | `architecture/lifecycle-snapshot-restore.md` |
|
||||
| rig send/capture/broadcast, transcripts, durable chat, `rig ask`, MCP-name vs tmux-key | `architecture/transport-and-transcripts.md` |
|
||||
| The workspace primitive (root/repos), `target_repo` scope gating, missions/slices indexing | `architecture/workspace-primitive.md` |
|
||||
| The file browser path safety, atomic writes + edit audit, PROGRESS tree, Steering composer | `architecture/content-surfaces.md` |
|
||||
| The composed slice/mission review (`/api/review/*`), staged-approval locks, proof artifacts + `verified`, freeze export, ranged media | `architecture/living-notes-review.md` |
|
||||
| Plugin discovery / agent images / context packs / Claude auto-compaction enforcer | `architecture/plugin-agent-image-context-pack.md` |
|
||||
| Bundle assembly, bundle install / `/api/up`, BootstrapOrchestrator, legacy install seams | `architecture/packaging-bootstrap-bundles.md` |
|
||||
| The 25 architecture rules / RigEvent union / SSE delivery / compatibility limits | `architecture/architecture-rules-and-event-system.md` |
|
||||
| The UI shell, the real route tree, the shared detail drawer | `ui/shell-and-routing.md` |
|
||||
| The topology graph/table/terminal views, activity-ring / hot-potato visuals | `ui/topology.md` |
|
||||
| The For-You attention feed, Project scope pages, the Dashboard landing | `ui/project-and-for-you.md` |
|
||||
| The vellum brand system on the destination surfaces (0.3.1 brand identity) | `ui/project-and-for-you.md` (vellum brand = **0.3.1** per slice-00 0.3.0-GT seam (b); slice-00 §2 row 1 = vellum-primitives → brand-identity, row 2 = destination-model → polished-destinations) |
|
||||
| The Library `/specs` UI + spec-review/spec-library/live-identity flows | `ui/library-specs-and-design-system.md` |
|
||||
| The full `rig` CLI surface | `cli-reference.md` |
|
||||
| The visual / brand / design-system spec | `../DESIGN.md` (repo `docs/` root) |
|
||||
| Which frontmatter these docs use + the as-built-unique field | `frontmatter-schema.md` |
|
||||
| Daemon process | `packages/daemon/src/index.ts`; `createDaemon()` in `packages/daemon/src/startup.ts`. |
|
||||
| HTTP application | `createApp()` in `packages/daemon/src/server.ts`; route modules under `packages/daemon/src/routes/`. |
|
||||
| State and migrations | `packages/daemon/src/db/connection.ts`, `packages/daemon/src/db/migrate.ts`, `packages/daemon/src/db/all-migrations.ts`. |
|
||||
| CLI | `createProgram()` in `packages/cli/src/index.ts`; commands under `packages/cli/src/commands/`, HTTP in `packages/cli/src/client.ts`. |
|
||||
| MCP | `createMcpServer()` in `packages/cli/src/mcp-server.ts`; stdio command in `packages/cli/src/commands/mcp.ts`. |
|
||||
| Terminal UI | `packages/tui/src/main.ts`, `packages/tui/src/state.ts`, `packages/tui/src/render.ts`; HTTP in `packages/tui/src/daemon-client.ts`. |
|
||||
| Web UI | `packages/ui/src/main.tsx`, `packages/ui/src/routes.tsx`. |
|
||||
| npm assembly | `scripts/build-package.sh`, `scripts/rewrite-daemon-imports.mjs`, `packages/cli/package.json`. |
|
||||
|
||||
## (e) Source-root pointer table
|
||||
The daemon's route handlers obtain domain services from request context. `startup.ts` constructs
|
||||
those services, repositories and runtime adapters; `server.ts` wires them into the application.
|
||||
CLI and TUI clients use the daemon's HTTP projections. Package boundaries and the development
|
||||
versus bundled paths are described in [ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
|
||||
Module → primary `packages/*/src/...` roots. This replaces the old per-file
|
||||
`Exports:`/`Related:` dump: it points at the code, it does not transcribe it.
|
||||
Source-grounding for each claim lives inside the module with file:line cites.
|
||||
## Runtime and state modules
|
||||
|
||||
| Module | Primary source roots |
|
||||
Each row names an existing module and a small set of source starting points. Directories group
|
||||
related code; they are not an exhaustive dependency list.
|
||||
|
||||
| Question | Module | Source entry points |
|
||||
|---|---|---|
|
||||
| How are the daemon, database and routes assembled? | [Daemon core](architecture/daemon-core.md) | `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/db/`. |
|
||||
| How do runtime launch, projection and resume work? | [Adapters and runtimes](architecture/adapters-and-runtimes.md) | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/`, `packages/daemon/src/domain/native-resume-probe.ts`. |
|
||||
| How do authored specs become launched agents with identity? | [Agent spec and startup](architecture/agent-spec-and-startup.md) | `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/startup-orchestrator.ts`, `packages/daemon/src/domain/whoami-service.ts`. |
|
||||
| Where are durable work, delivery and handoff handled? | [Coordination](architecture/coordination-primitive.md) | `packages/daemon/src/domain/queue-repository.ts`, `packages/daemon/src/domain/stream-store.ts`, `packages/daemon/src/domain/inbox-handler.ts`, `packages/daemon/src/domain/outbox-handler.ts`. |
|
||||
| How does a workflow retain progress and resume? | [Workflow runtime](architecture/workflow-runtime.md) | `packages/daemon/src/domain/workflow-runtime.ts`, `packages/daemon/src/domain/workflow-projector.ts`, `packages/daemon/src/domain/policies/`, `packages/daemon/src/routes/workflow.ts`. |
|
||||
| How are queue observations and actions exposed? | [Mission control](architecture/mission-control.md) | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/routes/mission-control.ts`. |
|
||||
| What is captured and checked during restore? | [Lifecycle, snapshot and restore](architecture/lifecycle-snapshot-restore.md) | `packages/daemon/src/domain/snapshot-capture.ts`, `packages/daemon/src/domain/restore-orchestrator.ts`, `packages/daemon/src/routes/restore-check.ts`. |
|
||||
| How do messages and transcript reads reach sessions? | [Transport and transcripts](architecture/transport-and-transcripts.md) | `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/routes/transport.ts`. |
|
||||
| Where do workspaces, projects and repository scope resolve? | [Workspace](architecture/workspace-primitive.md) | `packages/daemon/src/domain/workspace/`, `packages/daemon/src/domain/current-work.ts`, `packages/daemon/src/routes/projects.ts`. |
|
||||
| How are files, progress and steering read or written? | [Content surfaces](architecture/content-surfaces.md) | `packages/daemon/src/domain/files/`, `packages/daemon/src/domain/progress/`, `packages/daemon/src/domain/steering/`. |
|
||||
| How are review inputs, proof and approvals composed? | [Living notes and review](architecture/living-notes-review.md) | `packages/daemon/src/domain/review/`, `packages/daemon/src/domain/scope/`, `packages/daemon/src/routes/review.ts`. |
|
||||
| Where are plugins, agent images and context packs discovered? | [Plugins, images and context packs](architecture/plugin-agent-image-context-pack.md) | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/agent-images/`, `packages/daemon/src/domain/context-packs/`. |
|
||||
| How are reusable bundles assembled and installed? | [Packaging, bootstrap and bundles](architecture/packaging-bootstrap-bundles.md) | `packages/daemon/src/domain/pod-bundle-assembler.ts`, `packages/daemon/src/domain/bootstrap-orchestrator.ts`, `packages/daemon/src/routes/bundles.ts`, `packages/daemon/src/routes/up.ts`. |
|
||||
| Where are event types, publication and SSE handled? | [Architecture rules and events](architecture/architecture-rules-and-event-system.md) | `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/event-bus.ts`, `packages/daemon/src/routes/events.ts`. |
|
||||
|
||||
For an execution projection, also read `packages/daemon/src/domain/execution-view.ts` and
|
||||
`packages/daemon/src/domain/view-projector.ts`. The terminal-side consumer starts in
|
||||
`packages/tui/src/execution/`. These sit alongside the queue and workflow sources above.
|
||||
|
||||
## Change and verification guides
|
||||
|
||||
| Need | Read |
|
||||
|---|---|
|
||||
| `architecture/daemon-core.md` | `packages/daemon/src/{startup.ts,server.ts,index.ts}`, `packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts` |
|
||||
| `architecture/adapters-and-runtimes.md` | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`, `packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts` |
|
||||
| `architecture/coordination-primitive.md` | `packages/daemon/src/domain/{stream-store,queue-repository,queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`, `packages/daemon/src/routes/{stream,queue}.ts` |
|
||||
| `architecture/workflow-runtime.md` | `packages/daemon/src/domain/{workflow-projector,workflow-runtime,workflow-instance-store,workflow-spec-cache,workflow-step-trail-log,workflow-validator}.ts`, `packages/daemon/src/domain/policies/workflow-keepalive.ts`, `packages/daemon/src/routes/workflow.ts` |
|
||||
| `architecture/mission-control.md` | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/middleware/auth-bearer-token.ts`, `packages/daemon/src/routes/mission-control.ts`, `packages/daemon/src/db/migrations/037_mission_control_actions.ts` |
|
||||
| `architecture/agent-spec-and-startup.md` | `packages/daemon/src/domain/{agent-manifest,rigspec-schema,profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`, `packages/daemon/src/routes/{rigspec,whoami}.ts` |
|
||||
| `architecture/lifecycle-snapshot-restore.md` | `packages/daemon/src/domain/{restore-orchestrator,snapshot-capture,snapshot-repository,checkpoint-store}.ts`, `packages/daemon/src/routes/restore-check.ts`, `packages/cli/src/commands/restore-packet.ts` |
|
||||
| `architecture/transport-and-transcripts.md` | `packages/daemon/src/domain/{session-transport,transcript-store,history-query,ask-service,chat-repository}.ts`, `packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`, `packages/cli/src/mcp-server.ts` |
|
||||
| `architecture/workspace-primitive.md` | `packages/daemon/src/domain/workspace/`, `packages/cli/src/commands/config-init-workspace.ts`, `packages/daemon/src/db/migrations/{038,039}*`, `packages/ui/src/routes.tsx` |
|
||||
| `architecture/content-surfaces.md` | `packages/daemon/src/domain/{files,progress,steering}*`, `packages/daemon/src/routes/{files,progress,steering}.ts`, `packages/ui/src/routes.tsx` |
|
||||
| `architecture/living-notes-review.md` | `packages/daemon/src/domain/review/{types,compose,gather,freeze,brief-spine}.ts`, `packages/daemon/src/routes/{review,files,slices}.ts`, `packages/cli/src/commands/{scope,proof}.ts`, `packages/ui/src/components/review/` |
|
||||
| `architecture/plugin-agent-image-context-pack.md` | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/{agent-images,context-packs}/`, `packages/daemon/src/domain/claude-compaction-enforcer.ts` |
|
||||
| `architecture/packaging-bootstrap-bundles.md` | `packages/daemon/src/domain/{pod-bundle-assembler,bootstrap-orchestrator,bundle-*,package-*,install-*}.ts`, `packages/daemon/src/routes/{bundles,up}.ts` |
|
||||
| `architecture/architecture-rules-and-event-system.md` | `packages/daemon/src/domain/types.ts` (RigEvent union), `packages/daemon/src/routes/{stream,queue}.ts` (SSE watch), `packages/daemon/src/server.ts` (`/api/events`) |
|
||||
| `ui/shell-and-routing.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/AppShell.tsx` |
|
||||
| `ui/topology.md` | `packages/ui/src/components/topology/`, `packages/ui/src/lib/{graph,hybrid,multi-rig}-layout.ts` |
|
||||
| `ui/project-and-for-you.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/dashboard/vellum/` |
|
||||
| `ui/library-specs-and-design-system.md` | `packages/ui/src/components/specs/`, `packages/daemon/src/domain/{spec-review-service,spec-library-service}.ts`, `../DESIGN.md` (pointer) |
|
||||
| `cli-reference.md` | `packages/cli/src/index.ts`, `packages/cli/src/commands/*` |
|
||||
| Contributor setup and a small change recipe | [CONTRIBUTING.md](../../CONTRIBUTING.md), [ARCHITECTURE.md](../../ARCHITECTURE.md). |
|
||||
| What depends on a sensitive source path | [arteries.md](arteries.md). |
|
||||
| Unit, route, scenario, package and CI coverage | [test-layers.md](test-layers.md), `packages/test-system/README.md`, `.github/workflows/tests.yml`. |
|
||||
| Exact CLI syntax | [cli-reference.md](cli-reference.md), then `packages/cli/src/index.ts`, its command module and current command help. |
|
||||
| As-built metadata | [frontmatter-schema.md](frontmatter-schema.md). |
|
||||
| An older architecture link | [architecture.md](architecture.md), which redirects into this module tree. |
|
||||
| Visual specification | [DESIGN.md](../DESIGN.md). |
|
||||
|
||||
> No per-file flat dump. File-level detail belongs in the code; this codemap
|
||||
> points at the source root and the module that explains it. The per-file
|
||||
> index anti-pattern (old codemap) is retired, not relocated.
|
||||
## Existing web UI references
|
||||
|
||||
These historical documents are still indexed; this pass checks their locations, not their
|
||||
current implementation claims. Consult each page's stamp.
|
||||
|
||||
| Reference | Source area |
|
||||
|---|---|
|
||||
| [UI compatibility pointer](ui.md) | `packages/ui/src/`. |
|
||||
| [Shell and routing](ui/shell-and-routing.md) | `packages/ui/src/routes.tsx`, `packages/ui/src/components/AppShell.tsx`. |
|
||||
| [Topology](ui/topology.md) | `packages/ui/src/components/topology/`. |
|
||||
| [Project and For You](ui/project-and-for-you.md) | `packages/ui/src/routes.tsx`. |
|
||||
| [Library/specs and design system](ui/library-specs-and-design-system.md) | `packages/ui/src/components/specs/`. |
|
||||
@@ -1,65 +1,73 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
title: As-Built Frontmatter
|
||||
status: active
|
||||
topics: [knowledge-and-context, frontmatter]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Authoring or updating a doc under docs/as-built/. Tells you which
|
||||
frontmatter convention governs these docs and the one field that is
|
||||
unique to as-built docs.
|
||||
siblings: [README.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
Writing or refreshing a source-grounded document under docs/as-built/.
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
---
|
||||
|
||||
# As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
# As-Built Frontmatter
|
||||
|
||||
Every doc under `docs/as-built/` carries YAML frontmatter. The shape is
|
||||
**not** defined here. It is the substrate-wide context-frontmatter
|
||||
convention; this doc only points to it and documents the one field that
|
||||
is unique to as-built docs.
|
||||
Source snapshot: `254122872cf477511514979a4300b695d77cd1f7`. This describes the source at that commit;
|
||||
it does not establish the version or behavior of a running daemon.
|
||||
|
||||
## Canonical convention (do not duplicate — read it)
|
||||
Frontmatter makes an as-built page discoverable and records the source it
|
||||
was checked against. This page describes the convention visible in this
|
||||
repository. It does not require an unpublished schema or an internal
|
||||
workspace to interpret the fields.
|
||||
|
||||
`openrig-work/conventions/frontmatter-for-context/README.md` is the
|
||||
authoritative schema: required floor (`kind` + `title` + `status` +
|
||||
`applies-when`), recommended fields (`topics`, `domains`, `siblings`,
|
||||
`prerequisite-reads`), and the controlled vocabularies for `kind:`,
|
||||
`topics:`, and `domains:`. Author against that convention. This doc adds
|
||||
nothing to it except the field below.
|
||||
## Fields
|
||||
|
||||
## `kind: as-built`
|
||||
The corpus uses these fields; see the [module index](README.md) and
|
||||
[CLI reference](cli-reference.md) for examples.
|
||||
|
||||
These docs use `kind: as-built`. That value is in the convention's
|
||||
controlled `kind:` vocabulary (first row of its "Controlled vocabulary —
|
||||
`kind:`" table, source `openrig/docs/as-built/*`). Using it is conformant,
|
||||
not an extension.
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `kind: as-built` | Identifies a description of implemented behavior. |
|
||||
| `title` | Human-readable subject of the page. |
|
||||
| `status` | Document status, such as `active`; not a daemon or feature status. |
|
||||
| `applies-when` | Short retrieval cue explaining when to open the page. |
|
||||
| `topics`, `domains` | Lists of indexing labels used in this corpus. They do not select permissions or runtime behavior. |
|
||||
| `siblings`, `prerequisite-reads` | Related pages and reading order, when useful. Paths are relative to the containing page. |
|
||||
| `last-verified-against-source` | A resolvable Git commit whose source was used to check the page. |
|
||||
| `last-updated` | Date of the document update, in `YYYY-MM-DD` form. |
|
||||
|
||||
## `last-verified-against-source: <sha>` (as-built-unique)
|
||||
## Verification stamp
|
||||
|
||||
As-built docs describe the running system. They drift when the source
|
||||
drifts. The as-built-unique field records the exact commit the doc was
|
||||
last verified against:
|
||||
Use a commit ID, preferably full length, rather than a branch name or a
|
||||
moving `HEAD` label. A source stamp identifies what was read; it is not proof
|
||||
that an installed package or a running system uses that source. Keep runtime
|
||||
observations separately qualified.
|
||||
|
||||
```yaml
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-verified-against-source: 254122872cf477511514979a4300b695d77cd1f7
|
||||
last-updated: 2026-10-03
|
||||
```
|
||||
|
||||
- **Value:** the short SHA of the source HEAD the doc was verified
|
||||
against at its last edit. Pair with `last-updated: <iso-date>`.
|
||||
- **Why it is here and not in the convention:** the convention is
|
||||
substrate-wide; most context docs do not track a source SHA. This
|
||||
field is meaningful only for docs that mirror code, i.e. as-built
|
||||
docs. It is the per-module drift-detection anchor the
|
||||
context-architecture-v1 mission dogfoods on its own production.
|
||||
- **Discipline:** when re-verifying a module against a newer HEAD,
|
||||
bump this field (and `last-updated`) and carry an inline
|
||||
`re-confirmed <file:line> @HEAD` annotation on each load-bearing
|
||||
claim that was re-checked.
|
||||
Check that the commit resolves with `git cat-file -t <commit>` in a checkout
|
||||
containing that history. Verify the paths, symbols, and numeric claims cited
|
||||
by the page at that commit before changing its stamp. A newer date alone
|
||||
does not refresh the content. Missing history in a shallow checkout and an
|
||||
invalid commit ID are different conditions.
|
||||
|
||||
## See also
|
||||
Use repository links and named symbols for source references. For numeric
|
||||
inventories, state what is counted and how it was obtained; for example, the
|
||||
CLI reference distinguishes registered command objects from aliases and
|
||||
Commander-generated help.
|
||||
|
||||
- `openrig-work/conventions/frontmatter-for-context/README.md` — the canonical schema
|
||||
- `README.md` — the as-built corpus map of territory
|
||||
## What the repository guard checks
|
||||
|
||||
[`scripts/check-docs-guard.mjs`](../../scripts/check-docs-guard.mjs)
|
||||
checks allowed tracked documentation paths. Its `findBlockedDocsPaths`
|
||||
function does **not** validate this frontmatter or prove the prose matches
|
||||
source. Passing that guard should not be reported as a schema or factual
|
||||
accuracy check.
|
||||
|
||||
[As-built pages](README.md) and [operator reference pages](../reference/help.md)
|
||||
serve different purposes. `docs/reference/` is staged by
|
||||
[`scripts/build-package.sh`](../../scripts/build-package.sh); placing content
|
||||
there changes what is distributed in the package.
|
||||
@@ -0,0 +1,499 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Test layers — what to run before you push, and what each layer proves
|
||||
status: active
|
||||
applies-when: |
|
||||
You changed OpenRig and want to know which checks to run before opening a pull
|
||||
request, what each check actually proves, and what CI will run for you. Also
|
||||
read it before claiming a stub-agent scenario covers a behaviour.
|
||||
siblings: [arteries.md, README.md, codemap.md]
|
||||
last-verified-against-source: 1347d825
|
||||
last-updated: 2026-10-02
|
||||
---
|
||||
|
||||
# Test layers: what to run before you push, and what each layer proves
|
||||
|
||||
> **This page is a map, not the territory.** It was checked against source at `1347d825`
|
||||
> and it will drift. It is also incomplete on purpose. Before you rely on a command or a
|
||||
> claim here, read the script it names or run it. If a behaviour is missing from this page
|
||||
> (or from [arteries.md](arteries.md)), that tells you nothing about whether a change to it
|
||||
> is safe.
|
||||
|
||||
## Short version
|
||||
|
||||
For every change, run what the pull-request template asks for:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
npm run lint # typecheck only; there is no formatter or style linter
|
||||
npm test # = npm run test:repo && npm run test:workspaces
|
||||
```
|
||||
|
||||
Then add the layers your change calls for, using the ladder below. CI also runs a
|
||||
network-denied macOS test pass and a containerised installed-package scenario on every PR
|
||||
to `main`. In the PR description, say which layers you ran and which you could not run.
|
||||
|
||||
## The ladder
|
||||
|
||||
| Layer | Command | What it proves | What it cannot prove | When to run it |
|
||||
|---|---|---|---|---|
|
||||
| Typecheck | `npm run lint` | Builds the daemon, then `tsc --noEmit` passes for daemon, ui, cli and tui | Any runtime behaviour. Formatting. | Every change |
|
||||
| Build | `npm run build` | All four workspaces compile (the UI also runs `vite build`) | That the *published* package is complete (see Packaging) | Every change |
|
||||
| Repo checks | `npm run test:repo` | Builds the daemon, then runs `node --test --test-concurrency=1 scripts/*.test.mjs`, `scripts/check-docs-guard.mjs`, `mirror-skills --check` and `generate-context-packs --check` | Package behaviour | Every change. It matters most when you touch `scripts/`, `docs/` or skills. |
|
||||
| Package suites | `npm run test:workspaces`. One package: `npm run test -w packages/cli`. One file, from the repo root: `npx vitest run packages/cli/test/<file>.test.ts` | Vitest unit and integration tests for daemon, cli and tui. Some of them start real scenario daemons, tmux sessions and stub runners. | The installed package. Real Claude Code or Codex. CI's sandbox conditions (see CI). | Every change (`npm test` covers it) |
|
||||
| Web UI suite | `npm run test:ui` | jsdom unit tests for `packages/ui` | Real browser rendering | When you touch `packages/ui`, or a daemon API the UI reads. The web UI is in maintenance mode, so this suite is advisory locally and is not part of `npm test`. |
|
||||
| Generated files | `npm run mirror-skills:check`, `npm run generate-context-packs:check` (both part of `npm test`) | Every copy of each shipped skill matches its recorded hash, and every static context pack passes the daemon's own manifest parser and the leak scan | Whether the skill content is any good | After you edit a shipped skill: edit the same file in every copy that carries it, run `node scripts/regen-edge-digests.mjs`, then the check. The full `npm run mirror-skills` apply needs maintainer-only inputs. See "A shipped skill" in [ARCHITECTURE.md](../../ARCHITECTURE.md#a-shipped-skill). |
|
||||
| Local gate | `npm run gate` | Lint plus `npm test` on a clean tree, tied to `HEAD`, one run per machine at a time, with a JSON receipt | Anything that lint and `npm test` don't already prove | Optional. Useful when several agents share one machine, or when you want a receipt tied to a commit. |
|
||||
| Packaging | `npm run build:package`, `npm run test:tui-package`, `bash scripts/smoke-fresh-install.sh` | The publishable `@openrig/cli` assembles, the packed TUI launches, and a fresh install starts a daemon | Behaviour beyond startup | When you touch dependencies, bin entries, build scripts, or files a package ships |
|
||||
| Stub scenarios, host mode | `node --import tsx packages/daemon/scripts/run-scenarios.mjs [scenario.yaml …]` | A scenario's steps and assertions pass against a scratch daemon, real tmux and `runtime: stub` seats | The installed package. Seeded-regression pairing. Everything under [What a stub can and cannot prove](#what-a-stub-can-and-cannot-prove). | While you write or debug a scenario |
|
||||
| Stub scenarios, installed package in a container | CI job `installed-scenario`. Before pushing: `DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh --remote …` | A clean Linux install starts. A claimed queue item survives a daemon restart. A planted fault is caught at the intended assertion. Two stub seats' scripted output reads back through `rig transcript` and `rig capture` (passing form only). | Native runtimes. Most command families (see [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario)). | Changes that can affect daemon startup, packaging, queue durability or the scenario harness |
|
||||
| Live-model evals | `npm run eval -w packages/daemon -- --provider rig …` | Whether a real Claude Code seat runs the expected `rig context get <entry>` for natural prompts | Determinism. Codex. Anything without a case. | Changes to shipped skills or context entries meant to steer agents. **Uses provider credits.** |
|
||||
| Testbed runbooks | `docker/testbed/runbooks/` (manual) | The testbed image itself works as a test environment | Product behaviour | Only when you change the testbed image or the container plumbing |
|
||||
|
||||
### Gotchas that cost people time
|
||||
|
||||
- **Stale vendored daemon.** `npm run build:package` leaves an assembled copy of the daemon at
|
||||
`packages/cli/daemon/` (gitignored). If you change daemon source afterwards,
|
||||
`scripts/check-cli-daemon-freshness.test.mjs` inside `test:repo` can fail on the stale copy.
|
||||
Re-run `npm run build:package` or delete `packages/cli/daemon/`. `npm run gate` deletes it for you.
|
||||
- **Local passes, CI fails.** CI runs the package suites with a scratch `HOME`, no
|
||||
credentials, and outbound network blocked except to localhost (see below). A test that reads
|
||||
your real home directory, a running daemon, the network or a credential can pass on your
|
||||
machine and fail in CI.
|
||||
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
|
||||
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
|
||||
exception.
|
||||
|
||||
## What CI runs on your pull request
|
||||
|
||||
`.github/workflows/tests.yml` runs on pull requests to `main` and on manual dispatch, all on
|
||||
Node 22. Each suite is its own job, so one failure doesn't hide the others. No provider
|
||||
credentials are supplied.
|
||||
|
||||
| Job | Runner | What it does |
|
||||
|---|---|---|
|
||||
| `build-and-package` | ubuntu | `npm ci` and `npm run build`. Then `npm pack --dry-run` for cli, daemon and tui, with each file list uploaded as the `pack-lists` artifact. The job only records the lists; reviewers read them. |
|
||||
| `typecheck` | ubuntu | `npm run lint` |
|
||||
| `repo-checks` | ubuntu | `npm run test:repo` |
|
||||
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
|
||||
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (eight container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
|
||||
|
||||
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
|
||||
lists lines your PR adds that contain machine-specific values: credentials, home or temp
|
||||
paths, network addresses, email addresses. It never fails because of a finding. To see the
|
||||
matched text locally (credentials show only their first characters), run
|
||||
`node scripts/portability-report.mjs`. By default it compares the
|
||||
merge-base with `origin/main` against `HEAD`; pass `--staged` to check staged changes instead.
|
||||
|
||||
The UI suite is a CI matrix leg even though it is advisory locally. Check your PR's status to
|
||||
see whether a red UI leg is treated as blocking. Branch-protection settings are not in the
|
||||
repository.
|
||||
|
||||
## The local gate: `npm run gate`
|
||||
|
||||
`scripts/gate-lane.mjs` runs two legs: `npm run lint` and `npm run test`. It does not run
|
||||
`test:ui`, the scenarios or the packaging checks. On top of those legs it:
|
||||
|
||||
- holds a machine-wide lock on localhost port 40404 (override with `OPENRIG_GATE_LANE_PORT`).
|
||||
A second gate exits 2 straight away instead of waiting.
|
||||
- refuses a dirty worktree. Refuses a `node_modules` that is a symlink, or `@openrig/*`
|
||||
workspace links that resolve outside this worktree. Both are common with shared git worktrees.
|
||||
- deletes the stale vendored daemon bundle at `packages/cli/daemon/` before the legs run.
|
||||
- writes `gate-lane-verdict.json` at the repo root (or wherever `OPENRIG_GATE_VERDICT`
|
||||
points). The file holds the `HEAD` SHA, per-leg results and durations, and a note of other
|
||||
`node`/`vitest`/`tsc` processes running at the time. It fails if `HEAD` changes during the
|
||||
run. The file is not gitignored, so don't commit it.
|
||||
|
||||
Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
|
||||
`OPENRIG_GATE_LANE_SMOKE=1` skips the legs to test the wiring only, and the verdict records
|
||||
`smoke: true`.
|
||||
|
||||
## Stub-agent scenarios
|
||||
|
||||
### What's in the box
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
|
||||
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `emit`, `mutate`, `policy` parse but are unbound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
|
||||
| Scenario library | `packages/test-system/scenarios/`: 15 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
|
||||
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
|
||||
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
|
||||
| In-container entry and result check | `packages/test-system/ci/run.mjs`, `packages/test-system/ci/result.mjs` |
|
||||
| PR orchestrator | `scripts/run-pr-scenarios.sh` |
|
||||
| Image | `docker/testbed/Dockerfile`, built by `scripts/build-testbed-image.sh`; `docker/testbed/Dockerfile.scenarios` is layered on top by `scripts/run-pr-scenarios.sh` |
|
||||
|
||||
### Host mode: while you're writing a scenario
|
||||
|
||||
```sh
|
||||
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
|
||||
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
|
||||
node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml
|
||||
```
|
||||
|
||||
- The runner creates a scratch `HOME`/`OPENRIG_HOME`, its own tmux server and its own
|
||||
daemon. It builds the scenario environment from `HOME`, `PATH` and `TERM` only, so your
|
||||
shell's daemon-target variables (`OPENRIG_URL`, `OPENRIG_PORT` and similar), `TMUX` and
|
||||
`OPENRIG_TEST_CLOCK_NOW` are dropped rather than inherited. It does not touch the daemon or
|
||||
tmux server your own session uses.
|
||||
- It accepts YAML paths only. Any flag, including `--container`, is refused before anything runs.
|
||||
- It supplies no fault controller, so any scenario with a `seed_regression` step fails loudly
|
||||
at that step. That includes the library's `queue-baton-survives-restart`. Use the container
|
||||
path to run the seeded pair.
|
||||
- `scenario-10-one-view-state` declares its own normaliser for its cross-surface `equals`
|
||||
check. Whether it passes in host mode has not been verified.
|
||||
|
||||
### Container mode: the scenario pack CI runs
|
||||
|
||||
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
|
||||
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
|
||||
each run three times as healthy, then lost-baton, then healthy. Then the two stub-script
|
||||
cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
|
||||
`capture-returns-addressed-seat.yaml`) run once each, healthy only. That is eight fresh
|
||||
containers.
|
||||
|
||||
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
|
||||
--remote --case library --mode healthy --out dist/scenario-check
|
||||
```
|
||||
|
||||
- Options: `--case fixture|library|transcript|capture` (default: all four), `--mode
|
||||
healthy|lost-baton` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
|
||||
`dist/pr-scenarios`). `--mode` applies to `fixture` and `library`; `transcript` and `capture`
|
||||
always run once, healthy, and `--mode lost-baton` leaves them out. Use a fresh `--out`
|
||||
directory for each run you want to keep.
|
||||
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
|
||||
`DOCKER_CONTEXT` is set. The engine must be Linux on amd64 or arm64. The script reads the
|
||||
*server's* platform, so a macOS/arm64 laptop driving a Linux/amd64 engine works.
|
||||
- Without `--remote`, the script refuses to run anywhere except GitHub Actions on Linux.
|
||||
- The source build and pack run on your machine. The image build and the containers run on
|
||||
the engine. Building the image needs network access for the pinned base image, Node and
|
||||
dependencies. The scenario containers have no network.
|
||||
- The image is built from your working tree but tagged with `git rev-parse HEAD`. Commit
|
||||
first, so the tag names what you actually tested.
|
||||
- It never pushes images. It removes only the containers it named. If the SSH connection
|
||||
drops, it prints the name of any container it could not remove.
|
||||
|
||||
What happens, in order:
|
||||
|
||||
1. `scripts/build-testbed-image.sh` packs `@openrig/cli` from your tree. It installs the
|
||||
package into a digest-pinned Debian image with Node, checks `rig --version`, and checks
|
||||
that the installed daemon loads. The result is tagged `openrig-testbed:<sha>`.
|
||||
2. `docker/testbed/Dockerfile.scenarios` adds a bundled copy of
|
||||
`packages/test-system/ci/run.mjs` plus the fixture and library scenarios.
|
||||
3. Each run uses a fresh container: `--network none`, read-only root plus a 512 MB `/tmp`
|
||||
tmpfs, `--cap-drop ALL`, `no-new-privileges`, non-root, 2 CPUs, 2 GiB, 256 PIDs and a
|
||||
300-second deadline.
|
||||
4. In lost-baton mode, the runner stops the daemon, changes exactly one queue row in the
|
||||
scratch database from `in-progress` to `pending`, and restarts the daemon.
|
||||
`packages/test-system/ci/result.mjs` then accepts only one outcome: a failure at the
|
||||
post-restart queue assertion, with `baton-1` observed as `pending` at the expected
|
||||
destination. A startup error, a timeout or any other failure does not count as a caught
|
||||
regression. Healthy runs must pass with no fault armed.
|
||||
5. The `transcript` and `capture` runs are passing form only: `result.mjs` accepts only a
|
||||
clean PASS of the named scenario, with no seed or fault. Their planted failures are
|
||||
product-code mutations, so they stay per-PR evidence and are not CI runs. Stub scripts
|
||||
work here because the runner executes the pipeline in host mode inside the image.
|
||||
|
||||
A single healthy run proves that one leg only. The paired fault run is what shows the
|
||||
assertion can catch the failure.
|
||||
|
||||
Adding a new case to this pack today means changing three files:
|
||||
`scripts/run-pr-scenarios.sh` (the `--case` list), `CASES` in
|
||||
`packages/test-system/ci/result.mjs`, and the fault controller in
|
||||
`packages/test-system/ci/run.mjs`, which is currently specific to the baton row. A
|
||||
passing-only case needs no fault controller: add it to `PASSING_CASES` and the `--case` list
|
||||
in `scripts/run-pr-scenarios.sh`, and to `PASSING_CASES` in `result.mjs`.
|
||||
|
||||
### What a green stub scenario proves
|
||||
|
||||
- The packaged CLI installs into a clean Linux container and starts a daemon.
|
||||
- `rig up` and `rig down` of a stub topology go through the real daemon, tmux, SQLite and CLI.
|
||||
- Queue state, read through the shipped `rig queue list` path, survives a daemon restart.
|
||||
- With the paired seeded run: the assertion fails when that state is broken.
|
||||
|
||||
### What a stub can and cannot prove
|
||||
|
||||
The stub does not fabricate product outputs. It runs the real daemon, tmux and CLI. But it is
|
||||
not Claude Code or Codex, and it differs from them in ways that decide what a stub green
|
||||
means:
|
||||
|
||||
| Area | What the stub does at `1347d825` | What that means for your test |
|
||||
|---|---|---|
|
||||
| Consuming a message | `stub-runner.ts` runs its launch script once and then idles. It has no stdin reader, socket or other input channel. The default script prints `[stub] scripted reply: acknowledged` at boot, before anything has been sent. | A pane showing a reply, or your echoed text, does not prove the message was consumed. No stub scenario can currently prove "delivered and answered". Separately, `rig send --verify` means "appeared in the pane", not acknowledgement (see `rig send --help`), and the scenario `send` step doesn't pass `--verify` at all. |
|
||||
| Launch path | `StubRuntimeAdapter` types `node <stub-runner> …` into the pane (`tmux.sendText`, then Enter). Claude Code with an explicit permission mode launches through a managed launch (`ClaudeManagedLaunch.prepare`, `tmux.sendShellCommand`); without one it uses `sendText`. The shell-foreground check in `session-transport.ts` (`unverifiedShellForeground`) runs for every runtime except `terminal`, but its native-process proof is limited to `codex` and `claude-code`. | A stub seat doesn't exercise wrapper, managed-launch or native-process-identity behaviour (the class behind #197). Wrapping the stub in a shell wouldn't change that. |
|
||||
| Permissions | `validateNativePermissionSelection` accepts only `codex` and `claude-code`. The stub models only the `floor` / `full_bypass` launch posture. | Testing Claude permission modes or per-seat permission selection needs a real runtime. |
|
||||
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
|
||||
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
|
||||
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
|
||||
| Step-time restore, compaction, mutation, policy | `restore`, `emit`, `mutate` and `policy` parse, but the runner throws `UnboundActionError` when it reaches them. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
|
||||
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects into `<cwd>/.claude/skills/`. | A stub green says nothing about provider-specific projection (the class behind #159). |
|
||||
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
|
||||
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
|
||||
|
||||
The most useful next pieces of harness work, roughly in dependency order:
|
||||
|
||||
1. A stub that consumes input and answers a fresh nonce.
|
||||
2. A model-free test entry point shaped like a provider, that goes through the real native
|
||||
adapter and managed launch.
|
||||
3. Lifecycle and identity steps (tmux reset, archive/remove).
|
||||
4. Then pickup, wake and restore scenarios.
|
||||
|
||||
### Library status
|
||||
|
||||
Three scenarios run in CI: `queue-baton-survives-restart` (since #243) with its seeded pair,
|
||||
and `transcript-reads-addressed-seat` and `capture-returns-addressed-seat` in passing form
|
||||
only. Two more, `down-stops-every-seat` and `send-renders-in-addressed-pane`, run with the
|
||||
local runner but not in CI. Those four later scenarios are not in this table; see "Status
|
||||
today" under [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario). The
|
||||
other ten scenarios are authored but not yet runnable as meaningful checks:
|
||||
|
||||
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|
||||
|---|---|---|
|
||||
| `queue-baton-survives-restart` | Runs in CI: healthy / seeded `baton-drop` / healthy | Extend it to handoff, block, resolve and unclaim |
|
||||
| `clean-lifecycle-no-residue` | The post-`down` "nothing left over" assertion is commented out, because the format has no way to say "empty" | Add a real absence assertion |
|
||||
| `ps-scope-honesty` | The second `up` reuses the first topology, and the expected match tolerates the other rig | Make `up` honour the per-step topology, then assert the exclusion |
|
||||
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
|
||||
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
|
||||
| `compaction-restore-resumes-role` | Step-time `emit` is unbound | Input-triggered hooks plus evidence specific to one seat |
|
||||
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
|
||||
| `policy-posture-survives-structural-edit` | `policy` and `mutate` are unbound | Rewrite around current per-seat permission selection and an observed launch |
|
||||
| `locked-restore-never-permissive` | `policy` and `restore` are unbound | Keep the invariant (the selection survives a restore) and use current modes |
|
||||
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`), and `mutate` is unbound | Declare the comparable fields and real mutations |
|
||||
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
|
||||
|
||||
In most of these files, `seed_regression` steps that come *after* the assertions are
|
||||
historical markers, not executable fault injections.
|
||||
|
||||
## If your change touches an artery, add or extend a scenario (or say why not)
|
||||
|
||||
[arteries.md](arteries.md) lists the behaviours that everything else depends on. If your
|
||||
diff can affect one, your PR description should do one of these:
|
||||
|
||||
- link the scenario that covers it, and say which layer you ran it in;
|
||||
- add or extend a scenario. For a regression scenario, show that the fixed code passes and
|
||||
that the planted old defect fails *at the intended assertion*. A setup crash or a timeout
|
||||
doesn't count;
|
||||
- or say precisely why the stub can't exercise it. Use the table above, which covers
|
||||
message consumption, native launch and permissions, tmux reset and so on. Then say what
|
||||
you ran instead, for example a manual run with the real runtime.
|
||||
|
||||
This is part of normal review, not an extra approval step. It also isn't a claim that
|
||||
changes outside the arteries map are safe.
|
||||
|
||||
## Live-model evals
|
||||
|
||||
`packages/test-system/evals/` is a separate check from scenarios. A real seat receives a
|
||||
natural prompt and decides what to do. A case passes when the seat's transcript shows the
|
||||
expected `rig context get <entry>` command, in the expected order. Cases live in
|
||||
`cases/selection.yaml` (10) and `cases/loading.yaml` (4).
|
||||
|
||||
```sh
|
||||
# Harness check: a fake provider fed a JSON map of prompt -> transcript.
|
||||
# Any prompt without a transcript counts as an error, never as a pass.
|
||||
npm run eval -w packages/daemon -- --transcripts <transcripts.json> --out <result.json>
|
||||
|
||||
# Live run: drives a real Claude Code seat
|
||||
npm run eval -w packages/daemon -- --provider rig --seat <session> --out <result.json>
|
||||
npm run eval -w packages/daemon -- --provider rig --seat-spec "$PWD/<one-seat-rig.yaml>" --out <result.json>
|
||||
```
|
||||
|
||||
- **Live runs use provider credits, and the results vary from run to run.** CI never runs them.
|
||||
- `--provider rig` needs `OPENRIG_HOME` set, and it supports Claude Code seats only. It
|
||||
refuses a Codex seat.
|
||||
- `--seat-spec` runs `rig up` of a scratch rig against **your current daemon**, then
|
||||
`rig down` at the end. `--seat` attaches to an existing seat and leaves it running.
|
||||
- `evals/rig-seat/example-scratch-rig.yaml` sets `permission_policy: builtin:yolo` and
|
||||
`cwd: "."`. It also references `local:agents/blank`, and no such agent directory ships
|
||||
beside it. Read the file and expect to adapt it before you use it.
|
||||
- Before any run, the runner builds the production context package in a temp directory and
|
||||
refuses if any case's entry doesn't resolve in it.
|
||||
- `npm test` covers the harness without a model: the case schema, the grader, the fixtures
|
||||
and production entry resolution (`packages/daemon/test/eval-*.test.ts`).
|
||||
|
||||
## Docker testbed runbooks
|
||||
|
||||
`docker/testbed/runbooks/` holds manual procedures, L0 to L6, that you run on a machine with
|
||||
Docker. They check the testbed image itself:
|
||||
|
||||
- L0: resolve the pinned base image and the stub asset list
|
||||
- L1: PTY allocation
|
||||
- L2: tmux server lifecycle
|
||||
- L3: the daemon inside a container, settling a stub rig
|
||||
- L4: the scenario environment still refuses a foreign daemon target inside a container
|
||||
- L5: several containers acting as named hosts on one Docker network
|
||||
- L6: the scenario runner driving a scenario through a container
|
||||
|
||||
Use them when you change `docker/testbed/`, `scripts/build-testbed-image.sh` or the
|
||||
container staging helpers. Ordinary PRs don't need them. They were written before the CI
|
||||
scenario job and contain internal planning references, so treat them as procedures to adapt,
|
||||
not as current status.
|
||||
|
||||
## Help wanted: command families without a behavioural scenario
|
||||
|
||||
> This list may be stale. It was taken from the `rig --help` tree of 0.6.3: 85 visible
|
||||
> top-level families. At `1347d825`, `packages/cli/src/index.ts` registers 85 top-level
|
||||
> commands. Before picking one up, check `packages/test-system/scenarios/`, the cases in
|
||||
> `scripts/run-pr-scenarios.sh`, and the current `rig --help`.
|
||||
> One scenario doesn't cover a family's every option, sequence or platform. A `--help`
|
||||
> check only proves the command is discoverable. Existing unit tests are not counted here.
|
||||
> They exist, but this list is about behavioural scenarios.
|
||||
|
||||
Status today: `queue` has partial coverage, namely create, claim and list, plus the claim
|
||||
surviving a restart. `down` has partial coverage in
|
||||
`packages/test-system/scenarios/down-stops-every-seat.yaml`: after `rig down`, `rig ps` reports
|
||||
every seat stopped and the rig still listed. `send` has partial coverage in
|
||||
`send-renders-in-addressed-pane.yaml` beside it: the envelope and the message body render in
|
||||
the addressed seat's pane. `transcript` has partial coverage in
|
||||
`transcript-reads-addressed-seat.yaml`: each of two seats' scripted output is readable through
|
||||
that seat's `rig transcript --tail 200 --json`. `capture` has partial coverage in
|
||||
`capture-returns-addressed-seat.yaml`: in a rig whose member names overlap by prefix (`impl`,
|
||||
`impl2`; `dev-impl`, `dev-impl2` with the pod), `rig capture <seat>` returns each seat's own
|
||||
pane. The scenario catches a capture that resolves the seat by a prefix match on its member
|
||||
name (so `dev-impl` also matches `dev-impl2`) and takes the first hit, or any resolver that
|
||||
returns the same pane for both seats; it does not exercise a prefix match on the full seat
|
||||
name. All four run with the local runner (`run-scenarios.mjs`). The transcript and capture
|
||||
scenarios also run in the CI job, in passing form only; the down and send scenarios don't.
|
||||
They do not prove that panes are gone or that a seat consumed a message. The transcript
|
||||
case does not establish ordering, uniqueness, sibling-token absence, tail limits or restart
|
||||
persistence. `up` and `daemon` are used by the existing scenarios but have no scenario of their
|
||||
own. Everything else has none.
|
||||
|
||||
Before you start, two honest constraints:
|
||||
|
||||
- Today's step verbs can't run arbitrary `rig` commands. Many rows below need a runner
|
||||
binding first. One proposal (not implemented) is a single structured-argv step that runs
|
||||
the installed CLI through the existing `rigBin`, instead of one new verb per command.
|
||||
- Rows that need a seat to *answer* (anything with "nonce") depend on the input-consuming
|
||||
stub described above.
|
||||
- `expect` has no negative, absence or count form. `match` is a structural subset and
|
||||
`contains` is a substring, so "the sibling received nothing", "the panes are gone" or "no
|
||||
duplicates" can't be asserted yet. An exact field value, such as `runningCount: 0` on the
|
||||
`ps` surface, is the closest available form.
|
||||
- The `restart` step runs `rig launch <rig> <node>` with no flags, and the node is the logical
|
||||
ID (for example `dev.qa`). A stub seat has no resume token, so relaunching it after `down`
|
||||
stops at `awaiting-decision` and asks for `--fresh`. The `launch` row needs that flag passed
|
||||
through first.
|
||||
|
||||
| Group | Family | Proposed first check (observable result) |
|
||||
|---|---|---|
|
||||
| lifecycle | `start` | Restore two stopped stub seats. Both answer distinct fresh nonces, and no second daemon starts. |
|
||||
| lifecycle | `daemon` | start/status/stop/restart. Port, process and persisted queue identity agree through public commands. |
|
||||
| lifecycle | `bootstrap` | Bootstrap a minimal stub spec. Seats come up ready and the selected spec is the one used. |
|
||||
| lifecycle | `up` | Launch two stub seats. Each consumes its own send. Relaunching doesn't duplicate identities. |
|
||||
| lifecycle | `down` | (partial) Next: its panes are gone, a sibling rig still answers, retained data follows the docs. |
|
||||
| lifecycle | `create` | Create a one-seat rig through the public path. It appears and answers without a hand-written topology. |
|
||||
| lifecycle | `launch` | Fresh, relaunch, resume and fork on provider-shaped fixtures. Correct restored token, one live process. |
|
||||
| reads | `status` | Two rigs in different states. Status matches their exact identities and transitions. |
|
||||
| reads | `ps` | Two rigs with overlapping seat names. Rig and node filters return exactly the intended members. |
|
||||
| reads | `usage` | Deterministic stub telemetry. top and series respect seat and time bounds. Missing data isn't reported as zero. |
|
||||
| reads | `health` | Known fresh, stale and cleared findings. Scope, explain and diagnosis values match the fixture. |
|
||||
| continuity | `snapshot` | Snapshot two seats. Rig, membership and snapshot bytes survive a daemon restart. |
|
||||
| continuity | `restore` | Restore after a daemon and tmux reset. Seat identity and nonce answers survive. |
|
||||
| continuity | `agent-image` | create/show/pin/unpin/delete for a provider-shaped fixture. References and identity match. |
|
||||
| continuity | `fork` | Fork at a known history token. The child keeps the prefix with its own identity, and the parent still answers. |
|
||||
| continuity | `restore-check` | One restorable and one missing transcript. Readiness tells them apart without relaunching. |
|
||||
| continuity | `restore-packet` | write/read/validate round trip. A malformed packet fails clearly. |
|
||||
| continuity | `compact-plan` | Scripted history and usage fixtures. Candidates and exclusions match thresholds, and nothing is compacted. |
|
||||
| continuity | `compact` | Input-driven compact and restore keep the role and token. Messages before and after reach the same seat. |
|
||||
| continuity | `handover` | Scripted predecessor and successor. Context is delivered before the rebind, and the successor answers. |
|
||||
| recovery | `crash-cart` | Stop only the scratch daemon. The output names paths and state, and the command changes nothing. |
|
||||
| recovery | `preflight` | Ready and missing-dependency fixtures produce named checks without launching seats. |
|
||||
| recovery | `doctor` | Healthy and damaged scratch installs. Exact diagnosis, no automatic repair. |
|
||||
| recovery | `destroy` | In a disposable scenario HOME only. Removes the intended state and keeps explicitly retained markers. |
|
||||
| registry | `gateway` | Add, show, update and remove a synthetic human. Reads match exact fields, and removal sticks. |
|
||||
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
|
||||
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
|
||||
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
|
||||
| coordination | `queue` | (partial) Next: handoff, block, resolve and unclaim, each with exact items and destinations. |
|
||||
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
|
||||
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
|
||||
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
|
||||
| coordination | `workflow` | validate/compile/instantiate a tiny flow. It transitions and resumes after a restart with no duplicate queue work. |
|
||||
| coordination | `chatroom` | send/history/topic/clear in a scratch room. A bounded watcher sees exactly the ordered messages. |
|
||||
| coordination | `heartbeat` | Known execution-proof and missing-closure fixtures report the right status. Empty output isn't completion. |
|
||||
| authoring | `export` | Export a stub rig, then validate and re-import it. Membership and configuration are equivalent. |
|
||||
| authoring | `import` | Import a minimal spec, and the export matches it. Malformed input leaves state unchanged. |
|
||||
| authoring | `package` | validate/plan/install/rollback in scratch. Installed files and prior state match. |
|
||||
| authoring | `requirements` | Complete and missing capability fixtures produce the right named requirements. |
|
||||
| authoring | `bundle` | create/inspect/install a local bundle and round-trip it. A missing asset gives a named failure. |
|
||||
| authoring | `agent` | Validate a good and a malformed stub AgentSpec. The diagnostic names the bad field. |
|
||||
| authoring | `spec` | validate/audit/preflight/show a minimal spec, with predictable diagnostics for a malformed field. |
|
||||
| authoring | `specs` | add/show/preview/rename/remove a library fixture. A missing reference fails explicitly. |
|
||||
| authoring | `workspace` | validate/doctor known roots plus one missing-frontmatter case. Reports the exact affected path. |
|
||||
| authoring | `config` | set/get/reset a scratch key and init a workspace. Readback and files match the requested root. |
|
||||
| presentation | `ui` | Open the URL through a fake browser executable and read a local UI route. Rendering is out of scope. |
|
||||
| presentation | `tui` | Drive the TUI command bar and socket over two stub rigs. select, mutate and back show exact identities. |
|
||||
| presentation | `terminal` | open/status/views against a fake terminal-app endpoint. Correct target and layout arguments. |
|
||||
| adoption | `discover` | One unmanaged and one owned scratch tmux session. Only the intended candidate is discovered. |
|
||||
| adoption | `attach` | Attach to the intended seat. `whoami` reports it without duplicating the process. |
|
||||
| adoption | `bind` | Bind one discovered pane. A different, already-occupied identity is refused. |
|
||||
| adoption | `adopt` | Adopt two scratch sessions. PIDs and history are preserved, and the identities become addressable. |
|
||||
| adoption | `reconcile-session` | Rebind a hand-resumed provider-shaped fixture without starting or typing into another process. |
|
||||
| adoption | `unclaim` | Release an adopted seat. The tmux process and its history stay alive. |
|
||||
| adoption | `release` | Release one rig's claimed sessions. Processes survive, and a sibling rig is unchanged. |
|
||||
| identity | `archive` | Archive an old same-name generation. The current one stays routable, and the old data is kept. |
|
||||
| identity | `unarchive` | Unarchive a retained generation without taking over another live seat. |
|
||||
| identity | `remove` | Remove an archived generation or one member. Only that identity goes, and a reused name still routes. |
|
||||
| identity | `whoami` | Inside each stub seat, it returns that seat, not a sibling or the caller. |
|
||||
| identity | `seat` | Status, held-message guard, stop/launch and resume token keep the right process and permissions. |
|
||||
| integration | `mcp` | Serve against a scratch daemon. One listed tool's value matches CLI state. |
|
||||
| integration | `slack` | Manifest and config round trip, plus deterministic inbound and outbound fixture requests. No real Slack account. |
|
||||
| integration | `ask` | Search fixture transcripts via a fake model endpoint. Citations resolve to the stored lines. |
|
||||
| integration | `file` | Copy a fixture payload through isolated transport endpoints. Verify bytes and paths. |
|
||||
| integration | `env` | A fixture service-backed rig. status/logs/down reflect that service and stop only it. |
|
||||
| delivery | `transcript` (partial) | Own scripted output from two addressed seats is covered by `transcript-reads-addressed-seat.yaml`. Remaining: ordering, uniqueness, no other seat's token, tail limits and restart persistence; absence/count assertions need a runner binding. |
|
||||
| delivery | `send` | (partial) Next: send a fresh nonce and require an answer derived from it. Boot text or echo can't pass. |
|
||||
| delivery | `capture` | (partial) Each seat's own token, under prefix-overlapping member names. Next: the not-found, tail (`--lines`), sibling-absence and `--rig`/`--pod` cases, which need an error form, a tail option, an absence form and a multi-target surface in the runner. |
|
||||
| delivery | `broadcast` | Broadcast a nonce to a rig or pod. Each intended seat answers once, and an excluded seat gets nothing. |
|
||||
| delivery | `walk` | Walk two pieces through an input-consuming stub. Order is kept, and a bad piece isn't reported as delivered. |
|
||||
| context | `context` | add/get/compose/trace/profile over a fixture project. Output bytes, order and roots match. |
|
||||
| context | `plugin` | list/show/used-by/validate a fixture plugin. A missing resource is not the same as a valid empty selection. |
|
||||
| context | `skill` | Resolve and audit a fixture skill, then inspect the projection the consumer actually gets. |
|
||||
| context | `mode` | set/effective/cite/unset at nested scopes. Precedence holds, and the inherited value returns after unset. |
|
||||
| context | `startup-proof` | Submit a proof from a real stub identity. Check the stored attribution and the duplicate behaviour. |
|
||||
| permissions | `policy` | Apply and read an explicit permission selection using a provider-shaped fixture. It survives a restore, and the default is unchanged. |
|
||||
| accounts | `auth` | Synthetic auth files in scratch: save, list, validate, switch. No provider contact, no real credentials. |
|
||||
| accounts | `provider` | Synthetic account, binding and signal state. The precheck refuses where it should, and a switch keeps seat history. |
|
||||
| topology | `expand` | Add a pod to a live stub rig. Old seats survive, and new members answer nonces. |
|
||||
| topology | `add` | Add one seat. Exact binding, siblings preserved, and the duplicate-name behaviour is explicit. |
|
||||
| topology | `grow` | Grow two seats. Identities are unique, config is inherited, and existing history is kept. |
|
||||
| topology | `shrink` | Remove a pod. Unrelated seats still answer, and the removed membership is gone. |
|
||||
| onboarding | `setup` | Scripted newcomer answers against synthetic providers. No install or login for an unused provider. |
|
||||
| work | `scope` | Create a mission and a slice. move/progress/notes/graph readback keep identity and paths. |
|
||||
| work | `proof` | add/judge/show evidence in a fixture scope. Readiness updates without overwriting earlier proof. |
|
||||
|
||||
How to make a case useful:
|
||||
|
||||
- Assert actual state or content, not just exit code 0. Where ordinary use includes bad
|
||||
input, add an invalid or missing input case too.
|
||||
- Reuse the existing runner and the installed CLI. Don't add a second scenario engine.
|
||||
- If a scenario exposes an existing product bug, keep the failing case and open an issue.
|
||||
Include the exact source revision, the shortest failing sequence, and the intended versus
|
||||
observed result. Don't weaken the assertion to make it pass. Keep "the harness setup
|
||||
failed" separate from "the product misbehaved".
|
||||
- A check that only covers the success path is still useful. Just don't call it a
|
||||
regression detector until you have seen its negative case fail.
|
||||
|
||||
## Existing docs in this area that are out of date
|
||||
|
||||
Read these with care. This page notes them and proposes no changes.
|
||||
|
||||
- `packages/test-system/README.md`: says the scenarios "DO NOT run" until a runner lands.
|
||||
The runner exists, and three library scenarios run in CI. Its layout lists `fixtures/*.yaml`,
|
||||
a directory that no longer exists (the topologies live in `scenarios/`). It also says the
|
||||
emit behaviours don't exist yet. Launch-script `emit` now exists, but step-time `emit` is
|
||||
still unbound. `packages/test-system/ci/README.md` is the current description of the CI
|
||||
scenario job.
|
||||
- `packages/test-system/scripts/README.md` and `packages/test-system/ROUTING.md` are
|
||||
dependency-routing notes from when the scenarios were first written. Several of the items
|
||||
they list have since landed. Treat them as history.
|
||||
- `packages/test-system/evals/README.md`: says cases are `cases/*.ts`. They are
|
||||
`cases/selection.yaml` and `cases/loading.yaml`. Its "build in progress" status line is old.
|
||||
- `docs/reference/developing.md`: says "There is no external CI at this tip". In fact
|
||||
`.github/workflows/tests.yml` runs on every PR to `main`, and its `package-tests` matrix
|
||||
includes `ui`. Its description of `test:repo` leaves out the `generate-context-packs --check` step.
|
||||
- `docker/testbed/runbooks/`: see above. They are manual procedures that predate the CI job.
|
||||
@@ -269,7 +269,7 @@ Each array contains resource IDs. These can be:
|
||||
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
|
||||
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
|
||||
|
||||
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
|
||||
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `hooks`, `runtime_resources`.
|
||||
|
||||
---
|
||||
|
||||
@@ -299,6 +299,40 @@ resources:
|
||||
|
||||
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
|
||||
|
||||
### Plugin paths
|
||||
|
||||
A local plugin's `source.path` supports these forms:
|
||||
|
||||
| Form | Resolution |
|
||||
|------|------------|
|
||||
| `/absolute/plugin` | The exact absolute path. |
|
||||
| `~/plugins/example` or `~` | The operating-system user home. |
|
||||
| `openrig-home:plugins/openrig-core` | Relative to the daemon's configured OpenRig home, including a non-default `OPENRIG_HOME`. |
|
||||
| `plugins/example` or `./plugins/example` | Relative to the agent spec directory. |
|
||||
|
||||
For a plugin seeded by OpenRig, use the home-relative form so the spec selects
|
||||
that daemon's copy:
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
plugins:
|
||||
- id: openrig-core
|
||||
source:
|
||||
kind: local
|
||||
path: "openrig-home:plugins/openrig-core"
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
plugins: [openrig-core]
|
||||
```
|
||||
|
||||
`openrig-home:` is resolved by the daemon from its configured state root. It is
|
||||
not shell interpolation and does not consult a seat's shell startup files. Its
|
||||
suffix must be relative. Existing absolute, tilde and spec-relative paths keep
|
||||
their meaning; `~user` is still a literal relative segment, not a user lookup.
|
||||
This form requires a daemon version that supports it; keep bundles using it
|
||||
pinned to such a version.
|
||||
|
||||
### Resource Categories
|
||||
|
||||
| Category | Fields | Description |
|
||||
@@ -306,6 +340,7 @@ Resources are the available pool. They are NOT automatically delivered to agents
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
|
||||
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
|
||||
| `subagents` | `id`, `path` | Subagent definitions. |
|
||||
| `plugins` | `id`, `source` | Local plugin directories; selected with `uses.plugins`. |
|
||||
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
|
||||
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
|
||||
|
||||
|
||||
@@ -250,11 +250,14 @@ OpenRig performs best-effort deterministic runtime configuration for managed ses
|
||||
installations may retain one; existing user settings are not removed.
|
||||
- Claude global state: `~/.claude.json`
|
||||
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
|
||||
Explicit permission modes use the launch-selected `HOME/.claude.json`, or
|
||||
`<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
|
||||
retains the daemon-home path.
|
||||
- Claude project-local config: `.claude/settings.local.json`
|
||||
Purpose: apply context collector/activity hooks and selected `claude_settings_fragment` resources inside the project without committing them to git.
|
||||
- Claude project-local MCP config: `.mcp.json`
|
||||
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
|
||||
- Codex global config: `~/.codex/config.toml`
|
||||
- Codex global config: `$CODEX_HOME/config.toml`, or `~/.codex/config.toml` when unset
|
||||
Purpose: pre-trust managed workspaces and apply selected `codex_config_fragment` resources. Codex currently has no equivalent project-local MCP config path for global profile settings.
|
||||
|
||||
Two important caveats:
|
||||
@@ -266,6 +269,39 @@ can affect native configuration, but OpenRig's launch flags can override those
|
||||
values. Recording a config-surface `permission_policy` is not proof that its
|
||||
rules were translated or applied. See [permission precedence and limits](getting-started.md#custom-settings-and-precedence).
|
||||
|
||||
### Separate Codex homes for separate installations
|
||||
|
||||
Set an **absolute `CODEX_HOME` in the environment that starts the daemon** when
|
||||
you want a separate Codex configuration and state root. OpenRig uses that root
|
||||
for its startup hook setup/removal and trust records, feature flag, workspace
|
||||
trust and selected config fragments. Startup can write those files even when
|
||||
the daemon has no seats.
|
||||
|
||||
The explicit selection also applies to future managed Codex fresh, resume and
|
||||
fork launches, their capability/profile/configuration probes, native thread and
|
||||
context reads, and plugin-cache discovery. Launch commands reassert it after
|
||||
pane shell startup, so an rc file cannot silently switch that launch to another
|
||||
Codex home. Executable and PATH selection stay the same. Existing running
|
||||
sessions are not moved or restarted; a thread in another home is not searched
|
||||
as a fallback for an explicitly selected home.
|
||||
|
||||
**With `CODEX_HOME` unset, a second install still shares `~/.codex`.** Setting only
|
||||
`OPENRIG_HOME` separates OpenRig state, not provider configuration. The default
|
||||
launch behavior is unchanged, including existing pane-shell overrides.
|
||||
|
||||
OpenRig does not copy or migrate authentication, history, rules, trust or
|
||||
settings into the selected home. A separate file-backed home needs its intended
|
||||
authentication provisioned if absent; OS credential stores and other config
|
||||
layers can behave differently, so a new login is not universally required.
|
||||
Explicit Codex auth commands resolve their **caller's** `CODEX_HOME`, otherwise
|
||||
`$HOME/.codex`; contacting a daemon with `OPENRIG_URL` does not retarget auth
|
||||
save/switch/registry writes to that daemon's selection. Set the intended root
|
||||
on those commands as well.
|
||||
|
||||
This separates provider files, not OS users or accounts. Project-local resources
|
||||
still use the selected project directory; shared project and system settings
|
||||
remain shared.
|
||||
|
||||
### Runtime Differences
|
||||
|
||||
**Claude Code:**
|
||||
@@ -283,6 +319,14 @@ rules were translated or applied. See [permission precedence and limits](getting
|
||||
- Approval policy and sandbox access are separate controls. OpenRig's default
|
||||
`-s workspace-write` selects the sandbox; it does not force `-a`. A member's
|
||||
`codex_config_profile` selects native `-p`, distinct from the AgentSpec `profile`.
|
||||
- On that plain `-s workspace-write` launch (fresh, resume, fork or restore), a
|
||||
short-lived `codex app-server` first reads Codex's own configuration with the
|
||||
seat's executable, home and working directory. OpenRig adds
|
||||
`-c sandbox_workspace_write.network_access=true` only when no configuration
|
||||
layer sets network access and no managed requirement could restrict it. A
|
||||
timeout, error or unrecognized answer adds nothing. Named profiles and full
|
||||
bypass are not read. The read may write Codex's own state files in
|
||||
`CODEX_HOME`, read its login and fetch managed policy, as a Codex start does.
|
||||
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
|
||||
|
||||
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
|
||||
@@ -301,6 +345,41 @@ profile → uses.skills: [openrig-user]
|
||||
```
|
||||
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
|
||||
|
||||
For seats sharing a working directory, keep each role in its own AgentSpec startup entry:
|
||||
|
||||
```yaml
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/role.md
|
||||
orientation: role
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
```
|
||||
|
||||
`rig queue whoami --json` reports only the calling seat's explicitly marked role
|
||||
bindings, independently of its current work. The `role.state` is `no-record`,
|
||||
`not-declared`, `missing`, `present`, or `unknown` when identity, storage or the
|
||||
observation is unavailable. Files retain their original `path`, `absolutePath`
|
||||
and `ownerRoot` plus the currently resolved `resolvedPath` and
|
||||
`resolvedOwnerRoot`. Built-in packaged paths follow the running installation;
|
||||
existing development-checkout paths stay where they were recorded.
|
||||
|
||||
`recordedAt` is the startup-context record write/attempt, which happens before
|
||||
launch succeeds. Exact resume can retain an older record. `present` means the
|
||||
resolved file currently exists, not that its bytes still match the spec or that
|
||||
the agent read it. Unmarked startup files and startup actions are not exposed.
|
||||
|
||||
Refocus includes the role path and a re-read reminder with topology or both trees,
|
||||
even if the trace fails. Work-only mode omits it; disabling refocus still disables
|
||||
the entire hook. Custom refocus prose keeps its existing precedence. The hook
|
||||
reuses its work lookup, or makes one bounded lookup when the work node is explicit;
|
||||
a failed or skipped lookup reports `unknown`.
|
||||
|
||||
The shipped kernel seats no longer project their role into shared guidance.
|
||||
Existing user files and legacy role blocks are left intact; this is not a cleanup
|
||||
of already-installed shared blocks. General user-authored guidance projection is
|
||||
unchanged.
|
||||
|
||||
**Separate project context from role**
|
||||
|
||||
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
|
||||
@@ -356,7 +435,7 @@ If your rig REQUIRES a hook to function and the hook installation fails silently
|
||||
When creating a new agent's startup experience:
|
||||
|
||||
- [ ] Write a `guidance/role.md` — who is this agent?
|
||||
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
|
||||
- [ ] Reference it in `startup.files` with `orientation: role` and `delivery_hint: send_text`. Avoid projecting distinct seat roles into one shared `AGENTS.md` or `CLAUDE.md`.
|
||||
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
|
||||
- [ ] Choose skills from the shared pool via profile `uses`
|
||||
- [ ] Write a `startup/context.md` if the agent needs environment grounding
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
## Browser access and allowed addresses
|
||||
|
||||
The OpenRig daemon answers its API only when a request is addressed to this machine and, if it
|
||||
comes from a web page, only when that page is the OpenRig UI or a page you allowed.
|
||||
|
||||
### What works with no setup
|
||||
|
||||
- The CLI, the TUI and agents on this machine, addressing the daemon by `localhost` or an IP
|
||||
address.
|
||||
- Other OpenRig hosts and clients that reach this daemon by its IP address, its own hostname, or
|
||||
its own Tailscale name (for example `my-machine.your-tailnet.ts.net`, or the short `my-machine`).
|
||||
The daemon learns its own Tailscale name automatically from Tailscale on this machine.
|
||||
- The web UI, when it is turned on (`rig config set ui.enabled true`, then restart the daemon) and
|
||||
opened at the daemon's own address and port. To open it at a non-loopback name or address, such
|
||||
as its Tailscale name, also list that page's exact origin in `OPENRIG_ALLOWED_ORIGINS` (for
|
||||
example `http://my-machine.your-tailnet.ts.net:7433`), then restart the daemon. Without it, the
|
||||
UI's terminal is refused there, and at a non-loopback IPv6 address its API requests are too.
|
||||
|
||||
### When you see a refusal
|
||||
|
||||
The daemon answers `403` with a sentence that says what to do. There are two kinds:
|
||||
|
||||
- **The address is not recognized** (`untrusted_host`). You reached the daemon by a name it does not
|
||||
know as its own, such as a custom DNS name, an alias in `/etc/hosts`, or a reverse proxy domain.
|
||||
Use `localhost`, an IP address or the machine's own name instead, or list the name you use:
|
||||
`OPENRIG_ALLOWED_HOSTS=rig.example.com` in the daemon's environment, then restart the daemon.
|
||||
A client that presents the daemon's configured bearer token is not refused for its address
|
||||
(it still needs whatever access each route requires).
|
||||
- **The web page is not allowed** (`browser_origin_refused`). A browser page that is not the
|
||||
OpenRig UI at the daemon's own address called the API. This includes another local app or
|
||||
development server on a different port. To allow a page, list its exact origin:
|
||||
`OPENRIG_ALLOWED_ORIGINS=http://localhost:5173` in the daemon's environment, then restart the
|
||||
daemon. A bearer token does not override this.
|
||||
|
||||
`OPENRIG_ALLOWED_HOSTS` lists names you use to reach the daemon. `OPENRIG_ALLOWED_ORIGINS` lists
|
||||
web pages allowed to call it. They are separate: a UI served through your own domain usually needs
|
||||
both. Pages on other origins cannot read the refusal; the message is for you, the CLI and agents.
|
||||
|
||||
### Limits
|
||||
|
||||
- If you rename the machine in Tailscale, the daemon keeps accepting its previous name until it
|
||||
restarts or until it next looks its name up after seeing an unrecognized one.
|
||||
- If the daemon cannot reach Tailscale's resolver, its Tailscale name is refused until a later
|
||||
lookup succeeds. `localhost` and IP addresses keep working.
|
||||
- This protects against unknown web pages and unknown names. It is not complete browser isolation:
|
||||
a page on another site can still cause a simple read request to an address the daemon accepts,
|
||||
although it cannot read the answer. Keep the daemon on loopback or your tailnet.
|
||||
@@ -1,8 +1,13 @@
|
||||
# Developing OpenRig — gates and lanes
|
||||
|
||||
This is the contributor-facing statement of which checks BLOCK a change and which are
|
||||
advisory. There is no external CI at this tip: the root `package.json` script chain IS
|
||||
the gate, and the release checklists invoke it.
|
||||
advisory. Every pull request to `main` runs `.github/workflows/tests.yml` on a clean GitHub
|
||||
runner: build and packaging, typecheck, the repository scripts (`npm run test:repo`), each
|
||||
workspace's test suite (daemon, cli, tui and ui) on macOS without ambient credentials or
|
||||
external network, and an installed-package scenario (`scripts/run-pr-scenarios.sh`). The root
|
||||
`package.json` scripts below run the same build, typecheck, repository and test checks locally,
|
||||
with one difference: `npm test` does not include the UI suite, which CI runs as
|
||||
`package-tests (ui)`. Run `npm run test:ui` for it.
|
||||
|
||||
## Blocking gates (must pass before a candidate moves)
|
||||
|
||||
@@ -10,23 +15,26 @@ the gate, and the release checklists invoke it.
|
||||
|---|---|---|
|
||||
| Typecheck | `npm run lint` | daemon + **ui** + cli + tui tsconfigs — UI typecheck STAYS blocking |
|
||||
| Build | `npm run build` | all workspaces — the UI dist ships in the package, so its build STAYS blocking |
|
||||
| Repo scripts | `npm run test:repo` | script self-tests, docs guard, skill mirror check |
|
||||
| Repo scripts | `npm run test:repo` | daemon build, script self-tests, docs guard, skill mirror check, context-pack generation check |
|
||||
| Unit tests | `npm run test:workspaces` | `packages/daemon` + `packages/cli` + `packages/tui` |
|
||||
| UI unit tests | `npm run test:ui` | `packages/ui` vitest. NOT part of `npm test`, but every pull request runs it as `package-tests (ui)` (the `package-tests` matrix in `.github/workflows/tests.yml`) |
|
||||
|
||||
`npm test` runs `test:repo` and `test:workspaces` — the blocking set is readable in the
|
||||
script itself.
|
||||
`npm test` runs `test:repo` and `test:workspaces`; `npm run test:ui` is the separate UI
|
||||
suite. Both sets are readable in the root `package.json` scripts.
|
||||
|
||||
## Advisory lane
|
||||
|
||||
| Lane | Command | Meaning |
|
||||
|---|---|---|
|
||||
| UI unit tests | `npm run test:ui` | runs `packages/ui` vitest at will; NOT part of `npm test` |
|
||||
| Portability report | `node scripts/portability-report.mjs` | lists added lines with machine-specific values; findings never fail it (`.github/workflows/portability-report.yml`) |
|
||||
|
||||
## The norm (web-UI freeze at 0.5.0)
|
||||
|
||||
Daemon API changes no longer require UI sync or UI verification; the contract mirrors
|
||||
under `packages/ui/src/hooks/` are no longer proactively maintained; a new `test:ui`
|
||||
failure signals a moved API contract, not a broken gate.
|
||||
Daemon API changes no longer require browser or interaction verification of the UI, and
|
||||
the contract mirrors under `packages/ui/src/hooks/` are no longer proactively maintained. A
|
||||
new `test:ui` failure usually signals a moved API contract. Because pull-request CI runs the
|
||||
UI suite as `package-tests (ui)`, the change that moves the contract has to update the affected
|
||||
UI test or mirror to pass that check.
|
||||
|
||||
Browser/interaction testing of the web UI is not a contributor gate. (The packaged
|
||||
starter-rig agent skills that exercise the UI are product content for user rigs, not
|
||||
|
||||
@@ -15,8 +15,13 @@ supported; Node 26 and other versions are untested.
|
||||
Codex's `-s workspace-write`, with approval policy from your native configuration,
|
||||
or Claude Code's `acceptEdits`, which still leaves commands subject to native
|
||||
rules and prompts. Codex's sandbox normally blocks network access, including the
|
||||
local OpenRig daemon. A command allowance does not change general sandbox/network
|
||||
settings. The starter's `profile: default` selects OpenRig resources, not a native
|
||||
local OpenRig daemon, so before that plain launch OpenRig asks Codex for its own
|
||||
configuration and adds network access inside the sandbox unless a configuration
|
||||
layer sets `sandbox_workspace_write.network_access` or a managed requirement could
|
||||
restrict it. To keep it off, set `network_access = false` under
|
||||
`[sandbox_workspace_write]` in your Codex configuration. If Codex does not answer
|
||||
within a few seconds, the seat starts without it. A command allowance does not
|
||||
change general sandbox/network settings. The starter's `profile: default` selects OpenRig resources, not a native
|
||||
permission profile.
|
||||
Agent-guided setup [asks once](#have-your-agent-configure-permissions): “Allow
|
||||
your agents to run OpenRig commands without repeated permission prompts?”
|
||||
|
||||
@@ -132,9 +132,13 @@ these boundaries. One occurrence and the existing owner cooldown apply to each
|
||||
episode; a disposition stops repeated requests.
|
||||
|
||||
This bounded source covers explicitly linked project or mission work: at most 2,000 touched
|
||||
qitems, 200 roots, 1,000 members / 10,000 transitions per family, 200 workflow
|
||||
qitems, 1,000 members / 10,000 transitions per family, 200 workflow
|
||||
receipts, 200 declared slices, and 100 proof files per selected slice. Overflow
|
||||
refuses visibly. Context files are bounded to 64 KiB. A lineage beginning before
|
||||
refuses visibly. When more than 200 families (roots) were touched, it evaluates the
|
||||
200 with the most transitions in the window, then by lineage ID, and reports the
|
||||
result as partial coverage with the total and omitted counts. Omitted families
|
||||
were not evaluated, so having no finding for them is not a healthy verdict.
|
||||
Context files are bounded to 64 KiB. A lineage beginning before
|
||||
the retained window remains indeterminate, with the missing interval named.
|
||||
Project planning does not require a successor mission: an explicit project identity
|
||||
resolves its current context, with mission and phase absent unless evidenced.
|
||||
@@ -370,7 +374,10 @@ A consumer such as a later Herder plugin reads `GET /api/health` and
|
||||
`GET /api/health/:findingId`, or the identical `rig health --json` and
|
||||
`rig health explain <finding-id> --json` records. The record schema is
|
||||
`openrig.health/v0alpha1`; list metadata is `openrig.health-list/v0alpha1`.
|
||||
Preserve ID, status, policy version, time window, freshness, evidence and
|
||||
For a source that bounds its input, list metadata also carries `coverage`
|
||||
(`source`, `total`, `evaluated`, `omitted`, `limit`, `order`, `partial`), and
|
||||
`rig health policy` reports the scheduled evaluation's coverage under
|
||||
`engine.lastEvaluation.coverage`. Preserve ID, status, policy version, time window, freshness, evidence and
|
||||
threshold together. There is no health score or implicit remediation authority.
|
||||
The default list excludes cleared records; explicit cleared queries and exact
|
||||
ID reads retain them while the source can still project them. A recurrence after
|
||||
|
||||
+10
-4
@@ -58,6 +58,7 @@ Read [Have your agent configure permissions](getting-started.md#have-your-agent-
|
||||
(`rig context get reference/getting-started.md#have-your-agent-configure-permissions`). Identify the
|
||||
specific prompt or sandbox restriction. Work within the user's chosen permissions; don't switch the whole environment
|
||||
to unrestricted access to clear one prompt.
|
||||
For a `403` naming `untrusted_host` or `browser_origin_refused`, see [Browser access and allowed addresses](browser-access.md).
|
||||
|
||||
### A seat shows attention, waiting or a failed restore
|
||||
|
||||
@@ -77,12 +78,17 @@ involved before proposing changes.
|
||||
Compare the harness version and what you actually observe before attributing a failure to one of these. Check the
|
||||
issue's current status; a similar symptom alone is not a diagnosis.
|
||||
|
||||
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86).
|
||||
- Claude usage limits are not detected: [#98](https://github.com/mvschwarz/openrig/issues/98),
|
||||
[#99](https://github.com/mvschwarz/openrig/issues/99).
|
||||
- Codex prompt variants not recognised as ready: [#79](https://github.com/mvschwarz/openrig/issues/79).
|
||||
- Claude seats reported as needing attention after a restore: [#86](https://github.com/mvschwarz/openrig/issues/86),
|
||||
[#273](https://github.com/mvschwarz/openrig/issues/273).
|
||||
- Claude usage limits may not be detected: [#99](https://github.com/mvschwarz/openrig/issues/99).
|
||||
- Codex prompt variants not recognised as idle: [#79](https://github.com/mvschwarz/openrig/issues/79).
|
||||
- Codex asks about hook trust at first launch: [#17](https://github.com/mvschwarz/openrig/issues/17), not reproduced
|
||||
by the team.
|
||||
- A Codex seat on the default `workspace-write` sandbox starts without network access, so it can't reach the local
|
||||
daemon, when its Codex configuration sets network access off, a managed requirement could restrict it, or Codex
|
||||
doesn't answer OpenRig's configuration read in time: [#275](https://github.com/mvschwarz/openrig/issues/275).
|
||||
- Stopping the daemon can report a timeout while closing connections:
|
||||
[#166](https://github.com/mvschwarz/openrig/issues/166).
|
||||
|
||||
For everything else, search [open issues](https://github.com/mvschwarz/openrig/issues).
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Host load and transcript capture
|
||||
|
||||
Use `rig ps --resources` to inspect the serving host, or add `--host <id>`
|
||||
for one registered HTTP/SSH host. `--json` returns the same measurements for
|
||||
operators and monitoring tools. This view is host-scoped; rig/node filters and
|
||||
multi-host fan-out do not apply. Ordinary `rig ps` output is unchanged.
|
||||
|
||||
The view reports available CPU count, host load averages (1/5/15 minutes),
|
||||
load divided by CPU count, running seats, and transcript capture attempts,
|
||||
failures, UTF8 bytes and elapsed capture time for currently rotating seats.
|
||||
Load is not CPU utilization; capture elapsed time includes waiting for tmux,
|
||||
and is not daemon CPU time. Load averages are unavailable on Windows, rather
|
||||
than reported as zero. Counters reset when a rotation is restarted/stopped or
|
||||
the daemon restarts, so they are observations rather than a durable billing log.
|
||||
When no seat is capturing, the active interval, idle ceiling and line count are
|
||||
unavailable (`null` in JSON), not zero.
|
||||
|
||||
## Reduce idle capture cost
|
||||
|
||||
Transcript rotation still captures the bounded trailing buffer. When successive
|
||||
captures are unchanged and tmux exposes an unchanged current-window activity
|
||||
hint, capture backs off exponentially to a derived ceiling. With the default
|
||||
2-second active interval and 1-second settings timer, the effective ceiling is
|
||||
3 seconds: the 10-second freshness window minus the 1-second activity-hint
|
||||
deadline, 5-second capture-result deadline and 1-second timer spacing. The
|
||||
ceiling is capped at 6 seconds but never below the configured active interval.
|
||||
This scheduling budget is not a CPU or child-process lifetime bound; the result
|
||||
deadline does not cancel an unfinished capture. One shared tmux
|
||||
activity read wakes idle rotations; a changed hint restores the active cadence.
|
||||
Missing/failed hints retain the configured full-capture cadence. A hint read
|
||||
that takes longer than one second becomes unknown without starting additional
|
||||
metadata probes while its underlying call is still pending. Periodic full
|
||||
capture reconciles output even when a hint fails to change (tmux timestamps have
|
||||
one-second resolution). Longer configured active intervals retain that interval
|
||||
instead of silently increasing capture frequency.
|
||||
|
||||
This does not change provider readiness, delivery guards, seat activity or queue
|
||||
state. Capture activity is not proof an agent is healthy or finished. As before,
|
||||
transcripts are bounded trailing snapshots, not lossless terminal archives: an
|
||||
output burst larger than retained tmux scrollback or the configured line limit
|
||||
can exceed the saved trail. Interactive terminal streaming is unchanged.
|
||||
|
||||
## Change capture settings without restarting
|
||||
|
||||
```bash
|
||||
rig config set transcripts.poll_interval_seconds 3
|
||||
rig config set transcripts.lines 2000
|
||||
rig ps --resources
|
||||
rig config reset transcripts.poll_interval_seconds
|
||||
```
|
||||
|
||||
Running rotations reread shared settings at most once per second and adopt the
|
||||
next valid policy without starting overlapping captures. Intervals accept whole
|
||||
seconds from 1 to 3600; trailing lines accept integers from 1 to 1000000. The
|
||||
existing environment variables take precedence over file settings. Malformed
|
||||
intermediate JSON retains the last usable policy, is shown in the resource view,
|
||||
and is retried; normal config
|
||||
writes reject invalid values. Changing `transcripts.enabled` or
|
||||
`transcripts.path` still requires a daemon restart. This feature does not impose
|
||||
CPU quotas or allocate build slots.
|
||||
@@ -104,3 +104,14 @@ runtime reads for compatibility. Treat the two-home state as an explicit
|
||||
limitation: use the live spec-library commands to determine where a spec is
|
||||
served from, and do not infer convergence merely because the canonical
|
||||
directory exists.
|
||||
|
||||
Config updates publish a complete replacement file atomically where directory
|
||||
permissions and the filesystem allow it. On POSIX systems, replacement preserves
|
||||
the existing owner, group, and read/write/execute permission bits. If creating or
|
||||
preparing the replacement, or renaming it, fails with `EACCES`, `EPERM`, or `EBUSY`,
|
||||
the stores write the writable target in place, preserving compatibility with
|
||||
unwritable directories and single-file bind mounts. This fallback is not atomic
|
||||
and retains the previous partial-write risk. Read-only config files are refused.
|
||||
Other errors, including `ENOSPC`, leave the original file intact on the atomic
|
||||
path. Atomic replacement does not carry extended ACLs or other inode metadata;
|
||||
a hard-linked second name continues to refer to the previous file.
|
||||
@@ -247,7 +247,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
|
||||
| `id` | string | yes | — | Pod identifier. Must not contain dots or `@`. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
|
||||
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
|
||||
| `summary` | string | no | — | Pod description. |
|
||||
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy. Controls compaction recovery, artifact management, and peer-driven restoration. |
|
||||
@@ -258,6 +258,7 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
### Pod ID Rules
|
||||
|
||||
- Must not contain dots (`.`)
|
||||
- Must not contain `@`, which separates the pod/member portion from the rig name in session addresses
|
||||
- Must be unique across all pods in the rig
|
||||
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
|
||||
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
|
||||
@@ -268,17 +269,35 @@ directory's `CLAUDE.md`, including blocks written by other rigs.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
|
||||
| `id` | string | yes | — | Member identifier. Must not contain dots or `@`. Must be unique within the pod. |
|
||||
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
|
||||
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
|
||||
| `codex_config_profile` | string | no | — | Codex-only native profile passed as `-p <name>`; letters, numbers, `_`, `.`, `-`. Separate from the AgentSpec `profile`. With the normal launch mode, this replaces OpenRig's explicit workspace-write sandbox flag. A full-bypass policy instead emits danger-full-access and omits this profile argument. |
|
||||
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `terminal`. |
|
||||
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`. `stub` is a deterministic test harness, not an agent: a Node runner in the seat's tmux pane goes through the normal launch, skill projection, startup-file and readiness path, follows `<cwd>/.openrig/stub/script.json` when present, and calls no model. See "What a stub can and cannot prove" in `docs/as-built/test-layers.md`. |
|
||||
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
|
||||
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
|
||||
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
|
||||
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
|
||||
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
|
||||
|
||||
### Pi (`runtime: pi`)
|
||||
|
||||
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
|
||||
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY` or `KIMI_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>` or `kimi-coding/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
### Oh My Pi (`runtime: omp`)
|
||||
|
||||
`runtime: omp` launches Oh My Pi through OpenRig's RPC runner. It is separate from `runtime: pi`; OMP does not use Pi's `--name` or `--approve` flags.
|
||||
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/omp/<seat>/agent` and `sessions/` instead of your default `~/.omp` profile, and runs with that seat directory as `HOME`. OpenRig does not copy OMP credentials. The runner finds the real `omp` binary before switching `HOME`, so a version-manager shim such as mise on the daemon's `PATH` still works.
|
||||
- **Credentials:** Provision each seat separately, or put the provider's key variable in `recovery.provider_auth_env_allowlist` (for example `ANTHROPIC_API_KEY` or `MISTRAL_API_KEY`). The allowlist accepts the key variable of every provider in OpenRig's OMP provider map, from `anthropic` through `litellm`. A seat receives a key only when its `model` is written as `provider/id`, such as `anthropic/claude-sonnet-4-5`. Short names such as `opus` pass no key. OMP also reads the launch directory's `.env`, so use a trusted working directory.
|
||||
- **Approval posture:** The default floor is `--approval-mode always-ask`. Because the runner is headless, OMP approval requests are cancelled and the seat stays in needing-attention after the turn ends, until the next agent run starts. A `full_bypass` permission policy selects `--approval-mode yolo`.
|
||||
- **Model errors:** A rejected prompt, a provider or authentication error during a turn, or exhausted automatic retries is printed in the pane and keeps the seat in needing-attention until the next agent run starts.
|
||||
- **Restore:** OMP creates its session file after the first persisted turn. A new seat with no persisted turn has no resume token; restoring it requires `rig up --existing <rig> --fresh <seat>`. After that file exists, OpenRig restores that exact session file. If a full rig restore leaves an OMP seat in `attention_required` or `failed`, `rig seat clear-attention` cannot yet reconcile it to `operator_recovered`, even with `--reason`, because restore reconciliation only verifies Claude Code and Codex processes ([#41](https://github.com/mvschwarz/openrig/issues/41)). Relaunch that seat with `rig up --existing <rig> --fresh <seat>`, or restore it manually.
|
||||
|
||||
### Terminal Nodes
|
||||
|
||||
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
|
||||
@@ -309,6 +328,9 @@ The canonical session name is derived from the pod ID, member ID, and rig name:
|
||||
|
||||
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
|
||||
|
||||
Pod and member IDs cannot contain `@` because the first `@` separates their portion
|
||||
of the session address from the rig name. Rig names may still contain `@`.
|
||||
|
||||
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
|
||||
|
||||
---
|
||||
@@ -369,6 +391,7 @@ Startup blocks can appear at three levels: rig, pod, and member. They are merged
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
|
||||
| `orientation` | string | no | — | Set to `role` to identify a per-seat role file for `rig queue whoami --json` and refocus. Does not change delivery or replay startup. |
|
||||
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
|
||||
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
|
||||
@@ -454,7 +477,8 @@ Each target must define exactly one of `service`, `url`, or `tcp`:
|
||||
|
||||
```yaml
|
||||
wait_for:
|
||||
# HTTP probe — hits the URL, expects 2xx
|
||||
# HTTP probe — any 2xx or 3xx response passes the HTTP wait target; redirects are not followed.
|
||||
# This proves the server answered, not application readiness.
|
||||
- url: http://127.0.0.1:8200/v1/sys/health
|
||||
|
||||
# TCP probe — connects to host:port
|
||||
@@ -536,7 +560,10 @@ continuity_policy:
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
|
||||
Schema validation checks the structural rules below. Pod and member IDs containing
|
||||
`@` are additionally rejected during preflight and member creation or launch; a
|
||||
spec can pass `rig spec validate` and then fail at preflight or import. Run
|
||||
`rig spec preflight` before creation or import.
|
||||
|
||||
1. `version` and `name` are required non-empty strings.
|
||||
2. `pods` must be a non-empty array.
|
||||
|
||||
@@ -100,9 +100,12 @@ Each slice directory carries:
|
||||
approval starts here.
|
||||
- **`## Proof contract`** — a checkbox list of promised deliverables (see
|
||||
§3). The UI's DELIVERED section pairs each item with its proof.
|
||||
- **`PROGRESS.md`** — the slice's acceptance checklist: the durable
|
||||
mission/slice-level to-do checked at acceptance. In-process steps stay in
|
||||
the working agent's own todo tool.
|
||||
- **`PROGRESS.md`** — the slice's current delivery state and retained outcome
|
||||
log. Record material changes with links to proof; historical checkboxes are
|
||||
testimony, not current acceptance authority. For a selected proof policy,
|
||||
`rig proof show <slice>` derives current readiness from the proof contract
|
||||
and attributed judgments. In-process steps stay in the working agent's
|
||||
own todo tool.
|
||||
- **`PROOF.md`** — the retained proof summary, explicitly paired to the
|
||||
`SPEC.md` proof contract.
|
||||
- **`proof/`** — the proof-artifact directory. Curated canonical evidence
|
||||
|
||||
@@ -76,6 +76,12 @@ So after installing, compare the granted scopes Slack shows for the app with all
|
||||
The app subscribes to messages in public channels it is a member of (`message.channels`) and to
|
||||
mentions of the app (`app_mention`). It does not request direct-message or private-channel access.
|
||||
|
||||
The manifest also turns on **Interactivity**, so the human can answer a decision's structured
|
||||
questions by clicking a button (`rig queue create --human-questions-file`). In Socket Mode the
|
||||
clicks arrive over the same socket, so no request URL is needed. An app created from an older
|
||||
manifest has Interactivity off: turn it on under **Interactivity & Shortcuts**, or the buttons
|
||||
will do nothing. A typed reply in the thread still answers the decision either way.
|
||||
|
||||
## What the connector does with the tokens
|
||||
|
||||
The tokens stay in the env file you created. The connector reads them from that file and uses them
|
||||
|
||||
Generated
+61
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -4025,6 +4025,18 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
|
||||
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -5694,6 +5706,49 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi/node_modules/ansi-regex": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
|
||||
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
@@ -7013,7 +7068,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7027,6 +7082,7 @@
|
||||
"hono": "^4.7.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"smol-toml": "^1.8.0",
|
||||
"string-width": "^8.3.0",
|
||||
"tar": "^7.5.13",
|
||||
"ulid": "^2.3.0",
|
||||
"yaml": "^2.8.3",
|
||||
@@ -7047,7 +7103,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7088,7 +7144,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
daemon/
|
||||
ui/
|
||||
|
||||
# Root license copied by the CLI build
|
||||
# Root license and README copied by the CLI build
|
||||
/LICENSE
|
||||
/README.md
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"agents",
|
||||
@@ -38,7 +38,7 @@
|
||||
"scripts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/ && cp ../../LICENSE LICENSE",
|
||||
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/ && cp ../../LICENSE LICENSE && cp ../../README.md README.md",
|
||||
"postinstall": "node scripts/check-abi.mjs",
|
||||
"prepublishOnly": "bash ../../scripts/build-package.sh",
|
||||
"test": "vitest run",
|
||||
@@ -55,6 +55,7 @@
|
||||
"hono": "^4.7.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"smol-toml": "^1.8.0",
|
||||
"string-width": "^8.3.0",
|
||||
"tar": "^7.5.13",
|
||||
"ulid": "^2.3.0",
|
||||
"yaml": "^2.8.3",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { constants, writeFileSync, unlinkSync, lstatSync, readlinkSync, statSync, chmodSync, chownSync, openSync, closeSync, renameSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
/** Publish complete text while retaining the previous file on a failed staged write. */
|
||||
export function writeTextAtomically(path: string, content: string, label = "file"): void {
|
||||
// Keep stream/device semantics, including symlinks to pipes such as /dev/stdout.
|
||||
// Probing a FIFO for write access would deliver an empty EOF to its reader.
|
||||
const direct = statSync(path, { throwIfNoEntry: false });
|
||||
if (direct && !direct.isFile()) {
|
||||
writeFileSync(path, content, "utf-8");
|
||||
return;
|
||||
}
|
||||
let target = path;
|
||||
// Follow file links just as writeFileSync did; rename the target,
|
||||
// never the link. A dangling final target is still created normally.
|
||||
for (let depth = 0; ; depth++) {
|
||||
const entry = lstatSync(target, { throwIfNoEntry: false });
|
||||
if (!entry?.isSymbolicLink()) break;
|
||||
if (depth >= 40) throw Object.assign(new Error(`Too many ${label} symlinks`), { code: "ELOOP" });
|
||||
target = resolve(dirname(target), readlinkSync(target));
|
||||
}
|
||||
const existing = statSync(target, { throwIfNoEntry: false });
|
||||
// Match direct writes: a writable directory must not bypass a read-only
|
||||
// target. Opening without truncation also checks native ACL permissions.
|
||||
if (existing) closeSync(openSync(target, constants.O_WRONLY));
|
||||
const temporary = join(dirname(target), `.openrig.tmp-${randomUUID()}`);
|
||||
let owned = false;
|
||||
let canFallBack = true;
|
||||
try {
|
||||
// Atomic replacement requires directory create/rename permission. Keep
|
||||
// staged bytes private until the original ownership and mode are restored.
|
||||
const fd = openSync(temporary, "wx", 0o600);
|
||||
owned = true;
|
||||
// A failed data write must never retry against the original file.
|
||||
canFallBack = false;
|
||||
try { writeFileSync(fd, content, "utf-8"); } finally { closeSync(fd); }
|
||||
canFallBack = true;
|
||||
if (existing && process.platform !== "win32") {
|
||||
const staged = statSync(temporary);
|
||||
if (staged.uid !== existing.uid || staged.gid !== existing.gid) {
|
||||
try { chownSync(temporary, existing.uid, existing.gid); }
|
||||
catch (error) {
|
||||
throw Object.assign(new Error(`Cannot preserve ${label} ownership at ${target}: ${(error as Error).message}`),
|
||||
{ code: (error as NodeJS.ErrnoException).code });
|
||||
}
|
||||
}
|
||||
}
|
||||
// chown may clear permission bits, so apply the final mode afterward.
|
||||
chmodSync(temporary, existing ? existing.mode & 0o777 : 0o666 & ~process.umask());
|
||||
renameSync(temporary, target);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (canFallBack && (code === "EACCES" || code === "EPERM" || code === "EBUSY")) {
|
||||
// Preserve setups that permit writing the file but not replacing it,
|
||||
// such as an unwritable parent or a single-file bind mount.
|
||||
writeFileSync(target, content, "utf-8");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (owned) try { unlinkSync(temporary); } catch { /* Renamed or already removed. */ }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { InvalidArgumentError } from "commander";
|
||||
import { DaemonConnectionError, DaemonResponseError, DaemonTimeoutError } from "./client.js";
|
||||
import { blockedConnectionGuidance, isBlockedConnectionCode } from "./daemon-reachability.js";
|
||||
|
||||
/**
|
||||
* Commander option parser — a positive integer (>= 1). Rejects negative, zero,
|
||||
@@ -98,7 +99,7 @@ export function renderDaemonTransportError(
|
||||
e: unknown,
|
||||
io: { out: (l: string) => void; err: (l: string) => void; json: boolean },
|
||||
): boolean {
|
||||
let parts: { fact: string; consequence: string; action: string } | undefined;
|
||||
let parts: { fact: string; consequence: string; action: string; causeCode?: string } | undefined;
|
||||
// DaemonTimeoutError is a subclass of DaemonConnectionError — check it FIRST.
|
||||
if (e instanceof DaemonResponseError) {
|
||||
parts = {
|
||||
@@ -115,10 +116,22 @@ export function renderDaemonTransportError(
|
||||
"Check the command's effect before any retry, then inspect daemon load with 'rig status' and the daemon logs if it remains slow. This is a slow response, not a stopped daemon.",
|
||||
};
|
||||
} else if (e instanceof DaemonConnectionError) {
|
||||
const unknownWrite = e.writeOutcome === "unknown";
|
||||
const reconcile = "Reconcile using the recovery ID before any retry. A lost connection does not prove the write failed.";
|
||||
const delivery = unknownWrite
|
||||
? "The command's outcome is UNKNOWN — no usable response confirmed whether the write was applied."
|
||||
: "The command was not delivered.";
|
||||
// #275: a connection this machine blocked (EPERM/EACCES) is not a stopped daemon.
|
||||
const blocked = isBlockedConnectionCode(e.causeCode) ? blockedConnectionGuidance(e.causeCode) : undefined;
|
||||
parts = {
|
||||
fact: e.message,
|
||||
consequence: "The command was not delivered.",
|
||||
action: "Confirm the daemon is reachable with 'rig daemon status'; if it is down, start it with 'rig up' or 'rig daemon start'.",
|
||||
consequence: blocked ? `${delivery} ${blocked.consequence}` : delivery,
|
||||
action: blocked
|
||||
? (unknownWrite ? `${blocked.action} ${reconcile}` : blocked.action)
|
||||
: unknownWrite
|
||||
? `Check 'rig daemon status' on the affected host; if it is down, start it with 'rig daemon start'. ${reconcile}`
|
||||
: "Confirm the daemon is reachable with 'rig daemon status'; if it is down, start it with 'rig up' or 'rig daemon start'.",
|
||||
...(e.causeCode ? { causeCode: e.causeCode } : {}),
|
||||
};
|
||||
}
|
||||
if (!parts) return false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout, FetchTimeoutError } from "./fetch-with-timeout.js";
|
||||
import { readLocalOrigin } from "./local-origin.js";
|
||||
|
||||
@@ -57,10 +57,24 @@ function resolveTerminalToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** The OS code behind a failed connection (`EPERM`, `ECONNREFUSED`, …) when Node exposes one.
|
||||
* `fetch` reports every connection failure as "fetch failed" and keeps the code on `cause`. */
|
||||
export function connectionErrorCode(err: unknown): string | undefined {
|
||||
const e = err as { code?: unknown; cause?: { code?: unknown } } | null | undefined;
|
||||
const code = e?.cause?.code ?? e?.code;
|
||||
return typeof code === "string" && code ? code : undefined;
|
||||
}
|
||||
|
||||
export class DaemonConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
/** A write caller cannot infer nondelivery from losing the response. */
|
||||
writeOutcome?: "unknown";
|
||||
/** #275: the OS cause, so a connection this machine blocked is not rendered as a stopped daemon. */
|
||||
causeCode?: string;
|
||||
|
||||
constructor(message: string, causeCode?: string) {
|
||||
super(message);
|
||||
this.name = "DaemonConnectionError";
|
||||
if (causeCode) this.causeCode = causeCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +112,17 @@ export interface DaemonResponse<T = unknown> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #425: wrap bare IPv6 literals in brackets for URL hosts. `::1` must
|
||||
* render as `[::1]` — `http://::1:7433` never parses in fetch. Hostnames,
|
||||
* IPv4, and already-bracketed literals pass through unchanged.
|
||||
*/
|
||||
export function formatDaemonHostForUrl(host: string): string {
|
||||
const trimmed = host.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
|
||||
return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
|
||||
}
|
||||
|
||||
interface DaemonRequestOptions {
|
||||
timeoutMs?: number;
|
||||
/** Per-call header overrides (e.g., `Authorization: Bearer ...`). */
|
||||
@@ -109,6 +134,22 @@ interface DaemonClientOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Routing state published by daemon start, not a health check or sibling-home discovery. */
|
||||
function localDaemonUrl(): string | undefined {
|
||||
try {
|
||||
// Resolve at construction time: embedders may change OPENRIG_HOME after import.
|
||||
const state = JSON.parse(fs.readFileSync(path.join(getOpenRigHome(), "daemon.json"), "utf-8"));
|
||||
if (!state || !Number.isSafeInteger(state.pid) || state.pid <= 0
|
||||
|| !Number.isInteger(state.port) || state.port < 1 || state.port > 65535
|
||||
|| (state.host !== undefined && (typeof state.host !== "string" || !state.host.trim()))) return undefined;
|
||||
// A stale PID must not redirect reads or writes to another configured daemon.
|
||||
// Keep the recorded endpoint; the request decides reachability, even after exit.
|
||||
return `http://${formatDaemonHostForUrl(state.host ?? "127.0.0.1")}:${state.port}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonClient {
|
||||
readonly baseUrl: string;
|
||||
private fetchImpl: typeof fetch = fetch;
|
||||
@@ -157,9 +198,15 @@ export class DaemonClient {
|
||||
if (envUrl) {
|
||||
this.baseUrl = envUrl;
|
||||
} else {
|
||||
// Resolve from config (env > file > defaults)
|
||||
const config = new ConfigStore().resolve();
|
||||
this.baseUrl = `http://${config.daemon.host}:${config.daemon.port}`;
|
||||
// --port/--host can differ from configured startup defaults. Match the
|
||||
// lifecycle-aware commands by preferring this home's recorded endpoint.
|
||||
const localUrl = localDaemonUrl();
|
||||
if (localUrl) {
|
||||
this.baseUrl = localUrl;
|
||||
} else {
|
||||
const config = new ConfigStore().resolve(); // env > file > defaults
|
||||
this.baseUrl = `http://${formatDaemonHostForUrl(config.daemon.host)}:${config.daemon.port}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +304,11 @@ export class DaemonClient {
|
||||
// Headers prove that the daemon received the request, but not that a write
|
||||
// finished. A dropped body must preserve unknown-outcome guidance.
|
||||
if (responseStatus !== undefined) throw new DaemonResponseError(responseStatus, "");
|
||||
throw new DaemonConnectionError(`Cannot connect to the OpenRig daemon at ${this.baseUrl}: ${msg}`);
|
||||
const causeCode = connectionErrorCode(err);
|
||||
throw new DaemonConnectionError(
|
||||
`Cannot connect to the OpenRig daemon at ${this.baseUrl}: ${msg}${causeCode && !msg.includes(causeCode) ? ` (${causeCode})` : ""}`,
|
||||
causeCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ Examples:
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
let entries = res.data ?? [];
|
||||
if (opts.runtime) entries = entries.filter((e) => e.runtime === opts.runtime);
|
||||
if (opts.json) {
|
||||
|
||||
@@ -68,6 +68,9 @@ interface AskResult {
|
||||
guidance?: string;
|
||||
}
|
||||
|
||||
/** Largest delay a Node timer honours (2^31-1 ms); execFile's timeout uses one. */
|
||||
const MAX_WAKE_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
interface AskCommandDeps extends StatusDeps {
|
||||
identityResolver?: typeof resolveIdentitySource;
|
||||
wakeRunner?: WakeRunner;
|
||||
@@ -126,7 +129,14 @@ Exit codes:
|
||||
// an unresolvable seat REFUSES with teaching (never a guessed wake).
|
||||
if (opts.wake) {
|
||||
const target = opts.wake;
|
||||
const timeoutMs = opts.wakeTimeout ? Math.max(1, Number(opts.wakeTimeout)) * 1000 : undefined;
|
||||
// execFile throws on a NaN, infinite or fractional timeout, and Node timers run a
|
||||
// delay above 2^31-1 ms after 1ms, so validate the milliseconds before any call.
|
||||
const timeoutMs = opts.wakeTimeout ? Math.round(Math.max(1, Number(opts.wakeTimeout)) * 1000) : undefined;
|
||||
if (timeoutMs !== undefined && !(timeoutMs <= MAX_WAKE_TIMEOUT_MS)) {
|
||||
console.error(`--wake-timeout must be a number of seconds from 1 to ${Math.floor(MAX_WAKE_TIMEOUT_MS / 1000)}; got '${opts.wakeTimeout}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let token = target;
|
||||
let runtime: "claude" | "codex" = opts.runtime === "codex" ? "codex" : "claude";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodePath from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -29,10 +29,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
printDaemonNotRunning();
|
||||
return null;
|
||||
}
|
||||
if (!daemonStatusGuard(status)) return null;
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
@@ -135,8 +132,8 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
|
||||
const resultStatus = (res.data["status"] as string) ?? "";
|
||||
if (res.status === 409) {
|
||||
process.exitCode = 1; // blocked
|
||||
} else if (res.status >= 500) {
|
||||
process.exitCode = 2; // failure
|
||||
} else if (res.status >= 400) {
|
||||
process.exitCode = 2; // failure (same convention as --plan)
|
||||
} else if (resultStatus === "partial") {
|
||||
process.exitCode = 1; // partial is not clean success
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function broadcastCommand(depsOverride?: BroadcastDeps): Command {
|
||||
cmd
|
||||
.argument("[text]", "Message text to broadcast (optional with --context)")
|
||||
.option("--rig <name>", "Broadcast to all sessions in a rig")
|
||||
.option("--pod <name>", "Broadcast to all sessions in a pod")
|
||||
.option("--pod <name>", "Broadcast to matching pod names across all rigs unless --rig is set")
|
||||
.option("--force", "Send even if targets appear mid-task")
|
||||
.option("--host <id>", "Broadcast on a remote host declared in ~/.openrig/hosts.yaml (http hosts only — CLI-direct to the remote daemon's fan-out)")
|
||||
.option("--context <ref>", "Broadcast a context pack by its path-like ref (e.g. packs/fleet-update). Resolved content is fanned out; an oversized ref is flagged 'walk-sized'.")
|
||||
|
||||
@@ -115,6 +115,13 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
console.error((res.data as { error?: string })?.error ?? `Failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -259,6 +266,16 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--timeout <seconds>", "Timeout in seconds", "120")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (rig: string, opts: { after?: string; topic?: string; sender?: string; timeout: string; json?: boolean }) => {
|
||||
// A NaN timeout would never expire and would poll with a ~1ms sleep.
|
||||
// Preserve numeric forms and legacy numeric prefixes (e.g. 30s) without truncating fractions.
|
||||
const numericTimeout = Number(opts.timeout);
|
||||
const timeoutSeconds = Number.isNaN(numericTimeout) ? Number.parseFloat(opts.timeout) : numericTimeout;
|
||||
if (opts.timeout.trim() === "" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
|
||||
console.error(`--timeout must be a non-negative number of seconds; got '${opts.timeout}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
if (!client) return;
|
||||
|
||||
@@ -271,7 +288,7 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = parseInt(opts.timeout, 10) * 1000;
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
const pollIntervalMs = 3000;
|
||||
|
||||
// Bootstrap cursor: use --after if provided, otherwise generate a ULID at start time
|
||||
@@ -299,6 +316,12 @@ export function chatroomCommand(depsOverride?: StatusDeps): Command {
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/chat/history?${params}`,
|
||||
);
|
||||
|
||||
if (res.status >= 400) {
|
||||
console.error((res.data as { error?: string })?.error ?? `Failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.data && res.data.length > 0) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
|
||||
@@ -91,7 +91,13 @@ interface SkippedEntry extends BasePlanEntry {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface IncompleteRig {
|
||||
rig: string;
|
||||
status: number | "invalid_inventory" | "request_failed";
|
||||
}
|
||||
|
||||
interface CompactPlanResult {
|
||||
incompleteRigs: IncompleteRig[];
|
||||
summary: {
|
||||
totalSeats: number;
|
||||
claudeSeats: number;
|
||||
@@ -296,7 +302,7 @@ function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): Candid
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): CompactPlanResult {
|
||||
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds(), incompleteRigs: IncompleteRig[] = []): CompactPlanResult {
|
||||
const candidates: CandidateEntry[] = [];
|
||||
const blocked: BlockedEntry[] = [];
|
||||
const skipped: SkippedEntry[] = [];
|
||||
@@ -315,6 +321,7 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): Compac
|
||||
});
|
||||
|
||||
return {
|
||||
incompleteRigs,
|
||||
summary: {
|
||||
totalSeats: nodes.length,
|
||||
claudeSeats: nodes.filter(isClaude).length,
|
||||
@@ -344,6 +351,9 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): Compac
|
||||
|
||||
function printHuman(plan: CompactPlanResult): void {
|
||||
console.log("READ-ONLY PLAN - does not compact");
|
||||
if (plan.incompleteRigs.length > 0) {
|
||||
console.error(`Warning: partial plan; incomplete rigs: ${plan.incompleteRigs.map(({ rig, status }) => `${rig} (${typeof status === "number" ? `HTTP ${status}` : status})`).join(", ")}`);
|
||||
}
|
||||
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
|
||||
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
|
||||
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
|
||||
@@ -420,7 +430,13 @@ Examples:
|
||||
|
||||
try {
|
||||
const psResult = await client.get<RigEntry[]>("/api/ps");
|
||||
const rigs = psResult.data ?? [];
|
||||
if (psResult.status !== 200) {
|
||||
throw new Error(`Rig inventory returned HTTP ${psResult.status}`);
|
||||
}
|
||||
if (!Array.isArray(psResult.data)) {
|
||||
throw new Error("Rig inventory is not an array");
|
||||
}
|
||||
const rigs = psResult.data;
|
||||
const targetRigs = opts.rig ? rigs.filter((rig) => rig.name === opts.rig) : rigs;
|
||||
|
||||
if (opts.rig && targetRigs.length === 0) {
|
||||
@@ -450,19 +466,35 @@ Examples:
|
||||
}
|
||||
|
||||
const allNodes: NodeEntry[] = [];
|
||||
const incompleteRigs: IncompleteRig[] = [];
|
||||
for (const rig of targetRigs) {
|
||||
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
|
||||
if (Array.isArray(nodesResult.data)) {
|
||||
let failureStatus: IncompleteRig["status"] = "request_failed";
|
||||
try {
|
||||
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
|
||||
failureStatus = nodesResult.status;
|
||||
if (nodesResult.status !== 200) {
|
||||
throw new Error(`Node inventory for rig "${rig.name}" returned HTTP ${nodesResult.status}`);
|
||||
}
|
||||
failureStatus = "invalid_inventory";
|
||||
if (!Array.isArray(nodesResult.data)) {
|
||||
throw new Error(`Node inventory for rig "${rig.name}" is not an array`);
|
||||
}
|
||||
allNodes.push(...nodesResult.data);
|
||||
} catch (err) {
|
||||
// An explicitly selected rig has no useful partial inventory. For
|
||||
// fleet plans, keep healthy seats while making every omission visible.
|
||||
if (opts.rig) throw err;
|
||||
incompleteRigs.push({ rig: rig.name, status: failureStatus });
|
||||
}
|
||||
}
|
||||
|
||||
const plan = buildPlan(allNodes, thresholds.value);
|
||||
const plan = buildPlan(allNodes, thresholds.value, incompleteRigs);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
} else {
|
||||
printHuman(plan);
|
||||
}
|
||||
if (incompleteRigs.length > 0) process.exitCode = 1;
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error("Fix: check daemon status with: rig daemon status");
|
||||
|
||||
@@ -3,8 +3,7 @@ import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
// The trigger is two-phase (prep → wait-for-idle → /compact); the wait-for-idle
|
||||
// half can take up to the daemon's manual-prep ceiling (~120s). Give the HTTP
|
||||
// Preparation (exact map plus idle wait) shares the existing 120s ceiling. Give the HTTP
|
||||
// call generous headroom so the client does not time out mid-sequence.
|
||||
const MANUAL_COMPACT_REQUEST_TIMEOUT_MS = 180_000;
|
||||
|
||||
@@ -24,6 +23,9 @@ export function compactCommand(depsOverride?: CompactDeps): Command {
|
||||
.description("Manually run the guided compaction sequence (prep → /compact → restore → audit) for one Claude seat")
|
||||
.argument("<session>", "Target Claude session name (e.g. dev-impl@my-rig)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--skip-map", "Skip the restore-map prerequisite once; keep target, idle and permission checks")
|
||||
.option("--cancel", "End the current preparation and disarm its future compact")
|
||||
.option("--state", "Show the current preparation, expected map and deadline without triggering")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig compact dev-impl@my-rig Manually compact one Claude seat now
|
||||
@@ -33,14 +35,26 @@ Runs the SAME guided lifecycle the auto-compaction policy runs (pre-compact
|
||||
prep → /compact with the trust-bridge → restore-from-marker → read-depth audit)
|
||||
on demand, for ONE Claude seat, without waiting for the context threshold. It is
|
||||
NOT a bare /compact and NOT the read-only 'rig compact-plan' triage. Non-Claude
|
||||
seats are rejected. The /compact is sent only AFTER the prep turn completes.`);
|
||||
seats are rejected. Managed /compact waits for the exact attempt/occupant restore
|
||||
map named by preparation, published atomically with its completion marker. This
|
||||
checks completion identity, not map quality. Manual preparation and idle wait share
|
||||
120 seconds; expiry disarms that attempt. --state shows the map/deadline/reason;
|
||||
--cancel ends preparation. --skip-map skips only the map requirement for this one
|
||||
manual request. Cancel an active preparation before retrying or using --skip-map.
|
||||
Automatic preparation waits at most 25 minutes from prep delivery, using existing
|
||||
polls. A stopped attempt does not restart automatically; rerun this command to
|
||||
start a fresh attempt. Cancellation disarms future input; it cannot undo a command
|
||||
already submitted. Raw provider /compact and normal messages are unchanged.`);
|
||||
|
||||
const getDepsF = (): CompactDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd.action(async (session: string, opts: { json?: boolean }) => {
|
||||
cmd.action(async (session: string, opts: { json?: boolean; skipMap?: boolean; cancel?: boolean; state?: boolean }) => {
|
||||
if ([opts.skipMap, opts.cancel, opts.state].filter(Boolean).length > 1) {
|
||||
console.error("Choose only one of --skip-map, --cancel or --state."); process.exitCode = 1; return;
|
||||
}
|
||||
const deps = getDepsF();
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
@@ -51,9 +65,9 @@ seats are rejected. The /compact is sent only AFTER the prep turn completes.`);
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
"/api/compaction/trigger",
|
||||
{ session },
|
||||
const res = opts.state ? await client.get<Record<string, unknown>>(`/api/compaction/state?session=${encodeURIComponent(session)}`, { headers: terminalAuthHeaders() }) : await client.post<Record<string, unknown>>(
|
||||
opts.cancel ? "/api/compaction/cancel" : "/api/compaction/trigger",
|
||||
{ session, ...(opts.skipMap ? { skipMap: true } : {}) },
|
||||
{ headers: terminalAuthHeaders(), timeoutMs: MANUAL_COMPACT_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
@@ -70,6 +84,9 @@ seats are rejected. The /compact is sent only AFTER the prep turn completes.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.state || opts.cancel) {
|
||||
console.log(JSON.stringify(res.data, null, 2)); return;
|
||||
}
|
||||
const stage = res.data["stage"] as string | undefined;
|
||||
console.log(`Manual compaction triggered for ${session}${stage ? ` (stage: ${stage})` : ""}.`);
|
||||
console.log("The restore + read-depth audit prompts follow automatically as the seat drains below threshold.");
|
||||
|
||||
@@ -10,11 +10,9 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
|
||||
return store.resolveAllWithSource();
|
||||
}
|
||||
|
||||
// SWEEP-c (shape f2576102) — keys the daemon reads ONLY at boot: a set while it runs
|
||||
// is stale-until-restart; the honest floor is the loud notice (live-reload = its own
|
||||
// arch item, not built here).
|
||||
// These settings remain boot-only; capture interval/line settings reload live.
|
||||
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "db.path"];
|
||||
const BOOT_ONLY_PREFIXES = ["transcripts."];
|
||||
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
|
||||
|
||||
function isBootOnlyKey(key: string): boolean {
|
||||
return BOOT_ONLY_KEYS.includes(key) || BOOT_ONLY_PREFIXES.some((p) => key.startsWith(p));
|
||||
@@ -77,6 +75,7 @@ Keys:
|
||||
files.allowlist name:/abs/path,name:/abs/path
|
||||
progress.scan_roots name:/abs/path,name:/abs/path
|
||||
ui.timezone TUI IANA timezone (default America/Los_Angeles; reopen TUI after changing)
|
||||
ui.enabled serve the web UI and its terminal WebSocket (default off; restart the daemon)
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
|
||||
agents.* advisor_session, operator_session
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { assertSafeInstallRef, assertTreeHasNoSymlinks, assertDestinationNamespaceContained, validateContextPackManifestForInstall } from "../lib/context-install.js";
|
||||
import { addGitContext, inspectGitContext, updateGitContext } from "../lib/context-git.js";
|
||||
@@ -377,7 +377,7 @@ Examples:
|
||||
.description("Walk the topology tree for one chain filename (instance -> rig -> optional pod -> optional seat), keyed off topology.root")
|
||||
.requiredOption("--rig <rig>", "Rig name (the rigs/<rig> altitude)")
|
||||
.option("--pod <pod>", "Pod id (the pods/<pod> altitude); omit when no pod context is selected")
|
||||
.option("--seat <seat>", "Seat id (the seats/<seat> altitude); omit for a rig-level trace")
|
||||
.option("--seat <seat>", "Seat folder name (<pod>-<member>, e.g. dev1-qa); omit for a rig-level trace")
|
||||
.requiredOption("--name <file>", "Chain filename, identical at every altitude (e.g. LEARNED.md, CULTURE.md)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { rig: string; pod?: string; seat?: string; name: string; json?: boolean }) => {
|
||||
@@ -466,6 +466,7 @@ Examples:
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const entries = res.data ?? [];
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
@@ -671,9 +672,10 @@ Examples:
|
||||
const res = await client.get<{
|
||||
profileId?: string;
|
||||
phases?: Array<{ id: string; kind: string; sources?: string[]; estimatedTokens: number }>;
|
||||
pieces?: Array<{ atomId: string; address: string; sourceKind: string; text: string; estimatedTokens: number }>;
|
||||
pieces?: Array<{ atomId: string; address: string; sourceKind: string; text: string; estimatedTokens: number; writtenAt?: string }>;
|
||||
totalEstimatedTokens?: number;
|
||||
budget?: { limitTokens: number; overageTokens: number; dropCandidates: Array<{ atomId: string; priority: string; estimatedTokens: number }> };
|
||||
warnings?: string[];
|
||||
provenanceWarnings?: string[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
@@ -695,6 +697,7 @@ Examples:
|
||||
}
|
||||
// Warnings and the budget report ride stderr so stdout is exactly the
|
||||
// composed walk an agent consumes.
|
||||
for (const w of profile.warnings ?? []) console.error(`WARNING ${w}`);
|
||||
for (const w of profile.provenanceWarnings ?? []) console.error(`PROVENANCE ${w}`);
|
||||
if (profile.budget) {
|
||||
console.error(
|
||||
@@ -708,7 +711,8 @@ Examples:
|
||||
// outside its root — self-describing payload, zero composed bytes
|
||||
// touched.
|
||||
const escaped = (p as { provenance?: { escapesRoot?: boolean } }).provenance?.escapesRoot ? " !ESCAPED-ROOT" : "";
|
||||
console.log(`=== ${p.atomId} [${p.sourceKind}${escaped}] ${p.address} (~${p.estimatedTokens} tokens)`);
|
||||
const written = p.writtenAt ? ` written ${p.writtenAt}` : "";
|
||||
console.log(`=== ${p.atomId} [${p.sourceKind}${escaped}] ${p.address} (~${p.estimatedTokens} tokens)${written}`);
|
||||
console.log(p.text);
|
||||
console.log("");
|
||||
}
|
||||
@@ -818,15 +822,8 @@ Examples:
|
||||
throw new Error(`Source directory must contain manifest.yaml: ${source}`);
|
||||
}
|
||||
validateContextPackManifestForInstall(manifestPath);
|
||||
const installName = opts.name ?? (() => {
|
||||
try {
|
||||
const raw = readFileSync(manifestPath, "utf-8");
|
||||
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
|
||||
return m?.[1]?.trim() || basename(source);
|
||||
} catch {
|
||||
return basename(source);
|
||||
}
|
||||
})();
|
||||
const manifest = parseYaml(readFileSync(manifestPath, "utf-8")) as { name: string };
|
||||
const installName = opts.name ?? manifest.name;
|
||||
assertSafeInstallRef(installName);
|
||||
assertTreeHasNoSymlinks(source);
|
||||
mkdirSync(targetRoot, { recursive: true });
|
||||
@@ -842,7 +839,14 @@ Examples:
|
||||
if (targetExists) {
|
||||
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
|
||||
}
|
||||
cpSync(source, targetDir, { recursive: true });
|
||||
const staging = mkdtempSync(join(targetRoot, ".tmp-add-"));
|
||||
try {
|
||||
cpSync(source, staging, { recursive: true });
|
||||
mkdirSync(dirname(targetDir), { recursive: true });
|
||||
renameSync(staging, targetDir);
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
// Sync the daemon library so the new pack appears immediately.
|
||||
const client = await getClient();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { copyFileSync, lstatSync, mkdtempSync, readFileSync, rmSync } from "node
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
|
||||
// `rig crash-cart --json` — the daemon-DOWN recovery verdict emit (plan c015d9ed §C3, coupling ruling
|
||||
// option A). Prints ONE JSON = the 3-state detector verdict + (on DOWN) the discovery — READ-ONLY, a
|
||||
@@ -87,7 +88,7 @@ async function realEmit(): Promise<CrashCartEmit> {
|
||||
: "no daemon.json";
|
||||
const url = openrigUrl
|
||||
? `${openrigUrl.replace(/\/$/, "")}/healthz`
|
||||
: `http://${state?.host ?? "127.0.0.1"}:${state?.port ?? 7433}/healthz`;
|
||||
: `http://${formatDaemonHostForUrl(state?.host ?? "127.0.0.1")}:${state?.port ?? 7433}/healthz`;
|
||||
const probeResult = await probeClassified(url);
|
||||
return { pidState, probeResult, failedSignal: `healthz ${probeResult} at ${url}` };
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||
import fs from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { acquireDaemonStartLock } from "../daemon-start-lock.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { fetchWithTimeout } from "../fetch-with-timeout.js";
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
readLogs,
|
||||
tailLogs,
|
||||
type LifecycleDeps,
|
||||
type ProcessLiveness,
|
||||
OPENRIG_DIR,
|
||||
STATE_FILE,
|
||||
resolveBindIntent,
|
||||
@@ -32,6 +34,35 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
|
||||
};
|
||||
}
|
||||
|
||||
export type SignalOutcome = "sent" | "missing" | "not-permitted";
|
||||
|
||||
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
|
||||
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
|
||||
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
|
||||
try {
|
||||
kill(pid, 0);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
|
||||
}
|
||||
}
|
||||
|
||||
/** #275 — three-state liveness for status reads. Only a missing process (ESRCH) or a zombie is
|
||||
* dead. A process that exists but cannot be inspected (EPERM, or `ps` cannot run, as inside
|
||||
* Codex's macOS sandbox) is UNKNOWN, never dead: calling it dead turned a running daemon into
|
||||
* `stale` before any health probe. The boolean createIsProcessAlive (start/stop) is unchanged. */
|
||||
export function createProcessLiveness(deps: {
|
||||
signal: (pid: number) => SignalOutcome;
|
||||
readProcessState: (pid: number) => string | null;
|
||||
}): (pid: number) => ProcessLiveness {
|
||||
return (pid: number) => {
|
||||
if (deps.signal(pid) === "missing") return "dead";
|
||||
const state = deps.readProcessState(pid)?.trim();
|
||||
if (!state) return "unknown";
|
||||
return state.startsWith("Z") ? "dead" : "alive";
|
||||
};
|
||||
}
|
||||
|
||||
type ExecFile = (file: string, args: string[], options: { encoding: "utf-8" }) => string;
|
||||
|
||||
// Windows has no ps(1) and no zombie state, so the signal probe alone decides liveness there.
|
||||
@@ -50,18 +81,16 @@ export function readProcessState(
|
||||
|
||||
export function realDeps(): LifecycleDeps {
|
||||
const isProcessAlive = createIsProcessAlive({
|
||||
signalCheck: (pid) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
signalCheck: (pid) => signalProbe(pid) === "sent",
|
||||
readProcessState: (pid) => readProcessState(pid),
|
||||
});
|
||||
const processLiveness = createProcessLiveness({
|
||||
signal: (pid) => signalProbe(pid),
|
||||
readProcessState: (pid) => readProcessState(pid),
|
||||
});
|
||||
|
||||
return {
|
||||
processLiveness,
|
||||
acquireStartLock: () => acquireDaemonStartLock(OPENRIG_DIR),
|
||||
spawn: (cmd, args, opts) => spawn(cmd, args, opts as Parameters<typeof spawn>[2]),
|
||||
fetch: async (url) => {
|
||||
@@ -189,13 +218,8 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
contextRoot: config.context.root,
|
||||
skillsRoot: config.skills.root,
|
||||
topologyRoot: config.topology.root,
|
||||
// V1 pre-release CLI/daemon Item 1 — project the
|
||||
// ConfigStore-resolved rotation tunables into the daemon
|
||||
// process env so file-stored values
|
||||
// (`rig config set transcripts.lines 500`) actually
|
||||
// reach the rotation hook.
|
||||
transcriptsLines: config.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
|
||||
// Live transcript tunables are read from the shared config file.
|
||||
// Only operator-provided environment overrides should mask edits.
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — propagated via
|
||||
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
|
||||
// check in startup.ts honors the flag.
|
||||
@@ -215,7 +239,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
|
||||
? parseInt(opts.waitForKernelMs, 10)
|
||||
: 60_000;
|
||||
const baseUrl = `http://${state.host}:${state.port}`;
|
||||
const baseUrl = `http://${formatDaemonHostForUrl(state.host ?? "127.0.0.1")}:${state.port}`;
|
||||
const result = await waitForKernelReady(baseUrl, timeoutMs);
|
||||
if (result.ok) {
|
||||
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { getDefaultOpenRigPath, getPreferredOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
|
||||
import {
|
||||
@@ -73,7 +74,7 @@ function realDestroyDeps(): DestroyDeps {
|
||||
stopDaemon: async () => { await stopDaemon(lifecycleDeps); },
|
||||
inspectListener: async (host: string, port: number): Promise<ListenerInspection> => {
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/healthz`);
|
||||
const res = await fetch(`http://${formatDaemonHostForUrl(host)}:${port}/healthz`);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: "other_http",
|
||||
@@ -131,7 +132,7 @@ function printPlan(
|
||||
console.log("DESTROY PLAN");
|
||||
console.log(` scope: ${scope}`);
|
||||
console.log(` state root: ${runtimeConfig.stateRoot}`);
|
||||
console.log(` daemon: http://${runtimeConfig.daemonHost}:${runtimeConfig.daemonPort}`);
|
||||
console.log(` daemon: http://${formatDaemonHostForUrl(runtimeConfig.daemonHost)}:${runtimeConfig.daemonPort}`);
|
||||
console.log(` backup: ${backup ? "enabled" : "disabled"}`);
|
||||
console.log(` tmux cleanup: ${scope === "all" ? `enabled (${managedTmuxSessions.length} managed session${managedTmuxSessions.length === 1 ? "" : "s"})` : "disabled"}`);
|
||||
for (const warning of warnings) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -10,10 +10,7 @@ export function discoverCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
printDaemonNotRunning();
|
||||
return null;
|
||||
}
|
||||
if (!daemonStatusGuard(status)) return null;
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { execSync } from "node:child_process";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { buildWritableHomeCheck } from "../system-preflight.js";
|
||||
import {
|
||||
@@ -212,7 +213,7 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
// configured host/port must not be reported missing). Defaults stay 127.0.0.1:7433.
|
||||
const daemonHost = config.daemon.host ?? "127.0.0.1";
|
||||
const daemonPort = config.daemon.port ?? DEFAULT_PORT;
|
||||
const daemonBase = `http://${daemonHost}:${daemonPort}`;
|
||||
const daemonBase = `http://${formatDaemonHostForUrl(daemonHost)}:${daemonPort}`;
|
||||
const fetchFn = deps.fetch ?? globalThis.fetch;
|
||||
const portCheck = deps.checkPort(daemonPort, daemonHost).then(async (available): Promise<DoctorCheck> => {
|
||||
if (available) {
|
||||
@@ -406,7 +407,7 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
fetchLiveLogicalIds: async (rigName: string): Promise<string[] | null> => {
|
||||
try {
|
||||
const cfg = new ConfigStore().resolve();
|
||||
const base = `http://${cfg.daemon.host ?? "127.0.0.1"}:${cfg.daemon.port ?? DEFAULT_PORT}`;
|
||||
const base = `http://${formatDaemonHostForUrl(cfg.daemon.host ?? "127.0.0.1")}:${cfg.daemon.port ?? DEFAULT_PORT}`;
|
||||
const rigsRes = await fetch(`${base}/api/rigs`);
|
||||
if (!rigsRes.ok) return null;
|
||||
const rigs = (await rigsRes.json()) as Array<{ id?: string; name?: string }>;
|
||||
|
||||
@@ -47,6 +47,13 @@ Examples:
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
|
||||
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
|
||||
else console.error(res.data?.["error"] ?? `Failed to get service status (HTTP ${res.status})`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import fs from "node:fs";
|
||||
import { writeTextAtomically } from "../atomic-text-write.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
@@ -14,7 +14,7 @@ export function exportCommand(depsOverride?: ExportDeps): Command {
|
||||
const getDeps = (): ExportDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
writeFile: (p, content) => fs.writeFileSync(p, content, "utf-8"),
|
||||
writeFile: (p, content) => writeTextAtomically(p, content, "export"),
|
||||
};
|
||||
|
||||
cmd
|
||||
|
||||
@@ -97,7 +97,13 @@ export async function daemonQueueRows(address: string): ReturnType<HumanRowsLook
|
||||
const res = await client.get<Array<{ qitemId?: string; state?: string; summary?: string | null }>>(
|
||||
`/api/queue/list?destinationSession=${encodeURIComponent(address)}&state=pending,in-progress,blocked&limit=${limit}&compact=1`,
|
||||
);
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`HTTP ${res.status}: queue enumeration refused`);
|
||||
}
|
||||
if (!Array.isArray(res.data)) {
|
||||
throw new Error("HTTP 200: malformed queue enumeration response");
|
||||
}
|
||||
const data = res.data;
|
||||
if (data.length < limit) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -128,7 +134,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.requiredOption("--display-name <name>", "Human-readable display name")
|
||||
.requiredOption(
|
||||
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
|
||||
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
|
||||
"Connector binding (repeatable); role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
|
||||
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
|
||||
)
|
||||
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
|
||||
@@ -191,7 +197,21 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
});
|
||||
console.log(JSON.stringify({ ok: true, entityId, path: result.value.path, receipt: result.receipt }));
|
||||
} catch (error) { console.error(`refused: ${(error as Error).message}`); process.exitCode = 1; }
|
||||
});
|
||||
})
|
||||
.addHelpText("after", `
|
||||
Binding specification details:
|
||||
Format: <kind:connectorRef:secretsRef:role[:handle=<id>]>
|
||||
- kind: Connector kind (e.g. "slack")
|
||||
- connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal" or "main")
|
||||
- secretsRef: Pointer or label identifying where secrets are resolved (e.g. "file:/path/to/slack.env" or "vault://slack/user").
|
||||
NOTE: Never pass credentials or tokens inline here; credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file').
|
||||
- role: "primary" (exactly one binding must be primary) or "secondary"
|
||||
- handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing
|
||||
|
||||
Example:
|
||||
rig gateway human add alice --display-name "Alice" \\
|
||||
--binding "slack:openrig-personal:file:/etc/openrig/slack.env:primary:handle=U12345678" \\
|
||||
--delivery-class B`);
|
||||
|
||||
// ── S12 (OPR.0.5.5.12): the fragment lifecycle beyond add. Every verb operates through
|
||||
// fragments + regeneration (the registry surface); none writes the generated projection.
|
||||
|
||||
@@ -252,6 +252,14 @@ function scopeLabel(scope: HealthScope): string {
|
||||
function renderList(projection: HealthListProjection): void {
|
||||
const evaluated = projection.evaluatedAt ?? "unavailable";
|
||||
console.log(`Fleet health — evaluated=${evaluated} findings=${projection.total} limit=${projection.limit}`);
|
||||
for (const c of projection.coverage ?? []) {
|
||||
if (!c.partial) continue;
|
||||
if (c.status === "unavailable") {
|
||||
console.log(`UNAVAILABLE: ${c.source}: ${c.reason}. This source was not assessed; the result is partial.`);
|
||||
continue;
|
||||
}
|
||||
console.log(`PARTIAL: ${c.source} evaluated ${c.evaluated} of ${c.total} ${c.unit} (limit ${c.limit}; ${c.omitted} omitted; order: ${c.order}). Omitted ${c.unit} were not evaluated and are not healthy.`);
|
||||
}
|
||||
if (projection.records.length === 0) {
|
||||
console.log("No health findings match this bounded query. This is not a healthy assertion.");
|
||||
console.log("Next inspection: widen only as needed with `rig health --instance --json`.");
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function probeHostStatus(
|
||||
port = 22;
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
target = u.hostname;
|
||||
target = u.hostname.replace(/^\[|\]$/g, "");
|
||||
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
}
|
||||
const r = await tcpProbe(target, port, timeoutMs);
|
||||
@@ -160,10 +160,11 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
rows.push({ step: "remote-rig-binary", status: "pass", detail: `remote rig ${version.stdout.trim() || "(version unreadable)"}` });
|
||||
|
||||
const daemon = await deps.run(host, ["rig", "daemon", "status"]);
|
||||
const daemonUp = daemon.ok && /running/i.test(daemon.stdout);
|
||||
const daemonUp = daemon.ok && /^Daemon running\b/im.test(daemon.stdout)
|
||||
&& !/\bUNHEALTHY\b/i.test(daemon.stdout);
|
||||
if (daemonUp) {
|
||||
rows.push({ step: "remote-daemon-health", status: "pass", detail: "remote daemon reports running" });
|
||||
} else if (!daemon.ok) {
|
||||
} else if (!daemon.ok || /\bUNVERIFIED\b/i.test(daemon.stdout)) {
|
||||
rows.push({
|
||||
step: "remote-daemon-health",
|
||||
status: "unknown",
|
||||
@@ -171,6 +172,14 @@ export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<Che
|
||||
fix: "on the host: verify with `rig daemon status` and an actual daemon-backed operation such as `rig ps --json`",
|
||||
});
|
||||
return rows;
|
||||
} else if (/\bUNHEALTHY\b/i.test(daemon.stdout)) {
|
||||
rows.push({
|
||||
step: "remote-daemon-health",
|
||||
status: "fail",
|
||||
detail: "remote daemon reports a present process but unhealthy control plane",
|
||||
fix: "on the host: inspect `rig daemon status` and /healthz; follow the reported recovery guidance",
|
||||
});
|
||||
return rows;
|
||||
} else {
|
||||
rows.push({
|
||||
step: "remote-daemon-health",
|
||||
|
||||
@@ -122,6 +122,7 @@ interface AgentReferenceWire {
|
||||
agentName: string;
|
||||
sourcePath: string;
|
||||
profiles: string[];
|
||||
kind?: "consumer" | "definition";
|
||||
}
|
||||
|
||||
export function pluginCommand(depsOverride?: StatusDeps): Command {
|
||||
@@ -167,6 +168,7 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
|
||||
if (opts.source) params.push(`source=${encodeURIComponent(opts.source)}`);
|
||||
const path = `/api/plugins${params.length > 0 ? `?${params.join("&")}` : ""}`;
|
||||
const res = await client.get<PluginEntryWire[]>(path);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const entries = res.data ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
@@ -288,12 +290,13 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
|
||||
// -- rig plugin used-by <id> --
|
||||
cmd.command("used-by")
|
||||
.argument("<id>", "Plugin id (e.g., openrig-core)")
|
||||
.description("List agents referencing this plugin in their profile.uses.plugins[]")
|
||||
.description("List profile consumers and resource definitions for this plugin")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (id: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<AgentReferenceWire[]>(`/api/plugins/${encodeURIComponent(id)}/used-by`);
|
||||
if (res.status === 404) throw new Error(`Plugin "${id}" not found`);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
}
|
||||
@@ -309,10 +312,15 @@ export function pluginCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of refs) {
|
||||
for (const r of refs.filter(ref => ref.kind !== "definition")) {
|
||||
const profilesStr = r.profiles.length > 0 ? r.profiles.join(",") : "(none)";
|
||||
console.log(`${r.agentName.padEnd(36)} [${profilesStr.padEnd(20)}] ${r.sourcePath}`);
|
||||
}
|
||||
const definitions = refs.filter(ref => ref.kind === "definition");
|
||||
if (definitions.length > 0) {
|
||||
console.log("Plugin definitions:");
|
||||
for (const r of definitions) console.log(`${r.agentName.padEnd(36)} ${r.sourcePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Command } from "commander";
|
||||
import YAML from "yaml";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -458,10 +459,12 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
}
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
const frontmatter = YAML.stringify(header).trimEnd();
|
||||
const content = `---\n${frontmatter}\n---\n\n${body}`;
|
||||
try {
|
||||
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, { encoding: "utf8", flag: opts.replace ? "w" : "wx" });
|
||||
if (opts.replace) replaceArtifactFile(target, content);
|
||||
else fs.writeFileSync(target, content, { encoding: "utf8", flag: "wx" });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
if (opts.replace || (err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
||||
throw new ScopeCliError({
|
||||
fact: `Proof artifact '${fileName}' already exists.`,
|
||||
consequence: "The existing artifact was not changed.",
|
||||
@@ -506,3 +509,38 @@ checkboxes do not accept an item under the selected proof policy.
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/** Explicit --replace swaps the artifact's directory entry; it never writes through it. A symlink or hard
|
||||
* link at the artifact name therefore keeps its other path's bytes. The staging file is created
|
||||
* exclusively under a unique name, so cleanup only ever removes a file this call created, and a failed
|
||||
* write, close or rename leaves the target as it was. An existing regular artifact's permission bits carry
|
||||
* over, so a replacement never broadens access; a symlinked or new name gets default permissions. The
|
||||
* first failure is the one reported. There is no fsync: this is a same-directory swap, not a
|
||||
* crash-durability guarantee. */
|
||||
export function replaceArtifactFile(target: string, content: string, stagingId: string = randomUUID()): void {
|
||||
const staging = path.join(path.dirname(target), `.${path.basename(target)}.${stagingId}.replace-tmp`);
|
||||
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
|
||||
const keepMode = existing?.isFile() ? existing.mode & 0o777 : undefined;
|
||||
const fd = fs.openSync(staging, "wx", keepMode ?? 0o666); // never created wider than the artifact it replaces
|
||||
let failure: unknown;
|
||||
try {
|
||||
if (keepMode !== undefined) fs.fchmodSync(fd, keepMode);
|
||||
fs.writeFileSync(fd, content, "utf8");
|
||||
} catch (err) {
|
||||
failure = err;
|
||||
}
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch (closeErr) {
|
||||
if (failure === undefined) failure = closeErr;
|
||||
else console.error(`warning: closing staging file ${staging} also failed: ${(closeErr as Error).message}`);
|
||||
}
|
||||
try {
|
||||
if (failure !== undefined) throw failure;
|
||||
fs.renameSync(staging, target);
|
||||
} catch (err) {
|
||||
try { fs.rmSync(staging, { force: true }); }
|
||||
catch (cleanupErr) { console.error(`warning: could not remove staging file ${staging}: ${(cleanupErr as Error).message}`); }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
+107
-43
@@ -1,4 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import stringWidth from "string-width";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { sessionRigOf } from "../session-name.js";
|
||||
import { DaemonClient, remoteDaemonClient } from "../client.js";
|
||||
@@ -218,6 +219,7 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
|
||||
interface PsCliOptions {
|
||||
json?: boolean;
|
||||
resources?: boolean;
|
||||
nodes?: boolean;
|
||||
full?: boolean;
|
||||
verbose?: boolean;
|
||||
@@ -823,8 +825,9 @@ Exit codes:
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--resources", "Show this host’s load and transcript capture cost (also supports --host)")
|
||||
.option("--nodes", "Show per-node detail (current rig; -A for all rigs)")
|
||||
.option("--full", "Show all node-list fields per node (uncompacted rows; node-list recoveryGuidance/currentUsage live on the node detail, not the list)")
|
||||
.option("--full", "Show all rig rows without cell truncation, or all node-list fields with --nodes (recoveryGuidance/currentUsage live on node detail)")
|
||||
.option("--verbose", "Alias for --full")
|
||||
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
|
||||
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
|
||||
@@ -856,6 +859,11 @@ Exit codes:
|
||||
// every remote path).
|
||||
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const callerRig = sessionName ? extractRigName(sessionName) : undefined;
|
||||
if (opts.resources && (opts.nodes || opts.allRigs || opts.rig || opts.session || opts.allHosts || opts.hosts || opts.limit || opts.fields || opts.filter || opts.summary || opts.active || opts.includeArchived || opts.full || opts.verbose)) {
|
||||
console.error("rig ps --resources supports --json and one --host; rig/node filters and fan-out do not apply to host measurements.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ladderError = validatePsLadder(opts, callerRig);
|
||||
if (ladderError) {
|
||||
console.error(ladderError);
|
||||
@@ -913,6 +921,10 @@ Exit codes:
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
if (opts.resources) {
|
||||
await handleResources(client, opts.json);
|
||||
return;
|
||||
}
|
||||
if (opts.nodes) {
|
||||
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope, undefined, scopedToSessionRig);
|
||||
return;
|
||||
@@ -924,6 +936,8 @@ Exit codes:
|
||||
const res = await client.get<PsEntry[]>("/api/ps?includeArchived=true");
|
||||
|
||||
if (res.status >= 400) {
|
||||
const reason = (res.data as unknown as { error?: unknown } | null)?.error;
|
||||
if (typeof reason === "string" && reason) console.error(reason);
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${res.status}). Check daemon status with: rig status`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
@@ -1014,35 +1028,39 @@ Exit codes:
|
||||
const humanList = (opts.full || limit !== null) ? tableRows : tableRows.slice(0, HUMAN_RIG_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && tableRows.length > HUMAN_RIG_BUDGET;
|
||||
|
||||
const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "ATTN", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
|
||||
console.log(header);
|
||||
let anyArchivedShown = false;
|
||||
for (const e of humanList as PsEntry[]) {
|
||||
// OPR.0.3.3.19 - archived rigs only appear under --include-archived;
|
||||
// mark them with a trailing "*" (legend footer below) so the operator
|
||||
// can tell archived from active at a glance.
|
||||
if (e.isArchived) anyArchivedShown = true;
|
||||
console.log(padRigRow(
|
||||
e.isArchived ? `${e.rigName ?? e.name} *` : (e.rigName ?? e.name),
|
||||
String(e.nodeCount),
|
||||
String(e.runningCount),
|
||||
// Slice 15 — "—" when daemon predates the field; honest absence.
|
||||
e.activeCount !== undefined ? String(e.activeCount) : "—",
|
||||
e.hasWorkCount !== undefined ? String(e.hasWorkCount) : "—",
|
||||
// OPR.0.4.4.21 — the founder's field-of-view anchor: where is
|
||||
// something that might concern me. "—" = daemon predates the field.
|
||||
e.attentionCount !== undefined ? (e.attentionCount > 0 ? `▲${e.attentionCount}` : "0") : "—",
|
||||
e.status,
|
||||
abbrevRigLifecycle(e.lifecycleState),
|
||||
e.uptime ?? "—",
|
||||
e.latestSnapshot ?? "—",
|
||||
));
|
||||
const headers = ["RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "ATTN", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT"];
|
||||
const rows = (humanList as PsEntry[]).map((e) => [
|
||||
e.isArchived ? `${e.rigName ?? e.name} *` : (e.rigName ?? e.name),
|
||||
String(e.nodeCount),
|
||||
String(e.runningCount),
|
||||
e.activeCount !== undefined ? String(e.activeCount) : "—",
|
||||
e.hasWorkCount !== undefined ? String(e.hasWorkCount) : "—",
|
||||
e.attentionCount !== undefined ? (e.attentionCount > 0 ? `▲${e.attentionCount}` : "0") : "—",
|
||||
e.status,
|
||||
abbrevRigLifecycle(e.lifecycleState),
|
||||
e.uptime ?? "—",
|
||||
e.latestSnapshot ?? "—",
|
||||
]);
|
||||
const widths = [24, 7, 9, 8, 6, 6, 10, 11, 11];
|
||||
if (opts.full) {
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < widths.length; i++) {
|
||||
widths[i] = Math.max(widths[i]!, stringWidth(row[i]!));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < widths.length; i++) widths[i] = widths[i]! - RIG_COLUMN_SEPARATOR.length;
|
||||
}
|
||||
console.log(padRigRow(headers, widths));
|
||||
for (const row of rows) console.log(padRigRow(row, widths));
|
||||
const anyArchivedShown = humanList.some((e) => e.isArchived);
|
||||
const hiddenHistory = stoppedCount > 0 || (!opts.includeArchived && archivedCount > 0);
|
||||
if (anyArchivedShown || hiddenHistory || bareDefault || humanTruncated || truncated) console.log("");
|
||||
if (anyArchivedShown) {
|
||||
console.log("* = archived (hidden from the default view; shown via --include-archived). Reverse with: rig unarchive <rig>");
|
||||
}
|
||||
// FR-1 display element 2: history as ONE count line, never rows.
|
||||
if (stoppedCount > 0 || (!opts.includeArchived && archivedCount > 0)) {
|
||||
if (hiddenHistory) {
|
||||
const parts: string[] = [];
|
||||
if (stoppedCount > 0) parts.push(`${stoppedCount} stopped (rig ps --filter status=stopped)`);
|
||||
if (!opts.includeArchived && archivedCount > 0) parts.push(`${archivedCount} archived (rig ps --include-archived)`);
|
||||
@@ -1077,6 +1095,8 @@ async function handleNodes(
|
||||
): Promise<void> {
|
||||
const rigRes = await client.get<PsEntry[]>(psApiPath(opts), requestHeaders ? { headers: requestHeaders } : undefined);
|
||||
if (rigRes.status >= 400) {
|
||||
const reason = (rigRes.data as unknown as { error?: unknown } | null)?.error;
|
||||
if (typeof reason === "string" && reason) console.error(reason);
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
@@ -1259,24 +1279,27 @@ function fitCell(value: string, width: number): string {
|
||||
return truncate(value, width).padEnd(width);
|
||||
}
|
||||
|
||||
function padRigRow(rig: string, nodes: string, running: string, active: string, work: string, attn: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
|
||||
return [
|
||||
fitCell(rig, 24),
|
||||
fitCell(nodes, 7),
|
||||
fitCell(running, 9),
|
||||
// Slice 15 — distinct columns for the three orthogonal primitives.
|
||||
// RUNNING = process-alive (legacy); ACTIVE = terminal-active (tmux);
|
||||
// WORK = has-assigned-work (queue). UI/CLI render them separately so
|
||||
// operators see which dimension differs at a glance.
|
||||
fitCell(active, 8),
|
||||
fitCell(work, 6),
|
||||
// OPR.0.4.4.21 — ATTN: seats needing attention (the field-of-view anchor).
|
||||
fitCell(attn, 6),
|
||||
fitCell(status, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
fitCell(uptime, 11),
|
||||
snapshot,
|
||||
].join("");
|
||||
const RIG_COLUMN_SEPARATOR = " ";
|
||||
const rigCellSegmenter = new Intl.Segmenter();
|
||||
|
||||
function fitRigCell(value: string, width: number): string {
|
||||
if (stringWidth(value) > width) {
|
||||
const limit = width - 1;
|
||||
let prefix = "";
|
||||
let used = 0;
|
||||
for (const { segment } of rigCellSegmenter.segment(value)) {
|
||||
const segmentWidth = stringWidth(segment);
|
||||
if (used + segmentWidth > limit) break;
|
||||
prefix += segment;
|
||||
used += segmentWidth;
|
||||
}
|
||||
value = prefix + "…";
|
||||
}
|
||||
return value + " ".repeat(width - stringWidth(value));
|
||||
}
|
||||
|
||||
function padRigRow(cells: string[], widths: number[]): string {
|
||||
return cells.map((cell, i) => i < widths.length ? fitRigCell(cell, widths[i]!) : cell).join(RIG_COLUMN_SEPARATOR);
|
||||
}
|
||||
|
||||
export function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, model: string, status: string, startup: string, oriented: string, lifecycle: string, terminal: string, work: string, activity: string, ctx: string, restore: string, error: string): string {
|
||||
@@ -1374,6 +1397,7 @@ async function runCrossHostPs(
|
||||
|
||||
// SSH path — reconstruct argv
|
||||
const argv: string[] = ["rig", "ps"];
|
||||
if (opts.resources) argv.push("--resources");
|
||||
if (opts.nodes) argv.push("--nodes");
|
||||
if (opts.full) argv.push("--full");
|
||||
// OPR.0.4.0.34: forward the breadth flag so `--host h -A` keeps all-rigs
|
||||
@@ -1446,6 +1470,10 @@ async function runHttpPs(
|
||||
const headers = buildRemoteHeaders(bearerResult.token);
|
||||
|
||||
try {
|
||||
if (opts.resources) {
|
||||
await handleResources(client, opts.json, headers, host.id);
|
||||
return;
|
||||
}
|
||||
if (opts.nodes) {
|
||||
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope, headers);
|
||||
return;
|
||||
@@ -1667,3 +1695,39 @@ async function runFanOutPs(
|
||||
|
||||
if (hasFailure) process.exitCode = 3;
|
||||
}
|
||||
|
||||
|
||||
interface HostResources {
|
||||
sampledAt: string; cpuCount: number; runningSeats: number;
|
||||
loadAverage: number[] | null; loadPerCpu: number[] | null;
|
||||
capture: {
|
||||
rotatingSeats: number; idleSeats: number; captures: number; failures: number;
|
||||
capturedBytes: number; captureDurationMs: number;
|
||||
activeIntervalMs: number | null; lines: number | null; maxIdleIntervalMs: number | null;
|
||||
settingsReloadError?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
async function handleResources(client: DaemonClient, json?: boolean, headers?: Record<string, string>, hostId?: string): Promise<void> {
|
||||
const response = await client.get<HostResources>("/api/ps/resources", headers ? { headers } : undefined);
|
||||
if (response.status >= 400) {
|
||||
if (hostId && response.status !== 404) {
|
||||
emitCrossHostError(hostId, classifyHttpFailedStep(response.status), `HTTP ${response.status}`, json);
|
||||
return;
|
||||
}
|
||||
console.error(`Host resource measurements unavailable (HTTP ${response.status}); this host may need a newer daemon.`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const data = response.data;
|
||||
if (json) { console.log(JSON.stringify(data)); return; }
|
||||
console.log(`Host: ${hostId ? `${hostId} · ` : ""}${data.cpuCount} available CPUs · ${data.runningSeats} running seats`);
|
||||
console.log(data.loadAverage && data.loadPerCpu
|
||||
? `Load (1/5/15m): ${data.loadAverage.map((n) => n.toFixed(2)).join(" / ")} · per CPU: ${data.loadPerCpu.map((n) => n.toFixed(2)).join(" / ")} (not CPU utilization)`
|
||||
: "Load average: unavailable on this platform");
|
||||
const capture = data.capture;
|
||||
console.log(`Transcript capture: ${capture.rotatingSeats} rotating · ${capture.idleSeats} backed off · ${capture.captures} attempts · ${capture.failures} failures`);
|
||||
console.log(`Capture cost for currently rotating seats: ${capture.capturedBytes} bytes · ${capture.captureDurationMs.toFixed(1)} ms elapsed across captures (not daemon CPU time)`);
|
||||
console.log(`Effective capture: ${capture.activeIntervalMs ?? "unavailable"} ms active · ${capture.maxIdleIntervalMs ?? "unavailable"} ms idle ceiling · ${capture.lines ?? "unavailable"} trailing lines`);
|
||||
if (capture.settingsReloadError) console.log(capture.settingsReloadError);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, DaemonConnectionError, DaemonTimeoutError, DaemonResponseError } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-lifecycle.js";
|
||||
@@ -39,6 +40,8 @@ export interface VerifiedDeliveryResult {
|
||||
connectorAccepted: boolean | null;
|
||||
/** A connector receipt can never prove that a person read the message. */
|
||||
humanReadership: "unknown";
|
||||
/** #96: present for a --reply-to update once posted; false = it posted top-level instead. */
|
||||
threaded?: boolean;
|
||||
detail?: string;
|
||||
nextAction: string | null;
|
||||
}
|
||||
@@ -59,7 +62,17 @@ export async function waitForDeliveryOutcome(
|
||||
if (response.status !== 200) throw new Error(`receipt lookup returned HTTP ${response.status}`);
|
||||
const outcome = response.data.deliveryOutcome;
|
||||
if (outcome === "posted") {
|
||||
return { outcome, connectorAccepted: true, humanReadership: "unknown", nextAction: null };
|
||||
// #96: a --reply-to update reports whether it joined the earlier item's thread.
|
||||
const replyTo = typeof response.data.replyTo === "string" ? response.data.replyTo : null;
|
||||
const fallback = typeof response.data.replyToFallback === "string" ? response.data.replyToFallback : null;
|
||||
return {
|
||||
outcome,
|
||||
connectorAccepted: true,
|
||||
humanReadership: "unknown",
|
||||
...(replyTo ? { threaded: fallback === null } : {}),
|
||||
...(replyTo && fallback ? { detail: `posted as a new top-level message, not in ${replyTo}'s thread: ${fallback}` } : {}),
|
||||
nextAction: null,
|
||||
};
|
||||
}
|
||||
if (outcome === "transport-failed" || outcome === "never-posted") {
|
||||
return {
|
||||
@@ -137,6 +150,11 @@ async function withClient<T>(
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400 && body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "remote_queue_write_failed"
|
||||
&& (body as { outcome?: unknown }).outcome === "indeterminate") {
|
||||
console.error("The write outcome is INDETERMINATE if the request may have reached a daemon — reconcile by ID before any retry.");
|
||||
}
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
@@ -145,6 +163,33 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// Recovery belongs to the selected daemon's reply; never infer a host from an ID.
|
||||
async function printQueueItemResult(
|
||||
client: DaemonClient,
|
||||
qitemId: string,
|
||||
json: boolean,
|
||||
body: unknown,
|
||||
status: number,
|
||||
): Promise<void> {
|
||||
if (status >= 400 && body && typeof body === "object"
|
||||
&& (body as { error?: unknown }).error === "qitem_not_found") {
|
||||
let selfHostId: string | undefined;
|
||||
try {
|
||||
const health = await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 });
|
||||
if (health.status === 200 && typeof health.data?.selfHostId === "string" && health.data.selfHostId.trim()) {
|
||||
selfHostId = health.data.selfHostId;
|
||||
}
|
||||
} catch { /* Identity enrichment must not replace the original queue failure. */ }
|
||||
const daemon = selfHostId ? `daemon ${JSON.stringify(selfHostId)}` : "the selected daemon (host ID unavailable)";
|
||||
body = {
|
||||
...body,
|
||||
hint: `Queue item ${JSON.stringify(qitemId)} was not found on ${daemon}.\n`
|
||||
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(qitemId)} --full --json`,
|
||||
};
|
||||
}
|
||||
printResult(json, body, status);
|
||||
}
|
||||
|
||||
// OPR.0.4.3.03 — `rig queue show` body preview.
|
||||
//
|
||||
// Default `show` renders a BOUNDED body preview instead of dumping the whole
|
||||
@@ -397,7 +442,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
cmd
|
||||
.command("create")
|
||||
.description("Create a new qitem")
|
||||
.option("--source <session>", "(deprecated, ignored) the source is derived from the seat env (X-OpenRig-Session); P21 I3 made the create route derive it from the transport header")
|
||||
.option("--source <session>", "Declared source outside a managed seat (recorded as claimed:v1); the managed seat env takes precedence")
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.option("--body <text>", "Qitem body inline (use - to read from stdin; mutually exclusive with --body-file)")
|
||||
.option("--body-file <path>", "Read qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class for multiline bodies.")
|
||||
@@ -409,11 +454,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--id <qitemId>", "Retry identity: reuse for the same create after an unknown outcome; otherwise generated and printed before sending")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
|
||||
.option("--summary <text>", "Short human-readable subject, shown in the needs-you view. For a human destination, --body-file is the complete decision brief or update; keep technical continuation in the owning agent row and evidence.")
|
||||
.option("--human-intent <intent>", "decision (default) or update: a quiet informational delivery, never an approval request")
|
||||
.option("--human-detail-file <path>", "One explicitly authored supplemental thread reply; keep the complete action/options in --body-file")
|
||||
.option("--reply-to <qitemId>", "Post this update into an earlier qitem's Slack thread (requires --human-intent update; posts as a new top-level message instead if that thread can't be used, e.g. it is missing or still has an open human decision; --verify reports why)")
|
||||
.option("--human-questions-file <path>", "#193: JSON array of 1-4 questions for a decision, each {id, question, options: [{id, label, recommended?}]} with 2-4 options; Slack shows them as buttons")
|
||||
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: pointer to the durable artifact a human judges (e.g. a PROOF.md path). Required by the daemon when the item is human-routed; optional otherwise.")
|
||||
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
|
||||
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
|
||||
@@ -436,6 +483,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
targetRepo?: string;
|
||||
humanIntent?: string;
|
||||
humanDetailFile?: string;
|
||||
replyTo?: string;
|
||||
humanQuestionsFile?: string;
|
||||
summary?: string;
|
||||
evidenceRef?: string;
|
||||
host?: string;
|
||||
@@ -470,6 +519,35 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// #96: a thread root routes replies to the item that opened it, so only an update
|
||||
// (which never takes a reply) may join another item's thread. Fail before the daemon.
|
||||
if (opts.replyTo !== undefined && opts.humanIntent !== "update") {
|
||||
const message = "reply_to_requires_update: --reply-to is accepted only with --human-intent update; a decision keeps its own thread so its reply stays unambiguous.";
|
||||
if (opts.json) console.error(JSON.stringify({ error: "reply_to_requires_update", message }));
|
||||
else console.error(message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// #193 — read and parse the questions locally too; the daemon validates their shape.
|
||||
let humanQuestions: unknown;
|
||||
if (opts.humanQuestionsFile) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await resolveQueueBody({ bodyFile: opts.humanQuestionsFile });
|
||||
} catch (err) {
|
||||
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
humanQuestions = JSON.parse(text);
|
||||
} catch (err) {
|
||||
emitBodyResolveError(Object.assign(new Error(`--human-questions-file ${opts.humanQuestionsFile} is not valid JSON: ${(err as Error).message}`), {
|
||||
consequence: "The queue command did not run; the daemon was not contacted.",
|
||||
action: "Pass a JSON array of questions, each {id, question, options: [{id, label, recommended?}]}.",
|
||||
}), opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// OPR.0.4.1.18 (FR-7, warn-then-require grace): a summary SHOULD accompany
|
||||
// every new qitem (it feeds the Story node + helps humans skim). Warn — to
|
||||
// stderr so --json stdout stays clean — but do NOT hard-break existing
|
||||
@@ -479,9 +557,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
"warning: rig queue create called without --summary. Pass --summary <text> to set the new qitem's short human-readable summary; without it, the Story node falls back to a bounded body preview. A good summary is 1-2 plain sentences a human skims in the needs-you view — what the work is and why it needs this seat, not the agent-speak --body. Proceeding (pre-18 callers exempt).\n"
|
||||
);
|
||||
}
|
||||
// P21 I3 reconcile: the source is DERIVED from the seat env (X-OpenRig-Session) — --source
|
||||
// deprecated + ignored, no body sourceSession. Verify the env or the daemon returns 400 actor_required (no seat identity to record; P18 retired the 401 refusal).
|
||||
if (!resolveCurrentSession(undefined, "source")) return;
|
||||
// A managed seat keeps transport-derived identity; an external caller can
|
||||
// name the existing claimed:v1 body actor without forging a transport header.
|
||||
const managedSource = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const source = resolveCurrentSession(managedSource ?? opts.source, "source");
|
||||
if (!source) return;
|
||||
const deps = getDeps();
|
||||
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
|
||||
// translate to canonical mission:<id> / slice:<id> tags. Composes
|
||||
@@ -517,12 +597,29 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Allocate before the write: a timeout/abort cannot tell us whether the
|
||||
// daemon committed. The existing primary-key absorb makes a retry with
|
||||
// THIS id safe; a negative read is not permission to mint another one.
|
||||
// Keep timestamp-based fallback consumers, with 64 random bits so an
|
||||
// accidental collision is not mistaken for an intentional retry.
|
||||
const qitemId = opts.id ?? `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
|
||||
const show = `rig queue show ${shellQuote(qitemId)} --full --json`;
|
||||
const reconcile = hostResolved.hostId && hostResolved.hostId !== "local"
|
||||
? `For destination host ${shellQuote(hostResolved.hostId)}, replace <destination-daemon-url> with its registered daemon URL and run: OPENRIG_URL='<destination-daemon-url>' ${show}.`
|
||||
: `At the same endpoint and OPENRIG_HOME, run: ${show}.`;
|
||||
const recovery = `${reconcile} If retrying, repeat the same create with --id ${shellQuote(qitemId)} and unchanged source, destination, body and options. A missing row does not rule out a pending commit; retrying without this ID creates new work.`;
|
||||
// stderr survives an interrupted wait without adding a second JSON
|
||||
// document to stdout. This is a request identity, NOT a commit receipt.
|
||||
console.error(`Queue create request ID: ${qitemId} (not proof of persistence). ${recovery}`);
|
||||
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
|
||||
qitemId: opts.id,
|
||||
...(managedSource === undefined ? { sourceSession: source } : {}),
|
||||
qitemId,
|
||||
destinationSession: hostResolved.destination,
|
||||
body: resolvedBody,
|
||||
humanIntent: opts.humanIntent,
|
||||
humanDetail: opts.humanDetailFile ? await resolveQueueBody({ bodyFile: opts.humanDetailFile }) : undefined,
|
||||
replyTo: opts.replyTo,
|
||||
humanQuestions,
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
priority: opts.priority,
|
||||
@@ -534,7 +631,27 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
// OPR.0.4.6.MH3 FR-1: the out-of-band host envelope (omitted for
|
||||
// plain local writes — the local path stays byte-identical).
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
}).catch((error: unknown) => {
|
||||
// A pre-header disconnect can follow a committed create. Preserve the
|
||||
// typed error, but do not let the shared renderer assert nondelivery.
|
||||
if (error instanceof DaemonConnectionError) error.writeOutcome = "unknown";
|
||||
if (error instanceof DaemonConnectionError || error instanceof DaemonResponseError) {
|
||||
// Keep the shared typed error/exit path, including --json, while
|
||||
// supplying the identity missing from an unreadable/late response.
|
||||
error.message += ` ${recovery}`;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (res.status >= 400 && res.data?.outcome === "indeterminate") {
|
||||
// Forwarded writes may return a structured unknown outcome rather
|
||||
// than throw. Preserve that failure and expose the same recovery ID.
|
||||
printResult(opts.json ?? false, { ...res.data, qitemId, recovery }, res.status);
|
||||
return;
|
||||
}
|
||||
const createWarning = res.data?.createWarning as { code?: unknown; message?: unknown } | undefined;
|
||||
if (res.status < 400 && createWarning?.code === "qitem_body_not_saved" && typeof createWarning.message === "string") {
|
||||
console.error(`Warning: ${createWarning.message}`);
|
||||
}
|
||||
if (opts.verify && res.status < 400) {
|
||||
const created = res.data;
|
||||
const qitemId = typeof created.qitemId === "string" ? created.qitemId : null;
|
||||
@@ -566,7 +683,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -585,7 +702,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -636,7 +753,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -692,7 +809,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
wakeAfterSeconds: opts.wakeAfter,
|
||||
transitionNote: opts.continuation ? `continuation: ${opts.continuation}` : (opts.note ?? `parked on ${opts.on}`),
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -735,7 +852,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
},
|
||||
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined,
|
||||
);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -828,7 +945,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -921,7 +1038,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -958,7 +1075,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
fallbackDestination: opts.destination,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -978,7 +1095,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
// passthrough on error responses / non-object payloads, where there is
|
||||
// no string body to preview.
|
||||
if (opts.full || res.status >= 400 || !isRecordWithStringBody(item)) {
|
||||
printResult(json, item, res.status);
|
||||
await printQueueItemResult(client, qitemId, json, item, res.status);
|
||||
return;
|
||||
}
|
||||
const { preview, bodyBytes, bodyTruncated } = previewBody(item.body);
|
||||
@@ -1002,7 +1119,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ export function requirementsCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/bootstrap/plan", { sourceRef: spec });
|
||||
|
||||
if (res.status >= 500) {
|
||||
console.error(res.data["errors"] ?? res.data["error"] ?? "Failed to check requirements");
|
||||
process.exitCode = 2;
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) console.log(JSON.stringify(res.data));
|
||||
else console.error(res.data["errors"] ?? res.data["error"] ?? "Failed to check requirements");
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -441,8 +441,13 @@ export function rigModeCommand(depsOverride?: RigModeDeps): Command {
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-mode/effective?${qs.toString()}` : "/api/rig-mode/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
emitOperatingPosture(res.data.operatingPosture);
|
||||
if (res.status >= 400 || !res.data.effective) {
|
||||
if (!res.data.effective) {
|
||||
console.log("Operating without an explicit operator-context-mode binding (unknown_posture).");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ export function rigCommand(depsOverride?: RigDeps): Command {
|
||||
// rig spec validate <path>
|
||||
cmd
|
||||
.command("validate <path>")
|
||||
.description("Validate a rig spec (pure schema validation)")
|
||||
.description("Validate a rig spec locally (no daemon required)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (filePath: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
@@ -201,31 +201,26 @@ export function rigCommand(depsOverride?: RigDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!daemonStatusGuard(status)) return;
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
const res = await client.postText<{ valid?: boolean; errors?: string[]; name?: string; advisories?: string[] }>("/api/rigs/import/validate", yaml);
|
||||
const { RigSpecParseError, validateRigSpecImport } = await import("@openrig/daemon/spec-validation");
|
||||
let data: ReturnType<typeof validateRigSpecImport>;
|
||||
try {
|
||||
data = validateRigSpecImport(yaml);
|
||||
} catch (err) {
|
||||
if (!(err instanceof RigSpecParseError)) {
|
||||
const error = "Internal validator error; validation did not complete.";
|
||||
if (opts.json) console.log(JSON.stringify({ error }));
|
||||
else console.error(error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
data = { valid: false, errors: [err.message] };
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400 || !res.data.valid) process.exitCode = 1;
|
||||
console.log(JSON.stringify(data));
|
||||
if (!data.valid) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const data = res.data;
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
console.error(`Rig spec invalid:\n${data.errors.map((e) => ` ${e}`).join("\n")}\nFix: update ${filePath} and re-validate.`);
|
||||
} else {
|
||||
console.error(`Validation failed (HTTP ${res.status}). Check rig spec YAML syntax.`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
// OPR.0.5.3.3 — advisories are printed regardless of validity (fail-open; never an error).
|
||||
if (data.advisories && data.advisories.length > 0) {
|
||||
for (const a of data.advisories) console.error(`⚠ spec advisory: ${a}`);
|
||||
|
||||
@@ -356,6 +356,13 @@ function buildSliceCreateCommand(): Command {
|
||||
const id = sliceIdFromMission(missionId, nn);
|
||||
const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : [];
|
||||
for (const dependency of dependsOn) {
|
||||
if (dependency === id) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice ${id} cannot depend on itself.`,
|
||||
consequence: "Slice not created.",
|
||||
action: "Use a different sibling slice ID, or omit --depends-on.",
|
||||
});
|
||||
}
|
||||
if (!isSliceDotId(dependency) || !dependency.startsWith(`${missionId}.`)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Dependency "${dependency}" is not a sibling slice dot-ID under ${missionId}.`,
|
||||
@@ -613,6 +620,12 @@ function buildSliceMoveCommand(): Command {
|
||||
const newName = `${pad2(newNN)}-${slug}`;
|
||||
const destAbs = path.join(targetSlicesDir, newName);
|
||||
const sourceMission = findMission(missionsRoot, slice.missionName);
|
||||
// Cross-mission dependencies cannot remain sibling edges. Name affected dependents at the move.
|
||||
const dependencyWarnings = slice.id === null ? [] : listSlices(sourceMission, "all")
|
||||
.filter((sibling) => sibling.absPath !== slice.absPath
|
||||
&& Array.isArray(sibling.frontmatter.depends_on)
|
||||
&& sibling.frontmatter.depends_on.includes(slice.id))
|
||||
.map((sibling) => `${sibling.id ?? sibling.name} still has depends_on ${slice.id}; the moved slice is no longer a sibling. Review depends_on in ${sibling.readmePath ?? sibling.absPath}.`);
|
||||
const edits = [
|
||||
planMissionMembershipRemove(sourceMission.absPath, sliceManifestRef(sourceMission.absPath, slice.absPath)),
|
||||
planMissionMembershipAdd(target.absPath, `slices/${newName}/slice.yaml`, nextMissionMembershipOrder(target.absPath)),
|
||||
@@ -637,6 +650,7 @@ function buildSliceMoveCommand(): Command {
|
||||
const { usedGit, repoRoot } = moveResult;
|
||||
emit(out, {
|
||||
ok: true,
|
||||
warnings: dependencyWarnings,
|
||||
moved: {
|
||||
from: { mission: slice.missionName, name: slice.name, id: slice.id },
|
||||
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
|
||||
@@ -646,6 +660,7 @@ function buildSliceMoveCommand(): Command {
|
||||
`Moved ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
|
||||
` id: ${newSliceId}`,
|
||||
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
|
||||
...dependencyWarnings.map((warning) => `Warning: ${warning}`),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (moveResult) rollbackMovedSlice(slice.absPath, destAbs, moveResult);
|
||||
@@ -819,6 +834,13 @@ function buildMissionCreateCommand(): Command {
|
||||
const dependsOn = Array.isArray(opts.dependsOn) ? [...new Set(opts.dependsOn as string[])] : [];
|
||||
const project = id.split(".")[0];
|
||||
for (const dependency of dependsOn) {
|
||||
if (dependency === id) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission ${id} cannot depend on itself.`,
|
||||
consequence: "Mission not created.",
|
||||
action: "Use a different sibling mission ID, or omit --depends-on.",
|
||||
});
|
||||
}
|
||||
if (!isMissionDotId(dependency) || dependency.split(".")[0] !== project) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Dependency "${dependency}" is not a sibling mission dot-ID in project ${project}.`,
|
||||
|
||||
@@ -163,7 +163,7 @@ function printHuman(status: SeatStatusResponse): void {
|
||||
}
|
||||
console.log(`Startup: ${display(status.startup_status, "unknown")}`);
|
||||
console.log(`Occupant lifecycle: ${status.occupant_lifecycle}`);
|
||||
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unknown")}`);
|
||||
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unverified")}`);
|
||||
console.log(`Handover result: ${display(status.handover_result)}`);
|
||||
console.log(`Previous occupant: ${display(status.previous_occupant)}`);
|
||||
console.log(`Handover at: ${display(status.handover_at)}`);
|
||||
@@ -350,7 +350,7 @@ Examples:
|
||||
.option("--operator <address>", "Operator initiating the handover")
|
||||
.option("--dry-run", "Plan the handover without changing topology")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Plan a safe two-phase seat handover")
|
||||
.description("Hand a seat to a successor (two-phase). Pass --dry-run to plan without changing topology.")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig seat handover spec-writer@openrig-pm --reason context-wall --dry-run
|
||||
@@ -427,14 +427,22 @@ rig seat handover, THEN retarget the view. Examples:
|
||||
cmd
|
||||
.command("clear-attention")
|
||||
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig)")
|
||||
.option("--reason <text>", "Operator attestation override (skip evidence gate)")
|
||||
.option("--reason <text>", "Attest startup or subset-restore attention; cannot bypass full-restore or pane-identity checks")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Clear stuck attention_required startup status with evidence or operator attestation")
|
||||
.description("Reconcile seat attention using the checks for its attention class")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig seat clear-attention dev-impl@my-rig
|
||||
rig seat clear-attention dev-impl@my-rig --reason "founder re-authed, confirmed live"
|
||||
rig seat clear-attention dev-impl@my-rig --json
|
||||
|
||||
--reason can acknowledge startup-status and subset-restore attention. It does
|
||||
not bypass full-restore continuity or pane-identity checks. Acknowledgment alone
|
||||
does not prove that the original conversation resumed.
|
||||
If the recorded native token needs correction and you know the actual token:
|
||||
printf '%s' "$TOKEN" | rig seat set-resume-token dev-impl@my-rig --token-stdin --reason "verified native token"
|
||||
Then rerun clear-attention to check the live evidence. Setting the token alone,
|
||||
or stopping and relaunching the seat, does not prove continuity.
|
||||
`)
|
||||
.action(async (session: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
@@ -482,7 +490,11 @@ Examples:
|
||||
const route = `/api/seat/${path}/${encodeURIComponent(seat)}`;
|
||||
res = path === "launch"
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 120_000 })
|
||||
: await client.post<Record<string, unknown>>(route, body);
|
||||
// #260: a dynamic Claude mode waits up to 5 s for the capability query before the
|
||||
// daemon answers, so the 5 s default deadline would abort before its refusal arrives.
|
||||
: path === "set-permissions"
|
||||
? await client.post<Record<string, unknown>>(route, body, { timeoutMs: 10_000 })
|
||||
: await client.post<Record<string, unknown>>(route, body);
|
||||
} catch (err) {
|
||||
if (path !== "launch" || !(err instanceof DaemonTimeoutError)) throw err;
|
||||
const error = {
|
||||
@@ -710,12 +722,36 @@ export async function runSeatHandover(seat: string, opts: HandoverActionOpts, de
|
||||
if (!daemonStatusGuard(daemon)) return; // B8-1b: epistemic-matched
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
|
||||
const handoverRoute = `/api/seat/handover/${encodeURIComponent(seat)}`;
|
||||
const handoverBody = {
|
||||
source: opts.source,
|
||||
reason: opts.reason,
|
||||
operator: opts.operator,
|
||||
dryRun: opts.dryRun === true,
|
||||
});
|
||||
};
|
||||
let res;
|
||||
try {
|
||||
// #260: a mutating handover launches and readies the successor, so it gets the
|
||||
// launch request window. A dry run only plans, and keeps the default deadline.
|
||||
res = opts.dryRun === true
|
||||
? await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody)
|
||||
: await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(handoverRoute, handoverBody, { timeoutMs: 120_000 });
|
||||
} catch (err) {
|
||||
// The daemon keeps working when the client stops waiting, so reaching the bound leaves a
|
||||
// mutating handover's outcome unknown. One request; no retry.
|
||||
if (opts.dryRun === true || !(err instanceof DaemonTimeoutError)) throw err;
|
||||
const error = {
|
||||
ok: false as const,
|
||||
code: "handover_outcome_unknown",
|
||||
status: "unknown",
|
||||
message: "The CLI stopped waiting for the daemon after 120 seconds, so the handover outcome is unknown. The daemon may still be working on it.",
|
||||
guidance: `Inspect the seat before considering another handover: rig seat status ${seat}. A handover result shown there may belong to an earlier attempt.`,
|
||||
};
|
||||
if (opts.json) console.log(JSON.stringify(error, null, 2));
|
||||
else printSeatError(error, error.message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { parseDocument } from "yaml";
|
||||
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import {
|
||||
CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
@@ -121,8 +122,8 @@ const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "claude-code",
|
||||
path: "~/.claude.json",
|
||||
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
|
||||
path: "~/.claude.json or <CLAUDE_CONFIG_DIR>/.claude.json",
|
||||
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete. Explicit permission modes use the launch-selected HOME/config; classic startup uses the daemon home.",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
@@ -140,7 +141,7 @@ const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "codex",
|
||||
path: "~/.codex/config.toml",
|
||||
path: "$CODEX_HOME/config.toml (default: ~/.codex/config.toml)",
|
||||
purpose: "Pre-trust managed workspaces and apply selected Codex config runtime-resource fragments.",
|
||||
},
|
||||
];
|
||||
@@ -242,14 +243,14 @@ async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"availabl
|
||||
const port = config.daemon.port;
|
||||
|
||||
try {
|
||||
const healthRes = await fetchFn(`http://${host}:${port}/healthz`);
|
||||
const healthRes = await fetchFn(`http://${formatDaemonHostForUrl(host)}:${port}/healthz`);
|
||||
if (!healthRes.ok) return "skipped";
|
||||
} catch {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
try {
|
||||
const cmuxRes = await fetchFn(`http://${host}:${port}/api/adapters/cmux/status`);
|
||||
const cmuxRes = await fetchFn(`http://${formatDaemonHostForUrl(host)}:${port}/api/adapters/cmux/status`);
|
||||
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
|
||||
const data = (await cmuxRes.json()) as { available?: boolean };
|
||||
return data.available ? "available" : "unavailable";
|
||||
|
||||
@@ -201,14 +201,21 @@ export function slackCommand(deps: SlackDeps = {}): Command {
|
||||
const scope = verification.value.scope!;
|
||||
const member = verification.value.member;
|
||||
const ready = scope.ok && (member ? member.isMember : false);
|
||||
// Failed scope requests or an absent/empty grant header cannot prove
|
||||
// which optional features are available. Baseline readiness is unchanged.
|
||||
const missingFeatures = scope.error || scope.granted.length === 0 ? null
|
||||
: surface.FEATURE_SCOPES.filter((feature) => !scope.granted.includes(feature.scope));
|
||||
if (opts.json) {
|
||||
log(JSON.stringify({ scope, member, ready, receipt: verification.receipt }));
|
||||
log(JSON.stringify({ scope, member, ready, missingFeatures, receipt: verification.receipt }));
|
||||
} else {
|
||||
log(`granted scopes: ${scope.granted.join(", ") || "(none)"}`);
|
||||
if (!scope.ok) log(`✗ MISSING scopes (configured != granted — reinstall the app): ${scope.missing.join(", ")}${scope.error ? ` [${scope.error}]` : ""}`);
|
||||
else log("✓ all required scopes granted");
|
||||
if (member) log(member.isMember ? `✓ channel member (${member.name ?? cfg.channel})` : `✗ NOT a member of channel ${cfg.channel} — invite the app`);
|
||||
else log("… channel not configured — set --channel to verify membership");
|
||||
for (const feature of missingFeatures ?? []) {
|
||||
log(`⚠ ${feature.scope} missing: ${feature.usedBy}. Reinstall the app with this scope to use the feature.`);
|
||||
}
|
||||
log(ready ? "READY" : "NOT ready");
|
||||
}
|
||||
if (!ready) process.exitCode = 1;
|
||||
|
||||
@@ -206,6 +206,7 @@ Examples:
|
||||
const client = await getClient();
|
||||
const url = opts.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library";
|
||||
const res = await client.get<LibraryEntry[]>(url);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const entries = res.data ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -212,8 +212,6 @@ Examples:
|
||||
db: resolvedConfig.db.path,
|
||||
transcriptsEnabled: resolvedConfig.transcripts.enabled,
|
||||
transcriptsPath: resolvedConfig.transcripts.path,
|
||||
transcriptsLines: resolvedConfig.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: resolvedConfig.transcripts.pollIntervalSeconds,
|
||||
workspaceRoot: resolvedConfig.workspace.root,
|
||||
contextRoot: resolvedConfig.context.root,
|
||||
skillsRoot: resolvedConfig.skills.root,
|
||||
@@ -243,7 +241,8 @@ Examples:
|
||||
if (kernelResult.kernelState === "skipped") {
|
||||
if (!opts.json) console.log("Kernel auto-boot skipped (--no-kernel or test mode).");
|
||||
} else {
|
||||
console.error(`Kernel failed to start: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
|
||||
const terminalFailure = ["auth_blocked", "spec_missing", "bootstrap_failed", "degraded"].includes(kernelResult.kernelState ?? "");
|
||||
console.error(`${terminalFailure ? "Kernel failed to start" : "Kernel did not report ready before the deadline"}: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
|
||||
console.error("Cannot proceed to rig restore without a working kernel.");
|
||||
console.error("Fix: resolve the kernel issue, then rerun: rig start");
|
||||
process.exitCode = 1;
|
||||
@@ -269,9 +268,10 @@ Examples:
|
||||
let allSummaries: RigSummary[];
|
||||
try {
|
||||
const res = await client.get<RigSummary[]>("/api/rigs/summary");
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
allSummaries = res.data ?? [];
|
||||
} catch {
|
||||
console.error("Failed to list rigs. Daemon may not be ready.");
|
||||
} catch (err) {
|
||||
console.error(`Failed to list rigs: ${err instanceof Error ? err.message : String(err)}. Daemon may not be ready.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { formatDaemonHostForUrl } from "../client.js";
|
||||
|
||||
export interface StartupProofDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -47,7 +48,7 @@ export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoi
|
||||
|
||||
if (!baseUrl) {
|
||||
const port = first(env["OPENRIG_PORT"], env["RIGGED_PORT"]);
|
||||
if (port) baseUrl = `http://${first(env["OPENRIG_HOST"], env["RIGGED_HOST"]) ?? "127.0.0.1"}:${port}`;
|
||||
if (port) baseUrl = `http://${formatDaemonHostForUrl(first(env["OPENRIG_HOST"], env["RIGGED_HOST"]) ?? "127.0.0.1")}:${port}`;
|
||||
}
|
||||
|
||||
return baseUrl && token ? { baseUrl, token } : null;
|
||||
|
||||
@@ -56,6 +56,14 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.state === "unverified") {
|
||||
console.log(statusGuardMessage(status).fact);
|
||||
if (status.siblingHint) {
|
||||
console.log(` note: OPENRIG_HOME may be wrong — resolved ${status.siblingHint.resolvedHome}, live sibling ${status.siblingHint.siblingHome}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// state === "running"
|
||||
if (status.healthy === false) {
|
||||
console.log(`Daemon running (pid ${status.pid}) but unhealthy — healthz failed`);
|
||||
|
||||
@@ -106,6 +106,10 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
await withClient(deps, async (client) => {
|
||||
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}) };
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/open", body);
|
||||
if (!Array.isArray(res.data?.opened)) {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
printOpen(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,11 +183,11 @@ function renderTranscript(data: Record<string, unknown>, useGrep: boolean): void
|
||||
if (content) {
|
||||
// Print each line via console.log for consistent capture in tests and terminal
|
||||
const lines = content.split("\n");
|
||||
// console.log supplies the final newline; keep every other empty line.
|
||||
if (lines.at(-1) === "") lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line) {
|
||||
console.log(line);
|
||||
printed++;
|
||||
}
|
||||
console.log(line);
|
||||
if (line) printed++;
|
||||
}
|
||||
}
|
||||
// OPR.0.5.3.1 item 4 — point-of-use hint: a near-empty transcript for a Claude
|
||||
|
||||
@@ -6,8 +6,13 @@ import { realDeps } from "./daemon.js";
|
||||
export interface UiDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
exec: (cmd: string, args: string[]) => Promise<void>;
|
||||
/** Reads the daemon's own answer for the UI page. Defaults to fetch with a short timeout. */
|
||||
probeUi?: (url: string) => Promise<{ status: number; headers: { get(name: string): string | null } }>;
|
||||
}
|
||||
|
||||
export const UI_DISABLED_GUIDANCE =
|
||||
"The OpenRig web UI is off on this daemon. To turn it on, run `rig config set ui.enabled true`, then stop and start the daemon (`rig daemon stop`, `rig daemon start`).";
|
||||
|
||||
export const UI_MAINTENANCE_NOTICE =
|
||||
"The OpenRig UI is experimental and in maintenance mode. It is not under active development; support is best-effort. The CLI is the primary supported interface. Contributions welcome.";
|
||||
|
||||
@@ -47,6 +52,18 @@ export function uiCommand(depsOverride?: UiDeps): Command {
|
||||
if (!daemonStatusGuard(status)) return;
|
||||
|
||||
const url = getDaemonUrl(status);
|
||||
// Ask the running daemon, not local config: the setting takes effect when the daemon starts.
|
||||
const probeUi = deps.probeUi ?? ((target: string) => fetch(target, { signal: AbortSignal.timeout(3000) }));
|
||||
try {
|
||||
const res = await probeUi(url);
|
||||
if (res.status === 404 && res.headers.get("x-openrig-web-ui") === "off") {
|
||||
console.error(UI_DISABLED_GUIDANCE);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No answer: open the URL as before; the browser shows whatever the daemon serves.
|
||||
}
|
||||
console.log(url);
|
||||
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,7 @@ async function defaultPromptYesNo(question: string): Promise<boolean> {
|
||||
// inside the daemon install root without importing the daemon package.
|
||||
function isPathInsideRoot(candidate: string, root: string): boolean {
|
||||
const relative = nodePath.relative(nodePath.resolve(root), nodePath.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
|
||||
return relative === "" || (relative !== ".." && !relative.startsWith(`..${nodePath.sep}`) && !nodePath.isAbsolute(relative));
|
||||
}
|
||||
|
||||
// OPR.0.4.4.11 (arch return R11-2) — pre-dispatch detection of a topology
|
||||
@@ -126,7 +126,7 @@ Examples:
|
||||
} else if (result.ok) {
|
||||
console.log(JSON.stringify(result.data, null, 2));
|
||||
} else {
|
||||
console.error(`Error on host ${opts.host}: ${result.error}`);
|
||||
for (const line of formatRemoteUpFailure(opts.host, result)) console.error(line);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
@@ -193,8 +193,6 @@ Examples:
|
||||
db: resolvedConfig?.db.path,
|
||||
transcriptsEnabled: resolvedConfig?.transcripts.enabled,
|
||||
transcriptsPath: resolvedConfig?.transcripts.path,
|
||||
transcriptsLines: resolvedConfig?.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: resolvedConfig?.transcripts.pollIntervalSeconds,
|
||||
workspaceRoot: resolvedConfig?.workspace.root,
|
||||
contextRoot: resolvedConfig?.context.root,
|
||||
skillsRoot: resolvedConfig?.skills.root,
|
||||
@@ -644,6 +642,77 @@ Examples:
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/** Render a failed remote `rig up` response without stringifying an unknown
|
||||
* body as `[object Object]`. JSON mode keeps the complete RemoteOpResult;
|
||||
* human mode shows known daemon guidance and safely falls back for older or
|
||||
* partial error bodies. */
|
||||
export function formatRemoteUpFailure(
|
||||
hostId: string,
|
||||
result: { error?: string; data?: unknown },
|
||||
): string[] {
|
||||
const lines = [`Error on host ${hostId}${result.error ? `: ${result.error}` : ""}`];
|
||||
const payload = result.data !== null && typeof result.data === "object" && !Array.isArray(result.data)
|
||||
? result.data as Record<string, unknown>
|
||||
: undefined;
|
||||
const error = payload?.["error"];
|
||||
let detailAdded = false;
|
||||
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
lines.push(error.trim());
|
||||
detailAdded = true;
|
||||
} else if (error !== null && typeof error === "object" && !Array.isArray(error)) {
|
||||
const body = error as Record<string, unknown>;
|
||||
const fact = typeof body["fact"] === "string" ? body["fact"] : undefined;
|
||||
const consequence = typeof body["consequence"] === "string" ? body["consequence"] : undefined;
|
||||
const action = typeof body["action"] === "string" ? body["action"] : undefined;
|
||||
const message = typeof body["message"] === "string" ? body["message"].trim() : undefined;
|
||||
if (fact && consequence && action) {
|
||||
lines.push(...formatThreePart({ fact, consequence, action }));
|
||||
detailAdded = true;
|
||||
} else {
|
||||
if (fact) lines.push(`Error: ${fact}`);
|
||||
if (consequence) lines.push(consequence);
|
||||
if (action) lines.push(action);
|
||||
if (message) lines.push(message);
|
||||
detailAdded = Boolean(fact || consequence || action || message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!detailAdded && typeof payload?.["message"] === "string" && payload["message"].trim()) {
|
||||
lines.push(payload["message"].trim());
|
||||
detailAdded = true;
|
||||
}
|
||||
if (!detailAdded && Array.isArray(payload?.["errors"])) {
|
||||
const errors = payload["errors"].filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
||||
if (errors.length > 0) {
|
||||
lines.push(...errors.map((entry) => ` ${entry.trim()}`));
|
||||
detailAdded = true;
|
||||
}
|
||||
}
|
||||
if (typeof payload?.["code"] === "string" && payload["code"].trim()) {
|
||||
lines.push(`Code: ${payload["code"].trim()}`);
|
||||
}
|
||||
for (const node of Array.isArray(payload?.["attentionNodes"]) ? payload["attentionNodes"] : []) {
|
||||
if (node === null || typeof node !== "object" || Array.isArray(node)) continue;
|
||||
const entry = node as Record<string, unknown>;
|
||||
if (typeof entry["logicalId"] !== "string" || typeof entry["reason"] !== "string") continue;
|
||||
const session = typeof entry["sessionName"] === "string" ? ` (${entry["sessionName"]})` : "";
|
||||
lines.push(` ${entry["logicalId"]}${session}: ${entry["reason"]}`);
|
||||
detailAdded = true;
|
||||
}
|
||||
if (Array.isArray(payload?.["warnings"])) {
|
||||
for (const warning of payload["warnings"]) {
|
||||
if (typeof warning === "string" && warning.trim()) lines.push(` warning: ${warning.trim()}`);
|
||||
}
|
||||
}
|
||||
if (!detailAdded && typeof result.data === "string" && result.data.trim()) {
|
||||
lines.push(result.data.trim());
|
||||
detailAdded = true;
|
||||
}
|
||||
if (!detailAdded) lines.push("Remote daemon did not return a recognized error message.");
|
||||
return lines;
|
||||
}
|
||||
|
||||
interface RestoreBlocker {
|
||||
code: string;
|
||||
severity?: string;
|
||||
|
||||
@@ -377,11 +377,14 @@ initial record is explicitly reported as unverified delivery.`)
|
||||
if (opts.budget !== undefined) params.set("budget", opts.budget);
|
||||
const res = await client.get<{
|
||||
pieces?: Array<{ atomId: string; address: string; text: string }>;
|
||||
warnings?: string[];
|
||||
message?: string; error?: string;
|
||||
}>(`/api/context-packs/library/by-ref/profile?${params.toString()}`);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(res.data?.message ?? res.data?.error ?? `Daemon returned HTTP ${res.status} composing the profile.`);
|
||||
}
|
||||
// A skipped piece (a post-compaction compose with no seat recap) is reported, never silent.
|
||||
for (const w of res.data.warnings ?? []) console.error(`WARNING ${w}`);
|
||||
const profilePieces = res.data.pieces ?? [];
|
||||
return {
|
||||
// NO-COPY: content is the SERVED text, byte-for-byte; the label carries
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DaemonClient } from "../client.js";
|
||||
import { fetchWithTimeout } from "../fetch-with-timeout.js";
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.WF3 FR-1 — the shared follow engine behind `rig workflow
|
||||
@@ -85,6 +86,8 @@ export interface FollowOptions {
|
||||
io?: FollowIo;
|
||||
/** SSE reconnect attempts before degrading to the poll fallback. */
|
||||
maxReconnects?: number;
|
||||
/** Deadline for opening SSE response headers; streamed bodies remain unbounded. */
|
||||
streamConnectTimeoutMs?: number;
|
||||
/** Poll fallback interval (ms). */
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
@@ -178,6 +181,7 @@ export async function followInstance(
|
||||
const io = opts.io ?? realFollowIo();
|
||||
const maxReconnects = opts.maxReconnects ?? 3;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 3000;
|
||||
const streamConnectTimeoutMs = opts.streamConnectTimeoutMs ?? 5000;
|
||||
|
||||
// Walk-caught (VM iteration 2): without aborting the SSE connection
|
||||
// on return, the open socket keeps the node event loop alive and the
|
||||
@@ -192,10 +196,10 @@ export async function followInstance(
|
||||
const sseUrl = `${client.baseUrl}/api/workflow/sse`;
|
||||
let streamRes: Response | null = null;
|
||||
try {
|
||||
streamRes = await io.fetchImpl(sseUrl, {
|
||||
streamRes = await fetchWithTimeout(io.fetchImpl, sseUrl, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: aborter.signal,
|
||||
});
|
||||
}, { timeoutMs: streamConnectTimeoutMs, timeoutMessage: "Workflow stream connection timed out." });
|
||||
if (!streamRes.ok || !streamRes.body) streamRes = null;
|
||||
} catch {
|
||||
streamRes = null;
|
||||
@@ -229,10 +233,10 @@ export async function followInstance(
|
||||
reconnectsLeft -= 1;
|
||||
io.err(`stream dropped — reconnecting (${maxReconnects - reconnectsLeft}/${maxReconnects})`);
|
||||
try {
|
||||
const retry = await io.fetchImpl(sseUrl, {
|
||||
const retry = await fetchWithTimeout(io.fetchImpl, sseUrl, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: aborter.signal,
|
||||
});
|
||||
}, { timeoutMs: streamConnectTimeoutMs, timeoutMessage: "Workflow stream connection timed out." });
|
||||
if (retry.ok && retry.body) {
|
||||
streamRes = retry;
|
||||
continue;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolve } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { followInstance } from "./workflow-follow.js";
|
||||
@@ -35,10 +35,7 @@ async function withClient<T>(
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
printDaemonNotRunning();
|
||||
return undefined;
|
||||
}
|
||||
if (!daemonStatusGuard(status)) return undefined;
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, daemonStatusGuard } from "../daemon-lifecycle.js";
|
||||
import * as path from "node:path";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -40,10 +40,7 @@ async function withClient<T>(
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
printDaemonNotRunning();
|
||||
return undefined;
|
||||
}
|
||||
if (!daemonStatusGuard(status)) return undefined;
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { readFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { writeTextAtomically } from "./atomic-text-write.js";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
getDefaultOpenRigPath,
|
||||
@@ -76,6 +77,8 @@ export interface RiggedConfig {
|
||||
// Preview Terminal v0 (PL-018) — UI-side preferences for the live
|
||||
// terminal preview pane.
|
||||
ui: {
|
||||
/** Serve the web UI and its terminal WebSocket. Off by default; the daemon reads it at start. */
|
||||
enabled: boolean;
|
||||
timezone: string;
|
||||
preview: {
|
||||
refreshIntervalSeconds: number;
|
||||
@@ -239,6 +242,7 @@ const DEFAULTS = {
|
||||
files: { allowlist: "" },
|
||||
progress: { scanRoots: "" },
|
||||
ui: {
|
||||
enabled: false,
|
||||
timezone: "America/Los_Angeles",
|
||||
preview: {
|
||||
refreshIntervalSeconds: 3,
|
||||
@@ -377,6 +381,7 @@ export const VALID_KEYS = [
|
||||
"ui.preview.max_pins",
|
||||
"ui.preview.default_lines",
|
||||
"ui.timezone",
|
||||
"ui.enabled",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
@@ -471,6 +476,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.timezone": { primary: "OPENRIG_UI_TIMEZONE" },
|
||||
"ui.enabled": { primary: "OPENRIG_UI_ENABLED" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
@@ -551,6 +557,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.timezone": ["ui", "timezone"],
|
||||
"ui.enabled": ["ui", "enabled"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
@@ -764,6 +771,16 @@ function percentageConstraint(key: string) {
|
||||
}
|
||||
|
||||
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
|
||||
"transcripts.lines": (raw, value) => {
|
||||
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
|
||||
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
|
||||
}
|
||||
},
|
||||
"transcripts.poll_interval_seconds": (raw, value) => {
|
||||
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 3600) {
|
||||
throw new Error("Invalid transcripts.poll_interval_seconds: must be an integer in [1, 3600]");
|
||||
}
|
||||
},
|
||||
"ui.timezone": (_raw, value) => {
|
||||
try {
|
||||
if (typeof value !== "string" || !value || /^[+-]/.test(value)) throw new Error();
|
||||
@@ -915,6 +932,11 @@ export class ConfigStore {
|
||||
// resolves the legacy ~/.rigged sidecar), so a persisted read here IS proof the daemon sees it.
|
||||
// A read-back MISMATCH means the write silently did not take — we REFUSE loudly rather than report
|
||||
// a phantom success (the accept-and-drop / config-set-success-without-persist class).
|
||||
/** Publish a complete config without truncating the previous usable file. */
|
||||
private writeConfig(content: string): void {
|
||||
writeTextAtomically(this.configPath, content, "config");
|
||||
}
|
||||
|
||||
private verifyPersisted(keyPath: string[], expected: unknown): void {
|
||||
let reread: Record<string, unknown>;
|
||||
try {
|
||||
@@ -999,6 +1021,7 @@ export class ConfigStore {
|
||||
scanRoots: v("progress.scan_roots") as string,
|
||||
},
|
||||
ui: {
|
||||
enabled: v("ui.enabled") as boolean,
|
||||
timezone: v("ui.timezone") as string,
|
||||
preview: {
|
||||
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
|
||||
@@ -1192,7 +1215,7 @@ export class ConfigStore {
|
||||
const fcDyn = this.readConfigFile();
|
||||
setNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn);
|
||||
mkdirSync(dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
|
||||
this.writeConfig(JSON.stringify(fcDyn, null, 2) + "\n");
|
||||
this.verifyPersisted(["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn);
|
||||
return;
|
||||
}
|
||||
@@ -1215,7 +1238,7 @@ export class ConfigStore {
|
||||
}
|
||||
}
|
||||
mkdirSync(dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
|
||||
this.writeConfig(JSON.stringify(fileConfig, null, 2) + "\n");
|
||||
this.verifyPersisted(KEY_TO_PATH[key], coerced);
|
||||
}
|
||||
|
||||
@@ -1226,7 +1249,9 @@ export class ConfigStore {
|
||||
*/
|
||||
reset(key?: string): void {
|
||||
if (key === undefined) {
|
||||
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
|
||||
try { unlinkSync(this.configPath); } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const removedMessage = removedContextSettingMessage(key);
|
||||
@@ -1238,7 +1263,7 @@ export class ConfigStore {
|
||||
const fcDyn = this.readConfigFile();
|
||||
const subsParent = getNestedValue(fcDyn, ["feed", "subscriptions"]) as Record<string, unknown> | undefined;
|
||||
if (subsParent && feedHost.hostId in subsParent) delete subsParent[feedHost.hostId];
|
||||
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
|
||||
this.writeConfig(JSON.stringify(fcDyn, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (!isValidKey(key)) {
|
||||
@@ -1265,7 +1290,7 @@ export class ConfigStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
|
||||
this.writeConfig(JSON.stringify(fileConfig, null, 2) + "\n");
|
||||
}
|
||||
|
||||
private readConfigFile(): Record<string, unknown> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { connectionErrorCode, formatDaemonHostForUrl } from "./client.js";
|
||||
import { blockedConnectionGuidance, isBlockedConnectionCode } from "./daemon-reachability.js";
|
||||
import type { DaemonStartLock } from "./daemon-start-lock.js";
|
||||
import { DAEMON_STOP_WAIT_MS, DAEMON_SHUTDOWN_RECEIPT, type DaemonShutdownReceipt } from "@openrig/daemon/daemon-shutdown";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
@@ -58,8 +60,13 @@ export interface DaemonStatus {
|
||||
reason?: "unresponsive" | "event-loop-starved";
|
||||
/** OPR.0.4.3.21 — event-loop evidence when healthz answered with a monitor. */
|
||||
eventLoop?: DaemonEventLoopEvidence;
|
||||
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
|
||||
probeErrorCode?: string;
|
||||
}
|
||||
|
||||
/** "unknown": the process may exist but this shell can neither signal nor inspect it. */
|
||||
export type ProcessLiveness = "alive" | "dead" | "unknown";
|
||||
|
||||
export interface GetDaemonStatusOptions {
|
||||
/** Observers may disable cleanup; only a matching clean shutdown permits removal. */
|
||||
cleanupStaleState?: boolean;
|
||||
@@ -67,7 +74,7 @@ export interface GetDaemonStatusOptions {
|
||||
|
||||
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
|
||||
export function getDaemonUrl(status: DaemonStatus): string {
|
||||
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
|
||||
return `http://${formatDaemonHostForUrl(status.host ?? DEFAULT_HOST)}:${status.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +90,8 @@ export interface DaemonNotRunningError {
|
||||
fact: string;
|
||||
consequence: string;
|
||||
action: string;
|
||||
/** The OS code behind the failed probe, when known. */
|
||||
causeCode?: string;
|
||||
}
|
||||
|
||||
export function daemonNotRunningError(): DaemonNotRunningError {
|
||||
@@ -106,11 +115,22 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
|
||||
if (status.state === "stopped" || status.state === "stale") {
|
||||
return daemonNotRunningError();
|
||||
}
|
||||
const causeCode = status.probeErrorCode ? { causeCode: status.probeErrorCode } : {};
|
||||
if (isBlockedConnectionCode(status.probeErrorCode)) {
|
||||
const blocked = blockedConnectionGuidance(status.probeErrorCode);
|
||||
return {
|
||||
fact: `Daemon could not be checked: the health probe was blocked (${status.probeErrorCode}).`,
|
||||
consequence: `This command needs a responsive daemon. ${blocked.consequence}`,
|
||||
action: blocked.action,
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// unverified, or running-but-unhealthy: we do NOT know it is down.
|
||||
return {
|
||||
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
|
||||
consequence: "This command needs a responsive daemon; the outcome of proceeding would be indeterminate.",
|
||||
action: "Re-check with 'rig daemon status'. If it is confirmed stopped, run 'rig up' or 'rig daemon start'.",
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,6 +213,9 @@ export interface LifecycleDeps {
|
||||
openForAppend: (path: string) => number;
|
||||
closeFile?: (fd: number) => void;
|
||||
isProcessAlive: (pid: number) => boolean;
|
||||
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
|
||||
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
|
||||
processLiveness?: (pid: number) => ProcessLiveness;
|
||||
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
|
||||
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
|
||||
// existing deps / mocks / callers are untouched.
|
||||
@@ -372,7 +395,7 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
|
||||
if (!deps.isProcessAlive(state.pid)) return "dead";
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
|
||||
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
|
||||
// Any response (ok or not) means something is listening on our port → OpenRig
|
||||
return "openrig";
|
||||
} catch (err) {
|
||||
@@ -552,7 +575,7 @@ export async function verifyRequiredListeners(input: {
|
||||
for (const host of required) {
|
||||
// NO catch-collapse here (r2 finding): the probe classifies its own errors; an
|
||||
// exception reaching this point is a wiring bug and should surface, not convert.
|
||||
const outcome = await input.probe(`http://${host}:${input.port}/healthz`);
|
||||
const outcome = await input.probe(`http://${formatDaemonHostForUrl(host)}:${input.port}/healthz`);
|
||||
if (outcome === "healthy") verified.push(host);
|
||||
else if (outcome === "unhealthy") missing.push(host);
|
||||
else indeterminate.push(host);
|
||||
@@ -619,7 +642,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
} else {
|
||||
let recoveredRunning = false;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${probeHost}:${port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
|
||||
await fetchDaemonProbe(deps, `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`, HEALTHZ_PROBE_TIMEOUT_MS);
|
||||
recoveredRunning = true;
|
||||
} catch (err) {
|
||||
if (err instanceof HealthProbeTimeoutError) {
|
||||
@@ -690,7 +713,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
if (!Number.isSafeInteger(pid) || pid! <= 0) throw new Error("Daemon spawn returned no valid child PID");
|
||||
if (hasExited()) throw new Error(`Daemon child ${pid} exited before startup completed`);
|
||||
};
|
||||
const healthzUrl = `http://${probeHost}:${port}/healthz`;
|
||||
const healthzUrl = `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`;
|
||||
type StartHealth = { pid?: unknown; bind?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean } };
|
||||
const readOwnedHealth = async (url: string): Promise<StartHealth | null> => {
|
||||
assertChild();
|
||||
@@ -826,8 +849,8 @@ export async function stopDaemon(deps: LifecycleDeps): Promise<"stopped" | "no-t
|
||||
const state = readState(deps);
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
const explicitUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
const stateUrl = state ? `http://${state.host ?? DEFAULT_HOST}:${state.port}` : undefined;
|
||||
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${configured.host}:${configured.port}`;
|
||||
const stateUrl = state ? `http://${formatDaemonHostForUrl(state.host ?? DEFAULT_HOST)}:${state.port}` : undefined;
|
||||
const target = stateUrl ?? explicitUrl?.replace(/\/+$/, "") ?? `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}`;
|
||||
if (stateUrl && explicitUrl && new URL(explicitUrl).origin !== new URL(stateUrl).origin) {
|
||||
throw new Error(`Cannot stop safely: addressed ${explicitUrl}/healthz does not match local PID ${state!.pid} at ${stateUrl}/healthz; no signal sent.`);
|
||||
}
|
||||
@@ -954,8 +977,8 @@ export async function getDaemonStatus(
|
||||
const url = new URL(openrigUrl);
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified" };
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...probeErrorCodeOf(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,7 +986,7 @@ export async function getDaemonStatus(
|
||||
if (!state) {
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
try {
|
||||
const res = await probeHealthzWithSettle(deps, `http://${configured.host}:${configured.port}/healthz`);
|
||||
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(configured.host)}:${configured.port}/healthz`);
|
||||
const ev = await readHealthEvidence(res);
|
||||
return {
|
||||
state: "running",
|
||||
@@ -977,12 +1000,15 @@ export async function getDaemonStatus(
|
||||
// 1ae863d2: the resolved home has NO daemon state — before asserting anything,
|
||||
// look for a live sibling home / HOME-MOVED marker (the wrong-home class).
|
||||
const siblingHint = findSiblingHome(deps);
|
||||
if (siblingHint) return { state: "unverified", siblingHint };
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified" };
|
||||
if (siblingHint) return { state: "unverified", siblingHint, ...probeErrorCodeOf(err) };
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...probeErrorCodeOf(err) };
|
||||
}
|
||||
}
|
||||
|
||||
if (!deps.isProcessAlive(state.pid)) {
|
||||
const liveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(state.pid)
|
||||
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
|
||||
if (liveness === "dead") {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
@@ -992,26 +1018,47 @@ export async function getDaemonStatus(
|
||||
return { state: "stale" };
|
||||
}
|
||||
|
||||
// Process alive — check healthz
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
if (liveness === "unknown") {
|
||||
// #275: this shell can neither signal nor inspect the recorded PID (a sandbox, or another
|
||||
// user's process). Never call that stale; let the daemon answer, and touch no state file.
|
||||
try {
|
||||
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`);
|
||||
const ev = await readHealthEvidence(res);
|
||||
return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", pid: state.pid, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// Process alive — check healthz
|
||||
let healthy = false;
|
||||
let reason: DaemonStatus["reason"];
|
||||
let eventLoop: DaemonEventLoopEvidence | undefined;
|
||||
let probeFailure: Pick<DaemonStatus, "probeErrorCode"> = {};
|
||||
try {
|
||||
const res = await probeHealthzWithSettle(deps, `http://${host}:${state.port}/healthz`);
|
||||
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`);
|
||||
const ev = await readHealthEvidence(res);
|
||||
healthy = ev.healthy;
|
||||
reason = ev.reason;
|
||||
eventLoop = ev.eventLoop;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// OPR.0.4.3.21 — pid alive but healthz timed out: the honest wedged-loop
|
||||
// signal. Report process-present/unhealthy (NOT "stopped") with the
|
||||
// "unresponsive" reason so the operator knows it's the control plane.
|
||||
reason = "unresponsive";
|
||||
probeFailure = probeErrorCodeOf(err);
|
||||
}
|
||||
|
||||
// pid alive = running (state file preserved either way)
|
||||
return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop };
|
||||
return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop, ...probeFailure };
|
||||
}
|
||||
|
||||
/** #275 — keep the OS code of a failed health probe (absent for a timeout). */
|
||||
function probeErrorCodeOf(err: unknown): Pick<DaemonStatus, "probeErrorCode"> {
|
||||
const code = connectionErrorCode(err);
|
||||
return code ? { probeErrorCode: code } : {};
|
||||
}
|
||||
|
||||
export function readLogs(deps: LifecycleDeps): string | null {
|
||||
@@ -1194,8 +1241,11 @@ export async function waitForKernelReady(
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: KernelReadyResult = { ok: false, kernelState: null, variant: null, detail: null };
|
||||
while (Date.now() < deadline) {
|
||||
const remainingMs = Math.max(1, deadline - Date.now());
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/kernel/status`);
|
||||
const res = await fetch(`${baseUrl}/api/kernel/status`, {
|
||||
signal: AbortSignal.timeout(remainingMs),
|
||||
});
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as {
|
||||
kernel_state?: string;
|
||||
@@ -1223,7 +1273,9 @@ export async function waitForKernelReady(
|
||||
} catch {
|
||||
// Transient fetch failure; keep polling until the deadline.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
const sleepMs = Math.min(pollIntervalMs, Math.max(0, deadline - Date.now()));
|
||||
if (sleepMs <= 0) break;
|
||||
await new Promise((r) => setTimeout(r, sleepMs));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// #275 (OPR.0.6.5.5) — "my rig command can't reach the daemon": BLOCKED is not DOWN.
|
||||
//
|
||||
// The ONE blocked-connection guidance, shared by the transport render (cli-error.ts) and the
|
||||
// precheck guard (daemon-lifecycle.ts) so both entry points say the same thing. Kept apart from
|
||||
// both so neither depends on the other.
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const BLOCKED_CONNECTION_CODES = new Set(["EPERM", "EACCES"]);
|
||||
|
||||
/** True when THIS machine refused to make the connection (a sandbox or security policy), as
|
||||
* opposed to a daemon that refused it. The daemon may still be running. */
|
||||
export function isBlockedConnectionCode(code: string | undefined): code is string {
|
||||
return code !== undefined && BLOCKED_CONNECTION_CODES.has(code);
|
||||
}
|
||||
|
||||
const HELP_SECTION = "The agent is waiting for permission or can't reach the daemon";
|
||||
|
||||
/** Never advises starting a daemon, and names no access setting: whether a seat gets network
|
||||
* access is the user's choice, taught in the help guide section it routes to. The guide is the
|
||||
* offline copy, because `rig context get` itself needs the daemon this shell cannot reach. */
|
||||
export function blockedConnectionGuidance(
|
||||
code: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
helpPath: string | undefined = getOfflineHelpPath(),
|
||||
): { consequence: string; action: string } {
|
||||
const guide = helpPath
|
||||
? `the help guide at ${helpPath}, section "${HELP_SECTION}"`
|
||||
: `the help guide (https://www.openrig.dev/help/agents), section "${HELP_SECTION}"`;
|
||||
const blocked = `This machine blocked the connection (${code}), so the daemon may still be running.`;
|
||||
if (env.CODEX_SANDBOX_NETWORK_DISABLED === "1") {
|
||||
return {
|
||||
consequence: `${blocked} This shell runs in a Codex sandbox with network access off, which blocks the local OpenRig daemon (#275). The seat cannot change its own sandbox.`,
|
||||
action: `Do not start another daemon. Tell the user this seat cannot reach OpenRig from its sandbox; whether it gets network access is their choice. See ${guide}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
consequence: blocked,
|
||||
action: `Do not start another daemon. Check what restricts this shell's network access, such as a sandbox or security policy. See ${guide}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The help guide that ships with this CLI, readable without the daemon. `baseDir` is this
|
||||
* module's directory (`src/` in a checkout, `dist/` when installed). In a source checkout its
|
||||
* `docs/` wins, as in resolveDaemonPath; then the copy the package build places under
|
||||
* `daemon/docs/reference/`. An installed package (under `node_modules`) never looks outside
|
||||
* itself. Pure: `exists` is injected. */
|
||||
export function resolveOfflineHelpPath(baseDir: string, exists: (p: string) => boolean): string | undefined {
|
||||
const bundled = path.resolve(baseDir, "../daemon/docs/reference/help.md");
|
||||
const installed = path.resolve(baseDir).split(path.sep).includes("node_modules");
|
||||
const candidates = installed ? [bundled] : [path.resolve(baseDir, "../../../docs/reference/help.md"), bundled];
|
||||
return candidates.find((candidate) => exists(candidate));
|
||||
}
|
||||
|
||||
export function getOfflineHelpPath(): string | undefined {
|
||||
return resolveOfflineHelpPath(import.meta.dirname, existsSync);
|
||||
}
|
||||
@@ -54,13 +54,14 @@ export type ParsedFileArg =
|
||||
export type FileArgParse = { ok: true; arg: ParsedFileArg } | { ok: false; error: string };
|
||||
|
||||
const HOST_ID_SHAPE = /^[A-Za-z0-9_-]+$/;
|
||||
const DOTTED_HOST_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
const SSH_USER_SHAPE = /^[A-Za-z0-9._-]+$/;
|
||||
export const REMOTE_PATH_CHARSET = /^[A-Za-z0-9._/-]+$/;
|
||||
|
||||
/** The FR-4 short closed named deny list (dot-directory segments). */
|
||||
export const DENIED_SEGMENTS = [".openrig", ".ssh", ".codex", ".claude"] as const;
|
||||
|
||||
export function parseFilePathArg(raw: string): FileArgParse {
|
||||
export function parseFilePathArg(raw: string, isRegisteredHost: (id: string) => boolean = () => false): FileArgParse {
|
||||
if (raw === "") return { ok: false, error: "empty path operand" };
|
||||
if (raw.startsWith("-")) {
|
||||
// G18-P1 belt: a flag-shaped operand is refused outright; the ./ escape
|
||||
@@ -78,7 +79,10 @@ export function parseFilePathArg(raw: string): FileArgParse {
|
||||
if (colon >= 0) {
|
||||
const prefix = raw.slice(0, colon);
|
||||
const rest = raw.slice(colon + 1);
|
||||
if (HOST_ID_SHAPE.test(prefix)) {
|
||||
// Dotted prefixes have always been valid local filenames. Only a positive
|
||||
// registry match selects their remote meaning; undotted grammar stays lexical.
|
||||
const registeredDottedHost = prefix.includes(".") && DOTTED_HOST_ID_SHAPE.test(prefix) && isRegisteredHost(prefix);
|
||||
if (HOST_ID_SHAPE.test(prefix) || registeredDottedHost) {
|
||||
// Id-shaped prefix: this IS a host qualifier — it must resolve or the
|
||||
// command fails loudly (fail-closed; never a silent local fallback).
|
||||
if (rest === "") return { ok: false, error: `remote path missing after '${prefix}:' — expected ${prefix}:<absolute-path>` };
|
||||
@@ -221,9 +225,17 @@ function resolveRemoteSide(hostId: string, rawPath: string, deps: PlanDeps): { o
|
||||
/** Validate the whole invocation — EVERY rejection here happens before any
|
||||
* spawn (the fail-closed set; spawn-spy-asserted in tests). */
|
||||
export function planFileCopy(rawSrc: string, rawDst: string, opts: { dryRun?: boolean } & PlanDeps = {}): PlanResult {
|
||||
const srcParse = parseFilePathArg(rawSrc);
|
||||
// Use one registry snapshot for both classification and validation. A remote
|
||||
// choice must never turn into a local copy after a later resolution error.
|
||||
let registry: ReturnType<typeof loadHostRegistry> | undefined;
|
||||
const registryLoader = () => registry ??= (opts.registryLoader ?? loadHostRegistry)();
|
||||
const isRegisteredHost = (id: string) => {
|
||||
const loaded = registryLoader();
|
||||
return loaded.ok && resolveHost(loaded.registry, id).ok;
|
||||
};
|
||||
const srcParse = parseFilePathArg(rawSrc, isRegisteredHost);
|
||||
if (!srcParse.ok) return { ok: false, error: srcParse.error, code: "bad_operand" };
|
||||
const dstParse = parseFilePathArg(rawDst);
|
||||
const dstParse = parseFilePathArg(rawDst, isRegisteredHost);
|
||||
if (!dstParse.ok) return { ok: false, error: dstParse.error, code: "bad_operand" };
|
||||
|
||||
if (srcParse.arg.kind === "remote" && dstParse.arg.kind === "remote") {
|
||||
@@ -233,7 +245,7 @@ export function planFileCopy(rawSrc: string, rawDst: string, opts: { dryRun?: bo
|
||||
const sides: CopySide[] = [];
|
||||
for (const parsed of [srcParse.arg, dstParse.arg]) {
|
||||
if (parsed.kind === "remote") {
|
||||
const side = resolveRemoteSide(parsed.hostId, parsed.path, opts);
|
||||
const side = resolveRemoteSide(parsed.hostId, parsed.path, { registryLoader });
|
||||
if (!side.ok) return { ok: false, error: side.error, code: side.code };
|
||||
sides.push(side.side);
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# Progress — {{sliceName}}
|
||||
|
||||
> Durable acceptance state for this slice. In-process steps belong in the
|
||||
> working agent's todo tool. See the `mission-slice-sop` skill and the
|
||||
> conventions SSOT (`docs/reference/sdlc-conventions.md` in the repo,
|
||||
> Current state and retained outcomes for this slice. In-process steps belong
|
||||
> in the working agent's todo tool. For current acceptance under the selected
|
||||
> proof policy, run `rig proof show <slice>`; this log is not a second checklist.
|
||||
> See the `mission-slice-sop` skill and the conventions SSOT
|
||||
> (`docs/reference/sdlc-conventions.md` in the OpenRig source repository,
|
||||
> `$OPENRIG_HOME/reference/sdlc-conventions.md` when installed).
|
||||
|
||||
## Acceptance
|
||||
## Current state
|
||||
|
||||
- [ ] Implementation complete
|
||||
- [ ] Tests passing
|
||||
- [ ] Review approved
|
||||
[Record the current delivery state and the next action.]
|
||||
|
||||
## Outcomes
|
||||
|
||||
[Log material outcomes with links to the supporting proof. Retain historical entries.]
|
||||
@@ -69,6 +69,17 @@ export function nextMissionMembershipOrder(missionPath: string): number {
|
||||
/** Replace one or more already-validated manifests with rollback on write error. */
|
||||
export function applyMissionCompositionEdits(edits: MissionCompositionEdit[]): void {
|
||||
const unique = Array.from(new Map(edits.map((edit) => [edit.manifestPath, edit])).values());
|
||||
// Check the whole batch before staging so an already-stale plan cannot
|
||||
// overwrite another agent's authored membership or partially change a move.
|
||||
for (const edit of unique) {
|
||||
if (fs.readFileSync(edit.manifestPath, "utf8") !== edit.original) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission composition changed since this edit was planned: ${edit.manifestPath}.`,
|
||||
consequence: "Scope composition was not changed.",
|
||||
action: "Read the current mission.yaml and retry the scope operation.",
|
||||
});
|
||||
}
|
||||
}
|
||||
const staged: Array<{ edit: MissionCompositionEdit; temporary: string }> = [];
|
||||
const applied: MissionCompositionEdit[] = [];
|
||||
try {
|
||||
|
||||
@@ -85,10 +85,10 @@ export function isPristineScaffoldSection(body: string | null): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
/** The generic acceptance triple scaffolded by
|
||||
* `packages/cli/src/lib/scope-templates/slice-progress.md` — exact trimmed
|
||||
* literals, sync-tested against the shipped template so constant/template
|
||||
* drift fails CI instead of silently reviving the bogus pristine count. */
|
||||
/** The generic acceptance triple emitted by older slice-progress templates.
|
||||
* Keep recognizing these exact literals in existing PROGRESS.md files so
|
||||
* legacy scaffold marks never become authored acceptance evidence. New
|
||||
* templates emit an outcome log instead; existing files are not migrated. */
|
||||
export const GENERIC_SCAFFOLD_ACCEPTANCE: readonly string[] = [
|
||||
"Implementation complete",
|
||||
"Tests passing",
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "./dot-id.js";
|
||||
import { deriveMissionDependencyGraph, type MissionDependencyGraph } from "./scope-audit.js";
|
||||
|
||||
const FRONTMATTER_DELIM = "---\n";
|
||||
const SLICE_DIRNAME_RE = /^(\d+)-(.+)$/;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -35,21 +34,15 @@ export function splitFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
} {
|
||||
if (!content.startsWith(FRONTMATTER_DELIM)) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
const rest = content.slice(FRONTMATTER_DELIM.length);
|
||||
const endIdx = rest.indexOf(`\n${FRONTMATTER_DELIM.trim()}\n`);
|
||||
if (endIdx === -1) {
|
||||
// Allow trailing `---` without a final newline (last char of file).
|
||||
const endIdx2 = rest.indexOf(`\n---`);
|
||||
if (endIdx2 === -1) return { frontmatter: {}, body: content };
|
||||
const raw = rest.slice(0, endIdx2);
|
||||
return { frontmatter: parseYamlSafely(raw), body: rest.slice(endIdx2 + 4).replace(/^\n/, "") };
|
||||
}
|
||||
const raw = rest.slice(0, endIdx);
|
||||
const body = rest.slice(endIdx + `\n${FRONTMATTER_DELIM.trim()}\n`.length);
|
||||
return { frontmatter: parseYamlSafely(raw), body };
|
||||
const opening = /^---[ \t]*\r?\n/.exec(content);
|
||||
if (!opening) return { frontmatter: {}, body: content };
|
||||
const rest = content.slice(opening[0].length);
|
||||
const closing = /^---[ \t]*(?:\r?\n|(?![\s\S]))/m.exec(rest);
|
||||
if (!closing) return { frontmatter: {}, body: content };
|
||||
return {
|
||||
frontmatter: parseYamlSafely(rest.slice(0, closing.index)),
|
||||
body: rest.slice(closing.index + closing[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
function parseYamlSafely(raw: string): Record<string, unknown> {
|
||||
|
||||
@@ -195,6 +195,7 @@ export function parseClaudeTranscript(content: string): StructuredTranscript {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record === null || typeof record !== "object" || Array.isArray(record)) continue;
|
||||
const recordType = typeof record.type === "string" ? record.type : "";
|
||||
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
|
||||
|
||||
|
||||
Loaded 100 of 724 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user