mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
Compare commits
267
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6ba16882 | ||
|
|
f1ac0298d9 | ||
|
|
03a5f9158b | ||
|
|
2e4ea672b8 | ||
|
|
97ecaf2320 | ||
|
|
72d8544c33 | ||
|
|
cbb5d02d4d | ||
|
|
beea7133c7 | ||
|
|
aa0428474d | ||
|
|
50e3f23898 | ||
|
|
82baa90cac | ||
|
|
e23aa28cd7 | ||
|
|
02139f3052 | ||
|
|
8954094141 | ||
|
|
bb85aef8d4 | ||
|
|
1e1e720b41 | ||
|
|
56811ac0bd | ||
|
|
7075a755a2 | ||
|
|
254eaccdab | ||
|
|
c54235d27f | ||
|
|
7844bcaf02 | ||
|
|
ddad7027a0 | ||
|
|
3d071de8fa | ||
|
|
82ff95db72 | ||
|
|
b606c15806 | ||
|
|
0a32fa79ff | ||
|
|
7bf839736a | ||
|
|
8ca939aafc | ||
|
|
0b86022b73 | ||
|
|
0d425260d6 | ||
|
|
c66f0f93e1 | ||
|
|
877423d303 | ||
|
|
c8266a7299 | ||
|
|
e5c1c3a655 | ||
|
|
68d5884837 | ||
|
|
b1ed90c1bd | ||
|
|
47036178fe | ||
|
|
09ad0d3f5d | ||
|
|
719d615c1a | ||
|
|
9247849087 | ||
|
|
f2f374675e | ||
|
|
cb5d98401e | ||
|
|
b496528acb | ||
|
|
d94b8ce79f | ||
|
|
3f4567edab | ||
|
|
db3e139605 | ||
|
|
76583b21a4 | ||
|
|
2c5a9736c0 | ||
|
|
6ba0de56d4 | ||
|
|
aba207c689 | ||
|
|
9777da5664 | ||
|
|
7ecbe8d4e1 | ||
|
|
a9c1e8a5da | ||
|
|
b56f6dd6d0 | ||
|
|
cdca9c3d11 | ||
|
|
06d2c8914e | ||
|
|
3047856ac7 | ||
|
|
de408360c1 | ||
|
|
2a1ea97946 | ||
|
|
ea0e6ab12d | ||
|
|
fb427b9dec | ||
|
|
352845b215 | ||
|
|
6e874eb9ee | ||
|
|
7ef4df2c22 | ||
|
|
3a27a9d412 | ||
|
|
b88a168a9e | ||
|
|
27b9d801ee | ||
|
|
522cbd42ab | ||
|
|
74057337b6 | ||
|
|
b5ae76b474 | ||
|
|
b66095c2b2 | ||
|
|
53794fbe5a | ||
|
|
00d459dda5 | ||
|
|
dea5d5477e | ||
|
|
2295a1dc58 | ||
|
|
5f5bb4f363 | ||
|
|
6154fbdc9a | ||
|
|
6295d7f6aa | ||
|
|
7f45166b8f | ||
|
|
07775c00f4 | ||
|
|
1a31cb4dd2 | ||
|
|
f46fd24989 | ||
|
|
cd4c6dbe92 | ||
|
|
f17202429f | ||
|
|
929d697107 | ||
|
|
8d8e996d9d | ||
|
|
f29c950790 | ||
|
|
d2048cff8a | ||
|
|
a17990ed3b | ||
|
|
ce5567ab79 | ||
|
|
2a9ad780b5 | ||
|
|
41fc5c90b1 | ||
|
|
ff250f2fdc | ||
|
|
fa97ba530f | ||
|
|
fbb4d5a29a | ||
|
|
a4c18b37b2 | ||
|
|
bd04728936 | ||
|
|
a90c5bb715 | ||
|
|
de243d3086 | ||
|
|
8bb2d272df | ||
|
|
c491827bd7 | ||
|
|
9bcbbca868 | ||
|
|
10739b6108 | ||
|
|
008b84516d | ||
|
|
866b6c5c86 | ||
|
|
5fce62860b | ||
|
|
f7787c509d | ||
|
|
853d480db7 | ||
|
|
f0d2e7c736 | ||
|
|
da1703238e | ||
|
|
0a1809694d | ||
|
|
05d3542f35 | ||
|
|
90a32454a0 | ||
|
|
b73f79e2b2 | ||
|
|
8b35cf7266 | ||
|
|
e5ae69fefd | ||
|
|
de6476d826 | ||
|
|
26ece06051 | ||
|
|
70b3fee4f3 | ||
|
|
8d1867f5db | ||
|
|
8803544c12 | ||
|
|
63c3b583df | ||
|
|
db9619d520 | ||
|
|
8b2cb18112 | ||
|
|
94b2e30c74 | ||
|
|
584b830ff8 | ||
|
|
6883c24da7 | ||
|
|
503424aa09 | ||
|
|
4f4db0fe13 | ||
|
|
d87d1a85cc | ||
|
|
d7910f8980 | ||
|
|
941a2e3149 | ||
|
|
784222d30d | ||
|
|
6b21ce3c86 | ||
|
|
bc5ec05fce | ||
|
|
e5d61983ae | ||
|
|
a4b4b67dae | ||
|
|
e154a5cef1 | ||
|
|
c698838a46 | ||
|
|
a66e1488f1 | ||
|
|
164a67bef2 | ||
|
|
50b17c73d5 | ||
|
|
207ad5f1cb | ||
|
|
2f731b8410 | ||
|
|
58713e0c58 | ||
|
|
30fd7993dc | ||
|
|
eba8fbe4f6 | ||
|
|
236597084f | ||
|
|
4b6a541044 | ||
|
|
7865fd6bac | ||
|
|
f8b8dbe5bf | ||
|
|
9bf770e33f | ||
|
|
8eac96bcd4 | ||
|
|
43f2259ff7 | ||
|
|
ea03680de4 | ||
|
|
275873b507 | ||
|
|
79d06f8db9 | ||
|
|
64f8aa8bc6 | ||
|
|
ad6a522b69 | ||
|
|
a29c78834f | ||
|
|
5048842701 | ||
|
|
0c9e63f10e | ||
|
|
a0177ded00 | ||
|
|
dc4df12fc6 | ||
|
|
233dd16d72 | ||
|
|
79a89d4061 | ||
|
|
257b51b94a | ||
|
|
13d2adf992 | ||
|
|
e7a0b253b0 | ||
|
|
38f4206ed7 | ||
|
|
cb0bf7b943 | ||
|
|
3cd581e3c4 | ||
|
|
d491eca96e | ||
|
|
a0e7e0e196 | ||
|
|
329e2e228d | ||
|
|
16ebb8af07 | ||
|
|
d81456dc3d | ||
|
|
9f9ebe0a51 | ||
|
|
d43b7729f9 | ||
|
|
a2726e8f51 | ||
|
|
08a74ceacb | ||
|
|
1e4b229da0 | ||
|
|
da286abddd | ||
|
|
4f50d12735 | ||
|
|
e7e5192c18 | ||
|
|
1698f4e02a | ||
|
|
f6b2184940 | ||
|
|
76c5f0dbff | ||
|
|
5aec0ef529 | ||
|
|
5f410eeef7 | ||
|
|
2bf9c6606f | ||
|
|
595e9550d1 | ||
|
|
9de91ad43a | ||
|
|
0c13c7c661 | ||
|
|
f8bf875c84 | ||
|
|
1b9f15ee91 | ||
|
|
80f8f9f3b7 | ||
|
|
f7c5a477fa | ||
|
|
63ee7dd793 | ||
|
|
b0ae54ad57 | ||
|
|
0af652dca7 | ||
|
|
a660b47592 | ||
|
|
8752bfe972 | ||
|
|
8865ee6f4f | ||
|
|
97803a8fb3 | ||
|
|
5b00abf712 | ||
|
|
6401fe4be5 | ||
|
|
fbf92f9ce1 | ||
|
|
becce5e932 | ||
|
|
29a9027734 | ||
|
|
f10a19901a | ||
|
|
1fd11b762b | ||
|
|
77bed48ba6 | ||
|
|
6b81d3b6d5 | ||
|
|
adac0dcf51 | ||
|
|
3b7432e41c | ||
|
|
41fce1a5c9 | ||
|
|
82d397156f | ||
|
|
1b96a3b0b8 | ||
|
|
612be91d88 | ||
|
|
2e809917e9 | ||
|
|
ef659f0648 | ||
|
|
fc13c2be3e | ||
|
|
dc57b1e0c1 | ||
|
|
5264b19ae4 | ||
|
|
33547403bf | ||
|
|
d34c7a1aca | ||
|
|
33e6853bc2 | ||
|
|
7f4085e684 | ||
|
|
1aa0c5237e | ||
|
|
cfec550801 | ||
|
|
9ecd28b81c | ||
|
|
76edf9246a | ||
|
|
55e01f2e47 | ||
|
|
27e7fc1ab1 | ||
|
|
1cccc22678 | ||
|
|
168b97fa5f | ||
|
|
12d7faa79d | ||
|
|
fcd621a425 | ||
|
|
cef0dc543a | ||
|
|
ecd8dcc7f0 | ||
|
|
1909f7cf09 | ||
|
|
59f60cb637 | ||
|
|
4b8044816d | ||
|
|
7a12c930b6 | ||
|
|
02dfa8c4a9 | ||
|
|
07f4d35b2a | ||
|
|
e8c8298fc5 | ||
|
|
16b85d9c41 | ||
|
|
f3449bafdd | ||
|
|
777cd5b769 | ||
|
|
15b2526ec2 | ||
|
|
bb0ae3ccd7 | ||
|
|
0db030ce3f | ||
|
|
2adf3c6d84 | ||
|
|
2b4772c785 | ||
|
|
58bd1bb56b | ||
|
|
2b45c8efba | ||
|
|
c21252962e | ||
|
|
c77f5190a1 | ||
|
|
28a2b88a22 | ||
|
|
a3e3d4a0cb | ||
|
|
a28f9b710e | ||
|
|
92a993d37d | ||
|
|
2adf6a10a3 | ||
|
|
7eaf524cc4 | ||
|
|
1483215a45 |
No files matched your search
+881
@@ -8,6 +8,887 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.3.4] - 2026-06-15
|
||||
|
||||
**Status**: released. npm `@openrig/[email protected]` (latest); GitHub Release
|
||||
`v0.3.4`; git tag `v0.3.4`.
|
||||
|
||||
### Summary For Installing Agents
|
||||
|
||||
- **Package version**: package metadata bumped at release-manager step;
|
||||
CLI reports the new version after `npm publish`.
|
||||
- **Migrations**: no new schema-bumping migrations in 0.3.4. Existing
|
||||
databases upgrade by running `rig daemon start` on the new daemon.
|
||||
- **Node engines**: unchanged from 0.3.3 (CLI accepts Node `>=20`).
|
||||
- **Backward compatibility**: existing CLI argument shapes, daemon
|
||||
route paths, RigSpec/AgentSpec schemas, and persisted settings
|
||||
remain backward compatible. New routes are additive
|
||||
(`POST /api/rigs/:id/up` plan / apply path is the same path 0.3.3
|
||||
shipped; `POST /api/sessions/:session/reconcile` is new;
|
||||
`POST /api/sessions/:session/clear-attention` is new;
|
||||
`POST /api/rigs/:rigId/nodes/:nodeRef/launch` and
|
||||
`POST /api/rigs/:rigId/nodes/launch-subset` are new). New CLI
|
||||
commands (`rig start`, `rig reconcile-session`,
|
||||
`rig seat clear-attention`) are additive. `rig up` flips from
|
||||
fresh-by-default to resume-original-by-default — callers that
|
||||
implicitly relied on fresh-prime as the default must now name
|
||||
`--fresh <seats...>` explicitly.
|
||||
|
||||
### `rig start` — Recovery Entry Point (slice 01)
|
||||
|
||||
New top-level command. Sequences existing primitives: daemon-start
|
||||
-> kernel auto-boot wait -> candidate listing -> picker / flags ->
|
||||
per-rig restore (`/api/rigs/:id/up`) + reconcile:
|
||||
|
||||
```
|
||||
rig start Interactive: daemon + kernel + pick-and-restore
|
||||
rig start --last Headless: restore all rigs that were last running
|
||||
rig start --all Headless: restore all rigs with restore-usable snapshots
|
||||
rig start --rigs <name> [<name>...] Headless: restore only the named rigs
|
||||
rig start --json JSON output for agents
|
||||
```
|
||||
|
||||
- **Id-grounded end-to-end**: candidate preview and apply both go
|
||||
through `POST /api/rigs/:id/up`. Same-name rigs surface as
|
||||
separate candidates; selection carries `rigId` so the name-based
|
||||
`/api/up` route is never consulted in the recovery path (no
|
||||
`ambiguous_name` 409).
|
||||
- **Re-codes nothing**: same `/api/rigs/:id/up` that
|
||||
`rig up <existing>` uses; same five-term vocabulary on the
|
||||
seat-level outcome.
|
||||
|
||||
### `rig up` — Resume Original By Default (slice 02)
|
||||
|
||||
`rig up <existing-rig>` resumes original sessions by default.
|
||||
`--fresh <seats...>` is now the explicit per-seat opt-in for
|
||||
operation B (deliberate fresh-prime):
|
||||
|
||||
- **Default**: seats resume; outcome reports `resumed`.
|
||||
- **`--fresh <logicalId> [<logicalId>...]`**: deliberate fresh-
|
||||
prime for the named seats; outcome reports `fresh-primed`.
|
||||
- **`awaiting-decision`**: when the daemon cannot resume and the
|
||||
operator has not opted into fresh. TTY callers get a per-seat
|
||||
`[y/N]` prompt; headless callers get the explicit hint
|
||||
`rig up --existing <source> --fresh <logicalId>`. ZERO session
|
||||
started for `awaiting-decision` seats.
|
||||
- **Backward-incompatible default flip**: callers that implicitly
|
||||
depended on fresh-prime as the default must now name `--fresh`
|
||||
explicitly. The `--existing` flag (treat `<source>` as a rig
|
||||
name) is unchanged.
|
||||
|
||||
### `rig reconcile-session` — Adopt a Hand-Resumed Session (slice 03)
|
||||
|
||||
New top-level command. Adopts a LIVE, hand-resumed canonical
|
||||
session back into its persisted node without launching:
|
||||
|
||||
```
|
||||
rig reconcile-session <session>
|
||||
rig reconcile-session <session> --rig <rigId> --node <logicalId>
|
||||
rig reconcile-session <session> --no-launch
|
||||
rig reconcile-session <session> --json
|
||||
```
|
||||
|
||||
- **NEVER launches / relaunches / kills / replays startup / presses
|
||||
resume menus / compacts / types into the pane.** The only mode
|
||||
this command has; `--no-launch` is accepted for explicitness.
|
||||
- **Same node id, no re-key**: the live process binds back to its
|
||||
OWN persisted node.
|
||||
- **Honest reporting**: projection drift is a list of unproven
|
||||
metadata fields; conversation continuity is reported as a status
|
||||
string, never claimed as proven.
|
||||
- **Daemon route**: `POST /api/sessions/:session/reconcile`.
|
||||
|
||||
### `rig up --plan` — Read-Only Restore Preview (slice 04)
|
||||
|
||||
```
|
||||
rig up <existing-rig> --plan [--json]
|
||||
```
|
||||
|
||||
- **No mutation**: returns the restore plan only.
|
||||
- **Per-node `intendedAction`**: `resume` / `fresh-prime` /
|
||||
`awaiting-decision` keyed to the five-term vocabulary.
|
||||
- **Snapshot the plan would consume**: surfaced in the response so
|
||||
the operator can verify the floor before apply.
|
||||
- **Honest async timeout**: when the preview cannot complete
|
||||
within bound, the response says so rather than claiming a clean
|
||||
plan.
|
||||
|
||||
### Pod-Aware Claude Resume-Selection Menu (slice 05 rev1 BLOCKING)
|
||||
|
||||
`ClaudeCodeAdapter.verifyResumeLaunch` now treats a Claude
|
||||
resume-selection menu the same way it treats the Codex
|
||||
unresolved-gate case (the 0.3.3 slice 21 FR-2 pattern):
|
||||
|
||||
- Returns immediately with `recovery: attention_required` and
|
||||
last-12-line pane evidence on BOTH the inner poll loop and the
|
||||
final-probe exit path.
|
||||
- ZERO numeric selection keystrokes are ever sent.
|
||||
- Routes into the existing `HarnessLaunchResult` ->
|
||||
startup-orchestrator `startupStatus: attention_required` ->
|
||||
`ps` / `status` projection — same path the Codex case uses.
|
||||
|
||||
### Five-Term Restore Status Vocabulary (slice 06)
|
||||
|
||||
The seat-level status on `rig up`, `rig restore`, and `rig ps` is
|
||||
now ONE of:
|
||||
|
||||
- **`resumed`** — original session continuity proven by the
|
||||
native-resume probe.
|
||||
- **`fresh-primed`** — operator opted into deliberate fresh-prime
|
||||
(operation B); a brand new session started instead of resumed.
|
||||
- **`awaiting-decision`** — daemon could not resume and operator
|
||||
has not opted into fresh. ZERO session started. The honest
|
||||
zero-session state.
|
||||
- **`attention_required`** — seat is live or parked but needs
|
||||
operator action (auth gate, model-selection menu, trust prompt,
|
||||
stuck recovery). NEVER reported as `failed`.
|
||||
- **`failed`** — the launch transport itself failed.
|
||||
|
||||
Replaces the prior 2-3 term collapsed model that hid edge cases.
|
||||
`awaiting-decision` is new and replaces the prior pattern where
|
||||
zero-session outcomes were either misreported as success or as
|
||||
failure.
|
||||
|
||||
### Codex Profile-V2 Preflight (slice 07 rev1 BLOCKING)
|
||||
|
||||
Profile-load check on profile-bearing launch and restore
|
||||
surfaces, with narrowed legacy-profile detection:
|
||||
|
||||
- **Legacy detection requires legacy-specific patterns only**:
|
||||
`contains legacy`, `[profiles.<name>]`, `cannot be used while.*
|
||||
legacy`, `legacy profile selector`. The prior generic match
|
||||
against `failed to load configuration` was also catching
|
||||
invalid-TOML stderr and giving the wrong migration hint.
|
||||
- **Invalid TOML surfaces the parse reason**: up to 3 stderr
|
||||
lines (e.g. `expected newline`) with a generic TOML-validity
|
||||
hint instead of a wrong hint pointing at
|
||||
`[profiles.<profile>]`.
|
||||
- **Stale comment in `codex-runtime-adapter.ts` corrected**:
|
||||
absent `.config.toml` passes (Option B), not fails.
|
||||
|
||||
### cmux Launch Readiness (slice 08)
|
||||
|
||||
Launch path no longer silently produces a partial cmux workspace:
|
||||
|
||||
- **Honest partial-workspace state**: when cmux comes up with a
|
||||
subset of expected windows / panes, the seat surfaces an honest
|
||||
partial state instead of being projected as launched-clean.
|
||||
- **One-click open-missing affordance**: UI surfaces the one-click
|
||||
path to open the missing workspace pieces.
|
||||
|
||||
### Periodic Snapshots (slice 09)
|
||||
|
||||
`PeriodicSnapshotScheduler` ships as the crash-insurance floor
|
||||
under event-driven and teardown snapshots:
|
||||
|
||||
- **`snapshots.periodic.enabled`** (default `true`): master
|
||||
switch.
|
||||
- **`snapshots.periodic.interval_seconds`** (default `300`):
|
||||
per-rig snapshot interval.
|
||||
- **`snapshots.periodic.retention_keep`** (default `10`):
|
||||
per-rig `auto-periodic` retention count.
|
||||
- **`auto-periodic` snapshot kind**: captured per enabled rig on
|
||||
interval; pruned by `retention_keep`.
|
||||
- **Restore selector**: treats `auto-periodic` and `auto-pre-down`
|
||||
symmetrically — the newer wins. A daemon crash between teardown
|
||||
snapshots no longer loses arbitrary lifecycle state.
|
||||
- **`rig ps` / status**: surfaces last-snapshot / floor so the
|
||||
operator can see the crash-insurance floor at a glance.
|
||||
|
||||
### `rig seat clear-attention` (slice 10)
|
||||
|
||||
New subcommand on `rig seat`. Evidence-gated, operator-attested,
|
||||
audited reconcile of a stuck `attention_required` seat back to
|
||||
`ready`:
|
||||
|
||||
```
|
||||
rig seat clear-attention <session>
|
||||
rig seat clear-attention <session> --reason "<operator attestation>"
|
||||
rig seat clear-attention <session> --json
|
||||
```
|
||||
|
||||
- **Without `--reason`**: clear requires daemon-side evidence the
|
||||
seat is back to a clean state.
|
||||
- **`--reason <text>`**: operator-attestation override of the
|
||||
evidence gate; the attestation string lands in the audit row
|
||||
verbatim.
|
||||
- **Daemon route**: `POST /api/sessions/:session/clear-attention`.
|
||||
- **Output**: `Cleared <session>: <from> -> ready (<clearedBy>)`.
|
||||
- **Replaces** the hand-edit-SQLite / fake-clear pattern. Node id
|
||||
is unchanged.
|
||||
|
||||
### Node-Granular Managed Partial Restore (slice 11)
|
||||
|
||||
`rig launch` relaunches a seat or subset by logical id through
|
||||
orchestration:
|
||||
|
||||
```
|
||||
rig launch <rigId> <nodeRef> Single seat
|
||||
rig launch <rigId> --seats <a,b,c> Subset (comma-separated)
|
||||
rig launch <rigId> --seats <a,b> --hold-reason "<text>"
|
||||
rig launch <rigId> --seats <a,b> --json
|
||||
```
|
||||
|
||||
- **Single target**:
|
||||
`POST /api/rigs/:rigId/nodes/:nodeRef/launch`.
|
||||
- **Subset**: `POST /api/rigs/:rigId/nodes/launch-subset`.
|
||||
- **Partial outcomes never collapsed**: `launched`, `held` (with
|
||||
reason), `alreadyRunning`, and `failedTargets` (liveness
|
||||
unknown) reported separately.
|
||||
- **Retires** the v0.3.x-era `pod_aware_launch_unsupported`
|
||||
dead-end and the "use `rig up` instead" workaround that lost
|
||||
per-seat control.
|
||||
|
||||
### Slow Codex Resume Classification (slice 13)
|
||||
|
||||
Internal classification tweak in `verifyResumeLaunch` for slow
|
||||
but genuinely-resuming Codex sessions:
|
||||
|
||||
- Previously the slow path mis-categorized through the
|
||||
unresolved-gate branch.
|
||||
- Now correctly classifies through the five-term vocabulary and
|
||||
reaches `resumed` when the readiness probe confirms.
|
||||
|
||||
### What to STOP Using
|
||||
|
||||
- `rig up <existing-rig>` is no longer fresh-by-default. Use
|
||||
`--fresh <seats...>` for deliberate fresh-prime.
|
||||
- A live or parked seat is NEVER `failed`. Use
|
||||
`attention_required`. The honest zero-session state is
|
||||
`awaiting-decision`.
|
||||
- `pod_aware_launch_unsupported` / "use `rig up` instead" for
|
||||
per-seat relaunch is RETIRED. Use `rig launch <rigId> <nodeRef>`
|
||||
or `rig launch <rigId> --seats <a,b>`.
|
||||
- Hand-editing SQLite or fake-clearing a stuck
|
||||
`attention_required` seat is RETIRED. Use
|
||||
`rig seat clear-attention <session>` (evidence-gated) or
|
||||
`rig seat clear-attention <session> --reason "<attestation>"`
|
||||
(operator-attested, audited).
|
||||
- Relying only on event-driven or teardown snapshots for crash
|
||||
safety is RETIRED. The periodic snapshot scheduler is the
|
||||
floor; tune `snapshots.periodic.*` to taste.
|
||||
- Classifying invalid TOML as a legacy-profile problem is
|
||||
RETIRED. The Codex profile-v2 preflight surfaces the actual
|
||||
parse reason.
|
||||
- Restoring a hand-resumed session by relaunching the whole rig
|
||||
is RETIRED. Use `rig reconcile-session <session> --no-launch`.
|
||||
|
||||
### Known Limitations / 0.3.4 Carry-forwards
|
||||
|
||||
- **`rig view list/show --json` flag inconsistency** —
|
||||
wrapper-layer routing path remains; the daemon's
|
||||
`view show <name>` route returns JSON correctly when invoked
|
||||
directly. Workaround: human-readable output for now.
|
||||
- **`docs/DESIGN.md` docs-guard ledger** — design-doc update
|
||||
ledger carries forward; no runtime impact.
|
||||
- **Slice-21 FR-1 (native Codex session-id hook)**: scope-tipped
|
||||
to 0.4.0 after build-time forensic. Carried forward from 0.3.3.
|
||||
- **Slice-13 component 3 (auto-rollout dispatcher)**: skill-layer
|
||||
landing remains; product-code dispatcher / invocation is
|
||||
intentionally out of the shipped runtime bundle. Carried
|
||||
forward from 0.3.3.
|
||||
- **Slice-05 sub-scopes carried forward**: agent / port /
|
||||
managed-app collision detection (Item 4.3); broader
|
||||
install-into-existing-rig pathway acceptance (Item 4.4);
|
||||
`--target-name` CLI flag for install-time rig-name overrides.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema**: deferred from
|
||||
0.3.2; carried forward.
|
||||
- **Onboarding journey + battle-hardening**: slices 04
|
||||
(new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) carried forward to a later release.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots remains a
|
||||
follow-up; non-blocking for 0.3.4.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental`
|
||||
only; not split-deferred (stays held).
|
||||
- **0.3.4 host-upgrade flow held**: operators installing v0.3.4
|
||||
fresh from npm have full access immediately; existing-host
|
||||
upgrade follows the standard daemon-restart flow. The named
|
||||
host-upgrade flow is held for separate sequencing.
|
||||
|
||||
### Quick Verification Commands
|
||||
|
||||
```bash
|
||||
# Confirm CLI version after the release-manager version bump
|
||||
rig --version
|
||||
|
||||
# Confirm daemon starts cleanly (no new migrations to apply in 0.3.4)
|
||||
rig daemon start
|
||||
|
||||
# Confirm rig start surface is wired
|
||||
rig start --help
|
||||
|
||||
# Confirm rig up --plan + --fresh are wired
|
||||
rig up --help | grep -E "(plan|fresh)"
|
||||
|
||||
# Confirm rig reconcile-session is wired
|
||||
rig reconcile-session --help
|
||||
|
||||
# Confirm rig seat clear-attention is wired
|
||||
rig seat clear-attention --help
|
||||
|
||||
# Confirm rig launch supports single-seat + subset relaunch
|
||||
rig launch --help
|
||||
|
||||
# Confirm five-term vocabulary on rig ps
|
||||
rig ps --help | grep -i attention
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.3.3] - 2026-06-11
|
||||
|
||||
**Status**: released. npm `@openrig/[email protected]` (latest); GitHub Release
|
||||
`v0.3.3`; git tag `v0.3.3`.
|
||||
|
||||
### Summary For Installing Agents
|
||||
|
||||
- **Package version**: package metadata bumped at release-manager step;
|
||||
CLI reports the new version after `npm publish`.
|
||||
- **Migrations**: one new migration in 0.3.3 — `042_rig_archive`
|
||||
(`ALTER TABLE rigs ADD COLUMN archived_at TEXT` + `idx_rigs_archived`;
|
||||
append-only, non-destructive, no rigs-row restructure). Existing
|
||||
databases upgrade by running `rig daemon start`.
|
||||
- **Node engines**: unchanged from 0.3.2 (CLI accepts Node `>=20`).
|
||||
- **Backward compatibility**: existing CLI argument shapes, daemon
|
||||
route paths, RigSpec/AgentSpec schemas, and persisted settings
|
||||
remain backward compatible. New routes are additive
|
||||
(`/api/rigs/:id/archive`, `/api/rigs/:id/unarchive`,
|
||||
`/api/rigs/:rigId/pods/:podNamespace/members`). New CLI commands
|
||||
(`rig archive`, `rig unarchive`, `rig add` / `rig add-member`) are
|
||||
additive. New `rig ps`, summary, and `rig up` flags
|
||||
(`--include-archived`, `?includeArchived=true`, `?archived=only`)
|
||||
are additive and default to existing exclude-archived behavior.
|
||||
|
||||
### Large Startup-File Transport (slice 16)
|
||||
|
||||
`TmuxAdapter.sendText` no longer caps at the OS `MAX_ARG_STRLEN`
|
||||
limit:
|
||||
|
||||
- **Payloads over 100KB** are written to a unique temp file (Node
|
||||
`fs`, never shell-embedded), loaded into a unique tmux buffer,
|
||||
and pasted with `paste-buffer -d -r`. `-r` preserves raw LF so
|
||||
multi-line packs do not early-submit on every newline; `-d`
|
||||
drops the buffer on successful paste. The single trailing C-m
|
||||
stays the caller's separate `sendKeys(["C-m"])`.
|
||||
- **Payloads under 100KB** keep the exact inline
|
||||
`tmux send-keys -t <t> -l <text>` command (behavior-preserving).
|
||||
- **Cleanup**: temp file unlinked in `finally`; explicit
|
||||
`delete-buffer` on paste-error path; unique temp + buffer names
|
||||
per call so parallel `rig up` seats do not collide.
|
||||
- **Backward-compatible constructor**: optional second arg supplies
|
||||
the file/buffer ops (default wires Node `fs` + `os.tmpdir`);
|
||||
`new TmuxAdapter(exec)` is unchanged.
|
||||
|
||||
### `send_text` Inline Dash Sentinel (slice 17)
|
||||
|
||||
Inline `tmux send-keys -t <t> -l <text>` now carries a `--`
|
||||
end-of-options sentinel:
|
||||
|
||||
- Any small `send_text` startup payload whose content begins with
|
||||
`-` (notably `---` YAML frontmatter — the norm for per-seat
|
||||
packs) is no longer parsed by tmux as flags.
|
||||
- Inert for non-dash content.
|
||||
- The slice-16 large/buffer path is already immune (content
|
||||
travels via a file, never argv) and is untouched.
|
||||
|
||||
### cmux 0.64.x Compatibility (slice 18)
|
||||
|
||||
`surface.list` normalizer resolves the surface handle across cmux
|
||||
0.64.x AND 0.63.x:
|
||||
|
||||
- cmux 0.64.x renamed the surface identifier: `list-panels --json`
|
||||
rows carry `ref` with no `id` (0.63.x carried `id`). Result:
|
||||
`surfaceId` came back undefined and cmux-transport never mapped
|
||||
`surface.sendText` -> `cmux send`, throwing
|
||||
"Unknown cmux method: surface.sendText" (HTTP 500
|
||||
`build_workspace_failed`).
|
||||
- New `normalizeSurfaceRow` resolves the handle from
|
||||
`ref ?? surface_ref ?? surface_id ?? id`, normalizing every
|
||||
row regardless of array key (`panels` / `pane_surfaces` /
|
||||
`surfaces`).
|
||||
- One resolution order serves both versions — no
|
||||
version-negotiation shim. Mirrors the existing
|
||||
`normalizeWorkspaceRow` and create/split handle patterns.
|
||||
|
||||
### `rig archive` Affordance (slice 19)
|
||||
|
||||
New top-level commands and a non-destructive archive lifecycle:
|
||||
|
||||
```
|
||||
rig archive <rigId> [--force] [--json]
|
||||
rig unarchive <rigId> [--json]
|
||||
```
|
||||
|
||||
- **Non-destructive**: rig row preserved; `archived_at` set;
|
||||
`rig.archived` event fires. `unarchive` clears `archived_at`
|
||||
and fires `rig.unarchived`.
|
||||
- **Default reads exclude archived**: `rig ps`,
|
||||
`/api/rigs/summary`, `/api/rigs`, `/api/ps`. Opt-in via
|
||||
`rig ps --include-archived` / `?includeArchived=true` /
|
||||
`?archived=only`. `rig ps` marks archived rows with `*` and
|
||||
renders a legend; the flag propagates through cross-host argv.
|
||||
- **`rig up` archived-name refusal (AC-7)**: a name matching ONLY
|
||||
an archived rig is refused with a 3-part error pointing at
|
||||
`rig unarchive`; `--json` emits `rig_archived`. Never silently
|
||||
restores.
|
||||
- **`--force` on archive**: required when a rig is running or
|
||||
degraded; surfaces a 3-part fact / consequence / action error
|
||||
without it (AC-6).
|
||||
- **UI**: lazy collapsible "Archive" section under the localhost
|
||||
host node, fed by `useArchivedRigs` (separate query against
|
||||
`/api/rigs/summary?archived=only`), not by client-side
|
||||
filtering. `rig.archived` / `rig.unarchived` events drive
|
||||
global query invalidation (`["rigs","summary"]` +
|
||||
`["rigs","summary","archived"]` + `["ps"]` + `["nodes", rigId]`
|
||||
when present) so a CLI archive reactively refreshes other
|
||||
mounted UIs.
|
||||
- **Migration `042_rig_archive`**: `ALTER TABLE rigs ADD COLUMN
|
||||
archived_at TEXT` + `idx_rigs_archived`. Append-only; mirrors
|
||||
the `023_stream_items` precedent.
|
||||
|
||||
### `rig add` / `rig add-member` (slice 24)
|
||||
|
||||
New top-level command for the `add_member` converge op:
|
||||
|
||||
```
|
||||
rig add <rigId> <podNamespace> <member-fragment-path> \
|
||||
[--json] [--rig-root <root>]
|
||||
```
|
||||
|
||||
- **Member fragment**: YAML or JSON; tolerates a bare member or a
|
||||
`{ member }` wrapper; uses the spec snake_case field names
|
||||
(`id`, `runtime`, `agent_ref`, `profile`, `cwd`, ...).
|
||||
- **Daemon route**:
|
||||
`POST /api/rigs/:rigId/pods/:podNamespace/members`. Outcome ->
|
||||
HTTP: `rig_not_found` / `pod_not_found` -> 404,
|
||||
`member_conflict` -> 409, `validation_failed` /
|
||||
`preflight_failed` -> 400, success -> 201. Per-node launch
|
||||
status (`launched` / `failed` / `attention_required`) rides in
|
||||
the 201 body.
|
||||
- **MCP tool**: `rig_add` ships in lockstep.
|
||||
- **Honest edge validation**: non-array `edges` is REJECTED
|
||||
(CLI prints error + exits 1; route returns 400
|
||||
`validation_failed`; domain returns `validation_failed` as
|
||||
defense in depth). Edge `kind` is validated against the
|
||||
canonical `VALID_EDGE_KINDS` set exported from
|
||||
`rigspec-schema.ts` and reused (not duplicated). Edges still
|
||||
carry NO runtime behavior (the edge-runtime fence holds).
|
||||
- **Built on the topology-converge spine**: `Op` union + differ +
|
||||
`convergeOp` scaffold (AC-6); `rig add` is imperative CLI
|
||||
sugar over the converge interface, not a bypass.
|
||||
|
||||
### `rig down` Accepts Name-or-Id (slice 22)
|
||||
|
||||
`rig down <name>` now works:
|
||||
|
||||
- `/api/rigs/summary?includeArchived=true` is fetched; id-match
|
||||
across ALL rigs (archived ids still reach teardown);
|
||||
name-match ACTIVE rigs only.
|
||||
- Same-name active+archived pair resolves the ACTIVE rig (not
|
||||
ambiguous).
|
||||
- Archived-only name does not resolve by name (use the id, or
|
||||
`rig unarchive` first).
|
||||
- The packaged `openrig-user` skill is corrected across specs
|
||||
source + assets/plugins copy + `_canonical` mirror; the
|
||||
`down.ts` resolver comment is corrected. The historical v0.3.1
|
||||
and v0.3.2 CHANGELOG entries and release notes are left intact
|
||||
(the bug genuinely existed at those releases).
|
||||
|
||||
### Honest Codex Restore Gate (slice 21 FR-2)
|
||||
|
||||
`verifyResumeLaunch` no longer returns `ok:true` unless the
|
||||
native-resume-probe proves the seat actually resumed:
|
||||
|
||||
- **Unresolved operator-action gates** (update that cannot
|
||||
auto-dismiss, trust, model-selection) and a bounded poll that
|
||||
never reaches `resumed` return `ok:false` with
|
||||
`recovery: attention_required` plus last-12-line pane evidence.
|
||||
- **Routes into existing projection**: `HarnessLaunchResult` ->
|
||||
startup-orchestrator `startupStatus: attention_required` ->
|
||||
`ps` / `status` projection. Same path the Codex auth-refusal
|
||||
case already shipped; no new state machinery.
|
||||
- **Auto-dismiss preserved**: a skippable update gate still
|
||||
auto-dismisses and continues to success; only UNRESOLVED gates
|
||||
fail loudly. The readiness loop (`checkReady`) stays the
|
||||
SECOND check that upgrades the seat to `ready` once it
|
||||
genuinely reaches the TUI.
|
||||
- **Carry-forward**: FR-1 (native Codex session-id hook) scope-
|
||||
tipped to 0.4.0 after build-time forensic; FR-2 ships alone in
|
||||
0.3.3.
|
||||
|
||||
### `rig send --verify` Honest Delivery Outcomes (slice 99.0.6.3)
|
||||
|
||||
The three outcomes are now named in the response:
|
||||
|
||||
- **`delivered`**: `ok:true` + post-capture re-confirmed the
|
||||
snippet (the prior `Verified: yes`).
|
||||
- **`rendered-unconfirmed`**: text + Enter both succeeded but
|
||||
the capture could not re-confirm (redraw race, or the capture
|
||||
threw). LANDED, NOT failure. Exit stays clean.
|
||||
- **`failed`**: the transport itself failed (`send_failed` /
|
||||
`submit_failed` carry `outcome: "failed"`; HTTP mapping
|
||||
unchanged).
|
||||
|
||||
The legacy `Verified: yes/no` line is preserved verbatim
|
||||
(parsers); a new `Delivery:` line carries the named outcome plus,
|
||||
for `rendered-unconfirmed`, a `rig capture <session>`
|
||||
confirmation pointer. `--json` carries `outcome` through.
|
||||
Mid-work / wait-for-idle REFUSALS deliberately carry no outcome
|
||||
(nothing was sent).
|
||||
|
||||
### `rig whoami` Peers Contract (slice 99.0.6.1)
|
||||
|
||||
`peers[]` is this rig's roster EXCLUDING self. It is NOT a
|
||||
directionally-edged subset, and it is NOT host inventory.
|
||||
|
||||
- **`WhoamiResult.peersNote`** (required string, additive)
|
||||
carries the contract in-band, naming the three pointers:
|
||||
`peers[]` (roster), `edges{}` (directional graph),
|
||||
`rig ps --nodes` (inventory including self + live state).
|
||||
- **CLI Peers header**: keeps the literal `Peers:` prefix
|
||||
verbatim (shipped parsers grep on it), then adds the
|
||||
in-band clarifier.
|
||||
- **No new field**: `peers[]` name and shape are unchanged; no
|
||||
`roster` / `podRoster` field is added (peers[] already IS the
|
||||
roster).
|
||||
|
||||
### Workflow Instantiate By Name (slice 04.1)
|
||||
|
||||
`rig workflow instantiate <built-in-name>` (e.g. `conveyor`,
|
||||
`basic-loop`) now resolves end-to-end:
|
||||
|
||||
- `WorkflowRuntime.instantiate` resolves the identifier against
|
||||
the seeded spec cache BY NAME first via the new
|
||||
`WorkflowSpecCache.resolveSourcePathByName`, falling back to
|
||||
literal-sourcePath only when no named spec matches.
|
||||
- The cache returns the STORED path verbatim — no source-tree
|
||||
re-derivation — so the `dist/builtins` production layout
|
||||
stays safe.
|
||||
- Diagnostic rows are excluded via `version != ''` (valid specs
|
||||
always carry a version) so resolution needs no dependency on
|
||||
the slice-11 status column / migration.
|
||||
|
||||
### Guided Golden Path State Clarity (slice 04.2)
|
||||
|
||||
The new-operator golden path now points at the correct discovery
|
||||
verb:
|
||||
|
||||
- `rig workflow specs` lists built-in / seeded workflow specs
|
||||
(`(built-in)` tagged). USE THIS to discover names before
|
||||
`rig workflow instantiate <name>`.
|
||||
- `rig workflow list` lists workflow INSTANCES; empty for a
|
||||
fresh operator.
|
||||
|
||||
Corrected in `docs/reference/getting-started.md` and in the
|
||||
CLI's `setup` golden-path text (`goldenPathNextSteps()` step 4).
|
||||
|
||||
### For-You Manage-By-Exception (slice 20)
|
||||
|
||||
Feed-interaction UX on existing signals only (the 0.4.0
|
||||
attention-detection layer is untouched):
|
||||
|
||||
- **Drill-to-terminal**: new `FeedCardTerminalDrill` in the card
|
||||
footer opens the resolved source/author session's terminal
|
||||
PREVIEW via `GET /api/sessions/:sessionName/preview`,
|
||||
reusing `TerminalPreviewPopover` over the same
|
||||
externally-owned-trigger event contract `TopologyTerminalView`
|
||||
uses. Session-NAME keyed ONLY. Honest framing — "terminal
|
||||
preview" / "captured snapshot, not live"; disabled with an
|
||||
honest title when no session resolves.
|
||||
- **Decision-band sort**: `feed-classifier` exports
|
||||
`sortFeedByDecisionBand` — a stable two-band partition
|
||||
(action-required + approval above progress / observation /
|
||||
shipped) using the existing newest-first comparator within
|
||||
each band. Applied at the single `Feed.tsx` merge-consumption
|
||||
seam. Not a ranking engine.
|
||||
- **One-click approve (approve-only)**: `VerbActions` gains an
|
||||
additive `oneClickVerbs` prop. APPROVE submits directly on
|
||||
click; deny/route stay select+confirm. Defense in depth:
|
||||
prop type narrowed to
|
||||
`Array<Extract<MissionControlVerb, "approve">>` so misuse is a
|
||||
compile error, and a runtime `ONE_CLICK_SAFE_VERBS = {"approve"}`
|
||||
allowlist rejects cast-forced `"deny"` / `"route"`. Reuses the
|
||||
same `performSubmit` path (identical optimistic receipt and
|
||||
held-error behavior).
|
||||
|
||||
### CLI Release-Surface Parser (slice 13.1)
|
||||
|
||||
Deterministic TypeScript Compiler API extractor that walks
|
||||
Commander registrations in `packages/cli/src/commands/*.ts` at
|
||||
two git refs and emits a structured release-surface-diff:
|
||||
|
||||
- Resolves both Commander idioms (chained inline subcommands
|
||||
and factory indirection through `addCommand(buildChildCommand())`).
|
||||
- Emits the REGISTRATION name (`rig-policy.ts` surfaces as
|
||||
`policy`); option name-tokens taken from `.option()` arg[0]
|
||||
only so template-literal descriptions never drop an option.
|
||||
- Reads batched via one `git cat-file --batch` per ref (fast,
|
||||
offline, deterministic).
|
||||
- 3-part honest failure shape; never a silent empty diff.
|
||||
- NOT registered as a `rig` verb at 0.3.3 — the module is
|
||||
product code with hermetic test fixtures (including a
|
||||
v0.3.1..v0.3.2 worked example checked in at
|
||||
`src/release-surface/release-surface-diff.v0.3.1-v0.3.2.yaml`).
|
||||
|
||||
### Skill <-> CLI-Surface Binding Index (slice 13.2)
|
||||
|
||||
Deterministic offline lookup that joins a release surface-diff
|
||||
to "which skills are affected by this release":
|
||||
|
||||
- Composes with the slice 13.1 parser; invocation-agnostic.
|
||||
- Implements the ratified join grammar: component-wise prefix
|
||||
match in either direction (so `up` does not match `update`);
|
||||
conservative over-include bias for the no-false-negative floor.
|
||||
- Drops `--version` from the binding index (the canonical grammar
|
||||
is command paths only, not global flags); the skill body still
|
||||
documents `rig --version` as prose.
|
||||
- Ships with the v0.3.2-affected-skills regression fixture
|
||||
derived from the corpus.
|
||||
|
||||
### Known Limitations / 0.3.4+ Deferrals
|
||||
|
||||
- **Slice-21 FR-1 (native Codex session-id hook)**: scope-tipped
|
||||
to 0.4.0 after build-time forensic.
|
||||
- **Slice-13 component 3 (auto-rollout dispatcher)**: skill-layer
|
||||
landing for this release; product-code dispatcher / invocation
|
||||
is intentionally out of the shipped runtime bundle. 13.1
|
||||
parser + 13.2 binding index ARE shipped product code.
|
||||
- **Slice-05 sub-scopes carried forward**: agent / port /
|
||||
managed-app collision detection (Item 4.3); broader install-
|
||||
into-existing-rig pathway acceptance (Item 4.4);
|
||||
`--target-name` CLI flag for install-time rig-name overrides.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema**: deferred from
|
||||
0.3.2; carried forward.
|
||||
- **Onboarding journey + battle-hardening**: slices 04
|
||||
(new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) carried forward to a later release.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots remains a
|
||||
follow-up; non-blocking for 0.3.3.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental`
|
||||
only; not split-deferred (stays held).
|
||||
- **`rig view list/show --json` flag inconsistency** — wrapper-
|
||||
layer routing path remains; the daemon's `view show <name>`
|
||||
route returns JSON correctly when invoked directly.
|
||||
Workaround: human-readable output for now.
|
||||
|
||||
### Quick Verification Commands
|
||||
|
||||
```bash
|
||||
# Confirm CLI version after the release-manager version bump
|
||||
rig --version
|
||||
|
||||
# Confirm daemon starts and migration 042 applies
|
||||
rig daemon start
|
||||
|
||||
# Confirm rig archive surface is wired
|
||||
rig archive --help
|
||||
rig unarchive --help
|
||||
|
||||
# Confirm rig add surface is wired
|
||||
rig add --help
|
||||
|
||||
# Confirm rig down accepts name-or-id
|
||||
rig down --help
|
||||
|
||||
# Confirm rig workflow specs lists built-ins
|
||||
rig workflow specs
|
||||
|
||||
# Confirm rig send --verify carries the delivery outcome
|
||||
rig send --help | grep -i verify
|
||||
|
||||
# Confirm rig whoami peers contract is stated in-band
|
||||
rig whoami --json | jq '.peersNote'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.3.2] - 2026-06-02
|
||||
|
||||
**Status**: released. npm `@openrig/cli@0.3.2` (latest); GitHub Release
|
||||
`v0.3.2`; git tag `v0.3.2`.
|
||||
|
||||
### Summary For Installing Agents
|
||||
|
||||
- **Package version**: package metadata bumped at release-manager step; CLI
|
||||
reports the new version after `npm publish`.
|
||||
- **Migrations**: one new migration in 0.3.2 — `041_rig_policy`
|
||||
(`CREATE TABLE` for the operator-context-mode bindings store; no impact
|
||||
on existing data; binding rows are operator-authored at runtime).
|
||||
Existing databases upgrade by running `rig daemon start`.
|
||||
- **Node engines**: unchanged from 0.3.1 (CLI accepts Node `>=20`).
|
||||
- **Backward compatibility**: existing CLI argument shapes, daemon route
|
||||
paths, RigSpec/AgentSpec schemas, and persisted settings remain backward
|
||||
compatible. New routes are additive. New CLI commands (`rig policy`,
|
||||
`rig scope`, `rig workspace doctor`) are additive. New `rig queue create`
|
||||
flags (`--body-file`, `--mission`, `--slice`) are additive.
|
||||
|
||||
### Rigbundles First-Class
|
||||
|
||||
`rig bundle` ships cross-primitive bundling end-to-end:
|
||||
|
||||
- **Five content kinds routed**: skills + plugins (hybrid) +
|
||||
workflow_specs + context_packs + agent_images. Each kind lands in
|
||||
its canonical library under `$OPENRIG_HOME`; consumer-scan
|
||||
visibility preserved.
|
||||
- **`bundle.yaml` author manifests** auto-detected when present.
|
||||
Vendoring uses both-sides path containment + symlink-escape
|
||||
protection + integrity hashing in the manifest.
|
||||
- **`rig bundle create` new flags**: `--notes <text>`,
|
||||
`--min-daemon-version <ver>`, `--min-cli-version <ver>` — operator
|
||||
notes captured in bundle provenance metadata; min-version gates
|
||||
power the install-time compatibility check.
|
||||
- **`rig bundle install` new flags**: `--skip-version-check` (operator-
|
||||
explicit override of the install-time compatibility check),
|
||||
`--force` (operator-explicit override of the install-time conflict
|
||||
check). NOT recommended for routine use; provided for known-good
|
||||
operator scenarios.
|
||||
- **`rig bundle history`** — new subcommand reading
|
||||
`~/.openrig/bundle-audit.jsonl` with optional `--rig` /
|
||||
`--since` filters.
|
||||
- **Install timeout bumped** — the prior 5-second cap was too short
|
||||
for tmux-session-bootstrapping installs.
|
||||
|
||||
### Workspace + Workflow GA
|
||||
|
||||
Operator-facing surface hardened:
|
||||
|
||||
- **`rig workspace validate --max-files`** — strict-int regex enforced;
|
||||
out-of-range values produce a 3-part error before `client.post`.
|
||||
- **`rig workflow project --exit`** — enum guard against
|
||||
`handoff | waiting | done | failed`.
|
||||
- **14 new discriminator tests** on validator paths.
|
||||
|
||||
### `rig up <starter>` Paper-Cut Fix-Round
|
||||
|
||||
Four bounded fixes unblocking the homepage quick-start on fresh
|
||||
0.3.1→0.3.2 installs:
|
||||
|
||||
- **HTTP 4xx surface for pre-launch failures**: `cycle_error`,
|
||||
`preflight_failed`, `validation_failed`, `service_boot_failed` —
|
||||
replaces bare 500.
|
||||
- **No orphan rig record on pre-launch failure**: instantiator
|
||||
re-ordered + rollback path + compose teardown order tightened.
|
||||
- **Path-form `rig up <install-internal-spec>` defaults cwd**: closes
|
||||
the divergence between path-form and bare-form invocation; library
|
||||
specs now match path-form behavior.
|
||||
- **`walkYamlFiles` skip standard noise dirs**: `.worktrees`,
|
||||
`node_modules`, `.git`, `dist`, `.turbo`, `.next`. Plus stale
|
||||
`workflow_specs` rows prune at startup with an install-root
|
||||
preservation guard (shipped built-in specs survive the prune).
|
||||
|
||||
### Coordination + First-User Setup Fix-Round (slice 21)
|
||||
|
||||
Five bounded follow-rounds:
|
||||
|
||||
- **FR-1 — Coordination-model boot instinct**: `rig send` vs
|
||||
`rig queue` vs `rig queue handoff` + §1b doctrine surfaced in
|
||||
`core/openrig-user/SKILL.md`.
|
||||
- **FR-2 — First-user workspace + workflow setup teaching**: skill
|
||||
+ docs content for the path from fresh install to first
|
||||
workflow_spec authoring.
|
||||
- **FR-3 — MISSION_NOTES durable-pattern hardening**: convention
|
||||
codemap + auto-scaffold via `rig scope mission create` (uses
|
||||
`conventions/mission-notes/TEMPLATE.md`); `--no-mission-notes`
|
||||
opts out.
|
||||
- **FR-4 — Queue ergonomics**:
|
||||
- `rig queue create --body-file <path>` (use `-` for stdin) kills
|
||||
the backtick-shell-corruption class for multiline bodies.
|
||||
Mutually exclusive with `--body`.
|
||||
- First-class `--mission <id>` / `--slice <id>` flags translate
|
||||
to `mission:<id>` / `slice:<id>` tags (compose with `--tags`).
|
||||
- `lastNudgeResult` wording fix; closure-vs-acceptance docs.
|
||||
- **FR-5 — `rig workspace doctor`**: 7-check workspace-readiness
|
||||
diagnostic (workspace root, missions folder, file allowlist,
|
||||
daemon alignment, daemon reload, optional slice docs,
|
||||
MISSION_NOTES presence). Default exit-code: non-zero only on
|
||||
`fail`; `--strict` makes warn-or-fail non-zero. CLI overlays
|
||||
`OPENRIG_FILES_ALLOWLIST` from operator's shell env.
|
||||
|
||||
### Daemon Test Substrate-Path Scrub (slice 14)
|
||||
|
||||
Internal-team substrate path shape scrubbed from 4 daemon source
|
||||
sites + 10 test files. Hook-constants de-duplicated. Privacy class
|
||||
closed for tracked source + test surface.
|
||||
|
||||
### ConceptCard Data Source (slice 17)
|
||||
|
||||
`ConceptCard` wired to shaped backlog candidates; storytelling-adapter
|
||||
completion deferred from 0.3.1 is now complete.
|
||||
|
||||
### For-You Priority Windowing (slice 20)
|
||||
|
||||
Server-side attention query (Option 3): SQL predicate pushdown +
|
||||
exact-match attention regex + dismissal as string-keyed for
|
||||
queue-derived cards.
|
||||
|
||||
### Operator Context-Mode Bindings (slice 09)
|
||||
|
||||
New `rig policy` command surface paired with daemon-side typed-primitive
|
||||
store (migration `041_rig_policy`):
|
||||
|
||||
- **Six modes**: `sleep | desk | mobile | away | focus | debug`.
|
||||
- **Four scopes**: `global_host | rig | workstream | qitem`.
|
||||
- **Restate-and-confirm posture (HG-4)**: `set` is restate-only
|
||||
until `--confirm` is passed; scripts cannot silently apply a
|
||||
binding.
|
||||
- **`--qualifier` strict reject for `global_host` (HG-7 guard
|
||||
finding)**: operators who type `--scope global_host --qualifier
|
||||
<id>` get an error and the daemon is never contacted.
|
||||
- **Operator-edit verbs require bearer token**: `set --confirm`
|
||||
and `unset` require `--bearer <token>` (or
|
||||
`OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
- **6 subcommands**: `set`, `show`, `effective`, `cite`, `unset`,
|
||||
`defaults`.
|
||||
|
||||
### Scope Tree Primitive (slice 12)
|
||||
|
||||
New `rig scope` command for operating the substrate scope tree
|
||||
(missions, slices, sub-slices) per
|
||||
`conventions/scope-and-versioning`:
|
||||
|
||||
- **Mission tier**: `ls`, `show`, `create` (auto-mints stable
|
||||
dot-IDs into mission frontmatter; auto-scaffolds MISSION_NOTES
|
||||
by default; `--template` auto-selects `release` when name matches
|
||||
`release-X.Y.Z`).
|
||||
- **Slice tier**: `ls`, `show`, `create`, `ship`, `close`, `move`.
|
||||
- **Templates ship under `dist/lib/scope-templates/`**: `release-
|
||||
feature`, `placeholder`, `mission-notes`, `bug-fix`,
|
||||
`research`, `backlog-deprecation`, `backlog-tech-debt`,
|
||||
`mission-placeholder`, `mission-release`.
|
||||
- **Top-level option `--workspace <path>`** overrides workspace
|
||||
root (otherwise inferred from cwd or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
### Known Limitations / 0.3.3 Deferrals
|
||||
|
||||
- **Slice-05 Item-3 sub-scopes deferred to 0.3.3**: agent/port/managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-rig
|
||||
pathway acceptance (Item 4.4); the `--target-name` CLI flag.
|
||||
Design-contingent on the CLI surface decision.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema deferred to 0.3.3**:
|
||||
the new `accepted` queue state is a schema model change; carried
|
||||
forward per the release-triage philosophy.
|
||||
- **Onboarding journey + battle-hardening deferred to 0.3.3**: slices
|
||||
04 (new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) physically moved to the `release-0.3.3` mission tree.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots is a follow-up
|
||||
from slice-21 FR-5 QA; non-blocking for 0.3.2.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental` only;
|
||||
not split-deferred to 0.3.3 (stays held).
|
||||
- **`rig down <name>` still returns HTTP 404** at v0.3.2; use
|
||||
`rig down <rigId> --delete` instead.
|
||||
- **`rig view list/show --json` flag inconsistency** — wrapper-layer
|
||||
routing path remains; daemon's `view show <name>` route returns
|
||||
JSON correctly when invoked directly. Workaround: human-readable
|
||||
output for now.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.1] - 2026-05-15
|
||||
|
||||
**Status**: released. npm `@openrig/cli@0.3.1` (latest); GitHub Release
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
# OpenRig
|
||||
|
||||
Open source multi-agent harness for coding teams.
|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
OpenRig turns AI coding agents from a pile of terminal sessions into a persistent, organized team. A rig is the team that doesn't go away. If you've got tabs full of agents you're afraid to close, this is the layer you're missing: it's open source, it's local, and you're four commands away.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install -g @openrig/cli
|
||||
|
||||
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
|
||||
rig setup
|
||||
|
||||
# Boot the conveyor starter rig (4 seats, mixed runtimes)
|
||||
rig up conveyor
|
||||
|
||||
# Open the UI
|
||||
rig up product-team
|
||||
rig ui open
|
||||
```
|
||||
|
||||
After the conveyor rig boots, open the UI and click **intake.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
|
||||

|
||||
|
||||
## Launch Walkthrough
|
||||
It runs locally: a daemon, a SQLite database, a CLI, and a dashboard. The agents are ordinary Claude Code and Codex sessions in tmux. `product-team` is the fuller starter, 7 seats, 4 Claude and 3 Codex, with an orchestration HA pair plus development and review pods.
|
||||
|
||||
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
|
||||
Because 4 Claude seats run at once, single-plan users should expect provider throttling. For the light-footprint path, use `conveyor`, a 4-seat starter with 2 Claude and 2 Codex.
|
||||
|
||||
### 1. Open the conveyor rig
|
||||
```bash
|
||||
# Preview the smaller starter
|
||||
rig specs preview conveyor
|
||||
|
||||
When the UI opens, the explorer is already visible on the left. Click the `conveyor` rig to load its live topology.
|
||||
# Boot the light-footprint path
|
||||
rig up conveyor
|
||||
```
|
||||
|
||||
### 2. Click `CMUX` on `intake.lead`
|
||||
## First Run
|
||||
|
||||
Once the topology loads, go to the `intake.lead` node and click its `CMUX` button. That opens the intake terminal directly.
|
||||
After `product-team` boots, `rig ps --nodes` shows the running pods and seats. When you're ready to shut the team down and bring it back, use the same rig name:
|
||||
|
||||
> From here, give the intake agent a packet of work and watch it move through planning, build, review, and closeout.
|
||||
```bash
|
||||
rig ps
|
||||
rig down product-team
|
||||
rig up product-team
|
||||
```
|
||||
|
||||
`rig ps` is a fleet glance. `rig down product-team` snapshots the team and stops it. `rig up product-team` brings it back by name from that snapshot.
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -54,36 +53,31 @@ Every agent runs in a tmux session you can attach to, inspect, and work with dir
|
||||
|
||||
## Starter Rigs
|
||||
|
||||
OpenRig ships with a conveyor starter rig for learning workflow handoff:
|
||||
|
||||
```bash
|
||||
rig specs preview conveyor --kind rig
|
||||
```
|
||||
|
||||
```
|
||||
conveyor (rig, pod_aware)
|
||||
Starter workflow rig: a station pipeline that can move multiple
|
||||
work packets at once, with queue depth acting as natural backpressure.
|
||||
|
||||
Pod: intake (1 member)
|
||||
lead — claude-code
|
||||
Pod: plan (1 member)
|
||||
planner — codex
|
||||
Pod: build (1 member)
|
||||
builder — claude-code
|
||||
Pod: review (1 member)
|
||||
reviewer — codex
|
||||
```
|
||||
|
||||
For a larger human-operated product-development topology, inspect `product-team`:
|
||||
OpenRig's hero starter is `product-team`, the fuller product-development rig:
|
||||
|
||||
```bash
|
||||
rig specs preview product-team
|
||||
rig up product-team
|
||||
```
|
||||
|
||||
Use it when you want the week-one experience OpenRig is built around: an orchestrator HA pair, development work, review work, and enough moving pieces for the coordination layer to matter.
|
||||
|
||||
For a smaller starter, use `conveyor`:
|
||||
|
||||
```bash
|
||||
rig specs preview conveyor
|
||||
rig up conveyor
|
||||
```
|
||||
|
||||
`conveyor` is the smallest shippable software factory, one command. It keeps the footprint lower for single-plan users while still showing a real handoff path through intake, planning, build, and review.
|
||||
|
||||
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
|
||||
|
||||
Browse the library: `rig specs ls`
|
||||
Browse the library:
|
||||
|
||||
```bash
|
||||
rig specs ls
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Docs — Map of Territory + 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]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
> 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.
|
||||
|
||||
## How this tree is organized
|
||||
|
||||
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).
|
||||
|
||||
```
|
||||
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/ ← 13 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.
|
||||
|
||||
## Module index
|
||||
|
||||
### `architecture/` — backend, daemon, runtime
|
||||
|
||||
| Module | What it covers |
|
||||
|---|---|
|
||||
| [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. |
|
||||
| [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. |
|
||||
|
||||
### `ui/` — operator surfaces
|
||||
|
||||
| Module | What it covers |
|
||||
|---|---|
|
||||
| [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. |
|
||||
|
||||
### Root docs
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [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). |
|
||||
|
||||
## Source-grounding contract
|
||||
|
||||
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/`.
|
||||
+53
-1142
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,228 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Adapters and Runtimes — Claude/Codex/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).
|
||||
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
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
> 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.
|
||||
|
||||
## 1. The five-method RuntimeAdapter contract
|
||||
|
||||
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:127`
|
||||
(`interface RuntimeAdapter`). Every adapter declares a `readonly runtime`
|
||||
string and implements exactly five methods (`runtime-adapter.ts:128–153`):
|
||||
|
||||
| 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. |
|
||||
|
||||
Startup *action* execution (`slash_command` / `send_text`) is explicitly **not**
|
||||
part of this contract — the contract docstring (`runtime-adapter.ts:121–125`)
|
||||
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`).
|
||||
|
||||
> 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`).
|
||||
|
||||
### `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`.
|
||||
|
||||
## 2. The three adapters
|
||||
|
||||
All three live under `packages/daemon/src/adapters/` and implement
|
||||
`RuntimeAdapter` (Architecture Rule 1: zero Hono in `adapters/`).
|
||||
|
||||
### ClaudeCodeAdapter (`claude-code-adapter.ts:41`)
|
||||
|
||||
- `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
|
||||
<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.
|
||||
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:37`)
|
||||
|
||||
- `readonly runtime = "codex"` (`:38`).
|
||||
- **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.
|
||||
- Refuses `resumeToken` + `forkSource` together with a clear error
|
||||
(`:180–181`) — 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`.)
|
||||
|
||||
These three are constructed by `createDaemon` step 4 (`startup.ts`; 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:
|
||||
|
||||
### `native-resume-probe.ts`
|
||||
|
||||
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:43`) reads pane
|
||||
command + pane content and returns one of four honest statuses
|
||||
(`NativeResumeProbeStatus`, `:6`):
|
||||
|
||||
- `resumed` — runtime-specific indicators confirm a resumed session.
|
||||
- `failed` — terminal failure (e.g. Claude printed "No conversation found" →
|
||||
code `no_conversation_found`, `:51–57`).
|
||||
- `inconclusive` — we don't know yet (e.g. Claude trust gate, code
|
||||
`trust_gate`, `:65–70`).
|
||||
- `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`).
|
||||
|
||||
`buildNativeResumeCommand` (`:27`) builds the resume command per runtime:
|
||||
claude → `claude --resume <token> [--name <name>]` (`:35`); codex → `codex
|
||||
resume <token>` (`:38`); other runtimes → `null` (`:40`).
|
||||
|
||||
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).
|
||||
|
||||
### `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.
|
||||
|
||||
### `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`).
|
||||
|
||||
> 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 (source-verified at HEAD)
|
||||
|
||||
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).
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — where `createDaemon` constructs the three 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).
|
||||
- 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/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts`.
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Agent/Rig Spec, Resolution, Startup, Identity
|
||||
status: active
|
||||
topics: [specification-and-bundles, agent-runtime]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know the canonical AgentSpec/RigSpec/pod-aware reboot types, how
|
||||
profile resolution and additive startup layering work, the StartupOrchestrator
|
||||
pre-launch-vs-interactive delivery split, or how whoami/materialize/bind/adopt
|
||||
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
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
> 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.
|
||||
|
||||
## 1. Canonical spec and topology types
|
||||
|
||||
(`architecture.md` §4 "Spec and topology")
|
||||
|
||||
- **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`.
|
||||
|
||||
> 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`.
|
||||
|
||||
## 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)
|
||||
|
||||
- **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).
|
||||
|
||||
## 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)
|
||||
|
||||
**Parse / validate:**
|
||||
|
||||
- `agent-manifest.ts` — canonical AgentSpec parse/normalize/validate.
|
||||
- `rigspec-schema.ts` — dual-format RigSpec validation.
|
||||
- `rigspec-codec.ts` — dual-format YAML codec.
|
||||
- `startup-validation.ts` — shared startup-block validation.
|
||||
- `path-safety.ts` — shared relative-path safety checks.
|
||||
- `spec-validation-service.ts` — pure raw-YAML validation helpers.
|
||||
- `spec-review-service.ts` — daemon-owned structured review model for
|
||||
RigSpec/AgentSpec YAML, incl. topology preview, provenance state, managed-app
|
||||
services metadata (`waitFor`, `surfaces`, `composePreview`).
|
||||
|
||||
**Resolve:**
|
||||
|
||||
- `agent-resolver.ts` — resolves `agent_ref`, imports, collision metadata.
|
||||
- `agent-preflight.ts` — single-agent resolution/preflight.
|
||||
- `profile-resolver.ts` — applies defaults, profile uses, resource selection,
|
||||
startup layering, restore-policy narrowing.
|
||||
- `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` (`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 (filesystem, before harness boot): `guidance_merge`,
|
||||
`skill_install` (`startup-orchestrator.ts:78,167` — "Deliver pre-launch
|
||||
files (guidance_merge, skill_install → filesystem)").
|
||||
- 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`).
|
||||
|
||||
The orchestrator persists replay context including the resume token for future
|
||||
restores (consumed by `lifecycle-snapshot-restore.md`).
|
||||
|
||||
**Architecture Rule 6 — startup layering is additive and ordered** (carried
|
||||
verbatim, `architecture.md` §7): (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`.
|
||||
|
||||
**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.
|
||||
|
||||
**Remote import constraints** (§7): the reboot supports `local:...` and
|
||||
`path:/abs/...`. Remote `agent_ref` sources remain unsupported and fail in
|
||||
preflight.
|
||||
|
||||
## 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-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
|
||||
(pod-aware → `RigSpecSchema.validate`; legacy → `LegacyRigSpecSchema.validate`),
|
||||
preflight (`rigPreflight({ rigSpecYaml, rigRoot, fsOps })` vs
|
||||
`RigSpecPreflight.check(spec)`), import
|
||||
(`podInstantiator.instantiate(yaml, rigRoot)` vs
|
||||
`RigInstantiator.instantiate(spec)`), export (pod-aware exports canonical
|
||||
`version: "0.2"` RigSpec; legacy exports flat-node v1).
|
||||
|
||||
## 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.
|
||||
|
||||
- Managed sessions prefer projected `OPENRIG_NODE_ID` /
|
||||
`OPENRIG_SESSION_NAME`.
|
||||
- Adopted sessions use tmux-owned metadata written at claim/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.
|
||||
|
||||
**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
|
||||
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`.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — createDaemon wiring; the migration/route surface.
|
||||
- `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.
|
||||
- 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`.
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Architecture Invariants, Event System, Compatibility Notes
|
||||
status: active
|
||||
topics: [runtime-control, observability, doctrine]
|
||||
domains: [engineering-advisor, operating-advisor, review]
|
||||
applies-when: |
|
||||
Need the cross-cutting architecture invariants the codebase enforces (the
|
||||
25 architecture rules + startup/import constraints), the shape of the
|
||||
RigEvent union and its SSE delivery surfaces, or the intentional
|
||||
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
|
||||
---
|
||||
|
||||
# Architecture Invariants, Event System, Compatibility Notes
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
1. Zero Hono in `domain/` and `adapters/`.
|
||||
2. Routes depend on the domain; the domain never depends on routes.
|
||||
3. Shared DB-handle invariants are enforced at construction time.
|
||||
4. The reboot is engine-first: domain services land before public-surface
|
||||
rewiring.
|
||||
5. Runtime is member-authoritative in the pod-aware model.
|
||||
6. Startup layering is additive and ordered: agent base → profile → rig
|
||||
culture file → rig startup → pod startup → member startup → operator debug
|
||||
append.
|
||||
7. Restore-policy narrowing is one-way only: `resume_if_possible` →
|
||||
`relaunch_fresh` → `checkpoint_only`.
|
||||
8. Base/import collisions warn; ambiguous import/import unqualified refs fail
|
||||
loudly.
|
||||
9. Bundle assembly and startup-file resolution use containment checks rooted
|
||||
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`.
|
||||
12. Session recency depends on monotonic ULIDs: `session-registry.ts` uses
|
||||
`monotonicFactory()`; restore selects the newest session by max ULID.
|
||||
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` =
|
||||
new process assembled from artifacts.
|
||||
15. Restore honesty: failed resume is FAILED loudly. No automatic fresh
|
||||
fallback. Fresh launch is explicit follow-up only.
|
||||
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.
|
||||
20. Config precedence: CLI flag > env var > config file
|
||||
(`~/.openrig/config.json`, with legacy fallback from
|
||||
`~/.rigged/config.json`) > 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.
|
||||
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.
|
||||
25. Human-readable IDs are UI-only presentation helpers. CLI/API/MCP/backend
|
||||
keep full canonical ids.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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).
|
||||
|
||||
## 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: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).
|
||||
|
||||
### Per-prefix event families (grep-verified at HEAD)
|
||||
|
||||
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).
|
||||
|
||||
| Prefix | Members | 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`) |
|
||||
| `package.*` | 5 | legacy package/install engine events |
|
||||
| `mission_control.*` | 5 | PL-005 audit/notification (`:212`–`:218`; 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.*` |
|
||||
|
||||
Family counts sum to 73 (15 multi-member families totalling 62 + 11
|
||||
singletons), re-confirmed at HEAD.
|
||||
|
||||
### 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.
|
||||
|
||||
Three SSE delivery surfaces (re-confirmed at HEAD):
|
||||
|
||||
- `GET /api/events` — global stream of all events (`server.ts:457`
|
||||
`app.route("/api/events", eventsRoute)`).
|
||||
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:117`).
|
||||
- `GET /api/queue/watch` — queue/inbox coordination events
|
||||
(`routes/queue.ts:357`).
|
||||
- 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.
|
||||
|
||||
## 3. Remaining compatibility notes
|
||||
|
||||
Intentional limits that still describe the shipped system (`architecture.md`
|
||||
§11), verified as still-current at HEAD:
|
||||
|
||||
1. Remote `agent_ref` imports remain unsupported (see §1 remote import
|
||||
constraints).
|
||||
2. Startup actions remain intentionally constrained (`slash_command`,
|
||||
`send_text`).
|
||||
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.
|
||||
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.
|
||||
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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- Source roots: `packages/daemon/src/domain/types.ts` (RigEvent union),
|
||||
`packages/daemon/src/routes/{stream,queue}.ts` (SSE watch),
|
||||
`packages/daemon/src/server.ts` (`/api/events`).
|
||||
@@ -0,0 +1,308 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
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
|
||||
---
|
||||
|
||||
# Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
|
||||
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.
|
||||
|
||||
> **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.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| 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 |
|
||||
|
||||
> 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.
|
||||
|
||||
## 1. Path safety — the fail-closed allowlist (UI Enhancement Pack v0)
|
||||
|
||||
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**.
|
||||
|
||||
> 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.
|
||||
|
||||
`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`).
|
||||
|
||||
> 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.
|
||||
|
||||
## 2. Atomic write + JSONL audit (UI Enhancement Pack v0 item 4)
|
||||
|
||||
`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.
|
||||
|
||||
> 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.
|
||||
|
||||
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.)
|
||||
|
||||
> 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.
|
||||
|
||||
## 3. Files routes — browse + read + asset + write
|
||||
|
||||
`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):
|
||||
|
||||
- `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).
|
||||
- `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.
|
||||
|
||||
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).
|
||||
|
||||
> 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.
|
||||
|
||||
> **§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.
|
||||
|
||||
## 4. Progress tree indexer (UI Enhancement Pack v0 item 1B)
|
||||
|
||||
`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).
|
||||
|
||||
> 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.
|
||||
|
||||
`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.
|
||||
|
||||
> Source: `routes/progress.ts:18-40` (handler; 503s L29-35); mounted
|
||||
> `server.ts:508` @HEAD.
|
||||
|
||||
> **§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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator]
|
||||
applies-when: |
|
||||
Need to know how the daemon-backed coordination primitive works — the
|
||||
stream/queue/inbox/outbox tables, the hot-potato closure contract, the
|
||||
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
|
||||
---
|
||||
|
||||
# Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
|
||||
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).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
|
||||
## 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/`):
|
||||
|
||||
- **`stream_items`** (`023_stream_items.ts`) — L1 append-only intake/audit
|
||||
root. Columns: `stream_item_id` (ULID PK), `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.
|
||||
- **`queue_transitions`** (`025_queue_transitions.ts`) — L3 append-only
|
||||
transition log; authoritative audit trail for state evolution. No
|
||||
UPDATE/DELETE from domain code.
|
||||
- **`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`.
|
||||
|
||||
> 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`).
|
||||
|
||||
## 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).
|
||||
Routes import these; services are Hono-free:
|
||||
|
||||
- **`stream-store.ts`** — L1 stream: idempotent emit (on `stream_item_id`),
|
||||
chronological list with cursor pagination, soft archive.
|
||||
- **`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-transition-log.ts`** — append-only state-transition log; used by
|
||||
`queue-repository.ts`, exposed read-only on
|
||||
`queue_repository.transitionLog`.
|
||||
- **`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).
|
||||
|
||||
## 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`):
|
||||
|
||||
`state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied,
|
||||
canceled, no-follow-on, escalation}`. The reasons
|
||||
`handed_off_to | blocked_on | escalation` additionally require
|
||||
`closure_target`:
|
||||
|
||||
- `handed_off_to` — work continues with a different seat (`closure_target` =
|
||||
new owner).
|
||||
- `blocked_on` — work is parked pending another qitem (`closure_target` =
|
||||
blocker `qitem_id`).
|
||||
- `denied` — receiver rejected the work (`closure_target` = reason text).
|
||||
- `canceled` — sender or receiver withdrew (`closure_target` = note).
|
||||
- `no-follow-on` — terminal completion, nothing else needed.
|
||||
- `escalation` — kicked up to a higher tier (`closure_target` = escalation
|
||||
target).
|
||||
|
||||
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`).
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
## 5. Route surface
|
||||
|
||||
- `/api/stream` (`server.ts:489`) — `POST /emit` (`routes/stream.ts:24`),
|
||||
`GET /list` (`:56`), `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.
|
||||
|
||||
## 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`.
|
||||
- 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`.
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Daemon Core — Wiring, DB, Migrations, Startup
|
||||
status: active
|
||||
topics: [runtime-control, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the daemon boots, how createDaemon wires the dependency
|
||||
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
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
> 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.
|
||||
|
||||
## 1. System overview
|
||||
|
||||
The system has six architectural layers (`architecture.md` §1 L23–30):
|
||||
|
||||
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.
|
||||
|
||||
Legacy flat-node/package flows remain for backward compatibility.
|
||||
|
||||
### 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) |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
> 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).
|
||||
|
||||
### 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)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (49 app.route() mounts + 4 dedicated health/export/static handlers)
|
||||
|
|
||||
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
|
||||
+-- env routes (status / logs / down)
|
||||
+-- transport routes (send/capture/broadcast)
|
||||
+-- transcript routes (tail/grep)
|
||||
+-- ask routes (context evidence packs)
|
||||
+-- 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)
|
||||
|
|
||||
v
|
||||
Framework-free domain services (173 daemon domain files)
|
||||
|
|
||||
+-- SQLite state (40 migrations)
|
||||
+-- tmux / cmux / resume adapters
|
||||
+-- runtime adapters (Claude Code / Codex / Terminal)
|
||||
+-- RigEnv substrate (compose adapter, readiness, orchestrator)
|
||||
+-- transport / transcript / chat / ask layers
|
||||
+-- whoami identity service
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### Core state tables (`001_core_schema.ts`)
|
||||
|
||||
`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.
|
||||
|
||||
### Reboot-era 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.
|
||||
- `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).
|
||||
- `017_pod_namespace.ts` — first-class authored pod namespace for export/adoption.
|
||||
- `018_context_usage.ts` — per-node context-usage snapshots.
|
||||
- `019_external_cli_attachment.ts` — binding-row extension for external CLI attach.
|
||||
- `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
|
||||
|
||||
- `023_stream_items.ts` … `027_outbox_entries.ts` — PL-004 Phase A
|
||||
coordination tables (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.
|
||||
- `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`.
|
||||
|
||||
Legacy package/bootstrap/discovery tables (`packages`, `package_installs`,
|
||||
`install_journal`, `bootstrap_runs`, `bootstrap_actions`,
|
||||
`runtime_verifications`, `discovered_sessions`) remain active.
|
||||
|
||||
## 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`).
|
||||
|
||||
> 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).
|
||||
|
||||
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`).
|
||||
|
||||
## 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):
|
||||
|
||||
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.
|
||||
|
||||
The daemon entrypoint `packages/daemon/src/index.ts:36` calls
|
||||
`createDaemon({ dbPath, bearerToken })`.
|
||||
|
||||
## 5. Test and verification state
|
||||
|
||||
> 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.
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — PL-004 Phase A stream/queue/inbox/outbox.
|
||||
- `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/migrations/`, `packages/cli/src/index.ts`.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Lifecycle — Snapshot, Restore, Continuity
|
||||
status: active
|
||||
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.
|
||||
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
|
||||
---
|
||||
|
||||
# Lifecycle — Snapshot, Restore, Continuity
|
||||
|
||||
The durable-state half of the core product loop:
|
||||
`down (auto-snapshot) → up <rig-name> (auto-restore) → handoff`. Snapshot
|
||||
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.
|
||||
|
||||
## 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)
|
||||
|
||||
- **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:
|
||||
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).
|
||||
|
||||
## 2. Snapshot, restore, continuity domain services
|
||||
|
||||
(`architecture.md` §5 "Snapshot, restore, and continuity")
|
||||
|
||||
- `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.
|
||||
- `restore-orchestrator.ts` — resume, checkpoint delivery, startup replay,
|
||||
live continuity consultation, topology ordering, and RigEnv boot gating
|
||||
before agent restore.
|
||||
|
||||
## 3. Restore flow and restore honesty
|
||||
|
||||
(`architecture.md` §6 "Snapshot / restore" + §7 rules 7/14/15/16)
|
||||
|
||||
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").
|
||||
- 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).
|
||||
- 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).
|
||||
|
||||
**Restore-honesty rules (carried verbatim, `architecture.md` §7):**
|
||||
|
||||
- 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.
|
||||
|
||||
(The full 25-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 <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.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
|
||||
`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`).
|
||||
|
||||
`RestoreCheckService.check()` (`restore-check-service.ts:244`) 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).
|
||||
|
||||
The result shape is `RestoreCheckResult` (`restore-check-service.ts:120-130`):
|
||||
`verdict`, `readiness`, `continuity`, `rigs[]`, `hostInfra`, `recovery`,
|
||||
`counts {red,yellow,green}`, `checks[]`, `repairPacket`.
|
||||
|
||||
**Honest-error design (slice-00 bar):** 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`).
|
||||
|
||||
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.
|
||||
|
||||
### 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`).
|
||||
|
||||
## 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`).
|
||||
- `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`.
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
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.
|
||||
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
|
||||
---
|
||||
|
||||
# Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
|
||||
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).
|
||||
|
||||
> 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).
|
||||
|
||||
## 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`.
|
||||
|
||||
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,
|
||||
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).
|
||||
|
||||
`MissionControlReadLayer` maps each view to its source-of-truth path
|
||||
(`mission-control-read-layer.ts:4–14`, re-confirmed at HEAD):
|
||||
|
||||
- `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`.
|
||||
|
||||
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep)
|
||||
are graceful-degradation aids, NOT the primary path (`architecture.md` §3
|
||||
L378).
|
||||
|
||||
> 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.
|
||||
|
||||
## 2. The seven verbs (the write contract)
|
||||
|
||||
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`):
|
||||
|
||||
| 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) |
|
||||
| `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) |
|
||||
|
||||
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`).
|
||||
|
||||
## 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).
|
||||
|
||||
## 4. PL-005 Phase B — 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.
|
||||
- **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).
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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).
|
||||
- Source roots: `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`.
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Packaging, Bootstrap, Bundles, Legacy Install Engine
|
||||
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.
|
||||
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
|
||||
---
|
||||
|
||||
# Packaging, Bootstrap, Bundles, Legacy Install Engine
|
||||
|
||||
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.
|
||||
|
||||
> 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.
|
||||
|
||||
## 1. Bundle, bootstrap, legacy domain services
|
||||
|
||||
(`architecture.md` §5 "Bundles, bootstrap, and legacy compatibility")
|
||||
|
||||
- `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`).
|
||||
|
||||
All re-confirmed present in `packages/daemon/src/domain/` @HEAD.
|
||||
|
||||
## 2. Bundle create / inspect / install
|
||||
|
||||
(`architecture.md` §6 "Bundle create / inspect / install")
|
||||
|
||||
`routes/bundles.ts` is fully dual-format:
|
||||
|
||||
- **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.
|
||||
|
||||
## 3. `/api/up` source routing
|
||||
|
||||
(`architecture.md` §6 "`/api/up`")
|
||||
|
||||
`UpCommandRouter` + `BootstrapOrchestrator` own:
|
||||
|
||||
- direct pod-aware rig specs,
|
||||
- legacy rig specs,
|
||||
- v1 bundle installs,
|
||||
- v2 pod-bundle installs.
|
||||
|
||||
Plan mode and apply mode both work across those source kinds.
|
||||
|
||||
> 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.
|
||||
|
||||
## 4. Legacy install engine (still ships)
|
||||
|
||||
(`architecture.md` §5 "Legacy systems that still ship" + §11 compat note 3)
|
||||
|
||||
These pre-reboot seams remain active for backward compatibility:
|
||||
|
||||
- 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.
|
||||
|
||||
> 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.
|
||||
|
||||
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`.)
|
||||
|
||||
> 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.
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **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).
|
||||
|
||||
## See also
|
||||
|
||||
- `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`.
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
|
||||
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.
|
||||
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
|
||||
---
|
||||
|
||||
# Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
|
||||
|
||||
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.
|
||||
|
||||
> **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).
|
||||
|
||||
## 0. Release attribution (forensic seam — read first)
|
||||
|
||||
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| 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) |
|
||||
|
||||
> 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.
|
||||
|
||||
**Do not back-attribute plugins or compaction to 0.3.0.** The CLI
|
||||
reference already encodes this ("## Plugin Inspection (v0.3.1)").
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:943` @HEAD.
|
||||
|
||||
## 1. Context packs (PL-014) — 0.3.0
|
||||
|
||||
A **context pack** is a directory of `manifest.yaml` + included markdown /
|
||||
yaml / txt files: operator-authored, library-discoverable, reviewable,
|
||||
sendable. No SQLite tables; in-memory cache at daemon scope.
|
||||
|
||||
> Source: `domain/context-packs/context-pack-types.ts:1-11` @HEAD.
|
||||
|
||||
`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 three roots: builtin (`../context-packs`, first), user-file
|
||||
(`~/.openrig/context-packs`), and workspace-local
|
||||
`<workspaceRoot>/.openrig/context-packs` when present + distinct. Stable
|
||||
id `context-pack:<name>:<version>`.
|
||||
|
||||
> Source: `domain/context-packs/context-pack-library-service.ts:59-94`
|
||||
> (scan; last-wins L79-81), `:31-33` (id); `startup.ts:481-502` (3-root
|
||||
> IIFE; builtin `unshift` L497, workspace L491-493) @HEAD.
|
||||
|
||||
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)`.
|
||||
|
||||
> 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.
|
||||
|
||||
`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`.
|
||||
|
||||
> Source: `domain/context-packs/bundle-assembler.ts:38-39` (prefixes),
|
||||
> `:73-100` (missing skip L74-76; read-fail throw L81-87) @HEAD.
|
||||
|
||||
`contextPacksRoutes()` (mounted `server.ts:482`): `GET /library`,
|
||||
`POST /library/sync`, `GET /library/:id`, `GET /library/:id/preview`
|
||||
(assembled, no send), `POST /library/:id/send`
|
||||
(`{ destinationSession, dryRun }`; dry-run returns text without
|
||||
`SessionTransport`; real send → 502 on transport failure). `id==="sync"`
|
||||
→ 404 so it cannot shadow the sync route; 503 when unprovisioned. CLI:
|
||||
`rig context-pack list|show|preview|sync|add|send` (`send --dry-run`).
|
||||
|
||||
> Source: `routes/context-packs.ts:25-138` (table; sync-guard L51; 503
|
||||
> L31; dry-run L121-123; 502 L126-133); `server.ts:482`;
|
||||
> `docs/as-built/cli-reference.md:914-929` @HEAD.
|
||||
|
||||
## 2. Agent images (PL-016) — 0.3.0
|
||||
|
||||
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**.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-types.ts:1-13` @HEAD.
|
||||
|
||||
`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>`.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-library-service.ts:5-14`
|
||||
> (3-difference comment), `:38-41` (id) @HEAD.
|
||||
|
||||
`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.
|
||||
|
||||
> 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.
|
||||
|
||||
`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.
|
||||
|
||||
> 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.
|
||||
|
||||
**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: `domain/agent-images/evidence-guard.ts:1-16` (bounce +
|
||||
> fail-closed), `:53-115` (direct + transitive; fixed point L92-112),
|
||||
> `:117-122` (conservative) @HEAD.
|
||||
|
||||
**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.
|
||||
|
||||
> 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.
|
||||
|
||||
`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`.
|
||||
|
||||
> 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.
|
||||
|
||||
## 3. Plugins (plugin-primitive Phase 3a) — **0.3.1, NOT 0.3.0**
|
||||
|
||||
> **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.
|
||||
|
||||
`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).
|
||||
|
||||
> 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.
|
||||
|
||||
`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.
|
||||
|
||||
> 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.
|
||||
|
||||
`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`.
|
||||
|
||||
> 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.
|
||||
|
||||
`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).
|
||||
|
||||
> 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).
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Transport, Transcripts, Chat, Ask
|
||||
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.
|
||||
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
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
> 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.
|
||||
|
||||
## 1. The naming axes (D5 — 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:
|
||||
|
||||
**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:
|
||||
|
||||
> 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.
|
||||
|
||||
## 2. Transport and communication domain services
|
||||
|
||||
(`architecture.md` §5 "Transport and communication")
|
||||
|
||||
- `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"`.
|
||||
- `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.
|
||||
|
||||
Routes: `routes/{transport,transcripts,ask,chat,whoami}.ts` — all confirmed
|
||||
present @HEAD.
|
||||
|
||||
## 3. Communication flow
|
||||
|
||||
(`architecture.md` §6 "Communication flow")
|
||||
|
||||
`rig send <session> "message"` → CLI → `POST /api/transport/send` →
|
||||
`SessionTransport`:
|
||||
|
||||
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: `send-keys -l` → ~200ms delay → `C-m`
|
||||
(`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)`).
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
3. `TranscriptStore` owns path convention, ANSI stripping on read, boundary
|
||||
markers, `readTail`, `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.)
|
||||
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.
|
||||
|
||||
## 5. Chat flow
|
||||
|
||||
(`architecture.md` §6 "Chat flow")
|
||||
|
||||
1. `rig chatroom send <rig> "message"` → `POST
|
||||
/api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`.
|
||||
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).
|
||||
6. Source of truth: daemon-backed SQLite (`chat_messages` table), NOT tmux
|
||||
scrollback.
|
||||
|
||||
**ChatMessage** type (`architecture.md` §4): durable rig-scoped message —
|
||||
`id`, `rigId`, `sender`, `kind`, `body`, `topic`, `createdAt`.
|
||||
|
||||
## 6. Compatibility notes (carried verbatim, `architecture.md` §11)
|
||||
|
||||
The intentional limits in this layer:
|
||||
|
||||
- Note 4 — `rig ask` gathers context only; does not call an external LLM.
|
||||
The agent reasons about the gathered evidence.
|
||||
- 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.
|
||||
|
||||
(The full §11 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
|
||||
markers.
|
||||
- Source roots: `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`.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
status: active
|
||||
topics: [coordination, runtime-control]
|
||||
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: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
|
||||
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).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
|
||||
## 1. The three Phase D tables (+ the diagnostic addition)
|
||||
|
||||
Re-confirmed at HEAD in `packages/daemon/src/db/migrations/`:
|
||||
|
||||
- **`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.
|
||||
|
||||
`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).
|
||||
|
||||
## 2. The transactional-scribe contract
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
`WorkflowRuntime` (`packages/daemon/src/domain/workflow-runtime.ts:61`
|
||||
`export class WorkflowRuntime`) is the orchestration class above the
|
||||
projector.
|
||||
|
||||
**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**.
|
||||
|
||||
## 3. The workflow-keepalive watchdog policy
|
||||
|
||||
`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`):
|
||||
|
||||
- **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.
|
||||
|
||||
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`.
|
||||
|
||||
## 4. Workflow events
|
||||
|
||||
> 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.
|
||||
|
||||
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).
|
||||
|
||||
## 5. Route surface
|
||||
|
||||
`/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`.
|
||||
|
||||
## 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/routes/workflow.ts`.
|
||||
@@ -0,0 +1,416 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
|
||||
status: active
|
||||
topics: [specification-and-bundles, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how a rig declares a typed workspace (workspaceRoot / repos /
|
||||
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.
|
||||
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
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
> **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.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
|
||||
> 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).
|
||||
|
||||
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`".
|
||||
|
||||
## 1. The typed workspace declaration (PL-007) — 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.
|
||||
|
||||
| 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 |
|
||||
| `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`.
|
||||
|
||||
> Source: `domain/types.ts:748-749` (`WORKSPACE_KINDS` /
|
||||
> `WorkspaceKind`) @HEAD.
|
||||
|
||||
### 1.1 Persistence — migration 038 + RigRepository
|
||||
|
||||
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.
|
||||
|
||||
`RigRepository.setRigWorkspace(rigId, workspace)` persists it (UPDATE
|
||||
`workspace_json` + `updated_at`); `getRigWorkspace(rigId)` reads it back,
|
||||
JSON-parsing to `WorkspaceSpec` and returning `null` on parse failure. Both
|
||||
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.
|
||||
|
||||
The instantiator persists the block at rig-create time **only when declared**:
|
||||
|
||||
> Source: `domain/rigspec-instantiator.ts:653-655` (`if
|
||||
> (rigSpec.workspace) … setRigWorkspace(rigId, rigSpec.workspace)`) @HEAD.
|
||||
|
||||
### 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`.
|
||||
|
||||
> Source: `domain/workspace/workspace-resolver.ts:26-52` (resolver;
|
||||
> env-override-verbatim L34-43, including the "honored verbatim"
|
||||
> comment L35-36) @HEAD.
|
||||
|
||||
`whoami-service` reads the persisted spec and resolves with the query's
|
||||
`targetRepoOverride`, falling back to `process.env["OPENRIG_TARGET_REPO"]`:
|
||||
|
||||
> Source: `domain/whoami-service.ts:319-324` (`getRigWorkspace` +
|
||||
> `resolveWorkspaceContext`, env fallback L323); `whoami` returns
|
||||
> `workspace` in its payload `:335` @HEAD.
|
||||
|
||||
`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`.
|
||||
|
||||
> 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.
|
||||
|
||||
### 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.
|
||||
|
||||
> 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.
|
||||
|
||||
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.
|
||||
|
||||
> Source: `routes/queue.ts:49-57` (fail-open guards), `:55`
|
||||
> (`getRigWorkspace`), `:58-66` (`unknown_target_repo` +
|
||||
> `knownRepos`) @HEAD.
|
||||
|
||||
## 2. Workspace HTTP route — frontmatter validator (0.3.0)
|
||||
|
||||
`workspaceRoutes()` (mounted `server.ts:491` as `/api/workspace`) exposes a
|
||||
**single read-only endpoint** at v0:
|
||||
|
||||
`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.**
|
||||
|
||||
> Source: `routes/workspace.ts:23-65` (handler; root-required L35-37;
|
||||
> kind-enum L39-47; 500 L59-62); mounted `server.ts:491` @HEAD.
|
||||
|
||||
`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:
|
||||
`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.
|
||||
|
||||
> 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.
|
||||
|
||||
CLI surface: `rig workspace validate [root]` — v0 is intentionally narrow
|
||||
(`validate` only); future versions add typed-kind authoring on the same
|
||||
walker.
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:931-941` @HEAD.
|
||||
|
||||
## 3. Default workspace scaffold (0.3.0 spine; 0.3.1 narrative layer)
|
||||
|
||||
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the
|
||||
mission-aware default workspace (`~/.openrig/workspace/` or `--root`). The
|
||||
same scaffold is used **idempotently by daemon startup** so a fresh install
|
||||
has a browsable Project workspace before the operator discovers
|
||||
`rig config init-workspace`. Canonical subdirs: `missions/`, `artifacts/`,
|
||||
`evidence/`, `progress/`, `field-notes/`, `specs/`, `dogfood-evidence/`,
|
||||
plus per-mission `missions/<id>/slices/<slice-id>` folders. Drops
|
||||
`README.md` + `STEERING.md` (placeholder) + per-mission `README.md` /
|
||||
`PROGRESS.md` + per-slice `README.md` / `PROGRESS.md` /
|
||||
`IMPLEMENTATION-PRD.md`.
|
||||
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts:44-59`
|
||||
> (`workspaceScaffoldDirs`), `:247-283` (`workspaceScaffoldFiles`),
|
||||
> `WORKSPACE_README` `:82-94`, `STEERING_PLACEHOLDER` `:96-120`;
|
||||
> CLI `packages/cli/src/commands/config-init-workspace.ts:1-16`
|
||||
> (idempotent; `--dry-run`/`--force`; never deletes operator
|
||||
> content L12-16); cli-reference workspace note "See `rig config
|
||||
> init-workspace`" `:939` @HEAD.
|
||||
|
||||
**0.3.1 narrative layer (slice 21 onboarding-conveyor).** The
|
||||
`getting-started` mission's two slices ship rich teaching content from
|
||||
`GETTING_STARTED_NARRATIVE` (the click-through-to-learn surface) including a
|
||||
`timeline.md` the slice Story tab renders via slice-06's
|
||||
`useSliceTimelineMarkdown` hook; other slices keep the boilerplate.
|
||||
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts:1-4`
|
||||
> (slice-21 header), `:153-190` (narrative branch in `sliceReadme`),
|
||||
> `:192-200` (`sliceTimeline`), `:270-279` (timeline emit); §0 proof:
|
||||
> `getting-started-narrative.ts` [email protected] ⇒ **0.3.1** @HEAD.
|
||||
|
||||
## 4. File-backed missions / slices tree
|
||||
|
||||
### 4.1 SliceIndexer (Slice Story View v0) — 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.
|
||||
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.
|
||||
|
||||
> 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.
|
||||
|
||||
### 4.2 SliceDetailProjector (Slice Story View v0 + v1) — 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 +
|
||||
`workflow_specs`/`workflow_instances`/`workflow_step_trails` + slice docs on
|
||||
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase enum**
|
||||
(`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`).
|
||||
|
||||
> 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.
|
||||
|
||||
### 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.
|
||||
|
||||
> 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.
|
||||
|
||||
### 4.4 Missions route — **0.3.1** (slice 12 + slice 13 + slice 18)
|
||||
|
||||
`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/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.5 Projects route — Coordination L2 classifier (PL-004 Phase B) — 0.3.0
|
||||
|
||||
`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).
|
||||
|
||||
> 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.
|
||||
|
||||
## 5. UI route-reality (§10.7 — source-verified at routes.tsx@HEAD)
|
||||
|
||||
`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:
|
||||
|
||||
| Survey expectation | routes.tsx@HEAD 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) |
|
||||
|
||||
> 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.
|
||||
|
||||
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`).
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **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).
|
||||
+200
-15
@@ -1,6 +1,21 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: OpenRig CLI Reference — Full rig Command Surface
|
||||
status: active
|
||||
topics: [runtime-control, agent-runtime]
|
||||
domains: [operating-advisor, engineering-advisor, orchestrator]
|
||||
applies-when: |
|
||||
Need the exact rig CLI surface — command groups, subcommands, flags,
|
||||
JSON output, cross-host, coordination primitives.
|
||||
siblings: [README.md, architecture/daemon-core.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 03a5f915
|
||||
last-updated: 2026-06-15
|
||||
---
|
||||
|
||||
# OpenRig CLI Reference
|
||||
|
||||
Verified against the shipped CLI on 2026-05-15 (v0.3.1) using:
|
||||
Verified against the shipped CLI on 2026-06-15 (v0.3.4) using:
|
||||
- `packages/cli/src/index.ts`
|
||||
- `packages/cli/src/commands/*.ts`
|
||||
- `packages/cli/src/mcp-server.ts`
|
||||
@@ -11,7 +26,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
## Overview
|
||||
|
||||
- Binary: `rig`
|
||||
- Top-level command groups: `57`
|
||||
- Top-level command groups: `64`
|
||||
- Output mode: human-readable by default; many commands also support `--json`
|
||||
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
|
||||
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
|
||||
@@ -22,6 +37,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `daemon` | Manage the OpenRig daemon |
|
||||
| `start` | Recovery entrypoint — daemon + kernel + per-rig restore (interactive or headless) |
|
||||
| `status` | Show rig status |
|
||||
| `snapshot` | Manage rig snapshots |
|
||||
| `restore` | Restore a rig from a snapshot |
|
||||
@@ -35,9 +51,13 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `attach` | Attach the current shell or agent into a rig node |
|
||||
| `bind` | Bind a discovered session to a rig node |
|
||||
| `adopt` | Materialize topology and bind discovered live sessions |
|
||||
| `reconcile-session` | No-launch, no-input adopt of a hand-resumed session |
|
||||
| `bundle` | Manage rig bundles |
|
||||
| `up` | Bootstrap a rig from a spec or bundle |
|
||||
| `down` | Tear down a rig |
|
||||
| `archive` | Archive a rig (soft + reversible: hides it from the default view, retains all data) |
|
||||
| `unarchive` | Unarchive a rig (reverse of `rig archive`): returns it to the default view |
|
||||
| `add` | Add a member to an existing pod in a running rig (`add_member` converge op) |
|
||||
| `env` | Inspect and control rig environment services |
|
||||
| `ps` | List rigs and their status |
|
||||
| `mcp` | MCP server for agent integration |
|
||||
@@ -78,6 +98,8 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `context-pack` | Browse, preview, send, and install operator-authored context packs |
|
||||
| `workspace` | Workspace primitive — typed-kind tooling (frontmatter validation) |
|
||||
| `plugin` | Inspect plugins (read-only) — list, show, used-by, validate |
|
||||
| `scope` | Scope tree primitive — missions, slices, sub-slices |
|
||||
| `policy` | Operator context-mode bindings (sleep/desk/mobile/away/focus/debug) |
|
||||
|
||||
## Core Daemon and System Commands
|
||||
|
||||
@@ -127,6 +149,9 @@ Supported keys:
|
||||
- `transcripts.enabled`
|
||||
- `transcripts.path`
|
||||
- `workspace.root` (and other workspace-rooted paths used by `init-workspace`)
|
||||
- `snapshots.periodic.enabled` (default `true`) — daemon-side periodic snapshot scheduler on/off (v0.3.4)
|
||||
- `snapshots.periodic.interval_seconds` (default `300`) — interval between periodic snapshots
|
||||
- `snapshots.periodic.retention_keep` (default `10`) — number of periodic snapshots to retain per rig
|
||||
|
||||
Precedence:
|
||||
- CLI flag
|
||||
@@ -137,6 +162,7 @@ Precedence:
|
||||
Notes:
|
||||
- `--with-source` (top-level) and `--show-source` (`get`) report per-key source/default for honest provenance.
|
||||
- `init-workspace` scaffolds the default workspace at `~/.openrig/workspace/` (or the `--root` override) with mission/slice folders. `--force` overwrites scaffolded files (does NOT remove directories); `--dry-run` previews without writing. New in v0.3.0.
|
||||
- `snapshots.periodic.*` (v0.3.4): the daemon-side scheduler takes periodic snapshots per rig at `interval_seconds` and retains the newest `retention_keep`. At restore time, newest-wins between `auto-periodic` and `auto-pre-down` snapshots.
|
||||
|
||||
Legacy env compatibility: the original runtime keys still accept deprecated
|
||||
`RIGGED_*` aliases. New typed config keys use `OPENRIG_*` only.
|
||||
@@ -174,6 +200,21 @@ Notes:
|
||||
- Managed tmux cleanup is intentionally conservative. It only removes sessions that are present in current DB state; unrelated tmux sessions are left alone.
|
||||
- Human output prints a compact destroy plan followed by the destroy result.
|
||||
|
||||
### `rig start`
|
||||
|
||||
Usage:
|
||||
- `rig start` (interactive: daemon + kernel + pick-and-restore)
|
||||
- `rig start --last [--json]` (headless: restore rigs that were last running)
|
||||
- `rig start --all [--json]` (headless: restore all rigs with restore-usable snapshots)
|
||||
- `rig start --rigs <name> [<name>...] [--json]` (headless: restore only the named rigs)
|
||||
|
||||
Notes:
|
||||
- Recovery entrypoint introduced in v0.3.4 (slice 01). Sequencing-only: composes daemon start + kernel auto-boot wait + per-rig restore primitives; re-codes nothing.
|
||||
- NOT the getting-started boot hero — that remains `rig up <starter>`. `rig start` is for post-reboot/crash recovery.
|
||||
- TTY interactive flow lists last-running candidates with a readiness summary (`[ready to resume]`, `[will ask before fresh]`, `[fresh start]`, `[mixed]`), then offers restore-all or a spacebar multi-select picker.
|
||||
- Headless modes (`--last`, `--all`, `--rigs`) take zero prompts; if a node returns `awaiting-decision`, the CLI reports it honestly and prints the `rig up --existing <rig> --fresh <logicalId>` command to take action.
|
||||
- Surface source-verified against `packages/cli/src/commands/start.ts` at `03a5f915` (v0.3.4).
|
||||
|
||||
### `rig mcp`
|
||||
|
||||
Usage: `rig mcp serve [--port <port>]`
|
||||
@@ -225,7 +266,7 @@ Notes:
|
||||
|
||||
### `rig up`
|
||||
|
||||
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
|
||||
Usage: `rig up <source> [--plan] [--yes] [--cwd <path>] [--target <root>] [--existing] [--fresh <seats...>] [--json]`
|
||||
|
||||
Arguments:
|
||||
- `source`: path to `.yaml` or `.rigbundle`, or a bare name
|
||||
@@ -239,10 +280,16 @@ Actual source resolution:
|
||||
- if both a library spec and existing rig share the same name, exits with an ambiguity error
|
||||
|
||||
Current behavior notes:
|
||||
- `--cwd <path>` overrides launch working directory for all members for this run only. For path-form `rig up <install-internal-spec>` invocations the CLI defaults `cwd` to the caller's directory (slice-22 Bug 3) so library specs match path-form behavior.
|
||||
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
|
||||
- `--existing` skips the library-spec name resolution and treats `<source>` as an existing rig name directly (disambiguates when a library spec and a stopped rig share the same name).
|
||||
- `--plan` previews the restore without executing (read-only). The preview honors an honest async timeout and reports per-node intended action.
|
||||
- `--fresh <seats...>` deliberately fresh-primes the named seats (logical ids) instead of resuming their original sessions (operation B); reported in the per-node status vocabulary as `fresh-primed`. Repeatable: `--fresh seat-a --fresh seat-b` or `--fresh seat-a seat-b`.
|
||||
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
|
||||
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
|
||||
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
|
||||
- v0.3.2 paper-cut fix-round (slice-22): pre-launch failures now return structured HTTP 4xx (`cycle_error` / `preflight_failed` / `validation_failed` / `service_boot_failed`) instead of bare 500; failed boots no longer leave orphan rig records on disk.
|
||||
- v0.3.4: `rig up` is resume-original-by-default for existing rigs. Per-seat opt-in to deliberate fresh-prime is via `--fresh <seats...>`. The five-term restore status vocabulary surfaced per-node is `resumed` / `fresh-primed` / `awaiting-decision` / `attention_required` / `failed`. On TTY, `awaiting-decision` nodes trigger an interactive [y/N] ASK; in headless mode they are reported honestly with the exact `rig up --existing <rig> --fresh <logicalId>` follow-up command.
|
||||
|
||||
Success modes:
|
||||
- fresh boot
|
||||
@@ -251,7 +298,10 @@ Success modes:
|
||||
|
||||
### `rig down`
|
||||
|
||||
Usage: `rig down <rigId> [--delete] [--force] [--snapshot] [--json]`
|
||||
Usage: `rig down <rig> [--delete] [--force] [--snapshot] [--json]`
|
||||
|
||||
`<rig>` accepts a rig **name or id**, symmetric with `rig up`. A name is
|
||||
resolved to its id via the active (non-archived) rig summary before teardown.
|
||||
|
||||
Flags:
|
||||
- `--delete`: delete the rig record after teardown
|
||||
@@ -261,8 +311,36 @@ Flags:
|
||||
Notes:
|
||||
- When `--snapshot` succeeds, human output includes the restore command.
|
||||
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
|
||||
- v0.2.0 known issue: `rig down <name> --delete` can return HTTP 404; use
|
||||
`rig ps` to find the rig ID and run `rig down <rigId> --delete`.
|
||||
- Destructive-op safety: if a name matches more than one rig, `rig down`
|
||||
refuses to tear down any of them and lists the matching ids - re-run with
|
||||
`rig down <id>`. An id always resolves directly (ids are never ambiguous).
|
||||
|
||||
### `rig archive`
|
||||
|
||||
Usage: `rig archive <rigId> [--force] [--json]`
|
||||
|
||||
Flags:
|
||||
- `--force`: archive even if the rig is running or degraded
|
||||
- `--json`: JSON output for agents
|
||||
|
||||
Notes:
|
||||
- Soft, reversible archive: hides the rig from the default explorer and `rig ps`, while retaining the rig record, topology, and snapshots.
|
||||
- Different from `rig down --delete` (delete is destructive; archive is recoverable).
|
||||
- Archived rigs are hidden from default `rig ps`; use `rig ps --include-archived` to see them.
|
||||
- Archiving a running or degraded rig requires `--force`; without it the call returns HTTP `409` with a three-part honest error and exits `2`.
|
||||
- Reverse with `rig unarchive <rigId>`.
|
||||
- Emits `rig.archived` SSE event.
|
||||
- Surface source-verified against `packages/cli/src/commands/archive.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
### `rig unarchive`
|
||||
|
||||
Usage: `rig unarchive <rigId> [--json]`
|
||||
|
||||
Notes:
|
||||
- Reverse of `rig archive`: clears the `archived_at` flag so the rig returns to the default explorer and `rig ps` view.
|
||||
- Always non-destructive (the row and snapshots were retained while archived); no `--force` and no running-rig guard.
|
||||
- Emits `rig.unarchived` SSE event.
|
||||
- Surface source-verified against `packages/cli/src/commands/unarchive.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
### `rig env`
|
||||
|
||||
@@ -364,12 +442,15 @@ Notes:
|
||||
Usage: `rig bundle <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `create <spec> -o <path> [--name <name>] [--bundle-version <ver>] [--include-packages <refs...>] [--rig-root <root>] [--json]`
|
||||
- `inspect <path> [--json]`
|
||||
- `install <path> [--plan] [--yes] [--target <root>] [--json]`
|
||||
- `create <spec> -o <path> [--name <name>] [--bundle-version <ver>] [--include-packages <refs...>] [--rig-root <root>] [--notes <text>] [--min-daemon-version <ver>] [--min-cli-version <ver>] [--json]` — pack a rig spec + its declared content into a `.rigbundle`. v0.3.2 slice-05 ships first-class cross-primitive bundling: skills + plugins (hybrid) + workflow_specs + context_packs + agent_images vendor end-to-end with both-sides path containment, symlink escape protection, and integrity hashing.
|
||||
- `inspect <path> [--json]` — inspect a `.rigbundle` manifest. v0.3.2 surfaces the cross-primitive content fields as first-class.
|
||||
- `install <path> [--plan] [--yes] [--target <root>] [--skip-version-check] [--force] [--json]` — install a `.rigbundle`. Routes each declared content kind to its canonical library under `$OPENRIG_HOME`. `--skip-version-check` is an operator-explicit override of the install-time daemon/CLI compatibility gate (NOT recommended). `--force` is an operator-explicit override of the install-time conflict check (NOT recommended; conflicts may produce partial install state).
|
||||
- `history [--rig <name>] [--since <iso>] [--json]` — list bundle install audit records from `~/.openrig/bundle-audit.jsonl`. Filters by target rig name and earliest `installedAt`.
|
||||
|
||||
Important:
|
||||
- `bundle create` requires `-o, --output <path>` by source definition.
|
||||
- v0.3.2 install timeout bumped (was 5s — too short for tmux-session-bootstrapping installs).
|
||||
- Deferred to 0.3.3 (per release packet): agent/port/managed-app collision detection (Item 4.3), broader install-into-existing-rig pathway acceptance (Item 4.4), and the `--target-name` CLI flag (slice-05 Item-3 sub-scopes; design-contingent on CLI surface decision).
|
||||
|
||||
### `rig package` (legacy)
|
||||
|
||||
@@ -465,6 +546,25 @@ Notes:
|
||||
- Materializes the topology first, then resolves/binds discovered sessions.
|
||||
- In JSON mode, emits the materialized nodes plus binding results.
|
||||
|
||||
### `rig reconcile-session`
|
||||
|
||||
Usage:
|
||||
- `rig reconcile-session <session> [--rig <rigId>] [--node <logicalId>] [--no-launch] [--json]`
|
||||
|
||||
Arguments:
|
||||
- `session`: canonical session name (e.g. `dev-impl@my-rig`) of the LIVE session to adopt.
|
||||
|
||||
Flags:
|
||||
- `--rig` and `--node` are paired disambiguators (both required together) when the session resolves ambiguously.
|
||||
- `--no-launch` is the only mode this command has; accepted for explicitness.
|
||||
|
||||
Notes:
|
||||
- No-launch, no-input adopt of a hand-resumed canonical session (slice 03 / v0.3.4). The operator already resumed the session externally (e.g. `claude --resume`, `codex resume`) inside its canonical tmux session; the daemon still shows the seat down.
|
||||
- Binds the live process to its OWN persisted node (same node id, no re-key) and updates the projection so `rig ps` / topology / send / capture / queue routing work again.
|
||||
- NEVER launches, relaunches, kills, replays startup, presses resume menus, compacts, or types into the pane.
|
||||
- Anything that could not be proven is reported as projection drift; conversation continuity is never claimed.
|
||||
- Surface source-verified against `packages/cli/src/commands/reconcile-session.ts` at `03a5f915` (v0.3.4).
|
||||
|
||||
### `rig expand`
|
||||
|
||||
Usage: `rig expand <rig-id> <pod-fragment-path> [--json] [--rig-root <path>]`
|
||||
@@ -474,6 +574,24 @@ Notes:
|
||||
- `--rig-root` controls agent resolution.
|
||||
- Member YAML may carry `session_source` (see "Session source declaration" below) to start the new seat from a prior native conversation (`mode: fork`) or from operator-declared artifacts (`mode: rebuild`).
|
||||
|
||||
### `rig add`
|
||||
|
||||
Usage: `rig add <rig-id> <pod-namespace> <member-fragment-path> [--json] [--rig-root <path>]`
|
||||
|
||||
Arguments:
|
||||
- `<rig-id>`: id of the target rig
|
||||
- `<pod-namespace>`: namespace of the existing pod to add the member to
|
||||
- `<member-fragment-path>`: path to a YAML/JSON member-fragment file (spec snake_case fields)
|
||||
|
||||
Notes:
|
||||
- The `add_member` converge op verb: adds a member to an existing pod in a running rig from a YAML/JSON member-fragment file.
|
||||
- Member fragment accepts both the bare form (top-level member fields) and the wrapper form (`{ member: {...}, edges?: [...] }`). A top-level `edges:` field in the bare form is lifted as pod-local edges and is NOT silently dropped.
|
||||
- A present-but-non-array `edges` field is rejected with an honest error (no silent drop).
|
||||
- `--rig-root <path>` controls agent resolution.
|
||||
- HTTP outcomes: `201` on success (with the new node + persisted edges + optional warnings); `409 member_conflict`; `400 validation_failed` / `preflight_failed`; `404 pod_not_found` (lists existing pods).
|
||||
- Exit code is non-zero if the HTTP call failed OR the new node did not fully launch (`status !== "launched"`).
|
||||
- Surface source-verified against `packages/cli/src/commands/add.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
### Session source declaration (`session_source`)
|
||||
|
||||
Member YAML in a rig spec or `rig expand` payload may declare a launch-time `session_source` to control how the new managed seat derives its starting context. Two modes are supported in v1:
|
||||
@@ -537,11 +655,13 @@ Notes:
|
||||
|
||||
### `rig launch`
|
||||
|
||||
Usage: `rig launch <rigId> <nodeRef> [--json]`
|
||||
Usage: `rig launch <rigId> [nodeRef] [--seats <ids>] [--hold-reason <reason>] [--json]`
|
||||
|
||||
Notes:
|
||||
- Launches or relaunches a node in a running rig.
|
||||
- `nodeRef` can be a logical ID or node ID.
|
||||
- `nodeRef` (optional) can be a logical ID or node ID for the single-target form.
|
||||
- `--seats <ids>` (v0.3.4, slice 11) takes a comma-separated list of logical IDs for node-granular managed partial restore — launch a named subset of seats while holding the rest. Retires the prior `pod_aware_launch_unsupported` dead-end.
|
||||
- `--hold-reason <reason>` records the reason non-target seats are being held; surfaced via observability so the held state is auditable.
|
||||
|
||||
### `rig remove`
|
||||
|
||||
@@ -576,6 +696,7 @@ Identity resolution order:
|
||||
Notes:
|
||||
- If the daemon is unreachable but an identity source can still be resolved, `--json` returns a partial result instead of crashing.
|
||||
- Human-readable output includes transcript location and context usage when available.
|
||||
- `peers[]` is this rig's roster excluding self (no edge filter); use `edges{}` for directional relationships and `rig ps --nodes` for node inventory including self + live state.
|
||||
- In Claude Code projects, unattended `rig whoami` on boot may require the local permissions allow list to include `Bash(rig:*)`.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Identity resolution happens on the REMOTE rig (each host has its own daemon + tmux + identity context); local `--node-id`/`--session` flags are forwarded to the remote `rig whoami` invocation. The remote rig's output is verbatim passthrough on success; failure is distinguished into the same `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` enum as `rig ps --host` and `rig send --host`.
|
||||
|
||||
@@ -721,7 +842,7 @@ Notes:
|
||||
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
|
||||
|
||||
Subcommands:
|
||||
- `create --source <session> --destination <session> --body <text> [--priority <p>] [--tier <t>] [--tags <csv>] [--expires-at <iso>] [--id <qitemId>] [--json]`
|
||||
- `create --source <session> --destination <session> (--body <text> | --body-file <path>) [--mission <id>] [--slice <id>] [--priority <p>] [--tier <t>] [--tags <csv>] [--target-repo <name>] [--no-nudge] [--expires-at <iso>] [--id <qitemId>] [--json]` — v0.3.2 slice-21 FR-4 adds `--body-file <path>` (use `-` for stdin) which kills the backtick-shell-corruption class for multiline bodies, and first-class `--mission <id>` / `--slice <id>` flags that translate to `mission:<id>` / `slice:<id>` tags (compose with `--tags`). Exactly one of `--body` / `--body-file` must be provided.
|
||||
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
|
||||
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
|
||||
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
|
||||
@@ -859,16 +980,18 @@ Usage: `rig seat <subcommand>`
|
||||
Subcommands:
|
||||
- `status <seat> [options]` — show read-only seat handover observability status.
|
||||
- `handover <seat> [options]` — plan a safe two-phase seat handover.
|
||||
- `clear-attention <session> [--reason <text>] [--json]` — evidence-gated, operator-attested, audited reconcile of a stuck `attention_required` seat.
|
||||
|
||||
Notes:
|
||||
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
|
||||
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
|
||||
- `clear-attention` (v0.3.4) clears a stuck `attention_required` startup status using captured evidence; `--reason <text>` is an operator attestation override that skips the evidence gate (audited). Replaces SQLite hand-edit workarounds.
|
||||
|
||||
## Mission Control / Queue Observability (PL-005 Phase A)
|
||||
|
||||
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only."
|
||||
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture.md` § Mission Control / Queue Observability. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture/mission-control.md`. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
|
||||
Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
|
||||
|
||||
@@ -917,10 +1040,11 @@ Notes:
|
||||
Usage: `rig workspace <subcommand>` — Workspace Primitive (PL-007), v0 typed-kind tooling.
|
||||
|
||||
Subcommands:
|
||||
- `validate [root]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`.
|
||||
- `validate [root] [--kind <kind>] [--no-recursive] [--require-frontmatter] [--max-files <n>] [--json]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`. `--kind` validates against a specific workspace kind (`user | project | knowledge | lab | delivery`). `--max-files` (default `10000`) hard-caps the walk; v0.3.2 slice-01 GA enforces strict-int regex on this flag.
|
||||
- `doctor [--workspace <path>] [--strict] [--json]` — v0.3.2 slice-21 FR-5. Run a 7-check workspace-readiness diagnostic against the daemon's resolved workspace (workspace root, missions folder, file allowlist, daemon alignment, daemon reload, optional slice docs, MISSION_NOTES presence). Read-only. Default exit-code: non-zero only on `fail`; `--strict` makes warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST` from the operator's shell env so doctor reflects the operator's intended allowlist even when the daemon's env is unchanged.
|
||||
|
||||
Notes:
|
||||
- v0 surface is intentionally narrow (`validate` only) — read-only frontmatter audit.
|
||||
- v0 surface was intentionally narrow (`validate` only); v0.3.2 added `doctor` as the operator-facing readiness diagnostic.
|
||||
- Future versions will add typed-kind authoring/refactor tooling on the same root walker.
|
||||
- See `rig config init-workspace` to scaffold a fresh default workspace.
|
||||
|
||||
@@ -946,6 +1070,67 @@ Notes:
|
||||
- `openrig-core` ships bundled with the daemon (11 skills). Additional plugins (`gstack` — 45 skills; `obra-superpowers` — 14 skills) ship as substrate references for plugin authors to copy-install per the `OPENRIG-INSTALL.md` workflow inside each plugin's source tree.
|
||||
- A first-class `rig plugin install <substrate-path>` verb is deferred to 0.3.2.
|
||||
|
||||
## Scope Tree Primitive (v0.3.2)
|
||||
|
||||
One top-level command first shipped in v0.3.2 (`scopeCommand`,
|
||||
`packages/cli/src/index.ts:20,187`; defined in
|
||||
`packages/cli/src/commands/scope.ts`; release-0.3.2 slice 12). Operates
|
||||
the scope tree (missions, slices, sub-slices) per
|
||||
`conventions/scope-and-versioning`.
|
||||
|
||||
### `rig scope`
|
||||
|
||||
Usage: `rig scope <subcommand>` — scope tree primitive: missions, slices, sub-slices.
|
||||
|
||||
Top-level option:
|
||||
- `--workspace <path>` — override workspace root (otherwise inferred from cwd or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
Two subcommand groups: `slice` and `mission`.
|
||||
|
||||
`rig scope slice <subcommand>` — slice-tier commands:
|
||||
- `ls [--mission <name>] [--state <state>] [--json]` — list slices in a mission (or across all missions). `--state` filter: `active | closed | shipped | all` (default `active`).
|
||||
- `show <slice-path> [--mission <name>] [--json]` — inspect a single slice (frontmatter + README + children). `slice-path` is absolute, relative-to-substrate, or `NN-slug`; `--mission` hints the mission when path is just `NN-slug`.
|
||||
- `create <mission> <slug> [--template <kind>] [--title <text>] [--json]` — create a new slice in a mission. `--template` default `placeholder`.
|
||||
- `ship <slice-path> <release-mission> [--mission <name>] [--json]` — ship a slice to a release mission (preserves git history).
|
||||
- `close <slice-path> [--note <text>] [--mission <name>] [--json]` — close a slice (move to `<mission>/closed/`, update status). `--note` is an optional closure note.
|
||||
- `move <slice-path> <dest-mission> [--mission <name>] [--json]` — move a slice between missions (re-numbers in destination).
|
||||
|
||||
`rig scope mission <subcommand>` — mission-tier commands:
|
||||
- `ls [--json]` — list missions (top-level folders with `README.md`).
|
||||
- `show <mission> [--json]` — inspect a single mission.
|
||||
- `create <name> [--template <kind>] [--id <dot-id>] [--title <text>] [--json]` — create a new mission (mints a stable dot-ID into frontmatter). `--template` auto-selects when name matches `release-X.Y.Z`; `--id` overrides name-pattern inference.
|
||||
|
||||
Notes:
|
||||
- Surface source-verified against `packages/cli/src/commands/scope.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
## Operator Context-Mode Bindings (v0.3.2)
|
||||
|
||||
One top-level command first shipped in v0.3.2 (`rigPolicyCommand`,
|
||||
defined in `packages/cli/src/commands/rig-policy.ts`; release-0.3.2
|
||||
slice 09). Pairs with the daemon's typed-primitive store at
|
||||
`packages/daemon/src/db/migrations/041_rig_policy.ts`. Operates the
|
||||
operator context-mode binding surface (sleep / desk / mobile / away /
|
||||
focus / debug) used by mode-aware agent posture.
|
||||
|
||||
### `rig policy`
|
||||
|
||||
Usage: `rig policy <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `set <mode> [--scope <scope>] [--qualifier <id>] [--<field> ...] [--evidence <citation>] [--confirm] [--bearer <token>] [--json]` — propose a binding. Without `--confirm` the CLI echoes the proposed binding and exits with `exit 2` so scripts cannot accidentally apply; `--confirm` is the explicit operator action. `<scope>` is one of `global_host | rig | workstream | qitem` (defaults to the per-mode recommendation). `--qualifier <id>` is required for `rig | workstream | qitem` scopes and rejected for `global_host`. Per-field tuning flags: `--autonomy-scope`, `--heartbeat-cadence`, `--inspection-depth`, `--update-detail`, `--escalation-threshold`, `--concurrency-limit`, `--permission-prompt-posture` (one of `normal | batch_for_human | do_not_prompt_unless_blocked`; `auto_accept` is FORBIDDEN by convention), `--expiry-or-stale-rule`. `--evidence` carries a free-text operator citation (message id, file pointer, chatroom topic, etc.).
|
||||
- `show [--json]` — list all operator-context-mode bindings.
|
||||
- `effective [--rig <id>] [--workstream <id>] [--qitem <id>] [--json]` — resolve the effective mode for a (rig, workstream, qitem) read context. Surfaces `unknown_posture` when no binding matches.
|
||||
- `cite [--rig <id>] [--workstream <id>] [--qitem <id>]` — emit the short-prose citation line for the effective mode at the read context (per convention §Citation Rules).
|
||||
- `unset <scope> [qualifier] [--bearer <token>] [--json]` — delete one binding (operator-only).
|
||||
- `defaults [--json]` — print the recommended per-mode 6×7 field defaults + default-scope mapping + stale rule.
|
||||
|
||||
Notes:
|
||||
- The 6 modes are `sleep | desk | mobile | away | focus | debug`. Bare-word invocation is accepted (`set desk`); `mode:<word>` is the disambiguated prefix form.
|
||||
- Restate-and-confirm posture (HG-4): `set` is restate-only until `--confirm` is passed. This prevents accidental script application.
|
||||
- `--qualifier` strict reject for `global_host` (HG-7 guard finding): operators who type `--scope global_host --qualifier <id>` get an error and the daemon is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
- Operator-edit mutations (`set --confirm`, `unset`) require an operator bearer token (`--bearer` or `OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
- Surface source-verified against `packages/cli/src/commands/rig-policy.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
|
||||
+168
-399
@@ -1,425 +1,194 @@
|
||||
# OpenRig Source Codemap
|
||||
---
|
||||
kind: as-built
|
||||
title: OpenRig Codemap — Navigation Index / Map of Territory
|
||||
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]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
|
||||
# OpenRig Codemap — Navigation Index
|
||||
|
||||
Current footprint:
|
||||
- daemon: 211 source files (126 domain, 31 routes, 11 adapters, 36 migrations, 7 other)
|
||||
- CLI: 78 source files
|
||||
- UI: 87 source files
|
||||
- total: 376 source files
|
||||
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.
|
||||
|
||||
How to use this doc:
|
||||
- `Exports` lists the public symbols defined by the file.
|
||||
- `Related` lists the file's primary local outgoing dependencies so you can follow the code path quickly.
|
||||
- The package/area groupings are structural, not ownership metadata.
|
||||
> 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.
|
||||
|
||||
Fast lookup:
|
||||
- Daemon app wiring and mounted HTTP surface: `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/routes/*`
|
||||
- AgentSpec / pod-aware engine: `packages/daemon/src/domain/agent-*`, `profile-resolver.ts`, `projection-planner.ts`, `startup-*`, `rigspec-*`, `pod-repository.ts`
|
||||
- Rig environment / managed-app substrate: `packages/daemon/src/adapters/compose-services-adapter.ts`, `packages/daemon/src/domain/service-orchestrator.ts`, `packages/daemon/src/domain/services-readiness.ts`, `packages/daemon/src/routes/env.ts`
|
||||
- Snapshot / restore / continuity: `packages/daemon/src/domain/snapshot-*`, `checkpoint-store.ts`, `restore-orchestrator.ts`
|
||||
- Transport / communication: `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/routes/transport.ts`
|
||||
- Transcripts / persistence: `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/domain/history-query.ts`, `packages/daemon/src/routes/transcripts.ts`
|
||||
- Ask / context packs: `packages/daemon/src/domain/ask-service.ts`, `packages/daemon/src/routes/ask.ts`
|
||||
- Chat: `packages/daemon/src/domain/chat-repository.ts`, `packages/daemon/src/routes/chat.ts`
|
||||
- Spec review / library / identity: `packages/daemon/src/domain/spec-review-service.ts`, `packages/daemon/src/domain/spec-library-service.ts`, `packages/daemon/src/domain/whoami-service.ts`, `packages/daemon/src/routes/spec-review.ts`, `packages/daemon/src/routes/spec-library.ts`, `packages/daemon/src/routes/whoami.ts`
|
||||
- Node inventory: `packages/daemon/src/domain/node-inventory.ts`
|
||||
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
|
||||
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
|
||||
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (53 groups)
|
||||
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
|
||||
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
|
||||
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
|
||||
- UI spec/library surfaces: `packages/ui/src/components/RigSpecDisplay.tsx`, `packages/ui/src/components/AgentSpecDisplay.tsx`, `packages/ui/src/components/LibraryReview.tsx`, `packages/ui/src/components/SpecsWorkspace.tsx`
|
||||
- UI managed-app runtime surfaces: `packages/ui/src/components/RigEnvPanel.tsx`, `packages/ui/src/hooks/useRigEnv.ts`, `packages/ui/src/lib/build-setup-prompt.ts`
|
||||
- UI live identity surfaces: `packages/ui/src/components/LiveIdentityDisplay.tsx`, `packages/ui/src/components/LiveNodeDetails.tsx`
|
||||
- UI data layer: `packages/ui/src/hooks/*`
|
||||
## (a) Product frame
|
||||
|
||||
## Daemon
|
||||
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.
|
||||
|
||||
### Entrypoints And Wiring
|
||||
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
|
||||
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
|
||||
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 36 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, Final Stretch 3 managed-app services, and PL-004 coordination services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
> 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").
|
||||
|
||||
### Adapters
|
||||
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/claude-resume.ts`: Resume adapter for claude sessions. Exports: ClaudeResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux-transport.ts`: Adapter/util module for cmux transport integration. Exports: createCmuxCliTransport. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux.ts`: Adapter/util module for cmux integration. Exports: CmuxAdapter, CmuxResult, CmuxStatus, CmuxSurface, CmuxTransport, CmuxTransportFactory, CmuxWorkspace. Related: none.
|
||||
- `packages/daemon/src/adapters/compose-services-adapter.ts`: Compose-backed rig-environment adapter for `up`, `ps`, `logs`, and teardown. Exports: ComposePsService, ComposeServicesAdapter, ComposeServicesUpParams, ComposeServicesUpResult, ComposeServicesLogsParams, ComposeServicesLogsResult, ComposeServicesDownParams, ComposeServicesDownResult. Related: `packages/daemon/src/adapters/tmux-exec.js`.
|
||||
- `packages/daemon/src/adapters/codex-resume.ts`: Resume adapter for codex sessions. Exports: CodexResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`: Adapter implementation for codex runtime integration. Exports: CodexAdapterFsOps, CodexRuntimeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/terminal-adapter.ts`: Infrastructure runtime adapter for terminal/shell nodes. No-op project/deliver/launch, immediate readiness. Exports: TerminalAdapter. Related: `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/shell-quote.ts`: Adapter/util module for shell quote integration. Exports: shellQuote. Related: none.
|
||||
- `packages/daemon/src/adapters/tmux-exec.ts`: Adapter/util module for tmux exec integration. Exports: execCommand. Related: `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/tmux.ts`: Adapter/util module for tmux integration. Exports: ExecFn, TmuxAdapter, TmuxPane, TmuxResult, TmuxSession, TmuxWindow. Related: none.
|
||||
## (b) Module map
|
||||
|
||||
### DB Core
|
||||
- `packages/daemon/src/db/connection.ts`: Creates the SQLite connection with daemon-wide pragmas. Exports: createDb. Related: none.
|
||||
- `packages/daemon/src/db/migrate.ts`: Migration runner and `Migration` type shared by all schema files. Exports: Migration, migrate. Related: none.
|
||||
One row per module. "Reach for this when…" mirrors each module's actual
|
||||
`applies-when` frontmatter.
|
||||
|
||||
### DB Migrations
|
||||
- `packages/daemon/src/db/migrations/001_core_schema.ts`: Database migration for core schema. Exports: coreSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/002_bindings_sessions.ts`: Database migration for bindings sessions. Exports: bindingsSessionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/003_events.ts`: Database migration for events. Exports: eventsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/004_snapshots.ts`: Database migration for snapshots. Exports: snapshotsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/005_checkpoints.ts`: Database migration for checkpoints. Exports: checkpointsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/006_resume_metadata.ts`: Database migration for resume metadata. Exports: resumeMetadataSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/007_node_spec_fields.ts`: Database migration for node spec fields. Exports: nodeSpecFieldsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/008_packages.ts`: Database migration for packages. Exports: packagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/009_install_journal.ts`: Database migration for install journal. Exports: installJournalSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/010_journal_seq.ts`: Database migration for journal seq. Exports: journalSeqSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/011_bootstrap.ts`: Database migration for bootstrap. Exports: bootstrapSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/012_discovery.ts`: Database migration for discovery. Exports: discoverySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/013_discovery_fk_fix.ts`: Database migration for discovery fk fix. Exports: discoveryFkFix. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/014_agentspec_reboot.ts`: Database migration for agentspec reboot. Exports: agentspecRebootSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/015_startup_context.ts`: Database migration for startup context. Exports: startupContextSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/016_chat_messages.ts`: Database migration for durable rig-scoped chat messages. Exports: chatMessagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/017_pod_namespace.ts`: Database migration that persists authored pod namespace separately from pod DB id for export, adoption, and identity parity. Exports: podNamespaceSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/021_seat_handover_observability.ts`: Database migration for seat-handover observability fields. Exports: seatHandoverObservabilitySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/022_node_codex_config_profile.ts`: Database migration adding Codex config-profile field on nodes. Exports: nodeCodexConfigProfileSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/023_stream_items.ts`: PL-004 Phase A — L1 stream items table (append-only intake/audit root). Exports: streamItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/024_queue_items.ts`: PL-004 Phase A — L3 queue items table (owned-work queue with closure obligation). Exports: queueItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/025_queue_transitions.ts`: PL-004 Phase A — append-only queue state transition log. Exports: queueTransitionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/026_inbox_entries.ts`: PL-004 Phase A — mailbox-style inbox for asynchronous deposit. Exports: inboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/027_outbox_entries.ts`: PL-004 Phase A — sender-side outbox audit. Exports: outboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/028_project_classifications.ts`: PL-004 Phase B — L2 project classifications table (idempotent on stream_item_id; FK to stream_items). Exports: projectClassificationsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/029_classifier_leases.ts`: PL-004 Phase B — single-writer classifier lease table (partial UNIQUE INDEX on state='active'). Exports: classifierLeasesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/030_views_custom.ts`: PL-004 Phase B — custom view registry. Exports: viewsCustomSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/031_watchdog_jobs.ts`: PL-004 Phase C — watchdog jobs (canonical scheduler state; policy enum + intervals + lifecycle). Exports: watchdogJobsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/032_watchdog_history.ts`: PL-004 Phase C — append-only watchdog evaluation history (sent/skipped/terminal; not_due polls not recorded). Exports: watchdogHistorySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/033_workflow_specs.ts`: PL-004 Phase D — workflow specs (cached read-through from markdown/YAML; UNIQUE on name+version; source_hash invalidation). Exports: workflowSpecsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/037_mission_control_actions.ts`: PL-005 Phase A — Mission Control action audit table (append-only; 7-verb enum at app layer; before/after qitem snapshots; FK to queue_items). Exports: missionControlActionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
### architecture/
|
||||
|
||||
### Domain: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-action-log.ts`: Append-only writer for mission_control_actions. API surface lacks update/delete/remove (contract enforcement). Validates verb-specific required fields (annotate→annotation, hold/drop→reason). Exports: MissionControlActionLog, MissionControlActionLogError, MissionControlVerb, MISSION_CONTROL_VERBS. Related: `packages/daemon/src/db/migrations/037_mission_control_actions.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-write-contract.ts`: LOAD-BEARING. Atomic 7-verb action coordinator. Each verb is one db.transaction: queue mutation via Phase D's QueueRepository.updateWithinTransaction (preserves Phase A hot-potato closure validation; emits queue.updated) + audit row + persisted mission_control.action_executed event. The 4-step handoff shape (source-update + destination-create + opt-in best-effort notify + audit append) is verified atomic; notify failure does NOT roll back durable state. Exports: MissionControlWriteContract, MissionControlWriteContractError, MissionControlActionInput, MissionControlActionResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/mission-control/mission-control-action-log.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-read-layer.ts`: Maps 7 Mission Control views to source-of-truth paths. my-queue/human-gate/active-work/recent-ships query queue_items via QueueRepository; fleet uses fleetCliCapability roll-up; recently-active delegates to PL-004 Phase B ViewProjector.show("recently-active"); recent-observations reads stream_items via StreamStore. All rows return the 9-field phone-friendly content model (PRD non-negotiable). Exports: MissionControlReadLayer, MissionControlViewName, CompactStatusRow, MISSION_CONTROL_VIEWS. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/view-projector.js`, `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts`: Per-rig CLI capability cache + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: per-field availability check, per-rig honesty, once-per-session-per-rig logging, "rigs running stale CLI" indicator. Synthesizes per-rig activity state (active|idle|attention|blocked|degraded) from queue_items aggregation. Exports: MissionControlFleetCliCapability, FleetRollupRow, FleetRollup, MISSION_CONTROL_DESIRED_FIELDS. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/db/migrations/024_queue_items.js`.
|
||||
- `packages/daemon/src/routes/mission-control.ts`: Hono route module for `/api/mission-control` (`GET /views` literal list; `GET /views/:view-name` view fetch; `POST /action` 7-verb atomic execute; `GET /sse` + `GET /watch` SSE; `GET /cli-capabilities` fleet drift cache). SSE/literal paths mounted before /views/:view-name catchall per PL-004 Phase A R1 route-order discipline. Exports: missionControlRoutes. Related: `packages/daemon/src/domain/mission-control/*`, `packages/daemon/src/domain/event-bus.js`.
|
||||
| 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. |
|
||||
|
||||
### Product UI: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/ui/src/components/mission-control/MissionControlSurface.tsx`: Top-level Mission Control surface inside the existing product UI shell. Tab navigation across the 7 view names. Mounted at the `/mission-control` route. Exports: MissionControlSurface.
|
||||
- `packages/ui/src/components/mission-control/views/{MyQueueView,HumanGateView,FleetView,ActiveWorkView,RecentShipsView,RecentlyActiveView,RecentObservationsView}.tsx`: 7 view components. Most wrap a shared GenericListView; FleetView combines CliDriftIndicator + per-row MissingFieldPlaceholder; MyQueueView combines HumanSeatCard + GenericListView with verb actions enabled.
|
||||
- `packages/ui/src/components/mission-control/components/CompactStatusRow.tsx`: 9-field phone-friendly content-model atom (PRD non-negotiable). Renders compact / expanded density modes; UI may collapse but JSON preserves all 9 fields.
|
||||
- `packages/ui/src/components/mission-control/components/VerbActions.tsx`: Shared 7-verb action sub-component. Renders verb buttons + per-verb required-field input (destination for route/handoff, annotation for annotate, reason for hold/drop). Submits to `POST /api/mission-control/action`. Invalidates Mission Control view queries on success.
|
||||
- `packages/ui/src/components/mission-control/components/HumanSeatCard.tsx`: First-class human-seat rendering. Shows session identity, pending load, blocked count, verb capabilities. NOT invisible config-layer convention.
|
||||
- `packages/ui/src/components/mission-control/components/CliDriftIndicator.tsx`: Cross-CLI-version drift indicator (PRD sub-clause 4). Shows fleet-level "rigs running stale CLI" badge + per-row "field unavailable on this rig's daemon version" placeholders via the MissingFieldPlaceholder export.
|
||||
- `packages/ui/src/components/mission-control/hooks/{useMissionControlView,useMissionControlAction,useMissionControlCliCapabilities}.ts`: TanStack Query hooks fetching from `/api/mission-control/{views/:name,action,cli-capabilities}`.
|
||||
### ui/
|
||||
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
- `packages/daemon/src/domain/watchdog-history-log.ts`: Append-only writer for watchdog_history. Records meaningful evaluations only (sent/skipped/terminal). API surface intentionally lacks update/delete. Exports: WatchdogHistoryLog, WatchdogHistoryEntry, WatchdogOutcome. Related: `packages/daemon/src/db/migrations/032_watchdog_history.js`.
|
||||
- `packages/daemon/src/domain/watchdog-policy-engine.ts`: Resolves policy by name, parses spec_yaml context, dispatches policy.evaluate(), records meaningful outcomes via history-log, emits watchdog.* events, calls injected delivery callback for action=send. Exports: WatchdogPolicyEngine, DeliveryFn, DeliveryRequest, DeliveryOutcome. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/watchdog-scheduler.ts`: Tick-driven daemon-native scheduler that joins the supervision tree. Filters listActive() through isDue(), evaluates due jobs sequentially, supports start/stop with graceful in-flight await. Exports: WatchdogScheduler, isDue. Related: `packages/daemon/src/domain/watchdog-policy-engine.js`, `packages/daemon/src/domain/watchdog-jobs-repository.js`.
|
||||
- `packages/daemon/src/domain/policies/types.ts`: Policy contract (Policy, PolicyJob, PolicyEvaluation). Exports: Policy, PolicyJob, PolicyEvaluation. Related: none.
|
||||
- `packages/daemon/src/domain/policies/periodic-reminder.ts`: Policy that returns send {target, message} unconditionally; throws policy_spec_invalid if context.target.session or context.message missing. Translated from POC `lib/policies/periodic-reminder.mjs`. Exports: periodicReminderPolicy. Related: `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-ready.ts`: Policy that scans context.pools and skips with no_actionable_artifacts when empty; otherwise sends a formatted message capped at max_items. Translated from POC `lib/policies/artifact-pool-ready.mjs`. Exports: artifactPoolReadyPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/edge-artifact-required.ts`: Policy that scans source_pools and target_pools, computes source-key per artifact, and sends an edge-repair message for sources lacking matching downstream artifacts. Skips with no_missing_edge_artifacts when all sources matched. Translated from POC `lib/policies/edge-artifact-required.mjs`. Exports: edgeArtifactRequiredPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-helpers.ts`: Shared filesystem scanner + formatter for artifact-pool-ready and edge-artifact-required. Translated from POC `lib/policies/artifact-pool.js`. Exports: scanArtifactPools, formatArtifactList, sourceKeyFor, ArtifactPoolSpec, ScannedArtifact. Related: none.
|
||||
- `packages/daemon/src/routes/watchdog.ts`: Hono route module for `/api/watchdog` (`POST /register`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:job_id`, `GET /:job_id/status`, `POST /:job_id/stop`). SSE/literal paths mounted before /:job_id catchall per Phase A R1 route-order discipline. Exports: watchdogRoutes. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/policies/workflow-keepalive.ts`: PL-004 Phase D — workflow-keepalive policy (carry-forward from Phase C deferral). Reads workflow_instances directly via SQLite (audit row 18 LOAD-BEARING); requires status active|waiting; resolves frontier qitem owners via queue_items. Translated from POC `lib/policies/workflow-keepalive.mjs`; source-of-truth switched from markdown to SQLite. Exports: makeWorkflowKeepalivePolicy. Related: `packages/daemon/src/domain/policies/types.js`, `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
| 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. |
|
||||
|
||||
### Domain: Workflow Runtime (PL-004 Phase D)
|
||||
- `packages/daemon/src/domain/workflow-types.ts`: Shared types (WorkflowSpec, WorkflowInstance, WorkflowStepTrailEntry, WorkflowExitKind). Reverse-engineered from POC `workflow-runtime-v0/lib/workflow-runtime.rb` + fixture YAMLs. Exports: WorkflowSpec, WorkflowStepSpec, WorkflowRoleSpec, WorkflowInvariants, WorkflowClosureMessages, WorkflowLoopGuards, WorkflowInstance, WorkflowInstanceStatus, WorkflowStepTrailEntry, WorkflowExitKind, WorkflowSpecRow. Related: none.
|
||||
- `packages/daemon/src/domain/workflow-spec-cache.ts`: Read-through cache from markdown/YAML spec files into workflow_specs table. Workspace-surface reconciliation: source_hash invalidates the cache on next read; valid operator edits to spec files win. Uses the `yaml` package for parsing. Exports: WorkflowSpecCache, WorkflowSpecError, parseWorkflowSpec. Related: `packages/daemon/src/db/migrations/033_workflow_specs.js`.
|
||||
- `packages/daemon/src/domain/workflow-validator.ts`: Validates a workflow spec against role resolution, step uniqueness, allowed-exits consistency, and (optional via injected callback) seat liveness. Returns structured ValidationResult with severity-tagged issues. Exports: WorkflowValidator, ValidationIssue, ValidationResult, SeatLivenessCheckFn. Related: `packages/daemon/src/domain/workflow-types.js`.
|
||||
- `packages/daemon/src/domain/workflow-instance-store.ts`: CRUD on workflow_instances + frontier tracking. Survives daemon restart (queries SQLite directly). Exports: WorkflowInstanceStore, WorkflowInstanceError, CreateWorkflowInstanceInput. Related: `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
- `packages/daemon/src/domain/workflow-step-trail-log.ts`: Append-only writer/reader for workflow_step_trails. API surface lacks update/delete (contract enforcement). Mirrors Phase A's queue-transition-log + Phase C's watchdog-history-log shape. Exports: WorkflowStepTrailLog, WorkflowStepTrailRecordInput. Related: `packages/daemon/src/db/migrations/035_workflow_step_trails.js`.
|
||||
- `packages/daemon/src/domain/workflow-projector.ts`: LOAD-BEARING. Implements the transactional-scribe contract: closing a workflow_instances frontier qitem AND creating the next-step queue_items row AND appending the workflow_step_trails entry AND updating instance frontier+status all happen in the SAME daemon transaction (db.transaction). If any side throws, all roll back. Lost handoffs are impossible by design. Post-commit, fans out persisted events + nudges next owner. Uses Phase A QueueRepository.createWithinTransaction() (Phase D extension point). Exports: WorkflowProjector, WorkflowProjectorError, ProjectStepInput, ProjectStepResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/workflow-instance-store.js`, `packages/daemon/src/domain/workflow-step-trail-log.js`.
|
||||
- `packages/daemon/src/domain/workflow-runtime.ts`: Facade over spec-cache + validator + instance-store + projector + step-trail-log. Operations: validate, instantiate (creates instance + entry qitem in same transaction), project (delegates to projector), continue (idempotent inspector). Exports: WorkflowRuntime, WorkflowRuntimeDeps, InstantiateInput, InstantiateResult, WorkflowInstanceError, WorkflowProjectorError, WorkflowSpecError. Related: `packages/daemon/src/domain/workflow-projector.js`, `packages/daemon/src/domain/workflow-spec-cache.js`.
|
||||
- `packages/daemon/src/routes/workflow.ts`: Hono route module for `/api/workflow` (`POST /validate`, `POST /instantiate`, `POST /project`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:instance_id`, `GET /:instance_id/trace`, `POST /:instance_id/continue`). SSE/literal paths mounted before /:instance_id catchall per Phase A R1 route-order discipline. Exports: workflowRoutes. Related: `packages/daemon/src/domain/workflow-runtime.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
### root
|
||||
|
||||
### Domain: Core Runtime
|
||||
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
|
||||
- `packages/daemon/src/domain/event-bus.ts`: Persistent event log plus in-process pub/sub for routes, SSE, and domain emitters. Exports: EventBus. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/graph-projection.ts`: Pure projection from persisted rig/session state to React Flow graph nodes and edges. Exports: ReactFlowGraph, RigGraphInput, projectRigToGraph. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/context-monitor.ts`: Daemon-owned context-usage recorder that samples live runtime state and updates persistent usage summaries. Exports: ContextMonitor. Related: `packages/daemon/src/domain/context-usage-store.js`.
|
||||
- `packages/daemon/src/domain/context-usage-store.ts`: SQLite-backed store for per-node context usage snapshots used by whoami/node inventory surfaces. Exports: ContextUsageStore, ContextUsageSnapshot, ContextUsageStoreOpts. Related: none.
|
||||
- `packages/daemon/src/domain/node-launcher.ts`: Atomic node launch service that creates tmux, session rows, bindings, and launch events together. Exports: LaunchResult, NodeLauncher. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-name.js`, +2 more.
|
||||
- `packages/daemon/src/domain/ps-projection.ts`: Builds the `/api/ps` summary view over rigs, nodes, sessions, and latest snapshots. Exports: PsEntry, PsProjectionService. Related: none.
|
||||
- `packages/daemon/src/domain/reconciler.ts`: Marks missing tmux sessions as detached and keeps persisted session state aligned with reality. Exports: ReconcileResult, Reconciler. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, edge, and rig-services state. Also projects `hasServices` into rig summaries for runtime/UI gating. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rig-teardown.ts`: Graceful rig shutdown/delete orchestrator used by `/api/down`. Exports: RigTeardownOrchestrator, TeardownResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +2 more.
|
||||
- `packages/daemon/src/domain/service-orchestrator.ts`: Rig-scoped services orchestrator for env boot, readiness gating, receipt capture, and teardown. Exports: ServiceBootResult, ServiceOrchestrator, ServiceTeardownResult. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/services-readiness.js`.
|
||||
- `packages/daemon/src/domain/session-name.ts`: Tmux session-name derivation and validation helpers. Exports: deriveSessionName, validateSessionName. Related: none.
|
||||
- `packages/daemon/src/domain/session-registry.ts`: Session and binding lifecycle repository, including claimed-session registration and startup-status updates. Exports: SessionRegistry. Related: `packages/daemon/src/domain/session-name.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/services-readiness.ts`: Shared readiness evaluator for RigEnv wait targets (`url`, `tcp`, `service + condition`). Exports: WaitTargetFailure, WaitTargetSuccess, evaluateWaitTargets. Related: none.
|
||||
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, RigEnv/service contracts, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, EnvCheckpoint, EnvReceipt, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigServicesRecord, RigServicesSpec, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
|
||||
- `packages/daemon/src/domain/up-command-router.ts`: Source router that classifies a path as a rig spec or rig bundle before bootstrap. Exports: RouteResult, SourceKind, UpCommandRouter. Related: `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
| 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.) |
|
||||
|
||||
### Domain: Specs, Resolution, Startup
|
||||
- `packages/daemon/src/domain/agent-manifest.ts`: Canonical `agent.yaml` parser, validator, and normalizer for AgentSpec files. Exports: normalizeAgentSpec, parseAgentSpec, validateAgentSpec. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/agent-preflight.ts`: Lightweight preflight for a single `agent_ref`, used before full rig instantiation. Exports: agentPreflight. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/agent-resolver.ts`: Resolves `agent_ref` roots, imported AgentSpecs, and cross-spec resource-collision metadata. Exports: AgentResolverFsOps, ResolveResult, ResolvedAgentSpec, ResourceCollision, resolveAgentRef, resolveImports. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/pod-repository.ts`: SQLite-backed repository for pods and live continuity-state rows. Exports: PodRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/profile-resolver.ts`: Applies AgentSpec defaults, profile uses, startup layering, and restore-policy narrowing to produce a `ResolvedNodeConfig`. Exports: QualifiedResource, ResolutionContext, ResolutionResult, ResolvedNodeConfig, ResolvedResources, resolveNodeConfig. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/startup-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/projection-planner.ts`: Converts resolved node config plus collision data into runtime projection entries, diagnostics, conflicts, and no-ops. Exports: PlanResult, ProjectionClassification, ProjectionEntry, ProjectionFsOps, ProjectionInput, ProjectionPlan, planProjection. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/profile-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-codec.ts`: YAML codec layer for both legacy flat-node RigSpec and rebooted pod-aware RigSpec shapes. Exports: LegacyRigSpecCodec, RigSpecCodec. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-exporter.ts`: Exports live rigs back into RigSpec YAML/JSON. Dual-format: detects pod-aware rigs (podId non-null) and exports canonical v0.2 RigSpec with pods/members/edges; legacy rigs export as flat-node v1. Exports: RigSpecExporter. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/pod-repository.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-instantiator.ts`: Dual-stack instantiation layer containing both legacy `RigInstantiator` and rebooted `PodRigInstantiator`. Exports: PodRigInstantiator, RigInstantiator. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/node-launcher.js`, `packages/daemon/src/domain/pod-repository.js`, +10 more.
|
||||
- `packages/daemon/src/domain/rigspec-preflight.ts`: Dual-stack preflight layer: legacy `RigSpecPreflight` plus rebooted pure `rigPreflight`. Exports: RigPreflightInput, RigSpecPreflight, rigPreflight. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/profile-resolver.js`, `packages/daemon/src/domain/rig-repository.js`, +4 more.
|
||||
- `packages/daemon/src/domain/rigspec-schema.ts`: Validation and normalization for both legacy and rebooted RigSpec schemas. Exports: LegacyRigSpecSchema, RigSpecSchema. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/runtime-adapter.ts`: Runtime-adapter contract plus bridge types used by startup orchestration and runtime-specific adapters. Exports: InstalledResource, NodeBinding, ProjectionResult, ReadinessResult, ResolvedStartupFile, RuntimeAdapter, StartupDeliveryResult. Related: `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/spec-validation-service.ts`: Pure validation helpers for raw AgentSpec and RigSpec YAML strings. Exports: validateAgentSpecFromYaml, validateRigSpecFromYaml. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, provenance/source-state, and managed-app services metadata used by preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, RigSpecServicesReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, marks service-backed rigs with `hasServices`, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/domain/whoami-service.ts`: Daemon-side runtime identity projector used by `/api/whoami`. Resolves by node or session, expands peers/edges/transcript helpers, and surfaces ambiguity honestly. Exports: WhoamiResult, WhoamiAmbiguousError, WhoamiService. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/transcript-store.js`.
|
||||
- `packages/daemon/src/domain/startup-orchestrator.ts`: Startup execution engine for projection, startup-file delivery, action execution, readiness, and persisted replay context. Exports: StartupInput, StartupOrchestrator, StartupResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`, +2 more.
|
||||
- `packages/daemon/src/domain/startup-resolver.ts`: Pure additive startup-layer merge function across agent, profile, rig, pod, member, and operator layers. Exports: StartupLayerInputs, resolveStartup. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/startup-validation.ts`: Shared validation and normalization for startup files and startup actions. Exports: normalizeStartupBlock, validateStartupAction, validateStartupBlock, validateStartupFile. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/types.js`.
|
||||
## (c) Structural relationship diagram
|
||||
|
||||
### Domain: Snapshots And Restore
|
||||
- `packages/daemon/src/domain/checkpoint-store.ts`: Checkpoint persistence, lookup, and rig-wide checkpoint aggregation. Exports: CheckpointStore. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, topology ordering, and RigEnv boot gating before agent restore. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
|
||||
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, startup context, and the latest rig-env receipt. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
|
||||
- `packages/daemon/src/domain/snapshot-repository.ts`: SQLite-backed repository for snapshot CRUD and listing. Exports: SnapshotRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
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.
|
||||
|
||||
### Domain: Packages And Install Engine
|
||||
- `packages/daemon/src/domain/conflict-detector.ts`: Content-aware conflict classification shared by package installs and rebooted projection planning. Exports: GuidanceConflictMeta, RefinedInstallPlan, classifyResourceProjection, detectConflicts. Related: `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/package-resolver.js`, `packages/daemon/src/domain/projection-planner.js`.
|
||||
- `packages/daemon/src/domain/install-engine.ts`: Journaled apply/rollback engine for package installs. Exports: EngineFsOps, InstallEngine, InstallResult, RollbackResult. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/install-policy.js`, `packages/daemon/src/domain/install-repository.js`.
|
||||
- `packages/daemon/src/domain/install-planner.ts`: Computes classified package install entries and target paths. Exports: ActionClassification, ConflictInfo, InstallPlan, InstallPlanEntry, InstallPlanner, PlanOptions. Related: `packages/daemon/src/domain/package-resolver.js`, `packages/daemon/src/domain/role-resolver.js`.
|
||||
- `packages/daemon/src/domain/install-policy.ts`: Approval-policy gate for package install plans. Exports: PolicyRejection, PolicyResult, applyPolicy. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-planner.js`.
|
||||
- `packages/daemon/src/domain/install-repository.ts`: SQLite-backed repository for package install records and journal entries. Exports: Install, InstallRepository, InstallSummary, JournalEntry. Related: none.
|
||||
- `packages/daemon/src/domain/install-verifier.ts`: Post-apply verification for install records and filesystem targets. Exports: Check, EntryVerification, InstallVerifier, VerificationResult, VerifierFsOps. Related: `packages/daemon/src/domain/install-repository.js`, `packages/daemon/src/domain/package-repository.js`.
|
||||
- `packages/daemon/src/domain/package-install-service.ts`: Top-level package validate/plan/install/rollback service used by routes and bootstrap. Exports: PackageInstallOutcome, PackageInstallService. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/install-policy.js`, +4 more.
|
||||
- `packages/daemon/src/domain/package-manifest.ts`: Parser and validator for `package.yaml` manifests. Exports: AgentExport, GuidanceExport, HookExport, InstallPolicy, McpExport, PackageExports, PackageManifest, PackageRequirements, RoleDefinition, SkillExport, ValidationResult, VerificationCheck, VerificationConfig, normalizeManifest, parseManifest, serializeManifest, validateManifest. Related: none.
|
||||
- `packages/daemon/src/domain/package-repository.ts`: SQLite-backed repository for package metadata and install summaries. Exports: Package, PackageRepository, PackageSummary. Related: none.
|
||||
- `packages/daemon/src/domain/package-resolve-helper.ts`: Shared helper that wraps package resolution for routes and orchestrators. Exports: ResolveResult, resolvePackage. Related: `packages/daemon/src/domain/package-manifest.js`, `packages/daemon/src/domain/package-resolver.js`.
|
||||
- `packages/daemon/src/domain/package-resolver.ts`: Resolves local package sources and computes manifest hashes. Exports: FsOps, PackageResolver, ResolvedPackage. Related: `packages/daemon/src/domain/package-manifest.js`.
|
||||
- `packages/daemon/src/domain/path-safety.ts`: Shared safe-path validator used by manifests and startup config. Exports: validateSafePath. Related: none.
|
||||
- `packages/daemon/src/domain/role-resolver.ts`: Filters package exports according to selected roles/runtime scope. Exports: DeferredExport, ResolvedExports, resolveExports. Related: `packages/daemon/src/domain/package-manifest.js`.
|
||||
```
|
||||
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)
|
||||
|
||||
### Domain: Bootstrap, Discovery, Bundles
|
||||
- `packages/daemon/src/domain/bootstrap-orchestrator.ts`: Staged bootstrap workflow that composes runtime verification, requirement probes, external installs, package installs, legacy rig instantiation, direct pod-aware rig handling, and schema-version-2 pod-bundle delegation. Exports: BootstrapMode, BootstrapOptions, BootstrapOrchestrator, BootstrapResult, BootstrapStageResult. Related: `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bootstrap-types.js`, `packages/daemon/src/domain/bundle-source-resolver.js`, `packages/daemon/src/domain/external-install-executor.js`, +10 more.
|
||||
- `packages/daemon/src/domain/bootstrap-repository.ts`: SQLite-backed repository for bootstrap runs and action journals. Exports: BootstrapRepository. Related: `packages/daemon/src/domain/bootstrap-types.js`.
|
||||
- `packages/daemon/src/domain/bootstrap-types.ts`: Shared type definitions for bootstrap data. Exports: ActionKind, ActionStatus, BootstrapAction, BootstrapRun, BootstrapStatus, RuntimeStatus, RuntimeVerification. Related: none.
|
||||
- `packages/daemon/src/domain/bundle-archive.ts`: Archive pack/unpack and digest verification for `.rigbundle` files. Exports: pack, unpack, verifyArchiveDigest. Related: `packages/daemon/src/domain/bundle-integrity.js`, `packages/daemon/src/domain/bundle-types.js`.
|
||||
- `packages/daemon/src/domain/bundle-assembler.ts`: Legacy rig-bundle assembler for package-based bundles. Exports: AssembleOptions, AssemblerFsOps, LegacyBundleAssembler, PackageInput. Related: `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/bundle-integrity.ts`: Per-file integrity manifest generation and verification for bundles. Exports: IntegrityFsOps, VerifyResult, computeIntegrity, verifyIntegrity, writeIntegrity. Related: `packages/daemon/src/domain/bundle-types.js`.
|
||||
- `packages/daemon/src/domain/bundle-source-resolver.ts`: Dual-stack bundle resolver containing legacy package-bundle and rebooted pod-bundle extraction flows. Exports: BundleResolvedSource, LegacyBundleSourceResolver, PodBundleResolvedSource, PodBundleSourceResolver. Related: `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/package-resolve-helper.js`, `packages/daemon/src/domain/package-resolver.js`.
|
||||
- `packages/daemon/src/domain/bundle-types.ts`: Bundle manifest types plus parse/validate/serialize helpers for schema v1 and schema v2 bundles. Exports: BundleIntegrity, LegacyBundleManifest, LegacyBundlePackageEntry, PodBundleAgentEntry, PodBundleAgentImportEntry, PodBundleManifest, isRelativeSafePath, normalizeLegacyBundleManifest, parseLegacyBundleManifest, parsePodBundleManifest, serializeLegacyBundleManifest, serializePodBundleManifest, validateLegacyBundleManifest, validatePodBundleManifest. Related: none.
|
||||
- `packages/daemon/src/domain/claim-service.ts`: Claims discovered sessions into managed rig/node state. Exports: ClaimResult, ClaimService. Related: `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/domain/discovery-coordinator.ts`: Coordinates tmux scans, fingerprinting, enrichment, persistence, and event emission for discovery. Exports: DiscoveryCoordinator. Related: `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-enricher.js`, +3 more.
|
||||
- `packages/daemon/src/domain/discovery-repository.ts`: SQLite-backed repository for discovered session records. Exports: DiscoveryRepository, UpsertData. Related: `packages/daemon/src/domain/discovery-types.js`.
|
||||
- `packages/daemon/src/domain/discovery-types.ts`: Shared type definitions for discovery data. Exports: Confidence, DiscoveredSession, DiscoveryStatus, RuntimeHint, SessionOrigin. Related: none.
|
||||
- `packages/daemon/src/domain/external-install-executor.ts`: Runs approved external install actions and journals execution results. Exports: ExecutionResult, ExecutionSummary, ExternalInstallExecutor, TaggedAction. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/external-install-planner.js`.
|
||||
- `packages/daemon/src/domain/external-install-planner.ts`: Turns requirement-probe results into auto-approvable or manual external install actions. Exports: ApprovalClassification, ExternalInstallAction, ExternalInstallPlan, ExternalInstallPlanner. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/domain/requirements-probe.js`.
|
||||
- `packages/daemon/src/domain/pod-bundle-assembler.ts`: Rebooted bundle assembler that vendors AgentSpecs, imports, rig startup files, and rewritten `agent_ref`s. Exports: PodAssembleOptions, PodAssembleResult, PodAssemblerFsOps, PodBundleAssembler. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`, +1 more.
|
||||
- `packages/daemon/src/domain/requirements-probe.ts`: Requirement-probe registry and probe result types used during bootstrap. Exports: ProbeResult, ProbeStatus, RequirementSpec, RequirementsProbeRegistry. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/runtime-verifier.ts`: Verifies runtime/tool availability and persists verification results. Exports: RuntimeVerifier. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-types.js`.
|
||||
- `packages/daemon/src/domain/session-enricher.ts`: Adds repository/config hints to discovered sessions based on filesystem inspection. Exports: EnrichmentResult, SessionEnricher. Related: none.
|
||||
- `packages/daemon/src/domain/session-fingerprinter.ts`: Infers runtime/session identity from tmux, cmux, process, and pane evidence. Exports: FingerprintEvidence, FingerprintResult, SessionFingerprinter. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/tmux-discovery-scanner.js`.
|
||||
- `packages/daemon/src/domain/tmux-discovery-scanner.ts`: Low-level tmux scanner that enumerates panes/sessions for discovery. Exports: ScanResult, ScannedPane, TmuxDiscoveryScanner. Related: `packages/daemon/src/adapters/tmux.js`.
|
||||
workspace-primitive.md ──▶ content-surfaces.md
|
||||
(PL-007 declaration) (filesystem read/write layer on top)
|
||||
│ │
|
||||
└──────────┬───────────────┘
|
||||
▼ (UI counterpart)
|
||||
ui/project-and-for-you.md
|
||||
|
||||
### Domain: North Star Operator Layer
|
||||
- `packages/daemon/src/domain/node-inventory.ts`: Universal node-level projection consumed by CLI, UI, and MCP. Core fields (session name, status, startup status, restore outcome, attach command) plus extended fields (model, agent ref, startup files, installed resources). Exports: NodeInventoryService. Related: `packages/daemon/src/domain/types.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/domain/demo-rig-selector.ts`: Helper for existing-rig power-on — finds the right rig by name from ps summary entries. Exports: selectCurrentRigSummary. Related: none.
|
||||
- `packages/daemon/src/domain/draft-rig-generator.ts`: Synthesizes a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs. Exports: generateDraftRig. Related: `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/native-resume-probe.ts`: Honest assessment of whether a harness actually resumed vs fresh-launched — probes pane command and content for runtime-specific indicators. Exports: NativeResumeProbeResult, assessNativeResumeProbe, buildNativeResumeCommand, isProbeShellReady. Related: `packages/daemon/src/adapters/shell-quote.js`.
|
||||
- `packages/daemon/src/domain/resume-metadata-refresher.ts`: Post-launch resume token capture — reads Claude conversation IDs from state files and Codex thread IDs from SQLite. Exports: ResumeMetadataRefresher. Related: `packages/daemon/src/domain/codex-thread-id.js`, `packages/daemon/src/domain/native-resume-probe.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite state database. Exports: defaultResolveHomeDirByPid, readCodexThreadIdFromCandidateHomes, ResolveHomeDirByPid. Related: none.
|
||||
shell-and-routing.md ──▶ topology.md
|
||||
│ └──▶ project-and-for-you.md ◀──▶ architecture/mission-control.md
|
||||
└──────────────▶ library-specs-and-design-system.md ──▶ ../DESIGN.md (pointer)
|
||||
```
|
||||
|
||||
### Domain: Post-North-Star Transport and History
|
||||
- `packages/daemon/src/domain/session-transport.ts`: Communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection via pane content heuristics, honest error reporting, pod/rig/global targeting. Exports: SessionTransport, TargetSpec, SendResult, CaptureResult, BroadcastResult, SendOpts. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/transcript-store.ts`: Pipe-pane transcript management — ANSI stripping on read (preserves raw on disk), boundary markers for session lifecycle events, readTail, grep. Filesystem-backed, not SQLite. Exports: TranscriptStore, TranscriptStoreOpts. Related: none (filesystem-only).
|
||||
- `packages/daemon/src/domain/history-query.ts`: Unified transcript + chat search — prefers `rg` when available, falls back to `grep -E`, surfaces which backend was used. Exports: HistoryQuery, SearchResult, ChatSearchResult, ExecDep. Related: `packages/daemon/src/domain/transcript-store.js`.
|
||||
- `packages/daemon/src/domain/ask-service.ts`: Context engineering evidence pack — gathers topology snapshot, transcript excerpts, chat history excerpts, restore metadata. Returns structured context for the agent to reason about. Does NOT call an external LLM. Exports: AskService, AskDeps. Related: `packages/daemon/src/domain/ps-projection.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/history-query.js`.
|
||||
- `packages/daemon/src/domain/chat-repository.ts`: Durable rig-scoped chat messages — CRUD for chat_messages table, monotonic ULID IDs, rig-scoped queries. Exports: ChatRepository, ChatMessage. Related: none (SQLite-only).
|
||||
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).
|
||||
|
||||
### Domain: Coordination Primitive (PL-004 Phase A)
|
||||
- `packages/daemon/src/domain/stream-store.ts`: L1 coordination stream — append-only intake/audit root. Idempotent emit on stream_item_id, chronological list with cursor pagination, soft archive. Exports: StreamStore, StreamItem, StreamEmitInput, StreamListOptions. Related: `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/queue-repository.ts`: L3 coordination queue — create, claim/unclaim, update with hot-potato strict-rejection, transactional handoff (close source + create new in one transaction), pod-fallback rerouting, overdue lookup. POC qitem_id format preserved. Exports: QueueRepository, QueueRepositoryError, QueueItem, QueueState, QueuePriority, QueueCreateInput, QueueUpdateInput, QueueHandoffInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-transition-log.js`, `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/queue-transition-log.ts`: Append-only state transition log for queue_items. No UPDATE / DELETE from domain code. Exports: QueueTransitionLog, QueueTransition, QueueTransitionInput. Related: `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/hot-potato-enforcer.ts`: Pure validator for the load-bearing hot-potato API contract — state=done requires closure_reason from the 6-value enum. Tier→SLA mapping for closure_required_at. Exports: CLOSURE_REASONS, ClosureReason, validateClosure, isClosureReason, computeClosureRequiredAt. Related: none.
|
||||
- `packages/daemon/src/domain/inbox-handler.ts`: Mailbox handler — authenticated drop (idempotent on inbox_id), absorb (promote pending entry to a queue_item), deny (record reason). Authentication is a pluggable constructor hook. Exports: InboxHandler, InboxHandlerError, InboxEntry, InboxState, InboxDropInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-repository.js`.
|
||||
- `packages/daemon/src/domain/outbox-handler.ts`: Sender-side outbox — symmetric to inbox; idempotent record, mark delivered/failed, list. No event-bus events emitted. Exports: OutboxHandler, OutboxHandlerError, OutboxEntry, OutboxDeliveryState, OutboxRecordInput. Related: none (SQLite-only).
|
||||
## (d) Fast lookup by use-case
|
||||
|
||||
### Routes
|
||||
- `packages/daemon/src/routes/adapters.ts`: Hono route module for adapters API endpoints. Exports: adaptersRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/routes/agents.ts`: Hono route module for AgentSpec validation. POST `/api/agents/validate` using `validateAgentSpecFromYaml`. Exports: agentsRoutes. Related: `packages/daemon/src/domain/spec-validation-service.js`.
|
||||
- `packages/daemon/src/routes/bootstrap.ts`: Hono route module for bootstrap API endpoints. Exports: bootstrapRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/bundles.ts`: Hono route module for dual-format bundle create/inspect/install. Supports legacy v1 package bundles and schema-version-2 pod bundles, including v2 inspect responses with `agents[]` and integrity data. Exports: bundleRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-assembler.js`, +7 more.
|
||||
- `packages/daemon/src/routes/discovery.ts`: Hono route module for discovery scan/list/detail plus Final Stretch flows: `draft-rig`, `bind`, and composite `adopt`. Exports: discoveryRoutes. Related: `packages/daemon/src/domain/claim-service.js`, `packages/daemon/src/domain/discovery-coordinator.js`, `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/draft-rig-generator.js`.
|
||||
- `packages/daemon/src/routes/down.ts`: Hono route module for down API endpoints. Exports: downRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rig-teardown.js`.
|
||||
- `packages/daemon/src/routes/env.ts`: Hono route module for rig-environment status, logs, and teardown. Refreshes env receipts best-effort and returns additive managed-app/runtime metadata (`hasServices`, `surfaces`, `receipt`). Exports: envRoutes. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/service-orchestrator.js`.
|
||||
- `packages/daemon/src/routes/events.ts`: Hono route module for events API endpoints. Exports: eventsRoute. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/routes/packages.ts`: Hono route module for the legacy package-install surface (`/validate`, `/plan`, `/install`, history, rollback). AgentSpec validation no longer lives here. Exports: packagesRoutes. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, +6 more.
|
||||
- `packages/daemon/src/routes/ps.ts`: Hono route module for ps API endpoints. Exports: psRoutes. Related: `packages/daemon/src/domain/ps-projection.js`.
|
||||
- `packages/daemon/src/routes/rigs.ts`: Hono route module for rig CRUD, graph projection, summary, delete, and existing-rig power-on by rig id. Exports: rigsRoutes. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/graph-projection.js`, `packages/daemon/src/domain/node-inventory.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/routes/rigspec.ts`: Hono route module for dual-format RigSpec import, validate, preflight, materialize, and export. Pod-aware import/preflight/materialize require `X-Rig-Root`; export emits v0.2 pod-aware YAML/JSON when the live rig has pods. Exports: handleExportJson, handleExportYaml, rigspecImportRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-exporter.js`, `packages/daemon/src/domain/rigspec-instantiator.js`, +2 more.
|
||||
- `packages/daemon/src/routes/sessions.ts`: Hono route module for rig sessions and node inventory/detail/launch/focus. `GET /api/rigs/:rigId/nodes/:logicalId` is the backend contract for the live node drawer and full-details workspace. Exports: nodesRoutes, sessionsRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/domain/node-inventory.js`, `packages/daemon/src/domain/node-launcher.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/routes/snapshots.ts`: Hono route module for snapshots API endpoints. Exports: restoreRoutes, snapshotsRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/restore-orchestrator.js`, `packages/daemon/src/domain/snapshot-capture.js`, `packages/daemon/src/domain/snapshot-repository.js`.
|
||||
- `packages/daemon/src/routes/transport.ts`: Hono route module for communication primitives (`POST /send`, `/capture`, `/broadcast`). Resolves session targets, delegates to SessionTransport. Exports: transportRoutes. Related: `packages/daemon/src/domain/session-transport.js`.
|
||||
- `packages/daemon/src/routes/transcripts.ts`: Hono route module for transcript access (`GET /:session/tail`, `/:session/grep`). Resolves session to rig/node, delegates to TranscriptStore. Exports: transcriptRoutes. Related: `packages/daemon/src/domain/transcript-store.js`, `packages/daemon/src/domain/rig-repository.js`.
|
||||
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
|
||||
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/stream.ts`: Hono route module for the PL-004 Phase A coordination L1 stream (`POST /emit`, `GET /list`, `GET /:id`, `POST /:id/archive`, `GET /watch` SSE). Host-scoped (no rigId). Exports: streamRoutes. Related: `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/queue.ts`: Hono route module for the PL-004 Phase A coordination L3 queue plus inbox/outbox (`POST /create`, `POST /:id/{claim,unclaim,update,handoff,fallback}`, `GET /list`, `GET /overdue`, `GET /:id`, `GET /:id/transitions`, inbox/outbox subroutes, `GET /watch` SSE). Surfaces hot-potato strict-rejection structured errors with validReasons enum. Exports: queueRoutes. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/inbox-handler.js`, `packages/daemon/src/domain/outbox-handler.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Enriches service-backed reviews with best-effort compose preview data for managed-app UI surfaces. Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/whoami.ts`: Hono route module for daemon-backed runtime identity lookup by `nodeId` or `sessionName`. Exports: whoamiRoutes. Related: `packages/daemon/src/domain/whoami-service.js`.
|
||||
- `packages/daemon/src/routes/up.ts`: Hono route module for `/api/up`, routing direct rig specs, bundles, and rig-name restore into the staged bootstrap flow. Exports: upRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/up-command-router.js`.
|
||||
| I need to know… | → see |
|
||||
|---|---|
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
## CLI
|
||||
## (e) Source-root pointer table
|
||||
|
||||
### Entrypoints And Infrastructure
|
||||
- `packages/cli/src/client.ts`: Shared HTTP client for talking to the daemon API, including raw JSON posts and text-body posts with optional extra headers such as `X-Rig-Root`. Exports: DaemonClient, DaemonConnectionError, DaemonResponse. Related: none.
|
||||
- `packages/cli/src/daemon-lifecycle.ts`: Starts, stops, inspects, and tails the local daemon process. Spawns the daemon with `process.execPath` and bypasses local `daemon.json` gating when `OPENRIG_URL` is set by probing remote `/healthz` directly. Exports: DaemonState, DaemonStatus, LOG_FILE, LifecycleDeps, OPENRIG_DIR, STATE_FILE, StartOptions, getDaemonPath, getDaemonStatus, readLogs, startDaemon, stopDaemon, tailLogs. Related: none.
|
||||
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (40 mounted command groups, including env status/logs/down, `destroy`, and Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +19 more.
|
||||
- `packages/cli/src/mcp-server.ts`: Implements the CLI-hosted MCP server over stdio, wrapping 17 daemon-backed tools including node inventory, send/capture, bundle/spec validation, and chatroom. Exports: createMcpServer. Related: `packages/cli/src/client.js`.
|
||||
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.
|
||||
|
||||
### Commands
|
||||
- `packages/cli/src/commands/agent.ts`: Commander command module for `agent`. Subcommands: `validate` (POST `/api/agents/validate`). Supports `--json`. Exports: AgentDeps, agentCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bootstrap.ts`: Commander command module for `bootstrap`. Supports library-name resolution before falling back to raw source refs. Exports: bootstrapCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bind.ts`: Commander command module for `bind` — bind a discovered session to an existing logical node. Exports: bindCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bundle.ts`: Commander command module for `bundle`. Supports dual-format bundle create/inspect/install, `--json`, and pod-aware `--rig-root` on create. Exports: bundleCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/claim.ts`: Commander command module for `claim`. Exports: claimCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/daemon.ts`: Commander command module for `daemon`. Exports: daemonCommand, realDeps. Related: `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/discover.ts`: Commander command module for `discover`. Exports: discoverCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/down.ts`: Commander command module for `down`. Exports: downCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/env.ts`: Commander command module for `env` — rig environment status/logs/down for service-backed rigs and managed apps. Exports: envCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/commands/status.js`.
|
||||
- `packages/cli/src/commands/export.ts`: Commander command module for `export`. Exports: ExportDeps, exportCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/import.ts`: Commander command module for `import`. Locally parses YAML to detect pod-aware rigs, sends `X-Rig-Root` for pod-aware preflight/instantiate flows, and supports `--rig-root`. Exports: ImportDeps, importCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/mcp.ts`: Commander command module for `mcp`. Exports: mcpCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`, +1 more.
|
||||
- `packages/cli/src/commands/package.ts`: Commander command module for the legacy package-management surface. Exports: packageCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/ps.ts`: Commander command module for `ps`. Exports: psCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/requirements.ts`: Commander command module for `requirements`. Exports: requirementsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/restore.ts`: Commander command module for `restore`. Exports: restoreCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/rig.ts`: Commander command module for `rig`. Subcommands: `validate` (POST `/api/rigs/import/validate`), `preflight` (POST `/api/rigs/import/preflight` with `X-Rig-Root` header). Both support `--json`. Exports: RigDeps, rigCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/snapshot.ts`: Commander command module for `snapshot`. Exports: snapshotCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/status.ts`: Commander command module for `status`. Exports: StatusDeps, statusCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. `add` accepts single YAML files and full spec directories containing `rig.yaml` or `agent.yaml`. Help/examples and not-found guidance explicitly call out managed apps. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/ui.ts`: Commander command module for `ui`. Exports: UiDeps, uiCommand. Related: `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Help/examples now treat managed apps like `secrets-manager` as first-class launch targets. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
|
||||
- `packages/cli/src/commands/whoami.ts`: Commander command module for `whoami`. Resolves identity from explicit args, env vars, tmux metadata, and session-name fallback, then delegates to daemon `/api/whoami`. Exports: resolveIdentitySource, whoamiCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/adopt.ts`: Commander command module for `adopt` — materialize topology from a pod-aware RigSpec, then bind discovered live sessions according to `--bind` mappings. Exports: AdoptDeps, adoptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/import.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
| Module | Primary source roots |
|
||||
|---|---|
|
||||
| `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/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/*` |
|
||||
|
||||
### Commands: Post-North-Star Additions
|
||||
- `packages/cli/src/commands/send.ts`: Commander command module for `send` — send message to agent terminal via reliable tmux two-step. Supports `--verify` and `--force`. Exports: sendCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/capture.ts`: Commander command module for `capture` — capture agent pane content. Supports `--lines N` and `--json`. Exports: captureCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/broadcast.ts`: Commander command module for `broadcast` — send to all agents in a rig or pod. Reports per-session delivery status. Exports: broadcastCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/transcript.ts`: Commander command module for `transcript` — composable transcript access via `--tail N` and `--grep "pattern"`. Exports: transcriptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/stream.ts`: Commander command module for `rig stream` (PL-004 Phase A) — coordination L1 intake stream. Subcommands: `emit`, `list`, `show`, `archive`. Speaks only to the daemon HTTP API; does not touch POC filesystem state. Exports: streamCommand, StreamDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/queue.ts`: Commander command module for `rig queue` (PL-004 Phase A) — coordination L3 queue plus inbox/outbox. Subcommands: `create`, `claim`, `unclaim`, `update`, `handoff`, `fallback`, `show`, `transitions`, `list`, `overdue`, `inbox-drop`, `inbox-absorb`, `inbox-deny`, `inbox-pending`, `outbox-record`, `outbox-list`. Hot-potato strict-rejection enforced at daemon. Exports: queueCommand, QueueDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/config.ts`: Commander command module for `config` — read/write/reset config keys. Supports `--json`. Exports: configCommand. Related: `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/preflight.ts`: Commander command module for `preflight` — system readiness checks. Supports `--json`. Exports: preflightCommand. Related: `packages/cli/src/system-preflight.js`, `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/ask.ts`: Commander command module for `ask` — context evidence pack over transcripts + topology + chat. Supports `--json`. Exports: askCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/chatroom.ts`: Commander command module for `chatroom` — durable group chat with subcommands `send`, `watch`, `history`, and `topic`. Exports: chatroomCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
### CLI Infrastructure: Post-North-Star Additions
|
||||
- `packages/cli/src/config-store.ts`: Config file management at `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys with env var overrides. Precedence: CLI flag > env var > config file > default. Exports: ConfigStore and config helpers. Related: none (filesystem-only).
|
||||
- `packages/cli/src/system-preflight.ts`: System readiness checks: Node.js version, tmux availability, writable directories, port availability. Every error follows the 3-part pattern (what + why + fix). Exports: SystemPreflight, PreflightCheck, PreflightResult. Related: `packages/cli/src/config-store.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
## UI
|
||||
|
||||
### App Entry And Routing
|
||||
- `packages/ui/src/App.tsx`: Thin app wrapper that renders the TanStack Router provider. Exports: App. Related: `packages/ui/src/routes.js`.
|
||||
- `packages/ui/src/globals.css`: Tailwind/theme tokens and global UI styling. Exports: none. Related: none.
|
||||
- `packages/ui/src/main.tsx`: Bootstraps the React app, fonts, and global styles. Exports: none. Related: `packages/ui/src/App.js`, `packages/ui/src/globals.css`.
|
||||
- `packages/ui/src/routes.tsx`: Declares the explorer-first route tree and root shell composition, including specs review/library routes, discovery/specs drawer bridges, and live-node full-details route `/rigs/$rigId/nodes/$logicalId`. Exports: router. Related: `packages/ui/src/components/AppShell.js`, `packages/ui/src/components/BootstrapWizard.js`, `packages/ui/src/components/BundleInspector.js`, `packages/ui/src/components/BundleInstallFlow.js`, +13 more.
|
||||
|
||||
### Components: Application Surfaces
|
||||
- `packages/ui/src/components/ActivityFeed.tsx`: Rolling global activity/event overlay driven by SSE. Exports: ActivityFeed. Related: `packages/ui/src/hooks/useActivityFeed.js`.
|
||||
- `packages/ui/src/components/AppShell.tsx`: Persistent explorer/workspace/drawer shell. Owns drawer selection context, discovery placement state, specs workspace provider, explorer visibility, and top-level discovery/specs/system toggles. Exports: DrawerSelectionContext, DrawerSelectionContextValue, DiscoveryPlacementContext, DiscoveryPlacementContextValue, ExplorerVisibilityContext, ExplorerVisibilityContextValue, NodeSelectionContext, AppShell, useDiscoveryPlacement, useDrawerSelection, useExplorerVisibility, useNodeSelection. Related: `packages/ui/src/components/ActivityFeed.js`, `packages/ui/src/components/Explorer.js`, `packages/ui/src/components/SharedDetailDrawer.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useActivityFeed.js`, `packages/ui/src/hooks/useGlobalEvents.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/BootstrapWizard.tsx`: Multi-step bootstrap planning/apply flow UI for both rig specs and `.rigbundle` archives. Includes the spec-or-bundle entry field and bundle-inspector handoff link. Exports: BootstrapWizard. Related: `packages/ui/src/components/RequirementsPanel.js`, `packages/ui/src/hooks/useBootstrap.js`.
|
||||
- `packages/ui/src/components/BundleInspector.tsx`: Bundle inspection screen for manifest and integrity results. Renders `agents[]` plus schema-version badge for v2 pod bundles and legacy package content for v1 bundles. Exports: BundleInspector. Related: `packages/ui/src/hooks/useBundles.js`.
|
||||
- `packages/ui/src/components/BundleInstallFlow.tsx`: Bundle install plan/apply screen. Labels the install as pod-aware or legacy from bootstrap `resolve_spec.detail.source`. Exports: BundleInstallFlow. Related: `packages/ui/src/hooks/useBundles.js`.
|
||||
- `packages/ui/src/components/Dashboard.tsx`: Home dashboard that renders rig cards and high-level rig actions. Exports: Dashboard. Related: `packages/ui/src/components/RigCard.js`, `packages/ui/src/hooks/mutations.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/DiscoveryOverlay.tsx`: Session discovery and claim surface. Exports: DiscoveryOverlay. Related: `packages/ui/src/hooks/useDiscovery.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/ImportFlow.tsx`: RigSpec validate/preflight/instantiate flow UI. Supports pod-aware `X-Rig-Root`, structured preflight diagnostics, and rebooted RigSpec terminology. Exports: ImportFlow. Related: `packages/ui/src/hooks/mutations.js`.
|
||||
- `packages/ui/src/components/AgentSpecValidateFlow.tsx`: Standalone validate surface for one AgentSpec YAML, backed by `/api/agents/validate`. Exports: AgentSpecValidateFlow. Related: none.
|
||||
- `packages/ui/src/components/WorkflowScaffold.tsx`: Shared page-shell primitives for multi-step/spec-review flows (`WorkflowHeader`, `WorkflowStepIndicator`, `WorkflowSection`, summary cards, code preview). Exports: WorkflowHeader, WorkflowStepIndicator, WorkflowSection, WorkflowSummaryGrid, WorkflowSummaryCard, WorkflowCodePreview. Related: none.
|
||||
- `packages/ui/src/components/WorkspacePage.tsx`: Shared center-workspace page shell used by specs/library/live-details surfaces. Exports: WorkspacePage. Related: none.
|
||||
- `packages/ui/src/components/SpecsWorkspace.tsx`: Local persisted workspace state for spec authoring/review tasks, drafts, and active task list. Exports: SpecsDraft, SpecsTask, SPECS_WORKSPACE_STORAGE_KEYS, SpecsWorkspaceProvider, useSpecsWorkspace. Related: none.
|
||||
- `packages/ui/src/components/PackageDetail.tsx`: Package detail screen with install history, journal, and rollback actions. Exports: PackageDetail. Related: `packages/ui/src/hooks/usePackageDetail.js`.
|
||||
- `packages/ui/src/components/PackageInstallFlow.tsx`: Legacy package validate/configure/plan/apply flow UI, explicitly labeled `(Legacy)`. Exports: PackageInstallFlow. Related: none.
|
||||
- `packages/ui/src/components/PackageList.tsx`: Legacy package summary list/grid screen presented under the rebooted `SPECS` navigation, with an empty-state handoff into RigSpec import. Exports: PackageList. Related: `packages/ui/src/hooks/usePackages.js`.
|
||||
- `packages/ui/src/components/RequirementsPanel.tsx`: Visualizes requirement probe results inside bootstrap flows. Exports: RequirementResult, RequirementsPanel. Related: none.
|
||||
- `packages/ui/src/components/RigCard.tsx`: Dashboard card for one rig with counts, status, and primary actions. Exports: RigCard, RigSummary. Related: `packages/ui/src/hooks/useCountUp.js`, `packages/ui/src/hooks/usePsEntries.js`.
|
||||
- `packages/ui/src/components/RigGraph.tsx`: Rig graph view that renders topology, discovery overlays, and focus interactions. Exports: RigGraph. Related: `packages/ui/src/components/RigNode.js`, `packages/ui/src/hooks/useDiscovery.js`, `packages/ui/src/hooks/useRigEvents.js`, `packages/ui/src/hooks/useRigGraph.js`.
|
||||
- `packages/ui/src/components/RigNode.tsx`: Custom graph-node renderer for React Flow topology cards. Exports: RigNode. Related: none.
|
||||
- `packages/ui/src/components/Sidebar.tsx`: App navigation sidebar. Exports: Sidebar. Related: none.
|
||||
- `packages/ui/src/components/SnapshotPanel.tsx`: Snapshot list/create/restore panel shown beside rig graphs. Exports: SnapshotPanel. Related: `packages/ui/src/hooks/mutations.js`, `packages/ui/src/hooks/useSnapshots.js`.
|
||||
|
||||
### Components: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/components/Explorer.tsx`: Hierarchical tree navigator — rigs → pods → agents with status indicators. Primary navigation surface. Click rig loads graph, click node opens detail drawer. Exports: Explorer. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/SharedDetailDrawer.tsx`: Unified drawer surface keyed by `DrawerSelection`. Routes to rig, node, system, discovery, or specs panels without leaving the current workspace. Exports: DrawerSelection, SharedDetailDrawer. Related: `packages/ui/src/components/DiscoveryPanel.js`, `packages/ui/src/components/NodeDetailPanel.js`, `packages/ui/src/components/RigDetailPanel.js`, `packages/ui/src/components/SpecsPanel.js`, `packages/ui/src/components/SystemPanel.js`.
|
||||
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots/actions, and runtime drawer tabs (`Info`, conditional `Env`, `Chat Room`). Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/RigEnvPanel.tsx`: Runtime env surface for service-backed rigs. Shows overall env state, per-service health, health gates, surfaces, logs, and teardown action. Exports: RigEnvPanel. Related: `packages/ui/src/hooks/useRigEnv.js`.
|
||||
- `packages/ui/src/components/NodeDetailPanel.tsx`: Runtime-first node drawer. Shows rig/session/runtime identity, peers, directional edges, transcript helpers, compact spec summary, status, actions (tmux/cmux/resume/full-details), startup files, and recent events. Exports: NodeDetailPanel. Related: `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`, `packages/ui/src/lib/copy-text.js`, `packages/ui/src/lib/display-name.js`.
|
||||
- `packages/ui/src/components/NodeDetailPlaceholder.tsx`: Empty state for node detail when no node is selected. Exports: NodeDetailPlaceholder. Related: none.
|
||||
- `packages/ui/src/components/DiscoveryPanel.tsx`: Drawer-native discovery/adoption surface. Supports scan/list, draft-rig generation, placement-first bind/adopt, and pod/member targeting inside the shared drawer. Exports: DiscoveryPlacementTarget, DiscoveryPanel. Related: `packages/ui/src/hooks/useDiscovery.js`.
|
||||
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Presents a unified library list with `All / Apps / Rigs / Agents`, type/stability badges, richer managed-app rows, and review-route handoff. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/RigChatPanel.tsx`: Chat room tab in rig drawer — send messages, watch live stream, view history. Exports: RigChatPanel. Related: `packages/ui/src/hooks/useRigChat.js`.
|
||||
- `packages/ui/src/components/LiveIdentityDisplay.tsx`: Reusable runtime-identity subsection for peers, edges, transcript helpers, and compact spec summary. Used by the node drawer and full-details route. Exports: LiveIdentityDisplay. Related: none.
|
||||
- `packages/ui/src/components/LiveNodeDetails.tsx`: Explicit center-workspace route for deep inspection of one live node. Composes runtime identity plus reusable spec display components. Exports: LiveNodeDetails. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`.
|
||||
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Supports a library-only managed-app Environment tab with compose preview, health gates, and surfaces. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
|
||||
- `packages/ui/src/components/AgentSpecDisplay.tsx`: Canonical AgentSpec display primitive used by preview, library, and live-details flows. Exports: AgentSpecDisplay. Related: none.
|
||||
- `packages/ui/src/components/RigSpecReview.tsx`: Route-level rig review page that consumes `RigSpecDisplay` and specs workspace state. Exports: RigSpecReview. Related: `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/AgentSpecReview.tsx`: Route-level agent review page that consumes `AgentSpecDisplay` and specs workspace state. Exports: AgentSpecReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Service-backed rigs surface managed-app copy, specialist identity, setup-prompt copy, and env-aware review details. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/SpecTopologyPreview.tsx`: Lightweight topology preview for structured rig review models. Exports: SpecTopologyPreview. Related: none.
|
||||
- `packages/ui/src/components/SystemPanel.tsx`: Drawer surface for system-wide event log/status tabs. Replaces the old status-bar role with a richer right-drawer system surface. Exports: SystemPanel. Related: `packages/ui/src/hooks/useActivityFeed.js`.
|
||||
- `packages/ui/src/components/WorkspaceHome.tsx`: Landing page when no rig is selected. Entry point for explorer-first workflow. Exports: WorkspaceHome. Related: `packages/ui/src/components/AppShell.js`.
|
||||
|
||||
### Components: UI Primitives
|
||||
- `packages/ui/src/components/ui/alert.tsx`: Wrapped/customized shadcn-style UI primitive for alert. Exports: Alert, AlertDescription, AlertTitle. Related: none.
|
||||
- `packages/ui/src/components/ui/badge.tsx`: Wrapped/customized shadcn-style UI primitive for badge. Exports: Badge, BadgeProps, badgeVariants. Related: none.
|
||||
- `packages/ui/src/components/ui/button.tsx`: Wrapped/customized shadcn-style UI primitive for button. Exports: Button, ButtonProps, buttonVariants. Related: none.
|
||||
- `packages/ui/src/components/ui/card.tsx`: Wrapped/customized shadcn-style UI primitive for card. Exports: Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle. Related: none.
|
||||
- `packages/ui/src/components/ui/dialog.tsx`: Wrapped/customized shadcn-style UI primitive for dialog. Exports: Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger. Related: none.
|
||||
- `packages/ui/src/components/ui/input.tsx`: Wrapped/customized shadcn-style UI primitive for input. Exports: Input. Related: none.
|
||||
- `packages/ui/src/components/ui/separator.tsx`: Wrapped/customized shadcn-style UI primitive for separator. Exports: Separator. Related: none.
|
||||
- `packages/ui/src/components/ui/table.tsx`: Wrapped/customized shadcn-style UI primitive for table. Exports: Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow. Related: none.
|
||||
- `packages/ui/src/components/ui/tabs.tsx`: Wrapped/customized shadcn-style UI primitive for tabs. Exports: Tabs, TabsContent, TabsList, TabsTrigger. Related: none.
|
||||
- `packages/ui/src/components/ui/textarea.tsx`: Wrapped/customized shadcn-style UI primitive for textarea. Exports: Textarea. Related: none.
|
||||
- `packages/ui/src/components/ui/tooltip.tsx`: Wrapped/customized shadcn-style UI primitive for tooltip. Exports: Tooltip, TooltipContent, TooltipProvider, TooltipTrigger. Related: none.
|
||||
|
||||
### Hooks
|
||||
- `packages/ui/src/hooks/mutations.ts`: Shared mutation hooks for rig import, teardown, snapshot creation, and restore. `useImportRig` supports optional `rigRoot` and sends `X-Rig-Root` for pod-aware import. Exports: ImportError, useCreateSnapshot, useImportRig, useRestoreSnapshot, useTeardownRig. Related: none.
|
||||
- `packages/ui/src/hooks/useActivityFeed.ts`: SSE-backed activity-feed model and event-formatting helpers. Exports: ActivityEvent, UseActivityFeedResult, eventColor, eventRoute, eventSummary, formatRelativeTime, useActivityFeed. Related: none.
|
||||
- `packages/ui/src/hooks/useBootstrap.ts`: Bootstrap plan/apply hooks. Exports: BootstrapApplyResult, BootstrapPlanResult, useBootstrapApply, useBootstrapPlan. Related: none.
|
||||
- `packages/ui/src/hooks/useBundles.ts`: Bundle inspect/install hooks. Typed for v2 inspect responses (`schemaVersion`, `agents`, integrity) and install-stage `detail.source` labeling. Exports: BundleInstallResult, InspectResult, useBundleInspect, useBundleInstall. Related: none.
|
||||
- `packages/ui/src/hooks/useCountUp.ts`: Small animation helper for count-up telemetry. Exports: useCountUp. Related: none.
|
||||
- `packages/ui/src/hooks/useDiscovery.ts`: Discovery polling plus claim/bind/adopt mutations and draft-rig generation helpers. Exports: DiscoveredSession, useAdoptSession, useBindSession, useClaimSession, useDiscoveredSessions, useDiscoveredSessionsConditional, useDiscoveryPoll, useDiscoveryScan. Related: none.
|
||||
- `packages/ui/src/hooks/usePackageDetail.ts`: Package detail, journal, history, and rollback hooks. Exports: InstallSummary, JournalEntry, PackageInfo, useInstallHistory, useJournalEntries, usePackageInfo, useRollbackInstall. Related: none.
|
||||
- `packages/ui/src/hooks/usePackages.ts`: Package summary list hook. Exports: PackageSummary, usePackages. Related: none.
|
||||
- `packages/ui/src/hooks/usePsEntries.ts`: `/api/ps` summary hook. Exports: PsEntry, usePsEntries. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEvents.ts`: Rig-scoped SSE subscription and invalidation hook. Exports: UseRigEventsResult, useRigEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useRigGraph.ts`: Rig graph fetch/transformation hook. Exports: useRigGraph. Related: none.
|
||||
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Includes `hasServices` so runtime/UI can gate managed-app env surfaces. Exports: RigSummary, useRigSummary. Related: none.
|
||||
- `packages/ui/src/hooks/useSnapshots.ts`: Snapshot list hook for one rig. Exports: Snapshot, useSnapshots. Related: none.
|
||||
|
||||
### Hooks: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/hooks/useNodeInventory.ts`: Fetches node inventory for a rig from the canonical `GET /api/rigs/:rigId/nodes` endpoint. Exports: useNodeInventory. Related: none.
|
||||
- `packages/ui/src/hooks/useNodeDetail.ts`: Fetches extended node detail from `GET /api/rigs/:rigId/nodes/:logicalId`, including peers, edges, transcript helpers, and compact spec summary for the adopted-parity/live-identity drawer. Exports: NodeDetailData, useNodeDetail. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEnv.ts`: Fetches `GET /api/rigs/:rigId/env` for service-backed rig status, surfaces, and receipt data, with runtime-friendly refetch behavior. Exports: RigEnvData, useRigEnv. Related: none.
|
||||
- `packages/ui/src/hooks/useRigChat.ts`: Chat send/watch/history hooks for the rig chat panel. Exports: useRigChat. Related: none.
|
||||
- `packages/ui/src/hooks/useGlobalEvents.ts`: Global SSE subscription for explorer status updates. Exports: useGlobalEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results and `hasServices`-aware managed-app classification. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. The rig review type now carries managed-app services metadata and compose preview. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
|
||||
|
||||
### Lib Utilities
|
||||
- `packages/ui/src/lib/edge-styles.ts`: Edge-style resolver for graph links. Exports: EdgeStyleResult, getEdgeStyle. Related: none.
|
||||
- `packages/ui/src/lib/graph-layout.ts`: Tree-layout helper for rig graph positioning. Exports: applyTreeLayout. Related: none.
|
||||
- `packages/ui/src/lib/instantiate-status-colors.ts`: Color-class lookup for instantiation states. Exports: getInstantiateStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/query-client.ts`: Shared TanStack Query client singleton. Exports: queryClient. Related: none.
|
||||
- `packages/ui/src/lib/restore-status-colors.ts`: Color-class lookup for restore states. Exports: getRestoreStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/status-colors.ts`: Generic rig/session status color mapping. Exports: getStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/utils.ts`: Shared UI utility helpers, currently `cn` class merging. Exports: cn. Related: none.
|
||||
|
||||
### Lib Utilities: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/lib/display-id.ts`: Short ULID display utility — uses ULID tail (not head) for better uniqueness in same-view contexts. UI-only, no backend changes. Exports: shortId. Related: none.
|
||||
- `packages/ui/src/lib/display-name.ts`: Pod name extraction from qualified logical IDs (e.g., "dev1" from "dev1.impl"). Exports: displayPodName, inferPodName. Related: none.
|
||||
- `packages/ui/src/lib/build-setup-prompt.ts`: Managed-app setup-prompt generator used by library review copy surfaces. Exports: buildSetupPrompt. Related: none.
|
||||
- `packages/ui/src/lib/copy-text.ts`: Clipboard copy utility for the "Copy tmux attach" and "Copy resume command" buttons. Exports: copyText. Related: none.
|
||||
> 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.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
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
|
||||
---
|
||||
|
||||
# As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
|
||||
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.
|
||||
|
||||
## Canonical convention (do not duplicate — read it)
|
||||
|
||||
`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.
|
||||
|
||||
## `kind: as-built`
|
||||
|
||||
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.
|
||||
|
||||
## `last-verified-against-source: <sha>` (as-built-unique)
|
||||
|
||||
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:
|
||||
|
||||
```yaml
|
||||
last-verified-against-source: 7eaf524c
|
||||
```
|
||||
|
||||
- **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.
|
||||
|
||||
## See also
|
||||
|
||||
- `openrig-work/conventions/frontmatter-for-context/README.md` — the canonical schema
|
||||
- `README.md` — the as-built corpus map of territory
|
||||
+36
-230
@@ -1,238 +1,44 @@
|
||||
# OpenRig UI As-Built
|
||||
---
|
||||
kind: as-built
|
||||
title: ui.md — Reorganized into the Modular As-Built Tree (Redirect Stub)
|
||||
status: superseded
|
||||
topics: [knowledge-and-context]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
You followed an old reference to docs/as-built/ui.md. The monolith was
|
||||
reorganized (slice-08, context-architecture-v1) into the ui/ module folder.
|
||||
Go to README.md (map of territory) or codemap.md (use-case lookup), then the
|
||||
named ui/ module.
|
||||
siblings: [README.md, codemap.md, architecture.md]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
This document describes the current UI architecture and component system. The
|
||||
brand/design rules live in `docs/DESIGN.md`; this file focuses on how the shipped UI
|
||||
is assembled.
|
||||
# ui.md was reorganized into the modular as-built tree
|
||||
|
||||
## Package Shape
|
||||
This single-file UI 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 operator UI now lives in `ui/`.
|
||||
This stub is a forwarding pointer so old references still resolve.
|
||||
|
||||
UI package: `packages/ui`
|
||||
**Start here:**
|
||||
|
||||
Primary implementation areas:
|
||||
- [`./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.
|
||||
|
||||
- `src/routes.tsx` - TanStack Router route tree
|
||||
- `src/components/AppShell.tsx` - global shell, rail, Explorer, drawer, preview stack
|
||||
- `src/components/ui/` - reusable vellum and base UI primitives
|
||||
- `src/components/graphics/` - runtime, tool, and actor marks
|
||||
- `src/components/topology/` - host/rig/pod/seat graph, table, terminal, activity
|
||||
- `src/components/project/` - workspace/mission/slice observability and project metadata primitives
|
||||
- `src/components/for-you/` - attention feed and actionable queue cards
|
||||
- `src/components/specs/` - Library and skill file viewing
|
||||
- `src/components/preview/` - terminal/session preview panes
|
||||
- `src/hooks/` - route data hooks and event consumption
|
||||
- `src/lib/` - classifiers, formatters, layout helpers, brand helpers, activity logic
|
||||
## Where the major content went
|
||||
|
||||
## Shell Model
|
||||
The UI content is now the **4 modules under `ui/`**:
|
||||
|
||||
`AppShell` wraps every route with:
|
||||
| Module | What moved here |
|
||||
|---|---|
|
||||
| [`ui/shell-and-routing.md`](ui/shell-and-routing.md) | Package shape, the `AppShell` shell model (rail / Explorer / center workspace / drawer / preview stack), the route tree, design primitives, the shared detail-drawer/viewer system, event/activity consumption. |
|
||||
| [`ui/topology.md`](ui/topology.md) | The topology surface — host hybrid graph, table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, navigation/overlay contracts. |
|
||||
| [`ui/project-and-for-you.md`](ui/project-and-for-you.md) | The operator destination surfaces — Project observability (workspace/mission/slice scope tabs), the For-You attention feed (5-card classifier + queue actions), the Dashboard landing on the vellum brand system. |
|
||||
| [`ui/library-specs-and-design-system.md`](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/applications/skills surfaces, the graphics layer, the current design constraints, and the design-system pointer to `../DESIGN.md`. |
|
||||
|
||||
- Desktop rail
|
||||
- Route-aware Explorer sidebar
|
||||
- Center workspace
|
||||
- Shared detail drawer
|
||||
- Preview stack
|
||||
- Topology overlay state provider
|
||||
- Shared drawer selection context
|
||||
- Shared discovery placement context
|
||||
|
||||
The rail destinations are:
|
||||
|
||||
- Dashboard: `/`
|
||||
- Topology: `/topology`
|
||||
- For You: `/for-you`
|
||||
- Project: `/project`
|
||||
- Library: `/specs`
|
||||
- Settings: `/settings`
|
||||
- Advisor and Operator placeholders/settings links
|
||||
|
||||
The Explorer is contextual. It changes its tree based on the current destination
|
||||
rather than behaving as a generic file browser.
|
||||
|
||||
## Route Model
|
||||
|
||||
Main routes:
|
||||
|
||||
- `/` - Dashboard
|
||||
- `/topology` - host topology scope
|
||||
- `/topology/rig/$rigId` - rig scope
|
||||
- `/topology/pod/$rigId/$podName` - pod scope
|
||||
- `/topology/seat/$rigId/$logicalId` - seat scope with live node details
|
||||
- `/for-you` - attention feed
|
||||
- `/project` - workspace project scope
|
||||
- `/project/mission/$missionId` - mission scope
|
||||
- `/project/slice/$sliceId` - slice scope
|
||||
- `/specs` - Library
|
||||
- `/specs/applications` - application section
|
||||
- `/specs/skills/$skillToken` - skill viewer defaulting to `skill.md`
|
||||
- `/specs/skills/$skillToken/file/$fileToken` - specific skill file viewer
|
||||
- `/settings` - settings center
|
||||
- `/search` - audit/history view
|
||||
- `/lab/project-graphics-preview` - graphics preview lab route
|
||||
|
||||
Legacy routes remain for compatibility where needed, including package flows,
|
||||
bootstrap flows, validation flows, and `/rigs/$rigId`.
|
||||
|
||||
## Design Primitives
|
||||
|
||||
The UI moved from route-specific one-off panels toward reusable primitives.
|
||||
|
||||
Base primitives:
|
||||
|
||||
- `VellumCard` - paper card with optional dark header, hard shadow, registration marks
|
||||
- `VellumSheet` - side sheet/drawer with vellum-heavy surface and registration marks
|
||||
- `RegistrationMarks` - corner registration marks
|
||||
- `StatusPip` - semantic status only
|
||||
- `SectionHeader`, `EmptyState`, `Button`, `Tabs`, `Table`, and form controls
|
||||
|
||||
Graphics primitives:
|
||||
|
||||
- `RuntimeMark` and `RuntimeBadge`
|
||||
- `ToolMark` and `ToolBadge`
|
||||
- `ActorMark` and `OperatorMoodMark`
|
||||
- Runtime and tool normalization in `runtime-brand.ts` and `tool-brand.ts`
|
||||
|
||||
Project metadata primitives:
|
||||
|
||||
- `EventBadge`
|
||||
- `QueueStateBadge`
|
||||
- `TagPill`
|
||||
- `ActorChip`
|
||||
- `DateChip`
|
||||
- `FlowChips`
|
||||
- `ProofThumbnailGrid`
|
||||
- `ProofPacketHeader`
|
||||
|
||||
These primitives centralize visual language for runtime identity, queue state,
|
||||
actor flow, event labels, dates, tags, and proof images.
|
||||
|
||||
## Topology
|
||||
|
||||
Topology is a scoped workspace with graph, table, and terminal views.
|
||||
|
||||
Current topology pieces:
|
||||
|
||||
- `HostMultiRigGraph` renders the host-level hybrid React Flow graph.
|
||||
- `HybridAgentNode` renders compact agent cards with runtime badges, context
|
||||
percentage, token totals, activity state, terminal preview, and CMUX actions.
|
||||
- `HybridPodGroupNode` renders soft dashed pod frames.
|
||||
- `RigGroupNode` renders soft rig frames with registration marks and aggregate
|
||||
activity.
|
||||
- `ActivityRing` and card activity classes show active, needs-input, and blocked
|
||||
states.
|
||||
- `HotPotatoEdge` visualizes directional queue movement through the graph.
|
||||
- `TopologyTableView` mirrors topology data in a dense table.
|
||||
- `TopologyTerminalView` shows terminal-oriented topology state.
|
||||
- `TerminalPreviewPopover` provides black-glass quick terminal preview above the
|
||||
graph via a portal.
|
||||
|
||||
Important topology contracts:
|
||||
|
||||
- Graph/table/tree navigation resolves to seat URLs.
|
||||
- Host-level graph uses cross-rig node ID prefixing.
|
||||
- Expanded rig state is managed by `TopologyOverlayProvider`.
|
||||
- Graph data is lazy-fetched for expanded rigs.
|
||||
- Terminal preview actions are hover/focus visible.
|
||||
- The terminal popover must escape React Flow stacking contexts and stay within
|
||||
the viewport.
|
||||
|
||||
## Project Observability
|
||||
|
||||
Project is organized as a hierarchy:
|
||||
|
||||
- Workspace
|
||||
- Mission
|
||||
- Slice
|
||||
|
||||
The current route scope acts as a filter. Higher scopes show broader rollups;
|
||||
lower scopes show more specific story, queue, proof, artifact, test, and topology
|
||||
data.
|
||||
|
||||
Current project tabs:
|
||||
|
||||
- Story - narrative progress and queue body content
|
||||
- Progress - status and activity rollup
|
||||
- Artifacts - files, commits, proof, and outputs
|
||||
- Tests - proof material, screenshots, and verification detail
|
||||
- Queue - operational qitems with actions and drawer detail
|
||||
- Topology - workflow/agent diagram where available
|
||||
|
||||
Project primitives translate raw queue and event data into readable badges,
|
||||
chips, dates, flows, proof thumbnails, and action outcome panels.
|
||||
|
||||
## For You
|
||||
|
||||
For You is the attention-routing surface. It uses live event feed data plus queue
|
||||
details to classify cards into:
|
||||
|
||||
- Action Required
|
||||
- Approval
|
||||
- Shipped
|
||||
- Progress
|
||||
- Observation
|
||||
|
||||
The card renderer prioritizes readable body content, proof thumbnails, actor
|
||||
flow, friendly dates, and action affordances. Queue actions reuse existing
|
||||
mission-control action endpoints.
|
||||
|
||||
## Library And Skills
|
||||
|
||||
`/specs` is the Library route. It contains specs, applications, context packs,
|
||||
agent specs, agent images, and skills.
|
||||
|
||||
Skills are folder-based. The skill tree in Explorer treats each skill as a
|
||||
folder. Clicking a skill opens a skill detail route that defaults to `skill.md`
|
||||
case-insensitively. The skill detail page uses the existing `FileViewer` and
|
||||
does not add a second in-page file browser; file navigation belongs in the
|
||||
Explorer tree.
|
||||
|
||||
## Drawer And Viewer System
|
||||
|
||||
The shared detail drawer is used for transient focused detail:
|
||||
|
||||
- Queue item viewer
|
||||
- File viewer
|
||||
- Subspec preview
|
||||
- Other detail selections
|
||||
|
||||
Drawer triggers carry root/path provenance so the viewer can fetch and render the
|
||||
correct content. The drawer supports outside-click dismissal.
|
||||
|
||||
Image proof viewing is handled by `ProofImageViewer`, which respects the shell
|
||||
layout and uses black-glass styling.
|
||||
|
||||
## Event And Activity Consumption
|
||||
|
||||
UI event consumption is centralized through shared event hooks so the app avoids
|
||||
spawning duplicate SSE connections for related surfaces.
|
||||
|
||||
Current activity consumers include:
|
||||
|
||||
- For You feed hydration
|
||||
- Topology activity rings and hot-potato movement
|
||||
- Activity feed/system surfaces
|
||||
- Rig event surfaces where appropriate
|
||||
|
||||
Reduced-motion preference is honored for pulse and packet animations.
|
||||
|
||||
## Graphics Layer
|
||||
|
||||
The graphics package is intentionally centralized:
|
||||
|
||||
- Runtime identity: Claude, Codex, Terminal, Unknown
|
||||
- Tool identity: CMUX, tmux, VS Code, Terminal, file, markdown, config, code,
|
||||
screenshot, proof, transcript, commit, folder, skill, video, trace
|
||||
- Actor identity: human/operator, runtime agents, terminal fallback
|
||||
|
||||
The goal is to make dense operator surfaces scannable without adding more raw
|
||||
metadata text.
|
||||
|
||||
## Current Design Constraints
|
||||
|
||||
- Use the shared primitives before creating new card styles.
|
||||
- Keep vellum and black-glass separate.
|
||||
- Keep status semantics separate from activity animation.
|
||||
- Do not duplicate runtime/tool brand detection outside the central helpers.
|
||||
- Do not add new daemon shape for UI polish unless the slice explicitly declares
|
||||
and verifies that boundary.
|
||||
- Do not make page-level content look like a raw terminal log unless the content
|
||||
is actually terminal output.
|
||||
The brand/design rules still live at `docs/DESIGN.md` (repo `docs/` root, by
|
||||
design — see `ui/library-specs-and-design-system.md`).
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Library/Specs Surfaces + Design-System Pointer
|
||||
status: active
|
||||
topics: [specification-and-bundles, observability]
|
||||
domains: [engineering-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Need to know how the Library (`/specs`) 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.
|
||||
siblings: [shell-and-routing.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Library/Specs Surfaces + Design-System Pointer
|
||||
|
||||
The Library destination keeps `/specs` as the route and presents the product
|
||||
label "Library": specs, applications, context packs, agent specs, agent
|
||||
images, plugins, and skills.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). Component/primitive names below are reconciled against
|
||||
> `packages/ui/src/components/` at HEAD — NOT taken on trust from `ui.md`'s
|
||||
> or `DESIGN.md`'s lists.
|
||||
|
||||
## 1. Library / specs surfaces
|
||||
|
||||
Components in `packages/ui/src/components/specs/` (re-confirmed at HEAD):
|
||||
|
||||
- `SpecsLibraryPage.tsx` — the `/specs` Library page (unified list:
|
||||
All / Apps / Rigs / Agents; service-backed entries render as `APP`).
|
||||
- `SpecsTable.tsx`, `SpecsTreeView.tsx` — list + Explorer tree.
|
||||
- `SkillsIndexPage.tsx`, `SkillDetailPage.tsx` — folder-based skills.
|
||||
The skill tree treats each skill as a folder; clicking a skill opens a
|
||||
detail route defaulting to `skill.md` case-insensitively, rendered via the
|
||||
shared `FileViewer` in the center workspace (no second in-page file
|
||||
browser — file navigation belongs in the Explorer tree).
|
||||
- `PluginsIndexPage.tsx`, `PluginDetailPage.tsx`, `AgentPluginsList.tsx` —
|
||||
the plugin surfaces.
|
||||
|
||||
> Drift-note (slice-00 0.3.0-GT seam a): the plugin primitive is a **0.3.1**
|
||||
> feature (git-ancestry proven; absent at `v0.3.0`, present at `v0.3.1`). Do
|
||||
> NOT back-attribute the `/specs/plugins`, `/plugins/$pluginId` routes or
|
||||
> these plugin components to 0.3.0. `ui.md` (which predates 0.3.x) has **no
|
||||
> narrative for plugins at all** — these surfaces are authored from source +
|
||||
> the slice-00 0.3.0-GT seam, not migrated.
|
||||
|
||||
## 2. Spec review / library / live-identity flows
|
||||
|
||||
These daemon-backed flows feed the Library UI (`architecture.md` §6
|
||||
"Spec review and spec library flow", "Live identity / specs UI flow",
|
||||
re-confirmed accurate at HEAD):
|
||||
|
||||
- **Raw YAML preview:** UI/CLI posts YAML to `/api/specs/review/rig` or
|
||||
`/api/specs/review/agent`; `SpecReviewService` parses/validates/returns
|
||||
structured review models; UI renders them through `RigSpecDisplay` /
|
||||
`AgentSpecDisplay` (the same primitives reused for draft preview, library
|
||||
review, and live full-details).
|
||||
- **Filesystem-backed library:** `SpecLibraryService` scans builtin + user
|
||||
roots (`packages/daemon/specs`, `~/.openrig/specs`, legacy fallback
|
||||
`~/.rigged/specs`); each YAML is classified via structured review;
|
||||
service-backed rigs are marked `hasServices`; `/api/specs/library` serves
|
||||
list/get/review/sync.
|
||||
- **CLI mirror:** `rig specs ls/show/preview/add/sync`; `rig specs add`
|
||||
installs a single YAML spec or a full spec directory; `rig up` /
|
||||
`rig bootstrap` resolve library names before other source kinds.
|
||||
- **Live identity:** Explorer/graph selection opens the shared right drawer
|
||||
(runtime-first node detail: live identity, peers, directional edges,
|
||||
transcript helpers, compact spec summary); `Open Full Details` navigates to
|
||||
`/rigs/$rigId/nodes/$logicalId` in the center workspace.
|
||||
|
||||
## 3. UI primitive inventory (reconciled against source)
|
||||
|
||||
> Drift-fix — `ui.md` "Design Primitives" (L75–107) omits `ProjectPill`.
|
||||
> `docs/DESIGN.md` L195 lists it; re-confirmed at HEAD it is a real export
|
||||
> (`packages/ui/src/components/project/ProjectMetaPrimitives.tsx:196`
|
||||
> `export function ProjectPill`). Reconciled inventory below is from source,
|
||||
> not from either doc's list.
|
||||
|
||||
- **Vellum/base** (`components/ui/`): `VellumCard`, `VellumSheet`,
|
||||
`RegistrationMarks`, `StatusPip`, `SectionHeader`, `EmptyState`,
|
||||
`Button`, `Tabs`, `Table`, form controls, plus `rig-stamp.tsx`
|
||||
(`RigStamp`) — present at HEAD; not in ui.md's list.
|
||||
- **Graphics** (`components/graphics/RuntimeMark.tsx`, exports re-confirmed):
|
||||
`RuntimeMark`, `RuntimeBadge`, `ToolMark`, `ToolBadge`, `ActorMark`,
|
||||
`OperatorMoodMark`; normalization in `lib/runtime-brand.ts` +
|
||||
`lib/tool-brand.ts`.
|
||||
- **Project metadata** (`components/project/ProjectMetaPrimitives.tsx`,
|
||||
exports re-confirmed): `ProjectPill` (`:196`), `EventBadge` (`:224`),
|
||||
`QueueStateBadge` (`:228`), `TagPill` (`:274`), `ActorChip` (`:283`),
|
||||
`DateChip` (`:303`), `FlowChips` (`:315`), `ProofThumbnailGrid` (`:334`),
|
||||
`ProofPacketHeader` (`:380`) — plus `QueueCountIcon` / `StatusDot` (in
|
||||
source, not in ui.md/DESIGN.md primitive lists).
|
||||
- **Viewers**: `FileViewer` (`components/drawer-viewers/FileViewer.tsx`),
|
||||
`MarkdownViewer` (`components/markdown/MarkdownViewer.tsx`),
|
||||
`ProofImageViewer`, `SessionPreviewPane` (`components/preview/`).
|
||||
|
||||
## 4. Design-system pointer (NOT a copy — Q1)
|
||||
|
||||
The canonical OpenRig visual/design system is **`docs/DESIGN.md`** (at the
|
||||
repo `docs/` root, NOT under `docs/as-built/`). Q1 is ratified: DESIGN.md
|
||||
stays at root, byte-identical, and this module **points to it rather than
|
||||
copying it**. DESIGN.md is the source for: Vellum-paper vs Black-glass
|
||||
surface languages, colour tokens (`packages/ui/src/globals.css` +
|
||||
`tailwind.config.ts`), typography (`font-body` Inter / `font-headline` Space
|
||||
Grotesk / `font-mono` JetBrains Mono), layout (48px rail + Explorer + center
|
||||
+ drawer + preview stack), the core-primitive list, the graphics system, and
|
||||
the interaction/motion/accessibility/do-and-do-not rules.
|
||||
|
||||
> Reconciliation note: DESIGN.md's primitive lists were independently
|
||||
> verified against `packages/ui/src/components/` at HEAD — every primitive
|
||||
> DESIGN.md names (Vellum, Graphics, Project-metadata, Topology, Preview)
|
||||
> resolves to a real export at HEAD. DESIGN.md is accurate and carries no
|
||||
> slice-00 numeric drift, so it is referenced as-is. Do not duplicate its
|
||||
> content here; read `docs/DESIGN.md` directly for the visual spec.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/DESIGN.md` — the canonical visual/design-system spec (pointer; do
|
||||
not copy).
|
||||
- `shell-and-routing.md` — the `/specs` route family + shell + drawer.
|
||||
- `../architecture/agent-spec-and-startup.md` — the spec parsing/resolution
|
||||
contract behind the review flows.
|
||||
- `../architecture/plugin-agent-image-context-pack.md` — the 0.3.1 content
|
||||
layer behind the plugins/agent-image/context-pack Library surfaces.
|
||||
- Source roots: `packages/ui/src/components/specs/`,
|
||||
`packages/daemon/src/domain/{spec-review-service,spec-library-service}*`.
|
||||
@@ -0,0 +1,377 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Project Observability, For You, Dashboard
|
||||
status: active
|
||||
topics: [observability, coordination]
|
||||
domains: [engineering-advisor, product-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the operator-facing destination surfaces are built —
|
||||
the For-You attention feed (5-card classifier + verb actions), the
|
||||
Project workspace/mission/slice scope pages (tabbed rollups), and the
|
||||
Dashboard landing surface on the vellum brand system. Author-mode
|
||||
module — ui.md prose for these surfaces predates 0.3.x; every claim is
|
||||
sourced to file:line at HEAD.
|
||||
siblings: [shell-and-routing.md, ../architecture/mission-control.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Project Observability, For You, Dashboard
|
||||
|
||||
The three operator-facing destination surfaces: **For You** (`/for-you`,
|
||||
the attention feed), **Project** (`/project*`, the workspace/mission/slice
|
||||
scope pages), and the **Dashboard** (`/`, the landing surface on the
|
||||
vellum brand system). All read live daemon state; none adds UI-local
|
||||
persistence beyond per-event soft-dismiss.
|
||||
|
||||
> **AUTHOR-HEAVY module.** `ui.md`'s Project Observability / For You /
|
||||
> Graphics-Layer sections are thin and predate the 0.3.1 dashboard/vellum
|
||||
> brand refresh. Every load-bearing claim carries `> Source: <file:line>
|
||||
> @HEAD`; ambiguity is declared as an OPEN item, never smoothed. Paths
|
||||
> are relative to `packages/ui/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).
|
||||
|
||||
## 0. Release attribution (forensic seam — read first)
|
||||
|
||||
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Layer | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| Project-observability foundation (For-You feed, feed-classifier, project scope pages, the Mission Control 7-verb vocabulary) | **0.3.0** | `v0.3.0:components/for-you/Feed.tsx`, `v0.3.0:lib/feed-classifier.ts`, `v0.3.0:components/project/ScopePages.tsx`, `v0.3.0:components/mission-control/components/VerbActions.tsx` all resolve at `v0.3.0` (slice-00 0.3.0-GT §1.5/§1.6/§1.7) |
|
||||
| Vellum **brand-identity system** the Dashboard/For-You now render on (`dashboard/vellum/*`: CornerBracket, VellumDestinationCard, marks, graphics, single-source-of-truth barrel) | **0.3.1, NOT 0.3.0** | `git cat-file -e v0.3.0:components/dashboard/vellum/index.ts` → **ABSENT** ("exists on disk, but not in 'v0.3.0'"); `v0.3.1:` → present (slice-00 0.3.0-GT seam (b); §2 rows 1/2) |
|
||||
|
||||
> Source: re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/ui/src/components/dashboard/vellum/index.ts`
|
||||
> fails; `v0.3.0:.../for-you/Feed.tsx`, `.../lib/feed-classifier.ts`,
|
||||
> `.../project/ScopePages.tsx`, `.../mission-control/components/VerbActions.tsx`
|
||||
> all resolve at `v0.3.0`. Vellum surface *primitives*
|
||||
> (`components/ui/vellum-*.tsx`) shipped 0.3.0; the coherent vellum
|
||||
> *brand system* under `dashboard/vellum/` is 0.3.1 — slice-00 0.3.0-GT
|
||||
> seam (b) is the exact split. **Do not back-attribute the vellum brand
|
||||
> system to 0.3.0.**
|
||||
|
||||
## 1. Route reality (source-verified at `routes.tsx`@HEAD — BINDING)
|
||||
|
||||
Before narrating any surface, each route was verified at
|
||||
`packages/ui/src/routes.tsx`@HEAD (542 lines). The Phase-8.1 survey's
|
||||
"missing route" expectations are grep-derived and partially wrong; this
|
||||
table is authored from `routes.tsx` reality, not the survey:
|
||||
|
||||
| Path | Reality @HEAD | Component | Source |
|
||||
|---|---|---|---|
|
||||
| `/for-you` | **REAL destination route** | `Feed` | `routes.tsx:122-126` |
|
||||
| `/project` | **REAL destination route** | `WorkspaceScopePage` | `routes.tsx:128-132` |
|
||||
| `/project/mission/$missionId` | **REAL destination route** | `MissionScopePage` | `routes.tsx:134-138` |
|
||||
| `/project/slice/$sliceId` | **REAL destination route** | `SliceScopePage` | `routes.tsx:140-144` |
|
||||
| `/` (index) | **REAL destination route** | `Dashboard` | `routes.tsx:88-92` |
|
||||
| `/mission-control` | **DELETED `<Navigate to="/for-you">` redirect-stub** | `() => <Navigate to="/for-you">` | `routes.tsx:440-444` |
|
||||
| `/progress` | **`<Navigate to="/project">` redirect-stub** (folds into Project tabs) | `() => <Navigate to="/project">` | `routes.tsx:463-467` |
|
||||
| `/slices` | **DELETED `<Navigate to="/project">` redirect-stub** | `() => <Navigate to="/project">` | `routes.tsx:447-451` |
|
||||
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId">` redirect-stub** | `useParams` → `<Navigate>` | `routes.tsx:453-460` |
|
||||
| `/steering` | **`<Navigate to="/project">` redirect-stub** | `() => <Navigate to="/project">` | `routes.tsx:470-474` |
|
||||
| `/markdown` | **NOT a route** — `MarkdownViewer` is a component used inside `MissionScopePage` | (no route) | grep `routes.tsx`@HEAD: zero `path: "/markdown"` |
|
||||
|
||||
> Source: `routes.tsx:88-92`,`:122-144`,`:440-474` @HEAD; component
|
||||
> imports `routes.tsx:40-41` (`Dashboard`, `Feed`), `:60-63`
|
||||
> (`WorkspaceScopePage`/`MissionScopePage`/`SliceScopePage` from
|
||||
> `components/project/ScopePages.js`) @HEAD.
|
||||
|
||||
> **Route-reality contract (slice-08 §10.7):** the **Mission Control
|
||||
> *system*** lives at daemon/PL-005 (see `../architecture/mission-control.md`)
|
||||
> — `/mission-control` is **NOT a UI destination**; it is a deleted
|
||||
> redirect-stub (SC-18). The 7-verb *action vocabulary* surfaces in this
|
||||
> module via `VerbActions` embedded in For-You cards, but the system
|
||||
> itself is not this module's subject. `/progress`, `/slices`,
|
||||
> `/steering` all fold into `/project` tabs — they are redirect-stubs,
|
||||
> not surfaces to narrate.
|
||||
|
||||
## 2. For You — the attention feed (`/for-you` → `Feed`)
|
||||
|
||||
`Feed` is the operator's attention surface: a chronologically-sorted,
|
||||
subscription-filtered, soft-dismissable card stream over the live
|
||||
activity feed. Header reads `Attention` / `For You`; max width 720px.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:364-369` (`data-testid="for-you-feed"`,
|
||||
> max-w-720 + `For You` header), feed-centerpiece design note `:3-12`
|
||||
> (PRIMARY UX = the feed; subscriptions NOT dominating — LOAD-BEARING
|
||||
> SC-16) @HEAD.
|
||||
|
||||
### 2.1 The 5-card classifier (0.3.0 spine)
|
||||
|
||||
`classifyFeed(events)` maps every `ActivityEvent` to exactly one of five
|
||||
`FeedCardKind` values — `action-required`, `approval`, `shipped`,
|
||||
`progress`, `observation` — then sorts by `receivedAt` descending.
|
||||
**Nothing is silently dropped**: any unmatched event type falls through
|
||||
to `observation`. Queue-visibility events are sub-classified by
|
||||
`queueKind(type, state)` (e.g. `*.closed` → shipped; `human-gate` /
|
||||
`pending-approval` → action-required; `closeout-pending-ratify` →
|
||||
approval); a human-seat destination forces `action-required`.
|
||||
|
||||
> Source: `lib/feed-classifier.ts:9-14` (`FeedCardKind` union),
|
||||
> `classifyEvent` `:145-218` (default-observation fallthrough L216-217),
|
||||
> `queueKind` `:93-110`, `classifyFeed` `:220-223` (receivedAt-desc sort),
|
||||
> `isHumanSeat` `:141-144` @HEAD.
|
||||
|
||||
The feed pipeline (`Feed.tsx`): `classifyFeed` → cap at
|
||||
`HISTORY_LIMIT = 50` → hydrate each card's kind from the live queue-item
|
||||
map + action-audit (`hydratedCardKind`: a recorded outcome on an
|
||||
action/approval card → `approval`; a `done|closed|completed` qitem →
|
||||
`shipped`) → **subscription filter** (`isCardKindSubscribed` against the
|
||||
5 `feed.subscriptions.*` config keys; `action-required` is always
|
||||
visible, `observation` only when the audit subscription is on) → the
|
||||
transient **lens-chip** filter (All / Action req / Approvals / Shipped /
|
||||
Progress / Audit; not persisted) → per-event-seq soft-dismiss filter.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:63` (`HISTORY_LIMIT = 50`),
|
||||
> `:238` (`classifyFeed(events).slice(0, HISTORY_LIMIT)`),
|
||||
> `hydratedCardKind` `:156-180`, subscription+lens+dismiss pipeline
|
||||
> `:269-285` (subscription FIRST L280-282; lens L283; dismiss L284),
|
||||
> `LENS_CHIPS` `:54-61` @HEAD;
|
||||
> subscription forced/default rules `Feed.tsx:3-12` (action_required
|
||||
> forced ON L9; observation gated on audit_log L10) @HEAD.
|
||||
|
||||
### 2.2 Queue-item hydration + proof previews
|
||||
|
||||
For cards carrying a `qitemId` payload, `useQueueItemMap` hydrates the
|
||||
full queue-item body; for `shipped` cards `sliceForCard` matches a slice
|
||||
by tag/text and `proofPreviewForSlice` pulls the first proof packet with
|
||||
screenshots so the card renders an inline `ProofThumbnailGrid` →
|
||||
`ProofImageViewer`. This is the slice-00 §1.5 "queue cards hydrate qitem
|
||||
bodies + proof previews" foundation, live-wired.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:263` (`useQueueItemMap`),
|
||||
> `sliceForCard` `:182-201`, `proofPreviewForSlice` `:203-213`,
|
||||
> per-card proof wiring `:421-427`; `FeedCard.tsx` proof block
|
||||
> `:453-471` (`ProofPacketHeader`+`ProofThumbnailGrid`),
|
||||
> `ProofImageViewer` `:521` @HEAD.
|
||||
|
||||
### 2.3 Verb actions on action/approval cards (the 7-verb system; OPEN-3)
|
||||
|
||||
An actionable card (`action-required` or `approval`, no recorded
|
||||
outcome, non-terminal qitem) embeds `VerbActions`. The **canonical
|
||||
Mission Control action vocabulary is the 7-verb system** —
|
||||
`MISSION_CONTROL_VERBS = [approve, deny, route, annotate, hold, drop,
|
||||
handoff]` — defined at the Mission Control system level (slice-00
|
||||
0.3.0-GT §1.6/seam (c): the vocabulary *originates in 0.3.0 source*).
|
||||
|
||||
> Source: `components/mission-control/hooks/useMissionControlAction.ts:5-15`
|
||||
> (`MISSION_CONTROL_VERBS` 7-element const `:5-13` + `MissionControlVerb`
|
||||
> type `:15`), `components/mission-control/components/VerbActions.tsx:82`
|
||||
> (defaults to all 7 via `enabledVerbs = [...MISSION_CONTROL_VERBS]`),
|
||||
> per-verb input needs `:97-99` (route/handoff→destination L97;
|
||||
> annotate→annotation L98; hold/drop→reason L99) @HEAD; slice-00
|
||||
> 0.3.0-GT §1.6 + seam (c).
|
||||
|
||||
> **OPEN — For-You verb-subset (slice-00 0.3.0-GT OPEN-3; NOT smoothed).**
|
||||
> `VerbActions` accepts an `enabledVerbs` prop to restrict the offered
|
||||
> verbs (`VerbActions.tsx:21-22`). The For-You `FeedCard` currently
|
||||
> passes `enabledVerbs={["approve", "deny", "route"]}`
|
||||
> (`FeedCard.tsx:492`). **The exact shipped For-You-surface verb subset
|
||||
> is an unresolved velocity slice-01 ruling** (slice-00 0.3.0-GT OPEN-3:
|
||||
> CHANGELOG `[0.3.1]` enumerates the full 7 on `FeedCard`, while
|
||||
> source-material §6 frames it as a "subset" — the discrepancy is OPEN).
|
||||
> Per the slice-08 contract this module describes the **7-verb
|
||||
> system-level vocabulary only** and does NOT assert a definitive
|
||||
> For-You verb subset. The `enabledVerbs` prop value above is reported
|
||||
> as the literal current source state, explicitly NOT as a resolution
|
||||
> of OPEN-3.
|
||||
|
||||
On mutation success `VerbActions` fires `onOptimisticOutcome`; `Feed`
|
||||
keeps an optimistic-outcome map keyed by `qitemId` so the
|
||||
`ActionOutcomePanel` receipt renders instantly without waiting for the
|
||||
audit-log re-fetch (the audit re-fetch eventually surfaces the same
|
||||
shape). A terminal qitem with no recorded outcome derives a fallback
|
||||
receipt from its closure reason.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:224-236` (optimistic-outcome
|
||||
> map + `setOptimisticOutcome`), `:426-428` (optimistic-first, audit
|
||||
> fallback: `optimisticOutcomes.get ?? actionOutcomes.get ?? null`);
|
||||
> `FeedCard.tsx:473-500` (`isActionableCard` gate L473 + `VerbActions`
|
||||
> wiring L489-499), `isActionableCard` `:166-175`,
|
||||
> `fallbackOutcomeFromQueueItem` `:177-198`, `ActionOutcomePanel`
|
||||
> `:271-310`; `VerbActions.tsx:138-145` (`onOptimisticOutcome` on
|
||||
> mutation `onSuccess`) @HEAD.
|
||||
|
||||
### 2.4 Card surface — vellum-coherent (0.3.1 brand layer)
|
||||
|
||||
`FeedCard` renders on the **0.3.1 vellum brand system**: a
|
||||
`bg-stone-100/45 backdrop-blur` surface with a 3-stop ambient box-shadow
|
||||
(no border), four `CornerBracket` marks registering the bounds through
|
||||
the vellum, a mono-uppercase kind tag + colored dot, and runtime
|
||||
graphics marks (`ActorMark` for sessions) — the slice-00 §1.7 "shared
|
||||
graphics marks across drawers / proof rows / queue refs / story rows"
|
||||
foundation, now expressed in the matured vellum vocabulary. Cards are
|
||||
keyboard-dismissable (Backspace/Delete) and swipe-dismissable, with an
|
||||
`UndoToast`.
|
||||
|
||||
> Source: `components/for-you/FeedCard.tsx:72-73` (`CARD_SURFACE_CLASS`),
|
||||
> `:74-80` (`CARD_SHADOW_STYLE` 3-stop box-shadow), corner brackets
|
||||
> `:401-404` (4× `<CornerBracket position=…>`), `KIND_DOT` `:33-39`,
|
||||
> kind tag span `:410-414`, `ActorMark` import `:25` + usage `:229`;
|
||||
> `CornerBracket` from the 0.3.1 brand barrel
|
||||
> `dashboard/vellum/index.ts:17`; slice-00 0.3.0-GT seam (b) + §1.7 @HEAD.
|
||||
|
||||
### 2.5 Storytelling preview band
|
||||
|
||||
Above the legacy card list `Feed` renders a `StorytellingFeed` preview
|
||||
built by `buildStorytellingFeedItems` from discovered missions
|
||||
(`useMissionDiscovery` → `ProgressCard`, first 2) + slices
|
||||
(`useSlices` → `ShippedCard` for shipped/done, `IncidentCard`
|
||||
otherwise, capped at 3). Mission rows carry a daemon-derived
|
||||
`status` so the "Getting Started"-style complete-and-hide filters
|
||||
durably (`status === "complete"`), with an optimistic local hide +
|
||||
best-effort `POST /api/missions/:id/complete` audit write that swallows
|
||||
network errors so a partial-air-gapped daemon does not block the hide.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:319-343` (mission/slice adapters:
|
||||
> `useMissionDiscovery` L319, `missionsWithStatus` L329-335,
|
||||
> `buildStorytellingFeedItems` L336-343; adapter comment L305-318),
|
||||
> `handleMarkMissionComplete` `:351-361` (best-effort `void fetch` L354,
|
||||
> error-swallowed L357), preview render `:399-409`;
|
||||
> `components/feed/cards/storytelling-cards.tsx:36` (`CardKind`),
|
||||
> `ShippedCard` `:184`, `IncidentCard` `:235`, `ProgressCard` `:289`
|
||||
> @HEAD.
|
||||
|
||||
## 3. Project — workspace / mission / slice scope pages (0.3.0 spine)
|
||||
|
||||
`/project*` mounts three scope pages, all built on a shared
|
||||
`ScopeShell` + tabbed-rollup pattern (slice-00 §1.5 project
|
||||
observability foundation). A `SHARED_TABS` set —
|
||||
`overview / story / progress / artifacts / tests / queue / topology` —
|
||||
drives `WorkspaceScopePage` and `MissionScopePage`; `SLICE_TABS`
|
||||
reorders `story` first for `SliceScopePage` but `SliceScopePage`'s
|
||||
`useState` default is `overview` (README + readiness first, not a
|
||||
metric grid).
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:75-76` (`SharedTab` /
|
||||
> `SliceTab` types), `SHARED_TABS` `:78-86`, `SLICE_TABS` `:88-96`,
|
||||
> `ScopeShell` `:137-166`, `TabNav` `:98-135` @HEAD.
|
||||
|
||||
### 3.1 WorkspaceScopePage (`/project`)
|
||||
|
||||
Reads the live workspace name (`useWorkspaceName`); honest empty-state
|
||||
(`NO WORKSPACE CONNECTED` with an Open-settings action) when unset. The
|
||||
`overview` tab renders `WorkspaceOverviewPanel`: slices grouped into
|
||||
missions, then `partitionProjectMissions` splits them into a
|
||||
two-column **Current Work / Archive** layout (slice-00 §1.5
|
||||
"current/archive grouping"). Each mission card lists its slices as
|
||||
`Link`s to `/project/slice/$sliceId` with a `QueueCountIcon` +
|
||||
`StatusDot`. The other tabs are the scope rollups (§3.4).
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:676-749` (`WorkspaceScopePage`;
|
||||
> no-workspace empty-state guard `:682-693`, `workspace-scope-no-workspace`
|
||||
> `:689`), `WorkspaceOverviewPanel` `:534-674` (mission bucketing
|
||||
> `:536-551`, `partitionProjectMissions` `:552`, two-column panel `:631`,
|
||||
> Current section `:632-651`, Archive section `:652-671`) @HEAD.
|
||||
|
||||
### 3.2 MissionScopePage (`/project/mission/$missionId`)
|
||||
|
||||
Same `ScopeShell`/`SHARED_TABS`. `overview` renders the mission README
|
||||
(`useScopeMarkdown(missionPath, "README.md")` via `MarkdownViewer`)
|
||||
above a slice rail; `progress` renders a `MissionProgressHeatmap` + the
|
||||
mission `PROGRESS.md` + the shared `ScopeProgressRollup`. The `topology`
|
||||
tab renders the **projected workflow spec graph** via `TopologyTab`
|
||||
when the mission's README frontmatter declares a `workflow_spec` that is
|
||||
in the `WorkflowSpecCache` (daemon returns `topology.specGraph`),
|
||||
falling back to session-name aggregation otherwise.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:751-895` (`MissionScopePage`;
|
||||
> `useMission` `:759`, `missionPath` `:760-761`, README/PROGRESS
|
||||
> markdown `:762-763`, overview README section `:775`, progress
|
||||
> heatmap `:832`, `mission-progress-readme` `:838`, spec-graph topology
|
||||
> branch `:875-891` — `missionTopology` `:875`, `specGraph` check
|
||||
> `:879`, session-name fallback `:890`) @HEAD.
|
||||
|
||||
### 3.3 SliceScopePage (`/project/slice/$sliceId`)
|
||||
|
||||
Default tab `overview` (`SliceOverviewTab` — README + current step +
|
||||
readiness). Loading/error states are honest empty-states (the error
|
||||
state names `rig config get workspace.slices_root` as the likely
|
||||
misconfiguration). `progress` folds in `AcceptanceTab` (acceptance is
|
||||
the canonical slice-scope progress proof); `story` renders `TimelineTab`
|
||||
with curated `timeline.md` (`useSliceTimelineMarkdown`) above the
|
||||
auto-captured event feed; `topology` renders the slice's
|
||||
workflow-instance-aware `TopologyTab`.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:1187-1297` (`SliceScopePage`;
|
||||
> default `overview` `:1192`, timeline md `:1204`, loading-state
|
||||
> guard `:1206`, error-state guard `:1225` (slices_root remediation
|
||||
> hint `:1239`), story=`TimelineTab` `:1258-1265`,
|
||||
> progress=`AcceptanceTab` `:1269-1273`, topology `:1294`) @HEAD.
|
||||
|
||||
### 3.4 Shared scope rollups
|
||||
|
||||
`ScopeStoryRollup`, `ScopeProgressRollup`, `ScopeArtifactsRollup`,
|
||||
`ScopeTestsRollup`, `ScopeQueueRollup`, `ScopeTopologyRollup` (the
|
||||
non-overview tabs) all read from one `useProjectScopeRollup(missionId,
|
||||
loadDetails)` hook so workspace and mission scopes share identical
|
||||
rollup behavior over their respective slice sets — the slice-00 §1.5
|
||||
"workspace/mission rollups" foundation. Detail fetching is gated on the
|
||||
active tab (`active !== "overview"`) so the overview path stays cheap.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:198-217`
|
||||
> (`useProjectScopeRollup`; `rowsForScope` `:193-196`),
|
||||
> rollup components `:219-532`; gate `:679`,`:754` (`active !== "overview"`)
|
||||
> @HEAD.
|
||||
|
||||
## 4. Dashboard — the landing surface (`/` → `Dashboard`)
|
||||
|
||||
`Dashboard` is a thin composition over the **0.3.1 vellum brand
|
||||
system**: it imports `MidLayerContent`, `TopLayerContent`,
|
||||
`DestinationsLayer` from `dashboard/vellum/index.js` — the SAME barrel
|
||||
`/lab/vellum-lab` imports, so the production dashboard tracks the design
|
||||
lab exactly (single source of truth). Real-data wiring: `useRigSummary`
|
||||
→ totalRigs/totalAgents, `usePsEntries` → activeAgents, `useSpecLibrary`
|
||||
→ librarySize, `window.location.hostname` → classification eyebrow. Per
|
||||
a 2026-05-15 founder dispatch the heavy `BackLayerContent` /
|
||||
`BackVellumSheet` back-layer was removed so the dashboard sits on the
|
||||
page-level cream paper-grid; the barrel still exports them (used by the
|
||||
lab).
|
||||
|
||||
> Source: `components/dashboard/Dashboard.tsx:1-52` (vellum-barrel
|
||||
> import `:12-16`, single-source-of-truth note `:2-5`, real-data hooks
|
||||
> `:29-39`, back-layer-removal note `:21-27`, render `:40-50`);
|
||||
> `components/dashboard/vellum/index.ts:1-26` (barrel; `BackLayerContent`
|
||||
> `:5` / `BackVellumSheet` `:6` still exported); slice-00 0.3.0-GT
|
||||
> seam (b) + §2 rows 1/2 @HEAD.
|
||||
|
||||
## 5. Cross-cutting properties
|
||||
|
||||
- **Live daemon state, no UI-local persistence beyond soft-dismiss** —
|
||||
every surface reads daemon hooks; the only UI-local state is the
|
||||
per-event-seq dismiss set + transient lens chips + optimistic-outcome
|
||||
map (`Feed.tsx:217` lens, `:224` optimistic map, `:240-241` dismiss
|
||||
set; `ScopePages.tsx` `useState` tab state `:677`,`:753`,`:1192`)
|
||||
@HEAD.
|
||||
- **Honest empty/error states** — `WorkspaceScopePage` no-workspace
|
||||
(`ScopePages.tsx:682-693`), `WorkspaceOverviewPanel` index-unavailable
|
||||
(`:565-573`, label `:568`), `SliceScopePage` not-available
|
||||
(`:1225-1245`, label `:1235`) all surface the cause + a remediation
|
||||
pointer, never a blank screen @HEAD.
|
||||
- **0.3.0 observability spine on the 0.3.1 vellum brand layer** — the
|
||||
classifier / verb-action / scope-rollup *logic* is the 0.3.0
|
||||
foundation (§0); the *visual surface* (vellum cards, corner brackets,
|
||||
graphics marks, dashboard) is the 0.3.1 brand maturation. The split is
|
||||
slice-00 0.3.0-GT seam (b); do not collapse either direction @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN — For-You verb subset (slice-00 0.3.0-GT OPEN-3).** The exact
|
||||
shipped For-You-surface verb subset is an unresolved velocity slice-01
|
||||
ruling (CHANGELOG `[0.3.1]` "all 7" vs source-material §6 "subset"
|
||||
discrepancy). This module describes the **7-verb system-level
|
||||
vocabulary only** and reports the literal current `enabledVerbs`
|
||||
prop value (§2.3) as source state, NOT as a resolution. Defer
|
||||
For-You-subset enumeration to the pending velocity ruling.
|
||||
- **OPEN — `/project` redirect-stub consolidation.** `/progress`,
|
||||
`/slices`, `/steering` are `<Navigate to="/project">` stubs and
|
||||
`/slices/$name` redirects into `/project/slice/$sliceId` (§1). The
|
||||
former standalone surfaces fold into Project tabs; whether the stubs
|
||||
are permanent or transitional is a product decision not resolvable
|
||||
from source.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration / route-group / PL-004-event counts.
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Shell, Routing, Drawer System
|
||||
status: active
|
||||
topics: [observability]
|
||||
domains: [engineering-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Need to know how the UI shell (AppShell 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.
|
||||
siblings: [topology.md, project-and-for-you.md, library-specs-and-design-system.md]
|
||||
prerequisite-reads: [../README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Shell, Routing, Drawer System
|
||||
|
||||
The `@openrig/ui` package is the shell-first, route-first, primitive-driven
|
||||
operator surface. Brand/visual rules live in `docs/DESIGN.md` (pointer from
|
||||
`library-specs-and-design-system.md`); this module is how the shell is
|
||||
assembled and what routes it mounts.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). UI footprint is **235** source files
|
||||
> (`packages/ui/src`, `.ts`+`.tsx`, non-test; slice-00 §1.6 / D6).
|
||||
|
||||
## 1. Package shape
|
||||
|
||||
UI package: `packages/ui`. Primary implementation areas:
|
||||
|
||||
- `src/routes.tsx` — TanStack Router route tree.
|
||||
- `src/components/AppShell.tsx` — global shell: rail, Explorer, drawer,
|
||||
preview stack.
|
||||
- `src/components/ui/` — reusable vellum + base UI primitives.
|
||||
- `src/components/graphics/RuntimeMark.tsx` — runtime/tool/actor marks.
|
||||
- `src/components/topology/` — graph/table/terminal topology surfaces.
|
||||
- `src/components/project/`, `for-you/`, `dashboard/`, `feed/` —
|
||||
observability surfaces.
|
||||
- `src/components/specs/` — Library / skills / plugins surfaces.
|
||||
- `src/components/{preview,markdown,drawer-viewers}/` — viewers.
|
||||
- `src/hooks/`, `src/lib/` — route data hooks, classifiers, formatters,
|
||||
layout/brand helpers.
|
||||
|
||||
## 2. Shell model
|
||||
|
||||
`AppShell` wraps every route. `AppShell.tsx` lays a 48px icon rail + a
|
||||
route-aware Explorer sidebar + center workspace + shared right drawer +
|
||||
preview stack + topology overlay provider + shared drawer-selection /
|
||||
discovery-placement contexts.
|
||||
|
||||
The rail destinations (`AppShell.tsx` `RAIL_ICONS`, re-confirmed at HEAD)
|
||||
are exactly six destination icons plus Advisor/Operator entries:
|
||||
|
||||
- Dashboard `/` (`rail-dashboard`)
|
||||
- Topology `/topology` (`rail-topology`)
|
||||
- For You `/for-you` (`rail-for-you`)
|
||||
- Project `/project` (`rail-project`)
|
||||
- Library `/specs` (`rail-specs`)
|
||||
- Settings `/settings` (`rail-settings`)
|
||||
|
||||
The Explorer is contextual: its tree changes with the current destination
|
||||
rather than acting as a generic file browser.
|
||||
|
||||
## 3. Route model
|
||||
|
||||
> Drift-fix D16 — `ui.md` "Route Model" (the L52–73 list) and
|
||||
> `architecture.md` §2 "UI architecture" predate 0.3.x. The route table
|
||||
> below is **re-derived from `packages/ui/src/routes.tsx` at HEAD** (542
|
||||
> lines). The route tree is assembled at `routes.tsx:480`
|
||||
> (`rootRoute.addChildren([...])`).
|
||||
>
|
||||
> Correction to the D16 expectation itself (source-verified at HEAD): the
|
||||
> survey expected `/mission-control`, `/progress`, `/markdown` as net-new
|
||||
> *real* routes. At HEAD `/mission-control` and `/progress` are **redirect
|
||||
> stubs**, not destination routes, and there is **no `/markdown` route**.
|
||||
> There IS a real `/files` route (markdown is rendered by the `MarkdownViewer`
|
||||
> component inside file/drawer surfaces, not via a `/markdown` route). The
|
||||
> route table is reported as source says, not as the survey predicted.
|
||||
|
||||
### Primary destination routes
|
||||
|
||||
| Path | Component | Notes |
|
||||
|---|---|---|
|
||||
| `/` | Dashboard (`indexRoute` `routes.tsx:88`) | rail destination |
|
||||
| `/topology` | host topology (`:98`) | + rig/pod/seat scopes (see `topology.md`) |
|
||||
| `/topology/rig/$rigId` | rig scope (`:104`) | |
|
||||
| `/topology/pod/$rigId/$podName` | pod scope (`:110`) | |
|
||||
| `/topology/seat/$rigId/$logicalId` | seat scope (`:116`) | live node details |
|
||||
| `/for-you` | attention feed (`:122`) | rail destination |
|
||||
| `/project` | workspace project scope (`:128`) | rail destination |
|
||||
| `/project/mission/$missionId` | mission scope (`:134`) | |
|
||||
| `/project/slice/$sliceId` | slice scope (`:140`) | |
|
||||
| `/specs` | Library (`:146`) | rail destination |
|
||||
| `/specs/applications` | applications section (`:152`) | |
|
||||
| `/specs/skills` | skills index (`:160`) | |
|
||||
| `/specs/skills/$skillToken` | skill viewer (`:166`) | defaults to `skill.md` |
|
||||
| `/specs/skills/$skillToken/file/$fileToken` | skill file viewer (`:175`) | |
|
||||
| `/specs/plugins` | plugins index (`:186`) | 0.3.1 (see `library-specs…`) |
|
||||
| `/plugins/$pluginId` | plugin detail (`:201`) | 0.3.1 |
|
||||
| `/specs/$specKind/$specName` | generic spec → library redirect (`:213`) | |
|
||||
| `/files` | Files workspace (`:192`) | net-new vs ui.md/D16 |
|
||||
| `/settings` | settings center (`:222`) | rail destination |
|
||||
| `/settings/policies` | Policies (`:232`) | slice 27 Claude-compaction form |
|
||||
| `/settings/log` | Log (`:237`) | slice 26 4-item Settings explorer |
|
||||
| `/settings/status` | Status (`:242`) | |
|
||||
| `/search` | audit/history view (`:248`) | |
|
||||
|
||||
### Lab routes (design experiments)
|
||||
|
||||
`/lab/project-graphics-preview` (`:254`), `/lab/card-previews` (`:262`),
|
||||
`/lab/vellum-lab` (`:272`), `/lab/vellum-bg/{a-large,b-small,c-allover}`
|
||||
(`:283`–`:293`). The vellum-lab + vellum-bg routes are the 0.3.1 vellum
|
||||
brand-system experiment surface (slice-00 0.3.0-GT seam b: vellum brand
|
||||
identity matured in 0.3.1; the `dashboard/vellum/*` system is 0.3.1).
|
||||
|
||||
### Legacy / compatibility routes
|
||||
|
||||
`/rigs/$rigId` (`:318`), `/rigs/$rigId/nodes/$logicalId` (`:324`),
|
||||
`/import` (`:333`), `/packages`, `/packages/install`, `/packages/$packageId`
|
||||
(`:339`–`:351`), `/bootstrap` (`:357`), `/agents/validate` (`:363`),
|
||||
`/specs/rig` + `/specs/agent` review (`:372`/`:378`),
|
||||
`/specs/library/$entryId` (`:384`), `/discovery` + `/discovery/inventory`
|
||||
(`:395`/`:410`), `/bundles/inspect` + `/bundles/install` (`:416`/`:422`).
|
||||
|
||||
### Redirect stubs (deleted routes)
|
||||
|
||||
`routes.tsx:432`–`:474` mount `<Navigate>` redirects for removed routes —
|
||||
NOT destination pages: `/context` → `/topology`; `/mission-control` →
|
||||
`/for-you` (SC-18: `/mission-control` deleted, For-You replaces it);
|
||||
`/slices` + `/slices/$name` → `/project`; `/progress` → `/project`;
|
||||
`/steering` → `/project`. The Mission Control *system* still exists at the
|
||||
daemon/PL-005 layer (`../architecture/mission-control.md`); only the old UI
|
||||
*route* was retired in favour of For-You.
|
||||
|
||||
## 4. Drawer and viewer system
|
||||
|
||||
`SharedDetailDrawer` is the shared transient detail surface for queue-item
|
||||
viewer, file viewer, subspec preview, and other detail selections. Drawer
|
||||
triggers carry root/path provenance so the viewer fetches/render the correct
|
||||
content; the drawer supports outside-click dismissal. Image proof viewing is
|
||||
handled by `ProofImageViewer` with black-glass styling honouring the shell
|
||||
layout. Skill detail uses Library Explorer navigation + `FileViewer`
|
||||
(`components/drawer-viewers/FileViewer.tsx`) in the center workspace, not a
|
||||
nested three-column browser.
|
||||
|
||||
## 5. Event and activity consumption
|
||||
|
||||
UI event consumption is centralized through shared event hooks so the app
|
||||
avoids duplicate SSE connections for related surfaces. Current consumers: For
|
||||
You feed hydration, topology activity rings + hot-potato movement, the
|
||||
activity feed/system surfaces, and rig event surfaces. Reduced-motion
|
||||
preference is honoured for pulse/packet animation. (Daemon SSE surfaces:
|
||||
`/api/events`, `/api/stream/watch`, `/api/queue/watch` —
|
||||
`architecture-rules-and-event-system.md` §2.4.)
|
||||
|
||||
## See also
|
||||
|
||||
- `topology.md` — graph/table/terminal topology surface.
|
||||
- `project-and-for-you.md` — project observability + For-You feed.
|
||||
- `library-specs-and-design-system.md` — Library/specs + DESIGN.md pointer.
|
||||
- Source roots: `packages/ui/src/routes.tsx`,
|
||||
`packages/ui/src/components/AppShell.tsx`.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Topology — Graph/Table/Terminal, HotPotato, ActivityRing
|
||||
status: active
|
||||
topics: [observability, coordination]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know 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 topology navigation/overlay contracts.
|
||||
siblings: [shell-and-routing.md, project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Topology — Graph/Table/Terminal, HotPotato, ActivityRing
|
||||
|
||||
Topology is a scoped workspace with graph, table, and terminal views at the
|
||||
`/topology` route family (`shell-and-routing.md` §3). It is the operator's
|
||||
live picture of host → rig → pod → seat.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). All component names below are re-confirmed against
|
||||
> `packages/ui/src/components/topology/` at HEAD — `ui.md` and `DESIGN.md`
|
||||
> name *exported symbols*; the source file that defines the hybrid nodes is
|
||||
> `HybridTopologyNodes.tsx` (DESIGN.md L344 implementation reference).
|
||||
|
||||
## 1. Topology pieces
|
||||
|
||||
> Drift-fix — `ui.md` "Topology" (L108–137) lists `HybridAgentNode` /
|
||||
> `HybridPodGroupNode` as if top-level files. Re-confirmed at HEAD: both are
|
||||
> exports inside `packages/ui/src/components/topology/HybridTopologyNodes.tsx`
|
||||
> (`HybridPodGroupNode` is `memo(...)` at `HybridTopologyNodes.tsx:94`;
|
||||
> `HybridAgentNode` is `memo(HybridAgentNodeInner, ...)` at `:272`). The
|
||||
> component names are accurate; their file location is consolidated.
|
||||
|
||||
Components in `packages/ui/src/components/topology/` (re-confirmed at HEAD):
|
||||
|
||||
- `HostMultiRigGraph.tsx` — host-level hybrid React Flow graph (multi-rig
|
||||
single canvas).
|
||||
- `HybridTopologyNodes.tsx` — exports `HybridAgentNode` (compact agent cards:
|
||||
runtime badges, context %, token totals, activity state, terminal preview,
|
||||
CMUX actions) and `HybridPodGroupNode` (soft dashed pod frames).
|
||||
- `RigGroupNode.tsx` — soft rig frames with registration marks + aggregate
|
||||
activity.
|
||||
- `ActivityRing.tsx` — active / needs-input / blocked activity ring; card
|
||||
activity classes in `activity-card-visuals.ts`.
|
||||
- `HotPotatoEdge.tsx` — directional queue-movement edge animation.
|
||||
- `TopologyTableView.tsx` — dense table mirror of topology data.
|
||||
- `TopologyTerminalView.tsx` — terminal-oriented topology state.
|
||||
- `TopologyTreeView.tsx` — tree navigation view.
|
||||
- `TopologyViewModeTabs.tsx` — graph/table/terminal/tree mode switch.
|
||||
- `TerminalPreviewPopover.tsx` — black-glass quick terminal preview, portaled
|
||||
above the graph.
|
||||
- `LaunchCmuxButton.tsx` — hover/focus CMUX launch action.
|
||||
- `ScopePages.tsx` — host/rig/pod/seat scope page wrappers.
|
||||
- `topology-overlay-context.tsx` — `TopologyOverlayProvider` (expanded-rig
|
||||
state).
|
||||
|
||||
> Note (vs `DESIGN.md` "Topology" L205–216): DESIGN.md lists the same
|
||||
> exported primitives (`HostMultiRigGraph`, `HybridAgentNode`,
|
||||
> `HybridPodGroupNode`, `RigGroupNode`, `ActivityRing`, `HotPotatoEdge`,
|
||||
> `TerminalPreviewPopover`, `TopologyTableView`, `TopologyTerminalView`) —
|
||||
> reconciled against source: all present at HEAD. `TopologyTreeView` /
|
||||
> `TopologyViewModeTabs` / `LaunchCmuxButton` exist in source but are not in
|
||||
> DESIGN.md's primitive list (DESIGN.md is the brand primitive list, not an
|
||||
> exhaustive component inventory; not a drift, a scope difference). DESIGN.md
|
||||
> stays byte-identical (Q1).
|
||||
|
||||
## 2. Topology contracts
|
||||
|
||||
From `architecture.md` §2 "Current topology UI" + `ui.md` "Important
|
||||
topology contracts", re-confirmed against source behaviour at HEAD:
|
||||
|
||||
- Graph / table / tree navigation all resolve to seat detail URLs
|
||||
(`/topology/seat/$rigId/$logicalId`).
|
||||
- The host-level graph uses cross-rig node-ID prefixing for multi-rig single
|
||||
canvas.
|
||||
- Expanded-rig state is owned by `TopologyOverlayProvider`
|
||||
(`topology-overlay-context.tsx`); graph data is lazy-fetched for expanded
|
||||
rigs only.
|
||||
- Compact agent cards show context percentage, token totals, and activity
|
||||
card tint; `ActivityRing` + activity classes surface active / needs-input /
|
||||
blocked.
|
||||
- `HotPotatoEdge` animates directional queue movement at graph zoom levels;
|
||||
reduced-motion preference removes pulse/travel animation and keeps static
|
||||
state signals.
|
||||
- Terminal-preview actions are hover/focus-visible; the
|
||||
`TerminalPreviewPopover` must escape React Flow stacking contexts (portaled
|
||||
above the canvas) and stay within the viewport.
|
||||
|
||||
## 3. Layout helpers
|
||||
|
||||
Topology layout is computed by `src/lib/` helpers (re-confirmed at HEAD):
|
||||
`graph-layout.ts`, `hybrid-layout.ts`, `multi-rig-layout.ts`,
|
||||
`topology-activity.ts`, `activity-visuals.ts`. Runtime/tool identity on the
|
||||
cards comes from the central `runtime-brand.ts` / `tool-brand.ts` +
|
||||
`RuntimeMark.tsx` (do not duplicate brand logic — DESIGN.md "Do not";
|
||||
restated here as the topology-card brand-source contract).
|
||||
|
||||
## See also
|
||||
|
||||
- `shell-and-routing.md` — the `/topology` route family and shell.
|
||||
- `project-and-for-you.md` — sibling observability surface.
|
||||
- `../architecture/coordination-primitive.md` — the queue movement the
|
||||
hot-potato edge visualizes.
|
||||
- Source root: `packages/ui/src/components/topology/`,
|
||||
`packages/ui/src/lib/{graph,hybrid,multi-rig}-layout.ts`.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Getting started: the guided golden path
|
||||
|
||||
This is the ordered new-operator path from a fresh machine to a running rig with
|
||||
a workspace and a workflow. It uses the **existing** `rig` verbs in order - there
|
||||
is no magic one-command onboarding; each step is a real, inspectable verb.
|
||||
|
||||
> Everything below reports **what is currently true**, never a guarantee that
|
||||
> downstream work will succeed. "Daemon up" does not mean every agent is healthy;
|
||||
> "kernel ready" does not mean every kernel agent is healthy; a workspace root
|
||||
> being *live* does not mean it is the *right* one for your project.
|
||||
|
||||
## The ordered path
|
||||
|
||||
1. **`rig setup`** - install and verify the local runtime (tmux, cmux, Claude
|
||||
Code / Codex, runtime config). Run once per machine. `rig setup` does NOT boot
|
||||
the kernel (see "Kernel framing" below).
|
||||
2. **`rig up <rig-spec>`** - launch a rig. This **auto-starts the daemon** if it
|
||||
is not already running (the four daemon-dependent verbs below do not auto-start
|
||||
it - they tell you to run `rig up` or `rig daemon start`).
|
||||
3. **`rig status`** - see what is currently true: daemon up/down + port, kernel
|
||||
readiness (distinct from daemon health), and your effective workspace root
|
||||
(default vs override).
|
||||
4. **`rig workspace doctor`** - check that your workspace is ready; it reports
|
||||
state + fix-hints. The effective workspace root defaults to
|
||||
`~/.openrig/workspace/` unless you override `workspace.root` (env or config).
|
||||
5. **`rig workflow instantiate <name>`** - start a workflow runtime instance by
|
||||
its **discovered name** (e.g. `conveyor`, `basic-loop`) - no hidden file path
|
||||
needed. `rig workflow specs` shows the seeded built-ins. Instantiating creates a
|
||||
workflow instance and its first **entry qitem**.
|
||||
6. **`rig scope ...`** - browse and manage the durable mission/slice artifacts the
|
||||
workflow coordinates (see the scope <-> workflow bridge below).
|
||||
|
||||
`rig setup` prints this ordered sequence as its next-steps output; `rig status`
|
||||
points back here. This doc is the durable reference.
|
||||
|
||||
## Kernel framing (what `rig setup` does and does not do)
|
||||
|
||||
The kernel rig **auto-boots on daemon-start** - it is daemon-start behavior, NOT
|
||||
a `rig setup` step and NOT something you boot by hand. So:
|
||||
|
||||
- `rig setup` installs/verifies the runtime; it does not start the daemon or the
|
||||
kernel.
|
||||
- Starting the daemon (`rig daemon start`, or implicitly via `rig up`) is what
|
||||
boots the kernel rig in the background.
|
||||
- **Kernel readiness is a distinct signal from daemon health.** The daemon's HTTP
|
||||
health binds early; the kernel can still be booting or a kernel agent can be
|
||||
unhealthy while the daemon is up. `rig status` surfaces kernel readiness
|
||||
separately (via `/api/kernel/status`); `rig daemon start --wait-for-kernel`
|
||||
polls it.
|
||||
|
||||
## The scope <-> workflow bridge
|
||||
|
||||
Two related primitives, often confused by new operators:
|
||||
|
||||
- **`rig scope`** manages **durable, on-disk artifacts** - missions and slices
|
||||
(markdown/YAML files in your workspace). These are the persistent record of
|
||||
*what work exists*.
|
||||
- **`rig workflow`** manages a **runtime instance** - when you
|
||||
`rig workflow instantiate <name>`, the daemon creates a workflow instance plus
|
||||
an **entry qitem** that routes the first step to an owner. This is the live
|
||||
coordination of *who does the next step*.
|
||||
|
||||
How they relate: a scope slice is the durable description of a unit of work; a
|
||||
workflow instance + its qitems are the live machinery that moves that work
|
||||
through owners (hot-potato handoffs). They are not auto-bridged - there is no
|
||||
auto-instantiate-from-scope - you instantiate a workflow by name when you want to
|
||||
run one, and you reference your scope artifacts as the work it coordinates. A
|
||||
typical loop: author/track the unit in `rig scope`, then
|
||||
`rig workflow instantiate <name>` to start the runtime that drives it.
|
||||
@@ -0,0 +1,362 @@
|
||||
# OpenRig v0.3.2
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.2 ships Rigbundles as a first-class cross-primitive bundling
|
||||
mechanism, GAs the workspace + workflow operator surface, lands a
|
||||
4-bug `rig up <starter>` paper-cut fix-round, ships a 5-fix
|
||||
coordination + first-user-setup round (slice 21), scrubs internal
|
||||
substrate paths from daemon source + tests, wires `ConceptCard` to a
|
||||
shaped data source, and adds server-side priority windowing on For You.
|
||||
|
||||
No schema-breaking migrations. Existing v0.3.1 databases upgrade on first
|
||||
`rig daemon start` (adds migration `041_rig_policy` for the new
|
||||
operator-context-mode bindings table — `CREATE TABLE` with no impact on
|
||||
existing data; binding rows are operator-authored at runtime).
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **Rigbundles First-Class (slice 05)** — cross-primitive bundling for
|
||||
five content kinds (skills + plugins HYBRID + workflow_specs +
|
||||
context_packs + agent_images) routed end-to-end. `rig bundle create`
|
||||
auto-detects an author `bundle.yaml`, vendors declared content with
|
||||
both-sides path containment and symlink-escape protection, and
|
||||
emits an integrity-hashed manifest. `rig bundle install` routes
|
||||
each content kind to its canonical library at `$OPENRIG_HOME`
|
||||
with consumer-scan visibility. `rig bundle inspect` surfaces the
|
||||
cross-primitive content fields as first-class. CLI install timeout
|
||||
bumped (the 5-second cap was too short for tmux-session-bootstrapping
|
||||
installs).
|
||||
- **Workspace + Workflow GA (slice 01)** — operator-facing surface
|
||||
hardened. `rig workspace validate --max-files` strict-int regex;
|
||||
`rig workflow project --exit` enum guard; 3-part error returned
|
||||
before `client.post` for early-fail clarity; 14 new discriminator
|
||||
tests on the validator paths.
|
||||
- **`rig up <starter>` Paper-Cut Fix-Round (slice 22)** — unblocks
|
||||
the homepage quick-start on fresh 0.3.1→0.3.2 installs. Four bounded
|
||||
fixes:
|
||||
- HTTP 4xx surface for pre-launch failures
|
||||
(`cycle_error` / `preflight_failed` / `validation_failed` /
|
||||
`service_boot_failed`) — was bare 500.
|
||||
- No orphan rig record on pre-launch failure — instantiator
|
||||
re-ordered + rollback path + compose teardown order tightened.
|
||||
- Path-form `rig up <install-internal-spec>` defaults `cwd` to the
|
||||
caller directory (closes the divergence between path-form and
|
||||
bare-form invocation).
|
||||
- `walkYamlFiles` skips standard noise dirs (`.worktrees`,
|
||||
`node_modules`, `.git`, `dist`, `.turbo`, `.next`) + stale
|
||||
`workflow_specs` rows prune at startup with an install-root
|
||||
preservation guard (so shipped built-in specs survive the prune).
|
||||
- **Coordination + First-User Setup Fix-Round (slice 21)** — five
|
||||
bounded follow-rounds:
|
||||
- FR-1: Coordination-model boot instinct surfaced in
|
||||
`core/openrig-user/SKILL.md` (`rig send` vs `rig queue` vs
|
||||
`rig queue handoff` + §1b doctrine).
|
||||
- FR-2: First-user workspace + workflow setup teaching content
|
||||
(skill + docs).
|
||||
- FR-3: MISSION_NOTES durable-pattern hardening (template +
|
||||
codemap convention + scope-primitive auto-scaffold via
|
||||
`rig scope mission create`).
|
||||
- FR-4: Queue ergonomics — `rig queue create --body-file <path>`
|
||||
(use `-` for stdin) kills the backtick-shell-corruption class for
|
||||
multiline bodies; first-class `--mission` / `--slice` flags
|
||||
compose into tags; `lastNudgeResult` wording fix;
|
||||
closure-vs-acceptance documented.
|
||||
- FR-5: `rig workspace doctor` subcommand — 7-check workspace
|
||||
readiness diagnostic (workspace root, missions folder, file
|
||||
allowlist, daemon alignment, daemon reload, optional slice
|
||||
docs, MISSION_NOTES presence).
|
||||
- **Daemon Test Substrate-Path Scrub (slice 14)** — internal-team
|
||||
substrate path shape scrubbed from 4 daemon source sites + 10 test
|
||||
files. Hook-constants de-duplicated. Privacy class closed for
|
||||
tracked source/test surface.
|
||||
- **`ConceptCard` Data Source (slice 17)** — wired to shaped backlog
|
||||
candidates; storytelling-adapter completion deferred from 0.3.1
|
||||
is now complete.
|
||||
- **For-You Priority Windowing (slice 20)** — server-side attention
|
||||
query (Option 3): SQL predicate pushdown, exact-match attention
|
||||
regex, dismissal as string-keyed for queue-derived cards.
|
||||
- **Operator Context-Mode Bindings (slice 09)** — new `rig policy`
|
||||
command surface paired with a daemon-side typed-primitive store
|
||||
(migration `041_rig_policy`). Six modes
|
||||
(`sleep | desk | mobile | away | focus | debug`) bind to four
|
||||
scope tiers (`global_host | rig | workstream | qitem`) with a
|
||||
restate-and-confirm posture so scripts cannot silently apply a
|
||||
binding without explicit `--confirm`. Citation, effective-mode
|
||||
resolve, and `defaults` commands round out the surface.
|
||||
- **Scope Tree Primitive (slice 12)** — new `rig scope` command for
|
||||
operating the substrate scope tree (missions, slices, sub-slices)
|
||||
per `conventions/scope-and-versioning`. Mission-tier
|
||||
(`ls` / `show` / `create`) + slice-tier
|
||||
(`ls` / `show` / `create` / `ship` / `close` / `move`) commands.
|
||||
Auto-mints stable dot-IDs into mission frontmatter; mission
|
||||
templates include `release` / `placeholder` / `mission-notes`.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### Rigbundles First-Class
|
||||
|
||||
`rig bundle` now ships first-class cross-primitive bundling:
|
||||
|
||||
```
|
||||
rig bundle create <spec> -o <path>
|
||||
[--name <n>] [--bundle-version <v>] [--include-packages <refs...>]
|
||||
[--rig-root <r>] [--notes <text>]
|
||||
[--min-daemon-version <ver>] [--min-cli-version <ver>] [--json]
|
||||
rig bundle inspect <path> [--json]
|
||||
rig bundle install <path>
|
||||
[--plan] [--yes] [--target <root>]
|
||||
[--skip-version-check] [--force] [--json]
|
||||
rig bundle history [--rig <name>] [--since <iso>] [--json]
|
||||
```
|
||||
|
||||
Content kinds routed at install: `skills`, `plugins` (hybrid), `workflow_specs`,
|
||||
`context_packs`, `agent_images`. Each kind lands in its canonical
|
||||
library under `$OPENRIG_HOME`; consumer-scan visibility is preserved.
|
||||
|
||||
`bundle.yaml` author manifests are auto-detected when present.
|
||||
Vendoring uses both-sides path containment (source and destination)
|
||||
and symlink-escape protection. Integrity hashing is captured in the
|
||||
bundle manifest.
|
||||
|
||||
`--skip-version-check` and `--force` on `install` are operator-explicit
|
||||
overrides for the install-time daemon/CLI compatibility gate and the
|
||||
install-time conflict gate respectively (NOT recommended for routine
|
||||
use; the overrides exist for known-good operator scenarios).
|
||||
|
||||
`rig bundle history` reads `~/.openrig/bundle-audit.jsonl` — the
|
||||
audit ledger of every install — with optional filters on
|
||||
target rig name and earliest install time.
|
||||
|
||||
### Workspace Doctor (`rig workspace doctor`)
|
||||
|
||||
7-check workspace-readiness diagnostic against the daemon's resolved
|
||||
workspace:
|
||||
|
||||
```
|
||||
rig workspace doctor [--workspace <path>] [--strict] [--json]
|
||||
```
|
||||
|
||||
Checks: workspace root, missions folder, file allowlist, daemon
|
||||
alignment, daemon reload, optional slice docs, MISSION_NOTES presence.
|
||||
|
||||
Default exit-code: non-zero only on `fail`. `--strict` makes
|
||||
warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST`
|
||||
from the operator's shell env so the diagnostic reflects the
|
||||
operator's intended allowlist even when the daemon's env is unchanged.
|
||||
|
||||
### Queue Ergonomics
|
||||
|
||||
`rig queue create` accepts a body from a file or stdin and exposes
|
||||
first-class mission/slice scope:
|
||||
|
||||
```
|
||||
rig queue create --source <s> --destination <d> \
|
||||
(--body <text> | --body-file <path>) \
|
||||
[--mission <id>] [--slice <id>] \
|
||||
[--priority <p>] [--tier <t>] [--tags <csv>] \
|
||||
[--target-repo <name>] [--no-nudge] \
|
||||
[--expires-at <iso>] [--id <qitemId>] [--json]
|
||||
```
|
||||
|
||||
`--body-file -` reads from stdin. `--mission <id>` / `--slice <id>`
|
||||
translate to `mission:<id>` / `slice:<id>` tags (compose with
|
||||
`--tags`).
|
||||
|
||||
### Operator Context-Mode Bindings (`rig policy`)
|
||||
|
||||
```
|
||||
rig policy set <mode> [--scope <scope>] [--qualifier <id>] \
|
||||
[--<field> ...] [--evidence <citation>] \
|
||||
[--confirm] [--bearer <token>] [--json]
|
||||
rig policy show [--json]
|
||||
rig policy effective [--rig <id>] [--workstream <id>] [--qitem <id>] [--json]
|
||||
rig policy cite [--rig <id>] [--workstream <id>] [--qitem <id>]
|
||||
rig policy unset <scope> [qualifier] [--bearer <token>] [--json]
|
||||
rig policy defaults [--json]
|
||||
```
|
||||
|
||||
`set` is restate-only without `--confirm` (the CLI echoes the proposed
|
||||
binding and exits with `exit 2` so scripts cannot silently apply).
|
||||
|
||||
Six modes: `sleep | desk | mobile | away | focus | debug`. Four
|
||||
scopes: `global_host | rig | workstream | qitem`. `--qualifier` is
|
||||
required for `rig | workstream | qitem` scopes and explicitly
|
||||
rejected for `global_host`.
|
||||
|
||||
Operator-edit verbs (`set --confirm`, `unset`) require an operator
|
||||
bearer token (`--bearer` or `OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
|
||||
### Scope Tree Primitive (`rig scope`)
|
||||
|
||||
```
|
||||
rig scope slice ls [--mission <m>] [--state <s>] [--json]
|
||||
rig scope slice show <slice-path> [--mission <m>] [--json]
|
||||
rig scope slice create <mission> <slug> [--template <k>] [--title <t>] [--json]
|
||||
rig scope slice ship <slice-path> <release-mission> [--mission <m>] [--json]
|
||||
rig scope slice close <slice-path> --reason <r> [--note <t>] [--mission <m>] [--json]
|
||||
rig scope slice move <slice-path> <dest-mission> [--mission <m>] [--json]
|
||||
rig scope mission ls [--json]
|
||||
rig scope mission show <mission> [--json]
|
||||
rig scope mission create <name> [--template <k>] [--id <dot-id>] [--title <t>] [--json]
|
||||
```
|
||||
|
||||
Top-level option `--workspace <path>` overrides workspace root
|
||||
(otherwise inferred from `cwd` or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
### `rig up` Paper-Cut Fixes
|
||||
|
||||
```
|
||||
rig up <source> [--plan] [--yes] [--cwd <path>] [--target <root>] [--existing] [--json]
|
||||
```
|
||||
|
||||
- Pre-launch failures now return structured HTTP 4xx
|
||||
(`cycle_error` / `preflight_failed` / `validation_failed` /
|
||||
`service_boot_failed`) instead of bare 500.
|
||||
- Failed boots no longer leave orphan rig records on disk.
|
||||
- Path-form invocations default `cwd` to the caller directory.
|
||||
- `--existing` skips library-spec name resolution and treats
|
||||
`<source>` as an existing rig name directly.
|
||||
|
||||
### MISSION_NOTES Durable Pattern (slice 21 FR-3)
|
||||
|
||||
`rig scope mission create` auto-scaffolds `MISSION_NOTES.md` from
|
||||
`conventions/mission-notes/TEMPLATE.md` by default; `--no-mission-notes`
|
||||
opts out (rare). The convention codifies the durable-pattern model
|
||||
documented in workspace skills + canonical templates so missions
|
||||
ship with a uniform top-of-mind notes surface.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/[email protected]
|
||||
rig --version # expect 0.3.2
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
Migration `041_rig_policy` applies automatically on first post-upgrade
|
||||
daemon start. It is a non-destructive `CREATE TABLE` with no impact on
|
||||
existing data; binding rows are operator-authored at runtime through
|
||||
`rig policy set --confirm`.
|
||||
|
||||
For the new `rig policy` surface: opt-in default-none. With no
|
||||
bindings set, no behavior change. Use `rig policy defaults` to inspect
|
||||
the recommended per-mode 6×7 + default-scope mapping before authoring.
|
||||
|
||||
For the new `rig scope` surface: read-only against workspace by
|
||||
default (`ls`/`show`); authoring verbs (`create`/`ship`/`close`/`move`)
|
||||
operate on the substrate scope tree as advertised. Use `--workspace
|
||||
<path>` to override the resolved workspace root.
|
||||
|
||||
## Known Limitations / 0.3.3 Deferrals
|
||||
|
||||
- **Slice-05 Item-3 sub-scopes deferred to 0.3.3**: agent/port/managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-rig
|
||||
pathway acceptance (Item 4.4); the `--target-name` CLI flag for
|
||||
install-time rig-name overrides. Design-contingent on the CLI surface
|
||||
decision (A-breaking rename vs A-additive new flag).
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema deferred to 0.3.3**:
|
||||
the new `accepted` queue state is a schema model change; carried
|
||||
forward per the banked release-triage philosophy.
|
||||
- **Onboarding journey + battle-hardening deferred to 0.3.3**: slices
|
||||
04 (new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) physically moved to the `release-0.3.3` mission tree
|
||||
at wrap.
|
||||
- **Workspace symlink alignment**: the daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots is a follow-up
|
||||
surfaced by slice-21 FR-5 QA; non-blocking for 0.3.2.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental` only;
|
||||
not split-deferred to 0.3.3 (stays held).
|
||||
- **`rig down <name>` still returns HTTP 404** — name-based teardown
|
||||
still doesn't resolve through the daemon down route at v0.3.2; use
|
||||
`rig down <rigId> --delete` instead. Carries forward as known issue.
|
||||
- **`rig view list/show --json` flag inconsistency** — flag scaffolding
|
||||
on the wrapper-layer routing path remains; the daemon's
|
||||
`view show <name>` route returns JSON correctly when invoked directly
|
||||
(per v0.3.1 cutover evidence). Workaround: use human-readable output
|
||||
for now. Wrapper-layer fix is the documented target.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- `npm run test:repo` green.
|
||||
- Daemon test suite: 3996 PASS at slice-22 close.
|
||||
- TypeScript checks clean on every touched file at per-commit guard.
|
||||
- Per-commit guard CLEAR across every shipped slice;
|
||||
velocity-qa adversarial dogfood + orch heavy pre-merge verify on
|
||||
each merge.
|
||||
- Post-QA-fix deltas (when present) routed back to guard before
|
||||
merge per the banked qa-fix-lands-post-guard pattern.
|
||||
- Review-lead continuous-parallel-review ADVISORY-CLEAN on slice-22
|
||||
close with five POSITIVE observations.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.1 → v0.3.2 delta is 143 commits / 203 files /
|
||||
+29,028 insertions / −2,081 deletions. The list below groups
|
||||
load-bearing commits by theme:
|
||||
|
||||
### Rigbundles First-Class
|
||||
- `feat(daemon,cli)`: cross-primitive bundle routing — five content
|
||||
kinds plumbed end-to-end with both-sides path containment +
|
||||
symlink-escape protection + integrity hashing.
|
||||
- `feat(cli)`: `rig bundle` surface — `create` / `inspect` /
|
||||
`install` / `history` subcommands with operator-explicit override
|
||||
flags on install.
|
||||
|
||||
### Workspace + Workflow GA
|
||||
- `feat(daemon,cli)`: workspace `validate --max-files` strict-int
|
||||
regex; workflow `project --exit` enum guard; 3-part error before
|
||||
`client.post`.
|
||||
- `feat(cli)`: 14 new discriminator tests on the validator paths.
|
||||
|
||||
### `rig up <starter>` Paper-Cut Fix-Round
|
||||
- `fix(daemon)`: HTTP 4xx surface for pre-launch failures.
|
||||
- `fix(daemon)`: no orphan rig record on pre-launch failure.
|
||||
- `fix(cli)`: path-form `rig up` defaults `cwd` to caller dir.
|
||||
- `fix(daemon)`: `walkYamlFiles` skips standard noise dirs + stale
|
||||
`workflow_specs` row prune with install-root preservation.
|
||||
|
||||
### Coordination + First-User Setup Fix-Round (slice 21)
|
||||
- `feat(skill)`: coordination-model boot instinct surfaced in
|
||||
`core/openrig-user/SKILL.md`.
|
||||
- `feat(docs)`: first-user workspace + workflow setup teaching.
|
||||
- `feat(cli)`: MISSION_NOTES auto-scaffold via
|
||||
`rig scope mission create`.
|
||||
- `feat(cli)`: `rig queue create --body-file` + first-class
|
||||
`--mission` / `--slice` flags.
|
||||
- `feat(daemon,cli)`: `rig workspace doctor` 7-check diagnostic.
|
||||
|
||||
### Daemon Test Substrate-Path Scrub
|
||||
- `chore(privacy)`: substrate path shape scrubbed from daemon source
|
||||
+ test surface; hook-constants de-duplicated.
|
||||
|
||||
### ConceptCard Data Source
|
||||
- `feat(ui,daemon)`: wire `ConceptCard` to shaped backlog candidates.
|
||||
|
||||
### For-You Priority Windowing
|
||||
- `feat(daemon)`: server-side attention query with SQL predicate
|
||||
pushdown.
|
||||
- `feat(ui)`: dismissal as string-keyed for queue-derived cards.
|
||||
|
||||
### Operator Context-Mode Bindings
|
||||
- `feat(daemon)`: migration `041_rig_policy` + typed-primitive store.
|
||||
- `feat(cli)`: `rig policy` command surface
|
||||
(`set` / `show` / `effective` / `cite` / `unset` / `defaults`).
|
||||
|
||||
### Scope Tree Primitive
|
||||
- `feat(cli)`: `rig scope` command surface — mission + slice tiers.
|
||||
- `feat(cli)`: scope templates (release, placeholder, mission-notes,
|
||||
bug-fix, research, etc.) ship under `dist/lib/scope-templates/`.
|
||||
|
||||
### Privacy and Release Prep
|
||||
- `chore(privacy)`: pre-release scrub of residual founder-attribution
|
||||
comments + an internal-shape substrate-path JSDoc example.
|
||||
|
||||
See `CHANGELOG.md` for the full agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
@@ -0,0 +1,596 @@
|
||||
# OpenRig v0.3.3
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.3 is a usability + honesty release. Two new top-level CLI
|
||||
verbs land (`rig archive`/`rig unarchive` and `rig add`/`rig add-member`),
|
||||
several long-standing paper cuts are retired (large-payload startup
|
||||
delivery, dash-prefixed `send_text`, cmux 0.64.x compatibility, `rig down`
|
||||
by name, golden-path discovery), and three honesty surfaces are tightened
|
||||
to stop misclassifying ambiguous outcomes as success (`rig send --verify`
|
||||
delivery outcomes, Codex restore gates, `rig whoami` peers contract). The
|
||||
For-You feed gets a manage-by-exception pass (drill-to-terminal,
|
||||
decision-band sort, one-click approve). Slice 13's deterministic
|
||||
release-surface parser + skill-binding index ship as product code in
|
||||
service of the auto-feature-rollout system.
|
||||
|
||||
One new migration in 0.3.3: `042_rig_archive`
|
||||
(`ALTER TABLE rigs ADD COLUMN archived_at TEXT` + `idx_rigs_archived`).
|
||||
Append-only, non-destructive, no rigs-row restructure. Existing v0.3.2
|
||||
databases upgrade on first `rig daemon start`.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **Large Startup-File Transport (slice 16)** — `TmuxAdapter.sendText`
|
||||
payloads over 100KB now travel via a per-call tmux buffer
|
||||
(`load-buffer` + `paste-buffer -d -r`) instead of being embedded in
|
||||
a single `send-keys -l` argv. Closes the >128KB silent-launch-fail
|
||||
class on Linux (`MAX_ARG_STRLEN`) for large per-seat startup packs.
|
||||
`-r` preserves raw LF so multi-line packs do not early-submit on
|
||||
every newline; the single trailing C-m stays the caller's separate
|
||||
submit. Small payloads keep the exact inline `send-keys -l` command.
|
||||
Temp file + buffer cleanup on success AND paste-error paths; unique
|
||||
names per call so parallel `rig up` seats do not collide.
|
||||
- **`send_text` Inline Dash Sentinel (slice 17)** — inline
|
||||
`tmux send-keys -t <t> -l <text>` now carries a `--` end-of-options
|
||||
sentinel so any startup payload whose content begins with `-`
|
||||
(notably `---` YAML frontmatter — the norm for per-seat packs) is
|
||||
not parsed by tmux as flags. The slice-16 large/buffer path is
|
||||
already immune (content travels via a file, never argv) and is
|
||||
untouched. Inert for non-dash content.
|
||||
- **cmux 0.64.x Compatibility (slice 18)** — cmux 0.64.x renamed the
|
||||
surface identifier (`list-panels --json` rows carry `ref`, not
|
||||
`id`). The transport's `surface.list` normalizer remapped the array
|
||||
key but left row keys untouched, so `surface.sendText` silently
|
||||
failed under the new cmux line. Fixed via a `normalizeSurfaceRow`
|
||||
helper that resolves the handle from `ref ?? surface_ref ??
|
||||
surface_id ?? id`, normalizing every row regardless of array key.
|
||||
One resolution order serves both cmux 0.64.x and 0.63.x — no
|
||||
version-negotiation shim. Adaptation via the same pattern the code
|
||||
already uses for workspace rows and create/split handles.
|
||||
- **`rig archive` Affordance (slice 19)** — new top-level commands
|
||||
`rig archive <rigId>` + `rig unarchive <rigId>`. Archive is
|
||||
non-destructive: rig row is preserved, `archived_at` is set, and a
|
||||
`rig.archived` event fires. Default reads exclude archived (no
|
||||
surprise listings); `rig ps --include-archived`, `?includeArchived=
|
||||
true`, and `?archived=only` opt them back in. The UI gains a lazy
|
||||
collapsible "Archive" section under the localhost host node, fed
|
||||
by a separate archived-only query. `rig up <name>` against an
|
||||
archived-only name is refused honestly with an error pointing at
|
||||
`rig unarchive` — no silent restore, no silent fall-through. The
|
||||
running/degraded archive guard surfaces a 3-part fact / consequence
|
||||
/ action error and accepts `--force` to override. UI query
|
||||
invalidation consumes `rig.archived` / `rig.unarchived` events
|
||||
end-to-end so a CLI archive in one window reactively refreshes
|
||||
another mounted UI.
|
||||
- **`rig add` / `rig add-member` (slice 24)** — new top-level command
|
||||
for the `add_member` converge op. Adds a member to an existing pod
|
||||
in a live rig from a YAML/JSON member fragment file. Built on the
|
||||
new converge spine (`Op` union + differ + `convergeOp`), with
|
||||
imperative CLI sugar over the converge interface (not a bypass).
|
||||
Daemon route `POST /api/rigs/:rigId/pods/:podNamespace/members`
|
||||
maps `AddMemberOutcome` codes to HTTP (404 / 409 / 400 / 201).
|
||||
MCP tool `rig_add` ships in lockstep. Edge validation tightened in
|
||||
the same slice: non-array `edges` is rejected (not silently
|
||||
dropped); edge kinds are validated against the canonical
|
||||
`VALID_EDGE_KINDS` set exported from `rigspec-schema.ts` and
|
||||
reused (not duplicated), so `rig add` edges share the existing
|
||||
graph contract.
|
||||
- **`rig down` Accepts Name-or-Id (slice 22)** — the v0.2.0-era
|
||||
`rig down <name>` HTTP 404 caveat is gone. The resolver now fetches
|
||||
`/api/rigs/summary?includeArchived=true` and id-matches across ALL
|
||||
rigs (archived ids still reach teardown), then name-matches ACTIVE
|
||||
rigs only. Active+archived same-name pair resolves the active rig;
|
||||
archived-only name does not resolve by name. The packaged
|
||||
`openrig-user` skill (specs source + assets/plugins copy +
|
||||
`_canonical` mirror) and the `down.ts` resolver comment are
|
||||
corrected to reflect the real behavior. Historical CHANGELOG /
|
||||
v0.3.1 / v0.3.2 release notes are left intact — the bug genuinely
|
||||
existed at those releases.
|
||||
- **Honest Codex Restore Gate (slice 21 FR-2)** — `verifyResumeLaunch`
|
||||
no longer returns `ok:true` unless the native-resume-probe proves
|
||||
the seat actually resumed. Unresolved operator-action gates (an
|
||||
update that cannot auto-dismiss, trust, model-selection) and a
|
||||
bounded poll that never reaches `resumed` now return `ok:false`
|
||||
with `recovery: attention_required` and last-12-line pane evidence,
|
||||
routed into the existing
|
||||
`HarnessLaunchResult` -> startup-orchestrator
|
||||
`startupStatus: attention_required` -> `ps`/`status` projection —
|
||||
no new state machinery. The working auto-dismiss path is preserved
|
||||
(a skippable update gate still auto-dismisses and continues to
|
||||
success); only UNRESOLVED gates fail loudly. Resolves the 04.3
|
||||
clean-VM `kernel_state bootstrap_failed` failure-mode the herder
|
||||
surfaced.
|
||||
- **`rig send --verify` Honest Delivery Outcomes (slice 99.0.6.3)** —
|
||||
the prior `Verified: no` collapsed two materially different states
|
||||
into one: a message that LANDED (text pasted + Enter submitted,
|
||||
both succeeded) but whose post-send capture raced a TUI redraw,
|
||||
and a message that genuinely did not land. Careful agents paid a
|
||||
manual `rig capture` roundtrip on every send; careless agents read
|
||||
no as failure and double-sent. The three outcomes are now named in
|
||||
the response:
|
||||
- `delivered` — verified succeeded.
|
||||
- `rendered-unconfirmed` — text + Enter both succeeded but the
|
||||
capture could not re-confirm (redraw race, or capture threw).
|
||||
Landed-but-unconfirmable, NOT failure.
|
||||
- `failed` — the transport itself failed.
|
||||
The legacy `Verified: yes/no` line is preserved verbatim
|
||||
(parsers); a new `Delivery:` line carries the named outcome plus,
|
||||
for the middle case, a `rig capture <session>` confirmation
|
||||
pointer. `--json` carries the field through automatically.
|
||||
- **`rig whoami` Peers Contract (slice 99.0.6.1)** — the `peers[]`
|
||||
field was readable as a directionally-edged subset or as host
|
||||
inventory; it is neither. The grounded contract is now stated
|
||||
in-band: `peers[]` is this rig's roster EXCLUDING self (built from
|
||||
`rig.nodes` with only a self-skip — no edge/status/binding
|
||||
filter), `edges{}` is the directional graph, and
|
||||
`rig ps --nodes` is the inventory including self + live state.
|
||||
Additive clarification only — no behavior or shape change.
|
||||
`WhoamiResult` gains a required `peersNote` field carrying the
|
||||
three pointers; the CLI Peers header keeps the literal `Peers:`
|
||||
prefix verbatim (shipped tests grep on it) and adds the in-band
|
||||
clarifier.
|
||||
- **Workflow Instantiate By Name (slice 04.1)** — `rig workflow
|
||||
instantiate <built-in-name>` (e.g. `conveyor`, `basic-loop`)
|
||||
used to fail with `spec_file_missing` because instantiate fed the
|
||||
bare name straight to `WorkflowSpecCache.readThrough` (which
|
||||
treats its argument as a literal file path). Only a raw
|
||||
sourcePath worked — a hidden path that violated the
|
||||
fresh-operator goal: discovery advertised names you then could
|
||||
not use. `WorkflowRuntime.instantiate` now resolves the passed
|
||||
identifier against the seeded spec cache BY NAME first, falling
|
||||
back to literal-sourcePath only when no named spec matches. The
|
||||
cache returns the STORED path verbatim (no source-tree
|
||||
re-derivation), preserving the `dist/builtins` production layout.
|
||||
- **Guided Golden Path State Clarity (slice 04.2)** — the
|
||||
new-operator golden path said `rig workflow list` shows the
|
||||
seeded built-ins, but `rig workflow list` lists workflow
|
||||
INSTANCES; the built-in / spec discovery surface is
|
||||
`rig workflow specs` (built-ins tagged `(built-in)`). A fresh
|
||||
operator following the ordered path before `workflow instantiate
|
||||
<name>` was sent to the wrong command. Corrected in
|
||||
`docs/reference/getting-started.md` and in the CLI's
|
||||
`goldenPathNextSteps()` step 4.
|
||||
- **For-You Manage-By-Exception (slice 20)** — feed-interaction UX
|
||||
on existing signals only (the 0.4.0 attention-detection layer is
|
||||
untouched):
|
||||
- **Drill-to-terminal** — new `FeedCardTerminalDrill` opens the
|
||||
resolved source/author session's terminal PREVIEW directly
|
||||
from a card, by reusing `TerminalPreviewPopover` over
|
||||
`GET /api/sessions/:sessionName/preview`. Honest framing —
|
||||
"terminal preview" / "captured snapshot, not live", never
|
||||
claims live state; when no session resolves, the drill renders
|
||||
DISABLED with an honest title rather than an empty terminal.
|
||||
- **Decision-band sort** — a stable two-band partition lifts
|
||||
action-required + approval cards above progress / observation /
|
||||
shipped within the existing newest-first comparator. NOT a
|
||||
ranking engine (no scores, no card mutation). Applied at the
|
||||
single Feed merge-consumption seam.
|
||||
- **One-click approve (approve-only)** — `VerbActions` gains an
|
||||
additive `oneClickVerbs` prop. APPROVE submits directly on
|
||||
click; deny/route stay select+confirm. Defense in depth: the
|
||||
prop type narrows to `Extract<MissionControlVerb, "approve">`
|
||||
so misuse is a compile error, and a runtime
|
||||
`ONE_CLICK_SAFE_VERBS` allowlist rejects cast-forced
|
||||
`"deny"`/`"route"` at the call site. Reuses the same
|
||||
`performSubmit` mutation path (identical optimistic receipt
|
||||
and held-error behavior).
|
||||
- **CLI Release-Surface Parser (slice 13.1)** — deterministic TypeScript
|
||||
Compiler API extractor that walks the Commander registrations in
|
||||
`packages/cli/src/commands/*.ts` at two git refs and emits a
|
||||
structured release-surface-diff of added commands, added flags, and
|
||||
removed/renamed surface. Resolves both Commander idioms (chained
|
||||
inline subcommands and factory indirection). Emits the
|
||||
REGISTRATION name (`rig-policy.ts` surfaces as `policy`); option
|
||||
name-tokens are taken from `.option()` arg[0] only, so
|
||||
template-literal descriptions never drop an option. Reads batched
|
||||
via one `git cat-file --batch` per ref (fast, offline,
|
||||
deterministic). 3-part honest failure shape; never a silent empty
|
||||
diff. Not registered as a `rig` verb at 0.3.3 (a separate decision)
|
||||
— the module is product code with hermetic test fixtures
|
||||
(including a v0.3.1..v0.3.2 worked example).
|
||||
- **Skill <-> CLI-Surface Binding Index (slice 13.2)** — deterministic
|
||||
offline lookup that joins a release surface-diff to "which skills
|
||||
are affected by this release". Composes with the slice 13.1 parser;
|
||||
invocation-agnostic. Implements the ratified join grammar:
|
||||
component-wise prefix match in either direction (so `up` does not
|
||||
match `update`); conservative over-include bias for the
|
||||
no-false-negative floor. Drops `--version` from the binding index
|
||||
(the canonical grammar is command paths only, not global flags);
|
||||
the skill body still documents `rig --version` as prose. Ships
|
||||
with the v0.3.2-affected-skills regression fixture derived from
|
||||
the corpus.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### `rig archive` and `rig unarchive`
|
||||
|
||||
New top-level commands. Archive is non-destructive: the rig row is
|
||||
preserved, an `archived_at` timestamp is set, and a `rig.archived`
|
||||
event fires.
|
||||
|
||||
```
|
||||
rig archive <rigId> [--force] [--json]
|
||||
rig unarchive <rigId> [--json]
|
||||
```
|
||||
|
||||
`rig unarchive` is always safe (no `--force`, no guard). `rig
|
||||
archive` against a running or degraded rig surfaces a 3-part
|
||||
fact / consequence / action error and accepts `--force` to
|
||||
override.
|
||||
|
||||
Default reads exclude archived rigs:
|
||||
|
||||
```
|
||||
rig ps [--include-archived]
|
||||
GET /api/rigs/summary?includeArchived=true
|
||||
GET /api/rigs/summary?archived=only
|
||||
GET /api/ps?includeArchived=true
|
||||
```
|
||||
|
||||
`rig ps` marks archived rows with `*` and renders a legend. The
|
||||
`--include-archived` flag propagates through cross-host argv.
|
||||
|
||||
`rig up <name>` against an archived-only name is refused honestly
|
||||
with an error pointing at `rig unarchive` (no silent restore, no
|
||||
silent fall-through); `--json` form emits `rig_archived`.
|
||||
|
||||
UI surfaces a lazy collapsible "Archive" section under the
|
||||
localhost host node. The section is fed by a separate archived-only
|
||||
query (`useArchivedRigs`, `?archived=only`), not by client-side
|
||||
filtering of the default list. `rig.archived` / `rig.unarchived`
|
||||
events drive global query invalidation
|
||||
(`["rigs","summary"]` + `["rigs","summary","archived"]` + `["ps"]`)
|
||||
so a CLI archive reactively refreshes other mounted UIs.
|
||||
|
||||
### `rig add` and `rig add-member`
|
||||
|
||||
New top-level command for the `add_member` converge op. Adds a
|
||||
member to an existing pod in a live rig from a YAML/JSON member
|
||||
fragment file:
|
||||
|
||||
```
|
||||
rig add <rigId> <podNamespace> <member-fragment-path> \
|
||||
[--json] [--rig-root <root>]
|
||||
```
|
||||
|
||||
The member fragment tolerates a bare member or a `{ member }`
|
||||
wrapper. Field names follow the spec snake_case shape (`id`,
|
||||
`runtime`, `agent_ref`, `profile`, `cwd`, ...). Optional
|
||||
pod-local `edges` (array of `{from, to, kind}`) is preserved
|
||||
through to the converge op:
|
||||
|
||||
- Present-but-non-array `edges` is REJECTED (not silently dropped):
|
||||
CLI prints a clear error and exits 1 before POST; route returns
|
||||
400 `validation_failed`; domain also returns `validation_failed`
|
||||
as defense in depth.
|
||||
- Edge `kind` is validated against the canonical `VALID_EDGE_KINDS`
|
||||
set exported from `rigspec-schema.ts` and reused (not duplicated).
|
||||
`kind: nonsense` returns 400 `validation_failed` naming the valid
|
||||
set; no edge is persisted.
|
||||
|
||||
Daemon route: `POST /api/rigs/:rigId/pods/:podNamespace/members`.
|
||||
Outcome -> HTTP mapping: `rig_not_found` / `pod_not_found` -> 404;
|
||||
`member_conflict` -> 409; `validation_failed` /
|
||||
`preflight_failed` -> 400; success -> 201 (with per-node launch
|
||||
status in the body). MCP tool `rig_add` ships in lockstep.
|
||||
|
||||
### `rig down` Accepts Name-or-Id
|
||||
|
||||
`rig down <name>` now works. The v0.2.0-era `rig down <name>` HTTP
|
||||
404 caveat is gone.
|
||||
|
||||
```
|
||||
rig down <rigId-or-name> [--delete] [--json]
|
||||
```
|
||||
|
||||
Resolver semantics:
|
||||
|
||||
- `/api/rigs/summary?includeArchived=true` is fetched, then:
|
||||
- id-match across ALL rigs (archived ids still reach teardown);
|
||||
- name-match ACTIVE rigs only (`archivedAt == null`).
|
||||
|
||||
Same-name active+archived pair resolves the ACTIVE rig (not
|
||||
ambiguous). Archived-only name does not resolve by name (use the
|
||||
id, or `rig unarchive` first).
|
||||
|
||||
### Send Text Inline Dash Sentinel (slice 17)
|
||||
|
||||
The internal tmux `send_text` path now carries an explicit `--`
|
||||
end-of-options sentinel before user-supplied content, so tmux no
|
||||
longer parses content beginning with `-` or `--` as its own flag
|
||||
arguments. Companion to the slice 16 large-payload transport fix:
|
||||
|
||||
```
|
||||
rig send <session> "content with -- dashes or - hyphens"
|
||||
```
|
||||
|
||||
delivers literally without the agent having to escape or work
|
||||
around dash-prefixed leading characters (notably `---` YAML
|
||||
frontmatter in per-seat startup payloads). This is a daemon-side
|
||||
adapter fix in `packages/daemon/src/adapters/tmux.ts`; the
|
||||
`rig send` CLI surface itself is unchanged.
|
||||
|
||||
### `rig send --verify` Delivery Outcomes
|
||||
|
||||
`--verify` responses now name the outcome explicitly:
|
||||
|
||||
- `delivered` — verified succeeded.
|
||||
- `rendered-unconfirmed` — text + Enter both succeeded but the
|
||||
post-send capture could not re-confirm (TUI redraw race, or
|
||||
capture threw). The message LANDED; the render is not
|
||||
re-confirmable.
|
||||
- `failed` — the transport itself failed.
|
||||
|
||||
The legacy `Verified: yes/no` line is preserved verbatim
|
||||
(parsers); a new `Delivery:` line carries the named outcome plus,
|
||||
for `rendered-unconfirmed`, a `rig capture <session>`
|
||||
confirmation pointer. `--json` carries the `outcome` field
|
||||
through.
|
||||
|
||||
`exit` codes: `delivered` and `rendered-unconfirmed` exit 0; the
|
||||
middle is not dressed as success or failure. `failed` exits
|
||||
non-zero with HTTP mapping unchanged. Mid-work / wait-for-idle
|
||||
REFUSALS deliberately carry no outcome (nothing was sent; a
|
||||
refusal is not a failed delivery).
|
||||
|
||||
### `rig whoami --json` Peers Contract
|
||||
|
||||
`peers[]` is this rig's roster EXCLUDING self. It is NOT a
|
||||
directionally-edged subset, and it is NOT host inventory.
|
||||
|
||||
- For the directional graph, read `edges{}`.
|
||||
- For the inventory including self + live state, use
|
||||
`rig ps --nodes`.
|
||||
|
||||
`WhoamiResult.peersNote` carries the contract in-band. The CLI
|
||||
Peers header keeps the literal `Peers:` prefix verbatim (shipped
|
||||
parsers grep on it) and adds the clarifier pointing at `edges`
|
||||
and `rig ps --nodes`. `peers[]` name and shape are untouched;
|
||||
this is an additive clarification only.
|
||||
|
||||
### Workflow Instantiate By Name
|
||||
|
||||
`rig workflow instantiate <name>` now resolves built-in names
|
||||
end-to-end:
|
||||
|
||||
```
|
||||
rig workflow instantiate <name-or-source-path> [--cwd <path>] [--json]
|
||||
```
|
||||
|
||||
Built-in names (`conveyor`, `basic-loop`, ...) are resolved
|
||||
against the seeded spec cache BY NAME first; literal source paths
|
||||
remain a fallback for unmatched names (honest 404 if neither
|
||||
matches). Closes the discovery / use divergence where
|
||||
`rig workflow specs` advertised names that `rig workflow
|
||||
instantiate` could not consume.
|
||||
|
||||
### Guided Golden Path Discovery Surface
|
||||
|
||||
The new-operator golden path now points at the correct discovery
|
||||
verb:
|
||||
|
||||
- `rig workflow specs` lists built-in / seeded workflow specs
|
||||
(`(built-in)` tagged). USE THIS to discover names before
|
||||
`rig workflow instantiate <name>`.
|
||||
- `rig workflow list` lists workflow INSTANCES (created from
|
||||
specs). Empty for a fresh operator.
|
||||
|
||||
Corrected in `docs/reference/getting-started.md` and in the
|
||||
CLI's `setup` golden-path text.
|
||||
|
||||
### For-You Manage-By-Exception
|
||||
|
||||
Feed-interaction UX on existing signals (the 0.4.0
|
||||
attention-detection layer is untouched):
|
||||
|
||||
- **Drill to terminal preview** — each card's footer carries a
|
||||
drill that opens the resolved source/author session's terminal
|
||||
PREVIEW via `GET /api/sessions/:sessionName/preview`. Labelled
|
||||
"terminal preview" / "captured snapshot, not live" — never
|
||||
claims live state. Disabled with an honest title when no
|
||||
session resolves.
|
||||
- **Decision-band sort** — action-required + approval cards lift
|
||||
above progress / observation / shipped within the existing
|
||||
newest-first comparator. Stable two-band partition; not a
|
||||
ranking engine.
|
||||
- **One-click approve** — APPROVE submits directly on click; deny
|
||||
and route stay select+confirm. Compile-time + runtime guards
|
||||
prevent the one-click path from being applied to non-approve
|
||||
verbs (`ONE_CLICK_SAFE_VERBS = {"approve"}`).
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/[email protected]
|
||||
rig --version # expect 0.3.3
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
Migration `042_rig_archive` applies automatically on first
|
||||
post-upgrade daemon start. It is `ALTER TABLE rigs ADD COLUMN
|
||||
archived_at TEXT` plus `idx_rigs_archived` — append-only,
|
||||
non-destructive, no rigs-row restructure / rebuild / drop.
|
||||
Existing rows are unaffected.
|
||||
|
||||
For the new `rig archive` surface: default reads still exclude
|
||||
archived rigs at the daemon layer. With no rigs archived, no
|
||||
behavior change. `rig ps --include-archived`, the
|
||||
`?includeArchived=true` / `?archived=only` query params, and the
|
||||
UI's lazy Archive section opt archived rigs back in.
|
||||
|
||||
For the new `rig add` surface: the member fragment file is a YAML
|
||||
or JSON document using the rig spec's snake_case field names
|
||||
(same authoring format as a `RigSpec` pod member). The MCP
|
||||
`rig_add` tool accepts the same fragment shape.
|
||||
|
||||
For `rig down <name>`: previously-documented agent workarounds
|
||||
that resolved the rig id by hand before calling `rig down` no
|
||||
longer need to do that step. The id-based form continues to work
|
||||
unchanged.
|
||||
|
||||
## Known Limitations / 0.3.3 Deferrals
|
||||
|
||||
- **Slice-21 FR-1 (native Codex session-id hook)**: scope-tipped
|
||||
to 0.4.0 after the build-time forensic. FR-2 (honest restore
|
||||
gate) ships alone in 0.3.3.
|
||||
- **Slice-13 component 3 (auto-rollout dispatcher)**: the slice
|
||||
13 vertical's dispatcher / rollout-invocation layer is the
|
||||
skill-layer landing for this release, not product code. The
|
||||
product-code halves (13.1 parser + 13.2 binding index) ship in
|
||||
0.3.3; invocation is intentionally out of the shipped runtime
|
||||
bundle.
|
||||
- **Slice-05 sub-scopes from 0.3.2**: agent / port / managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-rig
|
||||
pathway acceptance (Item 4.4); the `--target-name` CLI flag for
|
||||
install-time rig-name overrides. Carried forward; CLI-surface
|
||||
decision (A-breaking rename vs A-additive flag) still pending.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema**: deferred from
|
||||
0.3.2; new `accepted` queue state is a schema model change.
|
||||
Carried forward.
|
||||
- **Onboarding journey + battle-hardening**: slices 04
|
||||
(new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) physically moved to the `release-0.3.3` mission
|
||||
tree at 0.3.2 wrap; the 0.3.3 cut focused on usability + honesty
|
||||
surfaces ahead of the onboarding journey. Carried forward to a
|
||||
later release.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots remains a
|
||||
follow-up; non-blocking for 0.3.3.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental`
|
||||
only; not split-deferred (stays held).
|
||||
- **`rig view list/show --json` flag inconsistency** — wrapper-layer
|
||||
routing path remains; the daemon's `view show <name>` route
|
||||
returns JSON correctly when invoked directly. Workaround:
|
||||
human-readable output for now. Wrapper-layer fix is the
|
||||
documented target.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- TypeScript checks clean on every touched file at per-commit
|
||||
guard across every shipped slice.
|
||||
- Per-slice focused-suite tests green:
|
||||
- Slice 16: tmux-adapter buffer path (small + large) +
|
||||
Claude/Codex adapters large-payload delivery.
|
||||
- Slice 17: tmux-adapter dash-prefix discriminator
|
||||
(`-l -- '<text>'`); 77/77 in the touched suite.
|
||||
- Slice 18: cmux 130/130 unit tests; VM e2e 0.64.x launch +
|
||||
0.63.x non-regression.
|
||||
- Slice 19: archive route / repo / projection + UI
|
||||
`topology-archive-section` + `global-events` (archived /
|
||||
unarchived invalidation); AC-4 host-scoped no-cross-host-leak;
|
||||
AC-8 archive-vs-down-delete non-destruction discriminator.
|
||||
- Slice 22: down 22/22 (id-exact-first; archived-id reaches
|
||||
teardown; active+archived same-name -> active; archived-only
|
||||
name halts not_found; AC-3 active-active discriminator).
|
||||
- Slice 21 FR-2: 45/45 in the verifyResumeLaunch suite
|
||||
including the unresolved-gate discriminator, trust-gate,
|
||||
model-selection-gate, and poll-exhaustion coverage.
|
||||
- Slice 24: add-member-route 8/8; add (CLI) 8/8; edge
|
||||
validation +6 (domain + route + CLI); cli index/expand/MCP +
|
||||
daemon rig-expansion green (41 cli + 49 daemon).
|
||||
- Slice 99.0.6.3: session-transport + transport-routes 76/76;
|
||||
cli send 12/12.
|
||||
- Slice 99.0.6.1: whoami-service 15/15; cli whoami 20/20.
|
||||
- Slice 04.1: workflow-spec-cache + workflow-routes 44/44.
|
||||
- Slice 04.2: setup AC-1 asserts the path references
|
||||
`rig workflow specs` and does not use `rig workflow list`.
|
||||
- Slice 20: feed-classifier 17, attention-feed 17,
|
||||
verb-actions-one-click 6, verb-actions-optimistic-error 7,
|
||||
feed-card-terminal-drill 4, feed-decision-band-sort 5,
|
||||
feed-card-dismiss 12, activity-feed 25, session-preview-pane
|
||||
3 — 102/102 touched + adjacent.
|
||||
- Slice 13.1: release-surface 10/10; sample byte-identical
|
||||
across two runs (determinism).
|
||||
- Slice 13.2: affected-skills 12/12.
|
||||
- Per-commit guard CLEAR across every shipped slice;
|
||||
velocity-qa adversarial dogfood + dual rev1 CLEAR on each
|
||||
merge.
|
||||
- The cumulative-workspace test runs surface the documented
|
||||
pre-existing baseline (cmux / preflight / archived_at-harness /
|
||||
@tanstack dep cascade); ZERO regressions versus the v0.3.2 base
|
||||
on every shipped slice.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.2 -> v0.3.3 delta is 56 commits / 93 files /
|
||||
+6,669 insertions / -238 deletions. The list below groups
|
||||
load-bearing commits by theme:
|
||||
|
||||
### Startup Transport + Cmux Compatibility
|
||||
- `fix(daemon)`: deliver large startup `send_text` via tmux
|
||||
buffer, not argv (slice 16).
|
||||
- `fix(daemon)`: add `--` end-of-options sentinel to inline
|
||||
`send_text` (slice 17).
|
||||
- `fix(daemon)`: cmux 0.64.x surface-listing `ref` / `id`
|
||||
normalization (slice 18).
|
||||
|
||||
### `rig archive` Affordance
|
||||
- `feat(daemon)`: archive vertical — migration `042_rig_archive`,
|
||||
repository filter, projection filter, routes
|
||||
`/api/rigs/:id/archive` + `/unarchive`,
|
||||
`?includeArchived=true` / `?archived=only` query params.
|
||||
- `feat(cli,ui)`: `rig archive` / `rig unarchive` commands,
|
||||
`rig ps --include-archived`, `rig up` archived-name refusal,
|
||||
UI archived-only hook + lazy "Archive" section under localhost
|
||||
host node.
|
||||
- `fix(ui)`: wire `rig.archived` / `rig.unarchived` into global
|
||||
query invalidation.
|
||||
|
||||
### `rig add` / `rig add-member`
|
||||
- `feat(daemon)`: `add_member` converge op on
|
||||
`PodRigInstantiator`; topology-converge spine
|
||||
(`Op` union + differ + `convergeOp`) scaffold.
|
||||
- `feat(cli,daemon)`: `rig add` HTTP route + CLI command for the
|
||||
`add_member` converge op.
|
||||
- `feat(cli)`: `rig_add` MCP tool.
|
||||
- `fix(daemon,cli)`: honest edge validation — reject non-array
|
||||
edges + non-canonical kinds (shared `VALID_EDGE_KINDS` with
|
||||
rigspec schema).
|
||||
|
||||
### `rig down` Name-or-Id
|
||||
- `fix(cli,docs)`: `rig down` resolves by name-or-id;
|
||||
`?includeArchived=true` summary fetch; archived-id reaches
|
||||
teardown; name-match active only; packaged-skill caveat
|
||||
retired across specs source + assets/plugins copy +
|
||||
`_canonical` mirror.
|
||||
|
||||
### Honest Outcomes
|
||||
- `feat(daemon)`: honest Codex restore gate — unresolved gates
|
||||
are `attention_required`, not launch success (slice 21 FR-2).
|
||||
- `feat(daemon,cli)`: `rig send --verify` honest delivery
|
||||
outcomes — `delivered` / `rendered-unconfirmed` / `failed`.
|
||||
- `feat(daemon,cli,docs)`: `rig whoami` peers contract stated
|
||||
in-band — `peersNote` field + CLI clarifier; `peers[]` shape
|
||||
unchanged.
|
||||
|
||||
### Workflow Discovery + Instantiate
|
||||
- `fix(daemon)`: resolve workflow instantiate by discovered name
|
||||
(slice 04.1 AC-3).
|
||||
- `fix(cli,docs)`: golden path points to `rig workflow specs`
|
||||
for built-in discovery (slice 04.2 rev1-r2).
|
||||
|
||||
### For-You Manage-By-Exception
|
||||
- `feat(ui)`: For-You drill-to-terminal + decision-band sort +
|
||||
one-click approve (slice 20).
|
||||
- `fix(ui)`: enforce approve-only one-click in code — narrowed
|
||||
type + runtime `ONE_CLICK_SAFE_VERBS` allowlist.
|
||||
|
||||
### Auto-Feature-Rollout (product-code halves)
|
||||
- `feat(cli)`: CLI release-surface parser POC (slice 13.1) —
|
||||
deterministic Commander-registration extractor; sample diff
|
||||
fixture; v0.3.1..v0.3.2 worked example.
|
||||
- `feat(cli)`: skill <-> CLI-surface binding-index lookup
|
||||
(slice 13.2 component 2) — composes with 13.1; ratified join
|
||||
grammar; v0.3.2-affected-skills regression fixture.
|
||||
- `fix(cli)`: drop `--version` flag token from the binding
|
||||
index (slice 13.2 guard B1).
|
||||
|
||||
See `CHANGELOG.md` for the agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
@@ -0,0 +1,593 @@
|
||||
# OpenRig v0.3.4
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.4 is a recovery + resilience release. Two new top-level CLI
|
||||
verbs land (`rig start` and `rig reconcile-session`), `rig up` flips
|
||||
from fresh-by-default to resume-original-by-default with `--fresh
|
||||
<seats...>` as the explicit per-seat opt-in, the restore status surface
|
||||
collapses every honest outcome into a named five-term vocabulary
|
||||
(`resumed` / `fresh-primed` / `awaiting-decision` / `attention_required`
|
||||
/ `failed`) across `rig up` / `rig restore` / `rig ps`, and a periodic
|
||||
snapshot scheduler ships as the crash-insurance floor under
|
||||
event-driven and teardown snapshots. A handful of long-standing
|
||||
recovery paper cuts are retired in the same cut: pod-aware narrow
|
||||
launch leaves dead-end-land for a node-granular managed partial
|
||||
restore through `rig launch`, a stuck `attention_required` seat now
|
||||
clears through an evidence-gated `rig seat clear-attention` rather
|
||||
than hand-edited SQLite, the Codex profile-v2 preflight stops
|
||||
misclassifying invalid TOML as a legacy-profile reason, the cmux
|
||||
launch-readiness gate surfaces honest partial workspace state, and a
|
||||
slow Codex resume no longer gets mis-categorized.
|
||||
|
||||
No new schema-bumping migrations in 0.3.4. Existing v0.3.3 databases
|
||||
upgrade by running `rig daemon start` on the new daemon.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **`rig start` (slice 01)** — new top-level recovery entry point.
|
||||
One command that sequences daemon-start, kernel readiness wait,
|
||||
restore-usable candidate listing, picker or headless flag
|
||||
selection, and per-rig restore + reconcile. Re-codes nothing
|
||||
underneath: it sequences existing primitives. Flags `--last`,
|
||||
`--all`, `--rigs <names...>`, `--json`. Candidate listing and
|
||||
apply are both id-grounded against `/api/rigs/:id/up` (the
|
||||
same-name-rigs class never reaches the name-based `/api/up`
|
||||
route).
|
||||
- **`rig up` Resume-By-Default (slice 02)** — `rig up <existing-rig>`
|
||||
now resumes original sessions by default. `--fresh <seats...>`
|
||||
becomes the explicit per-seat opt-in for operation B (deliberate
|
||||
fresh-prime). Previously the default was fresh-prime; agents had
|
||||
to know to ask for resume out-of-band. The seat-level outcome
|
||||
rides the five-term vocabulary below; `awaiting-decision` is the
|
||||
honest zero-session state when the daemon cannot resume and the
|
||||
operator has not opted into fresh.
|
||||
- **`rig reconcile-session` (slice 03)** — new top-level command.
|
||||
Adopts a LIVE, hand-resumed canonical session back into its
|
||||
persisted node without launching, relaunching, killing, replaying
|
||||
startup, pressing resume menus, compacting, or typing into the
|
||||
pane. The repair for "I manually resumed the seat; the daemon
|
||||
still shows it down." Same node id, no re-key; reports projection
|
||||
drift honestly; conversation continuity is never claimed.
|
||||
- **`rig up --plan` Read-Only Restore Preview (slice 04)** —
|
||||
`rig up <existing-rig> --plan` returns a read-only restore
|
||||
preview keyed by the five-term vocabulary and an honest async
|
||||
timeout. No mutation; no silent claims of resumability when the
|
||||
daemon cannot prove it.
|
||||
- **Five-Term Restore Status Vocabulary (slice 06)** — the prior
|
||||
2-3 term collapsed model is replaced across `rig up`,
|
||||
`rig restore`, and `rig ps`:
|
||||
- `resumed` — original session continuity proven.
|
||||
- `fresh-primed` — deliberate fresh-prime (operation B); a brand
|
||||
new session was started instead of resumed.
|
||||
- `awaiting-decision` — daemon could not resume and operator has
|
||||
not opted into fresh; ZERO session started. The honest
|
||||
zero-session state, not a silent failure.
|
||||
- `attention_required` — seat is live or parked but needs
|
||||
operator action (auth gate, model-selection menu, trust
|
||||
prompt, stuck recovery). NEVER `failed`.
|
||||
- `failed` — the launch transport itself failed.
|
||||
- **Codex Profile-V2 Preflight (slice 07)** — profile-load check
|
||||
on profile-bearing launch and restore surfaces. Legacy-profile
|
||||
detection narrowed to legacy-specific patterns only (`contains
|
||||
legacy`, `[profiles.<name>]`, `cannot be used while.*legacy`,
|
||||
`legacy profile selector`); the prior generic match against
|
||||
`failed to load configuration` was also catching invalid-TOML
|
||||
stderr and giving the wrong migration hint. Invalid TOML now
|
||||
surfaces the actual parse reason (up to 3 stderr lines) with a
|
||||
TOML-validity hint.
|
||||
- **cmux Launch Readiness (slice 08)** — launch path no longer
|
||||
silently produces a partial cmux workspace. When cmux comes up
|
||||
with a subset of expected windows / panes, the seat surfaces an
|
||||
honest partial-workspace state with a one-click open-missing
|
||||
affordance instead of being reported as launched-clean.
|
||||
- **Periodic Snapshots (slice 09)** — `snapshots.periodic` config
|
||||
family plus a daemon-side `PeriodicSnapshotScheduler`. Every
|
||||
enabled rig captures an `auto-periodic` snapshot on interval and
|
||||
the repository prunes by retention-keep. Provides a true
|
||||
crash-insurance floor — `auto-periodic` and `auto-pre-down`
|
||||
compete on freshness in the same restore selector (the newer
|
||||
wins), so a daemon crash between teardown snapshots no longer
|
||||
costs an arbitrary amount of lifecycle state. Last-snapshot /
|
||||
floor surfaced in `rig ps` / status.
|
||||
- **`rig seat clear-attention` (slice 10)** — new subcommand on
|
||||
`rig seat`. Evidence-gated, operator-attested, audited reconcile
|
||||
of a stuck `attention_required` seat back to `ready`. Replaces
|
||||
the prior hand-edit-SQLite / fake-clear pattern that agents
|
||||
reached for when a seat got stuck. The audit row records who
|
||||
cleared, from-state, the evidence path, and any operator
|
||||
attestation; `--reason <text>` is the operator-attestation
|
||||
override for the evidence gate.
|
||||
- **Node-Granular Managed Partial Restore (slice 11)** — `rig
|
||||
launch <rigId> <nodeRef>` and `rig launch <rigId> --seats <a,b>`
|
||||
relaunch a seat or subset by logical id through the
|
||||
orchestration layer. Retires the v0.3.x-era
|
||||
`pod_aware_launch_unsupported` dead-end and the
|
||||
"use rig up instead" workaround that lost per-seat control.
|
||||
Held seats carry a `held-reason`; failed targets surface as
|
||||
`failed (liveness unknown)`; already-running seats are reported,
|
||||
not relaunched.
|
||||
- **Slow Codex Resume Classification Fix (slice 13)** — internal
|
||||
classification tweak for slow-resume Codex sessions: a seat that
|
||||
was genuinely resuming but slowly was previously mis-categorized
|
||||
by `verifyResumeLaunch`. Now correctly classified through the
|
||||
same five-term vocabulary.
|
||||
- **Pod-Aware Claude Resume-Selection Menu (slice 05 rev1)** —
|
||||
follow-on to 0.3.3 slice 21 FR-2: `ClaudeCodeAdapter.verifyResumeLaunch`
|
||||
now treats a Claude resume-selection menu the same way it treats
|
||||
the Codex unresolved-gate case — returns immediately with
|
||||
`recovery: attention_required` and last-12-line pane evidence,
|
||||
never sends a numeric selection keystroke, and never lets the
|
||||
loop time out to a generic failure.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### `rig start` — Recovery Entry Point
|
||||
|
||||
New top-level command. Starts the daemon, waits for the kernel, lists
|
||||
restore-usable rigs, and applies restore + reconcile through the
|
||||
existing primitives:
|
||||
|
||||
```
|
||||
rig start Interactive: daemon + kernel + pick-and-restore
|
||||
rig start --last Headless: restore all rigs that were last running
|
||||
rig start --all Headless: restore all rigs with restore-usable snapshots
|
||||
rig start --rigs <name> [<name>...] Headless: restore only the named rigs
|
||||
rig start --json JSON output for agents
|
||||
```
|
||||
|
||||
Listing and apply are both id-grounded against
|
||||
`POST /api/rigs/:id/up`. Same-name rigs surface as separate
|
||||
candidates; selection carries the candidate's `rigId` end-to-end so
|
||||
the name-based `/api/up` route is never consulted (no
|
||||
`ambiguous_name` 409 in the recovery path).
|
||||
|
||||
`rig start` is sequencing-only; it does not invent recovery
|
||||
semantics. The per-rig restore call is the same `/api/rigs/:id/up`
|
||||
that `rig up <existing>` uses, and seat-level outcomes carry the
|
||||
same five-term vocabulary.
|
||||
|
||||
### `rig up` — Resume Original By Default
|
||||
|
||||
`rig up <existing-rig>` resumes original sessions by default.
|
||||
`--fresh <seats...>` is the per-seat opt-in for deliberate
|
||||
fresh-prime (operation B):
|
||||
|
||||
```
|
||||
rig up <existing-rig> Resume original sessions (default)
|
||||
rig up --existing <name> --fresh dev-impl Deliberately fresh-prime dev-impl only
|
||||
rig up <existing-rig> --plan Read-only restore preview (no mutation)
|
||||
```
|
||||
|
||||
- Seats whose original session resumes report `resumed`.
|
||||
- Seats opted into `--fresh` report `fresh-primed`.
|
||||
- Seats the daemon cannot resume and the operator has not opted
|
||||
into fresh report `awaiting-decision` — ZERO session started.
|
||||
Headless callers see the explicit hint `rig up --existing
|
||||
<source> --fresh <logical-id>`; TTY callers are offered a
|
||||
per-seat `[y/N]` prompt before any fresh-prime is started.
|
||||
- Seats live or parked but needing operator action (auth gate,
|
||||
model selection, trust prompt, stuck recovery) report
|
||||
`attention_required` — never silently re-classified as `failed`.
|
||||
- Only transport-level failure reports `failed`.
|
||||
|
||||
The prior fresh-by-default behavior is gone. If a workflow relied on
|
||||
implicit fresh, name `--fresh <seats...>` explicitly.
|
||||
|
||||
### `rig up --plan` — Read-Only Restore Preview
|
||||
|
||||
```
|
||||
rig up <existing-rig> --plan [--json]
|
||||
```
|
||||
|
||||
Returns the restore plan without mutating: per-node intended action
|
||||
(resume / fresh-prime / awaiting-decision), the snapshot the plan
|
||||
would consume, and whether the current state would be captured before
|
||||
mutation. The plan path carries an honest async timeout — when the
|
||||
preview cannot complete within bound, the response says so rather
|
||||
than claiming a clean plan.
|
||||
|
||||
### `rig reconcile-session` — Adopt a Hand-Resumed Session
|
||||
|
||||
New top-level command. Use after manually resuming a seat (e.g.
|
||||
`claude --resume <id>` / `codex resume <id>`) inside its canonical
|
||||
tmux session: the daemon still shows the seat down. Reconcile binds
|
||||
the live process to its OWN persisted node and updates the
|
||||
projection so `rig ps` / topology / `rig send` / `rig capture` /
|
||||
queue routing work again. It NEVER launches, relaunches, kills,
|
||||
replays startup, presses resume menus, compacts, or types into the
|
||||
pane.
|
||||
|
||||
```
|
||||
rig reconcile-session <session> Auto-resolve target node
|
||||
rig reconcile-session <session> --rig <rigId> --node <logicalId> Disambiguate
|
||||
rig reconcile-session <session> --no-launch Explicit (default mode)
|
||||
rig reconcile-session <session> --json JSON output for agents
|
||||
```
|
||||
|
||||
`--rig` and `--node` must be provided together (disambiguation).
|
||||
Same node id, no re-key. Anything the reconcile could not prove is
|
||||
reported as projection drift (a list of unproven metadata fields);
|
||||
conversation continuity is reported as a status string, never
|
||||
claimed as proven.
|
||||
|
||||
### Five-Term Restore Status Vocabulary
|
||||
|
||||
The seat-level status field on `rig up`, `rig restore`, and `rig ps`
|
||||
now carries one of five values:
|
||||
|
||||
| Status | Meaning |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `resumed` | Original session continuity proven by the native-resume probe. |
|
||||
| `fresh-primed` | Operator opted into a deliberate fresh-prime (operation B); a brand new session started. |
|
||||
| `awaiting-decision` | Daemon could not resume and operator has not opted into fresh. ZERO session started. |
|
||||
| `attention_required` | Seat is live or parked but needs operator action (auth, model selection, trust, stuck recovery). |
|
||||
| `failed` | The launch transport itself failed. |
|
||||
|
||||
`awaiting-decision` is new and replaces the prior pattern where a
|
||||
zero-session outcome was either misreported as success or as
|
||||
failure. `attention_required` covers the broader live-but-stuck
|
||||
class; a live or parked seat is NEVER reported as `failed`.
|
||||
|
||||
### `rig seat clear-attention` — Audited Stuck-Seat Recovery
|
||||
|
||||
Subcommand on `rig seat`. Evidence-gated, operator-attested, audited
|
||||
reconcile of a stuck `attention_required` seat back to `ready`:
|
||||
|
||||
```
|
||||
rig seat clear-attention <session>
|
||||
rig seat clear-attention <session> --reason "<operator attestation>"
|
||||
rig seat clear-attention <session> --json
|
||||
```
|
||||
|
||||
- Without `--reason`, the clear requires evidence the seat is
|
||||
actually back to a clean state (the daemon-side gate).
|
||||
- `--reason <text>` is the operator-attestation override; the
|
||||
audit row records the attestation string verbatim.
|
||||
- Output: `Cleared <session>: <from> -> ready (<clearedBy>)`. The
|
||||
audit log captures session, from-state, gate-or-attestation, and
|
||||
operator address.
|
||||
|
||||
Replaces the prior hand-edit-SQLite / fake-clear pattern. The seat's
|
||||
node id is unchanged.
|
||||
|
||||
### Node-Granular Managed Partial Restore
|
||||
|
||||
`rig launch` works for single-seat and subset relaunch on a running
|
||||
rig through the orchestration layer:
|
||||
|
||||
```
|
||||
rig launch <rigId> <nodeRef> Single seat (logical id or node id)
|
||||
rig launch <rigId> --seats <a,b,c> Subset (comma-separated logical ids)
|
||||
rig launch <rigId> --seats <a,b> --hold-reason "<text>" Reason for held non-targets
|
||||
rig launch <rigId> --seats <a,b> --json
|
||||
```
|
||||
|
||||
- Single-target: `POST /api/rigs/:rigId/nodes/:nodeRef/launch`.
|
||||
- Subset: `POST /api/rigs/:rigId/nodes/launch-subset`. Reports
|
||||
`launched`, `held` (with reason), `alreadyRunning`, and
|
||||
`failedTargets` (liveness unknown) separately — partial outcomes
|
||||
are never collapsed.
|
||||
|
||||
Retires the `pod_aware_launch_unsupported` dead-end and the
|
||||
"use rig up instead" workaround. Use `rig launch` for per-seat or
|
||||
per-subset relaunch on a running rig; `rig up <existing>` remains
|
||||
the whole-rig restore entry.
|
||||
|
||||
### Periodic Snapshots
|
||||
|
||||
The crash-insurance floor under event-driven and teardown
|
||||
snapshots. New `snapshots.periodic` settings family:
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| -------------------------------------- | ------- | ------------------------------------------ |
|
||||
| `snapshots.periodic.enabled` | `true` | Master switch |
|
||||
| `snapshots.periodic.interval_seconds` | `300` | Snapshot interval per enabled rig |
|
||||
| `snapshots.periodic.retention_keep` | `10` | Per-rig `auto-periodic` retention count |
|
||||
|
||||
Snapshots are captured with kind `auto-periodic`; the repository
|
||||
prunes by `retention_keep` per rig. Restore selection treats
|
||||
`auto-periodic` and `auto-pre-down` symmetrically — the newer of
|
||||
the two wins, so a daemon crash between teardown snapshots no
|
||||
longer loses arbitrary lifecycle state. Last-snapshot / floor is
|
||||
surfaced in `rig ps` / status so an operator can see the floor
|
||||
at a glance.
|
||||
|
||||
Tune via the settings store (`rig config set ...`) or rely on
|
||||
defaults; the scheduler reads at daemon startup and respects
|
||||
`snapshots.periodic.enabled = false` as a master switch.
|
||||
|
||||
### Codex Profile-V2 Preflight
|
||||
|
||||
The profile-load check on profile-bearing launch and restore
|
||||
surfaces is now honest about WHICH failure it sees:
|
||||
|
||||
- **Legacy-profile detection narrowed**: matches `contains legacy`,
|
||||
`[profiles.<name>]`, `cannot be used while.*legacy`, and `legacy
|
||||
profile selector` only. The prior generic match against `failed
|
||||
to load configuration` was also catching invalid-TOML stderr.
|
||||
- **Invalid TOML surfaces the parse reason**: up to 3 stderr lines
|
||||
(e.g. `expected newline`), with a TOML-validity hint instead of
|
||||
a wrong migration hint pointing at `[profiles.<profile>]`.
|
||||
|
||||
The auth-refusal projection path is unchanged; this is a
|
||||
classification fix in the preflight discriminator.
|
||||
|
||||
### cmux Launch Readiness
|
||||
|
||||
The cmux launch path no longer silently produces a partial
|
||||
workspace. When cmux comes up with a subset of expected windows or
|
||||
panes, the seat surfaces an honest partial-workspace state with a
|
||||
one-click open-missing affordance instead of being projected as
|
||||
launched-clean.
|
||||
|
||||
### Slow Codex Resume Classification
|
||||
|
||||
Internal classification tweak in `verifyResumeLaunch` for slow but
|
||||
genuinely-resuming Codex sessions. Previously the slow path got
|
||||
mis-categorized through the unresolved-gate branch; now it
|
||||
correctly classifies through the same five-term vocabulary and
|
||||
reaches `resumed` when the readiness probe confirms it.
|
||||
|
||||
### Pod-Aware Claude Resume-Selection Menu
|
||||
|
||||
Follow-on to 0.3.3 slice 21 FR-2. `ClaudeCodeAdapter.verifyResumeLaunch`
|
||||
now treats a Claude resume-selection menu the same way it treats
|
||||
the Codex unresolved-gate case: returns immediately with
|
||||
`recovery: attention_required` and last-12-line pane evidence on
|
||||
both the inner poll loop and the final-probe exit path. Zero
|
||||
numeric selection keystrokes are ever sent.
|
||||
|
||||
## What to STOP Using
|
||||
|
||||
These patterns are retired by 0.3.4. Stop reaching for them; the
|
||||
named replacement is the supported path:
|
||||
|
||||
- **STOP** treating `rig up <existing-rig>` as fresh-by-default.
|
||||
Resume-original is now default. Use `--fresh <seats...>` when a
|
||||
deliberate fresh-prime is the goal (operation B).
|
||||
- **STOP** reading a stuck or parked seat as `failed`. A live or
|
||||
parked seat carries `attention_required`; only the transport
|
||||
itself failing is `failed`. The honest zero-session state is
|
||||
`awaiting-decision`, not `failed`.
|
||||
- **STOP** reaching for `pod_aware_launch_unsupported` workarounds
|
||||
or "use `rig up` instead" for per-seat relaunch. Use `rig launch
|
||||
<rigId> <nodeRef>` or `rig launch <rigId> --seats <a,b>`.
|
||||
- **STOP** hand-editing SQLite or fake-clearing a stuck
|
||||
`attention_required` seat. Use `rig seat clear-attention
|
||||
<session>` (evidence-gated) or `rig seat clear-attention
|
||||
<session> --reason "<attestation>"` (operator-attested,
|
||||
audited).
|
||||
- **STOP** relying only on event-driven or teardown snapshots for
|
||||
crash safety. The periodic snapshot scheduler is the floor;
|
||||
tune `snapshots.periodic.interval_seconds` /
|
||||
`snapshots.periodic.retention_keep` to taste.
|
||||
- **STOP** classifying invalid TOML as a legacy-profile problem.
|
||||
The Codex profile-v2 preflight now surfaces the actual TOML
|
||||
parse reason; legacy detection requires legacy-specific
|
||||
patterns.
|
||||
- **STOP** restoring a hand-resumed session by relaunching the
|
||||
whole rig. Use `rig reconcile-session <session> --no-launch` —
|
||||
no relaunch, no input written, same node id.
|
||||
- **STOP** projecting a partial cmux workspace as launched-clean.
|
||||
The launch-readiness gate surfaces partial state honestly with
|
||||
a one-click open-missing path.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/[email protected]
|
||||
rig --version # expect 0.3.4
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
No new schema-bumping migrations in 0.3.4. The
|
||||
`snapshots.periodic.*` settings have built-in defaults
|
||||
(`enabled=true`, `interval_seconds=300`, `retention_keep=10`); no
|
||||
operator action is required to pick up the crash-insurance floor.
|
||||
|
||||
Operators installing v0.3.4 fresh from npm have full access to the
|
||||
new surfaces immediately. Operators upgrading an existing host
|
||||
follow the standard daemon-restart flow above; the 0.3.4 host-
|
||||
upgrade flow is held for separate sequencing and is NOT a
|
||||
prerequisite for fresh-install access.
|
||||
|
||||
For the new `rig start` surface: it sequences existing primitives
|
||||
(daemon + kernel + per-rig `/api/rigs/:id/up`). The picker is TTY-
|
||||
interactive by default; the headless flags `--last`, `--all`, and
|
||||
`--rigs <names...>` cover scripted recovery.
|
||||
|
||||
For `rig up` resume-original-by-default: any agent or script that
|
||||
implicitly depended on fresh-prime as the default must now name
|
||||
`--fresh <seats...>` explicitly. The five-term vocabulary applies
|
||||
on the response; treat `awaiting-decision` as the headless
|
||||
"operator must decide" outcome, not an error.
|
||||
|
||||
For `rig seat clear-attention`: prefer evidence-gated clear when
|
||||
the seat is genuinely back. The `--reason <text>` override is the
|
||||
audited operator-attestation path; the attestation string lands in
|
||||
the audit row verbatim.
|
||||
|
||||
For periodic snapshots: tune `snapshots.periodic.interval_seconds`
|
||||
down for higher-churn rigs and `snapshots.periodic.retention_keep`
|
||||
up for longer recovery windows. The default 5-minute interval +
|
||||
10-snapshot retention is a balanced floor.
|
||||
|
||||
## Known Limitations / 0.3.4 Carry-forwards
|
||||
|
||||
- **`rig view list/show --json` flag inconsistency** — wrapper-
|
||||
layer routing path remains; the daemon's `view show <name>`
|
||||
route returns JSON correctly when invoked directly. Workaround:
|
||||
human-readable output for now. Wrapper-layer fix is the
|
||||
documented target.
|
||||
- **`docs/DESIGN.md` docs-guard ledger** — the docs-guard ledger
|
||||
for design-doc updates carries forward; no behavior impact on
|
||||
the shipped runtime surface.
|
||||
- **Slice-21 FR-1 (native Codex session-id hook)**: scope-tipped
|
||||
to 0.4.0 after build-time forensic. Carried forward from 0.3.3.
|
||||
- **Slice-13 component 3 (auto-rollout dispatcher)**: skill-layer
|
||||
landing remains; product-code dispatcher / invocation is
|
||||
intentionally out of the shipped runtime bundle. Carried
|
||||
forward from 0.3.3.
|
||||
- **Slice-05 sub-scopes from 0.3.2**: agent / port / managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-
|
||||
rig pathway acceptance (Item 4.4); `--target-name` CLI flag for
|
||||
install-time rig-name overrides. Carried forward.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema**: deferred from
|
||||
0.3.2; new `accepted` queue state remains a schema model
|
||||
change. Carried forward.
|
||||
- **Onboarding journey + battle-hardening**: slices 04
|
||||
(new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) carried forward to a later release. The 0.3.4
|
||||
cut focused on recovery + resilience surfaces ahead of the
|
||||
onboarding journey.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots remains a
|
||||
follow-up; non-blocking for 0.3.4.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental`
|
||||
only; not split-deferred (stays held).
|
||||
- **0.3.4 host-upgrade flow held**: operators installing v0.3.4
|
||||
fresh from npm have full access immediately; upgrading an
|
||||
existing host follows the standard daemon-restart flow above.
|
||||
The named host-upgrade flow is held for separate sequencing.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- TypeScript checks clean on every touched file at per-commit
|
||||
guard across every shipped slice.
|
||||
- Per-slice focused-suite tests green:
|
||||
- Slice 01 (`rig start`): pod-aware id-grounded preview + apply
|
||||
(rev1 BLOCKING b4c6ada4); same-name disambiguation; `/api/up`
|
||||
never reached in the recovery path; `--last` / `--all` /
|
||||
`--rigs <names...>` headless paths.
|
||||
- Slice 02 (`rig up` resume-original-by-default): seat-level
|
||||
outcomes drive `resumed` / `fresh-primed` / `awaiting-
|
||||
decision`; per-seat TTY [y/N] prompt; headless explicit-hint
|
||||
path for `awaiting-decision`.
|
||||
- Slice 03 (`rig reconcile-session`): adopt a hand-resumed
|
||||
session with same node id; projection-drift reporting; never
|
||||
launches, never writes to the pane.
|
||||
- Slice 04 (`rig up --plan`): read-only restore preview; honest
|
||||
async timeout; no mutation; same five-term vocabulary on the
|
||||
per-node `intendedAction`.
|
||||
- Slice 05 rev1 (pod-aware Claude resume-selection menu):
|
||||
`ClaudeCodeAdapter.verifyResumeLaunch` returns
|
||||
`recovery: attention_required` with last-12-line evidence;
|
||||
zero numeric keystrokes sent.
|
||||
- Slice 06 (five-term vocabulary): `rig up` / `rig restore` /
|
||||
`rig ps` projections carry the named outcome on every
|
||||
representative seat-state path.
|
||||
- Slice 07 (Codex profile-v2 preflight): narrowed legacy
|
||||
detection regex; invalid-TOML stderr surfaces the parse
|
||||
reason and a TOML-validity hint (rev1 BLOCKING).
|
||||
- Slice 08 (cmux launch readiness): partial-workspace state
|
||||
surfaces honestly with the open-missing affordance; no
|
||||
silent launched-clean projection.
|
||||
- Slice 09 (periodic snapshots): `PeriodicSnapshotScheduler`
|
||||
captures + prunes by retention; `auto-periodic` vs
|
||||
`auto-pre-down` newest-wins in the restore selector;
|
||||
settings-store integration for the `snapshots.periodic.*`
|
||||
family.
|
||||
- Slice 10 (`rig seat clear-attention`): evidence-gated clear;
|
||||
operator-attestation override via `--reason`; audit row
|
||||
captures session / from-state / cleared-by.
|
||||
- Slice 11 (node-granular launch): single-seat
|
||||
`/api/rigs/:rigId/nodes/:nodeRef/launch`; subset
|
||||
`/api/rigs/:rigId/nodes/launch-subset`; partial outcomes
|
||||
(launched / held / alreadyRunning / failedTargets) never
|
||||
collapsed.
|
||||
- Slice 13 (slow Codex resume classification): slow-resume
|
||||
Codex path classifies through the five-term vocabulary and
|
||||
reaches `resumed` when the readiness probe confirms.
|
||||
- Per-commit guard CLEAR across every shipped slice;
|
||||
velocity-qa adversarial dogfood + dual rev1 CLEAR on each
|
||||
merge.
|
||||
- VM end-to-end suite green against the 0.3.4 cut; recovery
|
||||
paths (`rig start` + `rig up` resume-by-default +
|
||||
`rig reconcile-session` + `rig seat clear-attention`)
|
||||
exercised against a real macOS VM through the vm-e2e harness.
|
||||
- Zero regressions versus the v0.3.3 base on every shipped
|
||||
slice.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.3 -> v0.3.4 delta is 67 commits / 69 files /
|
||||
+7,193 insertions / -267 deletions. The list below groups
|
||||
load-bearing commits by theme:
|
||||
|
||||
### Recovery Entry Points
|
||||
|
||||
- `feat(cli)`: `rig start` one-command recovery orchestrator —
|
||||
daemon-start, kernel wait, candidate listing, picker / flags,
|
||||
per-rig restore + reconcile (slice 01).
|
||||
- `fix(cli,daemon)`: `rig start` id-grounded preview + apply —
|
||||
same-name rigs no longer hit `ambiguous_name` 409 (slice 01
|
||||
rev1 BLOCKING).
|
||||
- `feat(cli)`: `rig reconcile-session` — adopt a live, hand-
|
||||
resumed canonical session into its persisted node without
|
||||
launch / relaunch / kill / startup-replay / resume-menu /
|
||||
pane-input (slice 03).
|
||||
|
||||
### Resume Original By Default
|
||||
|
||||
- `feat(cli,daemon)`: `rig up` resume-original-by-default;
|
||||
`--fresh <seats...>` becomes the per-seat opt-in for
|
||||
operation B; per-seat TTY [y/N] for `awaiting-decision`;
|
||||
headless explicit-hint path (slice 02).
|
||||
- `feat(cli,daemon)`: `rig up --plan` read-only restore preview
|
||||
with honest async timeout (slice 04).
|
||||
|
||||
### Honest Restore Vocabulary
|
||||
|
||||
- `feat(daemon,cli)`: five-term restore status vocabulary across
|
||||
`rig up` / `rig restore` / `rig ps` — `resumed` /
|
||||
`fresh-primed` / `awaiting-decision` / `attention_required` /
|
||||
`failed` (slice 06).
|
||||
- `fix(daemon)`: pod-aware Claude resume-selection menu surfaces
|
||||
`attention_required` instead of timeout (slice 05 rev1
|
||||
BLOCKING).
|
||||
- `fix(daemon)`: slow Codex resume classification — slow but
|
||||
genuinely-resuming sessions no longer mis-categorize through
|
||||
the unresolved-gate branch (slice 13).
|
||||
|
||||
### Preflight + Readiness Honesty
|
||||
|
||||
- `fix(daemon)`: Codex profile-v2 preflight narrows legacy
|
||||
detection regex; invalid TOML surfaces parse reason + TOML-
|
||||
validity hint (slice 07 rev1 BLOCKING).
|
||||
- `fix(daemon,ui)`: cmux launch readiness — partial workspace
|
||||
surfaces honestly; one-click open-missing affordance instead
|
||||
of silent launched-clean (slice 08).
|
||||
|
||||
### Crash-Insurance Floor
|
||||
|
||||
- `feat(daemon)`: `PeriodicSnapshotScheduler` + `snapshots.
|
||||
periodic` settings family; `auto-periodic` snapshot kind;
|
||||
restore selector treats `auto-periodic` and `auto-pre-down`
|
||||
symmetrically (newest-wins); last-snapshot / floor in
|
||||
`rig ps` (slice 09).
|
||||
|
||||
### Stuck-Seat Recovery
|
||||
|
||||
- `feat(cli,daemon)`: `rig seat clear-attention` — evidence-
|
||||
gated, operator-attested, audited reconcile of a stuck
|
||||
`attention_required` seat; audit row captures session / from-
|
||||
state / cleared-by (slice 10).
|
||||
|
||||
### Node-Granular Managed Partial Restore
|
||||
|
||||
- `feat(cli,daemon)`: node-granular launch through orchestration
|
||||
— `rig launch <rigId> <nodeRef>` single-seat;
|
||||
`rig launch <rigId> --seats <a,b>` subset; partial outcomes
|
||||
(launched / held / alreadyRunning / failedTargets) reported
|
||||
separately; retires `pod_aware_launch_unsupported` dead-end
|
||||
(slice 11).
|
||||
|
||||
See `CHANGELOG.md` for the agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7419,7 +7419,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7450,7 +7450,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7474,7 +7474,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.4",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface DaemonResponse<T = unknown> {
|
||||
|
||||
interface DaemonRequestOptions {
|
||||
timeoutMs?: number;
|
||||
/** Per-call header overrides (e.g., `Authorization: Bearer ...`). */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DaemonClientOptions {
|
||||
@@ -79,7 +81,20 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" }, options);
|
||||
return this.requestJson<T>(path, {
|
||||
method: "DELETE",
|
||||
headers: options?.headers,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async put<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
const baseHeaders: Record<string, string> = body !== undefined ? { "Content-Type": "application/json" } : {};
|
||||
const headers = { ...baseHeaders, ...(options?.headers ?? {}) };
|
||||
return this.requestJson<T>(path, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}, options);
|
||||
}
|
||||
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Command } from "commander";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface AddMemberResponse {
|
||||
ok: boolean;
|
||||
result?: {
|
||||
podId: string;
|
||||
podNamespace: string;
|
||||
node: { logicalId: string; nodeId: string; status: string; error?: string; sessionName?: string };
|
||||
edges?: Array<{ from: string; to: string; kind: string }>;
|
||||
warnings?: string[];
|
||||
};
|
||||
code?: string;
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function addMemberCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("add").description("Add a member to an existing pod in a running rig");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rig-id>", "ID of the target rig")
|
||||
.argument("<pod-namespace>", "Namespace of the existing pod to add the member to")
|
||||
.argument("<member-fragment-path>", "Path to YAML/JSON member fragment file (spec snake_case fields)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--rig-root <path>", "Root directory for agent resolution")
|
||||
.action(async (rigId: string, podNamespace: string, fragmentPath: string, opts: { json?: boolean; rigRoot?: string }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let fileContent: string;
|
||||
try {
|
||||
fileContent = readFileSync(fragmentPath, "utf-8");
|
||||
} catch {
|
||||
console.error(`Cannot read file: ${fragmentPath}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let member: Record<string, unknown>;
|
||||
let edges: unknown;
|
||||
try {
|
||||
// Dynamic import to avoid bundling yaml at module load (matches expand).
|
||||
const { parse } = await import("yaml");
|
||||
const parsed = (parse(fileContent) ?? {}) as Record<string, unknown>;
|
||||
if (parsed["member"] && typeof parsed["member"] === "object" && !Array.isArray(parsed["member"])) {
|
||||
// Wrapper form: { member: {...}, edges?: [...] }.
|
||||
member = parsed["member"] as Record<string, unknown>;
|
||||
edges = parsed["edges"];
|
||||
} else {
|
||||
// Bare member form. Lift any top-level `edges:` out as pod-local edges
|
||||
// so they are NOT silently dropped (the schema ignores unknown member
|
||||
// fields). The rest is the member.
|
||||
const { edges: bareEdges, ...rest } = parsed;
|
||||
member = rest;
|
||||
edges = bareEdges;
|
||||
}
|
||||
} catch {
|
||||
console.error("Invalid YAML/JSON in member fragment file");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// A PRESENT-but-non-array edges field is an honest error, never silently
|
||||
// omitted (governance FM2 no-silent-drop).
|
||||
if (edges !== undefined && edges !== null && !Array.isArray(edges)) {
|
||||
console.error("Invalid member fragment: 'edges' must be an array of { from, to, kind }.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const body: Record<string, unknown> = { member };
|
||||
if (Array.isArray(edges)) body["edges"] = edges;
|
||||
if (opts.rigRoot) body["rigRoot"] = opts.rigRoot;
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<AddMemberResponse>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/pods/${encodeURIComponent(podNamespace)}/members`,
|
||||
body,
|
||||
);
|
||||
const data = res.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
// Non-zero if the HTTP failed OR the new node did not fully launch.
|
||||
if (res.status >= 400 || (data.ok && data.result !== undefined && data.result.node.status !== "launched")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400 || !data.ok) {
|
||||
// Honest 3-part error: the daemon's message already says what failed /
|
||||
// why / what to do (pod_not_found lists pods; member_conflict suggests a
|
||||
// new id); validation/preflight surface the specific field errors.
|
||||
const msg = data.message
|
||||
?? (data.errors && data.errors.length > 0 ? data.errors.join("; ") : data.error)
|
||||
?? `Add member failed (HTTP ${res.status})`;
|
||||
console.error(msg);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const node = data.result!.node;
|
||||
const icon = node.status === "launched" ? "OK" : "FAIL";
|
||||
const session = node.sessionName ? ` (${node.sessionName})` : "";
|
||||
const error = node.error ? ` - ${node.error}` : "";
|
||||
console.log(`Added member to rig ${rigId}`);
|
||||
console.log(` Pod: ${data.result!.podNamespace}`);
|
||||
console.log(` Member: [${icon}] ${node.logicalId}${session}${error}`);
|
||||
|
||||
const persistedEdges = data.result!.edges ?? [];
|
||||
if (persistedEdges.length > 0) {
|
||||
console.log(" Edges:");
|
||||
for (const e of persistedEdges) console.log(` ${e.from} ${e.kind} ${e.to}`);
|
||||
}
|
||||
|
||||
if (data.result!.warnings && data.result!.warnings.length > 0) {
|
||||
console.log("");
|
||||
for (const w of data.result!.warnings) console.log(` Warning: ${w}`);
|
||||
}
|
||||
|
||||
if (node.status !== "launched") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ArchiveResult {
|
||||
ok: boolean;
|
||||
rigId: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
interface ThreePartErrorBody {
|
||||
error:
|
||||
| { fact: string; consequence: string; action: string }
|
||||
| string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig archive <rigId>` - OPR.0.3.3.19. Soft, REVERSIBLE archive: hides a rig
|
||||
* from the default explorer + `rig ps`, while RETAINING the rigs row, topology,
|
||||
* and snapshots. This is NOT `rig down --delete` (which is destructive). Reverse
|
||||
* with `rig unarchive <rigId>`. Running/degraded rigs require `--force`.
|
||||
*/
|
||||
export function archiveCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("archive").description(
|
||||
"Archive a rig (soft + reversible: hides it from the default view, retains all data). NOT a delete; reverse with 'rig unarchive'.",
|
||||
);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Rig identifier to archive")
|
||||
.option("--force", "Archive even if the rig is running or degraded")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (rigId: string, opts: { force?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<ArchiveResult | ThreePartErrorBody>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/archive`,
|
||||
{ force: opts.force ?? false },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = res.status === 409 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
console.error(`Rig not found: ${rigId}. Check the id with: rig ps`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// AC-6: running/degraded without --force returns a 3-part honest error.
|
||||
if (res.status === 409) {
|
||||
const err = (res.data as ThreePartErrorBody).error;
|
||||
if (err && typeof err === "object") {
|
||||
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
|
||||
} else {
|
||||
process.stderr.write(`Error: ${String(err)}\n`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(`Archive failed (HTTP ${res.status}).`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const r = res.data as ArchiveResult;
|
||||
if (r.archived) {
|
||||
console.log(`Rig ${rigId} archived (reversible). It is hidden from the default view.`);
|
||||
console.log(` See it with: rig ps --include-archived`);
|
||||
console.log(` Bring it back: rig unarchive ${rigId}`);
|
||||
} else {
|
||||
console.log(`Rig ${rigId} was already archived.`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodePath from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -30,7 +30,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) {
|
||||
console.error("Daemon not running");
|
||||
printDaemonNotRunning();
|
||||
return null;
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
@@ -1,10 +1,46 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* Read the CLI's own package.json version at call time (Item 1 / slice-05).
|
||||
* Function-level read on purpose: module-level constants would mask test
|
||||
* isolation per the audit-every-layer discipline.
|
||||
*/
|
||||
function getCliVersion(): string {
|
||||
try {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgPath = nodePath.join(nodePath.dirname(here), "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: unknown };
|
||||
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provenance block the CLI sends to /api/bundles/create. Reads
|
||||
* hostname, session name (from canonical OPENRIG_SESSION_NAME env), and
|
||||
* CLI version at call time. Operator notes come from the --notes flag.
|
||||
* Daemon adds daemonVersion server-side.
|
||||
*/
|
||||
function buildClientProvenance(notes: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
sourceHost: os.hostname(),
|
||||
cliVersion: getCliVersion(),
|
||||
};
|
||||
const session = process.env.OPENRIG_SESSION_NAME;
|
||||
if (typeof session === "string" && session.length > 0) out.authorSession = session;
|
||||
if (typeof notes === "string" && notes.length > 0) out.notes = notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("bundle").description("Manage rig bundles");
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -26,17 +62,38 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--bundle-version <ver>", "Bundle version", "0.1.0")
|
||||
.option("--include-packages <refs...>", "Package refs to include (default: all from spec)")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--notes <text>", "Operator notes captured in bundle provenance metadata")
|
||||
.option("--min-daemon-version <ver>", "Minimum daemon version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--min-cli-version <ver>", "Minimum CLI version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; minDaemonVersion?: string; minCliVersion?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// Item 2 / slice-05: build compatibility from operator flags. Only included
|
||||
// in the request body when at least one of the two flags is set.
|
||||
const compatibility: Record<string, string> = {};
|
||||
if (opts.minDaemonVersion) compatibility.minDaemonVersion = opts.minDaemonVersion;
|
||||
if (opts.minCliVersion) compatibility.minCliVersion = opts.minCliVersion;
|
||||
const hasCompatibility = Object.keys(compatibility).length > 0;
|
||||
|
||||
// QA-20260601 A1 repair (banked banked-create-install-long-running):
|
||||
// /create can take seconds-to-minutes (pod-aware assembler walk +
|
||||
// Item-6 cross-primitive vendoring + integrity-over-all-content +
|
||||
// tar pack). Default 5000ms CLI timeout (client.ts:31) is too short
|
||||
// for real-world bundles; daemon completes work but CLI reports
|
||||
// failure. Per-call 120s upper bound covers reasonable real-world
|
||||
// bundle sizes; if a bundle author hits this, the daemon-side
|
||||
// operation is async-completable in a future /bundles/jobs/<id>
|
||||
// surface (not in slice-05 scope).
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
|
||||
specPath: spec, bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: opts.output,
|
||||
includePackages: opts.includePackages,
|
||||
rigRoot: opts.rigRoot ? nodePath.resolve(opts.rigRoot) : undefined,
|
||||
});
|
||||
provenance: buildClientProvenance(opts.notes),
|
||||
...(hasCompatibility ? { compatibility } : {}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -92,15 +149,31 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--plan", "Plan mode")
|
||||
.option("--yes", "Auto-approve")
|
||||
.option("--target <root>", "Target root directory")
|
||||
.option("--skip-version-check", "Operator-explicit override of the Item-2 install-time compatibility check (NOT recommended for routine use)")
|
||||
.option("--force", "Operator-explicit override of the Item-3 install-time conflict check (NOT recommended; conflicts may produce partial install state)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; json?: boolean }) => {
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; skipVersionCheck?: boolean; force?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// QA-20260601 A1 repair: /install completes a full bootstrap run
|
||||
// (resolve → vendor sibling primitives → boot rig sessions) which
|
||||
// can take many seconds for real bundles. Default 5000ms CLI
|
||||
// timeout (client.ts:31) caused CLI to report failure while
|
||||
// daemon completed the mutating install — operator-unsafe retry
|
||||
// path (rig_name_collision on second attempt). Bumped to 120s.
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/install", {
|
||||
bundlePath, plan: opts.plan ?? false, autoApprove: opts.yes ?? false, targetRoot: opts.target,
|
||||
});
|
||||
// Item 2 / slice-05 Checkpoint 3.3: send CLI version + skip flag for the
|
||||
// daemon-side install-time compatibility check. CLI version read at call
|
||||
// time (no module-level constant) via the existing getCliVersion helper.
|
||||
cliVersion: getCliVersion(),
|
||||
skipVersionCheck: opts.skipVersionCheck ?? false,
|
||||
// Item 3 / slice-05 Checkpoint 4.2: send force flag for the daemon-side
|
||||
// install-time conflict check. Operator-explicit override only.
|
||||
force: opts.force ?? false,
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -118,5 +191,48 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
if (res.data["rigId"]) console.log(`Rig: ${res.data["rigId"]}`);
|
||||
});
|
||||
|
||||
// rig bundle history — Item 4 / slice-05 Checkpoint 5.2
|
||||
cmd.command("history")
|
||||
.description("List bundle install audit records from ~/.openrig/bundle-audit.jsonl")
|
||||
.option("--rig <name>", "Filter to records whose targetRigName matches")
|
||||
.option("--since <iso>", "Filter to records installedAt >= this ISO timestamp")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { rig?: string; since?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.since) qs.set("since", opts.since);
|
||||
const query = qs.toString();
|
||||
const path = query.length > 0 ? `/api/bundles/history?${query}` : "/api/bundles/history";
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(path);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(res.data["error"] ?? "History fetch failed");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const records = Array.isArray(res.data["records"]) ? res.data["records"] as Array<Record<string, unknown>> : [];
|
||||
if (records.length === 0) {
|
||||
console.log("No bundle install audit records found.");
|
||||
return;
|
||||
}
|
||||
console.log(`Bundle install history (${records.length} record${records.length === 1 ? "" : "s"}):`);
|
||||
for (const r of records) {
|
||||
const at = r["installedAt"] ?? "?";
|
||||
const rig = r["targetRigName"] ?? "?";
|
||||
const outcome = r["outcome"] ?? "?";
|
||||
const bundle = r["bundlePath"] ?? "?";
|
||||
console.log(` ${at} ${outcome.toString().padEnd(8)} rig=${rig} ${bundle}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { renderMissionNotesTemplate } from "../lib/scope/templates.js";
|
||||
|
||||
export interface InitWorkspaceOpts {
|
||||
root?: string;
|
||||
@@ -39,6 +40,11 @@ export interface InitWorkspaceResult {
|
||||
|
||||
type DefaultSlice = {
|
||||
id: string;
|
||||
/** §1 dot-ID per conventions/scope-and-versioning (release-0.3.2
|
||||
* slice 01 GA-coherence pass). Mirror of the daemon constant in
|
||||
* packages/daemon/src/domain/workspace/default-workspace-scaffold.ts
|
||||
* — keep in lockstep for the slice-21 parity test. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
@@ -46,27 +52,36 @@ type DefaultSlice = {
|
||||
|
||||
type DefaultMission = {
|
||||
id: string;
|
||||
/** §1 mission dot-ID — escape band for non-release missions. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
slices: DefaultSlice[];
|
||||
};
|
||||
|
||||
// Release-0.3.2 slice 01 (OPR.0.3.2.1) — seeded missions/slices now
|
||||
// carry §1 dot-IDs in frontmatter so the scaffold coheres with
|
||||
// `conventions/scope-and-versioning`. Mirror of the daemon constant;
|
||||
// the slice-21 parity test asserts both sides emit identical bodies.
|
||||
const DEFAULT_MISSIONS: DefaultMission[] = [
|
||||
{
|
||||
id: "getting-started",
|
||||
dotId: "OPR.99.0.1",
|
||||
title: "Getting Started",
|
||||
status: "active",
|
||||
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
|
||||
slices: [
|
||||
{
|
||||
id: "first-conveyor-run",
|
||||
dotId: "OPR.99.0.1.1",
|
||||
title: "First Conveyor Run",
|
||||
status: "active",
|
||||
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
|
||||
},
|
||||
{
|
||||
id: "inspect-project-evidence",
|
||||
dotId: "OPR.99.0.1.2",
|
||||
title: "Inspect Project Evidence",
|
||||
status: "draft",
|
||||
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
|
||||
@@ -93,7 +108,7 @@ const WORKSPACE_DIRS = [
|
||||
function subdirReadmeContent(subdir: string): string {
|
||||
switch (subdir) {
|
||||
case "missions":
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-name>/README.md\nmissions/<mission-name>/PROGRESS.md\nmissions/<mission-name>/slices/<slice-name>/README.md\n```\n\nEvery mission and slice README carries a stable dot-ID in its frontmatter (`id: OPR.<ver>[.<n>]`) per the scope-and-versioning convention. Use `rig scope mission create <name>` and `rig scope slice create <mission> <slug>` to mint conformant artifacts; the CLI handles the dot-ID + auto-numbering for you. The folder name is the operator-facing slug; the `id:` is the stable handle that survives renames.\n";
|
||||
case "artifacts":
|
||||
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
|
||||
case "evidence":
|
||||
@@ -122,7 +137,7 @@ This workspace is file-backed. The Project UI mirrors this structure:
|
||||
- \`evidence/\` is for proof notes and verification summaries.
|
||||
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
|
||||
|
||||
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
|
||||
Use stable kebab-case names for mission and slice folders. Every mission/slice README also carries a stable dot-ID in its frontmatter (\`id: OPR.<ver>[.<n>]\`) per the \`scope-and-versioning\` convention; mint conformant artifacts via \`rig scope mission create <name>\` and \`rig scope slice create <mission> <slug>\`. Folder names are operator-facing slugs; the \`id:\` is the rename-proof handle.
|
||||
`;
|
||||
|
||||
const STEERING_PLACEHOLDER = `---
|
||||
@@ -153,6 +168,7 @@ alongside your Project workspace.
|
||||
|
||||
function missionReadme(mission: DefaultMission): string {
|
||||
return `---
|
||||
id: ${mission.dotId}
|
||||
title: ${mission.title}
|
||||
status: ${mission.status}
|
||||
mission: ${mission.id}
|
||||
@@ -423,6 +439,7 @@ function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
|
||||
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
|
||||
if (narrative) {
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -433,6 +450,7 @@ slice: ${slice.id}
|
||||
${narrative.readme}`;
|
||||
}
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -522,6 +540,20 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri
|
||||
files.push(
|
||||
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
|
||||
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
|
||||
// FR-5e A1 — reuse the FR-3 MISSION_NOTES scaffold helper so a
|
||||
// fresh `rig config init-workspace` produces a getting-started
|
||||
// mission that doctor check #7 (mission_notes_presence) marks
|
||||
// ok. Same env-var-pivot (OPENRIG_MISSION_NOTES_TEMPLATE_PATH >
|
||||
// built-in bundled template) FR-3's `rig scope mission create`
|
||||
// honors.
|
||||
{
|
||||
relPath: `missions/${mission.id}/MISSION_NOTES.md`,
|
||||
content: renderMissionNotesTemplate({
|
||||
mission_id: mission.dotId,
|
||||
mission_name: mission.title,
|
||||
created_date: new Date().toISOString().slice(0, 10),
|
||||
}).rendered,
|
||||
},
|
||||
);
|
||||
for (const slice of mission.slices) {
|
||||
files.push(
|
||||
@@ -580,6 +612,17 @@ export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string
|
||||
const dryRun = !!opts.dryRun;
|
||||
const force = !!opts.force;
|
||||
|
||||
// FR-5e BLOCKER-1 — precompute the full scaffold file list BEFORE
|
||||
// any filesystem mutation. workspaceScaffoldFiles() now invokes
|
||||
// renderMissionNotesTemplate(), which throws on invalid
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH. If we mkdir first then
|
||||
// throw on render, the operator is left with a half-created
|
||||
// workspace that the next `rig workspace doctor` will misread as
|
||||
// partially-initialized. Verify-first-then-write (banked from
|
||||
// FR-3 self-lesson) — fail before any side effect.
|
||||
const scaffoldFiles = workspaceScaffoldFiles();
|
||||
const scaffoldDirs = workspaceScaffoldDirs();
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
const result: InitWorkspaceResult = {
|
||||
root,
|
||||
@@ -591,14 +634,14 @@ export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string
|
||||
|
||||
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
|
||||
|
||||
for (const sub of workspaceScaffoldDirs()) {
|
||||
for (const sub of scaffoldDirs) {
|
||||
const subPath = join(root, sub);
|
||||
const subExists = existsSync(subPath);
|
||||
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
|
||||
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
|
||||
}
|
||||
|
||||
for (const file of workspaceScaffoldFiles()) {
|
||||
for (const file of scaffoldFiles) {
|
||||
const absPath = join(root, file.relPath);
|
||||
const exists = existsSync(absPath);
|
||||
if (exists && !force) {
|
||||
|
||||
@@ -46,6 +46,7 @@ Keys:
|
||||
policies.claude_compaction.*
|
||||
enabled, threshold_percent, compact_instruction,
|
||||
message_inline, message_file_path
|
||||
snapshots.periodic.* enabled, interval_seconds, retention_keep
|
||||
|
||||
Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action((opts: { json?: boolean; withSource?: boolean }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -11,7 +11,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) {
|
||||
console.error("Daemon not running");
|
||||
printDaemonNotRunning();
|
||||
return null;
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
@@ -16,8 +16,82 @@ interface TeardownResult {
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
interface RigSummaryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
archivedAt?: string | null;
|
||||
lifecycleState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig down <rigId>` — tear down a rig.
|
||||
* Outcome of resolving a `rig down <rig>` handle (name OR id) to a concrete id.
|
||||
* The destructive teardown only ever runs on a `resolved`/`passthrough` id;
|
||||
* `ambiguous`/`not_found` halt BEFORE any `/api/down` POST.
|
||||
*/
|
||||
type HandleResolution =
|
||||
| { kind: "resolved"; id: string }
|
||||
| { kind: "ambiguous"; name: string; ids: string[] }
|
||||
| { kind: "not_found"; handle: string }
|
||||
// Summary unavailable (non-200 / fetch error): fall back to today's id-only
|
||||
// behavior - POST the raw handle as the id and let the daemon resolve it by
|
||||
// exact id (404 if absent). Safe: the daemon matches a single exact id, so a
|
||||
// name posted this way cannot tear down the wrong rig.
|
||||
| { kind: "passthrough"; handle: string };
|
||||
|
||||
/**
|
||||
* Resolve a `rig down` handle (rig name OR id) to a concrete rig id, mirroring
|
||||
* the `/api/rigs/summary` path `rig up` uses. Resolution is a PRE-STEP: the
|
||||
* existing teardown + guards downstream are unchanged; this only maps the
|
||||
* handle to an id.
|
||||
*
|
||||
* Safety order (destructive-op):
|
||||
* 1. id-exact-match FIRST, across ALL rigs incl. archived - an id is unique, so
|
||||
* it is never ambiguous, and an archived rig's id must still reach the
|
||||
* canonical teardown id path (AC-2 unchanged).
|
||||
* 2. else name-filter over ACTIVE (non-archived) rigs only:
|
||||
* - exactly 1 active match -> resolve to that id;
|
||||
* - >1 active matches -> AMBIGUOUS: halt, never guess (load-bearing AC-3);
|
||||
* - 0 active matches -> NOT_FOUND: halt, honest error (AC-4).
|
||||
*
|
||||
* `/api/rigs/summary` defaults to ACTIVE-only and exposes `archivedAt`; we fetch
|
||||
* with `includeArchived=true` so an archived id still id-matches, then filter
|
||||
* names to active. So an active+archived same-name pair is NOT ambiguous (only
|
||||
* the active candidate counts), and an archived-only name does not resolve by
|
||||
* name (use the id, or the archive path).
|
||||
*/
|
||||
async function resolveRigHandle(client: DaemonClient, handle: string): Promise<HandleResolution> {
|
||||
let summaries: RigSummaryEntry[];
|
||||
try {
|
||||
// includeArchived=true so an archived rig's id still id-matches below
|
||||
// (preserving today's `rig down <id>` path); names are filtered to active.
|
||||
const res = await client.get<RigSummaryEntry[]>("/api/rigs/summary?includeArchived=true");
|
||||
if (res.status !== 200 || !Array.isArray(res.data)) {
|
||||
return { kind: "passthrough", handle };
|
||||
}
|
||||
summaries = res.data;
|
||||
} catch {
|
||||
return { kind: "passthrough", handle };
|
||||
}
|
||||
|
||||
// 1. id-exact-match first, across ALL rigs incl. archived (AC-2: down by id,
|
||||
// unchanged; ids are never ambiguous; archived ids still reach teardown).
|
||||
if (summaries.some((r) => r.id === handle)) {
|
||||
return { kind: "resolved", id: handle };
|
||||
}
|
||||
|
||||
// 2. name-filter over ACTIVE (non-archived) rigs only, symmetric with `up`.
|
||||
const activeNameMatches = summaries.filter((r) => r.name === handle && r.archivedAt == null);
|
||||
if (activeNameMatches.length === 1) {
|
||||
return { kind: "resolved", id: activeNameMatches[0]!.id };
|
||||
}
|
||||
if (activeNameMatches.length > 1) {
|
||||
return { kind: "ambiguous", name: handle, ids: activeNameMatches.map((r) => r.id) };
|
||||
}
|
||||
return { kind: "not_found", handle };
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig down <rig>` - tear down a rig by name or id.
|
||||
* @param depsOverride - injectable deps for testing
|
||||
* @returns Commander command
|
||||
*/
|
||||
@@ -26,12 +100,12 @@ export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Rig identifier to tear down")
|
||||
.argument("<rig>", "Rig name or id to tear down")
|
||||
.option("--delete", "Delete rig record after stopping")
|
||||
.option("--force", "Kill sessions immediately")
|
||||
.option("--snapshot", "Take snapshot before teardown")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (rigId: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean }) => {
|
||||
.action(async (rigHandle: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
@@ -43,6 +117,46 @@ export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
// Resolve the handle (name OR id) to a concrete id BEFORE the teardown POST.
|
||||
// Ambiguous/not-found halt here and never reach `/api/down` - for a
|
||||
// destructive op, ambiguity must stop, never guess (AC-3 load-bearing).
|
||||
const resolution = await resolveRigHandle(client, rigHandle);
|
||||
|
||||
if (resolution.kind === "ambiguous") {
|
||||
const fact = `'${resolution.name}' matches ${resolution.ids.length} rigs.`;
|
||||
const consequence = "Refusing to tear down: an ambiguous name could destroy the wrong rig.";
|
||||
const action = `Re-run with the specific id, e.g. ${resolution.ids.map((id) => `rig down ${id}`).join(" | ")}`;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ error: { fact, consequence, action, candidates: resolution.ids } }));
|
||||
} else {
|
||||
console.error(`Error: ${fact}`);
|
||||
console.error(` ${consequence}`);
|
||||
console.error(` ${action}`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolution.kind === "not_found") {
|
||||
const fact = `No rig found matching '${resolution.handle}'.`;
|
||||
const consequence = "Nothing was torn down.";
|
||||
const action = "List rigs with: rig ps";
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ error: { fact, consequence, action } }));
|
||||
} else {
|
||||
console.error(`Error: ${fact}`);
|
||||
console.error(` ${consequence}`);
|
||||
console.error(` ${action}`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
// resolved -> the looked-up id; passthrough -> the raw handle (summary
|
||||
// unavailable; daemon resolves by exact id, 404s if absent). Either way
|
||||
// the SAME existing teardown path + guards run below - no forked path.
|
||||
const rigId = resolution.kind === "resolved" ? resolution.id : resolution.handle;
|
||||
|
||||
const res = await client.post<TeardownResult | { error: string }>("/api/down", {
|
||||
rigId,
|
||||
delete: opts.delete ?? false,
|
||||
|
||||
@@ -11,6 +11,11 @@ type LaunchResponse = {
|
||||
logicalId?: string;
|
||||
sessionName?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
launched?: Array<{ nodeId: string; logicalId: string; status: string }>;
|
||||
held?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
alreadyRunning?: Array<{ nodeId: string; logicalId: string }>;
|
||||
failedTargets?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
};
|
||||
|
||||
export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
@@ -28,9 +33,11 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Target rig ID")
|
||||
.argument("<nodeRef>", "Node logical ID or node ID")
|
||||
.argument("[nodeRef]", "Node logical ID or node ID (single target)")
|
||||
.option("--seats <ids>", "Comma-separated logical IDs for subset launch")
|
||||
.option("--hold-reason <reason>", "Reason for holding non-target seats")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (rigId: string, nodeRef: string, opts: { json?: boolean }) => {
|
||||
.action(async (rigId: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string }) => {
|
||||
const deps = getDeps();
|
||||
const client = await getClient(deps);
|
||||
if (!client) {
|
||||
@@ -38,7 +45,48 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch`, {});
|
||||
const seatList = opts.seats ? opts.seats.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
|
||||
if (seatList.length > 0) {
|
||||
const body: { seats: string[]; holdReason?: string } = { seats: seatList };
|
||||
if (opts.holdReason) body.holdReason = opts.holdReason;
|
||||
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`, body);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400 || !res.data.ok) {
|
||||
console.error(res.data.error ?? `Launch failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const launchedIds = (res.data.launched ?? []).map((n) => n.logicalId).join(", ");
|
||||
const heldIds = (res.data.held ?? []).map((n) => `${n.logicalId} (${n.reason})`).join(", ");
|
||||
if (launchedIds) console.log(`Launched: ${launchedIds}`);
|
||||
if (heldIds) console.log(`Held: ${heldIds}`);
|
||||
if (res.data.alreadyRunning?.length) console.log(`Already running: ${res.data.alreadyRunning.map((n) => n.logicalId).join(", ")}`);
|
||||
if (res.data.failedTargets?.length) {
|
||||
console.error(`Failed (liveness unknown): ${res.data.failedTargets.map((n) => n.logicalId).join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if ((res.data as Record<string, unknown>).unmatchedIds && ((res.data as Record<string, unknown>).unmatchedIds as string[]).length > 0) {
|
||||
console.error(`Unmatched seats (not found): ${((res.data as Record<string, unknown>).unmatchedIds as string[]).join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeRef) {
|
||||
console.error("Provide a node logical ID or use --seats <a,b> for subset launch");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
if (opts.holdReason) body.holdReason = opts.holdReason;
|
||||
|
||||
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch`, body);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
@@ -52,6 +100,10 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
}
|
||||
|
||||
const logicalId = res.data.logicalId ?? nodeRef;
|
||||
if (res.data.code === "already_running" || (res.data.alreadyRunning && res.data.alreadyRunning.length > 0)) {
|
||||
console.log(`Node ${logicalId} is already running in rig ${rigId} (not relaunched)`);
|
||||
return;
|
||||
}
|
||||
const sessionSuffix = res.data.sessionName ? ` (${res.data.sessionName})` : "";
|
||||
console.log(`Launched node ${logicalId} in rig ${rigId}${sessionSuffix}`);
|
||||
});
|
||||
|
||||
@@ -14,10 +14,23 @@ interface PsEntry {
|
||||
rigName?: string;
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
/** Slice 15 — subset of nodes producing tmux output within the silence
|
||||
* window. Sourced from the daemon's SeatActivityService; NEVER derived
|
||||
* from queue/assignment state. */
|
||||
activeCount?: number;
|
||||
/** Slice 15 — subset of nodes with at least one pending qitem assigned
|
||||
* to their canonical session name. Sourced from queue_items; NEVER
|
||||
* derived from tmux output. */
|
||||
hasWorkCount?: number;
|
||||
status: "running" | "partial" | "stopped";
|
||||
lifecycleState?: "running" | "recoverable" | "stopped" | "degraded" | "attention_required";
|
||||
uptime: string | null;
|
||||
latestSnapshot: string | null;
|
||||
/** OPR.0.3.3.19 - ISO timestamp when archived, or null if active. Present
|
||||
* only when the daemon supports archive; optional for back-compat. */
|
||||
archivedAt?: string | null;
|
||||
/** OPR.0.3.3.19 - convenience flag; true iff the rig is archived. */
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
interface NodeEntry {
|
||||
@@ -36,6 +49,17 @@ interface NodeEntry {
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
latestError: string | null;
|
||||
/** Slice 15 — `terminal-active` primitive. true=producing output,
|
||||
* false=silent past threshold, null=no signal. NEVER derived from
|
||||
* hasAssignedWork (non-inference contract). */
|
||||
terminalActive?: boolean | null;
|
||||
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
|
||||
* NEVER derived from terminalActive. */
|
||||
hasAssignedWork?: boolean;
|
||||
/** Slice 15 — pending qitem count for this seat (cheap aggregate). */
|
||||
pendingWorkCount?: number;
|
||||
/** OPR.0.3.4.11 — held reason from node.held event. */
|
||||
heldReason?: string | null;
|
||||
agentActivity?: {
|
||||
state: "running" | "needs_input" | "idle" | "unknown";
|
||||
reason: string;
|
||||
@@ -110,6 +134,8 @@ const ALLOWED_RIG_FIELDS = new Set([
|
||||
"rigName",
|
||||
"nodeCount",
|
||||
"runningCount",
|
||||
"activeCount",
|
||||
"hasWorkCount",
|
||||
"status",
|
||||
"lifecycleState",
|
||||
"uptime",
|
||||
@@ -131,8 +157,12 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"tmuxAttachCommand",
|
||||
"resumeCommand",
|
||||
"latestError",
|
||||
"terminalActive",
|
||||
"hasAssignedWork",
|
||||
"pendingWorkCount",
|
||||
"agentActivity",
|
||||
"contextUsage",
|
||||
"heldReason",
|
||||
]);
|
||||
|
||||
interface PsCliOptions {
|
||||
@@ -145,6 +175,9 @@ interface PsCliOptions {
|
||||
filter?: string;
|
||||
host?: string;
|
||||
active?: boolean;
|
||||
/** OPR.0.3.3.19 - include archived rigs (default excludes them). Parity
|
||||
* with `rig stream list --include-archived`. */
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
export interface PsDeps extends StatusDeps {
|
||||
@@ -330,6 +363,13 @@ function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[
|
||||
});
|
||||
}
|
||||
|
||||
// OPR.0.3.3.19 - build the /api/ps path, opting archived rigs back in when
|
||||
// --include-archived is set. Default (no flag) leaves the daemon's
|
||||
// archived-excluding default in force.
|
||||
function psApiPath(opts: PsCliOptions): string {
|
||||
return opts.includeArchived ? "/api/ps?includeArchived=true" : "/api/ps";
|
||||
}
|
||||
|
||||
function selectFields<T extends Record<string, unknown>>(entries: T[], fields: string[]): Array<Record<string, unknown>> {
|
||||
return entries.map((e) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -421,6 +461,7 @@ Examples:
|
||||
rig ps --filter name-prefix=demo Filter by rig-name prefix
|
||||
rig ps --nodes Per-node detail (human; truncated to ${HUMAN_NODE_BUDGET})
|
||||
rig ps --nodes --json --limit 50 Bounded per-node JSON envelope
|
||||
rig ps --include-archived Include archived rigs (marked with *); hidden by default
|
||||
rig ps --nodes --active Show only nodes whose agentActivity.state == running (PL-019)
|
||||
rig ps --nodes --filter agentActivity.state=running
|
||||
Same as --active (the explicit form)
|
||||
@@ -434,11 +475,12 @@ Other keys are rejected. agentActivity.state is node-level (use with --nodes);
|
||||
allowed values: running, needs_input, idle, unknown.
|
||||
|
||||
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
|
||||
status, lifecycleState, uptime, latestSnapshot.
|
||||
activeCount, hasWorkCount, status, lifecycleState, uptime, latestSnapshot.
|
||||
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
|
||||
podNamespace, canonicalSessionName, nodeKind, runtime, sessionStatus,
|
||||
startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
|
||||
resumeCommand, latestError, agentActivity. Other keys are rejected.
|
||||
resumeCommand, latestError, terminalActive, hasAssignedWork,
|
||||
pendingWorkCount, agentActivity. Other keys are rejected.
|
||||
\`name\` is rig-level only; for node entries use \`rigName\`. Nested-field
|
||||
projection (e.g. \`agentActivity.state\`) is not supported in this slice; pass
|
||||
\`agentActivity\` to project the whole object and read the nested value
|
||||
@@ -465,6 +507,7 @@ Exit codes:
|
||||
.option("--summary", "Emit aggregate-only output (counts by status/lifecycle)")
|
||||
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name, agentActivity.state")
|
||||
.option("--active", "Shortcut for --filter agentActivity.state=running (PL-019)")
|
||||
.option("--include-archived", "Include archived rigs (default hides them); parity with 'rig stream list --include-archived'")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.action(async (opts: PsCliOptions) => {
|
||||
const deps = getDepsF();
|
||||
@@ -545,7 +588,7 @@ Exit codes:
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await client.get<PsEntry[]>("/api/ps");
|
||||
const res = await client.get<PsEntry[]>(psApiPath(opts));
|
||||
|
||||
if (res.status >= 400) {
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${res.status}). Check daemon status with: rig status`);
|
||||
@@ -608,19 +651,30 @@ Exit codes:
|
||||
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET;
|
||||
|
||||
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
|
||||
const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "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.rigName ?? e.name,
|
||||
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) : "—",
|
||||
e.status,
|
||||
abbrevRigLifecycle(e.lifecycleState),
|
||||
e.uptime ?? "—",
|
||||
e.latestSnapshot ?? "—",
|
||||
));
|
||||
}
|
||||
if (anyArchivedShown) {
|
||||
console.log("* = archived (hidden from the default view; shown via --include-archived). Reverse with: rig unarchive <rig>");
|
||||
}
|
||||
if (humanTruncated) {
|
||||
const remaining = filtered.length - HUMAN_RIG_BUDGET;
|
||||
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_RIG_BUDGET}).`);
|
||||
@@ -642,7 +696,7 @@ async function handleNodes(
|
||||
fields: string[] | null,
|
||||
useEnvelope: boolean,
|
||||
): Promise<void> {
|
||||
const rigRes = await client.get<PsEntry[]>("/api/ps");
|
||||
const rigRes = await client.get<PsEntry[]>(psApiPath(opts));
|
||||
if (rigRes.status >= 400) {
|
||||
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
|
||||
process.exitCode = 2;
|
||||
@@ -704,7 +758,7 @@ async function handleNodes(
|
||||
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
|
||||
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "CTX", "RESTORE", "ERROR");
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "TERMINAL", "WORK", "ACTIVITY", "CTX", "RESTORE", "ERROR");
|
||||
console.log(header);
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const parts = n.logicalId.split(".");
|
||||
@@ -720,10 +774,14 @@ async function handleNodes(
|
||||
n.sessionStatus ?? "—",
|
||||
n.startupStatus ?? "—",
|
||||
abbrevNodeLifecycle(n.lifecycleState),
|
||||
// Slice 15 — terminal-active + has-work as DISTINCT columns
|
||||
// (non-inference contract: never collapsed into one signal).
|
||||
formatTerminalActive(n.terminalActive),
|
||||
formatHasWork(n.hasAssignedWork, n.pendingWorkCount),
|
||||
formatActivity(n.agentActivity),
|
||||
formatContextUsage(n.contextUsage),
|
||||
n.restoreOutcome,
|
||||
n.latestError ? truncate(n.latestError, 30) : "—",
|
||||
n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}` : "—",
|
||||
));
|
||||
}
|
||||
if (humanTruncated) {
|
||||
@@ -752,11 +810,17 @@ function fitCell(value: string, width: number): string {
|
||||
return truncate(value, width).padEnd(width);
|
||||
}
|
||||
|
||||
function padRigRow(rig: string, nodes: string, running: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
|
||||
function padRigRow(rig: string, nodes: string, running: string, active: string, work: 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),
|
||||
fitCell(status, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
fitCell(uptime, 11),
|
||||
@@ -764,7 +828,7 @@ function padRigRow(rig: string, nodes: string, running: string, status: string,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, ctx: string, restore: string, error: string): string {
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, terminal: string, work: string, activity: string, ctx: string, restore: string, error: string): string {
|
||||
return [
|
||||
fitCell(rig, 30),
|
||||
fitCell(pod, 10),
|
||||
@@ -774,6 +838,9 @@ function padNodeRow(rig: string, pod: string, member: string, session: string, r
|
||||
fitCell(status, 10),
|
||||
fitCell(startup, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
// Slice 15 — distinct TERMINAL + WORK columns.
|
||||
fitCell(terminal, 9),
|
||||
fitCell(work, 6),
|
||||
fitCell(activity, 12),
|
||||
fitCell(ctx, 6),
|
||||
fitCell(restore, 10),
|
||||
@@ -781,6 +848,26 @@ function padNodeRow(rig: string, pod: string, member: string, session: string, r
|
||||
].join("");
|
||||
}
|
||||
|
||||
// Slice 15 — render the terminal-active primitive honestly.
|
||||
// `null` (no signal) is rendered distinctly from `false` (silent past
|
||||
// threshold) so an operator can see whether the daemon hasn't observed
|
||||
// the seat yet vs has observed and seen no output.
|
||||
function formatTerminalActive(t: boolean | null | undefined): string {
|
||||
if (t === true) return "active";
|
||||
if (t === false) return "idle";
|
||||
return "—"; // null / undefined → no signal
|
||||
}
|
||||
|
||||
// Slice 15 — render has-work with a hint of how much.
|
||||
// Pending count appended in parens when known and > 1 keeps the column
|
||||
// compact while remaining honest.
|
||||
function formatHasWork(has: boolean | undefined, count: number | undefined): string {
|
||||
if (has === undefined) return "—";
|
||||
if (!has) return "no";
|
||||
if (typeof count === "number" && count > 1) return `${count}`;
|
||||
return "yes";
|
||||
}
|
||||
|
||||
// PL-012: render context-usage as a 5-char cell — "<percent>%" when
|
||||
// known + fresh, "<percent>%*" when known but stale, "??" when unknown.
|
||||
// 4-char width keeps the table compact without truncating two-digit
|
||||
@@ -822,6 +909,7 @@ async function runCrossHostPs(
|
||||
if (opts.fields !== undefined) argv.push("--fields", opts.fields);
|
||||
if (opts.summary) argv.push("--summary");
|
||||
if (opts.filter !== undefined) argv.push("--filter", opts.filter);
|
||||
if (opts.includeArchived) argv.push("--include-archived");
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
@@ -41,6 +42,95 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// OPR.0.3.2.21.FR-4(a) — body input resolution. Three accepted shapes:
|
||||
// --body "<text>" inline (legacy; backtick-prone for raw
|
||||
// multiline content)
|
||||
// --body-file <path> read body content from a file path
|
||||
// (kills the backtick-corruption class)
|
||||
// --body-file - or --body - read body from stdin (pipeline-friendly)
|
||||
//
|
||||
// Exactly one of --body / --body-file must be provided; the resolver throws
|
||||
// a 3-part fact/consequence/action error otherwise.
|
||||
//
|
||||
// stdinReader is dependency-injected so tests can swap it without touching
|
||||
// process.stdin. Default reads UTF-8 from process.stdin until EOF.
|
||||
export interface ResolveBodyOpts {
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
}
|
||||
|
||||
export async function defaultStdinReader(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => { data += chunk; });
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
if (process.stdin.isTTY) {
|
||||
// No pipe is connected to stdin; resolve immediately to empty
|
||||
// rather than blocking forever waiting for data on a TTY. The
|
||||
// empty body then flows through to the daemon's content
|
||||
// validation (queue-repository owns the body contract); the CLI
|
||||
// does not error locally on empty stdin.
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveQueueBody(
|
||||
opts: ResolveBodyOpts,
|
||||
stdinReader: () => Promise<string> = defaultStdinReader,
|
||||
): Promise<string> {
|
||||
const hasInline = opts.body !== undefined && opts.body !== "";
|
||||
const hasFile = opts.bodyFile !== undefined && opts.bodyFile !== "";
|
||||
if (hasInline && hasFile) {
|
||||
const err = new Error("--body and --body-file are mutually exclusive.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Both --body and --body-file were passed; the body source is ambiguous.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass exactly one of --body or --body-file.";
|
||||
throw err;
|
||||
}
|
||||
if (!hasInline && !hasFile) {
|
||||
const err = new Error("Missing required body input.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Neither --body nor --body-file was provided.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass the qitem body via --body \"<text>\" or --body-file <path> (use - for stdin).";
|
||||
throw err;
|
||||
}
|
||||
if (hasInline) {
|
||||
if (opts.body === "-") return stdinReader();
|
||||
return opts.body!;
|
||||
}
|
||||
// hasFile path
|
||||
if (opts.bodyFile === "-") return stdinReader();
|
||||
const absPath = opts.bodyFile!;
|
||||
if (!fs.existsSync(absPath)) {
|
||||
const err = new Error(`--body-file path does not exist: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = `--body-file path does not exist: ${absPath}`;
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Check the path; pass an absolute path; or use --body-file - to read from stdin.";
|
||||
throw err;
|
||||
}
|
||||
const stat = fs.statSync(absPath);
|
||||
if (!stat.isFile()) {
|
||||
const err = new Error(`--body-file path is not a regular file: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = `--body-file path is not a regular file: ${absPath}`;
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass a path to a readable file (not a directory, symlink-to-directory, or block device). Use --body-file - to read from stdin.";
|
||||
throw err;
|
||||
}
|
||||
return fs.readFileSync(absPath, "utf8");
|
||||
}
|
||||
|
||||
function emitBodyResolveError(err: Error & { fact?: string; consequence?: string; action?: string }, json: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact: err.fact ?? err.message, consequence: err.consequence ?? "", action: err.action ?? "" } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.fact ?? err.message}\n${err.consequence ?? ""}\n${err.action ?? ""}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
|
||||
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
@@ -62,10 +152,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.description("Create a new qitem")
|
||||
.requiredOption("--source <session>", "Source session")
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.requiredOption("--body <text>", "Qitem body")
|
||||
.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.")
|
||||
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
|
||||
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.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("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
|
||||
@@ -74,7 +167,10 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
mission?: string;
|
||||
slice?: string;
|
||||
priority: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
@@ -84,14 +180,35 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// OPR.0.3.2.21.FR-4(a) — resolve body BEFORE contacting the daemon
|
||||
// so a missing/ambiguous body fails fast and locally.
|
||||
let resolvedBody: string;
|
||||
try {
|
||||
resolvedBody = await resolveQueueBody({ body: opts.body, bodyFile: opts.bodyFile });
|
||||
} catch (err) {
|
||||
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
|
||||
// translate to canonical mission:<id> / slice:<id> tags. Composes
|
||||
// with --tags (any flag-derived tags prepend; explicit --tags
|
||||
// append). De-duplicates so passing both --mission X and
|
||||
// --tags mission:X yields one mission:X tag.
|
||||
const fromTagsArg = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const fromFlags: string[] = [];
|
||||
if (opts.mission) fromFlags.push(`mission:${opts.mission}`);
|
||||
if (opts.slice) fromFlags.push(`slice:${opts.slice}`);
|
||||
const merged = [...fromFlags, ...fromTagsArg];
|
||||
const seen = new Set<string>();
|
||||
const dedupedTags = merged.filter((t) => { if (seen.has(t)) return false; seen.add(t); return true; });
|
||||
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
|
||||
qitemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
body: resolvedBody,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
@@ -141,7 +258,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("update <qitemId>")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation)")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation). Closure ≠ acceptance: handed_off_to records delivery to the next stage; acceptance is the next stage's verdict on its own qitem, not this closure.")
|
||||
.option("--actor <session>", "Actor session performing the transition (defaults to OPENRIG_SESSION_NAME)")
|
||||
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
|
||||
.option("--closure-reason <reason>", "Required for state=done")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// OPR.0.3.4.3 — rig reconcile-session: adopt a LIVE, hand-resumed canonical
|
||||
// session back into its persisted node WITHOUT launch/relaunch/kill/startup
|
||||
// replay/resume menus/compaction, and WITHOUT writing any input into the pane.
|
||||
// The repair for "I manually resumed the seat; make the daemon see it again."
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ReconcileResponse {
|
||||
ok: boolean;
|
||||
result?: {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
nodeId: string;
|
||||
logicalId: string;
|
||||
sessionName: string;
|
||||
sessionId: string;
|
||||
projectionDrift: string[];
|
||||
continuity: string;
|
||||
};
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function reconcileSessionCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("reconcile-session")
|
||||
.description("Adopt a live, hand-resumed session back into its persisted node (no launch, no input)");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig) of the LIVE session to adopt")
|
||||
.option("--rig <rigId>", "Disambiguate: target rig id (requires --node)")
|
||||
.option("--node <logicalId>", "Disambiguate: target node logical id (requires --rig)")
|
||||
.option("--no-launch", "Never launch/relaunch (the only mode this command has; accepted for explicitness)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig reconcile-session dev-impl@my-rig --no-launch
|
||||
rig reconcile-session dev-impl@my-rig --rig <rig-id> --node dev.impl --no-launch
|
||||
rig reconcile-session dev-impl@my-rig --json
|
||||
|
||||
Use after manually resuming a seat (claude --resume / codex resume) inside its
|
||||
canonical tmux session: the daemon still shows the seat down. Reconcile binds
|
||||
the live process to its OWN persisted node (same node id - no re-key) and
|
||||
updates the projection so rig ps / topology / send / capture / queue routing
|
||||
work again. It NEVER launches, relaunches, kills, replays startup, presses
|
||||
resume menus, compacts, or types into the pane. Anything it could not prove is
|
||||
reported as projection drift; conversation continuity is never claimed.`)
|
||||
.action(async (session: string, opts: { rig?: string; node?: string; launch?: boolean; json?: boolean }) => {
|
||||
if ((opts.rig && !opts.node) || (!opts.rig && opts.node)) {
|
||||
console.error("--rig and --node must be provided together.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (opts.rig) body["rigId"] = opts.rig;
|
||||
if (opts.node) body["logicalId"] = opts.node;
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<ReconcileResponse>(
|
||||
`/api/sessions/${encodeURIComponent(session)}/reconcile`,
|
||||
body,
|
||||
);
|
||||
const data = res.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
if (res.status >= 400 || !data.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400 || !data.ok) {
|
||||
console.error(data.message ?? data.error ?? `Reconcile failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const r = data.result!;
|
||||
console.log(`Reconciled ${r.sessionName} into rig ${r.rigName}`);
|
||||
console.log(` Node: ${r.logicalId} (node id unchanged - no relaunch, no input sent)`);
|
||||
if (r.projectionDrift.length > 0) {
|
||||
console.log(" Projection drift (unproven metadata):");
|
||||
for (const d of r.projectionDrift) console.log(` - ${d}`);
|
||||
} else {
|
||||
console.log(" Projection drift: none detected");
|
||||
}
|
||||
console.log(` Conversation continuity: ${r.continuity} (reconcile reconnects the projection; it does not verify the conversation)`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// Slice 09 (OPR.0.3.2.9) — `rig policy` CLI: operator-context-mode
|
||||
// surface. Pairs with the daemon's typed-primitive store.
|
||||
//
|
||||
// Subcommands:
|
||||
// rig policy set <mode> [--scope ...] [--qualifier ...] [--<field> ...]
|
||||
// [--evidence ...] [--confirm]
|
||||
// → restate-and-confirm; PUT only when --confirm
|
||||
// rig policy show → list all bindings (defaults to JSON-when-piped)
|
||||
// rig policy effective → resolve effective for a read context
|
||||
// rig policy cite → emit the citation line per convention §Component 5
|
||||
// rig policy unset <scope> [qualifier]
|
||||
// → DELETE one binding (operator-only)
|
||||
// rig policy defaults → recommended 6×7 + per-mode scope + stale rule
|
||||
//
|
||||
// HG-4 / HG-7 anchored here:
|
||||
// - `set` never silently applies. Without `--confirm` it echoes the
|
||||
// proposed binding (mode + scope + key settings) and exits with
|
||||
// `exit 2` so scripts cannot accidentally apply. `--confirm` is the
|
||||
// explicit operator action.
|
||||
// - Mode invocation: bare word OR `mode:<mode>` prefix; both normalize
|
||||
// via disambiguateModeInvocation().
|
||||
// - Citation format: short-prose per convention §Component 5 +
|
||||
// §Citation Rules.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export interface RigPolicyDeps extends StatusDeps {}
|
||||
|
||||
// Mirror of daemon enums + structure. We keep these inline so the CLI
|
||||
// doesn't grow a build-time dep on the daemon package; the validator
|
||||
// at the daemon edge is the source of truth, and the CLI sends the
|
||||
// record through unchanged.
|
||||
const MODES = ["sleep", "desk", "mobile", "away", "focus", "debug"] as const;
|
||||
type Mode = (typeof MODES)[number];
|
||||
|
||||
const SCOPES = ["global_host", "rig", "workstream", "qitem"] as const;
|
||||
type Scope = (typeof SCOPES)[number];
|
||||
|
||||
// Convention §Component 4 — bare-word disambiguation. A bare reserved
|
||||
// mode word, or `mode:<word>`, becomes an invocation; embedded-in-
|
||||
// sentence does not (the latter is a CLI input error here since the
|
||||
// CLI takes a single-positional <mode> argument).
|
||||
function disambiguateModeInvocation(raw: string): Mode | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const stripped = trimmed.startsWith("mode:") ? trimmed.slice("mode:".length).trim() : trimmed;
|
||||
if (stripped.split(/\s+/).length !== 1) return null;
|
||||
return (MODES as readonly string[]).includes(stripped) ? (stripped as Mode) : null;
|
||||
}
|
||||
|
||||
interface RecommendedDefaultsResponse {
|
||||
recommendedModeDefaults: Record<Mode, {
|
||||
autonomy_scope: string;
|
||||
heartbeat_cadence: string;
|
||||
inspection_depth: string;
|
||||
update_detail: string;
|
||||
escalation_threshold: string;
|
||||
concurrency_limit: string;
|
||||
permission_prompt_posture: string;
|
||||
}>;
|
||||
recommendedDefaultScope: Record<Mode, Scope>;
|
||||
defaultStaleRule: string;
|
||||
}
|
||||
|
||||
interface BindingResponse {
|
||||
binding: {
|
||||
id: string;
|
||||
mode: Mode;
|
||||
record: Record<string, string>;
|
||||
qualifier: string | null;
|
||||
setAt: string;
|
||||
setBy: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
bindings: Array<BindingResponse["binding"]>;
|
||||
}
|
||||
|
||||
interface EffectiveResponse {
|
||||
effective: { binding: BindingResponse["binding"]; resolvedScope: Scope } | null;
|
||||
posture: "known" | "unknown_posture";
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: RigPolicyDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function emitRecord(label: string, record: Record<string, string>): void {
|
||||
console.log(label);
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the daemon route's parseScopeAndQualifier semantics on the
|
||||
* CLI side. The CLI is the operator's authoring surface; the convention's
|
||||
* scope rule applies at invocation, NOT only at raw HTTP URL parsing.
|
||||
*
|
||||
* Reject:
|
||||
* - explicit qualifier with global_host (BLOCKING re-verify finding from
|
||||
* guard qitem-20260518044650): operators who type
|
||||
* `--scope global_host --qualifier <id>` get an error and the daemon
|
||||
* is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
* - missing qualifier for any non-global scope.
|
||||
*/
|
||||
type NormalizedScope = { ok: true; qualifier: string | null } | { ok: false; message: string };
|
||||
function normalizeScopeQualifier(scope: Scope, explicitQualifier: string | undefined): NormalizedScope {
|
||||
if (scope === "global_host") {
|
||||
if (explicitQualifier !== undefined && explicitQualifier !== "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Global-host bindings cannot carry a qualifier (got "${explicitQualifier}"). Either drop --qualifier OR change --scope to rig / workstream / qitem.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: null };
|
||||
}
|
||||
if (explicitQualifier === undefined || explicitQualifier === "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Scope ${scope} requires a qualifier (rigId / workstreamId / qitemId). Pass --qualifier <id>.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: explicitQualifier };
|
||||
}
|
||||
|
||||
function formatCitation(b: BindingResponse["binding"]): string {
|
||||
const qualifierPart = b.qualifier ? `:${b.qualifier}` : "";
|
||||
const scope = b.record.scope as Scope;
|
||||
return `Operating in \`${b.mode}\` mode at \`${scope}${qualifierPart}\` per operator (set_at ${b.setAt})`;
|
||||
}
|
||||
|
||||
export function rigPolicyCommand(depsOverride?: RigPolicyDeps): Command {
|
||||
const cmd = new Command("policy").description(
|
||||
"Slice 09 — operator-context-mode bindings (sleep/desk/mobile/away/focus/debug × global_host/rig/workstream/qitem).",
|
||||
);
|
||||
|
||||
const getDeps = (): RigPolicyDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
// -- set ---------------------------------------------------------------
|
||||
cmd
|
||||
.command("set <mode>")
|
||||
.description(
|
||||
"Propose a mode binding. Without --confirm, restates the proposed binding and exits 2 (no daemon write). With --confirm, sets it.",
|
||||
)
|
||||
.option("--scope <scope>", `Scope: ${SCOPES.join(" | ")} (default: per-mode recommendation)`)
|
||||
.option("--qualifier <id>", "Required for rig / workstream / qitem scopes; omit for global_host")
|
||||
.option("--autonomy-scope <v>")
|
||||
.option("--heartbeat-cadence <v>")
|
||||
.option("--inspection-depth <v>")
|
||||
.option("--update-detail <v>")
|
||||
.option("--escalation-threshold <v>")
|
||||
.option("--concurrency-limit <v>")
|
||||
.option("--permission-prompt-posture <v>", "One of: normal | batch_for_human | do_not_prompt_unless_blocked (auto_accept is FORBIDDEN by convention).")
|
||||
.option("--expiry-or-stale-rule <v>")
|
||||
.option("--evidence <citation>", "Free-text citation (operator message id, file pointer, chatroom topic, etc.).")
|
||||
.option("--confirm", "Confirm the proposed binding and apply it. Without this flag, set is restate-only.")
|
||||
.option("--bearer <token>", "Operator bearer token (or set OPENRIG_AUTH_BEARER_TOKEN env).")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
modeArg: string,
|
||||
opts: {
|
||||
scope?: string;
|
||||
qualifier?: string;
|
||||
autonomyScope?: string;
|
||||
heartbeatCadence?: string;
|
||||
inspectionDepth?: string;
|
||||
updateDetail?: string;
|
||||
escalationThreshold?: string;
|
||||
concurrencyLimit?: string;
|
||||
permissionPromptPosture?: string;
|
||||
expiryOrStaleRule?: string;
|
||||
evidence?: string;
|
||||
confirm?: boolean;
|
||||
bearer?: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
const mode = disambiguateModeInvocation(modeArg);
|
||||
if (!mode) {
|
||||
console.error(`Unknown mode '${modeArg}'. Allowed: ${MODES.join(", ")} (or 'mode:<name>' prefix).`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// BLOCKING re-verify-2 (qitem-20260518045300): when --scope is
|
||||
// explicit, validate scope + qualifier LOCALLY before touching
|
||||
// the daemon. Otherwise an invalid operator invocation
|
||||
// (e.g. `--scope global_host --qualifier X`) surfaces as
|
||||
// "Daemon not running" when the daemon is down, instead of the
|
||||
// proper local input error. Convention's scope rule applies at
|
||||
// operator invocation, not at daemon HTTP parsing.
|
||||
let explicitScope: Scope | null = null;
|
||||
if (opts.scope !== undefined) {
|
||||
if (!(SCOPES as readonly string[]).includes(opts.scope)) {
|
||||
console.error(`Unknown scope '${opts.scope}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
explicitScope = opts.scope as Scope;
|
||||
const preflight = normalizeScopeQualifier(explicitScope, opts.qualifier);
|
||||
if (!preflight.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: preflight.message }, null, 2));
|
||||
} else {
|
||||
console.error(preflight.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const defaultsRes = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
|
||||
if (defaultsRes.status >= 400) {
|
||||
console.error(JSON.stringify(defaultsRes.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const defaults = defaultsRes.data;
|
||||
const scope = explicitScope ?? defaults.recommendedDefaultScope[mode];
|
||||
// Defensive — the defaults map is from the daemon, so its scope
|
||||
// values are trusted; keep the assertion shape lean.
|
||||
if (!(SCOPES as readonly string[]).includes(scope)) {
|
||||
console.error(`Unknown scope '${scope}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Re-run normalization for the implicit-default-scope case.
|
||||
// (Explicit scope was already preflighted above; this branch is
|
||||
// a no-op for explicit-scope inputs but is needed when the
|
||||
// default scope is e.g. `qitem`/`workstream` and the operator
|
||||
// forgot --qualifier.)
|
||||
const normalized = normalizeScopeQualifier(scope, opts.qualifier);
|
||||
if (!normalized.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const perMode = defaults.recommendedModeDefaults[mode];
|
||||
// Component 3 — exactly the 10 settings fields. `mode` is the
|
||||
// binding's identity (Component 2) and lives at the top level
|
||||
// of the PUT body, NOT inside the record.
|
||||
const record = {
|
||||
autonomy_scope: opts.autonomyScope ?? perMode.autonomy_scope,
|
||||
heartbeat_cadence: opts.heartbeatCadence ?? perMode.heartbeat_cadence,
|
||||
inspection_depth: opts.inspectionDepth ?? perMode.inspection_depth,
|
||||
update_detail: opts.updateDetail ?? perMode.update_detail,
|
||||
escalation_threshold: opts.escalationThreshold ?? perMode.escalation_threshold,
|
||||
concurrency_limit: opts.concurrencyLimit ?? perMode.concurrency_limit,
|
||||
permission_prompt_posture: opts.permissionPromptPosture ?? perMode.permission_prompt_posture,
|
||||
scope,
|
||||
expiry_or_stale_rule: opts.expiryOrStaleRule ?? defaults.defaultStaleRule,
|
||||
evidence_citation: opts.evidence ?? `operator confirmed ${mode}`,
|
||||
};
|
||||
|
||||
if (!opts.confirm) {
|
||||
// Restate-and-confirm (HG-7). No daemon write.
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, proposed: { mode, scope, qualifier, record }, confirm_required: true }, null, 2));
|
||||
} else {
|
||||
console.log(`Proposed binding (restate-and-confirm — NOT applied):`);
|
||||
console.log(` mode: ${mode}`);
|
||||
console.log(` scope: ${scope}${qualifier ? ` (${qualifier})` : ""}`);
|
||||
emitRecord(` record:`, record);
|
||||
console.log(`\nRe-run with --confirm to apply.`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const res = await client.put<BindingResponse | { error: string; errors?: string[] }>(
|
||||
`/api/rig-policy/bindings/${scope}${qualifierPath}`,
|
||||
{ mode, record },
|
||||
{ headers },
|
||||
);
|
||||
if (res.status === 401) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "unauthorized", hint: "Daemon requires operator bearer. Pass --bearer <token> or set OPENRIG_AUTH_BEARER_TOKEN." }, null, 2));
|
||||
} else {
|
||||
console.error("Unauthorized. Daemon requires an operator bearer token.");
|
||||
console.error("Pass --bearer <token> or export OPENRIG_AUTH_BEARER_TOKEN before re-running.");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(`Error setting binding (HTTP ${res.status}):`);
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const body = res.data as BindingResponse;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, binding: body.binding }, null, 2));
|
||||
} else {
|
||||
console.log(`Set: ${body.binding.id}`);
|
||||
console.log(` mode: ${body.binding.mode}`);
|
||||
emitRecord(` record:`, body.binding.record);
|
||||
console.log(` set_by: ${body.binding.setBy}`);
|
||||
console.log(` set_at: ${body.binding.setAt}`);
|
||||
console.log(`\n${formatCitation(body.binding)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- show --------------------------------------------------------------
|
||||
cmd
|
||||
.command("show")
|
||||
.description("List all operator-context-mode bindings.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<ListResponse>("/api/rig-policy/bindings");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.bindings.length === 0) {
|
||||
console.log("No operator-context-mode bindings set.");
|
||||
return;
|
||||
}
|
||||
for (const b of res.data.bindings) {
|
||||
console.log(`${b.id} [${b.mode}] set_at=${b.setAt}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- effective ---------------------------------------------------------
|
||||
cmd
|
||||
.command("effective")
|
||||
.description("Resolve the effective mode for a (rig, workstream, qitem) read context. Q6 unknown_posture surfaced when no binding matches.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.posture === "unknown_posture" || !res.data.effective) {
|
||||
console.log("unknown_posture: no binding matches this read context.");
|
||||
if (res.data.hint) console.log(res.data.hint);
|
||||
return;
|
||||
}
|
||||
const b = res.data.effective.binding;
|
||||
console.log(`Effective: ${b.mode} (resolved scope: ${res.data.effective.resolvedScope})`);
|
||||
emitRecord(` record:`, b.record);
|
||||
console.log(` set_by: ${b.setBy}`);
|
||||
console.log(` set_at: ${b.setAt}`);
|
||||
});
|
||||
});
|
||||
|
||||
// -- cite --------------------------------------------------------------
|
||||
cmd
|
||||
.command("cite")
|
||||
.description("Emit a citation line for the effective mode at a read context. Per convention §Citation Rules.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400 || !res.data.effective) {
|
||||
console.log("Operating without an explicit operator-context-mode binding (unknown_posture).");
|
||||
return;
|
||||
}
|
||||
console.log(formatCitation(res.data.effective.binding));
|
||||
});
|
||||
});
|
||||
|
||||
// -- unset -------------------------------------------------------------
|
||||
cmd
|
||||
.command("unset <scope> [qualifier]")
|
||||
.description("Delete one binding (operator-only). Scope: global_host | rig | workstream | qitem.")
|
||||
.option("--bearer <token>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
scopeArg: string,
|
||||
qualifierArg: string | undefined,
|
||||
opts: { bearer?: string; json?: boolean },
|
||||
) => {
|
||||
if (!(SCOPES as readonly string[]).includes(scopeArg)) {
|
||||
console.error(`Unknown scope '${scopeArg}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const scope = scopeArg as Scope;
|
||||
// BLOCKING re-verify (qitem-20260518044650): never silently drop
|
||||
// an explicit operator-supplied qualifier on global_host on unset.
|
||||
// Same hazard class as set; same shared helper.
|
||||
const normalized = normalizeScopeQualifier(scope, qualifierArg);
|
||||
if (!normalized.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const res = await client.delete<{ removed: boolean }>(`/api/rig-policy/bindings/${scope}${qualifierPath}`, { headers });
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, removed: res.data.removed }, null, 2));
|
||||
} else {
|
||||
console.log(res.data.removed ? `Removed ${scope}${qualifier ? `:${qualifier}` : ""}` : "Nothing to remove.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- defaults ----------------------------------------------------------
|
||||
cmd
|
||||
.command("defaults")
|
||||
.description("Print the recommended per-mode 6×7 + default-scope + stale rule.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
for (const mode of MODES) {
|
||||
console.log(`${mode} (default scope: ${res.data.recommendedDefaultScope[mode]})`);
|
||||
emitRecord(" ", res.data.recommendedModeDefaults[mode] as unknown as Record<string, string>);
|
||||
}
|
||||
console.log(`\ndefault stale rule: ${res.data.defaultStaleRule}`);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Re-export for unit-testability of pure helpers.
|
||||
export const __test__ = { disambiguateModeInvocation, formatCitation, normalizeScopeQualifier };
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
updateFrontmatter,
|
||||
} from "../lib/scope/scope-fs.js";
|
||||
import {
|
||||
renderMissionNotesTemplate,
|
||||
renderMissionTemplate,
|
||||
renderSliceTemplate,
|
||||
titleFromSlug,
|
||||
@@ -558,6 +559,7 @@ function buildMissionCreateCommand(): Command {
|
||||
.option("--template <kind>", `Template: ${MISSION_TEMPLATE_KINDS.join(" | ")} (auto when name matches release-X.Y.Z)`, "")
|
||||
.option("--id <dot-id>", "Explicit dot-ID. Overrides name-pattern inference.")
|
||||
.option("--title <text>", "Display title (defaults to titlecased name)")
|
||||
.option("--no-mission-notes", "Skip the auto-scaffold of MISSION_NOTES.md (rare; default is to scaffold from conventions/mission-notes/TEMPLATE.md)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (rawName: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
@@ -614,12 +616,17 @@ function buildMissionCreateCommand(): Command {
|
||||
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
|
||||
id = inferMissionDotId(name, ordinal);
|
||||
}
|
||||
// Scaffold the mission directory + README.
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
|
||||
// Resolve titles + render templates BEFORE any filesystem side
|
||||
// effects (banked verify-first-then-write — guard catch on
|
||||
// OPR.0.3.2.21.FR-3 qitem-20260601121058: a stale
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH used to throw AFTER
|
||||
// mkdirSync + README write, leaking a half-created mission dir
|
||||
// that future `mission create <same-name>` would reject as
|
||||
// already-existing). Rendering MISSION_NOTES first means a
|
||||
// stale env-var fails before any disk mutation.
|
||||
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
|
||||
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
|
||||
const body = renderMissionTemplate(templateKind, {
|
||||
const readmeBody = renderMissionTemplate(templateKind, {
|
||||
id,
|
||||
slug: name,
|
||||
mission: name,
|
||||
@@ -627,8 +634,36 @@ function buildMissionCreateCommand(): Command {
|
||||
created_date: todayDateISO(),
|
||||
release_version: releaseVersion,
|
||||
});
|
||||
let missionNotesRendered: { rendered: string; resolvedFrom: "env" | "built-in" } | null = null;
|
||||
if (opts.missionNotes !== false) {
|
||||
const r = renderMissionNotesTemplate({
|
||||
mission_id: id,
|
||||
mission_name: title,
|
||||
created_date: todayDateISO(),
|
||||
});
|
||||
missionNotesRendered = { rendered: r.rendered, resolvedFrom: r.resolvedFrom };
|
||||
}
|
||||
// All renders succeeded — safe to touch the filesystem.
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
fs.writeFileSync(readmePath, body, "utf8");
|
||||
fs.writeFileSync(readmePath, readmeBody, "utf8");
|
||||
let missionNotesPath: string | null = null;
|
||||
let missionNotesResolvedFrom: "env" | "built-in" | null = null;
|
||||
if (missionNotesRendered) {
|
||||
missionNotesPath = path.join(absPath, "MISSION_NOTES.md");
|
||||
fs.writeFileSync(missionNotesPath, missionNotesRendered.rendered, "utf8");
|
||||
missionNotesResolvedFrom = missionNotesRendered.resolvedFrom;
|
||||
}
|
||||
const humanLines = [
|
||||
`Created mission ${name}`,
|
||||
` id: ${id}`,
|
||||
` template: ${templateKind}`,
|
||||
` path: ${absPath}`,
|
||||
];
|
||||
if (missionNotesPath) {
|
||||
humanLines.push(` mission-notes: ${missionNotesPath} (template: ${missionNotesResolvedFrom})`);
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
mission: {
|
||||
@@ -637,13 +672,10 @@ function buildMissionCreateCommand(): Command {
|
||||
template: templateKind,
|
||||
path: absPath,
|
||||
readmePath,
|
||||
missionNotesPath,
|
||||
missionNotesResolvedFrom,
|
||||
},
|
||||
}, json, [
|
||||
`Created mission ${name}`,
|
||||
` id: ${id}`,
|
||||
` template: ${templateKind}`,
|
||||
` path: ${absPath}`,
|
||||
]);
|
||||
}, json, humanLines);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
|
||||
@@ -284,5 +284,48 @@ Examples:
|
||||
}
|
||||
});
|
||||
|
||||
// OPR.0.3.4.10 — clear stuck attention_required / failed startup_status.
|
||||
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("--json", "JSON output for agents")
|
||||
.description("Clear stuck attention_required startup status with evidence or operator attestation")
|
||||
.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
|
||||
`)
|
||||
.action(async (session: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running") {
|
||||
console.error("Daemon not running.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
`/api/sessions/${encodeURIComponent(session)}/clear-attention`,
|
||||
opts.reason ? { reason: opts.reason } : {},
|
||||
);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
const code = res.data["code"] as string | undefined;
|
||||
const detail = res.data["detail"] as string | undefined;
|
||||
console.error(`${code ?? "error"}: ${detail ?? String(res.data["error"] ?? "unknown")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const clearedBy = res.data["clearedBy"] as string | undefined;
|
||||
const from = res.data["from"] as string | undefined;
|
||||
console.log(`Cleared ${session}: ${from} -> ready (${clearedBy})`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -139,8 +139,18 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
|
||||
console.log(`Sent to ${session}`);
|
||||
if (opts.verify) {
|
||||
// Legacy line preserved verbatim (existing scripts grep `Verified:`);
|
||||
// the Delivery line below carries the honest three-outcome vocabulary
|
||||
// (OPR.99.0.6.3): `Verified: no` alone collapsed a landed-but-redraw-
|
||||
// raced send into the same line as a miss.
|
||||
const verified = res.data["verified"] as boolean | undefined;
|
||||
console.log(`Verified: ${verified ? "yes" : "no"}`);
|
||||
const outcome = res.data["outcome"] as string | undefined;
|
||||
if (outcome === "delivered") {
|
||||
console.log("Delivery: delivered (message landed; render confirmed)");
|
||||
} else if (outcome === "rendered-unconfirmed") {
|
||||
console.log(`Delivery: rendered-unconfirmed (landed; pane re-render not confirmed - confirm with: rig capture ${session})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -599,6 +599,23 @@ function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.3.04.2 (AC-1): the ONE canonical ordered golden path over EXISTING
|
||||
* verbs - no magic mega-command, no hidden state. `rig setup` prints this as its
|
||||
* next-steps; `rig status`/`rig doctor` only HINT back to it; the durable
|
||||
* reference is docs/reference/getting-started.md. Returns the lines to print.
|
||||
*/
|
||||
export function goldenPathNextSteps(): string[] {
|
||||
return [
|
||||
"Next steps (the guided path; full reference: docs/reference/getting-started.md):",
|
||||
" 1. rig up <rig-spec> Launch a rig (auto-starts the daemon; the kernel boots on daemon-start)",
|
||||
" 2. rig status See daemon port, kernel readiness, and your effective workspace root",
|
||||
" 3. rig workspace doctor Check your workspace is ready",
|
||||
" 4. rig workflow instantiate <name> Start a workflow by its discovered name (e.g. conveyor); 'rig workflow specs' shows built-ins",
|
||||
" 5. rig scope ... Browse the durable mission/slice artifacts the workflow coordinates",
|
||||
];
|
||||
}
|
||||
|
||||
export function setupCommand(depsOverride?: SetupDeps): Command {
|
||||
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
|
||||
|
||||
@@ -632,7 +649,16 @@ export function setupCommand(depsOverride?: SetupDeps): Command {
|
||||
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
|
||||
}
|
||||
|
||||
console.log(`\n${result.ready ? "Setup complete." : "Some steps need attention. Run `rig doctor` for detailed diagnostics."}`);
|
||||
// OPR.0.3.3.04.2 (AC-1): the canonical ordered golden path. `rig setup` is
|
||||
// the primary surface for the new-operator sequence (status/doctor only
|
||||
// HINT back to it; the durable reference is docs/reference/getting-started.md).
|
||||
if (result.ready) {
|
||||
console.log("\nSetup complete.\n");
|
||||
for (const line of goldenPathNextSteps()) console.log(line);
|
||||
} else {
|
||||
console.log("\nSome steps need attention. Run `rig doctor` for detailed diagnostics.");
|
||||
console.log("Once setup is healthy, follow the guided path: docs/reference/getting-started.md");
|
||||
}
|
||||
if (!opts.dryRun && !result.ready) process.exitCode = 1;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
// OPR.0.3.4.1 — rig start: one-command recovery orchestrator.
|
||||
// Sequences existing primitives: daemon-start -> kernel auto-boot observe ->
|
||||
// candidate listing (restore-usable snapshots) -> picker/flags ->
|
||||
// per-rig restore (slice-02 /api/up) + reconcile (slice-03). Re-codes nothing.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, DaemonConnectionError } from "../client.js";
|
||||
import {
|
||||
getDaemonStatus,
|
||||
getDaemonUrl,
|
||||
startDaemon,
|
||||
waitForKernelReady,
|
||||
type LifecycleDeps,
|
||||
} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
const KERNEL_WAIT_MS = 60_000;
|
||||
|
||||
interface RigSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
nodeCount?: number;
|
||||
lifecycleState?: string;
|
||||
}
|
||||
|
||||
interface PlanPreviewNode {
|
||||
logicalId: string;
|
||||
intendedAction: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface PlanPreviewResponse {
|
||||
status: string;
|
||||
mode: string;
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
snapshot: { id: string; kind: string; createdAt: string } | null;
|
||||
wouldCaptureCurrentState: boolean;
|
||||
nodes: PlanPreviewNode[];
|
||||
mutated: boolean;
|
||||
}
|
||||
|
||||
export interface StartCandidate {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
lifecycleState: string;
|
||||
nodeCount: number;
|
||||
lastActivity: string | null;
|
||||
preview: PlanPreviewResponse | null;
|
||||
}
|
||||
|
||||
/** Default interactive [y/N] prompt (same shape as up.ts, reused for the TTY path). */
|
||||
async function defaultPromptYesNo(question: string): Promise<boolean> {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(/^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal spacebar multi-select picker for TTY (NET-NEW, lightweight). */
|
||||
async function multiSelectPicker(items: Array<{ label: string; value: string; checked: boolean }>): Promise<string[]> {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.close();
|
||||
|
||||
const { createInterface } = readline;
|
||||
return new Promise<string[]>((resolve) => {
|
||||
const state = items.map((item) => ({ ...item }));
|
||||
let cursor = 0;
|
||||
|
||||
const render = () => {
|
||||
process.stdout.write("\x1b[?25l");
|
||||
for (let i = 0; i < state.length; i++) {
|
||||
const prefix = i === cursor ? ">" : " ";
|
||||
const check = state[i]!.checked ? "[x]" : "[ ]";
|
||||
process.stdout.write(`\r${prefix} ${check} ${state[i]!.label}\n`);
|
||||
}
|
||||
process.stdout.write(`\r (space=toggle, enter=confirm, a=all, n=none)\n`);
|
||||
process.stdout.write(`\x1b[${state.length + 1}A`);
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
resolve(state.filter((s) => s.checked).map((s) => s.value));
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write(`\x1b[${state.length + 1}B\r`);
|
||||
process.stdout.write("\x1b[?25h");
|
||||
};
|
||||
|
||||
const onData = (buf: Buffer) => {
|
||||
const key = buf.toString();
|
||||
if (key === " ") {
|
||||
state[cursor]!.checked = !state[cursor]!.checked;
|
||||
render();
|
||||
} else if (key === "\r" || key === "\n") {
|
||||
cleanup();
|
||||
resolve(state.filter((s) => s.checked).map((s) => s.value));
|
||||
} else if (key === "\x1b[A" || key === "k") {
|
||||
cursor = Math.max(0, cursor - 1);
|
||||
render();
|
||||
} else if (key === "\x1b[B" || key === "j") {
|
||||
cursor = Math.min(state.length - 1, cursor + 1);
|
||||
render();
|
||||
} else if (key === "a") {
|
||||
state.forEach((s) => { s.checked = true; });
|
||||
render();
|
||||
} else if (key === "n") {
|
||||
state.forEach((s) => { s.checked = false; });
|
||||
render();
|
||||
} else if (key === "\x03") {
|
||||
cleanup();
|
||||
process.exitCode = 130;
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
export interface StartDeps extends StatusDeps {
|
||||
promptYesNo?: (question: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function startCommand(depsOverride?: StartDeps): Command {
|
||||
const cmd = new Command("start")
|
||||
.description("Start the daemon, verify kernel, and restore rigs that were last running")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig start Interactive: daemon + kernel + pick-and-restore
|
||||
rig start --last Headless: restore everything that was running
|
||||
rig start --all Headless: restore all rigs with restore-usable snapshots
|
||||
rig start --rigs prod-rig dev-rig Headless: restore only the named rigs
|
||||
`);
|
||||
const getDepsF = (): StartDeps =>
|
||||
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--last", "Headless: restore all rigs that were last running (zero prompts)")
|
||||
.option("--all", "Headless: restore all rigs with restore-usable snapshots (zero prompts)")
|
||||
.option("--rigs <names...>", "Headless: restore only the named rigs (zero prompts)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { last?: boolean; all?: boolean; rigs?: string[]; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// ---- PHASE 1: ensure daemon is running ----
|
||||
let status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running") {
|
||||
if (!opts.json) console.log("Starting daemon...");
|
||||
try {
|
||||
const { ConfigStore } = await import("../config-store.js");
|
||||
const configStore = new ConfigStore();
|
||||
const resolvedConfig = configStore.resolve();
|
||||
const hostResolution = configStore.resolveWithSource("daemon.host");
|
||||
const hostForDaemon = hostResolution.source === "default"
|
||||
? undefined
|
||||
: resolvedConfig.daemon.host;
|
||||
const { SystemPreflight } = await import("../system-preflight.js");
|
||||
const { execSync } = await import("node:child_process");
|
||||
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
|
||||
const preflight = new SystemPreflight({
|
||||
exec: async (cmd: string) =>
|
||||
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
configStore,
|
||||
getDaemonStatus: () => getDaemonStatus(deps.lifecycleDeps),
|
||||
openrigHome: OPENRIG_DIR,
|
||||
});
|
||||
const preflightResult = await preflight.run();
|
||||
if (!preflightResult.ready) {
|
||||
for (const check of preflightResult.checks.filter((c) => !c.ok)) {
|
||||
console.error(` ${check.name}: ${check.error}`);
|
||||
if (check.fix) console.error(` Fix: ${check.fix}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await startDaemon({
|
||||
port: resolvedConfig.daemon.port,
|
||||
host: hostForDaemon,
|
||||
db: resolvedConfig.db.path,
|
||||
transcriptsEnabled: resolvedConfig.transcripts.enabled,
|
||||
transcriptsPath: resolvedConfig.transcripts.path,
|
||||
transcriptsLines: resolvedConfig.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: resolvedConfig.transcripts.pollIntervalSeconds,
|
||||
workspaceRoot: resolvedConfig.workspace.root,
|
||||
}, deps.lifecycleDeps);
|
||||
status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
} catch (err) {
|
||||
console.error(`Daemon start failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon is not healthy. Check with: rig daemon status");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = getDaemonUrl(status);
|
||||
const client = deps.clientFactory(baseUrl);
|
||||
|
||||
// ---- PHASE 2: kernel invariant — verify/await kernel readiness ----
|
||||
if (!opts.json) console.log("Waiting for kernel...");
|
||||
const kernelResult = await waitForKernelReady(baseUrl, KERNEL_WAIT_MS);
|
||||
if (!kernelResult.ok) {
|
||||
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"}`);
|
||||
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;
|
||||
return;
|
||||
}
|
||||
} else if (!opts.json) {
|
||||
console.log("Kernel ready.");
|
||||
}
|
||||
|
||||
// Verify UI is actually serving before announcing the URL.
|
||||
let uiUrl: string | null = null;
|
||||
try {
|
||||
const healthRes = await fetch(`${baseUrl}/healthz`);
|
||||
if (healthRes.ok) {
|
||||
uiUrl = baseUrl;
|
||||
}
|
||||
} catch { /* UI not serving yet — degrade gracefully */ }
|
||||
if (uiUrl && !opts.json) {
|
||||
console.log(`UI: ${uiUrl}`);
|
||||
}
|
||||
|
||||
// ---- PHASE 3: list last-running candidates ----
|
||||
let allSummaries: RigSummary[];
|
||||
try {
|
||||
const res = await client.get<RigSummary[]>("/api/rigs/summary");
|
||||
allSummaries = res.data ?? [];
|
||||
} catch {
|
||||
console.error("Failed to list rigs. Daemon may not be ready.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard BLOCKING f359e3a3: use the id-based Explorer route for candidate
|
||||
// preview (POST /api/rigs/:id/up with plan:true) instead of the name-based
|
||||
// POST /api/up which 409s on same-name rigs and silently drops them.
|
||||
const candidates: StartCandidate[] = [];
|
||||
// Guard BLOCKING bfed4ce3: track preview errors so they are not collapsed
|
||||
// into a clean empty-candidate result.
|
||||
const previewErrors: Array<{ rigId: string; rigName: string; code: string; message: string }> = [];
|
||||
for (const rig of allSummaries) {
|
||||
if (rig.name === "kernel") continue;
|
||||
if (rig.lifecycleState === "running") continue;
|
||||
|
||||
try {
|
||||
const planRes = await client.post<Record<string, unknown>>(
|
||||
`/api/rigs/${encodeURIComponent(rig.id)}/up`,
|
||||
{ plan: true },
|
||||
);
|
||||
if (planRes.status === 200 && planRes.data["status"] === "plan") {
|
||||
const preview = planRes.data as unknown as PlanPreviewResponse;
|
||||
|
||||
let lastActivity: string | null = null;
|
||||
try {
|
||||
const rigDetail = await client.get<{ sessions?: Array<{ lastSeenAt?: string | null }> }>(`/api/rigs/${rig.id}`);
|
||||
const sessions = rigDetail.data?.sessions ?? [];
|
||||
const dates = sessions.map((s) => s.lastSeenAt).filter((d): d is string => !!d).sort();
|
||||
lastActivity = dates.length > 0 ? dates[dates.length - 1]! : null;
|
||||
} catch { /* degrade: lastActivity stays null */ }
|
||||
|
||||
candidates.push({
|
||||
rigId: rig.id,
|
||||
rigName: rig.name,
|
||||
lifecycleState: rig.lifecycleState ?? "unknown",
|
||||
nodeCount: rig.nodeCount ?? 0,
|
||||
lastActivity,
|
||||
preview,
|
||||
});
|
||||
} else if (planRes.status === 404) {
|
||||
// no_snapshot or not found — this rig is not a candidate (expected exclusion).
|
||||
} else {
|
||||
const code = String(planRes.data["code"] ?? `http_${planRes.status}`);
|
||||
const errorText = String(planRes.data["error"] ?? "preview failed");
|
||||
previewErrors.push({ rigId: rig.id, rigName: rig.name, code, message: errorText });
|
||||
if (!opts.json) console.error(` ${rig.name}: candidate preview failed -- ${errorText} (${code})`);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
previewErrors.push({ rigId: rig.id, rigName: rig.name, code: "transport_error", message: msg });
|
||||
if (!opts.json) console.error(` ${rig.name}: candidate preview unavailable (transport error)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
status: previewErrors.length > 0 ? "started_with_errors" : "started",
|
||||
candidates: [],
|
||||
restoredRigs: [],
|
||||
previewErrors: previewErrors.length > 0 ? previewErrors : undefined,
|
||||
}));
|
||||
} else {
|
||||
if (previewErrors.length > 0) {
|
||||
console.error(`${previewErrors.length} rig(s) failed preview (see errors above). Some candidates may be missing.`);
|
||||
} else {
|
||||
console.log("Daemon and kernel are up. No rigs to restore.");
|
||||
}
|
||||
}
|
||||
if (previewErrors.length > 0) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- PHASE 4: selection (id-grounded, rev1 BLOCKING b4c6ada4) ----
|
||||
let selectedCandidates: StartCandidate[];
|
||||
if (opts.all || opts.last) {
|
||||
selectedCandidates = [...candidates];
|
||||
} else if (opts.rigs) {
|
||||
selectedCandidates = candidates.filter((c) => opts.rigs!.includes(c.rigName));
|
||||
const missing = opts.rigs.filter((n) => !candidates.some((c) => c.rigName === n));
|
||||
if (missing.length > 0) {
|
||||
console.error(`Rig(s) not found in candidate set: ${missing.join(", ")}`);
|
||||
console.error(`Available candidates: ${candidates.map((c) => c.rigName).join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// TTY interactive: offer fast default + picker
|
||||
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!interactive) {
|
||||
console.error("No TTY available. Use --last, --all, or --rigs <names> for headless mode.");
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ status: "started", candidates: candidates.map((c) => ({ rigName: c.rigName, lifecycleState: c.lifecycleState })), restoredRigs: [] }));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${candidates.length} rig(s) were last running:\n`);
|
||||
for (const c of candidates) {
|
||||
const readiness = summarizeReadiness(c);
|
||||
const activity = c.lastActivity ? `last active ${c.lastActivity}` : "";
|
||||
console.log(` ${c.rigName} (${c.nodeCount} seats, ${c.lifecycleState}${activity ? ", " + activity : ""}) ${readiness}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
const ask = depsOverride?.promptYesNo ?? defaultPromptYesNo;
|
||||
const restoreAll = await ask(`Restore all ${candidates.length} rig(s)? [Y/pick] `);
|
||||
|
||||
if (restoreAll) {
|
||||
selectedCandidates = [...candidates];
|
||||
} else {
|
||||
const pickedIds = await multiSelectPicker(
|
||||
candidates.map((c) => ({
|
||||
label: `${c.rigName} (${c.nodeCount} seats, ${c.lifecycleState}) ${summarizeReadiness(c)}`,
|
||||
value: c.rigId,
|
||||
checked: true,
|
||||
})),
|
||||
);
|
||||
selectedCandidates = candidates.filter((c) => pickedIds.includes(c.rigId));
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCandidates.length === 0) {
|
||||
if (!opts.json) console.log("No rigs selected for restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- PHASE 5: restore each selected rig via id-based route (compose slice-02 path) ----
|
||||
if (!opts.json) console.log(`\nRestoring ${selectedCandidates.length} rig(s)...\n`);
|
||||
|
||||
const results: Array<{ rigName: string; status: string; nodes: Array<{ logicalId: string; status: string; error?: string }> }> = [];
|
||||
|
||||
for (const candidate of selectedCandidates) {
|
||||
const rigName = candidate.rigName;
|
||||
const rigId = candidate.rigId;
|
||||
|
||||
// Check if the rig is already running (idempotent rerun: reconcile, don't relaunch).
|
||||
try {
|
||||
const freshSummary = await client.get<RigSummary[]>("/api/rigs/summary");
|
||||
const current = (freshSummary.data ?? []).find((r) => r.id === rigId);
|
||||
if (current?.lifecycleState === "running") {
|
||||
if (!opts.json) console.log(` ${rigName}: already running (skipping)`);
|
||||
results.push({ rigName, status: "already_running", nodes: [] });
|
||||
continue;
|
||||
}
|
||||
} catch { /* proceed with restore attempt */ }
|
||||
|
||||
try {
|
||||
// Rev1 BLOCKING b4c6ada4: use id-based route for restore too (not
|
||||
// name-based /api/up which 409s on same-name rigs).
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/up`,
|
||||
{ plan: false },
|
||||
{ timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Guard BLOCKING 25661f72: check HTTP status before treating the response
|
||||
// as a success. The daemon returns non-2xx JSON payloads (rig_not_stopped,
|
||||
// pre_restore_validation_failed, ambiguous_name) without throwing.
|
||||
if (res.status >= 400) {
|
||||
const code = res.data["code"] as string | undefined;
|
||||
const errorText = String(res.data["error"] ?? "restore failed");
|
||||
if (code === "pre_restore_validation_failed") {
|
||||
console.error(` ${rigName}: restore blocked (pre-validation failed)`);
|
||||
const blockers = (res.data["blockers"] as Array<{ message: string; remediation: string }>) ?? [];
|
||||
for (const b of blockers) {
|
||||
console.error(` ${b.message}`);
|
||||
console.error(` fix: ${b.remediation}`);
|
||||
}
|
||||
} else {
|
||||
console.error(` ${rigName}: ${errorText} (${code ?? `HTTP ${res.status}`})`);
|
||||
}
|
||||
results.push({ rigName, status: code ?? "error", nodes: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const resStatus = res.data["status"] as string;
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
|
||||
const rigResult = res.data["rigResult"] as string | undefined;
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(` ${rigName}: ${rigResult ?? resStatus}`);
|
||||
for (const n of nodes) {
|
||||
if (n.status === "awaiting-decision" && n.error) {
|
||||
console.log(` ${n.logicalId}: awaiting-decision -- ${n.error}`);
|
||||
} else {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the awaiting-decision ASK (compose the slice-02 TTY flow).
|
||||
const awaiting = nodes.filter((n) => n.status === "awaiting-decision");
|
||||
if (awaiting.length > 0) {
|
||||
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || Boolean(depsOverride?.promptYesNo);
|
||||
if (interactive) {
|
||||
const ask = depsOverride?.promptYesNo ?? defaultPromptYesNo;
|
||||
const accepted: string[] = [];
|
||||
for (const n of awaiting) {
|
||||
const reason = n.error ?? "original session unresumable";
|
||||
const yes = await ask(` Couldn't resume ${n.logicalId} (reason: ${reason}). Fresh-prime? [y/N] `);
|
||||
if (yes) accepted.push(n.logicalId);
|
||||
}
|
||||
if (accepted.length > 0) {
|
||||
try {
|
||||
const freshRes = await client.post<Record<string, unknown>>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/up`,
|
||||
{ plan: false, freshLogicalIds: accepted },
|
||||
{ timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
|
||||
);
|
||||
if (freshRes.status >= 400) {
|
||||
const freshError = String(freshRes.data["error"] ?? "fresh-prime failed");
|
||||
console.error(` Fresh-prime for ${rigName}: ${freshError} (HTTP ${freshRes.status})`);
|
||||
} else {
|
||||
const freshNodes = (freshRes.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
for (const fn of freshNodes.filter((fn) => accepted.includes(fn.logicalId))) {
|
||||
if (!opts.json) console.log(` ${fn.logicalId}: ${fn.status}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError) {
|
||||
console.error(` Fresh-prime timed out for ${rigName}; the daemon may still be processing. Verify with: rig ps`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else if (!opts.json) {
|
||||
console.log(` No fresh sessions started for ${rigName}.`);
|
||||
}
|
||||
} else {
|
||||
// Headless: report honestly, no auto-substitute.
|
||||
for (const n of awaiting) {
|
||||
if (!opts.json) console.error(` ${n.logicalId}: awaiting-decision -- to fresh-prime: rig up --existing ${rigName} --fresh ${n.logicalId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ rigName, status: rigResult ?? resStatus, nodes });
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError) {
|
||||
console.error(` ${rigName}: timed out; the daemon may still be processing. Verify with: rig ps`);
|
||||
results.push({ rigName, status: "timeout", nodes: [] });
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ${rigName}: ${msg}`);
|
||||
results.push({ rigName, status: "error", nodes: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
status: previewErrors.length > 0 ? "started_with_errors" : "started",
|
||||
uiUrl,
|
||||
candidates: candidates.map((c) => ({ rigName: c.rigName, lifecycleState: c.lifecycleState })),
|
||||
restoredRigs: results,
|
||||
previewErrors: previewErrors.length > 0 ? previewErrors : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// Exit code: 1 if any rig has non-clean outcome. Include HTTP error codes
|
||||
// and non-clean rigResult values (partially_restored, failed, not_attempted).
|
||||
const NON_CLEAN_STATUSES = new Set(["timeout", "error", "skipped", "partially_restored", "failed", "not_attempted", "rig_not_stopped", "ambiguous_name", "pre_restore_validation_failed"]);
|
||||
const hasFailure = previewErrors.length > 0 || results.some((r) =>
|
||||
NON_CLEAN_STATUSES.has(r.status) ||
|
||||
r.nodes.some((n) => n.status === "failed" || n.status === "awaiting-decision"),
|
||||
);
|
||||
if (hasFailure) process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function summarizeReadiness(c: StartCandidate): string {
|
||||
if (!c.preview) return "";
|
||||
const actions = c.preview.nodes.map((n) => n.intendedAction);
|
||||
if (actions.every((a) => a === "resume-original")) return "[ready to resume]";
|
||||
if (actions.some((a) => a === "awaiting-decision")) return "[will ask before fresh]";
|
||||
if (actions.every((a) => a === "fresh-primed")) return "[fresh start]";
|
||||
return "[mixed]";
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type LifecycleDeps,
|
||||
} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
|
||||
export interface StatusDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
@@ -52,14 +53,39 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
// Fetch summary + cmux status
|
||||
const [summaryRes, cmuxRes] = await Promise.all([
|
||||
// Fetch summary + cmux + kernel readiness
|
||||
const [summaryRes, cmuxRes, kernelRes] = await Promise.all([
|
||||
client.get<Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null }>>("/api/rigs/summary"),
|
||||
client.get<{ available: boolean }>("/api/adapters/cmux/status").catch(() => null),
|
||||
client.get<{ kernel_state?: string; error?: string }>("/api/kernel/status").catch(() => null),
|
||||
]);
|
||||
|
||||
console.log(`Daemon running on port ${status.port}`);
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-2): kernel readiness is a DISTINCT signal from daemon
|
||||
// health - the kernel rig auto-boots on daemon-start, and a daemon can be up
|
||||
// while the kernel is not yet ready (or a kernel agent is unhealthy). Surface
|
||||
// it as "here's what's currently true," never as a guarantee that downstream
|
||||
// agents are healthy.
|
||||
if (kernelRes && kernelRes.status === 200 && kernelRes.data?.kernel_state) {
|
||||
console.log(`Kernel: ${kernelRes.data.kernel_state} (boots on daemon-start; distinct from daemon health)`);
|
||||
} else if (kernelRes && kernelRes.status === 503) {
|
||||
console.log("Kernel: not tracked (no kernel-boot tracker wired)");
|
||||
} else {
|
||||
console.log("Kernel: unknown (status unavailable)");
|
||||
}
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-2 / gap #7): surface WHICH workspace root is effective
|
||||
// and whether it is the default or an override - the operator never guesses.
|
||||
// This reports what is currently live, NOT that any root is the right one.
|
||||
try {
|
||||
const resolved = new ConfigStore().resolveWithSource("workspace.root");
|
||||
const origin = resolved.source === "default" ? "default" : `override via ${resolved.source}`;
|
||||
console.log(`Workspace root: ${resolved.value} (${origin})`);
|
||||
} catch {
|
||||
// config resolution unavailable - omit rather than guess.
|
||||
}
|
||||
|
||||
if (summaryRes.status !== 200) {
|
||||
console.error(`Failed to fetch rig summary (HTTP ${summaryRes.status})`);
|
||||
process.exitCode = 1;
|
||||
@@ -80,6 +106,13 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
// cmux status
|
||||
const cmuxAvailable = cmuxRes?.data?.available ?? false;
|
||||
console.log(`cmux: ${cmuxAvailable ? "available" : "unavailable"}`);
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-1): reinforcing HINT back to the one canonical ordered
|
||||
// path - status does not re-author the sequence (that lives in `rig setup`
|
||||
// next-steps + docs/reference/getting-started.md).
|
||||
if (rigs.length === 0) {
|
||||
console.log("\nNext: launch a rig with `rig up <rig-spec>`. Guided path: `rig setup` output or docs/reference/getting-started.md");
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface UnarchiveResult {
|
||||
ok: boolean;
|
||||
rigId: string;
|
||||
unarchived?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig unarchive <rigId>` - OPR.0.3.3.19. Reverse of `rig archive`: clears the
|
||||
* `archived_at` flag so the rig returns to the default explorer + `rig ps` view.
|
||||
* Always non-destructive (the row and snapshots were retained while archived);
|
||||
* no `--force` and no running-rig guard - unarchiving only makes a rig visible.
|
||||
*/
|
||||
export function unarchiveCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("unarchive").description(
|
||||
"Unarchive a rig (reverse of 'rig archive'): returns it to the default view. Always safe.",
|
||||
);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Rig identifier to unarchive")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (rigId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<UnarchiveResult | { error: string }>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/unarchive`,
|
||||
{},
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
console.error(`Rig not found: ${rigId}. List archived rigs with: rig ps --include-archived`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(`Unarchive failed (HTTP ${res.status}).`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const r = res.data as UnarchiveResult;
|
||||
if (r.unarchived) {
|
||||
console.log(`Rig ${rigId} unarchived. It is back in the default view.`);
|
||||
} else {
|
||||
console.log(`Rig ${rigId} was not archived.`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
+224
-12
@@ -1,16 +1,40 @@
|
||||
import nodePath from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, DaemonConnectionError } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** OPR.0.3.4.2 — default interactive [y/N] prompt for the awaiting-decision
|
||||
* ASK (TTY only; tests inject promptYesNo instead). Default answer: No. */
|
||||
async function defaultPromptYesNo(question: string): Promise<boolean> {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(/^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 helper — mirrors cwd-resolution.isPathInsideRoot in
|
||||
// the daemon so the CLI can decide whether a path-form sourceRef lives
|
||||
// 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));
|
||||
}
|
||||
|
||||
export function upCommand(
|
||||
depsOverride?: StatusDeps & {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
preflightExec?: (cmd: string) => Promise<string>;
|
||||
/** OPR.0.3.4.2 — injectable [y/N] prompt for the awaiting-decision ASK
|
||||
* (tests drive it; default = readline on stdin, offered on TTY only). */
|
||||
promptYesNo?: (question: string) => Promise<boolean>;
|
||||
},
|
||||
): Command {
|
||||
const cmd = new Command("up")
|
||||
@@ -30,8 +54,9 @@ Examples:
|
||||
.option("--cwd <path>", "Override launch working directory for all members for this run only")
|
||||
.option("--target <root>", "Target root directory for package installation (.rigbundle only; does not change agent cwd)")
|
||||
.option("--existing", "Treat <source> as an existing rig name; bypass library-spec name resolution")
|
||||
.option("--fresh <seats...>", "Deliberately fresh-prime the named seats (logical ids) instead of resuming their original sessions (operation B; reported as fresh-primed)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; json?: boolean }) => {
|
||||
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; fresh?: string[]; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// Run preflight before auto-start
|
||||
@@ -132,6 +157,54 @@ Examples:
|
||||
}
|
||||
return rigSummariesCache;
|
||||
};
|
||||
// OPR.0.3.3.19 (AC-7): archived rigs are excluded from default `rig up`
|
||||
// name resolution. If <source> matches ONLY an archived rig (no active
|
||||
// rig of that name), refuse with an honest error pointing at
|
||||
// `rig unarchive` - never silently restore an archived rig, never
|
||||
// silently fall through. Applies to both default and --existing paths.
|
||||
if (isRigName) {
|
||||
const activeSummaries = await fetchRigSummaries();
|
||||
const activeMatch = activeSummaries.some((r) => r.name === source);
|
||||
if (!activeMatch) {
|
||||
try {
|
||||
const archRes = await client.get<Array<{ id: string; name: string }>>(
|
||||
"/api/rigs/summary?archived=only",
|
||||
);
|
||||
const archivedMatches = (archRes.data ?? []).filter((r) => r.name === source);
|
||||
if (archivedMatches.length > 0) {
|
||||
// `rig unarchive` resolves by rig ID, not name (it posts to
|
||||
// /api/rigs/<rigId>/unarchive), so the remediation MUST name the
|
||||
// id - telling the operator `rig unarchive <name>` would 404. If
|
||||
// the name is ambiguous across multiple archived rigs, surface the
|
||||
// id list rather than guessing a single target.
|
||||
const ids = archivedMatches.map((r) => r.id);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
error: "rig_archived",
|
||||
rig: source,
|
||||
archivedRigIds: ids,
|
||||
action: ids.length === 1
|
||||
? `rig unarchive ${ids[0]}`
|
||||
: `rig unarchive <rigId> (archived rigs named '${source}': ${ids.join(", ")})`,
|
||||
}));
|
||||
} else if (ids.length === 1) {
|
||||
console.error(`Rig "${source}" is archived, so it is hidden from 'rig up' name resolution.`);
|
||||
console.error(` Bring it back first: rig unarchive ${ids[0]}`);
|
||||
console.error(` Then power it on: rig up ${source}`);
|
||||
} else {
|
||||
console.error(`${ids.length} archived rigs are named "${source}"; they are hidden from 'rig up' name resolution.`);
|
||||
console.error(` Unarchive the one you want by id (then 'rig up'):`);
|
||||
for (const id of ids) console.error(` rig unarchive ${id}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Archived-summary probe failed (e.g. older daemon) - fall through
|
||||
// to normal resolution; there are no archive semantics to enforce.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isRigName && !opts.existing) {
|
||||
try {
|
||||
const { resolveLibrarySpec } = await import("./specs.js");
|
||||
@@ -189,13 +262,78 @@ Examples:
|
||||
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
|
||||
const targetRoot = opts.target ?? (isRigBundle ? process.cwd() : undefined);
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
// OPR.0.3.2.22 Bug 3 — extend the bare `rig up <builtin>` default-cwd
|
||||
// treatment to path-form. Builtin starter specs declare member-level
|
||||
// cwd: "." which resolves to the spec directory (inside the daemon's
|
||||
// install root). Without --cwd that trips getOpenRigInstallCwdError
|
||||
// at preflight. Bare-name form is already rescued at the
|
||||
// resolveLibrarySpec branch above (entry.sourceType === "builtin");
|
||||
// path-form `rig up <install-internal-spec>` is the remaining gap.
|
||||
//
|
||||
// Detection: source is path-form (not isRigName, not isRigBundle),
|
||||
// no --cwd was given, no defaultLibraryCwdOverride was set by the
|
||||
// bare-name branch above, AND the resolved path lives inside the
|
||||
// daemon's install root (fetched via /api/info). When all hold,
|
||||
// default cwdOverride to process.cwd() so the operator's project
|
||||
// dir is used as launch cwd, and print a one-line notice. If
|
||||
// /api/info is unavailable, fall through silently — the daemon
|
||||
// preflight will still surface the install-cwd error.
|
||||
//
|
||||
// Structural redesign of how builtin starter specs declare cwd is
|
||||
// out of scope (deferred to 0.3.3 per the slice triage).
|
||||
if (!opts.cwd && !isRigName && !isRigBundle && defaultLibraryCwdOverride === undefined) {
|
||||
try {
|
||||
// Short timeout: a healthy daemon answers /api/info in <100ms; if
|
||||
// it doesn't, fall through to the daemon's own preflight error
|
||||
// rather than adding a multi-second stall to every rig up.
|
||||
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { timeoutMs: 2000 });
|
||||
const installRoot = infoRes.data?.installRoot;
|
||||
if (installRoot && isPathInsideRoot(sourceRef, installRoot)) {
|
||||
defaultLibraryCwdOverride = process.cwd();
|
||||
if (!opts.json) {
|
||||
console.log("Defaulting cwd to current directory because the spec lives inside the OpenRig install.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /api/info unavailable — fall through. The daemon preflight
|
||||
// returns getOpenRigInstallCwdError if this is in fact the
|
||||
// install-internal case; the operator gets the same hint they
|
||||
// would have gotten pre-Bug-3.
|
||||
}
|
||||
}
|
||||
|
||||
// OPR.0.3.4.4 — honest async: a client timeout / connection loss in
|
||||
// APPLY mode does NOT mean the operation failed; the daemon may still
|
||||
// be processing. Report in-progress/unknown with a verify command,
|
||||
// never a bare connection failure (the false-failure that caused the
|
||||
// outage's wrong next move). No operation id is surfaced client-side
|
||||
// on timeout today, so the honest message is the MVP floor.
|
||||
const printHonestTimeout = (err: DaemonConnectionError): void => {
|
||||
console.error(`The CLI timed out waiting for the daemon, but the operation may STILL BE IN PROGRESS.`);
|
||||
console.error(`This does not mean the operation failed; the daemon may still be processing it.`);
|
||||
console.error(`Verify the actual state with: rig ps`);
|
||||
console.error(`(underlying: ${err.message})`);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
|
||||
let res: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
res = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
// OPR.0.3.4.2 — operation B opt-in seats (deliberate fresh-prime).
|
||||
freshLogicalIds: opts.fresh,
|
||||
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError && !opts.plan) {
|
||||
printHonestTimeout(err);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -236,6 +374,25 @@ Examples:
|
||||
// Success output
|
||||
const resStatus = res.data["status"] as string;
|
||||
|
||||
// OPR.0.3.4.4 — read-only restore plan preview (`--plan` on the
|
||||
// existing-rig path). Renders intended per-seat actions; nothing ran.
|
||||
if (resStatus === "plan" && res.data["mode"] === "restore") {
|
||||
const planRigName = res.data["rigName"] as string | undefined;
|
||||
const planSnapshot = res.data["snapshot"] as { id: string; kind: string; createdAt: string } | null;
|
||||
console.log(`Plan: restore rig "${planRigName ?? source}" (read-only preview)`);
|
||||
if (planSnapshot) {
|
||||
console.log(`Snapshot: ${planSnapshot.id} (kind=${planSnapshot.kind}, captured ${planSnapshot.createdAt})`);
|
||||
} else if (res.data["wouldCaptureCurrentState"] === true) {
|
||||
console.log(`Snapshot: none usable — apply would first capture current DB state as an auto-rehydrate snapshot.`);
|
||||
}
|
||||
const planNodes = (res.data["nodes"] as Array<{ logicalId: string; intendedAction: string; reason?: string }>) ?? [];
|
||||
for (const n of planNodes) {
|
||||
console.log(` ${n.logicalId}: ${n.intendedAction}${n.reason ? ` — ${n.reason}` : ""}`);
|
||||
}
|
||||
console.log("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (resStatus === "restored") {
|
||||
// Existing-rig power-on handoff
|
||||
const rigId = res.data["rigId"] as string;
|
||||
@@ -251,9 +408,15 @@ Examples:
|
||||
}
|
||||
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
|
||||
if (rigResult) console.log(`Result: ${rigResult}`);
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
|
||||
for (const n of nodes) {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
// OPR.0.3.4.2 — the five-term vocabulary renders distinctly; the
|
||||
// awaiting-decision reason is part of the line (not a dead end).
|
||||
if (n.status === "awaiting-decision" && n.error) {
|
||||
console.log(` ${n.logicalId}: awaiting-decision — ${n.error}`);
|
||||
} else {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
}
|
||||
const warnings = (res.data["warnings"] as string[]) ?? [];
|
||||
for (const w of warnings) {
|
||||
@@ -264,7 +427,56 @@ Examples:
|
||||
if (attachCommand) {
|
||||
console.log(`Attach: ${attachCommand}`);
|
||||
}
|
||||
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted" || nodes.some((n) => n.status === "failed")) {
|
||||
|
||||
// OPR.0.3.4.2 — the actionable ASK/offer for awaiting-decision seats.
|
||||
// TTY: interactive [y/N] per seat; accepted seats re-run as a
|
||||
// deliberate fresh-prime (operation B). Headless: the machine status
|
||||
// stays awaiting-decision with the explicit --fresh hint. NEVER an
|
||||
// auto-substitution.
|
||||
const awaiting = nodes.filter((n) => n.status === "awaiting-decision");
|
||||
if (awaiting.length > 0) {
|
||||
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || Boolean(deps.promptYesNo);
|
||||
if (interactive) {
|
||||
const ask = deps.promptYesNo ?? defaultPromptYesNo;
|
||||
const accepted: string[] = [];
|
||||
for (const n of awaiting) {
|
||||
const reason = n.error ?? "original session unresumable";
|
||||
const yes = await ask(`Couldn't resume original session for ${n.logicalId} (reason: ${reason}). Start a fresh primed session instead? [y/N] `);
|
||||
if (yes) accepted.push(n.logicalId);
|
||||
}
|
||||
if (accepted.length > 0) {
|
||||
let freshRes: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
freshRes = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
freshLogicalIds: accepted,
|
||||
}, { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError) {
|
||||
printHonestTimeout(err);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const freshNodes = (freshRes.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
for (const n of freshNodes.filter((fn) => accepted.includes(fn.logicalId))) {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` No fresh sessions started. Re-run with --fresh <seat...> when you decide.`);
|
||||
}
|
||||
} else {
|
||||
for (const n of awaiting) {
|
||||
console.error(` ${n.logicalId}: awaiting-decision — no session started. To deliberately fresh-prime: rig up --existing ${source} --fresh ${n.logicalId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted" || nodes.some((n) => n.status === "failed" || n.status === "awaiting-decision")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -246,7 +246,10 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
|
||||
if (data.peers.length > 0) {
|
||||
console.log("");
|
||||
console.log("Peers:");
|
||||
// OPR.99.0.6.1: name the contract on the header so peers cannot be
|
||||
// misread as the edge-subset or as host inventory. Keeps the literal
|
||||
// `Peers:` prefix (existing output greps key on it).
|
||||
console.log("Peers: (this rig's roster, excluding self — directional edges below; `rig ps --nodes` for inventory incl. self + live state)");
|
||||
for (const peer of data.peers) {
|
||||
console.log(` ${peer.logicalId.padEnd(20)} ${(peer.sessionName ?? "—").padEnd(30)} ${peer.runtime}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -24,8 +24,7 @@ async function withClient<T>(
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
printDaemonNotRunning();
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
@@ -41,6 +40,50 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 GA polish — every mutating workflow command
|
||||
// ends with a 3-line what/state/next summary in human mode so the
|
||||
// operator doesn't have to grep the raw JSON for the outcome. JSON
|
||||
// mode keeps the daemon response verbatim — agent consumers parse
|
||||
// the structured body, not the human summary.
|
||||
export interface OutcomeSummary {
|
||||
what: string;
|
||||
state: string;
|
||||
next: string;
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — runtime validator for the
|
||||
// project --exit enum. TypeScript types don't enforce at runtime;
|
||||
// without this guard `--exit banana` would forward to the daemon
|
||||
// transactional-scribe surface. Match the same 3-part error shape
|
||||
// used elsewhere in the slice.
|
||||
export const PROJECT_EXIT_KINDS = ["handoff", "waiting", "done", "failed"] as const;
|
||||
export type ProjectExitKind = (typeof PROJECT_EXIT_KINDS)[number];
|
||||
|
||||
export function isProjectExitKind(value: unknown): value is ProjectExitKind {
|
||||
return typeof value === "string" && (PROJECT_EXIT_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function printOutcomeSummary(json: boolean, status: number, summary: OutcomeSummary | null): void {
|
||||
if (json || !summary || status >= 400) return;
|
||||
console.log("");
|
||||
console.log(` what: ${summary.what}`);
|
||||
console.log(` state: ${summary.state}`);
|
||||
console.log(` next: ${summary.next}`);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
const cmd = new Command("workflow").description(
|
||||
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
|
||||
@@ -55,6 +98,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow validate workflows/conveyor-starter.workflow.md
|
||||
$ rig workflow validate ./my-spec.workflow.md --json | jq .ok
|
||||
`)
|
||||
.action(async (specPath: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -70,6 +118,17 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
|
||||
.option("--entry-owner <session>", "Override default entry-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow instantiate workflows/conveyor.workflow.md \\
|
||||
--root-objective "Ship release-0.3.2" \\
|
||||
--created-by orch-lead@openrig-velocity
|
||||
|
||||
$ rig workflow instantiate ./my-spec.workflow.md \\
|
||||
--root-objective "Run dogfood" \\
|
||||
--created-by velocity-driver@openrig-velocity \\
|
||||
--entry-owner velocity-qa@openrig-velocity --json
|
||||
`)
|
||||
.action(async (specPath: string, opts: {
|
||||
rootObjective: string;
|
||||
createdBy: string;
|
||||
@@ -78,13 +137,29 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/instantiate", {
|
||||
const res = await client.post<{
|
||||
instanceId?: string;
|
||||
entryStepId?: string;
|
||||
entryOwnerSession?: string;
|
||||
status?: string;
|
||||
}>("/api/workflow/instantiate", {
|
||||
specPath,
|
||||
rootObjective: opts.rootObjective,
|
||||
createdBySession: opts.createdBy,
|
||||
entryOwnerSession: opts.entryOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const instanceId = asString(body.instanceId) ?? "(no instance id)";
|
||||
const entryStepId = asString(body.entryStepId);
|
||||
const owner = asString(body.entryOwnerSession) ?? opts.entryOwner ?? "(default from spec)";
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Instantiated workflow from ${specPath} (instance ${instanceId})`,
|
||||
state: `${body.status ?? "active"}; entry packet ${entryStepId ?? "pending"} owned by ${owner}`,
|
||||
next: entryStepId
|
||||
? `Inspect: rig workflow show ${instanceId} | Open packet: rig queue show ${entryStepId}`
|
||||
: `Inspect: rig workflow show ${instanceId}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,28 +174,82 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
|
||||
.option("--next-owner <session>", "Override default next-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
# handoff to the spec's default next step
|
||||
$ rig workflow project \\
|
||||
--instance WF01ABC \\
|
||||
--current-packet QITEM-123 \\
|
||||
--exit handoff \\
|
||||
--actor-session velocity-driver@openrig-velocity \\
|
||||
--result-note "implementation green; ready for review"
|
||||
|
||||
# close the run cleanly
|
||||
$ rig workflow project --instance WF01ABC --current-packet QITEM-9 \\
|
||||
--exit done --actor-session orch-lead@openrig-velocity
|
||||
|
||||
# block on an external gate
|
||||
$ rig workflow project --instance WF01ABC --current-packet QITEM-4 \\
|
||||
--exit waiting --actor-session velocity-qa@openrig-velocity \\
|
||||
--blocked-on "founder-gate-2"
|
||||
`)
|
||||
.action(async (opts: {
|
||||
instance: string;
|
||||
currentPacket: string;
|
||||
exit: "handoff" | "waiting" | "done" | "failed";
|
||||
exit: string;
|
||||
actorSession: string;
|
||||
resultNote?: string;
|
||||
blockedOn?: string;
|
||||
nextOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// HG-6 — runtime-validate the --exit enum BEFORE the daemon
|
||||
// call. TypeScript types are erased at runtime; without this
|
||||
// guard `--exit banana` would forward to the transactional
|
||||
// workflow surface.
|
||||
if (!isProjectExitKind(opts.exit)) {
|
||||
emit3PartError(
|
||||
Boolean(opts.json),
|
||||
`--exit must be one of ${PROJECT_EXIT_KINDS.join(" | ")} (got "${opts.exit}").`,
|
||||
"rig workflow project did not run; daemon was not contacted.",
|
||||
`Pass a valid exit kind. Example: --exit handoff.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const exitKind: ProjectExitKind = opts.exit;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/project", {
|
||||
const res = await client.post<{
|
||||
closedPacketId?: string;
|
||||
nextPacketId?: string;
|
||||
nextOwnerSession?: string;
|
||||
instanceStatus?: string;
|
||||
}>("/api/workflow/project", {
|
||||
instanceId: opts.instance,
|
||||
currentPacketId: opts.currentPacket,
|
||||
exit: opts.exit,
|
||||
exit: exitKind,
|
||||
actorSession: opts.actorSession,
|
||||
resultNote: opts.resultNote,
|
||||
blockedOn: opts.blockedOn,
|
||||
nextOwnerSession: opts.nextOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const closedId = asString(body.closedPacketId) ?? opts.currentPacket;
|
||||
const nextId = asString(body.nextPacketId);
|
||||
const nextOwner = asString(body.nextOwnerSession) ?? opts.nextOwner ?? "(default from spec)";
|
||||
const status = body.instanceStatus ?? (opts.exit === "done" ? "completed" : opts.exit === "failed" ? "failed" : "active");
|
||||
const whatTail = nextId ? ` and projected ${nextId} to ${nextOwner}` : (opts.exit === "done" ? " (instance done)" : "");
|
||||
const nextAction = nextId
|
||||
? `Inspect: rig queue show ${nextId}`
|
||||
: (opts.exit === "done" || opts.exit === "failed")
|
||||
? `Inspect: rig workflow show ${opts.instance}`
|
||||
: `Wait for upstream; rig workflow show ${opts.instance} to monitor`;
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Closed ${closedId} (${opts.exit})${whatTail}`,
|
||||
state: `instance ${opts.instance} = ${status}`,
|
||||
next: nextAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +258,12 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.description("List workflow instances; optionally filter by status")
|
||||
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow list
|
||||
$ rig workflow list --status active --json
|
||||
$ rig workflow list --status waiting | jq -r '.instances[].instanceId'
|
||||
`)
|
||||
.action(async (opts: { status?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
@@ -150,6 +285,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("specs")
|
||||
.description("List registered workflow specs; built-in starters tagged with (built-in)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow specs
|
||||
$ rig workflow specs --json | jq '.specs[] | select(.isBuiltIn==false)'
|
||||
`)
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -185,6 +325,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("show <instanceId>")
|
||||
.description("Show one workflow instance")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow show WF01ABC
|
||||
$ rig workflow show WF01ABC --json | jq '.instance.status'
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -197,6 +342,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("trace <instanceId>")
|
||||
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow trace WF01ABC
|
||||
$ rig workflow trace WF01ABC --json | jq '.trail[] | {step, actor, exit}'
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -209,11 +359,33 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("continue <instanceId>")
|
||||
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow continue WF01ABC
|
||||
$ rig workflow continue WF01ABC --json
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
const res = await client.post<{
|
||||
advanced?: boolean;
|
||||
nextPacketId?: string;
|
||||
nextOwnerSession?: string;
|
||||
instanceStatus?: string;
|
||||
}>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const advanced = body.advanced !== false;
|
||||
const nextId = asString(body.nextPacketId);
|
||||
const nextOwner = asString(body.nextOwnerSession) ?? "(unknown)";
|
||||
const status = body.instanceStatus ?? "(unknown)";
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: advanced
|
||||
? `Advanced instance ${instanceId}${nextId ? ` to packet ${nextId}` : ""}`
|
||||
: `No advance for instance ${instanceId} (already at frontier or terminal)`,
|
||||
state: `instance status = ${status}${nextId ? `; next owner = ${nextOwner}` : ""}`,
|
||||
next: nextId ? `Inspect: rig queue show ${nextId}` : `Inspect: rig workflow trace ${instanceId}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
|
||||
// PL-007 Workspace Primitive — `rig workspace` CLI surface.
|
||||
//
|
||||
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, and emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes the
|
||||
// report.
|
||||
// Verbs:
|
||||
// - `rig workspace validate` (slice-01) — walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes.
|
||||
// - `rig workspace doctor` (slice-21 FR-5) — runs the 7-check
|
||||
// workspace-readiness diagnostic against the daemon's resolved
|
||||
// workspace (or a --workspace override). Reports state + fix-hints.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import * as path from "node:path";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -38,17 +41,42 @@ async function withClient<T>(
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
printDaemonNotRunning();
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — strict-int validator for
|
||||
// --max-files. Rejects `12abc`, `abc`, `0`, `-1`, etc. with a 3-part
|
||||
// fact/consequence/action error; does NOT call the daemon on invalid
|
||||
// input. Keeps positive cases (`10000`, `12`, etc.) flowing through.
|
||||
export function parseMaxFilesStrict(raw: string): number {
|
||||
if (!/^[1-9][0-9]*$/.test(raw)) {
|
||||
const err = new Error(
|
||||
`--max-files must be a positive integer (got "${raw}").`,
|
||||
) as Error & { fact: string; consequence: string; action: string };
|
||||
err.fact = `--max-files must be a positive integer (got "${raw}").`;
|
||||
err.consequence = "rig workspace validate did not run; daemon was not contacted.";
|
||||
err.action = "Pass a positive integer like --max-files 10000.";
|
||||
throw err;
|
||||
}
|
||||
return Number.parseInt(raw, 10);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
const cmd = new Command("workspace").description(
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 7-check workspace-readiness diagnostic.",
|
||||
);
|
||||
|
||||
const getDeps = (): WorkspaceDeps =>
|
||||
@@ -78,6 +106,17 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
// HG-6 — CLI-side validation BEFORE the daemon call. Reject
|
||||
// malformed --max-files with a 3-part error; never silently
|
||||
// coerce `12abc` → 12.
|
||||
let maxFiles: number;
|
||||
try {
|
||||
maxFiles = parseMaxFilesStrict(opts.maxFiles);
|
||||
} catch (err) {
|
||||
const e = err as Error & { fact?: string; consequence?: string; action?: string };
|
||||
emit3PartError(Boolean(opts.json), e.fact ?? e.message, e.consequence ?? "", e.action ?? "");
|
||||
return;
|
||||
}
|
||||
const root = path.resolve(rootArg ?? process.cwd());
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -86,7 +125,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
workspaceKind: opts.kind,
|
||||
recursive: opts.recursive !== false,
|
||||
requireFrontmatter: opts.requireFrontmatter ?? false,
|
||||
maxFiles: Number.parseInt(opts.maxFiles, 10),
|
||||
maxFiles,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
@@ -105,6 +144,59 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
},
|
||||
);
|
||||
|
||||
// Slice-21 FR-5 — `rig workspace doctor`.
|
||||
cmd
|
||||
.command("doctor")
|
||||
.description(
|
||||
"Run the 7-check workspace-readiness diagnostic against the daemon's resolved workspace. Reports state of workspace root, missions folder, file allowlist, daemon alignment, daemon reload, slice docs, and MISSION_NOTES. Read-only.",
|
||||
)
|
||||
.option(
|
||||
"--workspace <path>",
|
||||
"Override the workspace under check (default: daemon-resolved configured root)",
|
||||
)
|
||||
.option("--json", "JSON output for agents")
|
||||
.option(
|
||||
"--strict",
|
||||
"Exit non-zero on warn-or-fail (default: non-zero only on fail)",
|
||||
)
|
||||
.action(async (opts: { workspace?: string; json?: boolean; strict?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const requestBody: { workspaceRoot?: string; filesAllowlistOverride?: string } = {};
|
||||
if (opts.workspace) requestBody.workspaceRoot = path.resolve(opts.workspace);
|
||||
// FR-5e A2 — CLI-side env overlay for files.allowlist. The
|
||||
// daemon runs in its own process with its own env; an
|
||||
// operator who sets OPENRIG_FILES_ALLOWLIST in their CLI
|
||||
// shell expects the doctor result to reflect that override
|
||||
// even though the daemon's env is unchanged. We mirror the
|
||||
// --workspace override pattern: read the env at request
|
||||
// time and forward as a per-request overlay; the daemon
|
||||
// route applies it to check #3 with source="env".
|
||||
const cliAllowlistEnv = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
if (typeof cliAllowlistEnv === "string" && cliAllowlistEnv.length > 0) {
|
||||
requestBody.filesAllowlistOverride = cliAllowlistEnv;
|
||||
}
|
||||
const res = await client.post<DoctorReport>("/api/workspace/doctor", requestBody);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const report = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
renderHumanDoctorReport(report);
|
||||
}
|
||||
// Exit-code semantics per FR-5 IMPL-PRD §74:
|
||||
// default: non-zero only on fail
|
||||
// --strict: non-zero on warn-or-fail
|
||||
const hasFail = report.summary.fail > 0;
|
||||
const hasWarn = report.summary.warn > 0;
|
||||
if (hasFail || (opts.strict && hasWarn)) process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -125,3 +217,74 @@ function renderHumanReport(r: ValidationReport): void {
|
||||
console.log(` ${g.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- slice-21 FR-5 — `rig workspace doctor` types + human formatter ---
|
||||
//
|
||||
// DoctorReport shape mirrors the daemon's runWorkspaceDoctor output
|
||||
// at packages/daemon/src/domain/workspace/workspace-doctor.ts. Type
|
||||
// is duplicated here (not imported from the daemon package) because
|
||||
// the CLI does not have a direct daemon-package dependency edge; the
|
||||
// HTTP boundary already enforces the JSON shape.
|
||||
|
||||
interface DoctorCheckResult {
|
||||
check: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
workspaceRoot: string;
|
||||
checks: DoctorCheckResult[];
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
const DOCTOR_CHECK_GROUPS: ReadonlyArray<{ category: string; checks: ReadonlyArray<string> }> = [
|
||||
{ category: "workspace", checks: ["workspace_root_reachable", "file_allowlist_sane"] },
|
||||
{ category: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence"] },
|
||||
{ category: "daemon", checks: ["daemon_points_at_this_workspace", "daemon_reload_needed"] },
|
||||
];
|
||||
|
||||
function statusIcon(status: DoctorCheckResult["status"]): string {
|
||||
switch (status) {
|
||||
case "ok": return "OK";
|
||||
case "warn": return "WARN";
|
||||
case "fail": return "FAIL";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHumanDoctorReport(report: DoctorReport): void {
|
||||
console.log(`workspace doctor — ${report.workspaceRoot}`);
|
||||
console.log(` summary: ${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.fail} fail`);
|
||||
console.log("");
|
||||
|
||||
const byName = new Map(report.checks.map((c) => [c.check, c]));
|
||||
const remaining = new Set(report.checks.map((c) => c.check));
|
||||
|
||||
for (const group of DOCTOR_CHECK_GROUPS) {
|
||||
console.log(`${group.category}:`);
|
||||
for (const checkName of group.checks) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
remaining.delete(checkName);
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Any checks not in the documented groups still get rendered so a
|
||||
// future check addition doesn't silently drop from the human view
|
||||
// before the group-map is updated.
|
||||
if (remaining.size > 0) {
|
||||
console.log("other:");
|
||||
for (const checkName of remaining) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,13 @@ export interface RiggedConfig {
|
||||
postRestoreAuditInstruction: string;
|
||||
};
|
||||
};
|
||||
snapshots: {
|
||||
periodic: {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
retentionKeep: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
@@ -203,6 +210,13 @@ const DEFAULTS = {
|
||||
postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION,
|
||||
},
|
||||
},
|
||||
snapshots: {
|
||||
periodic: {
|
||||
enabled: true,
|
||||
intervalSeconds: 300,
|
||||
retentionKeep: 10,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const VALID_KEYS = [
|
||||
@@ -253,6 +267,9 @@ export const VALID_KEYS = [
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
"snapshots.periodic.enabled",
|
||||
"snapshots.periodic.interval_seconds",
|
||||
"snapshots.periodic.retention_keep",
|
||||
] as const;
|
||||
|
||||
export type ValidKey = typeof VALID_KEYS[number];
|
||||
@@ -303,6 +320,9 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
|
||||
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
|
||||
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
|
||||
"snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" },
|
||||
"snapshots.periodic.interval_seconds": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_INTERVAL_SECONDS" },
|
||||
"snapshots.periodic.retention_keep": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_RETENTION_KEEP" },
|
||||
};
|
||||
|
||||
// Maps dotted-string config keys to the camelCase RiggedConfig path.
|
||||
@@ -346,6 +366,9 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
|
||||
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
|
||||
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
|
||||
"snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"],
|
||||
"snapshots.periodic.interval_seconds": ["snapshots", "periodic", "intervalSeconds"],
|
||||
"snapshots.periodic.retention_keep": ["snapshots", "periodic", "retentionKeep"],
|
||||
};
|
||||
|
||||
function isValidKey(key: string): key is ValidKey {
|
||||
@@ -469,6 +492,30 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
);
|
||||
}
|
||||
},
|
||||
"snapshots.periodic.interval_seconds": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim())) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.interval_seconds: expected an integer >= 60, got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 60) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.interval_seconds: must be >= 60, got ${raw}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
"snapshots.periodic.retention_keep": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim())) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.retention_keep: expected an integer >= 1, got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.retention_keep: must be >= 1, got ${raw}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
|
||||
@@ -594,6 +641,13 @@ export class ConfigStore {
|
||||
postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string,
|
||||
},
|
||||
},
|
||||
snapshots: {
|
||||
periodic: {
|
||||
enabled: v("snapshots.periodic.enabled") as boolean,
|
||||
intervalSeconds: v("snapshots.periodic.interval_seconds") as number,
|
||||
retentionKeep: v("snapshots.periodic.retention_keep") as number,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,46 @@ export function getDaemonUrl(status: DaemonStatus): string {
|
||||
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.3.04.2 (AC-4): the ONE shared honest daemon-not-running error for the
|
||||
* daemon-dependent journey verbs (bootstrap / discover / workspace / workflow).
|
||||
* Mirrors the repo's 3-part fact / consequence / action convention
|
||||
* (commands/archive.ts, commands/queue.ts) so the daemon-dependency UX is
|
||||
* consistent across the new-operator journey instead of four divergent bare
|
||||
* dead-ends. Bounded to the daemon-not-running path - NOT a general
|
||||
* error-framework (that would be its own slice).
|
||||
*/
|
||||
export interface DaemonNotRunningError {
|
||||
fact: string;
|
||||
consequence: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export function daemonNotRunningError(): DaemonNotRunningError {
|
||||
return {
|
||||
fact: "Daemon not running.",
|
||||
consequence: "This command needs a running daemon.",
|
||||
action: "Run 'rig up' (it auto-starts the daemon), or 'rig daemon start'.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the shared daemon-not-running error and set a failing exit code. Pass
|
||||
* `{ json: true }` for the agent-facing `{ error: { fact, consequence, action } }`
|
||||
* envelope (matching commands/queue.ts JSON output).
|
||||
*/
|
||||
export function printDaemonNotRunning(opts?: { json?: boolean }): void {
|
||||
const err = daemonNotRunningError();
|
||||
if (opts?.json) {
|
||||
console.log(JSON.stringify({ error: err }));
|
||||
} else {
|
||||
console.error(`Error: ${err.fact}`);
|
||||
console.error(` ${err.consequence}`);
|
||||
console.error(` ${err.action}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
port?: number;
|
||||
host?: string;
|
||||
@@ -155,12 +195,42 @@ export function resolveCliBaseDir(baseDir: string): string {
|
||||
return path.basename(baseDir) === "commands" ? path.resolve(baseDir, "..") : baseDir;
|
||||
}
|
||||
|
||||
/** Pure resolver: prefers bundled daemon (npm install layout), falls back to monorepo. */
|
||||
/**
|
||||
* Pure resolver: prefers monorepo source when present (dev checkout
|
||||
* where `packages/daemon/dist` is the source of truth), falls back to
|
||||
* the bundled/vendored copy at `packages/cli/daemon` (the npm-install
|
||||
* layout).
|
||||
*
|
||||
* Why this order: in a monorepo dev checkout BOTH paths exist:
|
||||
* - packages/daemon/dist ← refreshed by
|
||||
* `npm run build --workspace
|
||||
* @openrig/daemon` (frequent;
|
||||
* source of truth)
|
||||
* - packages/cli/daemon/dist ← refreshed only by
|
||||
* `scripts/build-package.sh`
|
||||
* (rare; for npm publish)
|
||||
*
|
||||
* Picking the vendored copy first (the prior order) means devs run a
|
||||
* stale daemon through `rig daemon start` whenever they've rebuilt
|
||||
* `@openrig/daemon` but haven't rerun `build-package.sh`. That hid the
|
||||
* fact that QA on local main 55e01f2e was getting a daemon WITHOUT the
|
||||
* shipped slice-09 `/api/rig-policy/*` routes (qitem-20260518054224).
|
||||
*
|
||||
* In an npm-install layout `packages/daemon/dist` does not exist
|
||||
* (only the bundled `packages/cli/daemon` lives next to the cli);
|
||||
* the fallback handles that case unchanged.
|
||||
*/
|
||||
export function resolveDaemonPath(baseDir: string, exists: (p: string) => boolean): string {
|
||||
const cliBaseDir = resolveCliBaseDir(baseDir);
|
||||
const monorepo = path.resolve(cliBaseDir, "../../daemon");
|
||||
if (exists(path.join(monorepo, "dist/index.js"))) return monorepo;
|
||||
const bundled = path.resolve(cliBaseDir, "../daemon");
|
||||
if (exists(path.join(bundled, "dist/index.js"))) return bundled;
|
||||
return path.resolve(cliBaseDir, "../../daemon");
|
||||
// Last-resort: return the monorepo path so callers get a meaningful
|
||||
// error rather than crashing on undefined. The caller validates
|
||||
// existence elsewhere (doctor / start) and surfaces "daemon dist not
|
||||
// found at <path>".
|
||||
return monorepo;
|
||||
}
|
||||
|
||||
export function getDaemonPath(): string {
|
||||
|
||||
@@ -20,6 +20,8 @@ import { bundleCommand } from "./commands/bundle.js";
|
||||
import { scopeCommand } from "./commands/scope.js";
|
||||
import { upCommand } from "./commands/up.js";
|
||||
import { downCommand } from "./commands/down.js";
|
||||
import { archiveCommand } from "./commands/archive.js";
|
||||
import { unarchiveCommand } from "./commands/unarchive.js";
|
||||
import { psCommand } from "./commands/ps.js";
|
||||
import { mcpCommand } from "./commands/mcp.js";
|
||||
import { agentCommand, type AgentDeps } from "./commands/agent.js";
|
||||
@@ -32,12 +34,15 @@ import { projectCommand, type ProjectDeps } from "./commands/project.js";
|
||||
import { viewCommand, type ViewDeps } from "./commands/view.js";
|
||||
import { watchdogCommand, type WatchdogDeps } from "./commands/watchdog.js";
|
||||
import { workflowCommand, type WorkflowDeps } from "./commands/workflow.js";
|
||||
import { startCommand, type StartDeps } from "./commands/start.js";
|
||||
import { captureCommand } from "./commands/capture.js";
|
||||
import { broadcastCommand } from "./commands/broadcast.js";
|
||||
import { configCommand } from "./commands/config.js";
|
||||
import { preflightCommand } from "./commands/preflight.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { expandCommand } from "./commands/expand.js";
|
||||
import { addMemberCommand } from "./commands/add.js";
|
||||
import { reconcileSessionCommand } from "./commands/reconcile-session.js";
|
||||
import { envCommand } from "./commands/env.js";
|
||||
import { askCommand } from "./commands/ask.js";
|
||||
import { chatroomCommand } from "./commands/chatroom.js";
|
||||
@@ -60,6 +65,7 @@ import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore
|
||||
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import { rigPolicyCommand, type RigPolicyDeps } from "./commands/rig-policy.js";
|
||||
import type { LifecycleDeps } from "./daemon-lifecycle.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
@@ -81,6 +87,8 @@ export interface ProgramDeps {
|
||||
bundleDeps?: StatusDeps;
|
||||
upDeps?: StatusDeps;
|
||||
downDeps?: StatusDeps;
|
||||
archiveDeps?: StatusDeps;
|
||||
unarchiveDeps?: StatusDeps;
|
||||
psDeps?: StatusDeps;
|
||||
mcpDeps?: StatusDeps;
|
||||
agentDeps?: AgentDeps;
|
||||
@@ -104,6 +112,8 @@ export interface ProgramDeps {
|
||||
workspaceDeps?: WorkspaceDeps;
|
||||
whoamiDeps?: StatusDeps;
|
||||
expandDeps?: StatusDeps;
|
||||
addDeps?: StatusDeps;
|
||||
reconcileSessionDeps?: StatusDeps;
|
||||
envDeps?: StatusDeps;
|
||||
unclaimDeps?: StatusDeps;
|
||||
releaseDeps?: StatusDeps;
|
||||
@@ -115,6 +125,8 @@ export interface ProgramDeps {
|
||||
compactPlanDeps?: CompactPlanDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
rigPolicyDeps?: RigPolicyDeps;
|
||||
startDeps?: StartDeps;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
@@ -126,6 +138,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
.description("CLI for the OpenRig local control plane")
|
||||
.version(CLI_VERSION);
|
||||
|
||||
program.addCommand(startCommand(depsOverride?.startDeps));
|
||||
program.addCommand(daemonCommand(depsOverride?.daemonDeps));
|
||||
program.addCommand(statusCommand(depsOverride?.statusDeps));
|
||||
program.addCommand(snapshotCommand(depsOverride?.snapshotDeps));
|
||||
@@ -143,6 +156,9 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(bundleCommand(depsOverride?.bundleDeps));
|
||||
program.addCommand(upCommand(depsOverride?.upDeps));
|
||||
program.addCommand(downCommand(depsOverride?.downDeps));
|
||||
// OPR.0.3.3.19 - rig archive affordance (soft, reversible; NOT a delete).
|
||||
program.addCommand(archiveCommand(depsOverride?.archiveDeps));
|
||||
program.addCommand(unarchiveCommand(depsOverride?.unarchiveDeps));
|
||||
program.addCommand(psCommand(depsOverride?.psDeps));
|
||||
program.addCommand(mcpCommand(depsOverride?.mcpDeps));
|
||||
program.addCommand(agentCommand(depsOverride?.agentDeps));
|
||||
@@ -164,11 +180,14 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
|
||||
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
|
||||
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
|
||||
program.addCommand(rigPolicyCommand(depsOverride?.rigPolicyDeps));
|
||||
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
|
||||
program.addCommand(configCommand(depsOverride?.configPath));
|
||||
program.addCommand(preflightCommand());
|
||||
program.addCommand(doctorCommand());
|
||||
program.addCommand(expandCommand(depsOverride?.expandDeps));
|
||||
program.addCommand(addMemberCommand(depsOverride?.addDeps));
|
||||
program.addCommand(reconcileSessionCommand(depsOverride?.reconcileSessionDeps));
|
||||
program.addCommand(envCommand(depsOverride?.envDeps));
|
||||
program.addCommand(unclaimCommand(depsOverride?.unclaimDeps));
|
||||
program.addCommand(releaseCommand(depsOverride?.releaseDeps));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
mission: {{mission_id}}
|
||||
name: {{mission_name}}
|
||||
status: active
|
||||
authored: {{created_date}}
|
||||
last-updated: {{created_date}}
|
||||
---
|
||||
|
||||
# Mission Notes — {{mission_name}}
|
||||
|
||||
> Read the frontmatter `last-updated` first. If stale relative to runtime,
|
||||
> treat this file as `provisional` and re-verify before relying on it.
|
||||
> Pointer-first by design — index durable canonical artifacts; don't duplicate
|
||||
> them (duplication is the stale-breadcrumb failure mode this convention
|
||||
> exists to kill).
|
||||
|
||||
## §1. Top-of-mind context
|
||||
|
||||
(Current most-load-bearing state — gates, open scope-decisions, in-flight
|
||||
surprises. Updated by anyone when material changes. Keep to 5-15 lines.)
|
||||
|
||||
## §2. Active workstreams + drivers + tips
|
||||
|
||||
(Per-workstream rows. THIS SECTION IS THE MISSION CODEMAP. Each row:
|
||||
state / driver / branch+tip-SHA / latest blocker or ACK.)
|
||||
|
||||
- (workstream-id) — state — driver — tip-SHA — blocker/ACK
|
||||
|
||||
## §3. Open scope-decisions
|
||||
|
||||
(Labeled gates the operator owns. Per gate: where the gate is, what's needed,
|
||||
who routed.)
|
||||
|
||||
- OQ-1: (decision needed) — gate at <artifact-path> — surfaced by <seat>
|
||||
|
||||
## §4. Slice inventory
|
||||
|
||||
(Full table of slices on this mission.)
|
||||
|
||||
| # | slice | state | driver | blocker |
|
||||
|---|---|---|---|---|
|
||||
|
||||
## §5. Pending dispatches
|
||||
|
||||
(Next-go items waiting on driver availability or convergence. Immediately-ready
|
||||
set, not full backlog.)
|
||||
|
||||
## §6. Ledgers
|
||||
|
||||
(Mission-specific shared ledgers: release version + tag, SC counts, cumulative
|
||||
deltas, etc.)
|
||||
|
||||
## §7. Banked memories that apply here
|
||||
|
||||
(Bulleted: banked-feedback / banked-project memories load-bearing on restore
|
||||
for this mission. Ones learned during this mission go into the same list.)
|
||||
|
||||
## §8. Convention pointers
|
||||
|
||||
(Which banked conventions this mission inherits. Cross-reference each by path.
|
||||
This section is the principles/conventions inheritance surface for the mission.)
|
||||
|
||||
- `conventions/mission-notes/README.md` — this convention (apply on every restore)
|
||||
|
||||
## §9. Reconstruction protocol
|
||||
|
||||
(Commands to run after compaction or for onboarding. Refresh as the mission's
|
||||
restore-needs evolve.)
|
||||
|
||||
1. `rig whoami --json` — confirm seat identity
|
||||
2. Read this file's §1 + your §A-§X section + this §9
|
||||
3. Read `missions/{{mission_id}}/README.md` for mission scope
|
||||
4. Read `missions/{{mission_id}}/PROGRESS.md` for delivery state
|
||||
5. `rig queue list --destination <your-session>` — your durable inbox
|
||||
6. State "restored from {{mission_id}}; resumed at <action>" before acting
|
||||
|
||||
## §10. What NOT to reconstruct
|
||||
|
||||
(Explicit pruning — completed slices / merged commits / closed ACK packets are
|
||||
recoverable on demand and don't need to be in working memory. Name them to
|
||||
free attention.)
|
||||
|
||||
## §A. <first-seat>@<rig> notes
|
||||
|
||||
(First seat's notes. Pattern: append cont.N entries with date + state of the
|
||||
world from this seat's perspective. Latest = truth. Other seats READ but
|
||||
don't modify.)
|
||||
|
||||
DONE {{created_date}} (cont.0 — mission scaffolded):
|
||||
- Mission scaffolded via `rig scope mission create`. Template applied; this
|
||||
file is the seed state. First substantive cont.1 entry follows when work
|
||||
begins.
|
||||
|
||||
## §B / §C / ... — per-seat sections
|
||||
|
||||
(Each additional seat adds an h2 ## §X section under their own header as they
|
||||
join. Section pattern matches §A.)
|
||||
@@ -128,7 +128,7 @@ export function resolveMissionsRoot(opts: {
|
||||
throw new ScopeCliError({
|
||||
fact: `Could not locate a missions/ root from cwd ${cwd}.`,
|
||||
consequence: "No mission tree to operate on.",
|
||||
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/substrate/shared-docs/openrig-work.",
|
||||
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/your/workspace.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,70 @@ export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpt
|
||||
return applyPlaceholders(raw, opts);
|
||||
}
|
||||
|
||||
// OPR.0.3.2.21.FR-3 — MISSION_NOTES.md scaffold support.
|
||||
//
|
||||
// `rig scope mission create` auto-copies a MISSION_NOTES.md alongside
|
||||
// the README. The template lives at scope-templates/mission-notes.md
|
||||
// by default; the operator can override via the
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH env var (banked env-var-pivot
|
||||
// pattern: env > built-in fallback).
|
||||
|
||||
export interface MissionNotesRenderOpts {
|
||||
mission_id: string;
|
||||
mission_name: string;
|
||||
created_date: string;
|
||||
}
|
||||
|
||||
function applyMissionNotesPlaceholders(content: string, opts: MissionNotesRenderOpts): string {
|
||||
return content
|
||||
.replace(/\{\{mission_id\}\}/g, opts.mission_id)
|
||||
.replace(/\{\{mission_name\}\}/g, opts.mission_name)
|
||||
.replace(/\{\{created_date\}\}/g, opts.created_date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the MISSION_NOTES.md template absolute path.
|
||||
* Env-var override > built-in bundled fallback (scope-templates/mission-notes.md).
|
||||
* Returns the absolute path; the caller reads the file content.
|
||||
*/
|
||||
export function resolveMissionNotesTemplatePath(envValue?: string): string {
|
||||
const fromEnv = envValue ?? process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
if (fromEnv && fromEnv.trim().length > 0) {
|
||||
const absPath = path.resolve(fromEnv.trim());
|
||||
if (!fs.existsSync(absPath)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `OPENRIG_MISSION_NOTES_TEMPLATE_PATH points at "${fromEnv}", which does not exist.`,
|
||||
consequence: "MISSION_NOTES.md not scaffolded.",
|
||||
action: "Set OPENRIG_MISSION_NOTES_TEMPLATE_PATH to an absolute path to a readable template file, or unset it to use the built-in bundled fallback.",
|
||||
});
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
return resolveTemplate("mission-notes.md");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a MISSION_NOTES.md by reading the resolved template + substituting
|
||||
* the three placeholders (mission_id / mission_name / created_date).
|
||||
*
|
||||
* Surfaces the resolution decision (env-override vs built-in) for callers
|
||||
* that want to log or test the path.
|
||||
*/
|
||||
export function renderMissionNotesTemplate(
|
||||
opts: MissionNotesRenderOpts,
|
||||
envValue?: string,
|
||||
): { rendered: string; resolvedFrom: "env" | "built-in"; absPath: string } {
|
||||
const envVal = envValue ?? process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
const usedEnv = Boolean(envVal && envVal.trim().length > 0);
|
||||
const absPath = resolveMissionNotesTemplatePath(envValue);
|
||||
const raw = fs.readFileSync(absPath, "utf8");
|
||||
return {
|
||||
rendered: applyMissionNotesPlaceholders(raw, opts),
|
||||
resolvedFrom: usedEnv ? "env" : "built-in",
|
||||
absPath,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a folder-slug to a title-cased display name. */
|
||||
export function titleFromSlug(slug: string): string {
|
||||
return slug
|
||||
|
||||
@@ -422,6 +422,41 @@ export function createMcpServer(client: DaemonClient): McpServer {
|
||||
},
|
||||
);
|
||||
|
||||
// 18. rig_add — add a single member to an existing pod (the add_member
|
||||
// converge op). Agent parity for `rig add`: the ergonomics live ON the
|
||||
// converge interface, so every future reshape verb inherits this surface.
|
||||
server.tool(
|
||||
"rig_add",
|
||||
"Add a single member to an existing pod in a running rig (the add_member converge op). Identity-migration-free: mints a fresh seat, nothing existing re-keys.",
|
||||
{
|
||||
rigId: z.string().describe("Target rig identifier"),
|
||||
podNamespace: z.string().describe("Namespace of the existing pod to add the member to"),
|
||||
member: z.record(z.string(), z.unknown()).describe("Member fragment with spec snake_case fields: id, runtime, agent_ref, profile, cwd (and optional model, codex_config_profile, restore_policy, label)"),
|
||||
edges: z.array(z.object({ from: z.string(), to: z.string(), kind: z.string() })).optional().describe("Optional pod-local edges (from/to are member ids within the pod; resolved against the new member + existing pod-mates)"),
|
||||
rigRoot: z.string().optional().describe("Root directory for agent resolution"),
|
||||
},
|
||||
async ({ rigId, podNamespace, member, edges, rigRoot }) => {
|
||||
try {
|
||||
const body: Record<string, unknown> = { member };
|
||||
if (edges) body["edges"] = edges;
|
||||
if (rigRoot) body["rigRoot"] = rigRoot;
|
||||
const res = await client.post(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/pods/${encodeURIComponent(podNamespace)}/members`,
|
||||
body,
|
||||
);
|
||||
// Structural failure: the add persisted (201) but the new node did not
|
||||
// fully launch (failed / attention_required) — surface it as an error so
|
||||
// the agent knows the seat is not live, mirroring the CLI non-zero exit.
|
||||
return mapResult(res, (data) => {
|
||||
const result = data["result"] as { node?: { status?: string } } | undefined;
|
||||
return result?.node?.status !== undefined && result.node.status !== "launched";
|
||||
});
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// OPR.0.3.3.13.2 - Skill <-> CLI-surface binding-index lookup (Component 2 of slice 13).
|
||||
//
|
||||
// Given a release surface-diff (13.1's deterministic output, surface-diff.ts) and
|
||||
// a set of skills carrying `metadata.cli_surfaces_referenced`, returns which
|
||||
// skills are affected by the release. Fully deterministic + offline (AC-4): the
|
||||
// match is pure set/string ops over the diff + the provided skill index; the
|
||||
// corpus loader is a plain filesystem read. No network, no agent/LLM - composes
|
||||
// with 13.1's deterministic half. 13.3 (the dispatcher that WRITES skill
|
||||
// updates) decides invocation; this stays invocation-agnostic.
|
||||
//
|
||||
// Join grammar - ADVISOR-RATIFIED (qitem-20260608235706-64ba9ef2; see the slice
|
||||
// governance-watch.md SHARP WATCH #1):
|
||||
// - Skill tokens are stored WITHOUT the `rig ` prefix, in 13.1's command-path
|
||||
// grammar (`scope slice create`, `queue create`). We strip a leading `rig `
|
||||
// defensively so a skill written either way still matches.
|
||||
// - Changed-surface set: per `added_commands`, `{name}` plus `{name + " " +
|
||||
// each subcommand}`; per `added_flags`, `{command}` plus `{command + " " +
|
||||
// each subcommand}`. (13.1 emits `added_commands[].name` as a BARE top-level
|
||||
// name with deep paths in a separate `subcommands` list, so the set MUST be
|
||||
// expanded - a naive name-only intersect false-negatives `scope slice create`.)
|
||||
// - Match = COMPONENT-WISE (path-segment) prefix, in EITHER direction (NOT
|
||||
// raw-string prefix - so `up` does NOT match `update`). The bias is
|
||||
// deliberately conservative: prefix-both-directions OVER-includes, never
|
||||
// UNDER-includes, which is exactly right for AC-3's hard no-false-negative
|
||||
// floor. Over-inclusions are the "explained extras" AC-3 permits.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import type { SurfaceDiff } from "./surface-diff.js";
|
||||
|
||||
/**
|
||||
* Split a command token into path segments, stripping a leading `rig ` and
|
||||
* collapsing internal whitespace. `"rig scope slice create"` -> `["scope",
|
||||
* "slice", "create"]`; `"queue create"` -> `["queue", "create"]`.
|
||||
*/
|
||||
export function toSegments(token: string): string[] {
|
||||
const stripped = token.trim().replace(/^rig\s+/, "");
|
||||
return stripped.split(/\s+/).filter((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The section-3 changed-surface set: every command-path string a release touched,
|
||||
* expanded from the diff's bare-top-level + subcommand-list grammar. This is
|
||||
* the AC-3a target (validated against 13.1's shipped sample diff + fixture).
|
||||
*/
|
||||
export function expandChangedSurface(diff: SurfaceDiff): Set<string> {
|
||||
const set = new Set<string>();
|
||||
for (const cmd of diff.added_commands ?? []) {
|
||||
if (cmd.name) set.add(cmd.name);
|
||||
for (const sub of cmd.subcommands ?? []) {
|
||||
if (sub) set.add(`${cmd.name} ${sub}`);
|
||||
}
|
||||
}
|
||||
for (const entry of diff.added_flags ?? []) {
|
||||
if (entry.command) set.add(entry.command);
|
||||
for (const sub of entry.subcommands ?? []) {
|
||||
if (sub) set.add(`${entry.command} ${sub}`);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component-wise prefix match in EITHER direction: true iff one segment-list
|
||||
* equals OR is a path-segment prefix of the other. Compares segment-by-segment
|
||||
* up to the shorter length - so `["scope"]` matches `["scope","slice","create"]`
|
||||
* (and vice-versa), but `["up"]` does NOT match `["update"]` (distinct first
|
||||
* segment). This is the advisor sharpening over raw-string prefix.
|
||||
*/
|
||||
export function pathsMatch(a: string[], b: string[]): boolean {
|
||||
if (a.length === 0 || b.length === 0) return false;
|
||||
const shorter = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < shorter; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Does a skill token match any path in the changed-surface set? */
|
||||
export function skillTokenMatches(skillToken: string, changedSurface: Set<string>): boolean {
|
||||
const tokenSegments = toSegments(skillToken);
|
||||
if (tokenSegments.length === 0) return false;
|
||||
for (const changed of changedSurface) {
|
||||
if (pathsMatch(tokenSegments, toSegments(changed))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface SkillIndexEntry {
|
||||
/** Skill directory name (the canonical skill id). */
|
||||
name: string;
|
||||
/** The skill's `metadata.cli_surfaces_referenced` tokens (13.1 grammar). */
|
||||
cliSurfacesReferenced: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AC-3b: the affected-skill output. A skill is affected iff ANY of its
|
||||
* `cli_surfaces_referenced` tokens matches the changed-surface set. Returns a
|
||||
* sorted, de-duplicated list of skill names - deterministic for a given
|
||||
* (diff, skills) pair (AC-4).
|
||||
*/
|
||||
export function computeAffectedSkills(diff: SurfaceDiff, skills: SkillIndexEntry[]): string[] {
|
||||
const changed = expandChangedSurface(diff);
|
||||
const affected = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
const hit = (skill.cliSurfacesReferenced ?? []).some((token) =>
|
||||
skillTokenMatches(token, changed),
|
||||
);
|
||||
if (hit) affected.add(skill.name);
|
||||
}
|
||||
return [...affected].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `metadata.cli_surfaces_referenced` from a SKILL.md frontmatter block.
|
||||
* Returns [] when there is no frontmatter, no metadata block, or the field is
|
||||
* absent/malformed (a skill with no CLI surfaces simply isn't in the index).
|
||||
*/
|
||||
export function parseCliSurfaces(skillMarkdown: string): string[] {
|
||||
const match = skillMarkdown.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return [];
|
||||
let meta: unknown;
|
||||
try {
|
||||
meta = parse(match[1]!);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const field = (meta as { metadata?: { cli_surfaces_referenced?: unknown } })
|
||||
?.metadata?.cli_surfaces_referenced;
|
||||
if (!Array.isArray(field)) return [];
|
||||
return field.filter((value): value is string => typeof value === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the skill index from a skills-root directory (offline fs read). Each
|
||||
* immediate subdirectory holding a SKILL.md with a non-empty
|
||||
* `cli_surfaces_referenced` becomes one index entry. Sorted by name.
|
||||
*/
|
||||
export function loadSkillIndex(skillsRoot: string): SkillIndexEntry[] {
|
||||
const entries: SkillIndexEntry[] = [];
|
||||
for (const dirent of readdirSync(skillsRoot, { withFileTypes: true })) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(join(skillsRoot, dirent.name, "SKILL.md"), "utf8");
|
||||
} catch {
|
||||
continue; // no SKILL.md in this dir
|
||||
}
|
||||
const tokens = parseCliSurfaces(text);
|
||||
if (tokens.length > 0) {
|
||||
entries.push({ name: dirent.name, cliSurfacesReferenced: tokens });
|
||||
}
|
||||
}
|
||||
return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// OPR.0.3.3.13.1 - CLI surface-detection parser (Component 1 of slice 13).
|
||||
//
|
||||
// Extracts the Commander command surface from `packages/cli/src/commands/*.ts`
|
||||
// source text via the TypeScript compiler API (already a devDependency; no new
|
||||
// dep per the slice governance ruling). The surface is the set of command paths
|
||||
// (e.g. `scope slice create`) and option name-tokens (e.g. `--body-file`) per
|
||||
// command, taken from the Commander registration chain - NOT from the filename
|
||||
// and NOT from option descriptions (which are frequently template literals).
|
||||
//
|
||||
// Two registration idioms are resolved:
|
||||
// - chained inline subcommands: `cmd.command("create").requiredOption(...)`,
|
||||
// - factory indirection: `parent.addCommand(buildChildCommand())` where the
|
||||
// builder returns `new Command("child")...`.
|
||||
// The registration NAME wins over the filename (the `rig-policy.ts` file's
|
||||
// `new Command("policy")` surfaces as `policy`, never `rig-policy`).
|
||||
|
||||
import ts from "typescript";
|
||||
|
||||
export interface Surface {
|
||||
/** Full command paths, space-joined (e.g. "queue create", "scope slice create"). */
|
||||
commands: Set<string>;
|
||||
/** "<command-path> <--flag>" entries, split on FLAG_SEP. */
|
||||
flags: Set<string>;
|
||||
}
|
||||
|
||||
// NUL separator between a command path and a flag. Command paths are
|
||||
// space-joined, so a space cannot unambiguously split a "<path> <flag>" entry
|
||||
// (the path itself contains spaces). NUL never occurs in a path or flag token,
|
||||
// so it splits cleanly. It lives only inside Set keys and never reaches output.
|
||||
export const FLAG_SEP = "\u0000";
|
||||
|
||||
interface CmdNode {
|
||||
name: string;
|
||||
flags: Set<string>;
|
||||
children: CmdNode[];
|
||||
}
|
||||
|
||||
function firstToken(s: string): string {
|
||||
return s.trim().split(/\s+/)[0] ?? "";
|
||||
}
|
||||
|
||||
function stringLiteralText(node: ts.Node | undefined): string | null {
|
||||
if (!node) return null;
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an option's first argument (the flags string) to its canonical long
|
||||
* flag, ignoring the value placeholder. Examples:
|
||||
* "--body-file <path>" -> "--body-file"
|
||||
* "-l, --literal" -> "--literal"
|
||||
* "--no-mission-notes" -> "--no-mission-notes"
|
||||
* "-y" -> "-y"
|
||||
* Returns null when no flag token is present.
|
||||
*/
|
||||
export function normalizeFlag(flagsArg: string): string | null {
|
||||
const tokens = flagsArg.split(/[\s,]+/).filter(Boolean);
|
||||
const longs = tokens.filter((t) => t.startsWith("--"));
|
||||
if (longs.length > 0) return longs[longs.length - 1]!;
|
||||
const shorts = tokens.filter((t) => /^-[^-]/.test(t));
|
||||
return shorts.length > 0 ? shorts[0]! : null;
|
||||
}
|
||||
|
||||
function unwrap(e: ts.Expression): ts.Expression {
|
||||
let cur = e;
|
||||
while (ts.isParenthesizedExpression(cur)) cur = cur.expression;
|
||||
return cur;
|
||||
}
|
||||
|
||||
function freshNode(rawName: string): CmdNode {
|
||||
return { name: firstToken(rawName), flags: new Set(), children: [] };
|
||||
}
|
||||
|
||||
/** Extract the root command node(s) for a single source file. */
|
||||
function extractFile(fileName: string, text: string): CmdNode[] {
|
||||
const sf = ts.createSourceFile(fileName, text, ts.ScriptTarget.ES2022, true);
|
||||
|
||||
const fnDecls = new Map<string, ts.FunctionDeclaration>();
|
||||
const exportedFnNames: string[] = [];
|
||||
sf.forEachChild((node) => {
|
||||
if (ts.isFunctionDeclaration(node) && node.name) {
|
||||
fnDecls.set(node.name.text, node);
|
||||
const exported = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
if (exported) exportedFnNames.push(node.name.text);
|
||||
}
|
||||
});
|
||||
|
||||
const fnRoots = new Map<string, CmdNode | null>();
|
||||
const inProgress = new Set<string>();
|
||||
|
||||
function rootOfFunction(name: string): CmdNode | null {
|
||||
if (fnRoots.has(name)) return fnRoots.get(name)!;
|
||||
if (inProgress.has(name)) return null; // cycle guard
|
||||
const decl = fnDecls.get(name);
|
||||
if (!decl || !decl.body) {
|
||||
fnRoots.set(name, null);
|
||||
return null;
|
||||
}
|
||||
inProgress.add(name);
|
||||
const root = processBody(decl.body);
|
||||
inProgress.delete(name);
|
||||
fnRoots.set(name, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function processBody(body: ts.Block): CmdNode | null {
|
||||
const vars = new Map<string, CmdNode>();
|
||||
let root: CmdNode | null = null;
|
||||
for (const stmt of body.statements) {
|
||||
if (ts.isVariableStatement(stmt)) {
|
||||
for (const decl of stmt.declarationList.declarations) {
|
||||
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
||||
const node = evalExpr(decl.initializer, vars);
|
||||
if (node) vars.set(decl.name.text, node);
|
||||
}
|
||||
}
|
||||
} else if (ts.isExpressionStatement(stmt)) {
|
||||
evalExpr(stmt.expression, vars); // side effects: .command / .addCommand / .option
|
||||
} else if (ts.isReturnStatement(stmt) && stmt.expression) {
|
||||
root = evalExpr(stmt.expression, vars);
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// Resolve an expression that evaluates to (or mutates) a command node.
|
||||
// `.command()` returns the freshly-created CHILD (Commander semantics);
|
||||
// every other builder method returns the receiver (`this`).
|
||||
function evalExpr(input: ts.Expression, vars: Map<string, CmdNode>): CmdNode | null {
|
||||
const expr = unwrap(input);
|
||||
|
||||
if (ts.isNewExpression(expr) && ts.isIdentifier(expr.expression) && expr.expression.text === "Command") {
|
||||
const nameLit = stringLiteralText(expr.arguments?.[0]);
|
||||
return nameLit == null ? null : freshNode(nameLit);
|
||||
}
|
||||
|
||||
if (ts.isIdentifier(expr)) {
|
||||
return vars.get(expr.text) ?? null;
|
||||
}
|
||||
|
||||
if (ts.isCallExpression(expr)) {
|
||||
const callee = expr.expression;
|
||||
|
||||
// builderFn() -> the root command that function returns
|
||||
if (ts.isIdentifier(callee) && fnDecls.has(callee.text)) {
|
||||
return rootOfFunction(callee.text);
|
||||
}
|
||||
|
||||
if (ts.isPropertyAccessExpression(callee)) {
|
||||
const method = callee.name.text;
|
||||
const recv = evalExpr(callee.expression, vars);
|
||||
|
||||
if (method === "command") {
|
||||
const nameLit = stringLiteralText(expr.arguments[0]);
|
||||
if (nameLit == null) return recv;
|
||||
const child = freshNode(nameLit);
|
||||
if (recv) recv.children.push(child);
|
||||
return child; // chained calls attach to the child
|
||||
}
|
||||
if (method === "option" || method === "requiredOption") {
|
||||
const flagLit = stringLiteralText(expr.arguments[0]);
|
||||
if (recv && flagLit != null) {
|
||||
const f = normalizeFlag(flagLit);
|
||||
if (f) recv.flags.add(f);
|
||||
}
|
||||
return recv;
|
||||
}
|
||||
if (method === "addCommand") {
|
||||
const arg0 = expr.arguments[0];
|
||||
const child = arg0 ? evalExpr(arg0, vars) : null;
|
||||
if (recv && child) recv.children.push(child);
|
||||
return recv;
|
||||
}
|
||||
// description / action / argument / alias / addHelpText / etc. -> receiver
|
||||
return recv;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const roots: CmdNode[] = [];
|
||||
const seen = new Set<CmdNode>();
|
||||
for (const name of exportedFnNames) {
|
||||
const r = rootOfFunction(name);
|
||||
if (r && !seen.has(r)) {
|
||||
roots.push(r);
|
||||
seen.add(r);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function walk(node: CmdNode, prefix: string[], surface: Surface): void {
|
||||
const path = [...prefix, node.name];
|
||||
const pathStr = path.join(" ");
|
||||
surface.commands.add(pathStr);
|
||||
for (const f of node.flags) surface.flags.add(pathStr + FLAG_SEP + f);
|
||||
for (const child of node.children) walk(child, path, surface);
|
||||
}
|
||||
|
||||
/** Build the combined command surface from a set of `{name, text}` sources. */
|
||||
export function extractSurfaceFromSources(files: { name: string; text: string }[]): Surface {
|
||||
const surface: Surface = { commands: new Set(), flags: new Set() };
|
||||
for (const file of files) {
|
||||
for (const root of extractFile(file.name, file.text)) {
|
||||
walk(root, [], surface);
|
||||
}
|
||||
}
|
||||
return surface;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// OPR.0.3.3.13.1 - sample-diff generator entry.
|
||||
//
|
||||
// Runnable after `npm run build`:
|
||||
// node dist/release-surface/generate.js --from v0.3.1 --to v0.3.2 --out <path>
|
||||
// Emits the deterministic release-surface diff to --out (or stdout). Not wired
|
||||
// into the shipped `rig` binary (that placement is a 13.3 decision); this is the
|
||||
// POC proof-artifact generator.
|
||||
|
||||
import fs from "node:fs";
|
||||
|
||||
import { generateSurfaceDiff, diffToYaml, SurfaceParserError } from "./surface-diff.js";
|
||||
|
||||
function parseArgs(argv: string[]): Record<string, string> {
|
||||
const args: Record<string, string> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!;
|
||||
if (a.startsWith("--")) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next !== undefined && !next.startsWith("--")) {
|
||||
args[key] = next;
|
||||
i++;
|
||||
} else {
|
||||
args[key] = "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function main(argv: string[]): number {
|
||||
const args = parseArgs(argv);
|
||||
try {
|
||||
const diff = generateSurfaceDiff({ from: args.from, to: args.to, cwd: args.cwd });
|
||||
const yaml = diffToYaml(diff);
|
||||
if (args.out) {
|
||||
fs.writeFileSync(args.out, yaml);
|
||||
process.stderr.write(`release-surface diff written to ${args.out}\n`);
|
||||
} else {
|
||||
process.stdout.write(yaml);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (err instanceof SurfaceParserError) {
|
||||
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
|
||||
return 1;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
@@ -0,0 +1,46 @@
|
||||
release_from: v0.3.1
|
||||
release_to: v0.3.2
|
||||
added_commands:
|
||||
- name: policy
|
||||
subcommands:
|
||||
- cite
|
||||
- defaults
|
||||
- effective
|
||||
- set
|
||||
- show
|
||||
- unset
|
||||
- name: scope
|
||||
subcommands:
|
||||
- mission
|
||||
- mission create
|
||||
- mission ls
|
||||
- mission show
|
||||
- slice
|
||||
- slice close
|
||||
- slice create
|
||||
- slice ls
|
||||
- slice move
|
||||
- slice ship
|
||||
- slice show
|
||||
added_flags:
|
||||
- command: bundle
|
||||
subcommands:
|
||||
- history
|
||||
- command: bundle create
|
||||
flags:
|
||||
- --min-cli-version
|
||||
- --min-daemon-version
|
||||
- --notes
|
||||
- command: bundle install
|
||||
flags:
|
||||
- --force
|
||||
- --skip-version-check
|
||||
- command: queue create
|
||||
flags:
|
||||
- --body-file
|
||||
- --mission
|
||||
- --slice
|
||||
- command: workspace
|
||||
subcommands:
|
||||
- doctor
|
||||
removed_or_renamed: []
|
||||
@@ -0,0 +1,250 @@
|
||||
// OPR.0.3.3.13.1 - CLI surface diff (Component 1 of slice 13).
|
||||
//
|
||||
// Given two git refs, reads `packages/cli/src/commands/*.ts` at each ref via
|
||||
// `git show`, extracts the Commander surface (extract-surface.ts), diffs them,
|
||||
// and emits a deterministic `release-surface-diff.yaml`. Fully offline and
|
||||
// deterministic: the only inputs are the two refs + the repo's git object store;
|
||||
// no network, no agent/LLM involvement (the deterministic half of slice 13's
|
||||
// "deterministic for enumeration, agent for voice" split).
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import { extractSurfaceFromSources, FLAG_SEP, type Surface } from "./extract-surface.js";
|
||||
|
||||
const COMMANDS_DIR = "packages/cli/src/commands";
|
||||
|
||||
export interface AddedCommand {
|
||||
name: string;
|
||||
subcommands: string[];
|
||||
}
|
||||
export interface AddedFlagsEntry {
|
||||
command: string;
|
||||
flags?: string[];
|
||||
subcommands?: string[];
|
||||
}
|
||||
export interface SurfaceDiff {
|
||||
release_from: string;
|
||||
release_to: string;
|
||||
added_commands: AddedCommand[];
|
||||
added_flags: AddedFlagsEntry[];
|
||||
removed_or_renamed: string[];
|
||||
}
|
||||
|
||||
/** Honest 3-part error matching the repo's CLI error shape (queue.ts:88-111). */
|
||||
export class SurfaceParserError extends Error {
|
||||
readonly fact: string;
|
||||
readonly consequence: string;
|
||||
readonly action: string;
|
||||
constructor(parts: { fact: string; consequence: string; action: string }) {
|
||||
super(parts.fact);
|
||||
this.name = "SurfaceParserError";
|
||||
this.fact = parts.fact;
|
||||
this.consequence = parts.consequence;
|
||||
this.action = parts.action;
|
||||
}
|
||||
}
|
||||
|
||||
function git(repoRoot: string, args: string[]): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function firstToken(path: string): string {
|
||||
return path.split(" ")[0] ?? "";
|
||||
}
|
||||
|
||||
function splitFlagEntry(entry: string): { command: string; flag: string } {
|
||||
const i = entry.indexOf(FLAG_SEP);
|
||||
return { command: entry.slice(0, i), flag: entry.slice(i + FLAG_SEP.length) };
|
||||
}
|
||||
|
||||
export function resolveRepoRoot(cwd: string): string {
|
||||
try {
|
||||
return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
|
||||
} catch {
|
||||
throw new SurfaceParserError({
|
||||
fact: `Not inside a git repository (cwd: ${cwd}).`,
|
||||
consequence: "The surface parser reads CLI sources at two git refs; without a repo it cannot run.",
|
||||
action: "Run from inside the openrig git repository, or pass an explicit repoRoot.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface TreeEntry {
|
||||
sha: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function listCommandTree(repoRoot: string, ref: string): TreeEntry[] {
|
||||
let out: string;
|
||||
try {
|
||||
out = git(repoRoot, ["ls-tree", ref, `${COMMANDS_DIR}/`]);
|
||||
} catch (err) {
|
||||
throw new SurfaceParserError({
|
||||
fact: `Could not list ${COMMANDS_DIR}/ at ref "${ref}": ${String((err as Error).message).split("\n")[0]}`,
|
||||
consequence: "The CLI surface for that ref could not be read, so no release-surface diff was produced.",
|
||||
action: `Verify the ref exists (git rev-parse "${ref}") and that ${COMMANDS_DIR}/ is present at that ref.`,
|
||||
});
|
||||
}
|
||||
const entries: TreeEntry[] = [];
|
||||
for (const line of out.split("\n")) {
|
||||
// "<mode> blob <sha>\t<path>"
|
||||
const m = line.match(/^\S+\s+blob\s+(\S+)\t(.+)$/);
|
||||
if (!m) continue;
|
||||
const path = m[2]!;
|
||||
if (path.endsWith(".ts") && !path.endsWith(".test.ts") && !path.endsWith("/index.ts")) {
|
||||
entries.push({ sha: m[1]!, path });
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
throw new SurfaceParserError({
|
||||
fact: `No Commander command files found under ${COMMANDS_DIR}/ at ref "${ref}".`,
|
||||
consequence: "An empty command layout would make the diff misreport the entire surface as removed or added.",
|
||||
action: `Confirm ${COMMANDS_DIR}/ exists at "${ref}" and holds the CLI command registrations.`,
|
||||
});
|
||||
}
|
||||
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
||||
return entries;
|
||||
}
|
||||
|
||||
// One `git cat-file --batch` reads every blob for a ref (vs one `git show` per
|
||||
// file). Keeps the parser fast enough to run repeatedly (and the determinism
|
||||
// test under timeout). Byte-accurate: cat-file emits `<oid> blob <size>\n` then
|
||||
// exactly <size> content bytes then `\n`, per requested oid, in request order.
|
||||
function readBlobs(repoRoot: string, entries: TreeEntry[]): Map<string, string> {
|
||||
const input = entries.map((e) => e.sha).join("\n") + "\n";
|
||||
const buf = execFileSync("git", ["cat-file", "--batch"], {
|
||||
cwd: repoRoot,
|
||||
input,
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
}) as unknown as Buffer;
|
||||
const result = new Map<string, string>();
|
||||
let pos = 0;
|
||||
for (const entry of entries) {
|
||||
const nl = buf.indexOf(0x0a, pos);
|
||||
const header = buf.toString("utf8", pos, nl);
|
||||
const size = Number.parseInt(header.split(" ")[2] ?? "", 10);
|
||||
if (!Number.isFinite(size)) {
|
||||
throw new SurfaceParserError({
|
||||
fact: `git cat-file returned an unreadable record for blob ${entry.sha} ("${header}").`,
|
||||
consequence: "The command source could not be read, so the surface diff would be wrong.",
|
||||
action: `Verify the repository object store is intact (git fsck) and the ref is valid.`,
|
||||
});
|
||||
}
|
||||
const contentStart = nl + 1;
|
||||
result.set(entry.sha, buf.toString("utf8", contentStart, contentStart + size));
|
||||
pos = contentStart + size + 1; // skip content + trailing newline
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readSurfaceAtRef(repoRoot: string, ref: string): Surface {
|
||||
const entries = listCommandTree(repoRoot, ref);
|
||||
const blobs = readBlobs(repoRoot, entries);
|
||||
const sources = entries.map((e) => ({ name: e.path, text: blobs.get(e.sha) ?? "" }));
|
||||
return extractSurfaceFromSources(sources);
|
||||
}
|
||||
|
||||
/** Pure diff of two extracted surfaces into the slice-13 YAML shape. */
|
||||
export function computeDiff(
|
||||
from: Surface,
|
||||
to: Surface,
|
||||
releaseFrom: string,
|
||||
releaseTo: string,
|
||||
): SurfaceDiff {
|
||||
const addedCmdPaths = [...to.commands].filter((c) => !from.commands.has(c));
|
||||
const removedCmdPaths = [...from.commands].filter((c) => !to.commands.has(c));
|
||||
const addedFlagEntries = [...to.flags].filter((f) => !from.flags.has(f));
|
||||
const removedFlagEntries = [...from.flags].filter((f) => !to.flags.has(f));
|
||||
|
||||
const addedCmdSet = new Set(addedCmdPaths);
|
||||
// A top-level command is brand-new when its single-token path was added.
|
||||
const newTopCommands = new Set(addedCmdPaths.filter((p) => !p.includes(" ")));
|
||||
|
||||
// added_commands: each brand-new top-level command + its added sub-paths.
|
||||
const added_commands: AddedCommand[] = [...newTopCommands].sort().map((name) => ({
|
||||
name,
|
||||
subcommands: addedCmdPaths
|
||||
.filter((p) => p !== name && firstToken(p) === name)
|
||||
.map((p) => p.split(" ").slice(1).join(" "))
|
||||
.sort(),
|
||||
}));
|
||||
|
||||
// added_flags: PRE-EXISTING commands that gained flags (keyed by full command
|
||||
// path) and/or new subcommands (keyed by their existing top-level command).
|
||||
const flagsByCommand = new Map<string, Set<string>>();
|
||||
for (const entry of addedFlagEntries) {
|
||||
const { command, flag } = splitFlagEntry(entry);
|
||||
if (newTopCommands.has(firstToken(command))) continue; // new command -> flags implied by added_commands
|
||||
if (addedCmdSet.has(command)) continue; // brand-new subcommand -> flags implied by its listing
|
||||
if (!flagsByCommand.has(command)) flagsByCommand.set(command, new Set());
|
||||
flagsByCommand.get(command)!.add(flag);
|
||||
}
|
||||
const subsByTop = new Map<string, Set<string>>();
|
||||
for (const p of addedCmdPaths) {
|
||||
if (!p.includes(" ")) continue; // brand-new top-level command (added_commands)
|
||||
const top = firstToken(p);
|
||||
if (newTopCommands.has(top)) continue; // subtree of a brand-new top-level command
|
||||
if (!subsByTop.has(top)) subsByTop.set(top, new Set());
|
||||
subsByTop.get(top)!.add(p.split(" ").slice(1).join(" "));
|
||||
}
|
||||
const changedCommands = new Set<string>([...flagsByCommand.keys(), ...subsByTop.keys()]);
|
||||
const added_flags: AddedFlagsEntry[] = [...changedCommands].sort().map((command) => {
|
||||
const entry: AddedFlagsEntry = { command };
|
||||
const fl = flagsByCommand.get(command);
|
||||
if (fl && fl.size > 0) entry.flags = [...fl].sort();
|
||||
const sub = subsByTop.get(command);
|
||||
if (sub && sub.size > 0) entry.subcommands = [...sub].sort();
|
||||
return entry;
|
||||
});
|
||||
|
||||
const removed_or_renamed = [
|
||||
...removedCmdPaths,
|
||||
...removedFlagEntries.map((e) => {
|
||||
const { command, flag } = splitFlagEntry(e);
|
||||
return `${command} ${flag}`;
|
||||
}),
|
||||
].sort();
|
||||
|
||||
return { release_from: releaseFrom, release_to: releaseTo, added_commands, added_flags, removed_or_renamed };
|
||||
}
|
||||
|
||||
/** Default `--from`: the latest `v*` tag reachable before `to`. */
|
||||
function defaultFrom(repoRoot: string, to: string): string {
|
||||
try {
|
||||
return git(repoRoot, ["describe", "--tags", "--abbrev=0", "--match", "v*", `${to}^`]).trim();
|
||||
} catch {
|
||||
try {
|
||||
return git(repoRoot, ["describe", "--tags", "--abbrev=0", "--match", "v*"]).trim();
|
||||
} catch {
|
||||
throw new SurfaceParserError({
|
||||
fact: "No release tag could be resolved for the default --from ref.",
|
||||
consequence: "Without a prior release ref the surface diff cannot be computed.",
|
||||
action: "Pass an explicit --from <ref> (e.g. --from v0.3.1).",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSurfaceDiff(opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
cwd?: string;
|
||||
repoRoot?: string;
|
||||
}): SurfaceDiff {
|
||||
const repoRoot = opts.repoRoot ?? resolveRepoRoot(opts.cwd ?? process.cwd());
|
||||
const to = opts.to ?? "HEAD";
|
||||
const from = opts.from ?? defaultFrom(repoRoot, to);
|
||||
const fromSurface = readSurfaceAtRef(repoRoot, from);
|
||||
const toSurface = readSurfaceAtRef(repoRoot, to);
|
||||
return computeDiff(fromSurface, toSurface, from, to);
|
||||
}
|
||||
|
||||
/** Deterministic YAML serialization (stable key + element order). */
|
||||
export function diffToYaml(diff: SurfaceDiff): string {
|
||||
return stringify(diff, { sortMapEntries: false });
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
# OPR.0.3.3.13.2 - AC-3b expected affected-skills set for the v0.3.1->v0.3.2 release diff.
|
||||
#
|
||||
# Self-contained FROZEN snapshot so the lookup test is deterministic + portable
|
||||
# (no dependency on the substrate skills-corpus path). `skills` is the v0.3.2-era
|
||||
# metadata.cli_surfaces_referenced backfill across the 45 CLI-referencing canonical
|
||||
# skills; `affected_skills` is the expected output of
|
||||
# computeAffectedSkills(release-surface-diff.v0.3.1-v0.3.2.yaml, skills). The test
|
||||
# recomputes from `skills` and asserts it equals `affected_skills` - so it proves
|
||||
# BOTH zero false-negatives (all 11 affected present) AND zero false-positives (the
|
||||
# other 34 absent). Regenerate when the v0.3.2 backfill changes.
|
||||
release_from: v0.3.1
|
||||
release_to: v0.3.2
|
||||
affected_skills:
|
||||
- context-architecture
|
||||
- control-plane-queue
|
||||
- openrig-operator
|
||||
- openrig-user
|
||||
- parent-qitem-coordinator
|
||||
- queue-handoff
|
||||
- rig-bundles-and-shareable-artifacts
|
||||
- rsi-orchestrator-operating
|
||||
- specification-system
|
||||
- vm-e2e
|
||||
- workspace-root-and-repo-context
|
||||
skills:
|
||||
agent-startup-and-context-ingestion:
|
||||
- context
|
||||
- whoami
|
||||
attention-queue:
|
||||
- chatroom
|
||||
- send
|
||||
building-agent-software:
|
||||
- adopt
|
||||
- discover
|
||||
- down
|
||||
- export
|
||||
- import
|
||||
- ps
|
||||
- restore
|
||||
- status
|
||||
- up
|
||||
claude-compact-in-place:
|
||||
- send
|
||||
- transcript
|
||||
- whoami
|
||||
cmux-rig-layout:
|
||||
- cmux-layout
|
||||
- send
|
||||
- up
|
||||
codex-seat-auth-refresh:
|
||||
- launch
|
||||
- ps
|
||||
containerized-e2e:
|
||||
- daemon start
|
||||
- daemon stop
|
||||
- doctor
|
||||
- down
|
||||
- expand
|
||||
- export
|
||||
- preflight
|
||||
- ps
|
||||
- restore
|
||||
- snapshot list
|
||||
- up
|
||||
context-architecture:
|
||||
- chatroom
|
||||
- context-pack send
|
||||
- queue
|
||||
- send
|
||||
control-plane-capabilities:
|
||||
- chatroom
|
||||
- send
|
||||
control-plane-queue:
|
||||
- queue create
|
||||
- queue handoff
|
||||
- queue list
|
||||
- queue show
|
||||
- queue update
|
||||
- send
|
||||
control-plane-workflows:
|
||||
- ps
|
||||
cross-host-rig-commands:
|
||||
- capture
|
||||
- daemon start
|
||||
- host list
|
||||
- host show
|
||||
- ps
|
||||
- send
|
||||
- whoami
|
||||
extension-and-user-workspace:
|
||||
- workflow
|
||||
externalized-memory-surfaces:
|
||||
- ask
|
||||
- down
|
||||
- transcript
|
||||
- up
|
||||
feature-rollout:
|
||||
- broadcast
|
||||
- send
|
||||
forming-an-openrig-mental-model:
|
||||
- ask
|
||||
- capture
|
||||
- down
|
||||
- ps
|
||||
- send
|
||||
- transcript
|
||||
- up
|
||||
- whoami
|
||||
high-velocity-implementation-pair:
|
||||
- capture
|
||||
- send
|
||||
- whoami
|
||||
human-agent-operator-posture:
|
||||
- bootstrap
|
||||
- destroy
|
||||
- ps
|
||||
- queue handoff
|
||||
- queue handoff-and-complete
|
||||
looping-workflows:
|
||||
- queue handoff
|
||||
- queue handoff-and-complete
|
||||
- send
|
||||
- up
|
||||
native-session-file-lab-boundary:
|
||||
- send
|
||||
- transcript
|
||||
openrig-architect:
|
||||
- agent validate
|
||||
- capture
|
||||
- daemon start
|
||||
- ps
|
||||
- send
|
||||
- spec validate
|
||||
- specs ls
|
||||
- up
|
||||
- whoami
|
||||
openrig-conventions-codemap:
|
||||
- send
|
||||
openrig-operator:
|
||||
- capture
|
||||
- config get
|
||||
- daemon status
|
||||
- ps
|
||||
- queue
|
||||
- queue create
|
||||
- queue show
|
||||
- queue-legacy
|
||||
- send
|
||||
- whoami
|
||||
openrig-upgrade:
|
||||
- capture
|
||||
- daemon adopt-state
|
||||
- daemon start
|
||||
- daemon status
|
||||
- daemon stop
|
||||
- down
|
||||
- ps
|
||||
- restore-check
|
||||
- snapshot
|
||||
- up
|
||||
- upgrade backup
|
||||
- upgrade preflight
|
||||
- upgrade promote-runtime
|
||||
- upgrade verify
|
||||
- version
|
||||
openrig-user:
|
||||
- adopt
|
||||
- agent validate
|
||||
- ask
|
||||
- attach
|
||||
- bind
|
||||
- bootstrap
|
||||
- broadcast
|
||||
- bundle create
|
||||
- bundle inspect
|
||||
- bundle install
|
||||
- capture
|
||||
- chatroom clear
|
||||
- chatroom history
|
||||
- chatroom send
|
||||
- chatroom topic
|
||||
- chatroom wait
|
||||
- chatroom watch
|
||||
- config
|
||||
- daemon status
|
||||
- discover
|
||||
- doctor
|
||||
- down
|
||||
- env
|
||||
- expand
|
||||
- export
|
||||
- import
|
||||
- launch
|
||||
- mcp serve
|
||||
- package install
|
||||
- package list
|
||||
- package plan
|
||||
- package rollback
|
||||
- package validate
|
||||
- preflight
|
||||
- project
|
||||
- ps
|
||||
- queue
|
||||
- queue create
|
||||
- release
|
||||
- remove
|
||||
- requirements
|
||||
- restore
|
||||
- send
|
||||
- shrink
|
||||
- snapshot
|
||||
- snapshot list
|
||||
- spec preflight
|
||||
- spec validate
|
||||
- specs add
|
||||
- specs ls
|
||||
- specs preview
|
||||
- specs remove
|
||||
- specs rename
|
||||
- specs show
|
||||
- specs sync
|
||||
- status
|
||||
- stream
|
||||
- transcript
|
||||
- unclaim
|
||||
- up
|
||||
- view
|
||||
- watchdog
|
||||
- whoami
|
||||
- workflow
|
||||
parent-qitem-coordinator:
|
||||
- queue
|
||||
- queue create
|
||||
permission-posture:
|
||||
- down
|
||||
- up
|
||||
primitive-workspace:
|
||||
- capture
|
||||
- send
|
||||
queue-handoff:
|
||||
- queue
|
||||
- queue create
|
||||
- queue handoff
|
||||
- queue handoff-and-complete
|
||||
research-task:
|
||||
- send
|
||||
- teleport codex
|
||||
rig-bundles-and-shareable-artifacts:
|
||||
- bundle create
|
||||
- bundle inspect
|
||||
- bundle install
|
||||
rig-lifecycle:
|
||||
- destroy
|
||||
- down
|
||||
- ps
|
||||
- release
|
||||
- restore
|
||||
- restore-check
|
||||
- resume
|
||||
- snapshot
|
||||
- unclaim
|
||||
- up
|
||||
rsi-orchestrator-operating:
|
||||
- capture
|
||||
- queue
|
||||
- queue handoff
|
||||
- send
|
||||
runtime-evidence-vocabulary:
|
||||
- ask
|
||||
- ps
|
||||
- restore-check
|
||||
seat-scaling-and-specialization:
|
||||
- adopt
|
||||
- attach
|
||||
- bind
|
||||
- expand
|
||||
- launch
|
||||
- ps
|
||||
- remove
|
||||
- shrink
|
||||
- whoami
|
||||
seating-live-claude-session-into-rig:
|
||||
- bind
|
||||
- capture
|
||||
- discover
|
||||
- ps
|
||||
- restore
|
||||
- whoami
|
||||
security-and-consequence-boundary-policy:
|
||||
- destroy
|
||||
- down
|
||||
- up
|
||||
session-compaction-and-restore:
|
||||
- whoami
|
||||
specification-system:
|
||||
- agent
|
||||
- bundle
|
||||
- spec
|
||||
- specs
|
||||
status-not-chat-orchestrator:
|
||||
- capture
|
||||
- send
|
||||
topology-mutation-and-seat-management:
|
||||
- adopt
|
||||
- attach
|
||||
- bind
|
||||
- discover
|
||||
- expand
|
||||
- launch
|
||||
- ps
|
||||
- release
|
||||
- remove
|
||||
- shrink
|
||||
- unclaim
|
||||
- up
|
||||
- whoami
|
||||
velocity-team-shared:
|
||||
- send
|
||||
vm-e2e:
|
||||
- capture
|
||||
- daemon
|
||||
- daemon start
|
||||
- daemon status
|
||||
- daemon stop
|
||||
- doctor
|
||||
- ps
|
||||
- queue
|
||||
- send
|
||||
- setup
|
||||
- up
|
||||
workflow-runtime:
|
||||
- workflow continue
|
||||
- workflow instantiate
|
||||
- workflow list
|
||||
- workflow project
|
||||
- workflow show
|
||||
- workflow trace
|
||||
- workflow validate
|
||||
workspace-root-and-repo-context:
|
||||
- bundle install
|
||||
- ps
|
||||
- up
|
||||
- whoami
|
||||
@@ -0,0 +1,37 @@
|
||||
# Known v0.3.2 CLI surface additions - AC-2 self-validation fixture (OPR.0.3.3.13.1).
|
||||
#
|
||||
# Each entry is INDEPENDENTLY derived from the release notes + corroborating
|
||||
# sources, then cross-checked against the parser output. It is the no-false-
|
||||
# negative floor AND (by construction here) the exact expected added-flags set,
|
||||
# so the test also asserts no parser over/under-reporting at command granularity.
|
||||
#
|
||||
# Sources (cited; all at main@8b2cb181):
|
||||
# - PRIMARY: docs/releases/v0.3.2.md
|
||||
# - CORROBORATING: CHANGELOG.md v0.3.2 section
|
||||
# - GROUND TRUTH: git diff --name-status v0.3.1 v0.3.2 -- packages/cli/src/commands/
|
||||
# (ADDED: scope.ts, rig-policy.ts; MODIFIED: bundle/config-init-workspace/ps/queue/up/workflow/workspace)
|
||||
#
|
||||
# Registration-name-not-filename: `policy` is `new Command("policy")` at
|
||||
# rig-policy.ts:150 - the FILENAME is rig-policy, the command name is policy.
|
||||
#
|
||||
# AC-2 RECONCILIATION NOTE (parser vs release-doc prose):
|
||||
# `rig up --existing` is NOT listed here. The release doc narrates `--existing`
|
||||
# under the "up paper-cut fixes" story (line 219), but the flag already existed
|
||||
# at v0.3.1 (up.ts:32) and is unchanged at v0.3.2 (up.ts:40), so it is NOT a
|
||||
# v0.3.1->v0.3.2 surface addition. The parser (source-grounded) correctly omits
|
||||
# it; an earlier fixture draft that included it was a prose over-read, caught by
|
||||
# this self-validation. Source/tag-diff is authoritative over release prose.
|
||||
added_commands:
|
||||
- name: policy # rig-policy.ts:150 new Command("policy"); doc L79-86, 166-189
|
||||
- name: scope # scope.ts:690 new Command("scope"); doc L87-93, 190-205
|
||||
added_flags:
|
||||
- command: queue create # queue.ts:156-158; doc L60-64, 148-164
|
||||
flags: [--body-file, --mission, --slice]
|
||||
- command: workspace # doc L65-68, 131-146 (new `doctor` subcommand)
|
||||
subcommands: [doctor]
|
||||
- command: bundle # doc L110, 127-129 (new `history` subcommand)
|
||||
subcommands: [history]
|
||||
- command: bundle create # doc L104-105 (version-gate + notes flags)
|
||||
flags: [--min-cli-version, --min-daemon-version, --notes]
|
||||
- command: bundle install # doc L109, 122-125 (operator-explicit override flags)
|
||||
flags: [--force, --skip-version-check]
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { addMemberCommand } from "../src/commands/add.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import http from "node:http";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try { await fn(); } finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
const OK_RESPONSE = {
|
||||
ok: true,
|
||||
result: {
|
||||
podId: "pod-123",
|
||||
podNamespace: "infra",
|
||||
node: { logicalId: "infra.server2", nodeId: "n2", status: "launched", sessionName: "infra-server2@test" },
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
|
||||
const CONFLICT_RESPONSE = {
|
||||
ok: false,
|
||||
code: "member_conflict",
|
||||
message: 'Member "infra.server" already exists in rig "test". Pick a different member id, or remove the existing seat first.',
|
||||
};
|
||||
|
||||
const NOT_FOUND_RESPONSE = {
|
||||
ok: false,
|
||||
code: "pod_not_found",
|
||||
message: 'Pod "nope" not found in rig "test". Existing pods: infra. Check the namespace, or add a new pod with `rig expand`.',
|
||||
};
|
||||
|
||||
const FAILED_LAUNCH_RESPONSE = {
|
||||
ok: true,
|
||||
result: {
|
||||
podId: "pod-123",
|
||||
podNamespace: "infra",
|
||||
node: { logicalId: "infra.server2", nodeId: "n2", status: "failed", error: "harness launch failed" },
|
||||
warnings: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("rig add", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let tmpDir: string;
|
||||
let fragmentPath: string;
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.method === "POST" && req.url?.includes("/members")) {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body);
|
||||
capturedBody = parsed;
|
||||
const memberId = parsed.member?.id;
|
||||
if (req.url?.includes("/nope/")) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(NOT_FOUND_RESPONSE));
|
||||
} else if (memberId === "server") {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(CONFLICT_RESPONSE));
|
||||
} else if (memberId === "failer") {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(FAILED_LAUNCH_RESPONSE));
|
||||
} else {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(OK_RESPONSE));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((r) => { server.listen(0, r); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = join(tmpdir(), `add-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
fragmentPath = join(tmpDir, "member.yaml");
|
||||
writeFileSync(fragmentPath, `id: server2\nruntime: terminal\nagent_ref: "builtin:terminal"\nprofile: none\ncwd: /tmp\n`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function runningDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-04-07T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
clientFactory: (url) => new DaemonClient(url),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCmd(deps?: StatusDeps): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(addMemberCommand(deps ?? runningDeps()));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("parses rig-id, pod-namespace, and member-fragment-path", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("infra.server2");
|
||||
expect(output).toContain("OK");
|
||||
});
|
||||
|
||||
it("--json returns the raw API response", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath, "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.result.node.logicalId).toBe("infra.server2");
|
||||
});
|
||||
|
||||
it("human output shows the launched member with its session", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("[OK] infra.server2");
|
||||
expect(output).toContain("infra-server2@test");
|
||||
});
|
||||
|
||||
it("duplicate member id -> exit 1 with the honest conflict message", async () => {
|
||||
writeFileSync(fragmentPath, `id: server\nruntime: terminal\nagent_ref: "builtin:terminal"\nprofile: none\ncwd: /tmp\n`);
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("already exists");
|
||||
expect(output).toContain("Pick a different member id");
|
||||
});
|
||||
|
||||
it("pod not found -> exit 1 with the honest not-found message", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "nope", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("not found");
|
||||
expect(output).toContain("Existing pods: infra");
|
||||
});
|
||||
|
||||
it("launched-but-failed node -> exit 1 (status surfaced)", async () => {
|
||||
writeFileSync(fragmentPath, `id: failer\nruntime: terminal\nagent_ref: "builtin:terminal"\nprofile: none\ncwd: /tmp\n`);
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("[FAIL] infra.server2");
|
||||
expect(output).toContain("harness launch failed");
|
||||
});
|
||||
|
||||
it("--json exits non-zero when the new node did not launch", async () => {
|
||||
writeFileSync(fragmentPath, `id: failer\nruntime: terminal\nagent_ref: "builtin:terminal"\nprofile: none\ncwd: /tmp\n`);
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath, "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.result.node.status).toBe("failed");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("wired via createProgram", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const program = createProgram();
|
||||
const cmd = program.commands.find((c) => c.name() === "add");
|
||||
expect(cmd).toBeDefined();
|
||||
});
|
||||
|
||||
// Governance FM2: rig add must NOT silently strip pod-local edges from the
|
||||
// fragment file - they must reach the daemon, in both wrapper and bare forms.
|
||||
it("forwards wrapper-form edges to the daemon (not stripped)", async () => {
|
||||
capturedBody = null;
|
||||
writeFileSync(fragmentPath, `member:\n id: server2\n runtime: terminal\n agent_ref: "builtin:terminal"\n profile: none\n cwd: /tmp\nedges:\n - from: server2\n to: server\n kind: delegates_to\n`);
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath]);
|
||||
});
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect((capturedBody as { member: { id: string } }).member.id).toBe("server2");
|
||||
expect((capturedBody as { edges: unknown[] }).edges).toEqual([{ from: "server2", to: "server", kind: "delegates_to" }]);
|
||||
});
|
||||
|
||||
it("lifts bare-form top-level edges out to pod-local edges (not dropped into the member)", async () => {
|
||||
capturedBody = null;
|
||||
writeFileSync(fragmentPath, `id: server2\nruntime: terminal\nagent_ref: "builtin:terminal"\nprofile: none\ncwd: /tmp\nedges:\n - from: server2\n to: server\n kind: delegates_to\n`);
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath]);
|
||||
});
|
||||
const sent = capturedBody as { member: Record<string, unknown>; edges: unknown[] };
|
||||
expect(sent.edges).toEqual([{ from: "server2", to: "server", kind: "delegates_to" }]);
|
||||
// edges lifted OUT of the member, not silently carried as an ignored field.
|
||||
expect(sent.member["edges"]).toBeUndefined();
|
||||
expect(sent.member["id"]).toBe("server2");
|
||||
});
|
||||
|
||||
it("rejects a present-but-non-array edges field (exit 1, never posted, no silent omit)", async () => {
|
||||
capturedBody = null;
|
||||
writeFileSync(fragmentPath, `member:\n id: server2\n runtime: terminal\n agent_ref: "builtin:terminal"\n profile: none\n cwd: /tmp\nedges: not-an-array\n`);
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "add", "rig-123", "infra", fragmentPath]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("edges");
|
||||
// Rejected before the POST - never silently omitted and sent.
|
||||
expect(capturedBody).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// OPR.0.3.3.13.2 AC-3b - affected-skill OUTPUT validated against the NEW
|
||||
// checked-in v0.3.2-affected-skills expected-set fixture (a frozen snapshot of
|
||||
// the backfill + the expected lookup output). Self-contained: recomputes from
|
||||
// the fixture's `skills` map, so it is deterministic + portable and does NOT
|
||||
// read the live substrate corpus. Proves zero false-negatives (all affected
|
||||
// present) AND zero false-positives (the rest absent).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import { computeAffectedSkills, type SkillIndexEntry } from "../src/release-surface/affected-skills.js";
|
||||
import type { SurfaceDiff } from "../src/release-surface/surface-diff.js";
|
||||
|
||||
function repoPath(rel: string): string {
|
||||
return fileURLToPath(new URL(`../${rel}`, import.meta.url));
|
||||
}
|
||||
function readYaml<T>(rel: string): T {
|
||||
return parseYaml(fs.readFileSync(repoPath(rel), "utf8")) as T;
|
||||
}
|
||||
|
||||
interface AffectedFixture {
|
||||
release_from: string;
|
||||
release_to: string;
|
||||
affected_skills: string[];
|
||||
skills: Record<string, string[]>;
|
||||
}
|
||||
|
||||
describe("affected-skills AC-3b - v0.3.2 expected affected-skills fixture", () => {
|
||||
const fixture = readYaml<AffectedFixture>("src/release-surface/v0.3.2-affected-skills.fixture.yaml");
|
||||
const diff = readYaml<SurfaceDiff>("src/release-surface/release-surface-diff.v0.3.1-v0.3.2.yaml");
|
||||
const skills: SkillIndexEntry[] = Object.entries(fixture.skills).map(
|
||||
([name, cliSurfacesReferenced]) => ({ name, cliSurfacesReferenced }),
|
||||
);
|
||||
|
||||
it("computeAffectedSkills over the frozen v0.3.2 backfill equals the expected affected_skills", () => {
|
||||
const got = computeAffectedSkills(diff, skills);
|
||||
// zero false-negative AND zero false-positive vs the expected set
|
||||
expect(got).toEqual([...fixture.affected_skills].sort());
|
||||
});
|
||||
|
||||
it("the non-affected remainder stays out (false-positive floor)", () => {
|
||||
const affected = new Set(fixture.affected_skills);
|
||||
const notAffected = skills.map((s) => s.name).filter((n) => !affected.has(n));
|
||||
expect(skills.length).toBe(45);
|
||||
expect(fixture.affected_skills.length).toBe(11);
|
||||
expect(notAffected.length).toBe(34);
|
||||
// representative not-affected skills (no queue-create / bundle / workspace token)
|
||||
for (const n of ["openrig-upgrade", "rig-lifecycle", "feature-rollout", "openrig-architect"]) {
|
||||
expect(fixture.affected_skills).not.toContain(n);
|
||||
}
|
||||
});
|
||||
|
||||
it("every affected skill is explained by a v0.3.2 added surface (queue create / bundle* / workspace)", () => {
|
||||
// each affected skill must carry at least one token whose first segment is
|
||||
// one of v0.3.2's actually-added command roots - no unexplained inclusion.
|
||||
const roots = new Set(["queue", "bundle", "workspace", "scope", "policy"]);
|
||||
for (const name of fixture.affected_skills) {
|
||||
const tokens = fixture.skills[name] ?? [];
|
||||
const explained = tokens.some((t) => roots.has(t.replace(/^rig\s+/, "").split(/\s+/)[0]!));
|
||||
expect(explained, `${name} should carry a v0.3.2-root token`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
// OPR.0.3.3.13.2 - Skill <-> CLI-surface binding-index lookup tests.
|
||||
//
|
||||
// AC-3 is TWO-PART:
|
||||
// (3a) NORMALIZATION - expandChangedSurface validated against 13.1's REAL
|
||||
// shipped sample diff (the INPUT grammar), not an assumed one.
|
||||
// (3b) AFFECTED-SKILL OUTPUT - computeAffectedSkills validated against an
|
||||
// expected set. Here with inline skill fixtures (deterministic,
|
||||
// decoupled from corpus churn); the corpus-derived
|
||||
// v0.3.2-affected-skills regression fixture is asserted in
|
||||
// affected-skills-corpus.test.ts once the backfill lands.
|
||||
// Plus the advisor-sharpened match rule discriminators (component-wise prefix,
|
||||
// up-vs-update) and AC-4 determinism.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { SurfaceDiff } from "../src/release-surface/surface-diff.js";
|
||||
import {
|
||||
toSegments,
|
||||
pathsMatch,
|
||||
skillTokenMatches,
|
||||
expandChangedSurface,
|
||||
computeAffectedSkills,
|
||||
parseCliSurfaces,
|
||||
type SkillIndexEntry,
|
||||
} from "../src/release-surface/affected-skills.js";
|
||||
|
||||
function repoPath(rel: string): string {
|
||||
return fileURLToPath(new URL(`../${rel}`, import.meta.url));
|
||||
}
|
||||
function readYaml<T>(rel: string): T {
|
||||
return parseYaml(fs.readFileSync(repoPath(rel), "utf8")) as T;
|
||||
}
|
||||
|
||||
const SAMPLE_DIFF = "src/release-surface/release-surface-diff.v0.3.1-v0.3.2.yaml";
|
||||
|
||||
describe("affected-skills - toSegments / pathsMatch (the ratified match rule)", () => {
|
||||
it("strips a leading `rig ` and splits on whitespace", () => {
|
||||
expect(toSegments("rig scope slice create")).toEqual(["scope", "slice", "create"]);
|
||||
expect(toSegments("queue create")).toEqual(["queue", "create"]);
|
||||
expect(toSegments(" rig ps ")).toEqual(["ps"]);
|
||||
expect(toSegments("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("component-wise prefix matches in EITHER direction (equal or path-prefix)", () => {
|
||||
// shorter is a component-prefix of longer (both directions)
|
||||
expect(pathsMatch(["scope"], ["scope", "slice", "create"])).toBe(true);
|
||||
expect(pathsMatch(["scope", "slice", "create"], ["scope"])).toBe(true);
|
||||
// exact equality
|
||||
expect(pathsMatch(["queue", "create"], ["queue", "create"])).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT raw-string-prefix match (`up` vs `update` are distinct segments)", () => {
|
||||
expect(pathsMatch(["up"], ["update"])).toBe(false);
|
||||
expect(pathsMatch(["update"], ["up"])).toBe(false);
|
||||
// divergent later segment also fails
|
||||
expect(pathsMatch(["scope", "slice"], ["scope", "mission"])).toBe(false);
|
||||
// empty either side never matches
|
||||
expect(pathsMatch([], ["scope"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("affected-skills - AC-3a normalization vs 13.1's shipped sample diff", () => {
|
||||
it("expandChangedSurface expands added_commands + added_flags to the full section-3 set", () => {
|
||||
const diff = readYaml<SurfaceDiff>(SAMPLE_DIFF);
|
||||
const got = [...expandChangedSurface(diff)].sort();
|
||||
const expected = [
|
||||
// added_commands: policy + subs
|
||||
"policy", "policy cite", "policy defaults", "policy effective",
|
||||
"policy set", "policy show", "policy unset",
|
||||
// added_commands: scope + subs
|
||||
"scope", "scope mission", "scope mission create", "scope mission ls",
|
||||
"scope mission show", "scope slice", "scope slice close", "scope slice create",
|
||||
"scope slice ls", "scope slice move", "scope slice ship", "scope slice show",
|
||||
// added_flags: command (+ subcommands)
|
||||
"bundle", "bundle create", "bundle history", "bundle install",
|
||||
"queue create", "workspace", "workspace doctor",
|
||||
].sort();
|
||||
expect(got).toEqual(expected);
|
||||
});
|
||||
|
||||
it("a deep skill token (scope slice create) matches the diff even though the diff also lists bare `scope`", () => {
|
||||
const diff = readYaml<SurfaceDiff>(SAMPLE_DIFF);
|
||||
const changed = expandChangedSurface(diff);
|
||||
// the exact deep path is present
|
||||
expect(skillTokenMatches("scope slice create", changed)).toBe(true);
|
||||
// a `rig `-prefixed skill token still matches (defensive strip)
|
||||
expect(skillTokenMatches("rig scope slice create", changed)).toBe(true);
|
||||
// a skill referencing only bare `scope` is over-included (conservative bias)
|
||||
expect(skillTokenMatches("scope", changed)).toBe(true);
|
||||
// an unrelated command is NOT matched
|
||||
expect(skillTokenMatches("send", changed)).toBe(false);
|
||||
expect(skillTokenMatches("whoami", changed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("affected-skills - AC-3b output (inline fixtures) + AC-4 determinism", () => {
|
||||
const diff = (): SurfaceDiff => readYaml<SurfaceDiff>(SAMPLE_DIFF);
|
||||
const skills: SkillIndexEntry[] = [
|
||||
{ name: "queue-handoff", cliSurfacesReferenced: ["queue create", "send"] },
|
||||
{ name: "openrig-work-codemap", cliSurfacesReferenced: ["scope slice create", "scope mission show"] },
|
||||
{ name: "rig-bundles-and-shareable-artifacts", cliSurfacesReferenced: ["bundle create", "bundle install"] },
|
||||
{ name: "openrig-user", cliSurfacesReferenced: ["whoami", "ps", "send"] }, // none in v0.3.2 diff
|
||||
{ name: "primitive-workspace", cliSurfacesReferenced: ["workspace doctor"] },
|
||||
];
|
||||
|
||||
it("computes the affected-skill set: only skills whose tokens hit the changed surface", () => {
|
||||
const affected = computeAffectedSkills(diff(), skills);
|
||||
expect(affected).toEqual([
|
||||
"openrig-work-codemap",
|
||||
"primitive-workspace",
|
||||
"queue-handoff",
|
||||
"rig-bundles-and-shareable-artifacts",
|
||||
]);
|
||||
// openrig-user (whoami/ps/send) is NOT affected by the v0.3.2 surface set
|
||||
expect(affected).not.toContain("openrig-user");
|
||||
});
|
||||
|
||||
it("AC-4: identical (diff, skills) produces identical output (deterministic, offline)", () => {
|
||||
const a = computeAffectedSkills(diff(), skills);
|
||||
const b = computeAffectedSkills(diff(), skills);
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toEqual([...a].sort()); // stable sorted order
|
||||
});
|
||||
});
|
||||
|
||||
describe("affected-skills - parseCliSurfaces (frontmatter loader)", () => {
|
||||
it("reads metadata.cli_surfaces_referenced from a SKILL.md frontmatter block", () => {
|
||||
const md = [
|
||||
"---",
|
||||
"name: demo",
|
||||
"description: x",
|
||||
"metadata:",
|
||||
" cli_surfaces_referenced:",
|
||||
" - queue create",
|
||||
" - scope slice create",
|
||||
"---",
|
||||
"",
|
||||
"# Demo",
|
||||
].join("\n");
|
||||
expect(parseCliSurfaces(md)).toEqual(["queue create", "scope slice create"]);
|
||||
});
|
||||
|
||||
it("returns [] when frontmatter, metadata, or the field is absent/malformed", () => {
|
||||
expect(parseCliSurfaces("# no frontmatter")).toEqual([]);
|
||||
expect(parseCliSurfaces("---\nname: x\n---\nbody")).toEqual([]);
|
||||
expect(parseCliSurfaces("---\nname: x\nmetadata:\n other: 1\n---\nbody")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
// OPR.0.3.3.19 - rig archive / unarchive CLI surface.
|
||||
//
|
||||
// Covers AC-6 (running-rig --force guard surfaces a 3-part honest error),
|
||||
// the happy archive/unarchive paths, the --force flag wiring, and JSON output.
|
||||
// The daemon-layer guard is proven in the daemon suite (rig-archive.test.ts);
|
||||
// here we prove the CLI faithfully surfaces the route's contract.
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { archiveCommand } from "../src/commands/archive.js";
|
||||
import { unarchiveCommand } from "../src/commands/unarchive.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
// archive.ts writes the 3-part error via process.stderr.write
|
||||
(process.stderr as { write: unknown }).write = (chunk: string) => { logs.push(String(chunk)); return true; };
|
||||
try { await fn(); } finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
(process.stderr as { write: unknown }).write = origWrite;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function runningDeps(port: number): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-03-26T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Archive / Unarchive CLI (OPR.0.3.3.19)", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let lastBody: Record<string, unknown>;
|
||||
let archiveResponse: { status: number; body: Record<string, unknown> } | null;
|
||||
let unarchiveResponse: { status: number; body: Record<string, unknown> } | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer(async (req, res) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url?.endsWith("/archive") && req.method === "POST") {
|
||||
lastBody = body ? JSON.parse(body) : {};
|
||||
const r = archiveResponse ?? { status: 200, body: { ok: true, rigId: "rig-1", archived: true } };
|
||||
res.writeHead(r.status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(r.body));
|
||||
} else if (req.url?.endsWith("/unarchive") && req.method === "POST") {
|
||||
const r = unarchiveResponse ?? { status: 200, body: { ok: true, rigId: "rig-1", unarchived: true } };
|
||||
res.writeHead(r.status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(r.body));
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => { server.listen(0, resolve); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
function archiveCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(archiveCommand(runningDeps(port)));
|
||||
return prog;
|
||||
}
|
||||
function unarchiveCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(unarchiveCommand(runningDeps(port)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("archive success prints reversible note and exits 0", async () => {
|
||||
archiveResponse = { status: 200, body: { ok: true, rigId: "rig-1", archived: true } };
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "rig-1"]);
|
||||
});
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("archived");
|
||||
expect(out).toContain("rig unarchive rig-1");
|
||||
expect(exitCode).toBeUndefined(); // 0
|
||||
});
|
||||
|
||||
it("archive defaults force:false; --force sends force:true", async () => {
|
||||
archiveResponse = { status: 200, body: { ok: true, rigId: "rig-1", archived: true } };
|
||||
await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "rig-1"]);
|
||||
});
|
||||
expect(lastBody["force"]).toBe(false);
|
||||
await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "rig-1", "--force"]);
|
||||
});
|
||||
expect(lastBody["force"]).toBe(true);
|
||||
});
|
||||
|
||||
it("AC-6: running-rig 409 surfaces the 3-part honest error and exits 2", async () => {
|
||||
archiveResponse = {
|
||||
status: 409,
|
||||
body: { error: {
|
||||
fact: "Rig 'alpha' is running (it has live sessions).",
|
||||
consequence: "Archiving it would hide a rig with running seats from the default view.",
|
||||
action: "Stop it first ('rig down rig-1'), or re-run with --force to archive anyway.",
|
||||
} },
|
||||
};
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "rig-1"]);
|
||||
});
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("is running");
|
||||
expect(out).toContain("would hide a rig with running seats");
|
||||
expect(out).toContain("--force to archive anyway");
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("archive 404 exits 1 with a list hint", async () => {
|
||||
archiveResponse = { status: 404, body: { error: "rig not found" } };
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "missing"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("Rig not found: missing");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("archive --json emits raw JSON; 409 json exits 2", async () => {
|
||||
archiveResponse = { status: 200, body: { ok: true, rigId: "rig-1", archived: true } };
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "rig-1", "--json"]);
|
||||
});
|
||||
expect(JSON.parse(logs.join("")).archived).toBe(true);
|
||||
|
||||
archiveResponse = { status: 409, body: { error: { fact: "f", consequence: "c", action: "a" } } };
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "rig-1", "--json"]);
|
||||
});
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("archive already-archived prints already-archived and exits 0", async () => {
|
||||
archiveResponse = { status: 200, body: { ok: true, rigId: "rig-1", archived: false } };
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await archiveCmd().parseAsync(["node", "rig", "archive", "rig-1"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("already archived");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unarchive success prints back-in-view and exits 0", async () => {
|
||||
unarchiveResponse = { status: 200, body: { ok: true, rigId: "rig-1", unarchived: true } };
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await unarchiveCmd().parseAsync(["node", "rig", "unarchive", "rig-1"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("back in the default view");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unarchive when not archived prints was-not-archived", async () => {
|
||||
unarchiveResponse = { status: 200, body: { ok: true, rigId: "rig-1", unarchived: false } };
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await unarchiveCmd().parseAsync(["node", "rig", "unarchive", "rig-1"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("was not archived");
|
||||
});
|
||||
|
||||
it("unarchive 404 exits 1", async () => {
|
||||
unarchiveResponse = { status: 404, body: { error: "rig not found" } };
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await unarchiveCmd().parseAsync(["node", "rig", "unarchive", "missing"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,8 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
// Captured create bodies for assertion
|
||||
let capturedCreateBodies: Record<string, unknown>[] = [];
|
||||
// Captured install bodies for assertion (Item 2 Checkpoint 3.3)
|
||||
let capturedInstallBodies: Record<string, unknown>[] = [];
|
||||
|
||||
describe("Bundle CLI", () => {
|
||||
let server: http.Server;
|
||||
@@ -90,6 +92,7 @@ describe("Bundle CLI", () => {
|
||||
res.end(JSON.stringify({ manifest: { name: "test", version: "0.1.0" }, digestValid: true, integrityResult: { passed: true } }));
|
||||
} else if (req.url === "/api/bundles/install" && req.method === "POST") {
|
||||
const parsed = JSON.parse(body);
|
||||
capturedInstallBodies.push(parsed);
|
||||
if (String(parsed.bundlePath ?? "").includes("blocked")) {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "blocked" }));
|
||||
@@ -102,6 +105,17 @@ describe("Bundle CLI", () => {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "run-2", rigId: "rig-1" }));
|
||||
}
|
||||
} else if (req.url?.startsWith("/api/bundles/history") && req.method === "GET") {
|
||||
// Capture query for assertion if needed; for now return a static fixture
|
||||
const url = new URL(req.url, "http://x");
|
||||
const rigFilter = url.searchParams.get("rig");
|
||||
const allRecs = [
|
||||
{ installedAt: "2026-05-18T10:00:00Z", bundlePath: "/tmp/a.rigbundle", targetRigName: "alpha", outcome: "success" },
|
||||
{ installedAt: "2026-05-18T11:00:00Z", bundlePath: "/tmp/b.rigbundle", targetRigName: "beta", outcome: "failed" },
|
||||
];
|
||||
const recs = rigFilter ? allRecs.filter((r) => r.targetRigName === rigFilter) : allRecs;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ records: recs, total: recs.length }));
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
@@ -211,4 +225,207 @@ describe("Bundle CLI", () => {
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["rigRoot"]).toMatch(/\/my\/project/);
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: --notes flag is captured into provenance in the request body
|
||||
it("bundle create --notes wires the operator note into provenance.notes in the request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--notes", "checkpoint-2-part-3 fixture",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["notes"]).toBe("checkpoint-2-part-3 fixture");
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: provenance auto-includes hostname + cliVersion at invoke time
|
||||
it("bundle create automatically includes hostname + cliVersion in provenance (no flag needed)", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
// hostname and cliVersion auto-populate (real os.hostname() + CLI package.json read)
|
||||
expect(typeof provenance!["sourceHost"]).toBe("string");
|
||||
expect((provenance!["sourceHost"] as string).length).toBeGreaterThan(0);
|
||||
expect(typeof provenance!["cliVersion"]).toBe("string");
|
||||
expect((provenance!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
// notes is undefined when --notes not passed (no empty string sent)
|
||||
expect(provenance!["notes"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version + --min-cli-version flags wire into request body compatibility
|
||||
it("bundle create --min-daemon-version and --min-cli-version wire into request body compatibility", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
"--min-cli-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBe("0.3.2");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version alone (partial) still wires
|
||||
it("bundle create --min-daemon-version alone wires partial compatibility into request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05 Checkpoint 3.3: bundle install --skip-version-check wires through
|
||||
it("bundle install --skip-version-check sets skipVersionCheck=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--skip-version-check",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(true);
|
||||
// cliVersion auto-included (read at call time via getCliVersion)
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
expect((installBody!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bundle install without --skip-version-check sets skipVersionCheck=false and still sends cliVersion", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(false);
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
});
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.2: --force flag wires through to request body
|
||||
it("bundle install --force sets force=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--force",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(true);
|
||||
});
|
||||
|
||||
it("bundle install without --force sets force=false in request body (operator-explicit opt-in only)", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(false);
|
||||
});
|
||||
|
||||
// Item 4 / slice-05 Checkpoint 5.2: rig bundle history subcommand
|
||||
it("bundle history renders records as text by default", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history"]);
|
||||
});
|
||||
expect(logs.some((l) => l.includes("Bundle install history"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("alpha"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("beta"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("success"))).toBe(true);
|
||||
});
|
||||
|
||||
it("bundle history --json outputs parseable JSON with records array", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(Array.isArray(parsed.records)).toBe(true);
|
||||
expect(parsed.records.length).toBe(2);
|
||||
expect(parsed.total).toBe(2);
|
||||
});
|
||||
|
||||
it("bundle history --rig filter passes through to the query string", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--rig", "alpha", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(parsed.records).toHaveLength(1);
|
||||
expect(parsed.records[0].targetRigName).toBe("alpha");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: no flags → compatibility omitted (no empty object sent)
|
||||
it("bundle create with neither min-version flag omits compatibility from request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["compatibility"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: authorSession populates when OPENRIG_SESSION_NAME env is set
|
||||
it("bundle create includes authorSession when OPENRIG_SESSION_NAME env is set", async () => {
|
||||
capturedCreateBodies = [];
|
||||
const origEnv = process.env.OPENRIG_SESSION_NAME;
|
||||
process.env.OPENRIG_SESSION_NAME = "velocity-driver@openrig-velocity";
|
||||
try {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
if (origEnv === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||||
else process.env.OPENRIG_SESSION_NAME = origEnv;
|
||||
}
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["authorSession"]).toBe("velocity-driver@openrig-velocity");
|
||||
});
|
||||
});
|
||||
@@ -114,10 +114,51 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
"snapshots.periodic.enabled",
|
||||
"snapshots.periodic.interval_seconds",
|
||||
"snapshots.periodic.retention_keep",
|
||||
];
|
||||
expect([...VALID_KEYS]).toEqual(expected);
|
||||
});
|
||||
|
||||
// OPR.0.3.4.9 — CLI config resolve shape + malformed write rejection.
|
||||
it("resolve().snapshots.periodic returns defaults", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const config = store.resolve();
|
||||
expect(config.snapshots.periodic.enabled).toBe(true);
|
||||
expect(config.snapshots.periodic.intervalSeconds).toBe(300);
|
||||
expect(config.snapshots.periodic.retentionKeep).toBe(10);
|
||||
});
|
||||
|
||||
it("resolve().snapshots.periodic respects typed file writes", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("snapshots.periodic.enabled", "false");
|
||||
store.set("snapshots.periodic.interval_seconds", "120");
|
||||
store.set("snapshots.periodic.retention_keep", "2");
|
||||
const config = store.resolve();
|
||||
expect(config.snapshots.periodic.enabled).toBe(false);
|
||||
expect(config.snapshots.periodic.intervalSeconds).toBe(120);
|
||||
expect(config.snapshots.periodic.retentionKeep).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects malformed snapshots.periodic.interval_seconds (60abc, 60.5)", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("snapshots.periodic.interval_seconds", "60abc")).toThrow(/expected an integer/);
|
||||
expect(() => store.set("snapshots.periodic.interval_seconds", "60.5")).toThrow(/expected an integer/);
|
||||
});
|
||||
|
||||
it("rejects malformed snapshots.periodic.retention_keep (1abc, 1.5)", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("snapshots.periodic.retention_keep", "1abc")).toThrow(/expected an integer/);
|
||||
expect(() => store.set("snapshots.periodic.retention_keep", "1.5")).toThrow(/expected an integer/);
|
||||
});
|
||||
|
||||
it("rejects out-of-range snapshots.periodic.interval_seconds=30 and retention_keep=0", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("snapshots.periodic.interval_seconds", "30")).toThrow(/must be >= 60/);
|
||||
expect(() => store.set("snapshots.periodic.retention_keep", "0")).toThrow(/must be >= 1/);
|
||||
});
|
||||
|
||||
it("workspace.operator_seat_name roundtrip — default derives operator-${USER}@kernel; set → resolve reflects override", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const before = store.resolve();
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
resolveDaemonPath,
|
||||
buildDaemonEnv,
|
||||
ensureWorkspaceScaffold,
|
||||
daemonNotRunningError,
|
||||
printDaemonNotRunning,
|
||||
STATE_FILE,
|
||||
LOG_FILE,
|
||||
OPENRIG_DIR,
|
||||
@@ -787,13 +789,38 @@ describe("Daemon Lifecycle", () => {
|
||||
});
|
||||
|
||||
describe("resolveDaemonPath", () => {
|
||||
it("returns bundled path when ../daemon/dist/index.js exists", () => {
|
||||
// QA BLOCKING qitem-20260518054224 — the resolver previously preferred
|
||||
// the bundled (vendored) copy, so monorepo dev workflows ran a stale
|
||||
// packaged daemon when packages/cli/daemon hadn't been re-assembled
|
||||
// via scripts/build-package.sh. After the fix the resolver prefers
|
||||
// the monorepo source (packages/daemon) when present, ensuring
|
||||
// `node packages/cli/dist/bin-wrapper.js daemon start` always
|
||||
// launches the daemon source-of-truth in development.
|
||||
|
||||
it("returns monorepo source path when packages/daemon/dist/index.js exists (DEV — both paths present)", () => {
|
||||
// Simulates a monorepo checkout: BOTH paths have dist/index.js
|
||||
// (vendored bundle assembled via scripts/build-package.sh + source
|
||||
// daemon built via @openrig/daemon). The resolver picks source.
|
||||
const exists = (p: string) => p.endsWith("daemon/dist/index.js");
|
||||
const result = resolveDaemonPath("/repo/packages/cli/dist", exists);
|
||||
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
|
||||
});
|
||||
|
||||
it("falls back to bundled path when only ../daemon/dist exists (NPM-INSTALL — single path)", () => {
|
||||
// Simulates `npm install -g @openrig/cli` layout:
|
||||
// node_modules/@openrig/cli/{dist,daemon,ui}
|
||||
// Only ../daemon (sibling to cli/dist) exists; ../../daemon doesn't.
|
||||
const exists = (p: string) => {
|
||||
// monorepo path absent; bundled present
|
||||
if (p === path.resolve("/install/cli/dist", "../../daemon", "dist/index.js")) return false;
|
||||
if (p === path.resolve("/install/cli/dist", "../daemon", "dist/index.js")) return true;
|
||||
return false;
|
||||
};
|
||||
const result = resolveDaemonPath("/install/cli/dist", exists);
|
||||
expect(result).toBe(path.resolve("/install/cli/dist", "../daemon"));
|
||||
});
|
||||
|
||||
it("falls back to monorepo path when bundled does not exist", () => {
|
||||
it("returns the monorepo path even when nothing exists, so callers can surface a clear error", () => {
|
||||
const exists = () => false;
|
||||
const result = resolveDaemonPath("/repo/packages/cli/dist", exists);
|
||||
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
|
||||
@@ -1172,3 +1199,53 @@ describe("ensureWorkspaceScaffold", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-4): the ONE shared daemon-not-running honest error used by
|
||||
// the daemon-dependent journey verbs (bootstrap/discover/workspace/workflow).
|
||||
describe("daemonNotRunningError / printDaemonNotRunning (AC-4 shared honest error)", () => {
|
||||
it("daemonNotRunningError is the 3-part fact/consequence/action shape pointing to rig up / rig daemon start", () => {
|
||||
const err = daemonNotRunningError();
|
||||
expect(err.fact).toContain("Daemon not running");
|
||||
expect(err.consequence).toContain("needs a running daemon");
|
||||
expect(err.action).toContain("rig up");
|
||||
expect(err.action).toContain("rig daemon start");
|
||||
});
|
||||
|
||||
it("printDaemonNotRunning (human) emits all three parts on stderr and sets exit code 1", () => {
|
||||
const lines: string[] = [];
|
||||
const origErr = console.error;
|
||||
const origExit = process.exitCode;
|
||||
console.error = (...a: unknown[]) => { lines.push(a.join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
printDaemonNotRunning();
|
||||
} finally {
|
||||
console.error = origErr;
|
||||
}
|
||||
const exit = process.exitCode;
|
||||
process.exitCode = origExit;
|
||||
const out = lines.join("\n");
|
||||
expect(out).toContain("Daemon not running");
|
||||
expect(out).toContain("needs a running daemon");
|
||||
expect(out).toContain("rig up");
|
||||
expect(exit).toBe(1);
|
||||
});
|
||||
|
||||
it("printDaemonNotRunning({ json: true }) emits the { error: { fact, consequence, action } } envelope", () => {
|
||||
const lines: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origExit = process.exitCode;
|
||||
console.log = (...a: unknown[]) => { lines.push(a.join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
printDaemonNotRunning({ json: true });
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
process.exitCode = origExit;
|
||||
const parsed = JSON.parse(lines.join("")) as { error: { fact: string; consequence: string; action: string } };
|
||||
expect(parsed.error.fact).toContain("Daemon not running");
|
||||
expect(parsed.error.consequence).toContain("needs a running daemon");
|
||||
expect(parsed.error.action).toContain("rig up");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { downCommand } from "../src/commands/down.js";
|
||||
@@ -55,16 +55,37 @@ function runningDeps(port: number): StatusDeps {
|
||||
describe("Down CLI", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let lastBody: Record<string, unknown>;
|
||||
let lastBody: Record<string, unknown> | undefined;
|
||||
let responseOverride: { status: number; body: Record<string, unknown> } | null;
|
||||
// When null, GET /api/rigs/summary returns 404 -> down falls back to today's
|
||||
// id-only behavior (so every pre-existing id-based test passes unchanged).
|
||||
// When set, the summary is served at 200 to exercise name resolution.
|
||||
let summaryOverride: Array<{ id: string; name: string; archivedAt?: string | null; lifecycleState?: string }> | null;
|
||||
// Count POST /api/down calls so the AC-3 discriminator can assert that an
|
||||
// ambiguous name reaches teardown ZERO times (fail-safe).
|
||||
let downCallCount: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
responseOverride = null;
|
||||
summaryOverride = null;
|
||||
downCallCount = 0;
|
||||
server = http.createServer(async (req, res) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
|
||||
if (req.url?.split("?")[0] === "/api/rigs/summary" && req.method === "GET") {
|
||||
if (summaryOverride === null) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(summaryOverride));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/api/down" && req.method === "POST") {
|
||||
downCallCount += 1;
|
||||
lastBody = JSON.parse(body);
|
||||
|
||||
if (responseOverride) {
|
||||
@@ -73,7 +94,7 @@ describe("Down CLI", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const rigId = lastBody["rigId"] as string;
|
||||
const rigId = lastBody?.["rigId"] as string;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
rigId, sessionsKilled: 2, snapshotId: null,
|
||||
@@ -90,6 +111,13 @@ describe("Down CLI", () => {
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
beforeEach(() => {
|
||||
responseOverride = null;
|
||||
summaryOverride = null;
|
||||
downCallCount = 0;
|
||||
lastBody = undefined;
|
||||
});
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
@@ -168,7 +196,7 @@ describe("Down CLI", () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-1", "--delete"]);
|
||||
});
|
||||
expect(lastBody["delete"]).toBe(true);
|
||||
expect(lastBody?.["delete"]).toBe(true);
|
||||
});
|
||||
|
||||
// T5: down --snapshot sends snapshot:true
|
||||
@@ -177,7 +205,7 @@ describe("Down CLI", () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-1", "--snapshot"]);
|
||||
});
|
||||
expect(lastBody["snapshot"]).toBe(true);
|
||||
expect(lastBody?.["snapshot"]).toBe(true);
|
||||
});
|
||||
|
||||
// T6: down --force sends force:true
|
||||
@@ -186,7 +214,7 @@ describe("Down CLI", () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-1", "--force"]);
|
||||
});
|
||||
expect(lastBody["force"]).toBe(true);
|
||||
expect(lastBody?.["force"]).toBe(true);
|
||||
});
|
||||
|
||||
// T7: down --json raw output
|
||||
@@ -232,4 +260,145 @@ describe("Down CLI", () => {
|
||||
});
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
// ---- OPR.0.3.3.22: down accepts name-or-id ----
|
||||
|
||||
// AC-1: down by name resolves the name to its id and tears down.
|
||||
it("AC-1: down by name resolves the name to its id and tears down", async () => {
|
||||
summaryOverride = [{ id: "rig-abc", name: "product-team" }];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "product-team"]);
|
||||
});
|
||||
expect(lastBody?.["rigId"]).toBe("rig-abc"); // resolved id, not the name
|
||||
expect(downCallCount).toBe(1);
|
||||
expect(logs.some((l) => l.includes("rig-abc") && l.includes("stopped"))).toBe(true);
|
||||
expect(exitCode).toBeUndefined(); // 0
|
||||
});
|
||||
|
||||
// AC-2: down by id still works when the id is present in the summary (no regression).
|
||||
it("AC-2: down by exact id resolves directly (id-match wins, even if a name also matches)", async () => {
|
||||
// A rig whose NAME equals another rig's id-shaped handle would be a trap;
|
||||
// assert the exact-id match is taken first and posts that id.
|
||||
summaryOverride = [
|
||||
{ id: "rig-xyz", name: "alpha" },
|
||||
{ id: "rig-collide", name: "rig-xyz" }, // name collides with the first rig's id
|
||||
];
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-xyz"]);
|
||||
});
|
||||
expect(lastBody?.["rigId"]).toBe("rig-xyz"); // id-exact-match wins, not the name-collision rig
|
||||
expect(downCallCount).toBe(1);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// AC-3 (LOAD-BEARING DISCRIMINATOR): ambiguous active name tears down NOTHING.
|
||||
it("AC-3: ambiguous name is refused and NEITHER rig is torn down (no /api/down POST)", async () => {
|
||||
summaryOverride = [
|
||||
{ id: "rig-1a", name: "product-team" },
|
||||
{ id: "rig-2b", name: "product-team" },
|
||||
];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "product-team"]);
|
||||
});
|
||||
// The discriminator: teardown was never attempted.
|
||||
expect(downCallCount).toBe(0);
|
||||
expect(lastBody).toBeUndefined(); // no POST body ever parsed
|
||||
// Honest 3-part error listing both ids + the remediation.
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("rig-1a");
|
||||
expect(out).toContain("rig-2b");
|
||||
expect(out).toMatch(/rig down rig-1a|rig down rig-2b/);
|
||||
expect(out.toLowerCase()).toContain("ambiguous");
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
// AC-3 --json: same fail-safe, structured error with candidate ids, no POST.
|
||||
it("AC-3 --json: ambiguous name emits structured error with candidates and does not POST", async () => {
|
||||
summaryOverride = [
|
||||
{ id: "rig-1a", name: "product-team" },
|
||||
{ id: "rig-2b", name: "product-team" },
|
||||
];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "product-team", "--json"]);
|
||||
});
|
||||
expect(downCallCount).toBe(0);
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(parsed.error.candidates).toEqual(["rig-1a", "rig-2b"]);
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
// AC-4: honest not-found when a good summary contains no id/name match. No POST.
|
||||
it("AC-4: not-found name halts with an honest error and no teardown", async () => {
|
||||
summaryOverride = [{ id: "rig-abc", name: "product-team" }];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "no-such-rig"]);
|
||||
});
|
||||
expect(downCallCount).toBe(0);
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("no-such-rig");
|
||||
expect(out).toContain("rig ps");
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
// AC-5 regression: flags flow through unchanged when a name resolves to an id.
|
||||
it("AC-5: --delete/--force/--snapshot flow through when a name resolves", async () => {
|
||||
summaryOverride = [{ id: "rig-abc", name: "product-team" }];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "product-team", "--delete", "--force", "--snapshot"]);
|
||||
});
|
||||
expect(lastBody?.["rigId"]).toBe("rig-abc");
|
||||
expect(lastBody?.["delete"]).toBe(true);
|
||||
expect(lastBody?.["force"]).toBe(true);
|
||||
expect(lastBody?.["snapshot"]).toBe(true);
|
||||
});
|
||||
|
||||
// Passthrough: summary unavailable (non-200) -> today's id-only behavior preserved.
|
||||
it("falls back to id-only POST when the rig summary is unavailable", async () => {
|
||||
summaryOverride = null; // GET /api/rigs/summary -> 404
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-1"]);
|
||||
});
|
||||
expect(lastBody?.["rigId"]).toBe("rig-1"); // raw handle posted, as today
|
||||
expect(downCallCount).toBe(1);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// AC-2 (archived): an archived rig's id still reaches the canonical /api/down
|
||||
// id path. The summary is fetched with includeArchived=true, so the archived
|
||||
// id id-matches and POSTs (today's behavior preserved).
|
||||
it("AC-2 archived: down by an archived rig id still POSTs the id (no regression)", async () => {
|
||||
summaryOverride = [{ id: "rig-arch", name: "old-team", archivedAt: "2026-06-01T00:00:00Z" }];
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "rig-arch"]);
|
||||
});
|
||||
expect(lastBody?.["rigId"]).toBe("rig-arch");
|
||||
expect(downCallCount).toBe(1);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// Governance pt-5: an active+archived same-name pair is NOT ambiguous - only
|
||||
// the ACTIVE candidate counts for name resolution; it resolves and tears down.
|
||||
it("active+archived same-name pair resolves the ACTIVE rig (not ambiguous)", async () => {
|
||||
summaryOverride = [
|
||||
{ id: "rig-active", name: "product-team", archivedAt: null },
|
||||
{ id: "rig-archived", name: "product-team", archivedAt: "2026-06-01T00:00:00Z" },
|
||||
];
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "product-team"]);
|
||||
});
|
||||
expect(lastBody?.["rigId"]).toBe("rig-active"); // the active one, not ambiguous
|
||||
expect(downCallCount).toBe(1);
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// An archived-only name does not resolve by name (use the id / archive path).
|
||||
it("archived-only name does not resolve by name (not_found, no teardown)", async () => {
|
||||
summaryOverride = [{ id: "rig-archived", name: "old-team", archivedAt: "2026-06-01T00:00:00Z" }];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "down", "old-team"]);
|
||||
});
|
||||
expect(downCallCount).toBe(0); // name path excludes archived -> no match -> halt
|
||||
expect(logs.join("\n")).toContain("old-team");
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Slice-21 FR-5e A1 — `rig config init-workspace` scaffolds
|
||||
// MISSION_NOTES.md for each seeded mission so a fresh baseline passes
|
||||
// doctor check #7 (mission_notes_presence) without manual scope
|
||||
// scaffolding.
|
||||
//
|
||||
// QA finding: fresh `rig config init-workspace` followed by `rig
|
||||
// workspace doctor` produced summary {ok:6, warn:1, fail:0} because
|
||||
// the FR-3 auto-scaffold only fired from `rig scope mission create`,
|
||||
// not the init-workspace path.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
runInitWorkspace,
|
||||
workspaceScaffoldFiles,
|
||||
} from "../src/commands/config-init-workspace.js";
|
||||
|
||||
let dir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "fr5e-init-"));
|
||||
configPath = path.join(dir, "test-config.json");
|
||||
// Seed config with workspace.root pointing into our temp dir so
|
||||
// ConfigStore's resolveWithSource returns a known root.
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ workspace: { root: path.join(dir, "workspace") } }) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe("FR-5e A1 — init-workspace scaffolds MISSION_NOTES.md", () => {
|
||||
it("writes missions/getting-started/MISSION_NOTES.md alongside README + PROGRESS", () => {
|
||||
const result = runInitWorkspace({ configPath });
|
||||
const notesFile = result.files.find((f) =>
|
||||
f.relPath === "missions/getting-started/MISSION_NOTES.md");
|
||||
expect(notesFile).toBeDefined();
|
||||
expect(notesFile?.created).toBe(true);
|
||||
expect(fs.existsSync(notesFile!.absPath)).toBe(true);
|
||||
const content = fs.readFileSync(notesFile!.absPath, "utf-8");
|
||||
// Discriminator: the rendered template must have placeholder
|
||||
// substitutions applied. Without applyMissionNotesPlaceholders
|
||||
// the file would still contain `{{mission_id}}` etc.
|
||||
expect(content).not.toContain("{{mission_id}}");
|
||||
expect(content).not.toContain("{{mission_name}}");
|
||||
expect(content).not.toContain("{{created_date}}");
|
||||
// Discriminator: at minimum the rendered file should mention the
|
||||
// mission's display name from DEFAULT_MISSIONS.
|
||||
expect(content).toContain("Getting Started");
|
||||
});
|
||||
|
||||
// Discriminator-flip: workspaceScaffoldFiles is the canonical
|
||||
// file-list used by both CLI init and daemon's
|
||||
// /api/config/init-workspace. Both surfaces need MISSION_NOTES so
|
||||
// the daemon-side init (UI Settings → init workspace) lands the
|
||||
// same scaffold.
|
||||
it("workspaceScaffoldFiles() emits a MISSION_NOTES.md entry per default mission", () => {
|
||||
const files = workspaceScaffoldFiles();
|
||||
const notesFiles = files.filter((f) => f.relPath.endsWith("/MISSION_NOTES.md"));
|
||||
// DEFAULT_MISSIONS has one mission (getting-started). When the
|
||||
// seed list grows, this discriminator catches the count drift.
|
||||
expect(notesFiles.length).toBeGreaterThanOrEqual(1);
|
||||
for (const f of notesFiles) {
|
||||
expect(f.content).not.toContain("{{mission_id}}");
|
||||
expect(f.content).not.toContain("{{mission_name}}");
|
||||
expect(f.content).not.toContain("{{created_date}}");
|
||||
}
|
||||
});
|
||||
|
||||
// Discriminator-flip: dry-run should NOT write the MISSION_NOTES
|
||||
// file to disk but should report it as "would create".
|
||||
it("dry-run reports MISSION_NOTES.md without writing", () => {
|
||||
const result = runInitWorkspace({ configPath, dryRun: true });
|
||||
const notesFile = result.files.find((f) =>
|
||||
f.relPath === "missions/getting-started/MISSION_NOTES.md");
|
||||
expect(notesFile).toBeDefined();
|
||||
expect(notesFile?.created).toBe(true);
|
||||
expect(fs.existsSync(notesFile!.absPath)).toBe(false);
|
||||
expect(result.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
// Discriminator-flip: idempotent re-run without --force does NOT
|
||||
// overwrite the existing MISSION_NOTES. Operators who hand-edit the
|
||||
// notes shouldn't lose work on a second init.
|
||||
it("re-run without --force skips existing MISSION_NOTES.md", () => {
|
||||
runInitWorkspace({ configPath });
|
||||
const notesPath = path.join(dir, "workspace", "missions", "getting-started", "MISSION_NOTES.md");
|
||||
fs.writeFileSync(notesPath, "operator hand-edited content", "utf-8");
|
||||
const result = runInitWorkspace({ configPath });
|
||||
const notesFile = result.files.find((f) =>
|
||||
f.relPath === "missions/getting-started/MISSION_NOTES.md");
|
||||
expect(notesFile?.created).toBe(false);
|
||||
expect(notesFile?.skipped).toBe("exists");
|
||||
expect(fs.readFileSync(notesPath, "utf-8")).toBe("operator hand-edited content");
|
||||
});
|
||||
|
||||
// GUARD/FR-5e BLOCKER-1 discriminator
|
||||
// (qitem-20260602045638-1a6e964c): invalid
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH must fail BEFORE any
|
||||
// filesystem mutation. The previous (pre-fix) flow created
|
||||
// workspace root + subdirs first, then threw on render, leaving a
|
||||
// half-initialized workspace. With the verify-first-then-write
|
||||
// reorder, the throw fires before any mkdir.
|
||||
it("invalid OPENRIG_MISSION_NOTES_TEMPLATE_PATH fails BEFORE any filesystem mutation", () => {
|
||||
const wsRoot = path.join(dir, "leak-target-workspace");
|
||||
expect(fs.existsSync(wsRoot)).toBe(false);
|
||||
const original = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = path.join(dir, "definitely-not-a-real-template.md");
|
||||
try {
|
||||
expect(() => runInitWorkspace({ root: wsRoot, configPath })).toThrow(
|
||||
/OPENRIG_MISSION_NOTES_TEMPLATE_PATH/,
|
||||
);
|
||||
// Mutation-target: if scaffoldFiles were called AFTER mkdir,
|
||||
// the workspace root would exist on disk. The fix asserts it
|
||||
// does NOT exist.
|
||||
expect(fs.existsSync(wsRoot)).toBe(false);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// OPR.0.3.4.11 — CLI rig launch --seats tests.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { launchCommand } from "../src/commands/launch.js";
|
||||
|
||||
function mockClient(responses: Record<string, { status: number; data: unknown }>) {
|
||||
return {
|
||||
post: vi.fn(async (url: string, body: unknown) => {
|
||||
for (const [pattern, resp] of Object.entries(responses)) {
|
||||
if (url.includes(pattern)) return resp;
|
||||
}
|
||||
return { status: 404, data: { ok: false, error: "not found" } };
|
||||
}),
|
||||
get: vi.fn(async () => ({ status: 200, data: {} })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(clientResponses: Record<string, { status: number; data: unknown }>) {
|
||||
const client = mockClient(clientResponses);
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
spawn: vi.fn(),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => JSON.stringify({ pid: 1, port: 3000, db: "t.sqlite", startedAt: new Date().toISOString() })),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => true),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 1),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
},
|
||||
clientFactory: () => client,
|
||||
_client: client,
|
||||
};
|
||||
}
|
||||
|
||||
describe("rig launch --seats", () => {
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => { logs.push(args.join(" ")); });
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => { errors.push(args.join(" ")); });
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("posts to launch-subset with seats array and holdReason", async () => {
|
||||
const deps = makeDeps({
|
||||
"launch-subset": {
|
||||
status: 201,
|
||||
data: {
|
||||
ok: true,
|
||||
launched: [
|
||||
{ nodeId: "n1", logicalId: "dev.driver", status: "fresh" },
|
||||
{ nodeId: "n2", logicalId: "dev.guard", status: "fresh" },
|
||||
],
|
||||
held: [],
|
||||
alreadyRunning: [],
|
||||
failedTargets: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cmd = launchCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "rig-1", "--seats", "dev.driver,dev.guard", "--hold-reason", "codex auth expired"]);
|
||||
|
||||
expect(deps._client.post).toHaveBeenCalledWith(
|
||||
"/api/rigs/rig-1/nodes/launch-subset",
|
||||
{ seats: ["dev.driver", "dev.guard"], holdReason: "codex auth expired" },
|
||||
);
|
||||
expect(logs.some((l) => l.includes("dev.driver"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("dev.guard"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reports held and failedTargets honestly in human output", async () => {
|
||||
const deps = makeDeps({
|
||||
"launch-subset": {
|
||||
status: 201,
|
||||
data: {
|
||||
ok: true,
|
||||
launched: [{ nodeId: "n1", logicalId: "dev.driver", status: "fresh" }],
|
||||
held: [{ nodeId: "n2", logicalId: "dev.guard", reason: "codex auth expired" }],
|
||||
alreadyRunning: [],
|
||||
failedTargets: [{ nodeId: "n3", logicalId: "dev.reviewer", reason: "tmux_probe_error" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cmd = launchCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "rig-1", "--seats", "dev.driver,dev.guard,dev.reviewer"]);
|
||||
|
||||
expect(logs.some((l) => l.includes("Launched") && l.includes("dev.driver"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("Held") && l.includes("dev.guard") && l.includes("codex auth expired"))).toBe(true);
|
||||
expect(errors.some((l) => l.includes("Failed") && l.includes("dev.reviewer"))).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("requires nodeRef or --seats", async () => {
|
||||
const deps = makeDeps({});
|
||||
const cmd = launchCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "rig-1"]);
|
||||
|
||||
expect(errors.some((l) => l.includes("--seats"))).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("single-target already_running prints honest message, not Launched", async () => {
|
||||
const deps = makeDeps({
|
||||
"/launch": {
|
||||
status: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
rigId: "rig-1",
|
||||
nodeId: "n1",
|
||||
logicalId: "dev.driver",
|
||||
code: "already_running",
|
||||
alreadyRunning: [{ nodeId: "n1", logicalId: "dev.driver" }],
|
||||
launched: [],
|
||||
held: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cmd = launchCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "rig-1", "dev.driver"]);
|
||||
|
||||
expect(logs.some((l) => l.includes("already running"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("Launched"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unmatchedIds in --seats mode", async () => {
|
||||
const deps = makeDeps({
|
||||
"launch-subset": {
|
||||
status: 201,
|
||||
data: {
|
||||
ok: true,
|
||||
launched: [{ nodeId: "n1", logicalId: "dev.driver", status: "fresh" }],
|
||||
held: [],
|
||||
alreadyRunning: [],
|
||||
failedTargets: [],
|
||||
unmatchedIds: ["typo.seat"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cmd = launchCommand(deps);
|
||||
await cmd.parseAsync(["node", "rig", "rig-1", "--seats", "dev.driver,typo.seat"]);
|
||||
|
||||
expect(errors.some((l) => l.includes("Unmatched") && l.includes("typo.seat"))).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -41,12 +41,13 @@ describe("MCP Server", () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
// T1: MCP server lists all 17 tools
|
||||
it("lists all 17 tools", async () => {
|
||||
// T1: MCP server lists all 18 tools
|
||||
it("lists all 18 tools", async () => {
|
||||
await setup();
|
||||
const result = await mcpClient.listTools();
|
||||
const names = result.tools.map((t) => t.name).sort();
|
||||
expect(names).toEqual([
|
||||
"rig_add",
|
||||
"rig_agent_validate",
|
||||
"rig_bind",
|
||||
"rig_bundle_inspect",
|
||||
@@ -68,6 +69,93 @@ describe("MCP Server", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
// rig_add: posts the member fragment to the pod-members route.
|
||||
it("rig_add posts to the pod-members route and returns the added node", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 201,
|
||||
data: {
|
||||
ok: true,
|
||||
result: {
|
||||
podId: "pod-1",
|
||||
podNamespace: "infra",
|
||||
node: { logicalId: "infra.server2", nodeId: "n2", status: "launched", sessionName: "infra-server2@r" },
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
await setup({ post: postFn });
|
||||
|
||||
const member = { id: "server2", runtime: "terminal", agent_ref: "builtin:terminal", profile: "none", cwd: "/tmp" };
|
||||
const result = await mcpClient.callTool({
|
||||
name: "rig_add",
|
||||
arguments: { rigId: "rig-1", podNamespace: "infra", member },
|
||||
});
|
||||
|
||||
expect(postFn).toHaveBeenCalledWith("/api/rigs/rig-1/pods/infra/members", { member });
|
||||
const content = result.content as Array<{ type: string; text: string }>;
|
||||
const parsed = JSON.parse(content[0].text);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.result.node.logicalId).toBe("infra.server2");
|
||||
expect(result.isError).toBeFalsy();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("rig_add forwards optional pod-local edges in the request body", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 201,
|
||||
data: { ok: true, result: { podId: "pod-1", podNamespace: "infra", node: { logicalId: "infra.server2", nodeId: "n2", status: "launched" }, edges: [{ from: "infra.server2", to: "infra.server", kind: "delegates_to" }] } },
|
||||
}));
|
||||
await setup({ post: postFn });
|
||||
|
||||
const member = { id: "server2", runtime: "terminal", agent_ref: "builtin:terminal", profile: "none", cwd: "/tmp" };
|
||||
const edges = [{ from: "server2", to: "server", kind: "delegates_to" }];
|
||||
await mcpClient.callTool({ name: "rig_add", arguments: { rigId: "rig-1", podNamespace: "infra", member, edges } });
|
||||
|
||||
expect(postFn).toHaveBeenCalledWith("/api/rigs/rig-1/pods/infra/members", { member, edges });
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("rig_add surfaces a member_conflict (HTTP 409) as isError", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 409,
|
||||
data: { ok: false, code: "member_conflict", message: 'Member "infra.server" already exists' },
|
||||
}));
|
||||
await setup({ post: postFn });
|
||||
|
||||
const result = await mcpClient.callTool({
|
||||
name: "rig_add",
|
||||
arguments: { rigId: "rig-1", podNamespace: "infra", member: { id: "server" } },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const content = result.content as Array<{ type: string; text: string }>;
|
||||
expect(JSON.parse(content[0].text).code).toBe("member_conflict");
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("rig_add flags a persisted-but-not-launched node as isError", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
status: 201,
|
||||
data: {
|
||||
ok: true,
|
||||
result: {
|
||||
podId: "pod-1",
|
||||
podNamespace: "infra",
|
||||
node: { logicalId: "infra.server2", nodeId: "n2", status: "failed", error: "harness launch failed" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
await setup({ post: postFn });
|
||||
|
||||
const result = await mcpClient.callTool({
|
||||
name: "rig_add",
|
||||
arguments: { rigId: "rig-1", podNamespace: "infra", member: { id: "server2" } },
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
// T2: rig_up calls POST /api/up
|
||||
it("rig_up calls POST /api/up with correct params", async () => {
|
||||
const postFn = vi.fn(async () => ({
|
||||
@@ -206,7 +294,7 @@ describe("MCP Server", () => {
|
||||
|
||||
// Verify server is responsive
|
||||
const result = await mcpClient.listTools();
|
||||
expect(result.tools.length).toBe(17);
|
||||
expect(result.tools.length).toBe(18);
|
||||
|
||||
// Clean disconnect
|
||||
await cleanup();
|
||||
@@ -220,7 +308,7 @@ describe("MCP Server", () => {
|
||||
await client2.connect(ct2);
|
||||
|
||||
const result2 = await client2.listTools();
|
||||
expect(result2.tools.length).toBe(17);
|
||||
expect(result2.tools.length).toBe(18);
|
||||
|
||||
await client2.close();
|
||||
await server2.close();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { QueueDeps } from "../src/commands/queue.js";
|
||||
import { resolveQueueBody } from "../src/commands/queue.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
/**
|
||||
@@ -134,6 +138,274 @@ describe("rig queue CLI", () => {
|
||||
expect((create!.body as { nudge: boolean }).nudge).toBe(false);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-4(a) — body input resolution kills the
|
||||
// backtick-corruption class. Three accepted shapes: --body inline,
|
||||
// --body-file <path>, --body / --body-file - for stdin. Exactly one
|
||||
// of --body / --body-file is required; mutual exclusion validates.
|
||||
describe("FR-4(a) — --body-file + stdin support", () => {
|
||||
it("resolveQueueBody returns inline body when --body is passed", async () => {
|
||||
const out = await resolveQueueBody({ body: "inline value" });
|
||||
expect(out).toBe("inline value");
|
||||
});
|
||||
|
||||
it("resolveQueueBody reads from a file path when --body-file is passed", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-"));
|
||||
const bodyPath = path.join(tmp, "body.txt");
|
||||
const content = "Multi-line body with `raw backticks` and\nliteral newlines\n— this is the corruption class --body-file kills.";
|
||||
fs.writeFileSync(bodyPath, content, "utf8");
|
||||
try {
|
||||
const out = await resolveQueueBody({ bodyFile: bodyPath });
|
||||
expect(out).toBe(content);
|
||||
// Discriminator: the backtick-corruption shell class is bypassed
|
||||
// entirely because no shell substitution happens on file content.
|
||||
expect(out).toMatch(/`raw backticks`/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveQueueBody throws 3-part error when both --body and --body-file are passed", async () => {
|
||||
await expect(resolveQueueBody({ body: "inline", bodyFile: "/tmp/x" })).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/mutually exclusive|ambiguous/i),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/exactly one/),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveQueueBody throws 3-part error when neither --body nor --body-file is passed", async () => {
|
||||
await expect(resolveQueueBody({})).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/Neither --body nor --body-file/),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/--body|--body-file/),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveQueueBody throws 3-part error when --body-file path does not exist", async () => {
|
||||
await expect(resolveQueueBody({ bodyFile: "/tmp/this-path-does-not-exist-fr4a-test.md" })).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/does not exist/),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/Check the path/),
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-4 cleanup (guard non-blocking note on FR-4a CLEAR): a
|
||||
// directory passed to --body-file used to fall through to fs.readFileSync
|
||||
// and surface a bare Error("EISDIR: illegal operation on a directory") with
|
||||
// blank consequence/action. The cleanup commit emits the 3-part shape
|
||||
// explicitly so the error reads consistently with the other body-resolve
|
||||
// failure modes.
|
||||
it("resolveQueueBody throws 3-part error when --body-file path is a directory (not a regular file)", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-isdir-"));
|
||||
try {
|
||||
await expect(resolveQueueBody({ bodyFile: tmp })).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/not a regular file/),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/Pass a path to a readable file/),
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveQueueBody calls the injected stdin reader when --body is -", async () => {
|
||||
const stdinReader = vi.fn(async () => "from stdin\n");
|
||||
const out = await resolveQueueBody({ body: "-" }, stdinReader);
|
||||
expect(out).toBe("from stdin\n");
|
||||
expect(stdinReader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolveQueueBody calls the injected stdin reader when --body-file is -", async () => {
|
||||
const stdinReader = vi.fn(async () => "from stdin file dash\n");
|
||||
const out = await resolveQueueBody({ bodyFile: "-" }, stdinReader);
|
||||
expect(out).toBe("from stdin file dash\n");
|
||||
expect(stdinReader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("create --body-file <file-with-backticks> POSTs the file content as body (operator-copy-paste-safe)", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-body-file-"));
|
||||
const bodyPath = path.join(tmp, "body.txt");
|
||||
const content = "Per-commit handoff for OPR.X.Y.Z\n\n```bash\nrig queue handoff qitem-1 --to next@rig\n```\n\nDone.";
|
||||
fs.writeFileSync(bodyPath, content, "utf8");
|
||||
try {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "qitem-fr4a-1", state: "pending" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body-file", bodyPath,
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect(create, "expected POST /api/queue/create to fire").toBeDefined();
|
||||
const body = create!.body as Record<string, unknown>;
|
||||
expect(body.body).toBe(content);
|
||||
// Discriminator: the backtick fence survived intact, proving the
|
||||
// shell-substitution class never touched the content.
|
||||
expect((body.body as string)).toMatch(/```bash[\s\S]*?```/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("create with both --body and --body-file errors with exit 1 + 3-part error + does NOT contact the daemon", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-conflict-"));
|
||||
const bodyPath = path.join(tmp, "body.txt");
|
||||
fs.writeFileSync(bodyPath, "x", "utf8");
|
||||
try {
|
||||
const { deps, calls } = makeDeps();
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
const prevExit = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "inline",
|
||||
"--body-file", bodyPath,
|
||||
"--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||||
} finally {
|
||||
process.exitCode = prevExit;
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("create with neither --body nor --body-file errors with exit 1 + does NOT contact the daemon", async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
const prevExit = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||||
} finally {
|
||||
process.exitCode = prevExit;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-4(b) — --mission / --slice first-class flags
|
||||
// translate to mission:<id> / slice:<id> tags and compose with --tags.
|
||||
// This is tag-formalization only — no schema change; the qitem still
|
||||
// stores tags as a flat list.
|
||||
describe("FR-4(b) — --mission / --slice first-class flag-formalization", () => {
|
||||
it("--mission translates to a mission:<id> tag", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-1" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--mission", "release-0.3.2",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags: string[] }).tags).toEqual(["mission:release-0.3.2"]);
|
||||
});
|
||||
|
||||
it("--slice translates to a slice:<id> tag", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-2" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--slice", "21-fr-4-queue-ergonomics",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags: string[] }).tags).toEqual(["slice:21-fr-4-queue-ergonomics"]);
|
||||
});
|
||||
|
||||
it("--mission + --slice + --tags merges all three sets (mission/slice first, --tags appended) and de-duplicates", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-3" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--mission", "release-0.3.2",
|
||||
"--slice", "21-fr-4-queue-ergonomics",
|
||||
"--tags", "gate:guard,handoff:per-commit",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags: string[] }).tags).toEqual([
|
||||
"mission:release-0.3.2",
|
||||
"slice:21-fr-4-queue-ergonomics",
|
||||
"gate:guard",
|
||||
"handoff:per-commit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("--mission release-0.3.2 + --tags mission:release-0.3.2 de-duplicates the redundant tag (one mission:X kept)", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-4" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--mission", "release-0.3.2",
|
||||
"--tags", "mission:release-0.3.2,gate:guard",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
const tags = (create!.body as { tags: string[] }).tags;
|
||||
expect(tags.filter((t) => t === "mission:release-0.3.2")).toHaveLength(1);
|
||||
expect(tags).toContain("gate:guard");
|
||||
});
|
||||
|
||||
it("no --mission/--slice/--tags → tags is undefined on the wire (legacy behavior preserved)", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-5" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags?: string[] }).tags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("update --state done WITHOUT --closure-reason renders structured hot-potato error and exits non-zero", async () => {
|
||||
const { deps } = makeDeps({
|
||||
routes: {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// OPR.0.3.4.3 — rig reconcile-session CLI.
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { reconcileSessionCommand } from "../src/commands/reconcile-session.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try { await fn(); } finally { console.log = origLog; console.error = origErr; }
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
const OK_RESPONSE = {
|
||||
ok: true,
|
||||
result: {
|
||||
rigId: "rig-1",
|
||||
rigName: "my-rig",
|
||||
nodeId: "node-1",
|
||||
logicalId: "dev.impl",
|
||||
sessionName: "dev-impl@my-rig",
|
||||
sessionId: "sess-2",
|
||||
projectionDrift: ['runtime unverified: pane command "zsh" does not confirm runtime "claude-code"'],
|
||||
continuity: "unverified",
|
||||
},
|
||||
};
|
||||
|
||||
describe("rig reconcile-session", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let lastBody: Record<string, unknown> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const url = decodeURIComponent(req.url ?? "");
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => { body += c; });
|
||||
req.on("end", () => {
|
||||
if (req.method === "POST" && url.includes("/reconcile")) {
|
||||
lastBody = JSON.parse(body || "{}");
|
||||
if (url.includes("dead-session")) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, code: "session_not_found", message: 'No live tmux session named "dead-session@my-rig".' }));
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(OK_RESPONSE));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => { server.listen(0, r); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
function runningDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => p === STATE_FILE
|
||||
? JSON.stringify({ pid: 123, port, db: "t.sqlite", startedAt: "2026-06-11T00:00:00Z" } as DaemonState)
|
||||
: null),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
clientFactory: (url) => new DaemonClient(url),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(reconcileSessionCommand(runningDeps()));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("reconciles and prints the no-relaunch/no-input line + honest drift + continuity", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "reconcile-session", "dev-impl@my-rig", "--no-launch"]);
|
||||
});
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("Reconciled dev-impl@my-rig into rig my-rig");
|
||||
expect(out).toContain("no relaunch, no input sent");
|
||||
expect(out).toContain("node id unchanged");
|
||||
expect(out).toContain("Projection drift");
|
||||
expect(out).toContain("runtime unverified");
|
||||
expect(out).toContain("Conversation continuity: unverified");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards --rig/--node together in the body", async () => {
|
||||
lastBody = null;
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "reconcile-session", "dev-impl@my-rig", "--rig", "rig-1", "--node", "dev.impl"]);
|
||||
});
|
||||
expect(lastBody).toEqual({ rigId: "rig-1", logicalId: "dev.impl" });
|
||||
});
|
||||
|
||||
it("rejects --rig without --node before contacting the daemon", async () => {
|
||||
lastBody = null;
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "reconcile-session", "dev-impl@my-rig", "--rig", "rig-1"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("--rig and --node must be provided together");
|
||||
expect(lastBody).toBeNull();
|
||||
});
|
||||
|
||||
it("daemon error -> honest message + exit 1", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "reconcile-session", "dead-session@my-rig"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("No live tmux session");
|
||||
});
|
||||
|
||||
it("--json passes the outcome through", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "reconcile-session", "dev-impl@my-rig", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.result.continuity).toBe("unverified");
|
||||
expect(parsed.result.projectionDrift).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("wired via createProgram", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const program = createProgram();
|
||||
expect(program.commands.find((c) => c.name() === "reconcile-session")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// OPR.0.3.3.13.1 - CLI surface-detection parser POC tests (AC-1..AC-5).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import {
|
||||
extractSurfaceFromSources,
|
||||
normalizeFlag,
|
||||
FLAG_SEP,
|
||||
type Surface,
|
||||
} from "../src/release-surface/extract-surface.js";
|
||||
import {
|
||||
computeDiff,
|
||||
generateSurfaceDiff,
|
||||
diffToYaml,
|
||||
SurfaceParserError,
|
||||
type SurfaceDiff,
|
||||
} from "../src/release-surface/surface-diff.js";
|
||||
|
||||
const here = fileURLToPath(new URL(".", import.meta.url));
|
||||
function repoPath(rel: string): string {
|
||||
return fileURLToPath(new URL(`../${rel}`, import.meta.url));
|
||||
}
|
||||
function readSource(rel: string): { name: string; text: string } {
|
||||
const abs = repoPath(rel);
|
||||
return { name: rel.replace(/^src\//, ""), text: fs.readFileSync(abs, "utf8") };
|
||||
}
|
||||
|
||||
const FROM = "v0.3.1";
|
||||
const TO = "v0.3.2";
|
||||
|
||||
describe("release-surface parser - extract (unit)", () => {
|
||||
it("AC-2 crux: emits the registration name `policy`, never the filename `rig-policy`", () => {
|
||||
const surface = extractSurfaceFromSources([readSource("src/commands/rig-policy.ts")]);
|
||||
expect(surface.commands.has("policy")).toBe(true);
|
||||
expect([...surface.commands].some((c) => c.includes("rig-policy"))).toBe(false);
|
||||
// a few known subcommands of `policy`
|
||||
expect(surface.commands.has("policy set")).toBe(true);
|
||||
expect(surface.commands.has("policy defaults")).toBe(true);
|
||||
});
|
||||
|
||||
it("AC-3: captures the option name-token despite a template-literal description", () => {
|
||||
const surface = extractSurfaceFromSources([readSource("src/commands/scope.ts")]);
|
||||
// `scope slice create --template` has a `\`Template: ${...}\`` description.
|
||||
expect(surface.commands.has("scope slice create")).toBe(true);
|
||||
expect(surface.flags.has(`scope slice create${FLAG_SEP}--template`)).toBe(true);
|
||||
// `--reason` on `scope slice close` is a requiredOption with a template desc.
|
||||
expect(surface.flags.has(`scope slice close${FLAG_SEP}--reason`)).toBe(true);
|
||||
// top-level `--workspace` option on the scope root.
|
||||
expect(surface.flags.has(`scope${FLAG_SEP}--workspace`)).toBe(true);
|
||||
});
|
||||
|
||||
it("AC-3 helper: normalizeFlag reduces flags strings to the canonical long flag", () => {
|
||||
expect(normalizeFlag("--body-file <path>")).toBe("--body-file");
|
||||
expect(normalizeFlag("--no-mission-notes")).toBe("--no-mission-notes");
|
||||
expect(normalizeFlag("-l, --literal")).toBe("--literal");
|
||||
expect(normalizeFlag("-y")).toBe("-y");
|
||||
expect(normalizeFlag("not-a-flag")).toBeNull();
|
||||
});
|
||||
|
||||
it("captures the new queue create flags", () => {
|
||||
const surface = extractSurfaceFromSources([readSource("src/commands/queue.ts")]);
|
||||
expect(surface.commands.has("queue create")).toBe(true);
|
||||
for (const f of ["--body-file", "--mission", "--slice"]) {
|
||||
expect(surface.flags.has(`queue create${FLAG_SEP}${f}`)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("release-surface parser - diff (AC-1)", () => {
|
||||
it("AC-1: produces all five keys from a synthetic surface delta", () => {
|
||||
const from: Surface = {
|
||||
commands: new Set(["queue", "queue create", "old", "workspace"]),
|
||||
flags: new Set([`queue create${FLAG_SEP}--source`, `old${FLAG_SEP}--gone`]),
|
||||
};
|
||||
const to: Surface = {
|
||||
commands: new Set(["queue", "queue create", "scope", "scope mission", "workspace", "workspace doctor"]),
|
||||
flags: new Set([`queue create${FLAG_SEP}--source`, `queue create${FLAG_SEP}--body-file`]),
|
||||
};
|
||||
const diff = computeDiff(from, to, "refA", "refB");
|
||||
|
||||
expect(diff.release_from).toBe("refA");
|
||||
expect(diff.release_to).toBe("refB");
|
||||
// brand-new top-level command `scope` with its added subpath.
|
||||
expect(diff.added_commands).toEqual([
|
||||
{ name: "scope", subcommands: ["mission"] },
|
||||
]);
|
||||
// existing `queue create` gained a flag; existing `workspace` gained a subcommand.
|
||||
const flagsByCmd = new Map(diff.added_flags.map((e) => [e.command, e]));
|
||||
expect(flagsByCmd.get("queue create")?.flags).toEqual(["--body-file"]);
|
||||
expect(flagsByCmd.get("workspace")?.subcommands).toEqual(["doctor"]);
|
||||
// removed command `old` + removed flag `old --gone`.
|
||||
expect(diff.removed_or_renamed).toEqual(["old", "old --gone"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release-surface parser - v0.3.1..v0.3.2 worked example (AC-2)", () => {
|
||||
const diff = generateSurfaceDiff({ from: FROM, to: TO, cwd: here });
|
||||
|
||||
it("emits exactly `policy` and `scope` as new top-level commands (no false positives, no filename)", () => {
|
||||
const names = diff.added_commands.map((c) => c.name).sort();
|
||||
expect(names).toEqual(["policy", "scope"]);
|
||||
expect(names).not.toContain("rig-policy");
|
||||
});
|
||||
|
||||
it("surfaces the new queue create flags", () => {
|
||||
const queueCreate = diff.added_flags.find((e) => e.command === "queue create");
|
||||
expect(queueCreate).toBeDefined();
|
||||
for (const f of ["--body-file", "--mission", "--slice"]) {
|
||||
expect(queueCreate!.flags ?? []).toContain(f);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates against the checked-in fixture with zero false negatives", () => {
|
||||
const fixture = parseYaml(
|
||||
fs.readFileSync(repoPath("src/release-surface/v0.3.2-surface.fixture.yaml"), "utf8"),
|
||||
) as SurfaceDiff;
|
||||
|
||||
const outCmdNames = new Set(diff.added_commands.map((c) => c.name));
|
||||
for (const fc of fixture.added_commands) {
|
||||
expect(outCmdNames.has(fc.name), `fixture added_command "${fc.name}" missing from parser output`).toBe(true);
|
||||
}
|
||||
|
||||
const outByCmd = new Map(diff.added_flags.map((e) => [e.command, e]));
|
||||
for (const fe of fixture.added_flags) {
|
||||
const out = outByCmd.get(fe.command);
|
||||
expect(out, `fixture command "${fe.command}" missing from parser output`).toBeDefined();
|
||||
for (const f of fe.flags ?? []) {
|
||||
expect(out!.flags ?? [], `fixture flag ${fe.command} ${f} missing`).toContain(f);
|
||||
}
|
||||
for (const s of fe.subcommands ?? []) {
|
||||
expect(out!.subcommands ?? [], `fixture subcommand ${fe.command} ${s} missing`).toContain(s);
|
||||
}
|
||||
}
|
||||
|
||||
// No silent over/under-report at command granularity: the parser's
|
||||
// added_flags command set must exactly equal the fixture's (the fixture was
|
||||
// independently derived from the release notes + tag diff, then reconciled).
|
||||
const outFlagCmds = diff.added_flags.map((e) => e.command).sort();
|
||||
const fixtureFlagCmds = fixture.added_flags.map((e) => e.command).sort();
|
||||
expect(outFlagCmds).toEqual(fixtureFlagCmds);
|
||||
|
||||
// Nothing was removed or renamed v0.3.1..v0.3.2 (the release was additive).
|
||||
// A spurious entry here would signal inconsistent cross-ref extraction.
|
||||
expect(diff.removed_or_renamed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release-surface parser - determinism (AC-4)", () => {
|
||||
it("produces byte-identical YAML across two runs on the same refs", () => {
|
||||
const a = diffToYaml(generateSurfaceDiff({ from: FROM, to: TO, cwd: here }));
|
||||
const b = diffToYaml(generateSurfaceDiff({ from: FROM, to: TO, cwd: here }));
|
||||
expect(a).toBe(b);
|
||||
expect(a.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("release-surface parser - honest failure (AC-5)", () => {
|
||||
it("throws a 3-part error (not a silent empty diff) on a non-existent ref", () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
generateSurfaceDiff({ from: "v0.0.0-does-not-exist", to: TO, cwd: here });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SurfaceParserError);
|
||||
const e = caught as SurfaceParserError;
|
||||
expect(e.fact).toBeTruthy();
|
||||
expect(e.consequence).toBeTruthy();
|
||||
expect(e.action).toBeTruthy();
|
||||
// explicitly NOT a silent empty diff
|
||||
expect(e.consequence.toLowerCase()).toContain("diff");
|
||||
});
|
||||
});
|
||||
@@ -1468,7 +1468,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
|
||||
|
||||
describe("M2c-Daemon final regression — Velocity 4-pack round-trip via CLI", () => {
|
||||
let tmpRoot: string;
|
||||
const VELOCITY_ROOT = "/Users/example/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
|
||||
const VELOCITY_ROOT = "/Users/example/.openrig/shared-docs/internal-docs/field-notes/2026-04-27-velocity-claude-from-codex-restore";
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// Slice 09 (OPR.0.3.2.9) — `rig policy` CLI tests.
|
||||
//
|
||||
// HG-4 / HG-7 anchored at the CLI layer:
|
||||
// - HG-7: `rig policy set <mode>` WITHOUT --confirm restates the
|
||||
// proposed binding and exits 2; daemon is NOT called.
|
||||
// - HG-7: bare-word + `mode:` prefix BOTH normalize to a valid mode.
|
||||
// - HG-4: with --confirm + --bearer, an Authorization header is sent
|
||||
// on the PUT.
|
||||
// - HG-3: `rig policy effective` surfaces Q6 unknown_posture cleanly
|
||||
// when no binding matches.
|
||||
// - Citation helper format matches convention §Citation Rules
|
||||
// (short-prose with mode + scope(:qualifier) + operator source).
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { rigPolicyCommand, __test__, type RigPolicyDeps } from "../src/commands/rig-policy.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeResponse<T = unknown> { status: number; data: T }
|
||||
type RecordedCall = { method: string; path: string; body?: unknown; options?: { headers?: Record<string, string> } };
|
||||
|
||||
function fakeClient(opts: {
|
||||
defaultsResponse?: FakeResponse;
|
||||
putResponse?: FakeResponse;
|
||||
deleteResponse?: FakeResponse;
|
||||
getResponse?: FakeResponse;
|
||||
effectiveResponse?: FakeResponse;
|
||||
}): { client: unknown; calls: RecordedCall[] } {
|
||||
const calls: RecordedCall[] = [];
|
||||
const client = {
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
if (path.startsWith("/api/rig-policy/defaults")) {
|
||||
return opts.defaultsResponse ?? {
|
||||
status: 200,
|
||||
data: {
|
||||
recommendedModeDefaults: {
|
||||
sleep: { autonomy_scope: "pre_approved_only", heartbeat_cadence: "sparse", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "serial", permission_prompt_posture: "batch_for_human" },
|
||||
desk: { autonomy_scope: "full_autonomy_within_workstream", heartbeat_cadence: "normal", inspection_depth: "normal", update_detail: "normal", escalation_threshold: "normal", concurrency_limit: "unlimited", permission_prompt_posture: "normal" },
|
||||
mobile: { autonomy_scope: "bounded_continuation", heartbeat_cadence: "normal", inspection_depth: "surface", update_detail: "compact", escalation_threshold: "low", concurrency_limit: "unlimited", permission_prompt_posture: "batch_for_human" },
|
||||
away: { autonomy_scope: "pre_approved_only", heartbeat_cadence: "sparse", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "serial", permission_prompt_posture: "batch_for_human" },
|
||||
focus: { autonomy_scope: "full_autonomy_within_workstream", heartbeat_cadence: "normal", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "unlimited", permission_prompt_posture: "batch_for_human" },
|
||||
debug: { autonomy_scope: "bounded_continuation", heartbeat_cadence: "fast", inspection_depth: "forensic", update_detail: "verbose", escalation_threshold: "low", concurrency_limit: "serial", permission_prompt_posture: "normal" },
|
||||
},
|
||||
recommendedDefaultScope: { sleep: "global_host", desk: "global_host", mobile: "global_host", away: "global_host", focus: "workstream", debug: "qitem" },
|
||||
defaultStaleRule: "re_confirm_on_long_gap",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path.startsWith("/api/rig-policy/effective")) {
|
||||
return opts.effectiveResponse ?? { status: 200, data: { effective: null, posture: "unknown_posture", hint: "..." } };
|
||||
}
|
||||
return opts.getResponse ?? { status: 200, data: { bindings: [] } };
|
||||
}),
|
||||
put: vi.fn(async (path: string, body: unknown, options?: { headers?: Record<string, string> }) => {
|
||||
calls.push({ method: "PUT", path, body, options });
|
||||
const reqBody = body as { mode: string; record: Record<string, string> };
|
||||
return opts.putResponse ?? { status: 200, data: { binding: { id: "qitem:q-1", mode: reqBody.mode, record: reqBody.record, qualifier: "q-1", setAt: "2026-05-17T00:00:00.000Z", setBy: "operator" } } };
|
||||
}),
|
||||
delete: vi.fn(async (path: string, options?: { headers?: Record<string, string> }) => {
|
||||
calls.push({ method: "DELETE", path, options });
|
||||
return opts.deleteResponse ?? { status: 200, data: { removed: true } };
|
||||
}),
|
||||
};
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
function deps(client: unknown): RigPolicyDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => client as ReturnType<RigPolicyDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
let logs: string[];
|
||||
let errs: string[];
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errs = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => errs.push(args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
describe("disambiguateModeInvocation (Component 4 bare-word rule)", () => {
|
||||
const { disambiguateModeInvocation } = __test__;
|
||||
it("accepts bare reserved modes", () => {
|
||||
for (const m of ["sleep", "desk", "mobile", "away", "focus", "debug"]) {
|
||||
expect(disambiguateModeInvocation(m)).toBe(m);
|
||||
}
|
||||
});
|
||||
it("accepts the `mode:` prefix form", () => {
|
||||
expect(disambiguateModeInvocation("mode:debug")).toBe("debug");
|
||||
expect(disambiguateModeInvocation("mode: focus")).toBe("focus");
|
||||
});
|
||||
it("rejects unknown words", () => {
|
||||
expect(disambiguateModeInvocation("banana")).toBeNull();
|
||||
expect(disambiguateModeInvocation("operator:L1")).toBeNull();
|
||||
expect(disambiguateModeInvocation("OFF")).toBeNull();
|
||||
});
|
||||
it("rejects multi-word strings (embedded-in-sentence)", () => {
|
||||
expect(disambiguateModeInvocation("set debug")).toBeNull();
|
||||
expect(disambiguateModeInvocation("we should mobile this")).toBeNull();
|
||||
});
|
||||
it("rejects empty input", () => {
|
||||
expect(disambiguateModeInvocation("")).toBeNull();
|
||||
expect(disambiguateModeInvocation(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCitation (Component 5)", () => {
|
||||
const { formatCitation } = __test__;
|
||||
it("emits mode + scope(:qualifier) + operator source + set_at — mode is binding-level", () => {
|
||||
const line = formatCitation({
|
||||
id: "qitem:q-1",
|
||||
mode: "debug",
|
||||
record: { scope: "qitem" } as Record<string, string>,
|
||||
qualifier: "q-1",
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
});
|
||||
expect(line).toContain("`debug`");
|
||||
expect(line).toContain("`qitem:q-1`");
|
||||
expect(line).toContain("operator");
|
||||
expect(line).toContain("2026-05-17T00:00:00.000Z");
|
||||
});
|
||||
it("omits qualifier when null (global_host)", () => {
|
||||
const line = formatCitation({
|
||||
id: "global_host:host",
|
||||
mode: "sleep",
|
||||
record: { scope: "global_host" } as Record<string, string>,
|
||||
qualifier: null,
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
});
|
||||
expect(line).toContain("`global_host`");
|
||||
expect(line).not.toContain("`global_host:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy set — restate-and-confirm gate (HG-7)", () => {
|
||||
it("WITHOUT --confirm: restates + exits 2 + does NOT call PUT", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
const puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(puts).toHaveLength(0);
|
||||
expect(logs.join("\n")).toContain("Proposed binding (restate-and-confirm — NOT applied)");
|
||||
expect(logs.join("\n")).toContain("debug");
|
||||
});
|
||||
|
||||
it("WITHOUT --confirm + --json: ok:false + confirm_required:true", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--json"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
const joined = logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.confirm_required).toBe(true);
|
||||
expect(parsed.proposed.mode).toBe("debug");
|
||||
expect(parsed.proposed.record.mode).toBeUndefined();
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("WITH --confirm: PUTs { mode, record } to the daemon and prints citation", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0]!.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
const putBody = puts[0]!.body as { mode: string; record: Record<string, string> };
|
||||
expect(putBody.mode).toBe("debug");
|
||||
// BLOCKING-1: record must NOT carry `mode` inside.
|
||||
expect(putBody.record.mode).toBeUndefined();
|
||||
expect(putBody.record.scope).toBe("qitem");
|
||||
expect(logs.join("\n")).toContain("Operating in `debug` mode at `qitem:q-1`");
|
||||
});
|
||||
|
||||
it("HG-4: --bearer forwards Authorization: Bearer <token>", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm", "--bearer", "operator-token"]);
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
expect(put.options?.headers?.Authorization).toBe("Bearer operator-token");
|
||||
});
|
||||
|
||||
it("HG-7 mode:<name> prefix: works the same as bare word", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "mode:focus", "--qualifier", "ws-1", "--confirm"]);
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
const body = put.body as { mode: string; record: Record<string, string> };
|
||||
expect(body.mode).toBe("focus");
|
||||
expect(body.record.scope).toBe("workstream");
|
||||
expect(put.path).toBe("/api/rig-policy/bindings/workstream/ws-1");
|
||||
});
|
||||
|
||||
it("rejects unknown mode with exit=1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "banana", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Unknown mode/);
|
||||
});
|
||||
|
||||
it("forwards 401 from daemon with operator hint", async () => {
|
||||
const { client } = fakeClient({ putResponse: { status: 401, data: { error: "unauthorized" } } });
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errs.join("\n")).toMatch(/Unauthorized/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy effective — Q6 unknown_posture surfaced", () => {
|
||||
it("prints 'unknown_posture' when daemon returns null effective", async () => {
|
||||
const { client } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "effective", "--qitem", "q-1"]);
|
||||
expect(logs.join("\n")).toContain("unknown_posture");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy cite — convention citation rules", () => {
|
||||
it("prints unknown_posture fallback when no binding resolves", async () => {
|
||||
const { client } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "cite", "--qitem", "q-1"]);
|
||||
expect(logs.join("\n")).toContain("without an explicit");
|
||||
});
|
||||
|
||||
it("emits citation line when a binding resolves", async () => {
|
||||
const { client } = fakeClient({
|
||||
effectiveResponse: {
|
||||
status: 200,
|
||||
data: {
|
||||
posture: "known",
|
||||
effective: {
|
||||
resolvedScope: "qitem",
|
||||
binding: {
|
||||
id: "qitem:q-1",
|
||||
mode: "debug",
|
||||
record: { scope: "qitem" },
|
||||
qualifier: "q-1",
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "cite", "--qitem", "q-1"]);
|
||||
const line = logs.join("\n");
|
||||
expect(line).toMatch(/Operating in `debug` mode at `qitem:q-1` per operator/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeScopeQualifier (CLI mirror of daemon-route parseScopeAndQualifier)", () => {
|
||||
const { normalizeScopeQualifier } = __test__;
|
||||
it("global_host + no explicit qualifier → ok with null", () => {
|
||||
const r = normalizeScopeQualifier("global_host", undefined);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.qualifier).toBeNull();
|
||||
});
|
||||
it("global_host + explicit qualifier → REJECTED (no silent drop)", () => {
|
||||
const r = normalizeScopeQualifier("global_host", "unexpected");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.message).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
it("rig + qualifier → ok with the qualifier", () => {
|
||||
const r = normalizeScopeQualifier("rig", "rig-a");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.qualifier).toBe("rig-a");
|
||||
});
|
||||
it("rig + no qualifier → rejected (qualifier_required)", () => {
|
||||
const r = normalizeScopeQualifier("rig", undefined);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.message).toMatch(/requires a qualifier/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BLOCKING re-verify-2 (qitem-20260518045300): explicit --scope global_host --qualifier <X> rejected BEFORE any daemon contact", () => {
|
||||
it("WITHOUT --confirm: exits 1; daemon NOT contacted at all (no GET /defaults, no PUT, no restate)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
// BLOCKING re-verify-2 evidence: zero daemon calls of any kind.
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
expect(logs.join("\n")).not.toContain("Proposed binding");
|
||||
});
|
||||
|
||||
it("WITH --confirm: exits 1; daemon NOT contacted (no GET /defaults, no PUT)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected", "--confirm"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
|
||||
it("WITH --json: emits ok:false + qualifier_invalid; zero daemon calls", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected", "--json"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toBe("qualifier_invalid");
|
||||
});
|
||||
|
||||
it("Explicit non-global scope missing qualifier ALSO preflighted: zero daemon calls", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--scope", "rig"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/requires a qualifier/);
|
||||
});
|
||||
|
||||
it("Explicit unknown scope rejected locally: zero daemon calls", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--scope", "banana", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Unknown scope 'banana'/);
|
||||
});
|
||||
|
||||
it("Positive: set sleep --scope global_host --confirm → PUT /bindings/global_host (one GET /defaults + one PUT)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--confirm"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
expect(put.path).toBe("/api/rig-policy/bindings/global_host");
|
||||
});
|
||||
|
||||
it("Implicit-default-scope path STILL fetches defaults (no preflight rejection): set debug without --scope reaches GET /defaults + PUT", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const gets = calls.filter((c) => c.method === "GET" && c.path.startsWith("/api/rig-policy/defaults"));
|
||||
const puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(gets.length).toBe(1);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0]!.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
});
|
||||
|
||||
it("Implicit-default-scope path still rejects missing qualifier when default scope needs one (no PUT)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
// `debug` default scope is `qitem`; without --qualifier the post-defaults normalization rejects.
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/requires a qualifier/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy unset — operator-only DELETE", () => {
|
||||
it("calls DELETE with Authorization when --bearer is provided", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "qitem", "q-1", "--bearer", "operator-token"]);
|
||||
const del = calls.find((c) => c.method === "DELETE")!;
|
||||
expect(del.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
expect(del.options?.headers?.Authorization).toBe("Bearer operator-token");
|
||||
});
|
||||
|
||||
it("rejects unknown scope with exit=1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "banana", "x"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// BLOCKING re-verify (qitem-20260518044650): `unset global_host
|
||||
// <qualifier>` must NOT silently drop the qualifier and delete the
|
||||
// host row. Same shape as set; same shared helper.
|
||||
it("BLOCKING re-verify: unset global_host unexpected → exits 1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "global_host", "unexpected"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
|
||||
it("Positive: unset global_host (no qualifier) targets /bindings/global_host", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "global_host"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const del = calls.find((c) => c.method === "DELETE")!;
|
||||
expect(del.path).toBe("/api/rig-policy/bindings/global_host");
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ function writeFile(p: string, content: string): void {
|
||||
|
||||
function seedSubstrate(): { root: string; missionsRoot: string } {
|
||||
const root = mktemp();
|
||||
const missionsRoot = path.join(root, "openrig-work", "missions");
|
||||
const missionsRoot = path.join(root, "internal-docs", "missions");
|
||||
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.name", "T"], { stdio: "ignore" });
|
||||
@@ -391,6 +391,97 @@ describe("rig scope mission create (HG-14 + HG-15)", () => {
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(JSON.parse(r.stdout).ok).toBe(false);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-3 — auto-scaffold MISSION_NOTES.md alongside README.
|
||||
it("FR-3: mission create auto-scaffolds MISSION_NOTES.md with placeholders substituted (default ON; built-in template)", async () => {
|
||||
const r = await run(["mission", "create", "release-0.6.0", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.missionNotesPath).toBeTruthy();
|
||||
expect(parsed.mission.missionNotesResolvedFrom).toBe("built-in");
|
||||
const mnPath = parsed.mission.missionNotesPath as string;
|
||||
expect(fs.existsSync(mnPath)).toBe(true);
|
||||
const content = fs.readFileSync(mnPath, "utf8");
|
||||
// Placeholders must be substituted.
|
||||
expect(content).not.toMatch(/\{\{mission_id\}\}/);
|
||||
expect(content).not.toMatch(/\{\{mission_name\}\}/);
|
||||
expect(content).not.toMatch(/\{\{created_date\}\}/);
|
||||
// Substituted values present.
|
||||
expect(content).toMatch(/mission: OPR\.0\.6\.0/);
|
||||
// titleFromSlug("0.6.0") → "0.6.0" (no separators to titlecase); the
|
||||
// bare version string is what lands in mission_name.
|
||||
expect(content).toMatch(/name: 0\.6\.0/);
|
||||
expect(content).toMatch(/cont\.0 — mission scaffolded/);
|
||||
// Canonical structure markers.
|
||||
expect(content).toMatch(/## §1\. Top-of-mind context/);
|
||||
expect(content).toMatch(/## §10\. What NOT to reconstruct/);
|
||||
expect(content).toMatch(/## §A\. <first-seat>@<rig> notes/);
|
||||
});
|
||||
|
||||
it("FR-3: --no-mission-notes opts out — README created, MISSION_NOTES.md is NOT", async () => {
|
||||
const r = await run([
|
||||
"mission", "create", "release-0.7.0",
|
||||
"--no-mission-notes", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
// README still produced.
|
||||
expect(fs.existsSync(parsed.mission.readmePath)).toBe(true);
|
||||
// MISSION_NOTES suppressed.
|
||||
expect(parsed.mission.missionNotesPath).toBeNull();
|
||||
expect(parsed.mission.missionNotesResolvedFrom).toBeNull();
|
||||
expect(fs.existsSync(path.join(parsed.mission.path, "MISSION_NOTES.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("FR-3: OPENRIG_MISSION_NOTES_TEMPLATE_PATH env var overrides the built-in template", async () => {
|
||||
// Write a custom template at a temp path containing a distinctive marker.
|
||||
const customTemplatePath = path.join(env.root, "custom-mission-notes-template.md");
|
||||
fs.writeFileSync(
|
||||
customTemplatePath,
|
||||
"---\nmission: {{mission_id}}\n---\n\n# CUSTOM TEMPLATE — {{mission_name}}\n\nCREATED ON {{created_date}}\n",
|
||||
"utf8",
|
||||
);
|
||||
const prior = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = customTemplatePath;
|
||||
try {
|
||||
const r = await run(["mission", "create", "release-0.8.0", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.missionNotesResolvedFrom).toBe("env");
|
||||
const content = fs.readFileSync(parsed.mission.missionNotesPath as string, "utf8");
|
||||
expect(content).toMatch(/CUSTOM TEMPLATE — 0\.8\.0/);
|
||||
expect(content).toMatch(/mission: OPR\.0\.8\.0/);
|
||||
// Built-in canonical sections must NOT appear (proves we used the custom one).
|
||||
expect(content).not.toMatch(/## §1\. Top-of-mind context/);
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it("FR-3: OPENRIG_MISSION_NOTES_TEMPLATE_PATH pointing at a missing file fails with a 3-part error and no mission dir leaked", async () => {
|
||||
const prior = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = path.join(env.root, "does-not-exist.md");
|
||||
try {
|
||||
const r = await run(["mission", "create", "release-0.9.0", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/OPENRIG_MISSION_NOTES_TEMPLATE_PATH/);
|
||||
// Discriminator (guard catch qitem-20260601121058): the failed scaffold
|
||||
// MUST NOT leave a half-created mission dir behind. Without the
|
||||
// verify-first-then-write reorder, the mission dir + README would
|
||||
// exist after this command, and a retry would hit
|
||||
// "Mission folder already exists." which the operator would
|
||||
// reasonably read as a CLI bug.
|
||||
const leakedPath = path.join(env.missionsRoot, "release-0.9.0");
|
||||
expect(fs.existsSync(leakedPath), `expected no half-created mission at ${leakedPath} after stale env-var failure`).toBe(false);
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = prior;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -69,6 +69,15 @@ describe("Send CLI", () => {
|
||||
if (parsed.session === "dev-impl@my-rig") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessionName: "dev-impl@my-rig" }));
|
||||
} else if (parsed.session === "verified-session") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessionName: "verified-session", verified: true, outcome: "delivered" }));
|
||||
} else if (parsed.session === "racy-session") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, sessionName: "racy-session", verified: false, outcome: "rendered-unconfirmed" }));
|
||||
} else if (parsed.session === "dead-session") {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, sessionName: "dead-session", reason: "submit_failed", outcome: "failed", error: "Text is visible in 'dead-session' but was not submitted (Enter failed)." }));
|
||||
} else if (parsed.session === "busy-session") {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, sessionName: "busy-session", reason: "mid_work", error: "Target pane appears mid-task. Use force: true to send anyway." }));
|
||||
@@ -113,6 +122,52 @@ describe("Send CLI", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// OPR.99.0.6.3 — honest delivery-outcome vocabulary; legacy Verified: line preserved.
|
||||
it("verify confirmed prints Delivery: delivered AND the legacy Verified: yes", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "verified-session", "hello", "--verify"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Sent to verified-session");
|
||||
expect(output).toContain("Verified: yes");
|
||||
expect(output).toContain("Delivery: delivered");
|
||||
});
|
||||
|
||||
it("verify redraw-race prints Delivery: rendered-unconfirmed (landed, with capture guidance) AND legacy Verified: no", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "racy-session", "hello", "--verify"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Sent to racy-session");
|
||||
expect(output).toContain("Verified: no");
|
||||
expect(output).toContain("Delivery: rendered-unconfirmed");
|
||||
expect(output).toContain("landed");
|
||||
expect(output).toContain("rig capture racy-session");
|
||||
// The middle is NOT dressed as failure: exit stays clean.
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("verify genuine transport failure stays an error path, distinct from the middle (discriminator)", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "dead-session", "hello", "--verify"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
// HTTP 502 -> error branch; no success lines, exit non-zero.
|
||||
expect(output).not.toContain("Sent to dead-session");
|
||||
expect(output).not.toContain("Delivery: rendered-unconfirmed");
|
||||
expect(output).toContain("not submitted");
|
||||
expect(exitCode).toBe(2);
|
||||
});
|
||||
|
||||
it("verify --json passes the additive outcome field through", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "racy-session", "hello", "--verify", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.verified).toBe(false);
|
||||
expect(parsed.outcome).toBe("rendered-unconfirmed");
|
||||
});
|
||||
|
||||
it("send --json prints raw JSON", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "hello", "--json"]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { setupCommand, runSetup, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
|
||||
import { setupCommand, runSetup, goldenPathNextSteps, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
|
||||
import type { DoctorDeps } from "../src/commands/doctor.js";
|
||||
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
|
||||
|
||||
@@ -207,6 +207,30 @@ describe("rig setup", () => {
|
||||
expect(verify?.status).toBe("pass");
|
||||
});
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-1): the ONE canonical ordered golden path over EXISTING
|
||||
// verbs (no mega-command), in sequence, with the durable doc reference.
|
||||
it("AC-1: goldenPathNextSteps is the ordered sequence over existing verbs, with the durable doc", () => {
|
||||
const out = goldenPathNextSteps().join("\n");
|
||||
const upIdx = out.indexOf("rig up");
|
||||
const statusIdx = out.indexOf("rig status");
|
||||
const wsIdx = out.indexOf("rig workspace doctor");
|
||||
const wfIdx = out.indexOf("rig workflow instantiate");
|
||||
const scopeIdx = out.indexOf("rig scope");
|
||||
expect(upIdx).toBeGreaterThan(-1);
|
||||
expect(statusIdx).toBeGreaterThan(upIdx);
|
||||
expect(wsIdx).toBeGreaterThan(statusIdx);
|
||||
expect(wfIdx).toBeGreaterThan(wsIdx);
|
||||
expect(scopeIdx).toBeGreaterThan(wfIdx);
|
||||
expect(out).toContain("docs/reference/getting-started.md");
|
||||
// no magic mega-command - the path is existing verbs only
|
||||
expect(out).not.toMatch(/rig (journey|onboarding)\b/);
|
||||
// built-in discovery surface is `rig workflow specs` (lists registered specs,
|
||||
// built-ins tagged "(built-in)"), NOT `rig workflow list` (lists instances) -
|
||||
// the path must point a fresh operator at the right discovery command.
|
||||
expect(out).toContain("rig workflow specs");
|
||||
expect(out).not.toContain("rig workflow list");
|
||||
});
|
||||
|
||||
it("fails setup honestly when tmux is installed but the default control socket is unhealthy", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
|
||||
@@ -187,6 +187,21 @@ function createMockDaemon() {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.4.6 — five-term cross-surface guard for the rig restore CLI surface.
|
||||
if (snapshotId === "five-term") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ nodeId: "n1", logicalId: "a", status: "resumed" },
|
||||
{ nodeId: "n2", logicalId: "b", status: "fresh-primed" },
|
||||
{ nodeId: "n3", logicalId: "c", status: "awaiting-decision", error: "no token" },
|
||||
{ nodeId: "n4", logicalId: "d", status: "attention_required" },
|
||||
{ nodeId: "n5", logicalId: "e", status: "failed" },
|
||||
],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
rigResult: "fully_restored",
|
||||
@@ -335,6 +350,29 @@ describe("rig snapshot + restore", () => {
|
||||
process.exitCode = savedExitCode;
|
||||
});
|
||||
|
||||
// OPR.0.3.4.6 — cross-surface regression guard: rig restore CLI surface.
|
||||
// All five terms render distinctly and attention_required/awaiting-decision
|
||||
// with partially_restored -> exit 1, never exit 0.
|
||||
it("OPR.0.3.4.6 guard: rig restore renders all five terms distinctly + exit 1 for partially_restored", async () => {
|
||||
const savedExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
|
||||
const program = new Command();
|
||||
program.addCommand(restoreCommand(runningDeps(port)));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "restore", "five-term", "--rig", "rig-1"]));
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("a: resumed");
|
||||
expect(output).toContain("b: fresh-primed");
|
||||
expect(output).toContain("c: awaiting-decision");
|
||||
expect(output).toContain("d: attention_required");
|
||||
expect(output).toContain("e: failed");
|
||||
expect(output).toContain("partially_restored");
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
process.exitCode = savedExitCode;
|
||||
});
|
||||
|
||||
// Test 6: restore snapshot not found (404)
|
||||
it("restore: snapshot not found (404) -> error", async () => {
|
||||
const program = new Command();
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
// OPR.0.3.4.1 — rig start: one-command recovery orchestrator tests.
|
||||
// Tests verify composition (slice-02 /api/up called, never private restore),
|
||||
// headless flags, honest reporting, kernel invariant, and idempotency.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { startCommand } from "../src/commands/start.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try { await fn(); } finally { console.log = origLog; console.error = origErr; }
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
describe("rig start (OPR.0.3.4.1)", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let upBodies: Array<Record<string, unknown>> = [];
|
||||
let routes: Record<string, (req: http.IncomingMessage, body: Record<string, unknown>) => Record<string, unknown>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c: Buffer) => { raw += c; });
|
||||
req.on("end", () => {
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
const url = req.url ?? "";
|
||||
const method = req.method ?? "GET";
|
||||
|
||||
if (method === "GET" && url === "/healthz") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && url === "/api/kernel/status") {
|
||||
const handler = routes["/api/kernel/status"];
|
||||
const data = handler ? handler(req, body) : { kernel_state: "ready" };
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && url === "/api/rigs/summary") {
|
||||
const handler = routes["/api/rigs/summary"];
|
||||
const data = handler ? handler(req, body) : [];
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
if (method === "GET" && url.match(/^\/api\/rigs\/[^/]+$/)) {
|
||||
const handler = routes["/api/rigs/:id"];
|
||||
const data = handler ? handler(req, body) : { sessions: [] };
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
if (method === "POST" && url === "/api/up") {
|
||||
upBodies.push(body);
|
||||
const handler = routes["/api/up"];
|
||||
const data = handler ? handler(req, body) : {};
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
// Id-based plan preview: POST /api/rigs/:id/up (used by rig start candidate discovery)
|
||||
const rigUpMatch = method === "POST" && url.match(/^\/api\/rigs\/([^/]+)\/up$/);
|
||||
if (rigUpMatch) {
|
||||
upBodies.push({ ...body, _rigIdRoute: rigUpMatch[1] });
|
||||
const handler = routes["/api/rigs/:id/up"] ?? routes["/api/up"];
|
||||
const data = handler ? handler(req, { ...body, _rigId: rigUpMatch[1] }) : {};
|
||||
const httpStatus = (data as Record<string, unknown>)?._httpStatus as number | undefined;
|
||||
res.writeHead(httpStatus ?? 200, { "Content-Type": "application/json" });
|
||||
const { _httpStatus: _, ...cleanData } = data as Record<string, unknown>;
|
||||
res.end(JSON.stringify(httpStatus ? cleanData : data));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => { server.listen(0, r); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
beforeEach(() => {
|
||||
upBodies = [];
|
||||
routes = {};
|
||||
});
|
||||
|
||||
function deps(promptYesNo?: (q: string) => Promise<boolean>) {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => p === STATE_FILE
|
||||
? JSON.stringify({ pid: 123, port, db: "t.sqlite", startedAt: "2026-06-14T00:00:00Z" } as DaemonState)
|
||||
: null),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
promptYesNo,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCmd(promptYesNo?: (q: string) => Promise<boolean>): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(startCommand(deps(promptYesNo)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
function setupCandidateRig(name: string, opts?: { lifecycleState?: string; hasToken?: boolean }) {
|
||||
const lifecycleState = opts?.lifecycleState ?? "recoverable";
|
||||
const hasToken = opts?.hasToken ?? true;
|
||||
routes["/api/rigs/summary"] = () => [
|
||||
{ id: "rig-1", name, nodeCount: 2, lifecycleState },
|
||||
];
|
||||
routes["/api/rigs/:id"] = () => ({ sessions: [{ lastSeenAt: "2026-06-14T12:00:00Z" }] });
|
||||
// Both plan preview AND apply go through the id-based route.
|
||||
routes["/api/rigs/:id/up"] = (_req, body) => {
|
||||
if (body.plan === true) {
|
||||
return {
|
||||
status: "plan", mode: "restore", rigId: "rig-1", rigName: name,
|
||||
snapshot: { id: "snap-1", kind: "auto-pre-down", createdAt: "2026-06-14T11:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [
|
||||
{ logicalId: "dev.impl", intendedAction: hasToken ? "resume-original" : "awaiting-decision", ...(hasToken ? {} : { reason: "no token available" }) },
|
||||
{ logicalId: "dev.qa", intendedAction: "resume-original" },
|
||||
],
|
||||
mutated: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "restored", rigId: "rig-1", rigName: name,
|
||||
rigResult: "fully_restored",
|
||||
nodes: [
|
||||
{ logicalId: "dev.impl", status: hasToken ? "resumed" : "awaiting-decision", error: hasToken ? undefined : "no token available" },
|
||||
{ logicalId: "dev.qa", status: "resumed" },
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ---- HEADLESS FLAGS ----
|
||||
|
||||
it("--last restores all candidates with ZERO prompts", async () => {
|
||||
setupCandidateRig("my-rig");
|
||||
const prompt = vi.fn(async () => true);
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd(prompt).parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
const applyBodies = upBodies.filter((b) => b.plan !== true && b._rigIdRoute);
|
||||
expect(applyBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(applyBodies[0]!._rigIdRoute).toBe("rig-1");
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("my-rig");
|
||||
});
|
||||
|
||||
it("--all restores all candidates with ZERO prompts", async () => {
|
||||
setupCandidateRig("my-rig");
|
||||
const prompt = vi.fn(async () => true);
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd(prompt).parseAsync(["node", "rig", "start", "--all"]);
|
||||
});
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
const applyBodies = upBodies.filter((b) => b.plan !== true);
|
||||
expect(applyBodies.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("--rigs restores only the named rig", async () => {
|
||||
routes["/api/rigs/summary"] = () => [
|
||||
{ id: "rig-1", name: "alpha", nodeCount: 1, lifecycleState: "recoverable" },
|
||||
{ id: "rig-2", name: "beta", nodeCount: 1, lifecycleState: "recoverable" },
|
||||
];
|
||||
routes["/api/rigs/:id"] = () => ({ sessions: [] });
|
||||
routes["/api/rigs/:id/up"] = (_req, body) => {
|
||||
if (body.plan === true) {
|
||||
return {
|
||||
status: "plan", mode: "restore", rigId: body._rigId ?? "rig-x", rigName: "rig",
|
||||
snapshot: { id: "snap-1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
|
||||
mutated: false,
|
||||
};
|
||||
}
|
||||
return { status: "restored", rigResult: "fully_restored", nodes: [{ logicalId: "w", status: "resumed" }], warnings: [] };
|
||||
};
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--rigs", "alpha"]);
|
||||
});
|
||||
const applyBodies = upBodies.filter((b) => b.plan !== true && b._rigIdRoute);
|
||||
expect(applyBodies).toHaveLength(1);
|
||||
expect(applyBodies[0]!._rigIdRoute).toBe("rig-1");
|
||||
});
|
||||
|
||||
// ---- COMPOSE, NOT REIMPLEMENT ----
|
||||
|
||||
it("restore goes through /api/rigs/:id/up (id-based route), not a private implementation", async () => {
|
||||
setupCandidateRig("my-rig");
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
const applyBodies = upBodies.filter((b) => b.plan !== true && b._rigIdRoute);
|
||||
expect(applyBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(applyBodies.some((b) => b._rigIdRoute === "rig-1")).toBe(true);
|
||||
});
|
||||
|
||||
// ---- TOKENLESS INCLUSION (guard rev1 discriminator) ----
|
||||
|
||||
it("a tokenless rig (no resume token, restore-usable snapshot) is LISTED and previewed as awaiting-decision", async () => {
|
||||
setupCandidateRig("tokenless-rig", { hasToken: false });
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("tokenless-rig");
|
||||
expect(output).toContain("awaiting-decision");
|
||||
const applyBodies = upBodies.filter((b) => b.plan !== true);
|
||||
expect(applyBodies.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// ---- KERNEL INVARIANT ----
|
||||
|
||||
it("kernel failure -> honest hard-fail, restore NOT attempted", async () => {
|
||||
routes["/api/kernel/status"] = () => ({ kernel_state: "auth_blocked", detail: "Claude not authorized" });
|
||||
setupCandidateRig("my-rig");
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Kernel failed");
|
||||
expect(output).toContain("Cannot proceed");
|
||||
expect(exitCode).toBe(1);
|
||||
const applyBodies = upBodies.filter((b) => b.plan !== true);
|
||||
expect(applyBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---- IDEMPOTENT RERUN ----
|
||||
|
||||
it("already-running rig is SKIPPED (not relaunched)", async () => {
|
||||
routes["/api/rigs/summary"] = () => [
|
||||
{ id: "rig-1", name: "live-rig", nodeCount: 2, lifecycleState: "running" },
|
||||
];
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--all"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("No rigs to restore");
|
||||
expect(upBodies.filter((b) => b.plan !== true)).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---- UI URL HONESTY ----
|
||||
|
||||
it("reports UI URL only when healthz is reachable", async () => {
|
||||
routes["/api/rigs/summary"] = () => [];
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
// ---- HONEST TIMEOUT ----
|
||||
|
||||
it("apply-mode timeout reports in-progress/unknown + rig ps, not bare failure", async () => {
|
||||
setupCandidateRig("timeout-rig");
|
||||
// Override /api/up to throw a timeout for apply
|
||||
const throwClient = new DaemonClient(`http://127.0.0.1:${port}`);
|
||||
const origPost = throwClient.post.bind(throwClient);
|
||||
let planCallCount = 0;
|
||||
throwClient.post = async (path: string, body?: unknown, opts?: unknown) => {
|
||||
const b = body as Record<string, unknown> | undefined;
|
||||
// Apply now uses the id-based route; throw on any non-plan POST to /api/rigs/:id/up.
|
||||
if (path.match(/^\/api\/rigs\/[^/]+\/up$/) && b?.plan !== true) {
|
||||
throw new (await import("../src/client.js")).DaemonConnectionError("request timed out");
|
||||
}
|
||||
return origPost(path, body, opts);
|
||||
};
|
||||
const d = deps();
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(startCommand({ ...d, clientFactory: () => throwClient }));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("may still be processing");
|
||||
expect(output).toContain("rig ps");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// ---- NO NEW STATUS TERMS ----
|
||||
|
||||
it("only the five slice-02 terms appear in restore output (no new term invented)", async () => {
|
||||
const validTerms = ["resumed", "fresh-primed", "awaiting-decision", "attention_required", "failed"];
|
||||
routes["/api/rigs/summary"] = () => [{ id: "r1", name: "term-rig", nodeCount: 3, lifecycleState: "recoverable" }];
|
||||
routes["/api/rigs/:id"] = () => ({ sessions: [] });
|
||||
routes["/api/rigs/:id/up"] = (_req, body) => {
|
||||
if (body.plan === true) {
|
||||
return {
|
||||
status: "plan", mode: "restore", rigId: "r1", rigName: "term-rig",
|
||||
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [
|
||||
{ logicalId: "a", intendedAction: "resume-original" },
|
||||
{ logicalId: "b", intendedAction: "awaiting-decision", reason: "no token" },
|
||||
{ logicalId: "c", intendedAction: "fresh-primed" },
|
||||
],
|
||||
mutated: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "restored", rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ logicalId: "a", status: "resumed" },
|
||||
{ logicalId: "b", status: "awaiting-decision", error: "no token" },
|
||||
{ logicalId: "c", status: "fresh-primed" },
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
const statusMatches = output.match(/\b(resumed|fresh-primed|awaiting-decision|attention_required|failed)\b/g) ?? [];
|
||||
for (const term of statusMatches) {
|
||||
expect(validTerms).toContain(term);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- SAME-NAME RIG CANDIDATE DISCOVERY (guard BLOCKING f359e3a3) ----
|
||||
|
||||
it("two stopped rigs with the same name are BOTH listed as candidates (id-grounded preview, no ambiguous_name drop)", async () => {
|
||||
routes["/api/rigs/summary"] = () => [
|
||||
{ id: "rig-1", name: "foo", nodeCount: 1, lifecycleState: "detached" },
|
||||
{ id: "rig-2", name: "foo", nodeCount: 1, lifecycleState: "detached" },
|
||||
];
|
||||
routes["/api/rigs/:id"] = () => ({ sessions: [] });
|
||||
routes["/api/rigs/:id/up"] = (_req, body) => {
|
||||
if (body.plan === true || body._rigId) {
|
||||
if (body.plan === true) {
|
||||
return {
|
||||
status: "plan", mode: "restore",
|
||||
rigId: body._rigId ?? "unknown", rigName: "foo",
|
||||
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
|
||||
mutated: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "restored", rigResult: "fully_restored",
|
||||
nodes: [{ logicalId: "w", status: "resumed" }],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "restored", rigResult: "fully_restored",
|
||||
nodes: [{ logicalId: "w", status: "resumed" }],
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
// Name-based /api/up would return ambiguous_name 409 for same-name rigs;
|
||||
// rig start should never reach this path for restore since it uses id-based route.
|
||||
routes["/api/up"] = () => ({
|
||||
_httpStatus: 409,
|
||||
error: 'Multiple rigs named "foo" found',
|
||||
code: "ambiguous_name",
|
||||
} as never);
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--all"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
// Both should appear — never "No rigs to restore".
|
||||
expect(output).not.toContain("No rigs to restore");
|
||||
expect(output).not.toContain("ambiguous_name");
|
||||
// The id-based route was used for both preview AND apply.
|
||||
const idBodies = upBodies.filter((b) => b._rigIdRoute);
|
||||
expect(idBodies.length).toBeGreaterThanOrEqual(4); // 2 plan + 2 apply
|
||||
expect(idBodies.some((b) => b._rigIdRoute === "rig-1")).toBe(true);
|
||||
expect(idBodies.some((b) => b._rigIdRoute === "rig-2")).toBe(true);
|
||||
// Name-based /api/up was NOT called for restore.
|
||||
const nameBodies = upBodies.filter((b) => !b._rigIdRoute);
|
||||
expect(nameBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---- PREVIEW ERROR STATE (guard BLOCKING bfed4ce3) ----
|
||||
|
||||
it("preview 500 error under --json -> JSON contains previewErrors + exit 1, not clean empty candidates", async () => {
|
||||
routes["/api/rigs/summary"] = () => [
|
||||
{ id: "err-rig-1", name: "broken-rig", nodeCount: 1, lifecycleState: "detached" },
|
||||
];
|
||||
routes["/api/rigs/:id"] = () => ({ sessions: [] });
|
||||
routes["/api/rigs/:id/up"] = () => ({
|
||||
_httpStatus: 500,
|
||||
error: "internal server error",
|
||||
code: "restore_error",
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--all", "--json"]);
|
||||
});
|
||||
const jsonLine = logs.find((l) => l.startsWith("{"));
|
||||
expect(jsonLine).toBeDefined();
|
||||
const parsed = JSON.parse(jsonLine!);
|
||||
expect(parsed.status).toBe("started_with_errors");
|
||||
expect(parsed.previewErrors).toBeDefined();
|
||||
expect(parsed.previewErrors.length).toBe(1);
|
||||
expect(parsed.previewErrors[0].rigName).toBe("broken-rig");
|
||||
expect(parsed.previewErrors[0].code).toBe("restore_error");
|
||||
expect(parsed.candidates).toHaveLength(0);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("mixed case: rig A restores cleanly + rig B preview 500 -> JSON started_with_errors + previewErrors + exit 1", async () => {
|
||||
routes["/api/rigs/summary"] = () => [
|
||||
{ id: "rig-ok", name: "ok-rig", nodeCount: 1, lifecycleState: "detached" },
|
||||
{ id: "rig-err", name: "err-rig", nodeCount: 1, lifecycleState: "detached" },
|
||||
];
|
||||
routes["/api/rigs/:id"] = () => ({ sessions: [] });
|
||||
routes["/api/rigs/:id/up"] = (_req, body) => {
|
||||
if (body._rigId === "rig-ok") {
|
||||
return {
|
||||
status: "plan", mode: "restore", rigId: "rig-ok", rigName: "ok-rig",
|
||||
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
|
||||
mutated: false,
|
||||
};
|
||||
}
|
||||
return { _httpStatus: 500, error: "internal server error", code: "restore_error" };
|
||||
};
|
||||
routes["/api/up"] = () => ({
|
||||
status: "restored", rigResult: "fully_restored",
|
||||
nodes: [{ logicalId: "w", status: "resumed" }],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--all", "--json"]);
|
||||
});
|
||||
const jsonLine = logs.find((l) => l.startsWith("{"));
|
||||
expect(jsonLine).toBeDefined();
|
||||
const parsed = JSON.parse(jsonLine!);
|
||||
expect(parsed.status).toBe("started_with_errors");
|
||||
expect(parsed.previewErrors).toBeDefined();
|
||||
expect(parsed.previewErrors.some((e: { rigName: string }) => e.rigName === "err-rig")).toBe(true);
|
||||
expect(parsed.restoredRigs.some((r: { rigName: string }) => r.rigName === "ok-rig")).toBe(true);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// ---- NON-2XX ERROR HANDLING (guard BLOCKING 25661f72) ----
|
||||
|
||||
it("non-2xx /api/up (rig_not_stopped) -> honest error output, nonzero exit, not undefined success", async () => {
|
||||
routes["/api/rigs/summary"] = () => [{ id: "r1", name: "live-rig", nodeCount: 1, lifecycleState: "detached" }];
|
||||
routes["/api/rigs/:id"] = () => ({ sessions: [] });
|
||||
let planDone = false;
|
||||
routes["/api/up"] = (_req, body) => {
|
||||
if (body.plan === true && !planDone) {
|
||||
planDone = true;
|
||||
return {
|
||||
status: "plan", mode: "restore", rigId: "r1", rigName: "live-rig",
|
||||
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
|
||||
mutated: false,
|
||||
};
|
||||
}
|
||||
// Apply returns 409 rig_not_stopped (simulate live sessions blocking restore).
|
||||
return "__409__" as never;
|
||||
};
|
||||
// Override server to return 409 for the apply call.
|
||||
const origRouteHandler = routes["/api/up"];
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", (req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c: Buffer) => { raw += c; });
|
||||
req.on("end", () => {
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
const url = req.url ?? "";
|
||||
const method = req.method ?? "GET";
|
||||
if (method === "GET" && url === "/healthz") { res.writeHead(200).end(JSON.stringify({ ok: true })); return; }
|
||||
if (method === "GET" && url === "/api/kernel/status") { res.writeHead(200).end(JSON.stringify({ kernel_state: "ready" })); return; }
|
||||
if (method === "GET" && url === "/api/rigs/summary") { res.writeHead(200).end(JSON.stringify(routes["/api/rigs/summary"]!(req, body))); return; }
|
||||
if (method === "GET" && url.match(/^\/api\/rigs\/[^/]+$/)) { res.writeHead(200).end(JSON.stringify({ sessions: [] })); return; }
|
||||
const rigUpMatch2 = method === "POST" && url.match(/^\/api\/rigs\/([^/]+)\/up$/);
|
||||
if (rigUpMatch2) {
|
||||
upBodies.push({ ...body, _rigIdRoute: rigUpMatch2[1] });
|
||||
if (body.plan === true) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
status: "plan", mode: "restore", rigId: rigUpMatch2[1], rigName: "live-rig",
|
||||
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [{ logicalId: "w", intendedAction: "resume-original" }],
|
||||
mutated: false,
|
||||
}));
|
||||
} else {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Rig r1 has live sessions", code: "rig_not_stopped" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("live-rig");
|
||||
expect(output).toContain("rig_not_stopped");
|
||||
expect(output).not.toContain("undefined");
|
||||
expect(exitCode).toBe(1);
|
||||
|
||||
// Restore the original server handler for subsequent tests.
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", (req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c: Buffer) => { raw += c; });
|
||||
req.on("end", () => {
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
const url = req.url ?? "";
|
||||
const method = req.method ?? "GET";
|
||||
if (method === "GET" && url === "/healthz") { res.writeHead(200).end(JSON.stringify({ ok: true })); return; }
|
||||
if (method === "GET" && url === "/api/kernel/status") { const h = routes["/api/kernel/status"]; res.writeHead(200).end(JSON.stringify(h ? h(req, body) : { kernel_state: "ready" })); return; }
|
||||
if (method === "GET" && url === "/api/rigs/summary") { const h = routes["/api/rigs/summary"]; res.writeHead(200).end(JSON.stringify(h ? h(req, body) : [])); return; }
|
||||
if (method === "GET" && url.match(/^\/api\/rigs\/[^/]+$/)) { const h = routes["/api/rigs/:id"]; res.writeHead(200).end(JSON.stringify(h ? h(req, body) : { sessions: [] })); return; }
|
||||
if (method === "POST" && url === "/api/up") { upBodies.push(body); const h = routes["/api/up"]; const d = h ? h(req, body) : {}; res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(d)); return; }
|
||||
const rigUpRestore = method === "POST" && url.match(/^\/api\/rigs\/([^/]+)\/up$/);
|
||||
if (rigUpRestore) { upBodies.push({ ...body, _rigIdRoute: rigUpRestore[1] }); const h = routes["/api/rigs/:id/up"] ?? routes["/api/up"]; const d = h ? h(req, { ...body, _rigId: rigUpRestore[1] }) : {}; res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(d)); return; }
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("partially_restored rigResult -> nonzero exit (not treated as clean)", async () => {
|
||||
setupCandidateRig("partial-rig");
|
||||
// Override both plan and apply to return partially_restored on apply.
|
||||
routes["/api/rigs/:id/up"] = (_req, body) => {
|
||||
if (body.plan === true) {
|
||||
return {
|
||||
status: "plan", mode: "restore", rigId: "rig-1", rigName: "partial-rig",
|
||||
snapshot: { id: "s1", kind: "auto-pre-down", createdAt: "2026-06-14T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [{ logicalId: "dev.impl", intendedAction: "resume-original" }],
|
||||
mutated: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "restored", rigResult: "partially_restored",
|
||||
nodes: [{ logicalId: "dev.impl", status: "fresh-primed" }],
|
||||
warnings: [],
|
||||
};
|
||||
};
|
||||
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// ---- JSON OUTPUT ----
|
||||
|
||||
it("--json outputs structured result", async () => {
|
||||
setupCandidateRig("json-rig");
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "start", "--last", "--json"]);
|
||||
});
|
||||
const jsonLine = logs.find((l) => l.startsWith("{"));
|
||||
expect(jsonLine).toBeDefined();
|
||||
const parsed = JSON.parse(jsonLine!);
|
||||
expect(parsed.status).toBe("started");
|
||||
expect(parsed.restoredRigs).toBeDefined();
|
||||
expect(parsed.restoredRigs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed.restoredRigs[0].rigName).toBe("json-rig");
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,11 @@ function captureLogs(fn: () => Promise<void>): Promise<string[]> {
|
||||
}
|
||||
|
||||
// Echo server for daemon API
|
||||
function createDaemonServer(summaryData: unknown[], cmuxStatus: unknown) {
|
||||
function createDaemonServer(
|
||||
summaryData: unknown[],
|
||||
cmuxStatus: unknown,
|
||||
kernelStatus?: { code: number; body: unknown },
|
||||
) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
|
||||
@@ -57,6 +61,12 @@ function createDaemonServer(summaryData: unknown[], cmuxStatus: unknown) {
|
||||
res.end(JSON.stringify(cmuxStatus));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/kernel/status") {
|
||||
const k = kernelStatus ?? { code: 200, body: { kernel_state: "ready" } };
|
||||
res.writeHead(k.code, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(k.body));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/healthz") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
@@ -130,6 +140,64 @@ describe("rig status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-2): state clarity - daemon port, kernel readiness
|
||||
// (distinct from daemon health), effective workspace root (default vs override).
|
||||
describe("AC-2 state clarity (OPR.0.3.3.04.2)", () => {
|
||||
let srv: ReturnType<typeof createDaemonServer>;
|
||||
let port: number;
|
||||
beforeAll(async () => {
|
||||
srv = createDaemonServer([], { available: false }, { code: 200, body: { kernel_state: "ready" } });
|
||||
port = await srv.listen();
|
||||
});
|
||||
afterAll(async () => { await srv.close(); });
|
||||
|
||||
function statusDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(runningState(port)) : null)),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
it("surfaces daemon port + kernel readiness (distinct from daemon health) + effective workspace root with its source", async () => {
|
||||
const prev = process.env.OPENRIG_WORKSPACE_ROOT;
|
||||
delete process.env.OPENRIG_WORKSPACE_ROOT; // not env-overridden in this case
|
||||
try {
|
||||
const program = new Command();
|
||||
program.addCommand(statusCommand(statusDeps()));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "status"]));
|
||||
const output = logs.join("\n");
|
||||
expect(output).toMatch(/Daemon running on port \d+/);
|
||||
expect(output).toContain("Kernel: ready");
|
||||
expect(output).toContain("distinct from daemon health");
|
||||
// the effective root is surfaced WITH whether it's default vs an override
|
||||
// (host-independent: a host may legitimately carry a file-level override).
|
||||
expect(output).toMatch(/Workspace root: .+ \((default|override via (env|file))\)/);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT;
|
||||
else process.env.OPENRIG_WORKSPACE_ROOT = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it("marks the workspace root as an override when OPENRIG_WORKSPACE_ROOT is set", async () => {
|
||||
const prev = process.env.OPENRIG_WORKSPACE_ROOT;
|
||||
process.env.OPENRIG_WORKSPACE_ROOT = process.cwd(); // a real existing dir
|
||||
try {
|
||||
const program = new Command();
|
||||
program.addCommand(statusCommand(statusDeps()));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "status"]));
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain(`Workspace root: ${process.cwd()} (override via env)`);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT;
|
||||
else process.env.OPENRIG_WORKSPACE_ROOT = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Test 2: Running daemon with no rigs -> 'No rigs'
|
||||
describe("with running daemon and no rigs", () => {
|
||||
let srv: ReturnType<typeof createDaemonServer>;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// OPR.0.3.4.2 — rig up --existing: resume-original default surface.
|
||||
// --fresh opt-in forwarding, the awaiting-decision ASK/offer (TTY [y/N] via
|
||||
// injected prompt; headless honest machine-status + --fresh hint), and the
|
||||
// five-term distinct-naming render. NEVER an auto-substitution.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { upCommand } from "../src/commands/up.js";
|
||||
import { DaemonClient, DaemonConnectionError } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try { await fn(); } finally { console.log = origLog; console.error = origErr; }
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let upBodies: Array<Record<string, unknown>> = [];
|
||||
/** Per-test response factory keyed on the freshLogicalIds in the request. */
|
||||
let respond: (body: Record<string, unknown>) => Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", (c: Buffer) => { raw += c; });
|
||||
req.on("end", () => {
|
||||
if (req.method === "POST" && req.url === "/api/up") {
|
||||
const body = JSON.parse(raw || "{}");
|
||||
upBodies.push(body);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(respond(body)));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => { server.listen(0, r); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
beforeEach(() => {
|
||||
upBodies = [];
|
||||
respond = () => ({ status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "fully_restored", nodes: [], warnings: [] });
|
||||
});
|
||||
|
||||
function deps(promptYesNo?: (q: string) => Promise<boolean>) {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => p === STATE_FILE
|
||||
? JSON.stringify({ pid: 123, port, db: "t.sqlite", startedAt: "2026-06-11T00:00:00Z" } as DaemonState)
|
||||
: null),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
promptYesNo,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCmd(promptYesNo?: (q: string) => Promise<boolean>): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(upCommand(deps(promptYesNo)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("--fresh seats forward as freshLogicalIds in the /api/up body (operation B)", async () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--fresh", "dev.impl", "dev.qa"]);
|
||||
});
|
||||
expect(upBodies).toHaveLength(1);
|
||||
expect(upBodies[0]!.freshLogicalIds).toEqual(["dev.impl", "dev.qa"]);
|
||||
});
|
||||
|
||||
it("headless awaiting-decision: honest machine status + --fresh hint, exit 1, NO auto-substitution", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [{ logicalId: "dev.impl", status: "awaiting-decision", error: "Original session unresumable: resume attempted but failed." }],
|
||||
warnings: [],
|
||||
});
|
||||
// No promptYesNo injected and no TTY in vitest -> the headless path.
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("dev.impl: awaiting-decision");
|
||||
expect(output).toContain("--fresh dev.impl");
|
||||
expect(exitCode).toBe(1);
|
||||
// CRITICAL: exactly one POST — nothing auto-substituted a fresh session.
|
||||
expect(upBodies).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("TTY ASK accepted: [y/N] offer names seat + reason, then re-runs as a deliberate fresh-prime", async () => {
|
||||
respond = (body) => Array.isArray(body.freshLogicalIds) && (body.freshLogicalIds as string[]).length > 0
|
||||
? {
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [{ logicalId: "dev.impl", status: "fresh-primed" }], warnings: [],
|
||||
}
|
||||
: {
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [{ logicalId: "dev.impl", status: "awaiting-decision", error: "no token available" }], warnings: [],
|
||||
};
|
||||
const prompt = vi.fn(async () => true);
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd(prompt).parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
|
||||
expect(prompt).toHaveBeenCalledTimes(1);
|
||||
const question = prompt.mock.calls[0]![0] as string;
|
||||
expect(question).toContain("dev.impl");
|
||||
expect(question).toContain("no token available");
|
||||
expect(question).toContain("[y/N]");
|
||||
// The accepted seat was re-run as operation B.
|
||||
expect(upBodies).toHaveLength(2);
|
||||
expect(upBodies[1]!.freshLogicalIds).toEqual(["dev.impl"]);
|
||||
expect(logs.join("\n")).toContain("dev.impl: fresh-primed");
|
||||
});
|
||||
|
||||
it("TTY ASK declined: no second POST, no session started", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [{ logicalId: "dev.impl", status: "awaiting-decision", error: "no token available" }],
|
||||
warnings: [],
|
||||
});
|
||||
const prompt = vi.fn(async () => false);
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd(prompt).parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
|
||||
expect(upBodies).toHaveLength(1);
|
||||
expect(logs.join("\n")).toContain("No fresh sessions started");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// OPR.0.3.4.4 — plan-restore preview render + honest-async timeout.
|
||||
it("--plan restore preview renders per-seat intended actions and 'No changes made.'", async () => {
|
||||
respond = () => ({
|
||||
status: "plan",
|
||||
mode: "restore",
|
||||
rigId: "rig-1",
|
||||
rigName: "myrig",
|
||||
snapshot: { id: "snap-9", kind: "auto-pre-down", createdAt: "2026-06-11T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: [
|
||||
{ logicalId: "dev.impl", intendedAction: "resume-original" },
|
||||
{ logicalId: "dev.qa", intendedAction: "awaiting-decision", reason: "resume source 'claude_name' recorded but no token available — apply would stop and ask (zero session)" },
|
||||
],
|
||||
mutated: false,
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain('Plan: restore rig "myrig"');
|
||||
expect(output).toContain("snap-9");
|
||||
expect(output).toContain("dev.impl: resume-original");
|
||||
expect(output).toContain("dev.qa: awaiting-decision");
|
||||
expect(output).toContain("no token available");
|
||||
expect(output).toContain("No changes made.");
|
||||
expect(exitCode).toBeUndefined();
|
||||
// The plan flag reached the daemon body.
|
||||
expect(upBodies).toHaveLength(1);
|
||||
expect(upBodies[0]!.plan).toBe(true);
|
||||
});
|
||||
|
||||
it("--plan --fresh posts BOTH plan:true and freshLogicalIds, and renders the fresh-primed preview", async () => {
|
||||
respond = (body) => ({
|
||||
status: "plan",
|
||||
mode: "restore",
|
||||
rigId: "rig-1",
|
||||
rigName: "myrig",
|
||||
snapshot: { id: "snap-9", kind: "auto-pre-down", createdAt: "2026-06-11T00:00:00Z" },
|
||||
wouldCaptureCurrentState: false,
|
||||
nodes: Array.isArray(body.freshLogicalIds) && (body.freshLogicalIds as string[]).includes("dev.impl")
|
||||
? [{ logicalId: "dev.impl", intendedAction: "fresh-primed", reason: "listed in --fresh — apply would deliberately skip the resume (operation B)" }]
|
||||
: [{ logicalId: "dev.impl", intendedAction: "resume-original" }],
|
||||
mutated: false,
|
||||
});
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan", "--fresh", "dev.impl"]);
|
||||
});
|
||||
|
||||
expect(upBodies).toHaveLength(1);
|
||||
expect(upBodies[0]!.plan).toBe(true);
|
||||
expect(upBodies[0]!.freshLogicalIds).toEqual(["dev.impl"]);
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("dev.impl: fresh-primed");
|
||||
expect(output).toContain("No changes made.");
|
||||
});
|
||||
|
||||
it("HONEST TIMEOUT (apply mode): DaemonConnectionError renders in-progress/unknown + verify command, never a bare failure", async () => {
|
||||
const throwingClient = {
|
||||
get: async () => { throw new DaemonConnectionError("request timed out after 120000ms"); },
|
||||
post: async () => { throw new DaemonConnectionError("request timed out after 120000ms"); },
|
||||
} as unknown as DaemonClient;
|
||||
const customDeps = { ...deps(), clientFactory: () => throwingClient };
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(upCommand(customDeps));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
// Honest in-progress/unknown framing, not a false "failed".
|
||||
expect(output).toContain("may still be processing");
|
||||
expect(output).toContain("does not mean the operation failed");
|
||||
expect(output).toContain("rig ps");
|
||||
expect(output).not.toContain("Up failed");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// OPR.0.3.4.6 — cross-surface regression guard: CLI exit-code + JSON.
|
||||
it("OPR.0.3.4.6 guard: attention_required/awaiting-decision -> partially_restored -> exit 1, never exit 0", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ logicalId: "a", status: "resumed" },
|
||||
{ logicalId: "b", status: "attention_required" },
|
||||
{ logicalId: "c", status: "awaiting-decision", error: "no token" },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("OPR.0.3.4.6 guard: JSON passes raw per-node status for all five terms (never collapsed)", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ logicalId: "a", status: "resumed" },
|
||||
{ logicalId: "b", status: "fresh-primed" },
|
||||
{ logicalId: "c", status: "awaiting-decision", error: "no token" },
|
||||
{ logicalId: "d", status: "attention_required" },
|
||||
{ logicalId: "e", status: "failed" },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--json"]);
|
||||
});
|
||||
const jsonLine = logs.find((l) => l.startsWith("{"));
|
||||
expect(jsonLine).toBeDefined();
|
||||
const parsed = JSON.parse(jsonLine!);
|
||||
const statuses = (parsed.nodes as Array<{ status: string }>).map((n) => n.status);
|
||||
expect(statuses).toContain("resumed");
|
||||
expect(statuses).toContain("fresh-primed");
|
||||
expect(statuses).toContain("awaiting-decision");
|
||||
expect(statuses).toContain("attention_required");
|
||||
expect(statuses).toContain("failed");
|
||||
});
|
||||
|
||||
it("distinct naming: all five vocabulary terms render distinctly per seat", async () => {
|
||||
respond = () => ({
|
||||
status: "restored", rigId: "rig-1", rigName: "myrig", rigResult: "partially_restored",
|
||||
nodes: [
|
||||
{ logicalId: "a", status: "resumed" },
|
||||
{ logicalId: "b", status: "fresh-primed" },
|
||||
{ logicalId: "c", status: "awaiting-decision", error: "no token available" },
|
||||
{ logicalId: "d", status: "attention_required" },
|
||||
{ logicalId: "e", status: "failed" },
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("a: resumed");
|
||||
expect(output).toContain("b: fresh-primed");
|
||||
expect(output).toContain("c: awaiting-decision");
|
||||
expect(output).toContain("d: attention_required");
|
||||
expect(output).toContain("e: failed");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -137,7 +137,13 @@ describe("Up CLI", () => {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.2.22 Bug 3 — path-form sourceRef triggers a /api/info
|
||||
// call for install-root awareness; respond with a 404 so the CLI
|
||||
// falls through (this test does not exercise the install-internal
|
||||
// path-form gate).
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
await captureLogs(async () => {
|
||||
@@ -220,7 +226,10 @@ describe("Up CLI", () => {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.2.22 Bug 3 /api/info fallback (404 → CLI falls through).
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
await captureLogs(async () => {
|
||||
@@ -364,6 +373,104 @@ describe("Up CLI", () => {
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 — path-form `rig up <spec-inside-install>` without
|
||||
// --cwd should default cwdOverride to process.cwd() so the spec's
|
||||
// member-level cwd: "." does not resolve into the OpenRig install root
|
||||
// and trip getOpenRigInstallCwdError at preflight. The bare-name form
|
||||
// is already rescued at the resolveLibrarySpec branch (covered by the
|
||||
// earlier library-name test); this pins the path-form gap.
|
||||
it("up path-form spec inside install root defaults cwdOverride to caller cwd and prints notice", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||||
const specPath = path.join(installRoot, "rigs", "demo", "rig.yaml");
|
||||
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
||||
fs.writeFileSync(specPath, "version: '0.2'\nname: demo\npods: []\nedges: []\n");
|
||||
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
let infoCalls = 0;
|
||||
const origListeners = server.listeners("request");
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/info" && req.method === "GET") {
|
||||
infoCalls += 1;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ installRoot }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||||
});
|
||||
expect(infoCalls, "expected /api/info to be queried for install-root awareness").toBe(1);
|
||||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(true);
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("up path-form spec OUTSIDE install root leaves cwdOverride undefined", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-project-"));
|
||||
const specPath = path.join(projectRoot, "rig.yaml");
|
||||
fs.writeFileSync(specPath, "version: '0.2'\nname: outside-rig\npods: []\nedges: []\n");
|
||||
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
const origListeners = server.listeners("request");
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/info" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ installRoot }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||||
});
|
||||
expect(lastBody.cwdOverride).toBeUndefined();
|
||||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(false);
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("up apply uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
|
||||
@@ -989,6 +1096,131 @@ describe("Up CLI", () => {
|
||||
expect(() => JSON.parse(joined)).not.toThrow();
|
||||
});
|
||||
|
||||
// OPR.0.3.3.19 AC-7: a name matching ONLY an archived rig is refused with an
|
||||
// honest error pointing at `rig unarchive` - never a silent restore.
|
||||
it("up <archived-name> refuses with an honest unarchive error and never posts /api/up", async () => {
|
||||
const origListeners = server.listeners("request");
|
||||
let upHit = false;
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||||
// No ACTIVE rig of this name (default summary excludes archived).
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([]));
|
||||
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
|
||||
// The name matches an archived rig.
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ id: "r-arc", name: "tidy-me", nodeCount: 2 }]));
|
||||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||||
upHit = true;
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", rigId: "r-arc", stages: [], errors: [], warnings: [] }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "tidy-me"]);
|
||||
});
|
||||
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain('Rig "tidy-me" is archived');
|
||||
// Remediation must name the rig ID (rig unarchive resolves by id, not name).
|
||||
expect(output).toContain("rig unarchive r-arc");
|
||||
expect(output).not.toContain("rig unarchive tidy-me");
|
||||
expect(upHit).toBe(false); // no silent restore
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// OPR.0.3.3.19 AC-7 (json): same refusal, machine-readable.
|
||||
it("up <archived-name> --json emits a rig_archived error object and exits 1", async () => {
|
||||
const origListeners = server.listeners("request");
|
||||
let upHit = false;
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([]));
|
||||
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ id: "r-arc", name: "tidy-me", nodeCount: 2 }]));
|
||||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||||
upHit = true;
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed" }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "tidy-me", "--json"]);
|
||||
});
|
||||
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(parsed.error).toBe("rig_archived");
|
||||
// Action targets the rig ID (rig unarchive resolves by id, not name).
|
||||
expect(parsed.action).toBe("rig unarchive r-arc");
|
||||
expect(parsed.archivedRigIds).toEqual(["r-arc"]);
|
||||
expect(upHit).toBe(false);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// OPR.0.3.3.19 AC-7 (ambiguous): a name shared by multiple archived rigs must
|
||||
// surface the id list, not guess a single (wrong) target.
|
||||
it("up <archived-name> with multiple archived id matches lists each id, never posts /api/up", async () => {
|
||||
const origListeners = server.listeners("request");
|
||||
let upHit = false;
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([]));
|
||||
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([
|
||||
{ id: "r-arc-1", name: "dupe", nodeCount: 1 },
|
||||
{ id: "r-arc-2", name: "dupe", nodeCount: 1 },
|
||||
]));
|
||||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||||
upHit = true;
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed" }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "dupe"]);
|
||||
});
|
||||
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("rig unarchive r-arc-1");
|
||||
expect(output).toContain("rig unarchive r-arc-2");
|
||||
expect(upHit).toBe(false);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// L3b: manual-fallback note printed when restore came from a non-auto-pre-down snapshot.
|
||||
it("L3b: prints manual-fallback note when daemon restored from a manual snapshot", async () => {
|
||||
const origListeners = server.listeners("request");
|
||||
|
||||
@@ -44,6 +44,7 @@ const WHOAMI_RESPONSE = {
|
||||
resolvedSpecName: "impl", resolvedSpecVersion: "1.0",
|
||||
},
|
||||
peers: [{ logicalId: "dev.qa", sessionName: "dev-qa@my-rig", runtime: "codex", podId: "pod-dev-123", podNamespace: "dev", memberId: "qa" }],
|
||||
peersNote: "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
|
||||
edges: {
|
||||
outgoing: [{ kind: "delegates_to", to: { logicalId: "dev.qa", sessionName: "dev-qa@my-rig" } }],
|
||||
incoming: [],
|
||||
@@ -289,6 +290,41 @@ describe("Whoami CLI", () => {
|
||||
expect(output).toContain("Transcript:");
|
||||
});
|
||||
|
||||
// OPR.99.0.6.1 — the clarified Peers header (the roster contract, in-band).
|
||||
it("human Peers header names the roster contract and points at edges + rig ps --nodes", async () => {
|
||||
process.env["OPENRIG_NODE_ID"] = "node-1";
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "whoami"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
// Legacy grep target preserved verbatim.
|
||||
expect(output).toContain("Peers:");
|
||||
// The discriminator: a fresh reader cannot misread peers[] as the
|
||||
// edge-subset (pointed to edges below) or as host inventory (pointed to
|
||||
// rig ps --nodes for inventory incl. self).
|
||||
expect(output).toContain("roster, excluding self");
|
||||
expect(output).toContain("directional edges below");
|
||||
expect(output).toContain("rig ps --nodes");
|
||||
// Peers + edges still render normally under the clarified header.
|
||||
expect(output).toContain("dev.qa");
|
||||
expect(output).toContain("delegates_to");
|
||||
});
|
||||
|
||||
it("--json carries the additive peersNote; peers[] name/shape unchanged; no roster field", async () => {
|
||||
process.env["OPENRIG_NODE_ID"] = "node-1";
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "whoami", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.peersNote).toContain("roster excluding self");
|
||||
expect(Array.isArray(parsed.peers)).toBe(true);
|
||||
expect(Object.keys(parsed.peers[0]).sort()).toEqual(
|
||||
["logicalId", "memberId", "podId", "podNamespace", "runtime", "sessionName"],
|
||||
);
|
||||
expect(parsed.roster).toBeUndefined();
|
||||
expect(parsed.podRoster).toBeUndefined();
|
||||
});
|
||||
|
||||
it("human output shows missing session honestly and omits transcript section for unbound nodes", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "whoami", "--node-id", "node-2"]);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// release-0.3.2 slice 01 BC repair — HG-6 discriminators for the
|
||||
// workflow project --exit enum. Without runtime validation, the
|
||||
// TypeScript-only enum allowed `--exit banana` to flow through to
|
||||
// the daemon transactional-scribe surface.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
workflowCommand,
|
||||
isProjectExitKind,
|
||||
PROJECT_EXIT_KINDS,
|
||||
type WorkflowDeps,
|
||||
} from "../src/commands/workflow.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
function captureStderr(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
return { logs, restore: () => { process.stderr.write = original; } };
|
||||
}
|
||||
|
||||
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkflowDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isProjectExitKind — pure helper", () => {
|
||||
it("accepts every documented exit kind", () => {
|
||||
for (const kind of PROJECT_EXIT_KINDS) {
|
||||
expect(isProjectExitKind(kind)).toBe(true);
|
||||
}
|
||||
});
|
||||
it("rejects garbage strings (BC-2 root cause)", () => {
|
||||
expect(isProjectExitKind("banana")).toBe(false);
|
||||
expect(isProjectExitKind("")).toBe(false);
|
||||
expect(isProjectExitKind("HANDOFF")).toBe(false); // case-sensitive
|
||||
});
|
||||
it("rejects non-strings", () => {
|
||||
expect(isProjectExitKind(undefined)).toBe(false);
|
||||
expect(isProjectExitKind(null)).toBe(false);
|
||||
expect(isProjectExitKind(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workflow project --exit — CLI-side discriminator (BLOCK 2)", () => {
|
||||
it("rejects '--exit banana' with --json: exit=1, body.error 3-part, client.post NOT called", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", "banana",
|
||||
"--actor-session", "x@y",
|
||||
"--json",
|
||||
]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = out.logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/--exit must be one of handoff \| waiting \| done \| failed.*banana/);
|
||||
expect(parsed.error.consequence).toMatch(/did not run/);
|
||||
expect(parsed.error.action).toMatch(/--exit handoff/);
|
||||
});
|
||||
|
||||
it("emits 3-part error to stderr in human mode", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const err = captureStderr();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", "banana",
|
||||
"--actor-session", "x@y",
|
||||
]);
|
||||
err.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = err.logs.join("");
|
||||
expect(joined).toMatch(/Error:.*--exit must be one of/);
|
||||
expect(joined).toMatch(/banana/);
|
||||
expect(joined).toMatch(/did not run/);
|
||||
});
|
||||
|
||||
it("rejects each garbage variant ('', 'HANDOFF', 'shipped') without calling the daemon", async () => {
|
||||
for (const bad of ["", "HANDOFF", "shipped"]) {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", bad,
|
||||
"--actor-session", "x@y",
|
||||
"--json",
|
||||
]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode, `--exit "${bad}" should fail`).toBe(1);
|
||||
expect(post, `--exit "${bad}" must not call the daemon`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
// Positive-path validation (e.g. --exit handoff forwards correctly)
|
||||
// is covered by isProjectExitKind pure-helper above; the live action
|
||||
// path is gated by getDaemonStatus and tested at the integration
|
||||
// tier, mirroring the slice-01 workflow-ux test pattern.
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// release-0.3.2 slice 01 (OPR.0.3.2.1) — GA-polish coverage for the
|
||||
// rig workflow command surface.
|
||||
//
|
||||
// Scope: static --help + description coverage; the what/state/next
|
||||
// summary behavior is verified at the building-block level via
|
||||
// printOutcomeSummary unit tests (exported for this purpose), since
|
||||
// the live action path is gated by getDaemonStatus and tested at the
|
||||
// integration tier elsewhere.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import {
|
||||
workflowCommand,
|
||||
printOutcomeSummary,
|
||||
type OutcomeSummary,
|
||||
} from "../src/commands/workflow.js";
|
||||
|
||||
describe("rig workflow --help carries examples on every command (HG-4)", () => {
|
||||
const expectedVerbs = [
|
||||
"validate",
|
||||
"instantiate",
|
||||
"project",
|
||||
"list",
|
||||
"specs",
|
||||
"show",
|
||||
"trace",
|
||||
"continue",
|
||||
];
|
||||
|
||||
// Commander v13 attaches `addHelpText('after', ...)` as a listener
|
||||
// that fires when --help is rendered; `helpInformation()` returns
|
||||
// Usage+Options+Subcommands but NOT the after-text. Source-scan the
|
||||
// command file to prove every verb carries an Examples block —
|
||||
// catches drift if a future refactor drops `addHelpText` from one
|
||||
// verb but leaves the others.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workflowSrc = readFileSync(
|
||||
path.resolve(here, "../src/commands/workflow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
for (const verb of expectedVerbs) {
|
||||
it(`${verb} has an addHelpText('after', ...) block with an Examples section`, () => {
|
||||
const root = workflowCommand();
|
||||
const sub = root.commands.find((c) => c.name() === verb);
|
||||
expect(sub, `verb ${verb} should be registered`).toBeTruthy();
|
||||
// Source-scan: locate the command(...) declaration and look
|
||||
// for an Examples block in the addHelpText that follows.
|
||||
const verbBlockRe = new RegExp(
|
||||
`\\.command\\("${verb}[ "(<]([\\s\\S]*?)\\.action\\(`,
|
||||
"m",
|
||||
);
|
||||
const match = verbBlockRe.exec(workflowSrc);
|
||||
expect(match, `couldn't locate builder for "${verb}"`).toBeTruthy();
|
||||
const block = match![0];
|
||||
expect(block).toMatch(/\.addHelpText\("after",/);
|
||||
expect(block).toMatch(/Examples?:/i);
|
||||
expect(block).toMatch(/\$ rig workflow /); // at least one concrete example invocation
|
||||
});
|
||||
}
|
||||
|
||||
it("every verb has a non-empty description", () => {
|
||||
const root = workflowCommand();
|
||||
for (const sub of root.commands) {
|
||||
expect(
|
||||
sub.description().length,
|
||||
`verb ${sub.name()} needs a description`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("the top-level command itself is documented", () => {
|
||||
const root = workflowCommand();
|
||||
expect(root.description().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("printOutcomeSummary — what/state/next ending (HG-4)", () => {
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
const baseSummary: OutcomeSummary = {
|
||||
what: "Closed QITEM-123 (handoff) and projected QITEM-456 to velocity-qa@openrig-velocity",
|
||||
state: "instance WF01ABC = active",
|
||||
next: "Inspect: rig queue show QITEM-456",
|
||||
};
|
||||
|
||||
it("emits 4 lines (blank + what + state + next) in human mode on success", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(false, 200, baseSummary);
|
||||
out.restore();
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toMatch(/what:\s+Closed QITEM-123/);
|
||||
expect(joined).toMatch(/state:\s+instance WF01ABC = active/);
|
||||
expect(joined).toMatch(/next:\s+Inspect: rig queue show QITEM-456/);
|
||||
// Blank separator line precedes the summary.
|
||||
expect(out.logs[0]).toBe("");
|
||||
});
|
||||
|
||||
it("suppresses output in --json mode (machine consumers stay clean)", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(true, 200, baseSummary);
|
||||
out.restore();
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses output when status >= 400 (error path uses its own surface)", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(false, 500, baseSummary);
|
||||
out.restore();
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses output when summary is null", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(false, 200, null);
|
||||
out.restore();
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
// Slice-21 FR-5 — `rig workspace doctor` CLI subcommand tests.
|
||||
//
|
||||
// Covers:
|
||||
// - default behavior posts to /api/workspace/doctor with empty body
|
||||
// - --workspace passes body.workspaceRoot
|
||||
// - --json passes through the structured report
|
||||
// - human output groups by category (workspace / missions / daemon)
|
||||
// - exit-code semantics for fail / warn-with-strict / warn-without-strict / all-ok
|
||||
//
|
||||
// Mirrors the queue.test.ts pattern: vi.mock daemon-lifecycle to skip
|
||||
// the live status probe; clientFactory delivers stub HTTP responses.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
workspaceCommand,
|
||||
renderHumanDoctorReport,
|
||||
type WorkspaceDeps,
|
||||
} from "../src/commands/workspace.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>(
|
||||
"../src/daemon-lifecycle.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({
|
||||
state: "running",
|
||||
healthy: true,
|
||||
pid: 1234,
|
||||
port: 7433,
|
||||
})),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface DoctorReportFixture {
|
||||
workspaceRoot: string;
|
||||
checks: Array<{
|
||||
check: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
}>;
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
function healthyReport(workspaceRoot = "/ws"): DoctorReportFixture {
|
||||
return {
|
||||
workspaceRoot,
|
||||
checks: [
|
||||
{ check: "workspace_root_reachable", status: "ok", message: `workspace root '${workspaceRoot}' is a reachable directory` },
|
||||
{ check: "missions_folder_present", status: "ok", message: `missions folder '${workspaceRoot}/missions' is present` },
|
||||
{ check: "file_allowlist_sane", status: "ok", message: "files.allowlist has 1 usable entry covering workspace root" },
|
||||
{ check: "daemon_points_at_this_workspace", status: "ok", message: "daemon and caller agree on workspace root" },
|
||||
{ check: "daemon_reload_needed", status: "ok", message: "config file mtime is older than daemon start" },
|
||||
{ check: "optional_slice_docs", status: "ok", message: "every slice has a README" },
|
||||
{ check: "mission_notes_presence", status: "ok", message: "every mission has MISSION_NOTES.md" },
|
||||
],
|
||||
summary: { ok: 7, warn: 0, fail: 0 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(response: { status: number; data: unknown }): {
|
||||
deps: WorkspaceDeps;
|
||||
post: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const post = vi.fn(async (_path: string, _body: unknown) => response);
|
||||
return {
|
||||
post,
|
||||
deps: {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
getText: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
let originalExitCode: number | string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — request shape", () => {
|
||||
it("POSTs to /api/workspace/doctor with empty body when no --workspace", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", {});
|
||||
});
|
||||
|
||||
// Discriminator-flip: when --workspace is passed, body.workspaceRoot
|
||||
// must carry the resolved absolute path. Without path.resolve(), a
|
||||
// relative input like "./ws" would land as "./ws" on the wire and
|
||||
// the daemon check #4 would compare against an un-normalized value.
|
||||
it("POSTs body.workspaceRoot (absolute) when --workspace is supplied", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport("/ws-override") });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync([
|
||||
"node", "rig", "doctor", "--workspace", "/ws-override",
|
||||
]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", { workspaceRoot: "/ws-override" });
|
||||
});
|
||||
|
||||
it("resolves relative --workspace to absolute path before posting", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync([
|
||||
"node", "rig", "doctor", "--workspace", "./local-ws",
|
||||
]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { workspaceRoot: string };
|
||||
expect(sent.workspaceRoot.startsWith("/")).toBe(true);
|
||||
expect(sent.workspaceRoot.endsWith("/local-ws")).toBe(true);
|
||||
});
|
||||
|
||||
// GUARD/QA BLOCKING-A2 discriminator: CLI-side
|
||||
// OPENRIG_FILES_ALLOWLIST must propagate to the daemon as
|
||||
// body.filesAllowlistOverride. Without this, an operator setting
|
||||
// the env in their CLI shell sees no doctor behavior change because
|
||||
// the daemon's env-resolution is decoupled from the CLI process.
|
||||
it("forwards OPENRIG_FILES_ALLOWLIST from process.env as body.filesAllowlistOverride", async () => {
|
||||
const original = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
process.env.OPENRIG_FILES_ALLOWLIST = "workspace:.";
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
else process.env.OPENRIG_FILES_ALLOWLIST = original;
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { workspaceRoot?: string; filesAllowlistOverride?: string };
|
||||
expect(sent.filesAllowlistOverride).toBe("workspace:.");
|
||||
});
|
||||
|
||||
// Discriminator-flip: an unset OPENRIG_FILES_ALLOWLIST must NOT
|
||||
// attach an override (the daemon should use SettingsStore as the
|
||||
// source of truth, not silently apply an empty-string).
|
||||
it("omits filesAllowlistOverride from body when OPENRIG_FILES_ALLOWLIST is unset", async () => {
|
||||
const original = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
else process.env.OPENRIG_FILES_ALLOWLIST = original;
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { filesAllowlistOverride?: string };
|
||||
expect(sent.filesAllowlistOverride).toBeUndefined();
|
||||
});
|
||||
|
||||
// Discriminator-flip: empty-string env value is treated as unset
|
||||
// (no override sent). Without the length > 0 check, an empty value
|
||||
// would land as filesAllowlistOverride="" and the daemon route
|
||||
// would treat that as an explicit override.
|
||||
it("treats empty-string OPENRIG_FILES_ALLOWLIST as unset (no override sent)", async () => {
|
||||
const original = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
process.env.OPENRIG_FILES_ALLOWLIST = "";
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
else process.env.OPENRIG_FILES_ALLOWLIST = original;
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { filesAllowlistOverride?: string };
|
||||
expect(sent.filesAllowlistOverride).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — output formatters", () => {
|
||||
it("--json prints the report as a single-line JSON document", async () => {
|
||||
const report = healthyReport();
|
||||
const { deps } = makeDeps({ status: 200, data: report });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--json"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(out.logs).toHaveLength(1);
|
||||
const parsed = JSON.parse(out.logs[0]!) as DoctorReportFixture;
|
||||
expect(parsed.summary).toEqual({ ok: 7, warn: 0, fail: 0 });
|
||||
expect(parsed.checks).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("default human output groups checks under workspace / missions / daemon categories", async () => {
|
||||
const { deps } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("workspace doctor — /ws");
|
||||
expect(joined).toContain("summary: 7 ok, 0 warn, 0 fail");
|
||||
expect(joined).toContain("workspace:");
|
||||
expect(joined).toContain("missions:");
|
||||
expect(joined).toContain("daemon:");
|
||||
// Discriminator: each check name must appear under the correct
|
||||
// group section. We assert the category labels appear BEFORE the
|
||||
// check names that belong to them.
|
||||
const wsIdx = joined.indexOf("workspace:");
|
||||
const wsCheckIdx = joined.indexOf("workspace_root_reachable");
|
||||
const missionsIdx = joined.indexOf("missions:");
|
||||
const missionsCheckIdx = joined.indexOf("missions_folder_present");
|
||||
const daemonIdx = joined.indexOf("daemon:");
|
||||
const daemonCheckIdx = joined.indexOf("daemon_points_at_this_workspace");
|
||||
expect(wsIdx).toBeLessThan(wsCheckIdx);
|
||||
expect(missionsIdx).toBeLessThan(missionsCheckIdx);
|
||||
expect(daemonIdx).toBeLessThan(daemonCheckIdx);
|
||||
});
|
||||
|
||||
// Discriminator-flip: fixHint MUST render below the failing check.
|
||||
// Without `if (c.fixHint) console.log(...)` operators don't see how
|
||||
// to fix the failure.
|
||||
it("human output renders fixHint under failing/warning checks", () => {
|
||||
const report: DoctorReportFixture = {
|
||||
workspaceRoot: "/ws",
|
||||
checks: [
|
||||
{ check: "workspace_root_reachable", status: "fail", message: "workspace root '/ws' does not exist", fixHint: "run `rig config init-workspace`" },
|
||||
{ check: "missions_folder_present", status: "ok", message: "ok" },
|
||||
{ check: "file_allowlist_sane", status: "ok", message: "ok" },
|
||||
{ check: "daemon_points_at_this_workspace", status: "ok", message: "ok" },
|
||||
{ check: "daemon_reload_needed", status: "ok", message: "ok" },
|
||||
{ check: "optional_slice_docs", status: "ok", message: "ok" },
|
||||
{ check: "mission_notes_presence", status: "ok", message: "ok" },
|
||||
],
|
||||
summary: { ok: 6, warn: 0, fail: 1 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
const out = captureStdout();
|
||||
try {
|
||||
renderHumanDoctorReport(report);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("[FAIL] workspace_root_reachable");
|
||||
expect(joined).toContain("Fix: run `rig config init-workspace`");
|
||||
});
|
||||
|
||||
// Discriminator-flip: a check absent from DOCTOR_CHECK_GROUPS falls
|
||||
// into "other:" rather than vanishing. Protects against silent
|
||||
// future-check drop-off.
|
||||
it("human output renders ungrouped checks under 'other:'", () => {
|
||||
const report: DoctorReportFixture = {
|
||||
workspaceRoot: "/ws",
|
||||
checks: [
|
||||
{ check: "future_unknown_check", status: "warn", message: "new check the formatter didn't know about" },
|
||||
],
|
||||
summary: { ok: 0, warn: 1, fail: 0 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
const out = captureStdout();
|
||||
try {
|
||||
renderHumanDoctorReport(report);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("other:");
|
||||
expect(joined).toContain("future_unknown_check");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — exit-code semantics", () => {
|
||||
it("exits 0 when summary is all-ok (default mode)", async () => {
|
||||
const { deps } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// Discriminator-flip: any fail must trip exit code 1 even without
|
||||
// --strict.
|
||||
it("exits 1 when summary has at least one fail (default mode)", async () => {
|
||||
const failing = healthyReport();
|
||||
failing.checks[0]!.status = "fail";
|
||||
failing.summary = { ok: 6, warn: 0, fail: 1 };
|
||||
const { deps } = makeDeps({ status: 200, data: failing });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// Discriminator-flip: warns are NOT failures in default mode.
|
||||
it("exits 0 when summary has warns but no fails (default mode)", async () => {
|
||||
const warning = healthyReport();
|
||||
warning.checks[6]!.status = "warn";
|
||||
warning.summary = { ok: 6, warn: 1, fail: 0 };
|
||||
const { deps } = makeDeps({ status: 200, data: warning });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// Discriminator-flip: --strict turns warns into exit-1.
|
||||
it("exits 1 when summary has warns and --strict is set", async () => {
|
||||
const warning = healthyReport();
|
||||
warning.checks[6]!.status = "warn";
|
||||
warning.summary = { ok: 6, warn: 1, fail: 0 };
|
||||
const { deps } = makeDeps({ status: 200, data: warning });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--strict"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// Discriminator-flip: HTTP 5xx → exit 1.
|
||||
it("exits 1 when daemon returns 5xx", async () => {
|
||||
const { deps } = makeDeps({ status: 503, data: { error: "settings_unavailable" } });
|
||||
const out = captureStdout();
|
||||
const stderrOrig = process.stderr.write;
|
||||
process.stderr.write = ((_chunk: unknown) => true) as typeof process.stderr.write;
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
process.stderr.write = stderrOrig;
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// release-0.3.2 slice 01 BC repair — HG-6 discriminators.
|
||||
// Verifies that `rig workspace validate --max-files <garbage>` rejects
|
||||
// CLI-side with a 3-part error AND does NOT call the daemon. Matches
|
||||
// the guard BC-1 fix recipe.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { workspaceCommand, parseMaxFilesStrict, type WorkspaceDeps } from "../src/commands/workspace.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
function captureStderr(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
return { logs, restore: () => { process.stderr.write = original; } };
|
||||
}
|
||||
|
||||
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkspaceDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseMaxFilesStrict — pure helper", () => {
|
||||
it("accepts positive integers", () => {
|
||||
expect(parseMaxFilesStrict("1")).toBe(1);
|
||||
expect(parseMaxFilesStrict("12")).toBe(12);
|
||||
expect(parseMaxFilesStrict("10000")).toBe(10000);
|
||||
});
|
||||
it("rejects garbage tails (BC-1 root cause)", () => {
|
||||
expect(() => parseMaxFilesStrict("12abc")).toThrow(/positive integer/);
|
||||
});
|
||||
it("rejects non-numeric input", () => {
|
||||
expect(() => parseMaxFilesStrict("abc")).toThrow(/positive integer/);
|
||||
expect(() => parseMaxFilesStrict("")).toThrow(/positive integer/);
|
||||
});
|
||||
it("rejects zero and negatives", () => {
|
||||
expect(() => parseMaxFilesStrict("0")).toThrow(/positive integer/);
|
||||
expect(() => parseMaxFilesStrict("-1")).toThrow(/positive integer/);
|
||||
});
|
||||
it("3-part error carries fact + consequence + action fields", () => {
|
||||
try {
|
||||
parseMaxFilesStrict("12abc");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as Error & { fact?: string; consequence?: string; action?: string };
|
||||
expect(e.fact).toMatch(/positive integer/);
|
||||
expect(e.consequence).toMatch(/did not run/);
|
||||
expect(e.action).toMatch(/Pass a positive integer/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace validate --max-files — CLI-side discriminator (BLOCK 1)", () => {
|
||||
it("rejects '12abc' with --json: exit=1, body.error has fact/consequence/action, client.post NOT called", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workspaceCommand(deps);
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc", "--json"]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = out.logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/positive integer/);
|
||||
expect(parsed.error.consequence).toMatch(/did not run/);
|
||||
expect(parsed.error.action).toMatch(/--max-files/);
|
||||
});
|
||||
|
||||
it("rejects 'abc', '0', '-1' equivalently", async () => {
|
||||
for (const bad of ["abc", "0", "-1"]) {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const root = workspaceCommand(deps);
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", bad, "--json"]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode, `--max-files ${bad} should fail`).toBe(1);
|
||||
expect(post, `--max-files ${bad} must not call the daemon`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits 3-part error to stderr in human mode (no --json)", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const err = captureStderr();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workspaceCommand(deps);
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc"]);
|
||||
err.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = err.logs.join("");
|
||||
expect(joined).toMatch(/Error:.*positive integer/);
|
||||
expect(joined).toMatch(/did not run/);
|
||||
expect(joined).toMatch(/Pass a positive integer/);
|
||||
});
|
||||
|
||||
// Positive case is covered by parseMaxFilesStrict pure-helper test
|
||||
// above ("accepts positive integers"); driving end-to-end here
|
||||
// requires stubbing the daemon-status check too, which is covered
|
||||
// by the daemon-lifecycle adopt/expand test pattern (out of scope
|
||||
// for this BC discriminator).
|
||||
});
|
||||
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user