Release 0.4.4

Multi-host + Living Notes.

Multi-host foundation: shareable whole-topology staged spin-up brings
a rig topology up across multiple hosts in stages; VPS product-factory
multi-host hardening ships with rig host verbs (add / list / doctor,
capped at three; one built-in product-factory-vps posture, three-
valued per item); the transport posture is documented (ssh for pane
ops, http-bearer for daemon REST, no cross-transport fallback); the
For-You feed aggregates activity across every registered host; and
rig file moves files across the topology's registered hosts.

Living Notes: durable INTENT / PLAN / DELIVERED signal layer at
mission and slice altitude with agent authorship + timestamps; cheap
composer surfaces make it the first-class place agents record
decisions, plans, and delivered work; the one-structure review
contract reads left-to-right as a single vertical stack (INTENT above,
PLAN + mockup in the middle, DELIVERED with paired proof at the
bottom); a plan change deletes the old plan and writes a new one -
never demotes or stacks multiple competing plans.

Operationalize the SDLC control plane: conventions SSOT ships in
source at docs/reference/sdlc-conventions.md (Living Notes UI section
names, proof-contract format + plannedRef mockup pairing, staged-
approval locks, C1 proof header + closed sets, three role contracts,
curation rule, elastic-middle doctrine, advisory fail-open audit
posture); rig scope slice create scaffolds the convention sections +
proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every
template kind; rig scope audit + rig workspace doctor gain sdlc-
convention advisories (records-and-advises, gates on HIGH only); the
mission-slice-sop skill ships in canonical source with byte-parity
guard; rig proof / rig scope slice create / rig scope slice approve
help + cli-reference teach the SDLC flow.

BREAKING: rig ps default flips to consolidated all-active-rigs
compact projection (the v0.4.0 current-rig-only default is retired -
it hid running rigs from the operator's field of view). --json is
scope-not-shape (still a bare array with existing per-entry keys;
scope widens to all non-archived rigs including stopped ones; one-line
migration for the old fleet firehose: rig ps --nodes -A --full).
-A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener;
bare -A is a structured teaching error). --nodes names its scope
everywhere (session default local-only; --host <id> --nodes requires
--rig or -A; multi-host fan-out is rollup-only by default; the
explicit ladder --all-hosts --nodes -A --full fans out per-node with
hostId-stamped projected rows). --all-hosts / --hosts --json emits the
shared AggregatedPayload (items + hosts with closed-enum statuses:
ok | unreachable | unsupported-transport | auth-failed).

Operator UX: agent altitude coordination panel scopes multi-agent
state to the right altitude (workspace / mission / slice) so the
coordination surface reads without per-seat drowning; composes with
the workspace observability tabs from 0.4.1. Docs closeout catches
docs/as-built/architecture.md, docs/as-built/cli-reference.md, and
the codemaps up to shipped state.

Migrations: additive only. Existing v0.4.3 databases upgrade by
running rig daemon start.

Behavior change (rig ps default view): the compact all-active-rigs
projection replaces the current-rig-only default. If your call sites
relied on the v0.4.3 default shape, add --full for the rich per-node
projection or --rig <name> for the prior scope.

Public-safety scrub applied inline to packages/ui/twin/corrective/
fixtures-corrective.ts (one demo-fixture role identifier neutralized
to the demo-namespace baseline established at 0.4.1).

See CHANGELOG.md and docs/releases/v0.4.4.md.
This commit is contained in:
mvschwarz committed 2026-07-06 01:20:09 -07:00
1 parent c7f501a744
commit ac75777a3c
253 files changed
+21013 -577

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
node_modules/
node_modules
dist/
.DS_Store
*.log
+69
View File
@@ -8,6 +8,75 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.4.4] - 2026-07-06
**Status**: shipped; multi-host + Living Notes theme.
### Summary For Installing Agents
- **Package version**: bumps from `0.4.3`.
- **Migrations**: additive only. Existing v0.4.3 databases upgrade by running `rig daemon start` on the new daemon.
- **Node engines**: unchanged.
- **Backward compatibility**: `rig ps` default view flips to a consolidated all-active-rigs compact projection (the v0.4.0 current-rig-only default is retired); progressive-disclosure via `--full` / `-A` / `--rig <name>` returns the v0.4.3 default shape. `--json` shape unchanged (scope-not-shape). `rig host` gains three new verbs (`add` / `list` / `doctor`); transport posture documented (no behavior change).
### Headline
**Multi-host + Living Notes.** A shared rig topology can now span multiple hosts with staged whole-topology spin-up, real cross-host file movement, and a consolidated For-You feed that aggregates activity across every registered host. Living Notes ships as the durable INTENT → PLAN → DELIVERED signal layer with a one-structure review contract (single vertical stack, delete-not-demote) and cheap composer surfaces. Operator UX picks up `rig ps` consolidated default (with progressive disclosure), an agent-altitude coordination panel, and the operationalize-SDLC control plane. As-built docs closeout catches everything up.
### The SDLC control plane ships in source (OPR.0.4.4.23 — release requirement)
- **Conventions SSOT**: `docs/reference/sdlc-conventions.md` (copied into the assembled CLI package) — the section names the Living Notes UI projects (`## Intent` / `## Mini-requirements` / `## Proof contract`), the proof-contract format + `plannedRef` mockup pairing, the two staged-approval locks, the C1 proof header + closed sets, the three role contracts, the curation rule, the elastic-middle doctrine, and the advisory fail-open audit posture. Once shipped, the repo doc is the living SSOT; the corrective-redesign spec it derives from is the historical design record.
- **Scaffold**: `rig scope slice create` emits the convention sections + `proof/` + `PROOF.md` + an `IMPLEMENTATION-PRD.md` skeleton (elastic-middle note in its header) for **EVERY template kind** — enumeration-tested, so a future kind fails until covered. Mission templates carry the conventions pointer.
- **Advisory audit**: `rig scope audit` (both byte-identical classifier copies) gains `missing_intent_section` / `mini_requirements_missing_or_malformed` / `proof_contract_missing_or_malformed` / `ui_slice_missing_mockup` (mockup ref = a real image ref or plannedRef token, never bare prose) — low/info severities by construction (the exit code flips on HIGH findings only; records-and-advises, never gates). `rig workspace doctor` gains check #8 (`sdlc_convention_sections`, advisory warn) — the 7-check diagnostic is now 8.
- **Skill**: `mission-slice-sop` now ships in the canonical product skill source (+ `skills/_canonical` mirror), updated to teach the full flow: intent → mini-requirements + proof contract → mockups (UI slices) → plan-lock (`--scope spec`) → build the locked set → QA mockup↔delivered visual compare → `rig proof add` C1 drops → proof-lock (`--scope delivery`). The bundled openrig-core plugin's copy is now pinned by a CI byte-parity test. **Census (verbatim)**: before this slice, `mission-slice-sop` was absent from the canonical shared skill source and `skills/_canonical` mirror; the bundled plugin carried a stale orphan copy (from `c7f501a7`) with no guard — that orphan is replaced and parity-guarded. KNOWN residue: the plugin's `openrig-user` copy remains a wholesale-stale older edition with no mechanical guard (routed as a follow-up candidate, not swept here).
- **Bootstrap**: the shipped `openrig-start.md` overlay (the CLAUDE.md/AGENTS.md floor every managed seat sees) + the product-team and pm-team rig-spec cultures point fresh seats at the SOP skill and the SSOT at boot.
- **CLI help**: `rig proof`, `rig scope slice create`, and `rig scope slice approve` help text teach the flow and cite the SSOT; cli-reference gains the SDLC control-plane verbs section (`approve` locks, `rig proof add`, the audit advisories).
### `rig host` verbs + the documented multi-host transport posture (OPR.0.4.4.13)
- **New verbs (capped at exactly three)**: `rig host add` (registry writes validated by the loader's own rules — no more hand-edited YAML for the standard path), `rig host list` (config pointers, never secret values), `rig host doctor <id>` (stepwise distinct errors: transport → remote rig binary → daemon health → identity) with `--posture product-factory-vps` — the ONE built-in security baseline, three-valued per item (UNKNOWN is never pass).
- **Transport posture DECIDED + documented** (no behavior change): ssh carries pane ops (`send`/`capture`), http-bearer carries daemon REST (`up`/`down`/`launch`), `ps`/`whoami` follow the host's declared transport, fan-out is http-only; NO cross-transport fallback; NO http parity for send/capture in 0.4.4. Per-command table in cli-reference §Cross-host execution.
- **Product-factory bootstrap**: `scripts/bootstrap-product-factory-vps.sh` + `docs/reference/product-factory-vps-runbook.md` (fresh Ubuntu VPS → factory-ready; the 2026-06-23 OVH smoke-test posture as encoded defaults; safe UI tunnel + restricted fail-closed reverse-path recipes).
### BREAKING: `rig ps` consolidated all-rigs default + explicit disclosure ladder (OPR.0.4.4.21)
- **Default scope flips**: bare `rig ps` now shows **every ACTIVE rig on the host** as one compact O(rigs) row (the v0.4.0 current-rig-only default is retired — it hid running rigs from the operator's field of view). New display elements: the host rollup line ("N rigs · M seats · K need attention"), the archived/stopped count line, the drill-ladder footer, and an ATTN column (additive `attentionCount` JSON field).
- **`--json` is scope-not-shape**: still a bare array with the existing per-entry keys; scope widens to ALL non-archived rigs INCLUDING stopped ones (only the human table folds stopped rigs into the count line). Scripts that assumed current-rig-only add the existing `--rig <name>` flag — same schema, wider scope. **One-line migration for the old fleet firehose: `rig ps --nodes -A --full`.**
- **`-A`/`--all-rigs` keeps exactly ONE meaning** — the `--nodes` fleet widener. Bare `rig ps -A` is now a structured teaching error (all-rigs IS the default; archived history stays behind `--include-archived`).
- **`--nodes` names its scope everywhere**: session default applies locally only; `rig ps --host <id> --nodes` requires an explicit `--rig` or `-A` (implicit scope defaults don't cross host boundaries); multi-host fan-out is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows.
- **`--all-hosts`/`--hosts --json` shape change**: emits the intra-P4 shared `AggregatedPayload` — `items` (per-host O(rigs) rows stamped with their origin `hostId`) + `hosts` (closed-enum per-host statuses: `ok | unreachable | unsupported-transport | auth-failed`).
### Multi-host foundation (OPR.0.4.4.11 + 13 + 15 + 18)
- **Shareable whole-topology staged spin-up (S11)** — a rig topology can be brought up in stages across multiple hosts; the spec + the daemon coordinate to reach a green whole-fleet state without requiring single-host bring-up.
- **VPS product-factory multi-host hardening (S13)** — `rig host` verbs (above) + the documented transport posture harden the fresh-Ubuntu-VPS → factory-ready flow. Product-factory bootstrap script + runbook ship for the OVH-smoke-tested posture.
- **Multi-host consolidated For-You feed (S15)** — the For-You feed aggregates activity across all registered hosts in the topology. Real-host feed-subscription capture is sequenced as a lifecycle-post-publish belt-and-suspenders proof.
- **`rig file` cross-host movement (S18)** — files move across registered hosts via the `rig file` surface. Real registered-host two-host round-trip proves the flow on top of the VM stand-ins already merged.
### Living Notes signal layer (OPR.0.4.4.19 + 20 + corrective rebuild)
- **Living Notes signal layer (S19)** — durable agent-authored notes at mission and slice altitude; INTENT / PLAN / DELIVERED entries thread down the mission tree with agent authorship + timestamps.
- **Living Notes composer surfaces (S20)** — cheap authoring surfaces make Living Notes the first-class place agents record decisions, plans, and delivered work.
- **One-structure review contract §3.1 (corrective rebuild)** — the review surface reads left-to-right as a single vertical stack (INTENT above, PLAN + mockup in the middle, DELIVERED with paired proof at the bottom); a plan change **deletes** the old plan and writes a new one — never demotes / stacks multiple competing plans. Replaced an earlier three-column layout after human review.
### Operator UX (OPR.0.4.4.22)
- **Agent altitude coordination panel (S22)** — a coordination surface scoped to the right altitude (workspace / mission / slice) so the operator sees actual coordination state without drowning in per-seat detail. Composes with the workspace observability tabs shipped at 0.4.1.
### Docs closeout (OPR.0.4.4.24)
- **As-built docs closeout (S24)** — the as-built documentation family (`docs/as-built/architecture.md`, `docs/as-built/cli-reference.md`, codemaps) is caught up to what shipped through 0.4.4.
### Known Follow-ons
- **R2 — iOS-Safari S20** — deferred to 0.4.5 for iOS Safari verification.
- **R4 — S18 symlink footgun** — routed to the 0.4.5+ backlog.
- **`openrig-user` stale plugin copy + `whoami --all-hosts` silent host filter** — 0.4.5 candidates.
- **Wider mission-template prose sweep** — post-cut sequencing.
- **Belt-and-suspenders real-host proofs (R13-1 / R1 / R3)** — published-npm Linux install smoke, real registered-host two-host e2e for S13 + S18, and S15 real-host feed-subscription capture sequenced as post-publish lifecycle validation lanes. Shipped code already proven via VM/SSH stand-ins per PM rulings; real-host lanes are defense-in-depth belt-and-suspenders, not release gates.
---
## [0.4.3] - 2026-07-03
**Status**: shipped; "rigs that survive" theme.
+1 -1
View File
@@ -27,7 +27,7 @@ rig up conveyor
## First Run
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:
After `product-team` boots, `rig ps` shows the fleet map and `rig ps --nodes --rig product-team` shows the running pods and seats. When you're ready to shut the team down and bring it back, use the same rig name:
```bash
rig ps
+2 -2
View File
@@ -80,7 +80,7 @@ This produces automated proof artifacts in `demo/proof/`:
| Artifact | Source | Type |
|----------|--------|------|
| `up-transcript.txt` | `rig up demo/rig.yaml` output | Automatic |
| `ps-nodes.txt` | `rig ps --nodes` after boot | Automatic |
| `ps-nodes.txt` | `rig ps --nodes --rig <rig>` after boot | Automatic |
| `health-after-boot.json` | `check-demo-health.ts` after boot | Automatic |
| `native-resume-after-boot.txt` | immediate native probe after boot | Automatic |
| `native-resume-after-boot.json` | immediate native probe machine output | Automatic |
@@ -91,7 +91,7 @@ This produces automated proof artifacts in `demo/proof/`:
| `down-transcript.txt` | `rig down` output | Automatic |
| `tmux-check.txt` | `tmux ls` after teardown | Automatic |
| `restore-transcript.txt` | `rig restore <snapshotId> --rig <rigId>` output | Automatic |
| `ps-restored.txt` | `rig ps --nodes` after restore | Automatic |
| `ps-restored.txt` | `rig ps --nodes --rig <rig>` after restore | Automatic |
| `browser-screenshot.png` | Explorer + Graph + Detail Panel | **Manual** |
| `resume-test.txt` | Post-restore agent context check | **Manual** |
+3 -2
View File
@@ -35,7 +35,8 @@ echo ""
# 2. Node status
echo "Step 2: Node status after boot..."
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session.
rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
echo ""
# 3. Health check after boot
@@ -95,7 +96,7 @@ echo ""
# 10. Node status after restore
echo "Step 10: Node status after restore..."
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
echo ""
echo "=== Automated proof artifacts produced ==="
+3 -2
View File
@@ -32,7 +32,8 @@ fi
# Show node status
echo ""
echo "Node status:"
rig ps --nodes
# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session.
rig ps --nodes --rig "$RIG_ID"
# Establish a restore-safe baseline for the demo rig.
echo ""
@@ -55,6 +56,6 @@ echo "=== Demo topology is running ==="
echo "Dashboard: http://localhost:5173"
echo ""
echo "Next steps:"
echo " rig ps --nodes # Check node status"
echo " rig ps --nodes --rig '$RIG_ID' # Check node status"
echo " rig down $RIG_ID # Tear down (auto-snapshots)"
echo " rig restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
+2 -1
View File
@@ -42,7 +42,7 @@ docs/as-built/
├── 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
├── architecture/ ← 14 backend/runtime modules
└── ui/ ← 4 operator-surface modules
```
@@ -67,6 +67,7 @@ at the repo `docs/` root** by design — it is referenced by many existing
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, pipe-pane transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key distinction. |
| [workspace-primitive.md](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration (root/repos/defaultRepo/knowledgeRoot), migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. |
| [content-surfaces.md](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + JSONL edit audit, PROGRESS.md tree indexer, the one-screen Steering composer. |
| [living-notes-review.md](architecture/living-notes-review.md) | **v0.4.4** — the Living Notes review surface: the ONE `ComposedSliceReview` (intent→plan→delivered) projection, pure composer + gatherer, staged-approval locks, C1-bound `verified`, `/api/review/*` routes, freeze export, ranged media serving, and the SDLC on-disk convention it projects. |
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | The filesystem-canonical content layer — plugin discovery, agent images, context packs, the Claude auto-compaction policy enforcer. |
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 pod bundles + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules, the RigEvent union + SSE delivery, intentional compatibility limits. |
+3 -1
View File
@@ -32,7 +32,8 @@ resolve.
## Where the major content went
The architecture content is now the **13 modules under `architecture/`**:
The architecture content is now the **14 modules under `architecture/`**
(13 from the slice-08 reorg + `living-notes-review.md`, added v0.4.4):
| Module | What moved here |
|---|---|
@@ -49,6 +50,7 @@ The architecture content is now the **13 modules under `architecture/`**:
| [`architecture/plugin-agent-image-context-pack.md`](architecture/plugin-agent-image-context-pack.md) | Plugin discovery, agent images, context packs, the Claude auto-compaction enforcer. |
| [`architecture/packaging-bootstrap-bundles.md`](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
| [`architecture/architecture-rules-and-event-system.md`](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules (incl. **rule 15**, restore honesty), the RigEvent union + SSE delivery, intentional compatibility limits. |
| [`architecture/living-notes-review.md`](architecture/living-notes-review.md) | **Added v0.4.4** (not part of the original monolith): the Living Notes review surface — the one intent→plan→delivered projection, staged-approval locks, proof artifacts, freeze export, ranged media serving. |
The UI half of the old `### UI architecture` section is now the **4 modules
under `ui/`** (see [`ui.md`](ui.md), also a redirect stub, and the `ui/` index
+13 -1
View File
@@ -19,6 +19,11 @@ last-updated: 2026-05-16
# Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
> **See also (v0.4.4):** the Living Notes review surface — the composed
> intent→plan→delivered projection over this same allowlisted content layer —
> is documented in [`living-notes-review.md`](living-notes-review.md).
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,
@@ -141,7 +146,14 @@ surfaces it as a 500 so the failure is honest, not swallowed.)
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).
`Content-Type`, 5-min cache). **v0.4.4 (OPR.0.4.4.20 FR-5):** serves HTTP
**byte ranges** — single-range form, `206` + `Content-Range` +
`Accept-Ranges: bytes` (malformed/unsatisfiable → `416`) — the
iOS-Safari-class media-playback requirement; and `.html` renders as
`text/html` ONLY under the explicit `?render=1` opt-in (text/plain stays
the default). The slice proof-asset route (`/api/slices/:name/proof-asset/*`)
carries the same range semantics (living-notes corrective). See
[`living-notes-review.md`](living-notes-review.md) §5.
- `POST /write` — atomic write (§2); 503 when no write service
(`OPENRIG_FILES_ALLOWLIST` empty); 409 `write_conflict` on stale
mtime/hash with the current values for the UI's refresh prompt.
@@ -0,0 +1,162 @@
---
kind: as-built
title: Living Notes Review Surface — the intent→plan→delivered projection
status: active
topics: [knowledge-and-context, observability, sdlc]
domains: [engineering-advisor, operating-advisor, product-advisor]
applies-when: |
Working on or consuming the composed slice/mission review surface — the
/api/review/* routes, the ComposedSliceReview contract, the on-disk SDLC
convention it projects, staged-approval locks, proof artifacts and the C1
header, the freeze export, or media serving for review evidence.
siblings: [content-surfaces.md, workspace-primitive.md, mission-control.md]
prerequisite-reads: [workspace-primitive.md]
last-verified-against-source: bb5ad219
last-updated: 2026-07-06
---
# Living Notes review surface (v0.4.4)
The review surface is a **pure projection of on-disk markdown into one
reviewable structure per slice**: INTENT → PLAN → DELIVERED. Agents change
files; the daemon re-projects; nothing here writes product state except the
two deliberate approval stamps and the freeze export. Shipped across the
0.4.4 living-notes slices (19 signal layer, 20 composer/surfaces, 22 rig
agents altitude, 23 SDLC operationalization) and the **corrective rebuild**
(2026-07-05/06) that collapsed the surface to the one structure described
here. Everything below is verified at `bb5ad219`.
## 1. The ONE contract — `ComposedSliceReview`
`packages/daemon/src/domain/review/types.ts` defines the read contract every
consumer shares (slice Review tab, mission U5 row expansion, For-You
expansion — never a second endpoint per consumer):
- **identity + phase**: `slice`, `sliceId`, `title`, `missionId`, `phase`
(five-way, derived read-time: `locked > review > building > spec > intent`,
`derivePhase` in `compose.ts`), `laneLabel` (SS14 vocabulary INTENT / PLAN /
BUILD / REVIEW / LOCKED).
- **`intent`**: `{text, media[], ssotPath, degrade}` — the slice README's
`## Intent` section verbatim (`extractSection`), plus any media it embeds.
- **`plan`**: `{concise: {text, media[]}, lockedArtifacts[], lock, ssotPath}` —
the PRD's pinned `## Mini-requirements` tier + planned mockups; the pinned
artifact set is a **frontmatter READ** (`locked-artifacts:` list on the
slice README — no new write machinery); `lock` is the spec-scope
staged-approval stamp (§3).
- **`delivered`**: `{items[], extraProof[], lock, proofDirPath}` — the
redesigned join (§4): each `## Proof contract` deliverable paired with its
curated proof media and QA's recorded comparison signal. `lock` is the
delivery-scope stamp; `proofDirPath` is the drill-in door to the full
fix-loop history.
- **KEPT orthogonal bands**: `needsYou` (two sources, one queue: agent-routed
items ∪ derived ▲ exceptions with inline evidence), `agents`
(scope-parameterized `slice:<id> | mission:<id> | rig` — one contract, all
scopes), `lineage` (`VerifyLineage`: candidate/merge/tip facts + the four
gate `VerdictCell`s rendering recorded tokens verbatim), `defects`
(out-of-slice media refs surfaced, never silently dropped), `composedAt`.
**Deleted by the corrective (2026-07-05, founder-blocked original):** the
four parallel renderable structures (`sections`, `acceptance`, `compare`,
`join`) and the coequal `green` field. They do not exist in the types, the
composer output, the UI hook, or the fixtures; the daemon route tests assert
the keys are ABSENT on the wire (`test/review-composer.test.ts`,
`test/review-routes.test.ts`). Recorded-verdict green survives in exactly two
places: the per-deliverable `verified` signal (§4) and the mission ledger's
completion fact (§6) — never a slice-review structure.
## 2. Composer + gatherer split
- `compose.ts` is **PURE**: `(gathered inputs) → composed doc`; identical
inputs (including caller-supplied `nowIso`/git facts) produce byte-identical
output — idempotence holds by construction and is pinned by tests.
- `gather.ts` (`ReviewGatherer`) is the impure shell: reads slice docs +
`proof/*.md` artifacts from disk, attention/agent rows from SQLite,
approval stamps from frontmatter (cross-checked against the audit log, §3),
git lineage facts from the workspace default repo. Every unreadable source
degrades honestly (nulls / "unknown") — the composer renders the named
degrade, never invented content.
## 3. The two locks = the shipped staged-approval stamps
`plan.lock` and `delivered.lock` are projections of the **staged-approval
verb** (`rig scope slice|mission approve --scope spec|delivery` — see
cli-reference "SDLC control plane verbs"): frontmatter stamps
(`approved-spec-by/-at`, `approved-by/-at`) written daemon-side together with
an append-only `mission_control_actions` audit row (no half-stamp). The
gatherer cross-checks each stamp against the pinned scope-approval
`audit_notes_json` shape (`approval_scope` + scope identity); a stamp with no
matching row projects `auditVerified: false` and renders as an UNVERIFIED
stamp — visible, never a block. Approval is freeze/sign-off, **never**
proven-green (BR-6).
## 4. DELIVERED — promised ↔ curated proof ↔ verified
`composeDelivered` joins the PRD's `## Proof contract` checkbox items
(`extractProofContract`; an optional markdown image on the checkbox line
becomes the deliverable's `plannedRef` mockup) to the C1-headed proof
artifacts in `<slice>/proof/`:
- An artifact **covers** a deliverable when its C1 `evidences:` list names the
item (exact text or 1-based index).
- `verified` binds to the shipped C1 fields, never mere presence:
**verified** = a covering `qa|adjudication` artifact records the comparison
(`self_check`) AND its recorded verdict is passing; **unverified** = some
covering artifact without a passing recorded QA comparison (QA's
why-kicked-back `note` still surfaces); **missing** = promised, nothing
delivered. All three are render states — fail-open by construction.
- The curated proof set is the covering artifacts' body media refs
(`ProofArtifact.mediaRefs`, populated by `rig proof add --media` or markdown
refs in the artifact body), normalized slice-relative; refs escaping the
slice dir become `defects` findings and are never served or inlined.
Unmapped-artifact media renders bounded as `extraProof`.
- The ▲ insufficient-proof NEEDS-YOU exception fires from the delivered
MISSING count (`deriveExceptions`).
## 5. Routes, freeze, media serving
- `packages/daemon/src/routes/review.ts`, mounted at `/api/review`
(`server.ts` route mounts): `GET /slice/:name`, `GET /mission/:name`,
`GET /rig` (the OPR.0.4.4.22 rig-agents altitude root), `GET
/agents?scope=slice:<id>|mission:<id>|rig`, `POST /freeze`.
- **Freeze** (`freeze.ts`): the ONE synchronous compose-and-freeze path,
invoked after the delivery stamp + audit row commit. Renders the composed
review to a single self-contained HTML file (`REVIEW-<id>-<date>.html` in
the slice dir): CSS inline, images data-URI-inlined under slice-dir
containment (resolve-prefix + realpath — a traversal/symlink ref renders
the muted outside-slice branch, never inlined), video by link + poster.
Exclusive-create through the allowlist-governed atomic write service;
re-invocation is an idempotent no-op; a failed render never un-approves.
The freeze moment also folds the mission brief spine (`brief-spine.ts`)
into `MISSION_BRIEF.md`, section-scoped, schema-order-preserving.
- **Media serving** for review evidence is ranged on BOTH asset route
families (iOS-Safari-class players require `206`): `GET /api/files/asset`
(`routes/files.ts` — single-range parse, `206`/`416`, `Accept-Ranges:
bytes`; `.html` renders as text/html only under the explicit `?render=1`
opt-in) and `GET /api/slices/:name/proof-asset/*` (`routes/slices.ts` —
same range semantics via `fileAssetResponse`, added by the corrective QA
fixback; immutable-cache posture retained).
## 6. Mission + rig altitudes
`composeMissionReview` consumes `{review, green}` entries per slice: the
board (stage-specific cells re-bound to the collapsed contract — spec-stamp
state, delivered n/m, GREEN·merge pair, stamp), the completion **ledger**
(a query over the slice set, never an authored list; `green` here is the
recorded-verdict fact from `computeRecordedGreen` — regime 1 = four passing
gate verdicts, regime 2 = passing adjudication), `cutComplete` (TRUE only
when every in-cut slice is green AND merged AND has zero open needs-human
items), and the union NEEDS-YOU (distinct identities — one item seen from N
altitudes is one item). `composeRigAgents` serves the standalone rig
altitude (roster ∪ recently-holding, park/health/settled from the queue
transitions log).
## 7. The on-disk convention it projects (SDLC control plane)
The surface projects the markdown-control-plane shapes operationalized by
OPR.0.4.4.23 — conventions SSOT `docs/reference/sdlc-conventions.md`:
`## Intent` (README) / `## Mini-requirements` + `## Proof contract` (PRD) /
`proof/` C1-headed artifacts + `PROOF.md`, scaffolded for every template kind
by `rig scope slice create` and checked advisory-only by `rig scope audit`
(+ the `rig doctor` SDLC row). The UI half of this surface (the slice Review
tab, mission board, For-You approve+chat) is documented in
[`../ui/project-and-for-you.md`](../ui/project-and-for-you.md).
+99 -23
View File
@@ -59,6 +59,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `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 |
| `file` | Cross-host file movement over ssh/rsync (v0.4.4; one explicit verb: `copy`) |
| `ps` | List rigs and their status |
| `mcp` | MCP server for agent integration |
| `agent` | Manage agent specs |
@@ -117,6 +118,7 @@ Subcommands:
Notes:
- `start` launches the daemon process and accepts runtime overrides for port, host, and DB path.
- `logs` reads daemon log output and can follow it.
- **Deploy identity (v0.4.4, OPR.0.4.4.11 FR-6/7)**: a PACKAGED build (built via `scripts/build-package.sh`) is stamped with `{semver, commit, dirty, builtAt}`; the daemon's `/healthz` payload carries the four stamp fields additively and `rig --version` renders `<semver> (<commit8>[, dirty])`. A source/dev run has NO stamp and adds NOTHING (never an invented SHA) — `/healthz` keeps its legacy body and `--version` prints the plain semver. This is the 30-second stale-deploy diagnostic: an unstamped or old-commit `/healthz` on a long-running host means you are looking at an older deployed build, not the source tree. Source: `packages/{daemon,cli}/src/build-info.ts` (`stampFields`), `packages/daemon/src/server.ts` (`/healthz`), `packages/cli/src/version.ts`.
### `rig status`
@@ -153,6 +155,8 @@ Supported keys:
- `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
- `feed.subscriptions.{action_required|approvals|shipped|progress|audit_log}` (booleans) — the For-You feed's five flat lens toggles (`OPENRIG_FEED_SUBSCRIPTIONS_*` env mapping)
- `feed.subscriptions.<hostId>.enabled` (boolean; **v0.4.4, OPR.0.4.4.15**) — ONE registered dynamic key CLASS (not a general dynamic-key mechanism): per-host feed subscription toggles for the aggregated multi-host For-You feed. `hostId` segment charset `[A-Za-z0-9_-]+` (dotted host ids are inexpressible in dotted keys and reject as unknown); the flat toggle tails + `enabled` are RESERVED segments in both spellings, so a host id can never shadow a flat key. No env-var mapping for the dynamic class in v1 — file/API only. The CLI config store carries the same class (parity-pinned against the daemon store).
Precedence:
- CLI flag
@@ -328,6 +332,7 @@ Current behavior notes:
- 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.4.4 (OPR.0.4.4.11) — whole-topology sources**: a `.rigtopology` manifest (or a YAML file whose body declares the topology form) boots MULTIPLE rigs in one staged spin-up. v0 manifest entries are **spec paths only** (a closed-key manifest: `.rigbundle` and bare library-name entries are rejected at parse time with per-entry what/why/fix naming the v0 boundary). Per-entry `host: <id>` is the ONLY placement mechanism for topology entries — `rig up --host <id> <topology>` is REJECTED pre-dispatch (two placement mechanisms must not coexist). The launcher acquires per-rig launch locks route-side and reports a CLOSED per-entry aggregate `{ok | failed | skipped}` (skipped is explicit — a lock conflict or upstream failure never reads as silent success). Source: `packages/cli/src/commands/up.ts` (`.rigtopology` sniff + `--host` rejection), `packages/daemon/src/domain/topology/{topology-manifest,multi-rig-launcher,remote-up-leaf}.ts`, `packages/daemon/src/routes/up.ts`.
- 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:
@@ -402,20 +407,20 @@ Usage:
- `rig ps --nodes [--json] [--full] [--rig <name>] [-A | --all-rigs] [--session <sess>] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--host <id>]`
Notes:
- **v0.4.0 — current-rig default + compact-by-default (slices 25 + 34)**: the default breadth is **current-rig** (derived from `OPENRIG_SESSION_NAME`'s `@<rig>` suffix), not all-rigs. `-A` / `--all-rigs` widens to fleet breadth. `--rig <name>` overrides the current-rig default explicitly. `--session <sess>` narrows further. The default `--json` output is a compact TL;DR projection per node: `session`, `rig` (to disambiguate under `-A`), `activity` (state + reason), `assigned` / `pending` counts, resume summary as `resumeType` + `resumeTokenPresent` (boolean — NOT the token value, per the slice-34 security correction). `--full` returns the complete per-node record (raw byte-equivalent passthrough — preserves the prior shape including `tmuxAttachCommand`, `resumeCommand`, `contextUsage`, `agentActivity` full, `restoreOutcome`, etc.; `resumeToken` value is still part of `--full` for downstream consumers that need it). All-states stays the default (per the orch-lead-grounded ruling: ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal — unlike queue-list which defaults to active items only). `--active` / `--running` is the opt-in active-filter (already existed). Closes a ~77,000-token status-glance incident at root + a fleet-scale unbounded-default-output bomb.
- **v0.4.4 — consolidated all-rigs default + disclosure ladder (OPR.0.4.4.21)**: the default is **every ACTIVE rig, one compact row each** — O(rigs), never a fleet node fan-out — plus three load-bearing display elements: the host rollup line ("N rigs · M seats · K need attention"), the archived/stopped count line (history folds to ONE line), and the affordance footer teaching the drill ladder. The v0.4.0 current-rig default is RETIRED (it hid running rigs from the operator's field of view); the session-rig default now applies ONLY to `--nodes`, and only locally — **implicit scope defaults don't cross host boundaries** (remote `--nodes` requires explicit `--rig` or `-A`). `-A`/`--all-rigs` keeps exactly ONE meaning: the `--nodes` fleet widener; bare `-A` is a structured teaching error naming `--include-archived` for history. STATED contract: default `--json` is a bare array of ALL non-archived rigs INCLUDING stopped ones (existing keys preserved; additive `attentionCount`); only the human table folds stopped rigs. Fan-out (`--all-hosts`/`--hosts`) emits the intra-P4 shared `AggregatedPayload` (hostId-stamped `items` + closed-enum per-host `hosts[]` statuses) and is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows. Migration from the old firehose: `rig ps --nodes -A --full`. The default `--json` output is a compact TL;DR projection per node: `session`, `rig` (to disambiguate under `-A`), `activity` (state + reason), `assigned` / `pending` counts, resume summary as `resumeType` + `resumeTokenPresent` (boolean — NOT the token value, per the slice-34 security correction). `--full` returns the complete per-node record (raw byte-equivalent passthrough — preserves the prior shape including `tmuxAttachCommand`, `resumeCommand`, `contextUsage`, `agentActivity` full, `restoreOutcome`, etc.; `resumeToken` value is still part of `--full` for downstream consumers that need it). All-states stays the default (per the orch-lead-grounded ruling: ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal — unlike queue-list which defaults to active items only). `--active` / `--running` is the opt-in active-filter (already existed). Closes a ~77,000-token status-glance incident at root + a fleet-scale unbounded-default-output bomb.
- **Daemon node-list payload trimmed at source (slice 26)**: `recoveryGuidance` is no longer serialized as near-identical templated prose on every node — relocated to a guidance-by-reference map at the top level so the 4 current consumers still resolve it. `contextUsage` is a compact summary in the list payload (full telemetry remains retrievable per-node via `rig whoami` / detail queries). Even `--full` and the UI consumers stop paying for the redundant per-node blobs.
- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`.
- `rig ps --nodes` expands into a cross-rig node inventory. Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command.
- `rig ps` lists rig summaries. Default human columns (v0.4.4): `RIG`, `NODES`, `RUNNING`, `ACTIVE`, `WORK`, `ATTN`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`; `ATTN` is the additive attention count.
- `rig ps --nodes` expands into the current (or `--rig`-named) rig's node inventory — `--nodes -A` for the cross-rig inventory (v0.4.4 scoping). Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command.
- JSON output for both rig and node tiers includes a `rigName` alias (equal to `name`) for forward compatibility; agent code should prefer `rigName`. Default `--json` is a bare array (back-compat); the envelope shape `{entries, totalRigs|totalNodes, truncated, hint?}` is only used when `--limit`, `--fields`, `--summary`, or `--filter` is set.
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `activeCount`, `hasWorkCount`, `attentionCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `oriented`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `terminalActive`, `hasAssignedWork`, `pendingWorkCount`, `agentActivity`, `contextUsage`, `heldReason`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
- `--filter <key=value>` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do).
- `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture.
- `--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). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
- Exit codes:
- `0` success
- `1` daemon not running, or invalid `--filter` / `--limit`
- `1` daemon not running, or invalid `--filter` / `--limit` / `--fields`
- `2` daemon fetch failure
### `rig snapshot`
@@ -786,11 +791,10 @@ Notes:
### Cross-host execution (`--host <id>`)
`rig send` and `rig capture` accept a `--host <id>` flag that routes the
command to a remote host over single-hop SSH. v0 is CLI-side shell-out
only — the local daemon is not involved in routing, and there is no
daemon-to-daemon networking. The remote host is expected to have its own
managed `rig` available on `$PATH`.
Cross-host commands route to a remote host declared by id. SSH-transport
commands use single-hop SSH CLI-side shell-out; HTTP-transport commands talk to
the remote daemon API. The local daemon is not involved in SSH routing. The
remote host is expected to have its own managed `rig` available on `$PATH`.
Hosts are declared by the operator in `~/.openrig/hosts.yaml`:
@@ -801,18 +805,19 @@ hosts:
target: vm-claude-test.local
user: your-username # optional
notes: "Tart VM" # optional
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: your-username
- id: factory-http
transport: http
url: http://100.64.1.2:7433
bearer_env: FACTORY_HTTP_TOKEN
```
Validation rules:
- `hosts` is required and must be a non-null array.
- Each entry: `id` required (non-empty, unique), `transport` required (v0 supports `ssh` only), `target` required (non-empty — DNS name, SSH config alias, or IP).
- `user` and `notes` are optional.
- The file is operator-managed; v0 does NOT include any sub-command to add, remove, or list hosts (operators edit the YAML directly).
- Each entry: `id` required (non-empty, unique), `transport` required (`ssh` or `http`).
- SSH entries require `target` (non-empty — DNS name, SSH config alias, or IP); `user` and `notes` are optional.
- HTTP entries require `url` plus exactly one bearer pointer (`bearer_env` or `bearer_file`); pointers are config names/paths, never resolved token values.
- `rig host add/list/doctor` covers the standard path; hand-editing remains the path for exotica.
- A missing or invalid file returns a clear error pointing at the canonical path.
The CLI distinguishes four structured failure modes (operators get an
@@ -823,7 +828,70 @@ actionable error per mode; JSON output preserves the `failedStep` enum):
- `remote-daemon-unreachable` — SSH succeeded but the remote `rig` reported the remote daemon was not reachable. Start it with `ssh <target> rig daemon start`.
- `remote-command-failed` — SSH succeeded but the remote `rig` exited non-zero for some other reason; the remote stderr is surfaced.
Out of scope for v0: non-SSH transports; `--host` on `rig ps` / `rig whoami` / other commands; connection pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
**The transport posture (OPR.0.4.4.13 FR-4 — DECIDED, pm-ruled: document, no parity).** The
partition is the intended posture, not an accident of history: **ssh carries interactive pane
ops, http-bearer carries daemon REST ops, `ps`/`whoami` follow the host's DECLARED transport.**
There is NO cross-transport fallback, and NO http parity for `send`/`capture` ships in 0.4.4
(parity would be new attack surface with no scope-locked need). Per-command:
| Command | ssh transport | http transport | Fan-out (`--all-hosts`/`--hosts`) |
| --- | --- | --- | --- |
| `rig send` | ✓ (ssh-ONLY) | ✗ | ✗ |
| `rig capture` | ✓ (ssh-ONLY) | ✗ | ✗ |
| `rig up` / `rig down` / `rig launch` | ✗ | ✓ (http-ONLY) | ✗ |
| `rig file copy` (v0.4.4) | ✓ (ssh-ONLY, rsync-over-ssh) | ✗ | ✗ |
| `rig ps --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts appear as STRUCTURED `unsupported-transport` statuses in `hosts[]` |
| `rig whoami --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts are currently SILENTLY FILTERED from the fan-out (a shipped gap, routed for 0.4.5 triage — differs from ps's structured status) |
| `rig host doctor` | ✓ | ✓ | n/a (single host) |
Out of scope in 0.4.4: cross-transport fallback; http parity for `send`/`capture`; connection
pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
### `rig file` (v0.4.4 — OPR.0.4.4.18)
Usage: `rig file copy <src> <dst> [--dry-run] [--json]`
Cross-host file movement over ssh/rsync — v0 ships ONE explicit verb, `copy`,
for a single file.
Operand grammar (parsed, never guessed):
- `<hostId>:<absolute-path>` = remote (the `<hostId>` must resolve in the ssh
hosts registry; remote paths must be absolute).
- Bare path = local. A LOCAL file whose name contains a colon needs the `./`
prefix (`./weird:name.txt`) — the grammar refuses the ambiguous form with a
structured error instead of guessing.
- Valid shapes: local→remote, remote→local, local→local. remote→remote is not
a v0 shape.
Semantics + safety wall (source: `packages/cli/src/lib/file-transfer.ts`):
- An existing destination is **OVERWRITTEN** (v0 copy semantics, stated) —
preview with `--dry-run`, which prints the exact planned transfer (src, dst,
host, files/bytes) and moves nothing.
- **Default-deny wall over live agent/credential state**: paths resolving into
the closed deny set `~/.openrig`, `~/.ssh`, `~/.codex`, `~/.claude` — plus
the ACTIVE `OPENRIG_HOME` and the active hosts registry file — are refused
with a named what/why error (extension of the deny set requires a ruling,
never a silent widening).
- Traversal is rejected on the RAW operand: a `..` path segment refuses
BEFORE any normalization (normalization collapses `..`, so a post-normalize
check would be dead code); remote paths are additionally restricted to a
shell-inert charset (`A-Za-z0-9._/-` — restriction over escaping, because
both rsync implementations in the fleet differ on quoting flags), and every
rsync invocation pins operands behind `--` and spawns argv-style with no
shell.
- Transport is ssh-only (see the transport-posture table above); the remote
side uses the same ssh registry entries `rig send`/`capture` use.
### `rig host`
Usage: `rig host <add|list|doctor>` — the multi-host registry verbs (OPR.0.4.4.13; capped at
exactly these three — no edit/remove/tunnel/bootstrap verbs; hand-editing `hosts.yaml` remains
the path for exotica, and the factory bootstrap ships as script + runbook at
`docs/reference/product-factory-vps-runbook.md`).
- `add --id <id> --transport <ssh|http> [--target <t> --user <u> | --url <u> --bearer-env <n>|--bearer-file <p>] [--notes <text>] [--json]` — writes the entry validated by the registry loader's OWN rules (add-time errors are load-time errors, verbatim; duplicate ids refused). Rewrites `hosts.yaml` canonically (hand-authored comments are not preserved).
- `list [--json]` — id/transport/target plus AUTH as a config POINTER (`env:NAME` / `file:PATH` / `ssh-key`); never a resolved secret value.
- `doctor <id> [--posture product-factory-vps] [--public-addr <ip>] [--json]` — stepwise, honest verification: transport reachability → remote `rig` binary (+version) → remote daemon health → remote identity; each failing step is a DISTINCT actionable error, and unknown host ids surface as the registry error class. `--posture` runs the ONE built-in baseline (`product-factory-vps`): every item reports pass/fail/**unknown** individually with a fix per non-pass — UNKNOWN is never pass; the public `:7433`/`:22` probes need `--public-addr` (outside vantage) and a reachable public daemon port FAILS loudly. Exit `1` on any fail.
### `rig broadcast`
@@ -1049,11 +1117,11 @@ Notes:
## 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 an integrated product UI inside the existing shell, NOT a new `rig` command. PL-005 originally named the read-only node surface as `rig ps --nodes --json`; under the v0.4.4 disclosure ladder, the fleet-wide projected node source is `rig ps --nodes -A --json`.
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.
Mission Control consumes `rig ps --nodes -A --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.
## Agent Images, Context Packs, and Workspace (v0.3.0)
@@ -1101,7 +1169,7 @@ Usage: `rig workspace <subcommand>` — Workspace Primitive (PL-007), v0 typed-k
Subcommands:
- `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.
- `doctor [--workspace <path>] [--strict] [--json]` — v0.3.2 slice-21 FR-5 (+1 check v0.4.4 slice 23). Run an 8-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, SDLC convention sections — the last advisory-warn per `docs/reference/sdlc-conventions.md`). 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 was intentionally narrow (`validate` only); v0.3.2 added `doctor` as the operator-facing readiness diagnostic.
@@ -1150,7 +1218,7 @@ 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`. **v0.4.0 (slice 33)**: scaffolds a canonical `PROGRESS.md` (the structure the OpenRig PROGRESS UI page parses) plus a convention-correct README with proper frontmatter per `conventions/scope-and-versioning/README.md`, for EVERY template. Fixes the v0.3.x bug where newly-created slices were missing `PROGRESS.md` entirely.
- `create <mission> <slug> [--template <kind>] [--title <text>] [--json]` — create a new slice in a mission. `--template` default `placeholder`. **v0.4.0 (slice 33)**: scaffolds a canonical `PROGRESS.md` (the structure the OpenRig PROGRESS UI page parses) plus a convention-correct README with proper frontmatter, for EVERY template. **v0.4.4 (slice 23)**: EVERY template kind additionally emits the SDLC convention sections (`## Intent` / `## Mini-requirements` / `## Proof contract`, kind-specific body below them), a `proof/` dir, `PROOF.md`, and an `IMPLEMENTATION-PRD.md` skeleton carrying the elastic-middle note — the shapes the Living Notes UI projects. Conventions SSOT: `docs/reference/sdlc-conventions.md`.
- `progress <slice-path> [--mission <name>] [--status <state>] [--milestone <text>] [--owner <session>] [--note <text>] [--json]` — **v0.4.0 (slice 33)** new verb: append / set / update progress entries deterministically. Writes the canonical structure the OpenRig PROGRESS UI page reads. Replaces hand-editing `PROGRESS.md` with markdown.
- `stage <slice-path> <new-stage> [--mission <name>] [--successor <id>] [--json]` — **v0.4.0 (slice 35)** new verb: set the slice's `stage` frontmatter (wip / provisional / established / canonical / superseded / retired) deterministically. `superseded` REQUIRES `--successor <id>` (rejected otherwise + records the successor); `retired` warns "do not use"; invalid stages rejected with the valid set named.
- `verified <slice-path> --against "<source>" [--mission <name>] [--json]` — **v0.4.0 (slice 35)** new verb: stamp the slice's `verified` line with `verified: <today> against <source>`. `--against` is MANDATORY (bare timestamps rejected — the anti-stale keystone per `conventions/scope-and-versioning` §2). Overwrites the prior verified line.
@@ -1170,8 +1238,16 @@ Two subcommand groups: `slice` and `mission`.
Convention compliance: `rig scope` together with slice 33 (`PROGRESS.md` + scaffolding) and slice 35 (`stage` / `verified` / `reconcile`) makes `rig scope` the **deterministic enforcer** of `conventions/scope-and-versioning` (§1 dot-IDs, §2 maturity vocabulary). Agents update the convention through commands rather than hand-editing markdown.
### SDLC control plane verbs (v0.4.4)
The conventions these verbs operate live in ONE shipped document: `docs/reference/sdlc-conventions.md` (copied into the assembled CLI package); the operating procedure is the packaged `mission-slice-sop` skill.
- `rig scope slice|mission approve <target> [--scope spec|delivery] [--actor <session>] [--on-behalf-of <human>] [--json]` — **v0.4.4 (slice 19 FR-9)** the two staged-approval locks, one daemon-side write path (frontmatter stamp + append-only audit row land together; no half-stamp). `--scope spec` = the **plan-lock** ("the PRD matches my intent; this artifact set gets built"); `--scope delivery` (default) = the **proof-lock** (terminal sign-off; fires the freeze). Approval is freeze/sign-off — never proven-green (proven-green requires recorded C1 verdicts).
- `rig proof add <slice-path> --artifact-type <guard|qa|rev1-r1|rev1-r2|adjudication> --verdict <CLEAR|BLOCKING|CONCERNING|PASS|NOT-CLEAR> --candidate-sha <sha> --money-evidence "<line>" [--file <path>|--body <text>] [--evidences <refs>] [--media <refs>] [--self-check <text>] [--json]` — **v0.4.4 (slice 19 FR-8; `--media` via the corrective §3.4)** drop a proof artifact into `<slice>/proof/` with the machine-readable C1 header, validated at drop time (closed sets above). `--evidences` (item text or 1-based index) joins the drop to the slice's `## Proof contract` items — the pairing the Living Notes DELIVERED section renders; `--media` (proof/-relative refs, containment-checked, never absolute) names the curated media the drop stands behind, projected into the DELIVERED items' proof set. Contract/self-check outputs are advisories (exit 0), never gates.
- `rig scope audit <mission> [--json]` — extended **v0.4.4 (slice 19 FR-10 + slice 23)** with C1-header and IMPLEMENTATION-PRD backstops plus the convention-section advisories (missing `## Intent`, missing/malformed `## Mini-requirements`, missing/malformed `## Proof contract`, UI-slice-without-mockup — a mockup ref means a real image ref or plannedRef token, never bare prose). All advisory/fail-open: the exit code flips on HIGH findings only; the convention advisories are low/info by construction.
Notes:
- Surface source-verified against `packages/cli/src/commands/scope.ts` at `51554eee` (v0.4.0 post-slice-35).
- Surface source-verified against `packages/cli/src/commands/scope.ts` at `51554eee` (v0.4.0 post-slice-35); SDLC control-plane verbs source-verified against `scope.ts` / `proof.ts` / `scope-audit.ts` in OPR.0.4.4.23.
## Skill Cascade Audit (v0.4.0)
+3
View File
@@ -66,6 +66,7 @@ One row per module. "Reach for this when…" mirrors each module's actual
| `plugin-agent-image-context-pack.md` | Four filesystem-canonical content primitives the daemon discovers and serves. | You need how OpenRig discovers plugins, captures/forks agent images, assembles/sends context packs, or how the Claude auto-compaction enforcer decides to send `/compact`. |
| `packaging-bootstrap-bundles.md` | How a topology is packaged into a shareable bundle and reconstituted elsewhere. | You need how rig/pod bundles are assembled (schema-v2 vs legacy v1), how bundle create/inspect/install + `/api/up` route across source kinds, the staged BootstrapOrchestrator flow, or which legacy install-engine seams still ship. |
| `architecture-rules-and-event-system.md` | The cross-cutting invariants that belong to no single subsystem. | You need the 25 architecture rules + startup/import constraints, the shape of the RigEvent union and its SSE delivery, or the intentional compatibility limits that still describe the shipped system. |
| `living-notes-review.md` | **v0.4.4** — the one intent→plan→delivered review projection over on-disk SDLC markdown. | You need the `ComposedSliceReview` contract, the `/api/review/*` routes, staged-approval locks (`--scope spec|delivery`), the `## Proof contract` ↔ proof-artifact join and C1-bound `verified`, the freeze export, or ranged media serving for review evidence. |
### ui/
@@ -150,6 +151,7 @@ Reading-order rule: open a module's `prerequisite-reads` first
| rig send/capture/broadcast, transcripts, durable chat, `rig ask`, MCP-name vs tmux-key | `architecture/transport-and-transcripts.md` |
| The workspace primitive (root/repos), `target_repo` scope gating, missions/slices indexing | `architecture/workspace-primitive.md` |
| The file browser path safety, atomic writes + edit audit, PROGRESS tree, Steering composer | `architecture/content-surfaces.md` |
| The composed slice/mission review (`/api/review/*`), staged-approval locks, proof artifacts + `verified`, freeze export, ranged media | `architecture/living-notes-review.md` |
| Plugin discovery / agent images / context packs / Claude auto-compaction enforcer | `architecture/plugin-agent-image-context-pack.md` |
| Bundle assembly, bundle install / `/api/up`, BootstrapOrchestrator, legacy install seams | `architecture/packaging-bootstrap-bundles.md` |
| The 25 architecture rules / RigEvent union / SSE delivery / compatibility limits | `architecture/architecture-rules-and-event-system.md` |
@@ -180,6 +182,7 @@ Source-grounding for each claim lives inside the module with file:line cites.
| `architecture/transport-and-transcripts.md` | `packages/daemon/src/domain/{session-transport,transcript-store,history-query,ask-service,chat-repository}.ts`, `packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`, `packages/cli/src/mcp-server.ts` |
| `architecture/workspace-primitive.md` | `packages/daemon/src/domain/workspace/`, `packages/cli/src/commands/config-init-workspace.ts`, `packages/daemon/src/db/migrations/{038,039}*`, `packages/ui/src/routes.tsx` |
| `architecture/content-surfaces.md` | `packages/daemon/src/domain/{files,progress,steering}*`, `packages/daemon/src/routes/{files,progress,steering}.ts`, `packages/ui/src/routes.tsx` |
| `architecture/living-notes-review.md` | `packages/daemon/src/domain/review/{types,compose,gather,freeze,brief-spine}.ts`, `packages/daemon/src/routes/{review,files,slices}.ts`, `packages/cli/src/commands/{scope,proof}.ts`, `packages/ui/src/components/review/` |
| `architecture/plugin-agent-image-context-pack.md` | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/{agent-images,context-packs}/`, `packages/daemon/src/domain/claude-compaction-enforcer.ts` |
| `architecture/packaging-bootstrap-bundles.md` | `packages/daemon/src/domain/{pod-bundle-assembler,bootstrap-orchestrator,bundle-*,package-*,install-*}.ts`, `packages/daemon/src/routes/{bundles,up}.ts` |
| `architecture/architecture-rules-and-event-system.md` | `packages/daemon/src/domain/types.ts` (RigEvent union), `packages/daemon/src/routes/{stream,queue}.ts` (SSE watch), `packages/daemon/src/server.ts` (`/api/events`) |
+71 -15
View File
@@ -14,7 +14,7 @@ applies-when: |
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
last-updated: 2026-07-06
---
# UI Project Observability, For You, Dashboard
@@ -166,19 +166,29 @@ handoff]` — defined at the Mission Control system level (slice-00
> 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.
> **OPEN-3 RESOLVED (v0.4.4 living-notes corrective, founder ruling
> N-1, 2026-07-05).** The For-You actionable-card surface is **bare
> one-tap APPROVE + CHAT — nothing else**. `FeedCard` passes
> `enabledVerbs={["approve"]}` + `oneClickVerbs={["approve"]}` + the
> v0.4.4 `bare` prop (JSX-only: skips the "Choose response" header
> chrome; the mutation/receipt/error paths are byte-identical — pinned
> by a raw-body test), beside a CHAT button that opens the **shared
> `ProgressiveTerminal`** seeded via `buildChatPreamble` (terminal,
> never a chat panel — BR-12). Deny/route are RETIRED from this
> surface (including the action-required lens's empty-state copy:
> "one-tap approve and chat with the owning agent"); the card-level
> kind tag is the status label "Action required", never instruction
> chrome. The 7-verb vocabulary above remains the MISSION-CONTROL
> system-level vocabulary; For-You offers the two-action subset.
>
> Source @`bb5ad219`: `FeedCard.tsx:547-593` (bare `VerbActions`
> `enabledVerbs=["approve"]` :561-566 + chat button + inline
> `ProgressiveTerminal`), `:87-94` (`resolveCardTerminalSession` —
> human-action cards chat with the SENDER), `Feed.tsx:76-82`
> (`EMPTY_COPY["action-required"]`), `VerbActions.tsx` (`bare` prop),
> `review/chat.ts:21` (`buildChatPreamble`); tests
> `test/foryou-bare-approve-chat.test.tsx` (byte-identical mutation +
> chatBtns:1/denyRoute:0 + retired-copy source-scan).
On mutation success `VerbActions` fires `onOptimisticOutcome`; `Feed`
keeps an optimistic-outcome map keyed by `qitemId` so the
@@ -295,7 +305,8 @@ 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`.
workflow-instance-aware `TopologyTab`; **`review` (v0.4.4) renders the
Living Notes `SliceReviewTab`** (§3.5).
> Source: `components/project/ScopePages.tsx:1187-1297` (`SliceScopePage`;
> default `overview` `:1192`, timeline md `:1204`, loading-state
@@ -318,6 +329,51 @@ active tab (`active !== "overview"`) so the overview path stays cheap.
> rollup components `:219-532`; gate `:679`,`:754` (`active !== "overview"`)
> @HEAD.
### 3.5 Review tabs (v0.4.4 — the Living Notes surface)
Both the slice and mission scope pages mount a `Review` tab
(`SLICE_TABS`/mission tabs, `ScopePages.tsx:99-113`). The slice tab
(`review/SliceReviewTab.tsx`) renders the ONE reviewable structure per
slice — bands in order **NEEDS YOU → AGENTS → INTENT / PLAN / DELIVERED →
verify-lineage → SETTLED** — from `GET /api/review/slice/:name`
(`hooks/useReview.ts`):
- **DELIVERED** pairs each planned deliverable with its curated proof down
the column (planned mockup above delivered artifact), media inline at text
height expanding on tap one-at-a-time (`?item=` deep link); `verified`
renders QA's recorded comparison in plain words (✓ QA-verified · ◇
unverified · ✗ missing + QA's note) — visible, never blocking. "See all
proof" drills into `proof/`.
- **Media actually plays**: video renders inline with native controls
(`?seek`/`?play` deep links for capture verification); images open the
shipped `Lightbox`; evidence and the "full PRD →" door open the shipped
`FileViewer` in the shared **right-edge** drawer (the v0.4.4 corrective
reverted the FR-11.1 left flip at its three tokens).
- **Two locks** (§4 of the architecture module) render as deliberate stamps:
plan-lock in PLAN, both stamps in SETTLED; an unaudited stamp renders
loudly as UNVERIFIED.
- Review cards ride ONE vellum recipe (`review/vellum.ts` —
background-matched translucency + backdrop blur, token-driven for both
themes); DELIVERED rows stack vertically below the `sm` breakpoint
(phone-first single-column scan).
- The mission tab (`review/MissionReviewTab.tsx`) is board-first: stage
cells from the collapsed contract, the completion ledger, cut-complete,
and a U5 row expansion reading the SAME contract (intent verbatim + a
bounded per-item verified summary; the full pairing lives on the slice
page). The rig altitude (`review/RigAgentsPage.tsx`) reads
`GET /api/review/rig`.
Backend contract + composition: see
[`../architecture/living-notes-review.md`](../architecture/living-notes-review.md).
> Source @`bb5ad219`: `components/review/{SliceReviewTab,MissionReviewTab,
> NeedsYouAccordion,AgentsBandView,VerifyLineageCard,EvidenceOpener,
> RigAgentsPage}.tsx`, `review/vellum.ts`, `hooks/useReview.ts`,
> `SharedDetailDrawer.tsx` (right-edge anchor + pre-flip z);
> tests `test/{fileviewer-resolvable-target,drawer-primitives}.test.tsx`.
> The rejected three-column compare and the separate item-join table are
> DELETED files (corrective delete-not-demote; zero references remain).
## 4. Dashboard — the landing surface (`/` → `Dashboard`)
`Dashboard` is a thin composition over the **0.3.1 vellum brand
+1 -1
View File
@@ -311,7 +311,7 @@ This prompts the agent to actually invoke the skills, not just have them as pass
## System Check
After identity recovery, verify:
1. `rig ps --nodes` shows your rig running
1. `rig ps --nodes` shows your rig running (scoped to your session's rig by default; outside a managed session name it explicitly: `rig ps --nodes --rig <name>`)
2. `rig env status` shows services healthy (if applicable)
3. Your working directory is correct
4. Required tools are available (node, npm, git, etc.)
@@ -0,0 +1,132 @@
# Product-Factory VPS Runbook (product-factory-vps)
OPR.0.4.4.13 — the productized form of the 2026-06-23 OVH smoke test: a fresh
Ubuntu VPS becomes an OpenRig product factory managed from a home base, with
the proven security posture as the encoded default. The bootstrap is a
SCRIPT + THIS RUNBOOK by design — host verbs are capped at `add`/`list`/
`doctor`, and the acceptance anchor is the observable end state: home-base
`rig host doctor <id>` all green.
## 1. Prerequisites
- A fresh Ubuntu 22.04/24.04 VPS (any provider; the lived reference was OVH).
- A Tailscale tailnet you administer; the home base already on it.
- A home-base SSH keypair for the factory (`ssh-keygen -t ed25519 -f ~/.ssh/openrig-factory`).
- The published OpenRig artifact (`@openrig/cli@<version>`) or the packaged
tarball a release branch builds (`npm pack` output) for pre-release proof.
- Dedicated Claude/Codex accounts for THIS factory. Never copy home-base
credentials (`~/.claude`, `~/.codex`, `~/.openrig`) onto a VPS.
## 2. Tailscale tag + ACL (one-time per tailnet)
In the Tailscale admin console (the UI's words, mapped):
- **tag name**: `openrig-vps` (enter as `tag:openrig-vps` in ACL JSON).
- **tag owner**: your tailnet admin group/user (`"tagOwners": {"tag:openrig-vps": ["autogroup:admin"]}`).
- ACL: home base may reach `tag:openrig-vps` on 22/7433 over the tailnet;
`tag:openrig-vps` gets NO grants toward the home base (the reverse path is
the explicit recipe in §6, never standing policy).
- Auth keys for `tailscale up` must be EPHEMERAL or revoked after use.
## 3. Bootstrap (fresh VPS → factory-ready)
On the VPS, as root:
```bash
curl -fsSLO <your-copy-of>/scripts/bootstrap-product-factory-vps.sh
chmod +x bootstrap-product-factory-vps.sh
./bootstrap-product-factory-vps.sh \
--artifact @openrig/cli@<version> \
--authorized-key "$(cat ~/.ssh/openrig-factory.pub)" \
--ts-authkey tskey-... # ephemeral
```
The script is stepwise and loud: non-root `openrig` user (key-only) → sshd
hardening drop-in (`PermitRootLogin no`, `PasswordAuthentication no`,
`KbdInteractiveAuthentication no`, `X11Forwarding no`) → UFW default-deny +
tailnet ingress (public SSH stays open as a BOOTSTRAP ESCAPE HATCH only) →
Tailscale with `tag:openrig-vps`, no routes/exit-node/Tailscale SSH → Node 22 →
`npm install -g` of the artifact (must succeed with NO manual package
patching) → daemon + kernel.
On the home base:
```bash
rig host add --id ovh-01 --transport ssh --target <tailnet-alias> --user openrig
rig host doctor ovh-01 # expect 4/4 green
rig host doctor ovh-01 --posture product-factory-vps --public-addr <public-ip>
```
Then close the escape hatch on the VPS: `sudo ufw delete allow OpenSSH` and
make sure Tailscale SSH is off: `tailscale set --ssh=false`. Re-run the
posture check. UNKNOWN posture items are NOT passes — each carries the command
that verifies it from the right vantage.
## 4. Transport posture (the decided partition)
See the per-command transport table in `docs/as-built/cli-reference.md`
(§Cross-host execution). The partition IS the intended posture: ssh carries
interactive pane ops (`send`/`capture`); http-bearer carries daemon REST ops
(`up`/`down`/`launch`); `ps`/`whoami` follow the host's DECLARED transport;
fan-out (`--all-hosts`) is http-only. There is NO cross-transport fallback and
NO http parity for send/capture in 0.4.4 (parity = new attack surface with no
scope-locked need).
## 5. Safe UI access (recipe, not a verb)
The daemon binds loopback/tailnet — never public. View the factory UI through
a local tunnel (the smoke test's exact form):
```bash
ssh -N -L 127.0.0.1:17433:127.0.0.1:7433 <tailnet-alias>
# then open http://127.0.0.1:17433
```
Tear the tunnel down when done. Do not add public allow rules for :7433.
## 6. Restricted reverse path (supported recipe — NOT always-on)
Boundary first: home-base-initiated ONLY; no broad VPS→home SSH; the tunnel is
torn down after use. The factory cannot freely reach into the home base.
1. On the home base, create a dedicated key the FACTORY will use through the
reverse tunnel, and gate it with a forced command in
`~/.ssh/authorized_keys`:
```text
command="$HOME/.openrig/bin/openrig-reverse-gate",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA...factory-reverse-key
```
2. The gate script (generatable template — allowlists EXACTLY the four verbs
the smoke test proved; everything else is refused, fail-closed):
```bash
#!/usr/bin/env bash
# openrig-reverse-gate — forced-command allowlist for the reverse path.
set -euo pipefail
case "${SSH_ORIGINAL_COMMAND:-}" in
"rig send "*|"rig capture "*|"rig ps"*|"rig whoami"*)
exec $SSH_ORIGINAL_COMMAND ;;
*)
echo "refused: only rig send/capture/ps/whoami are allowed on this key" >&2
exit 77 ;;
esac
```
3. Open the reverse tunnel FROM the home base when you want the channel:
```bash
ssh -N -R 127.0.0.1:2222:127.0.0.1:22 <tailnet-alias> # home-base initiated
```
4. On the factory, agents reach home through it:
`ssh -p 2222 -i <factory-reverse-key> [email protected] rig send <session> "..."`.
5. Kill the tunnel after use. The REFUSAL of any out-of-allowlist verb is the
proof artifact that the gate works — test it on purpose.
## 7. Credential + teardown discipline
- Runtime credentials (Claude/Codex) live ON the VPS, provisioned for it —
never copied from the home host.
- Snapshot/backup expectations and clean teardown follow the provider's
tooling; `rig down --snapshot` before destroying a factory.
- Revoke the Tailscale node + one-time keys when a factory is retired.
+187
View File
@@ -0,0 +1,187 @@
# SDLC conventions — the markdown control plane
> **Succession rule (source of truth):** this document was derived from the
> corrective review-surface redesign spec
> (`CORRECTIVE-REDESIGN-review-surface-2026-07-05`, DRAFT v0.2, §2–§6,
> grounded against openrig main `c27f2aff`, 2026-07-05/06). **Once shipped,
> THIS repo document is the living SSOT for the SDLC conventions going
> forward**; the corrective spec is the historical design record. Scaffold
> templates, the advisory scope audit, the `mission-slice-sop` skill, and the
> shipped bootstrap overlay all point HERE — they must not restate this
> document in full (restatement is how drift is born).
OpenRig is a software factory the human steers from a high altitude. The human
records intent; agents turn intent into a plan, the plan into a build, the
build into proof. The Living Notes UI is a **plain projection of well-formed
markdown on disk** — agents change the files, the UI re-projects. These
conventions define "well-formed." Everything here is **advisory / fail-open
for agents**: nothing below blocks a write; the audit records and advises.
## 1. The flow in one pass
```
intent → mini-requirements + proof contract → (UI slices: mockups)
→ plan-lock (rig scope slice approve --scope spec)
→ build the LOCKED set
→ QA: mockup ↔ delivered VISUAL compare
→ proof drops (rig proof add <slice> …)
→ proof-lock (rig scope slice approve --scope delivery)
```
The human should almost never see something that doesn't match what was
planned — QA catches mismatches and kicks them back. When the human looks,
they map intent → plan → delivered at a glance, mostly by scanning screenshots
down a single column, and give the final 1% approval.
## 2. Slice artifact conventions (what the UI projects)
Each slice directory carries:
- **`README.md`** (or the slice body) opening with the three convention
sections, in this order and with these exact headings:
- **`## Intent`** — the recorded intent, verbatim. The UI projects this
text as the INTENT section.
- **`## Mini-requirements`** — the concise, one-glance requirement tier
(numbered list). This is the founder's first structured catchpoint;
approval starts here.
- **`## Proof contract`** — a checkbox list of promised deliverables (see
§3). The UI's DELIVERED section pairs each item with its proof.
- **`IMPLEMENTATION-PRD.md`** — the full PRD. It OPENS with the
mini-requirements; everything between intent and proof is **elastic**
(see §7 — for a small slice the mini-requirements may BE the whole PRD).
- **`proof/`** — the proof-artifact directory. Curated canonical evidence
lands here via `rig proof` with valid C1 headers (§5).
The three sections project into the UI's one review structure: a vertical
stack of **INTENT → PLAN → DELIVERED**. A slice missing a section still
renders (the projection degrades to a muted "—", never invents content) —
but it does not carry its weight in review.
## 3. The proof contract format
`## Proof contract` is a markdown checkbox list; each item is one promised
deliverable, written as an observable outcome:
```markdown
## Proof contract
- [ ] The consolidated `rig ps` default renders all rigs with a rollup footer — captured.
- [ ] UI: the slice review tab renders the three-section stack — screenshot vs the locked mockup.
```
- Each item is joined (by item text or 1-based index) to the proof artifacts
that evidence it — that pairing is what the DELIVERED section renders, so
the human never hunts through dozens of artifacts to find which one proves
what.
- **UI deliverables carry a planned mockup** (`plannedRef`): the planning
agent produces the mockup and attaches it to the locked set. A UI slice
with no mockup in its locked set is an incomplete plan. Non-UI slices
(backend, skills, markdown) have no mockup and no `plannedRef` — that is
not a gap and not a gate.
- A deliverable QA did not actually verify shows as `unverified`/`missing`
in the UI — visible, never hard-blocking.
## 4. The two locks (shipped verb — not new machinery)
Two deliberate stamps, both written by the SAME shipped verb
(`rig scope slice approve`, one daemon-side write path: frontmatter stamp +
append-only audit row land together):
- **Plan-lock:** `rig scope slice approve <slice> --scope spec` — "the PRD
matches my intent; THIS artifact set is what gets built." Pins the locked
artifact set (spec/PRD/mockups) out of everything else in the folder.
- **Proof-lock:** `rig scope slice approve <slice> --scope delivery`
(the default scope) — the terminal "this is done" sign-off; fires the
freeze.
Approval is freeze/sign-off — **never** proven-green. Proven-green requires a
recorded verdict (a C1 proof artifact, §5); presence of an approval stamp does
not assert the work was proven. `--on-behalf-of` records delegation honestly
(the actor stays the real invoking session).
## 5. Proof drops and the C1 header (closed sets)
Proof artifacts land in `proof/` via the shipped verb:
```bash
rig proof add <slice> \
--artifact-type qa \
--verdict PASS \
--candidate-sha <the-proven-tip> \
--money-evidence "one line of money evidence" \
--file <artifact.md> \
--evidences "1,3" \
--media "walk.webm,panel.png" \
--self-check "I looked at the captures; they show the claim"
```
The C1 header's five required fields: `slice`, `candidate_sha`,
`artifact_type`, `verdict`, `money_evidence`. Two **ratified closed sets**
(extending either is a convention change, not a local edit):
- `artifact_type`: `guard | qa | rev1-r1 | rev1-r2 | adjudication`
- `verdict`: `CLEAR | BLOCKING | CONCERNING | PASS | NOT-CLEAR`
`candidate_sha` is the join key: the proven candidate tip this artifact
judges. `--evidences` names which proof-contract deliverable(s) the drop
covers (item text or 1-based index) — that reference populates the
planned↔delivered pairing. `--self-check` is the agent's recorded assertion
that it LOOKED at the evidence. `--media` names the curated media files
(relative to the slice's `proof/` dir — co-located, never absolute) this
drop stands behind; the composer projects them into the DELIVERED items'
proof set. Validation happens at drop time; the audit
(§6) backstops artifacts that arrived by other paths. **Hand-placing files
in `proof/` without a drop is the anti-pattern**: the deliverable stays
unpaired and `unverified` in the DELIVERED view — always attach media via
`--media` on a drop.
## 6. Role contracts (what makes the structure self-enforcing)
- **Planning agent:** authors intent verbatim, the mini-requirements, and the
`## Proof contract` (each UI deliverable with its mockup `plannedRef`);
produces the mockups; locks the plan (`--scope spec`).
- **Build agent:** builds against the LOCKED set only; looks at the mockups,
not just the spec text.
- **QA agent (owns the compare):** for each deliverable — load the locked
`plannedRef`, produce the real artifact in a test/demo environment,
**visually compare**, record the verdict + note via a proof drop, and
**curate** the canonical proof set. On mismatch beyond minutiae: fix and
have another agent review, or kick back with the reason — never escalate a
raw mismatch to the human. A proof drop with no recorded comparison leaves
the deliverable `unverified` — visibly.
**Curation rule:** the primary proof set is the curated canonical "this is
what it looks like now" evidence the agent stands behind — bounded, mapped
1:1 (or few:1) to deliverables. The fix-loop's full artifact history stays in
`proof/`, one drill-in down, NEVER in the primary view. The anti-pattern:
an append-only pile where the human can't tell final from superseded.
## 7. The elastic middle (proportionality — no minted ceremony)
The SDLC has exactly three fixed capture points: **intent** → a
**proportional structured requirement** → **proof**. Everything between is
elastic. For a small slice (a bug fix, a research note), the
mini-requirements may BE the whole PRD — the convention sections must be
present so the slice projects, but their contents scale to the work. Gates
are losslessness checks on the decompression from intent to delivery, not
paperwork. Scaffolding emits the sections; it must never mint ceremony.
## 8. The audit (advisory, fail-open — always)
`rig scope audit` (and the advisory rows in `rig workspace validate` /
`doctor`) checks these conventions: the section headings present, the proof
contract well-formed, `proof/` artifacts carrying valid C1 headers, UI slices
referencing a mockup. Every finding **records and advises — it never blocks a
write path and never changes exit semantics into a gate**. Unknown is
reported as unknown, not failure.
## 9. Where the knowledge lives (the four pointers)
- **This document** — the SSOT.
- **Scaffold**: `rig scope slice create` emits the convention sections +
`proof/` + an `IMPLEMENTATION-PRD.md` skeleton for every template kind;
`rig scope mission create` emits the convention pointer.
- **Skill**: `mission-slice-sop` teaches the full flow (shipped in the
product skill tree and the bundled plugin, mechanically mirrored).
- **Bootstrap**: the shipped agent overlay points fresh seats at the skill
and this document at boot.
+131
View File
@@ -0,0 +1,131 @@
# OpenRig v0.4.4
## Summary — Multi-host + Living Notes
OpenRig v0.4.4 lands the multi-host foundation: a shared rig topology
can now span multiple hosts, with a staged spin-up flow, real cross-
host file movement, and a consolidated For-You activity feed that
merges signal across every host in the topology. The VPS product-
factory story hardens for real multi-host operation.
Alongside the multi-host foundation, this release ships the **Living
Notes signal layer** — a durable, agent-authored notes surface that
threads INTENT → PLAN → DELIVERED down the mission tree, with a
one-structure review contract that reads left-to-right as a single
vertical stack (INTENT above, PLAN in the middle, DELIVERED at the
bottom; delete-not-demote when a plan changes). Composer surfaces make
authoring cheap; the review surface makes reading intent-versus-build
one-glance.
Operator UX picks up **`rig ps` consolidated default** (the token-
efficient-defaults family gains progressive disclosure that keeps the
default view glanceable without hiding the fleet), an **agent altitude
coordination panel** that surfaces multi-agent state at the right
altitude for the operator, and the **operationalize-SDLC control
plane** which pulls the release / delivery / lifecycle levers into a
single control surface. As-built docs closeout catches everything up.
Migrations, if any, are additive. Existing v0.4.3 databases upgrade by
running `rig daemon start` on the new daemon.
## What Shipped
### Multi-host foundation
- **Shareable whole-topology staged spin-up** — a rig topology can be
brought up in stages across multiple hosts; the spec + the daemon
coordinate to reach a green whole-fleet state without requiring a
single-host bring-up. Prior to this release, a whole-topology spin-
up assumed a single host.
- **VPS product-factory multi-host hardening** — the shipped
`openrig-user`/`openrig-architect` operator flows work correctly
across VPS/self-hosted product-factory scenarios; multi-host
handshake + registration hardens.
- **Multi-host consolidated For-You feed** — the For-You feed
aggregates activity across all registered hosts in the topology, not
just the local daemon. The stream classifier + real-data fidelity
from 0.4.3 apply per-host and merge upstream. A real-host feed-
subscription capture ships as belt-and-suspenders proof (see
Known Follow-ons).
- **`rig file` cross-host movement** — files move across the
topology's registered hosts via the `rig file` surface. Real
registered-host two-host end-to-end round-trip proves the flow on
top of the VM stand-ins already merged.
### Living Notes
- **Signal layer** — a durable, agent-authored notes surface at
mission and slice altitude; INTENT / PLAN / DELIVERED entries thread
down the mission tree with agent authorship + timestamps.
- **Composer surfaces** — cheap authoring surfaces make Living Notes
the first-class place agents record decisions, plans, and delivered
work.
- **One-structure review contract (§3.1) — corrective rebuild** — the
Living Notes review surface reads left-to-right as a **single
vertical stack** (INTENT above, PLAN + mockup in the middle,
DELIVERED with paired proof at the bottom); a plan change **deletes**
the old plan and writes a new one — never demotes / stacks multiple
competing plans. The corrective rebuild replaced an earlier
three-column layout after human review.
### Operator UX
- **`rig ps` consolidated default + progressive disclosure** — the
default `rig ps` now consolidates the fleet's readable rows into a
single glanceable default; progressive disclosure via `--full` /
`-A` / per-scope filters keeps the rich per-node record retrievable.
Follows the token-efficient-defaults family established at 0.4.0.
- **Agent altitude coordination panel** — a coordination surface
scoped to the right altitude (workspace / mission / slice) so the
operator sees the actual coordination state without drowning in
per-seat detail. Composes with the workspace observability tabs
shipped at 0.4.1.
### Doctrine
- **Operationalize the SDLC control plane** — release / delivery /
lifecycle levers pull into a single surface. The lanes that shipped
0.4.1 → 0.4.3 (release-cut squash + bounded-diff, sidecar-operator
host cutover, curation pass) are now first-class control-plane
actions rather than lifecycle-tribal-wisdom.
- **As-built docs closeout** — the as-built documentation family
(`docs/as-built/architecture.md`, `docs/as-built/cli-reference.md`,
codemaps) is caught up to what shipped through 0.4.4.
## Behavior + Compatibility
- **Migrations** — additive only. Existing v0.4.3 databases upgrade
by running `rig daemon start` on the new daemon.
- **Multi-host registration** — the multi-host handshake is
backward-compatible with single-host deployments; single-host
operation is unchanged.
- **`rig ps` default view** — the consolidated default is a
compact readable view; `--full` returns the v0.4.3 default shape.
- **For-You feed** — multi-host feed aggregation is opt-in via
registered-host membership; a single-host topology sees no
behavior change.
## Known Follow-ons
- **R2 — iOS-Safari S20 (Living Notes composer)** — deferred to
0.4.5 for iOS Safari verification.
- **R4 — S18 symlink footgun** — routed to the 0.4.5+ backlog.
- **`openrig-user` stale plugin copy + `whoami --all-hosts` silent
host filter** — 0.4.5 candidates.
- **Wider mission-template prose sweep** — post-cut sequencing.
- **Belt-and-suspenders real-host proofs** — R13-1 (published-npm
Linux install smoke + daemon-start / `/healthz` / WebSocket
handshake), R1 (real registered-host two-host e2e for S13 + S18 on
top of the merged VM stand-ins), and R3 (S15 real-host feed-
subscription capture) are sequenced as post-publish lifecycle
validation lanes. The shipped code was already proven via VM/SSH
stand-ins per PM rulings; the real-host lanes are defense-in-depth
belt-and-suspenders proofs, not release gates.
## See Also
- `CHANGELOG.md` — the standard changelog entry for `0.4.4`.
- `docs/releases/v0.4.3.md` — the prior release ("rigs that
survive").
- `docs/releases/v0.4.2.md` — the daemon-status hotfix.
- `docs/releases/v0.4.1.md` — the workspace observability overhaul.
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.4.3",
"version": "0.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.4.3",
"version": "0.4.4",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7449,7 +7449,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.4.3",
"version": "0.4.4",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7481,7 +7481,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.4.3",
"version": "0.4.4",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7506,7 +7506,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.4.3",
"version": "0.4.4",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.4.3",
"version": "0.4.4",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.4.3",
"version": "0.4.4",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
+21
View File
@@ -0,0 +1,21 @@
// OPR.0.4.4.11 FR-6 — the CLI's build-identity module.
//
// COMMITTED DEV STUB: unstamped runs report absent identity — never an
// invented SHA. scripts/build-package.sh computes the stamp ONCE and
// overwrites the compiled counterpart (dist/build-info.js) at package time;
// this is the CLI's own generated twin of the daemon module (arch ruling 6 —
// per-package modules, no runtime cross-package import).
export interface BuildInfo {
semver: string | null;
commit: string | null;
dirty: boolean | null;
builtAt: string | null;
}
export const BUILD_INFO: BuildInfo = {
semver: null,
commit: null,
dirty: null,
builtAt: null,
};
@@ -463,8 +463,18 @@ slice: ${slice.id}
# ${slice.title}
## Intent
${slice.objective}
## Mini-requirements
1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`.
## Proof contract
- [ ] The work is visible in the Project slice — captured (see IMPLEMENTATION-PRD.md).
## Queue Mapping
Queue items for this slice should mention or tag:
@@ -511,15 +521,21 @@ slice: ${slice.id}
# ${slice.title} Implementation Notes
## Goal
## Intent
${slice.objective}
## Acceptance
## Mini-requirements
- [ ] The work is visible in the Project slice.
1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`.
## Proof contract
- [ ] The work is visible in the Project slice — captured.
- [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`.
- [ ] Any proof artifacts are referenced from the slice before closure.
- [ ] Proof artifacts are referenced from the slice before closure (drop via \`rig proof add\`).
> Conventions SSOT: \`docs/reference/sdlc-conventions.md\` — section names, proof-contract format, the two locks, C1 headers. For a small slice the mini-requirements may BE the whole PRD.
`;
}
+2 -2
View File
@@ -192,13 +192,14 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
});
// 7. Port availability (async) — daemon already running on that port counts as OK
const fetchFn = deps.fetch ?? globalThis.fetch;
const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise<DoctorCheck> => {
if (available) {
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` };
}
// Port in use — check if it's our daemon via healthz
try {
const res = await fetch(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
const res = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
if (res.ok) {
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} in use by OpenRig daemon.` };
}
@@ -215,7 +216,6 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
// 8. Daemon cmux control (async, only when shell cmux passed)
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
if (shellCmuxPassed) {
const fetchFn = deps.fetch ?? globalThis.fetch;
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
try {
const healthRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
+1 -1
View File
@@ -110,7 +110,7 @@ export function expandCommand(depsOverride?: StatusDeps): Command {
console.log("");
console.log(" Failed nodes cannot be relaunched directly because pod-aware launch");
console.log(" bypasses startup orchestration. Recover by:");
console.log(` 1. Verify other seats are healthy: rig ps --filter rigName=${rigId} --nodes`);
console.log(` 1. Verify other seats are healthy: rig ps --nodes --rig ${rigId}`);
if (data.podNamespace) {
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} ${data.podNamespace}`);
} else {
+93
View File
@@ -0,0 +1,93 @@
// OPR.0.4.4.18 — `rig file` (v0: ONE explicit verb, copy).
//
// Every transfer names its source, destination, and policy: <hostId>:<path>
// is remote (resolving the registry's ssh entries), a bare path is local,
// and NOTHING is inferred from cwd/env/session (FR-2). Existing destination
// files are OVERWRITTEN — v0 copy semantics, stated (arch R18-2); --dry-run
// is the guard rail. The security wall lives in lib/file-transfer.ts.
import { Command } from "commander";
import {
planFileCopy,
buildRsyncArgv,
runFileCopy,
type CopyPlan,
type PlanDeps,
} from "../lib/file-transfer.js";
export interface FileCommandDeps extends PlanDeps {
run?: typeof runFileCopy;
}
function operandLabel(side: CopyPlan["src"]): string {
return side.kind === "remote" ? `${side.host!.id}:${side.path}` : side.path;
}
export function fileCommand(deps: FileCommandDeps = {}): Command {
const file = new Command("file").description(
"Cross-host file movement over ssh/rsync (v0: one explicit verb — copy)",
);
file
.command("copy <src> <dst>")
.description(
"Copy one file. <hostId>:<absolute-path> = remote (ssh registry entry); bare path = local; local files with a colon need the ./ prefix. Valid shapes: local→remote, remote→local, local→local. An existing destination is OVERWRITTEN — preview with --dry-run.",
)
.option("--dry-run", "Print the exact planned transfer (src, dst, host, files/bytes) and move nothing")
.option("--json", "JSON output for agents")
.action(async (src: string, dst: string, opts: { dryRun?: boolean; json?: boolean }) => {
const planned = planFileCopy(src, dst, { dryRun: opts.dryRun, registryLoader: deps.registryLoader });
if (!planned.ok) {
if (opts.json) {
console.log(JSON.stringify({ ok: false, code: planned.code, error: planned.error }));
} else {
console.error(planned.error);
}
process.exitCode = 1;
return;
}
const plan = planned.plan;
const srcLabel = operandLabel(plan.src);
const dstLabel = operandLabel(plan.dst);
const run = deps.run ?? runFileCopy;
const result = await run(plan);
if (opts.json) {
console.log(
JSON.stringify({
ok: result.ok,
dryRun: plan.dryRun,
src: srcLabel,
dst: dstLabel,
failedStep: result.failedStep,
bytesTransferred: result.bytesTransferred ?? null,
filesTransferred: result.filesTransferred ?? null,
exitCode: result.exitCode,
error: result.ok ? null : result.stderr.trim() || `rsync failed (${result.failedStep})`,
hint: result.hint ?? null,
rsyncArgv: buildRsyncArgv(plan), // transparency for agents; the builder is closed
}),
);
if (!result.ok) process.exitCode = 1;
return;
}
if (result.ok) {
if (plan.dryRun) {
console.log(`DRY RUN — nothing moved. Planned transfer:`);
console.log(` ${srcLabel} → ${dstLabel}`);
if (result.filesTransferred !== undefined) console.log(` files: ${result.filesTransferred}${result.bytesTransferred !== undefined ? `, bytes: ${result.bytesTransferred}` : ""}`);
const itemized = result.stdout.split("\n").filter((l) => /^[<>ch.*][fdLDS]/.test(l));
for (const line of itemized) console.log(` ${line}`);
} else {
console.log(`Copied ${srcLabel} → ${dstLabel}${result.bytesTransferred !== undefined ? ` (${result.bytesTransferred} bytes)` : ""}`);
}
return;
}
console.error(`file copy failed [${result.failedStep}]: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
if (result.hint) console.error(`Hint: ${result.hint}`);
process.exitCode = 1;
});
return file;
}
+472
View File
@@ -0,0 +1,472 @@
// OPR.0.4.4.13 — the host-registry verbs. CAPPED at exactly three
// (add / list / doctor — arch R13-2a): no edit/remove/tunnel/bootstrap
// verbs; hand-editing hosts.yaml remains the path for everything else,
// and the bootstrap ships as script + runbook.
//
// Secret hygiene (FR-1): every render carries bearer POINTERS (env var /
// file NAMES) only — no code path in this file resolves a bearer value
// for display.
import { Command } from "commander";
import { existsSync } from "node:fs";
import { connect } from "node:net";
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, type HostEntry, type HttpHostEntry } from "../host-registry.js";
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
// ---------------------------------------------------------------------------
// rig host doctor — stepwise, honest, three-valued (FR-1 + FR-2).
// ---------------------------------------------------------------------------
export type CheckStatus = "pass" | "fail" | "unknown";
export interface CheckRow {
step: string;
status: CheckStatus;
detail: string;
/** Actionable next step; REQUIRED for fail, encouraged for unknown. */
fix?: string;
}
export interface DoctorDeps {
run: (host: HostEntry, argv: readonly string[]) => Promise<CrossHostResult>;
httpGet: (url: string, headers?: Record<string, string>) => Promise<{ status: number; body: string }>;
/** TCP connect probe: "open" (connected) | "closed" (refused/filtered/timeout) | "unknown" (probe itself failed). */
tcpProbe: (target: string, port: number, timeoutMs: number) => Promise<"open" | "closed" | "unknown">;
}
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
let cur: unknown = obj;
for (const part of path) {
if (typeof cur !== "object" || cur === null) return undefined;
cur = (cur as Record<string, unknown>)[part];
}
return typeof cur === "boolean" ? cur : undefined;
}
function tailscaleRunSshFromPrefs(text: string): boolean | undefined {
if (!text.trim().startsWith("{")) return undefined;
const parsed = JSON.parse(text) as unknown;
return nestedBoolean(parsed, ["RunSSH"])
?? nestedBoolean(parsed, ["Prefs", "RunSSH"])
?? nestedBoolean(parsed, ["CurrentProfile", "RunSSH"]);
}
function defaultDoctorDeps(): DoctorDeps {
return {
run: (host, argv) => runCrossHostCommand(host, argv),
httpGet: async (url, headers) => {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
return { status: res.status, body: await res.text() };
},
tcpProbe: (target, port, timeoutMs) =>
new Promise((resolve) => {
const sock = connect({ host: target, port, timeout: timeoutMs });
sock.on("connect", () => { sock.destroy(); resolve("open"); });
sock.on("timeout", () => { sock.destroy(); resolve("closed"); });
sock.on("error", () => { sock.destroy(); resolve("closed"); });
}),
};
}
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
* "remote rig binary missing/old". */
export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<CheckRow[]> {
const rows: CheckRow[] = [];
if (host.transport === "ssh") {
const reach = await deps.run(host, ["true"]);
if (!reach.ok && (reach.failedStep === "ssh-unreachable" || reach.failedStep === "permission-gate")) {
rows.push({
step: "transport-reachability",
status: "fail",
detail: `ssh to ${host.target} failed (${reach.failedStep}): ${reach.sshStderr.trim()}`,
fix: reach.failedStep === "permission-gate"
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
});
return rows; // later legs are meaningless without the shell
}
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
const version = await deps.run(host, ["rig", "--version"]);
if (!version.ok) {
rows.push({
step: "remote-rig-binary",
status: "fail",
detail: "reached the remote shell, but `rig --version` failed — remote rig binary missing or broken",
fix: "install the published artifact on the host: npm install -g @openrig/cli (see the product-factory runbook)",
});
return rows;
}
rows.push({ step: "remote-rig-binary", status: "pass", detail: `remote rig ${version.stdout.trim() || "(version unreadable)"}` });
const daemon = await deps.run(host, ["rig", "daemon", "status"]);
const daemonUp = daemon.ok && /running/i.test(daemon.stdout);
rows.push(daemonUp
? { step: "remote-daemon-health", status: "pass", detail: "remote daemon reports running" }
: {
step: "remote-daemon-health",
status: "fail",
detail: "SSH works and rig is installed, but the remote daemon is not running",
fix: "on the host: rig daemon start (then re-run doctor)",
});
if (!daemonUp) return rows;
const whoami = await deps.run(host, ["rig", "ps", "--json", "--limit", "5"]);
let identityOk = false;
if (whoami.ok) {
try { identityOk = typeof JSON.parse(whoami.stdout) === "object"; } catch { identityOk = false; }
}
rows.push(identityOk
? { step: "remote-identity", status: "pass", detail: "remote daemon identity/list resolves (rig ps --json parses)" }
: {
step: "remote-identity",
status: "fail",
detail: "daemon is up but remote identity/list did not resolve",
fix: "on the host: check `rig ps --json` directly; the daemon API may be unhealthy even though status says running",
});
return rows;
}
// http transport
const bearer = resolveRemoteBearer(host as HttpHostEntry);
if (!bearer.ok) {
rows.push({ step: "transport-reachability", status: "fail", detail: bearer.error, fix: "set the bearer env var / file the registry entry points at (pointer names: rig host list)" });
return rows;
}
try {
const health = await deps.httpGet(`${(host as HttpHostEntry).url}/healthz`, { Authorization: `Bearer ${bearer.token}` });
if (health.status === 401 || health.status === 403) {
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon reachable but rejected the bearer (HTTP ${health.status})`, fix: "registry is reachable but the token is wrong — rotate/set the bearer the entry points at" });
return rows;
}
if (health.status < 200 || health.status >= 300) {
rows.push({ step: "transport-reachability", status: "fail", detail: `healthz returned HTTP ${health.status}`, fix: "the daemon answered abnormally — check remote daemon logs" });
return rows;
}
rows.push({ step: "transport-reachability", status: "pass", detail: `healthz ok at ${(host as HttpHostEntry).url}` });
rows.push({ step: "remote-daemon-health", status: "pass", detail: "healthz IS the daemon health check on http transport" });
} catch (err) {
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
return rows;
}
rows.push({
step: "remote-rig-binary",
status: "unknown",
detail: "not determinable over http transport (no shell)",
fix: "verify via an ssh-transport entry for this host, or on the host directly: rig --version",
});
try {
const ps = await deps.httpGet(`${(host as HttpHostEntry).url}/api/ps`, { Authorization: `Bearer ${bearer.token}` });
rows.push(ps.status >= 200 && ps.status < 300
? { step: "remote-identity", status: "pass", detail: "authenticated daemon API answers (/api/ps)" }
: { step: "remote-identity", status: "fail", detail: `/api/ps returned HTTP ${ps.status}`, fix: "daemon healthz is up but the API is unhealthy — check remote daemon logs" });
} catch (err) {
rows.push({ step: "remote-identity", status: "fail", detail: `/api/ps unreachable: ${(err as Error).message}`, fix: "check remote daemon logs" });
}
return rows;
}
/** FR-2 — THE one built-in posture profile (arch R13-2b: a named constant
* table, no framework). Every item is three-valued; UNKNOWN is never
* smoothed to pass, and every non-pass carries its fix. */
export const POSTURE_PROFILE_ID = "product-factory-vps";
export async function postureCheck(
host: HostEntry,
deps: DoctorDeps,
opts: { publicAddr?: string } = {},
): Promise<CheckRow[]> {
if (host.transport !== "ssh") {
// Posture needs a shell. Report every item unknown — honestly.
return POSTURE_ITEM_IDS.map((id) => ({
step: id,
status: "unknown" as const,
detail: "posture checks need shell access; this host is registered with http transport",
fix: "register an ssh-transport entry for this host (or run the checks on the host per the runbook)",
}));
}
const rows: CheckRow[] = [];
const sh = async (cmd: string) => deps.run(host, ["sh", "-c", cmd]);
// 1. non-root openrig user
const idOut = await sh("id -u openrig 2>/dev/null");
if (idOut.ok && idOut.stdout.trim() !== "") {
rows.push(idOut.stdout.trim() === "0"
? { step: "nonroot-openrig-user", status: "fail", detail: "user 'openrig' resolves to uid 0", fix: "create a non-root openrig user per the runbook" }
: { step: "nonroot-openrig-user", status: "pass", detail: `user 'openrig' uid ${idOut.stdout.trim()}` });
} else {
rows.push({ step: "nonroot-openrig-user", status: "fail", detail: "user 'openrig' does not exist", fix: "adduser openrig + key-only SSH per the runbook" });
}
// 2+3. sshd effective config (root SSH off, password auth off)
const sshd = await sh("sudo -n sshd -T 2>/dev/null || sshd -T 2>/dev/null || true");
const sshdOut = sshd.ok ? sshd.stdout.toLowerCase() : "";
const sshdItem = (step: string, key: string, want: string): CheckRow => {
if (!sshdOut.includes(key)) {
return { step, status: "unknown", detail: "effective sshd config not readable from this vantage (sshd -T needs root)", fix: "on the host: sudo sshd -T | grep " + key };
}
return sshdOut.includes(`${key} ${want}`)
? { step, status: "pass", detail: `${key} ${want}` }
: { step, status: "fail", detail: `sshd -T reports ${key} != ${want}`, fix: `set ${key} ${want} in /etc/ssh/sshd_config.d/99-openrig-hardening.conf and reload sshd` };
};
rows.push(sshdItem("root-ssh-disabled", "permitrootlogin", "no"));
rows.push(sshdItem("key-only-ssh", "passwordauthentication", "no"));
// 4+5. UFW default-deny + tailnet ingress
const ufw = await sh("sudo -n ufw status verbose 2>/dev/null || true");
const ufwOut = ufw.ok ? ufw.stdout.toLowerCase() : "";
if (ufwOut.trim() === "") {
const unknownUfw = (step: string): CheckRow => ({ step, status: "unknown", detail: "ufw status not readable from this vantage (needs sudo)", fix: "on the host: sudo ufw status verbose" });
rows.push(unknownUfw("ufw-default-deny-incoming"));
rows.push(unknownUfw("tailnet-ingress-allowed"));
} else {
rows.push(/default:\s*deny\s*\(incoming/.test(ufwOut)
? { step: "ufw-default-deny-incoming", status: "pass", detail: "ufw default deny (incoming)" }
: { step: "ufw-default-deny-incoming", status: "fail", detail: "ufw incoming default is not deny", fix: "sudo ufw default deny incoming" });
// R2-B1: mentioning tailscale0 is NOT enough — a DENY on tailscale0
// must never pass. Pass requires an ALLOW IN rule on the interface.
const tailnetAllowIn = ufwOut.split("\n").some((line) => line.includes("tailscale0") && /allow\s+in/.test(line));
if (tailnetAllowIn) {
rows.push({ step: "tailnet-ingress-allowed", status: "pass", detail: "ufw has an ALLOW IN rule on tailscale0" });
} else if (ufwOut.includes("tailscale0")) {
rows.push({ step: "tailnet-ingress-allowed", status: "fail", detail: "tailscale0 appears in ufw rules but with no ALLOW IN rule (deny/other only)", fix: "sudo ufw allow in on tailscale0" });
} else {
rows.push({ step: "tailnet-ingress-allowed", status: "fail", detail: "no tailscale0 allow rule found", fix: "sudo ufw allow in on tailscale0" });
}
}
// 6. tailscale flags (no subnet routes / exit node / ts-SSH)
const ts = await sh("tailscale status --json 2>/dev/null || true");
const prefs = await sh("tailscale debug prefs --json 2>/dev/null || true");
let routes: number | undefined;
let exitNode: boolean | undefined;
let runSsh: boolean | undefined;
const unknownReasons: string[] = [];
try {
if (ts.ok && ts.stdout.trim().startsWith("{")) {
const parsed = JSON.parse(ts.stdout) as { Self?: { PrimaryRoutes?: unknown[]; ExitNodeOption?: boolean }; ExitNodeStatus?: unknown };
routes = parsed.Self?.PrimaryRoutes?.length ?? 0;
exitNode = (parsed.ExitNodeStatus !== undefined && parsed.ExitNodeStatus !== null) || parsed.Self?.ExitNodeOption === true;
} else {
unknownReasons.push("tailscale status unreadable");
}
} catch {
unknownReasons.push("tailscale status did not parse");
}
try {
runSsh = prefs.ok ? tailscaleRunSshFromPrefs(prefs.stdout) : undefined;
if (runSsh === undefined) unknownReasons.push("tailscale debug prefs unreadable");
} catch {
unknownReasons.push("tailscale debug prefs did not parse");
}
if ((routes ?? 0) > 0 || exitNode === true || runSsh === true) {
rows.push({
step: "tailscale-minimal-trust",
status: "fail",
detail: `subnet routes: ${routes ?? "unknown"}; exit node in use: ${exitNode ?? "unknown"}; Tailscale SSH enabled: ${runSsh ?? "unknown"}`,
fix: "remove advertised routes / exit-node use (tailscale set) and disable Tailscale SSH: tailscale set --ssh=false",
});
} else if (routes === 0 && exitNode === false && runSsh === false) {
rows.push({ step: "tailscale-minimal-trust", status: "pass", detail: "no advertised subnet routes; no exit-node use; Tailscale SSH disabled" });
} else {
rows.push({
step: "tailscale-minimal-trust",
status: "unknown",
detail: `tailscale posture not fully determined (${unknownReasons.join("; ")})`,
fix: "on the host: tailscale status --json && tailscale debug prefs --json; disable Tailscale SSH with tailscale set --ssh=false",
});
}
// 7. daemon bound loopback/tailnet — never public. R2-B1: a SPECIFIC
// public IP bind must fail exactly like a wildcard — pass is an
// ALLOWLIST (loopback or the tailnet CGNAT range 100.64.0.0/10), never
// "not wildcard".
const ss = await sh("ss -tln 2>/dev/null | grep ':7433' || true");
const ssOut = ss.ok ? ss.stdout : "";
const listenAddrs = ssOut
.split("\n")
.map((line) => line.match(/(\S+):7433\b/)?.[1])
.filter((a): a is string => a !== undefined);
if (listenAddrs.length === 0) {
rows.push({ step: "daemon-bind-not-public", status: "unknown", detail: "no listener on :7433 observed (daemon may be down)", fix: "start the daemon, then re-check: ss -tln | grep 7433" });
} else {
const isLoopbackOrTailnet = (addr: string): boolean => {
const a = addr.replace(/^\[|\]$/g, "");
if (a.startsWith("127.") || a === "::1") return true;
const m = a.match(/^100\.(\d+)\./);
return m !== null && Number(m[1]) >= 64 && Number(m[1]) <= 127;
};
const offending = listenAddrs.filter((a) => !isLoopbackOrTailnet(a));
rows.push(offending.length === 0
? { step: "daemon-bind-not-public", status: "pass", detail: `listener(s) bound loopback/tailnet only: ${listenAddrs.join(", ")}` }
: { step: "daemon-bind-not-public", status: "fail", detail: `daemon listens on a non-loopback/non-tailnet address: ${offending.join(", ")}`, fix: "bind the daemon to loopback/tailnet only (OPENRIG daemon host config); never expose :7433 publicly" });
}
// 8+9. public reachability probes — only meaningful with a known PUBLIC address.
if (opts.publicAddr) {
for (const [step, port] of [["public-daemon-port-unreachable", 7433], ["public-ssh-unreachable-or-accepted", 22]] as const) {
const probe = await deps.tcpProbe(opts.publicAddr, port, 5000);
rows.push(probe === "open"
? { step, status: "fail", detail: `${opts.publicAddr}:${port} is publicly reachable`, fix: "remove the public allow rule / firewall the port (the smoke-test hardening bar: public :22/:7433 time out)" }
: { step, status: "pass", detail: `${opts.publicAddr}:${port} not reachable (${probe})` });
}
} else {
const unknownProbe = (step: string, port: number): CheckRow => ({
step, status: "unknown",
detail: `public reachability of :${port} not probed (no public address known — the registry target may be a tailnet alias)`,
fix: `re-run with --public-addr <ip> to probe from this vantage`,
});
rows.push(unknownProbe("public-daemon-port-unreachable", 7433));
rows.push(unknownProbe("public-ssh-unreachable-or-accepted", 22));
}
return rows;
}
export const POSTURE_ITEM_IDS = [
"nonroot-openrig-user",
"root-ssh-disabled",
"key-only-ssh",
"ufw-default-deny-incoming",
"tailnet-ingress-allowed",
"tailscale-minimal-trust",
"daemon-bind-not-public",
"public-daemon-port-unreachable",
"public-ssh-unreachable-or-accepted",
] as const;
function renderRows(rows: CheckRow[], json: boolean | undefined): void {
if (json) {
console.log(JSON.stringify(rows));
return;
}
const glyph = { pass: "✓", fail: "✗", unknown: "?" } as const;
for (const r of rows) {
console.log(`${glyph[r.status]} ${r.step}: ${r.detail}${r.fix ? `\n fix: ${r.fix}` : ""}`);
}
}
function authPointer(h: HostEntry): string {
if (h.transport === "ssh") return "ssh-key";
return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—";
}
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
cmd
.command("add")
.description("Add a host entry (validated with the registry loader's own rules)")
.requiredOption("--id <id>", "Unique host id")
.requiredOption("--transport <transport>", "ssh or http")
.option("--target <target>", "SSH target (DNS name, ssh-config alias, or IP) — ssh transport")
.option("--user <user>", "SSH user — ssh transport")
.option("--url <url>", "Remote daemon base URL — http transport")
.option("--bearer-env <name>", "Env var NAME holding the bearer token — http transport (pointer, never a value)")
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (pointer, never a value)")
.option("--notes <text>", "Free-form operator note")
.option("--json", "JSON output")
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
const rawEntry: Record<string, unknown> = { id: opts.id, transport: opts.transport };
if (opts.target !== undefined) rawEntry["target"] = opts.target;
if (opts.user !== undefined) rawEntry["user"] = opts.user;
if (opts.url !== undefined) rawEntry["url"] = opts.url;
if (opts.bearerEnv !== undefined) rawEntry["bearer_env"] = opts.bearerEnv;
if (opts.bearerFile !== undefined) rawEntry["bearer_file"] = opts.bearerFile;
if (opts.notes !== undefined) rawEntry["notes"] = opts.notes;
const res = addHostEntry(rawEntry);
if (!res.ok) {
console.error(res.error);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry }));
return;
}
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
console.log(`Verify it: rig host doctor ${res.entry.id}`);
});
cmd
.command("list")
.description("List registered hosts (config pointers only — never secret values)")
.option("--json", "JSON output")
.action((opts: { json?: boolean }) => {
const registryPath = defaultHostRegistryPath();
if (!existsSync(registryPath)) {
if (opts.json) {
console.log(JSON.stringify([]));
return;
}
console.log(`No hosts registered in ${registryPath}. Add one: rig host add --id <id> --transport <ssh|http> ...`);
return;
}
const loaded = loadHostRegistry(registryPath);
if (!loaded.ok) {
console.error(loaded.error);
process.exitCode = 1;
return;
}
if (opts.json) {
// Entries as validated: bearer fields are NAMES by construction.
console.log(JSON.stringify(loaded.registry.hosts));
return;
}
if (loaded.registry.hosts.length === 0) {
console.log(`No hosts registered in ${registryPath}. Add one: rig host add --id <id> --transport <ssh|http> ...`);
return;
}
const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
console.log(`${pad("ID", 20)} ${pad("TRANSPORT", 10)} ${pad("TARGET", 36)} ${pad("AUTH", 24)} NOTES`);
for (const h of loaded.registry.hosts) {
console.log(`${pad(h.id, 20)} ${pad(h.transport, 10)} ${pad(hostDisplayTarget(h), 36)} ${pad(authPointer(h), 24)} ${h.notes ?? "—"}`);
}
});
cmd
.command("doctor")
.description("Stepwise host verification (+ --posture for the product-factory-vps baseline)")
.argument("<id>", "Registered host id")
.option("--posture <profile>", `Run the posture baseline (only built-in profile: ${POSTURE_PROFILE_ID})`)
.option("--public-addr <ip>", "Public address for the outside-vantage reachability probes (posture items 8-9)")
.option("--json", "JSON output")
.action(async (id: string, opts: { posture?: string; publicAddr?: string; json?: boolean }) => {
const deps: DoctorDeps = doctorDepsOverride ?? defaultDoctorDeps();
const loaded = loadHostRegistry();
if (!loaded.ok) {
console.error(loaded.error);
process.exitCode = 1;
return;
}
const resolved = resolveHost(loaded.registry, id);
if (!resolved.ok) {
// The DISTINCT "registry is wrong" error class.
console.error(`registry: ${resolved.error}`);
process.exitCode = 1;
return;
}
if (opts.posture !== undefined && opts.posture !== POSTURE_PROFILE_ID) {
console.error(`unknown posture profile '${opts.posture}'. The only built-in profile is: ${POSTURE_PROFILE_ID}`);
process.exitCode = 1;
return;
}
const rows = await doctorLegs(resolved.host, deps);
if (opts.posture) {
rows.push(...await postureCheck(resolved.host, deps, { publicAddr: opts.publicAddr }));
}
renderRows(rows, opts.json);
if (rows.some((r) => r.status === "fail")) process.exitCode = 1;
const unknowns = rows.filter((r) => r.status === "unknown").length;
if (!opts.json && unknowns > 0) {
console.log(`${unknowns} item${unknowns === 1 ? "" : "s"} UNKNOWN — unknown is not pass; see the fix lines to verify.`);
}
});
return cmd;
}
+344
View File
@@ -0,0 +1,344 @@
import fs from "node:fs";
import path from "node:path";
import { Command } from "commander";
import YAML from "yaml";
import { findSlice, resolveMissionsRoot } from "../lib/scope/scope-fs.js";
import { ScopeCliError } from "../lib/scope/types.js";
/**
* `rig proof` — the proof-drop write path (OPR.0.4.4.19 FR-8 + FR-11;
* conventions C1 + C2 + C8, D2 attestation).
*
* CLI-side filesystem only (plan-review + arch-lead confirmed): the drop
* validates the C1 header AT THE MOMENT THE EVIDENCE IS IN-HAND, writes the
* artifact into the slice's proof/ dir, and echoes the parsed header (the
* seat sees what the composer will see). No daemon involvement, no synthetic
* qitems, no DB writes.
*
* LOAD-BEARING boundaries:
* - Validation applies ONLY to drops made through this path. An artifact
* written by any other means (raw file write, existing workflows) is
* NEVER blocked at write time — the backstop is `rig scope audit`
* (FR-10), not a write-path gate on ordinary file I/O.
* - The D2 proof contract + self_check are AGENT JUDGMENT recorded here;
* the drop path ADVISES (exit 0) and never blocks on them. There is no
* configuration that makes any advisory blocking (BR-7).
*/
/** C1 ratified closed sets (BR-4 — extending them is a convention change
* owned by pm-lead, not a code decision). */
export const C1_ARTIFACT_TYPES = ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const;
export const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEAR"] as const;
/** Video extensions for the C8 UX advisory (screencast evidence). */
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv"]);
export interface C1Header {
slice: string;
candidate_sha: string;
artifact_type: string;
verdict: string;
money_evidence: string;
/** D2 optional attestation fields — advise-never-block. */
evidences?: string[];
self_check?: string;
}
export interface C1ValidationResult {
ok: boolean;
missing: string[];
invalid: Array<{ field: string; value: string; allowed: readonly string[] }>;
}
/** Validate the five required C1 fields + closed sets. Pure. */
export function validateC1Header(header: Partial<C1Header>): C1ValidationResult {
const missing: string[] = [];
for (const field of ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"] as const) {
const value = header[field];
if (typeof value !== "string" || value.trim().length === 0) missing.push(field);
}
const invalid: C1ValidationResult["invalid"] = [];
if (header.artifact_type && !(C1_ARTIFACT_TYPES as readonly string[]).includes(header.artifact_type)) {
invalid.push({ field: "artifact_type", value: header.artifact_type, allowed: C1_ARTIFACT_TYPES });
}
if (header.verdict && !(C1_VERDICTS as readonly string[]).includes(header.verdict)) {
invalid.push({ field: "verdict", value: header.verdict, allowed: C1_VERDICTS });
}
return { ok: missing.length === 0 && invalid.length === 0, missing, invalid };
}
/**
* Parse the pinned `## Proof contract` section out of a slice's
* IMPLEMENTATION-PRD.md (the C7 pinned name; PM-lane authored). Returns the
* promised items (checkbox-item form, one promised item per line), or null
* when the slice declares no contract (tier-1 degrade — zero noise).
*/
export function parseProofContract(prdContent: string): string[] | null {
const lines = prdContent.split("\n");
const start = lines.findIndex((l) => /^##\s+Proof contract\s*$/i.test(l.trim()));
if (start === -1) return null;
const items: string[] = [];
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i]!;
if (/^##\s/.test(line)) break; // next section
const m = line.match(/^\s*-\s*(?:\[[ xX]\]\s*)?(.+\S)\s*$/);
if (m) items.push(m[1]!);
}
return items;
}
function isVideoFile(filePath: string): boolean {
return VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
export function proofCommand(): Command {
const cmd = new Command("proof").description(
"Proof artifacts — drop gate/proof evidence into a slice's proof/ dir with the machine-readable C1 header, validated at the natural moment (OPR.0.4.4.19 FR-8). The drop is the SDLC's proof leg: --evidences joins it to the slice's ## Proof contract items (what the Living Notes DELIVERED section pairs + renders); proof-lock afterwards via rig scope slice approve --scope delivery. Conventions SSOT: docs/reference/sdlc-conventions.md."
);
cmd.option("--workspace <path>", "Override workspace root (else cwd walk or $OPENRIG_WORK_ROOT)");
cmd
.command("add <slice-path>")
.description("Drop a proof artifact: authors the C1 frontmatter from flags, writes <slice>/proof/<name>, echoes the parsed header. Contract/self-check/C8 outputs are advisories (exit 0) — never gates.")
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
.requiredOption("--artifact-type <type>", `C1 artifact_type, one of: ${C1_ARTIFACT_TYPES.join(" | ")}`)
.requiredOption("--verdict <verdict>", `C1 verdict, one of: ${C1_VERDICTS.join(" | ")}`)
.requiredOption("--candidate-sha <sha>", "C1 candidate_sha — the join key (convention C2): the proven candidate tip this artifact judges")
.requiredOption("--money-evidence <line>", "C1 money_evidence — the one line of money evidence")
.option("--slice-id <dot-id>", "C1 slice dot-ID (defaults to the slice frontmatter id)")
.option("--file <path>", "Artifact body from a file (mutually exclusive with --body)")
.option("--body <text>", "Artifact body inline (mutually exclusive with --file)")
.option("--name <filename>", "Artifact filename in proof/ (defaults to the --file basename, else <artifact-type>-<verdict>-<UTC>.md)")
.option("--evidences <refs>", "D2 attestation: comma-separated proof-contract item refs this artifact covers (item text or 1-based index)")
.option("--self-check <text>", "D2 attestation: the agent's assertion that it LOOKED at the evidence and confirmed it shows the claim")
.option("--media <refs>", "Corrective §3.4: comma-separated media refs (relative to the slice proof/ dir) this drop stands behind — appended to the artifact body as markdown refs so the composer curates them into delivered.items[].proof")
.option("--json", "JSON output for agents")
.action(async (slicePath: string, opts: {
mission?: string;
artifactType: string;
verdict: string;
candidateSha: string;
moneyEvidence: string;
sliceId?: string;
file?: string;
body?: string;
name?: string;
evidences?: string;
selfCheck?: string;
media?: string;
json?: boolean;
}, command: Command) => {
const json = Boolean(opts.json);
const advisories: string[] = [];
const warns: string[] = [];
try {
if (opts.file && opts.body) {
throw new ScopeCliError({
fact: "Both --file and --body were provided.",
consequence: "The artifact body is ambiguous.",
action: "Pass exactly one of --file <path> or --body <text>.",
});
}
const parentOpts = (command.parent?.opts() ?? {}) as { workspace?: string };
const missionsRoot = resolveMissionsRoot({ override: parentOpts.workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
// Resolve the artifact body.
let body = "";
if (opts.file) {
if (!fs.existsSync(opts.file)) {
throw new ScopeCliError({
fact: `--file ${opts.file} does not exist.`,
consequence: "No artifact body to drop.",
action: "Point --file at the evidence file, or use --body.",
});
}
body = fs.readFileSync(opts.file, "utf8");
} else if (opts.body) {
body = opts.body;
}
// Corrective §3.4 — attach curated media refs (proof/-relative) to
// the artifact body as markdown refs; the composer projects them
// into delivered.items[].proof for the covered deliverables. Same
// containment discipline as --name: nothing outside the slice dir.
const sliceProofDir = path.join(slice.absPath, "proof");
const mediaRefs = opts.media
? opts.media.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const mediaLines: string[] = [];
for (const ref of mediaRefs) {
if (path.isAbsolute(ref)) {
throw new ScopeCliError({
fact: `--media ref '${ref}' is absolute.`,
consequence: "The artifact was NOT dropped — proof media is co-located slice content (FR-5), referenced relative to the slice's proof/ dir.",
action: "Copy the media into the slice's proof/ dir and pass the relative name.",
});
}
const resolved = path.resolve(sliceProofDir, ref);
if (!resolved.startsWith(path.resolve(slice.absPath) + path.sep)) {
throw new ScopeCliError({
fact: `--media ref '${ref}' resolves outside the slice dir.`,
consequence: "The artifact was NOT dropped — out-of-slice media can never be served or frozen with the review (FR-5).",
action: "Move the media under the slice dir (proof/ is the natural home) and re-run.",
});
}
if (!fs.existsSync(resolved)) {
warns.push(`--media ref '${ref}' does not exist yet (${resolved}) — the review will show it as unavailable until the file lands`);
}
const ext = path.extname(ref).toLowerCase();
if (VIDEO_EXTENSIONS.has(ext)) mediaLines.push(`<video src="${ref}"></video>`);
else mediaLines.push(`![${ref}](${ref})`);
}
if (mediaLines.length > 0) {
body = `${body.trimEnd()}\n\n## Media\n\n${mediaLines.join("\n")}\n`;
}
// Author the C1 header from flags + slice identity.
const header: Partial<C1Header> = {
slice: opts.sliceId ?? (typeof slice.id === "string" ? slice.id : undefined),
candidate_sha: opts.candidateSha,
artifact_type: opts.artifactType,
verdict: opts.verdict,
money_evidence: opts.moneyEvidence,
};
const evidences = opts.evidences
? opts.evidences.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
if (evidences && evidences.length > 0) header.evidences = evidences;
if (opts.selfCheck) header.self_check = opts.selfCheck;
// Validate the closed sets AT DROP TIME (while the evidence is
// in-hand) — the one REJECTING validation this path performs.
const validation = validateC1Header(header);
if (!validation.ok) {
const parts: string[] = [];
if (validation.missing.length > 0) parts.push(`missing required C1 field(s): ${validation.missing.join(", ")}`);
for (const inv of validation.invalid) {
parts.push(`${inv.field}='${inv.value}' is not in the ratified closed set (${inv.allowed.join(" | ")})`);
}
throw new ScopeCliError({
fact: `C1 header invalid — ${parts.join("; ")}.`,
consequence: "The artifact was NOT dropped (post-hoc reconstruction is the failure mode this fights).",
action: "Provide the named fields with allowed values and re-run while the evidence is in-hand. Extending the closed sets is a pm-lead convention change (BR-4).",
});
}
// D2 — validate evidences refs against the slice's declared proof
// contract (unknown refs = a named WARN, never a rejection), and
// emit the coverage/self_check ADVISORY when a contract exists.
const prdPath = path.join(slice.absPath, "IMPLEMENTATION-PRD.md");
const contractItems = fs.existsSync(prdPath)
? parseProofContract(fs.readFileSync(prdPath, "utf8"))
: null;
let coveredItems: string[] = [];
if (contractItems && contractItems.length > 0) {
if (evidences && evidences.length > 0) {
for (const ref of evidences) {
const byIndex = /^\d+$/.test(ref) ? contractItems[Number.parseInt(ref, 10) - 1] : undefined;
const byText = contractItems.find((item) => item === ref);
const match = byText ?? byIndex;
if (match) coveredItems.push(match);
else warns.push(`evidences ref '${ref}' matches no declared proof-contract item (known items: ${contractItems.map((_, i) => i + 1).join(", ")} or exact text)`);
}
}
if (coveredItems.length === 0 || !header.self_check) {
const uncovered = contractItems.filter((item) => !coveredItems.includes(item));
const reasons: string[] = [];
if (coveredItems.length === 0) reasons.push("this drop covers no declared contract item");
if (!header.self_check) reasons.push("self_check attestation omitted");
advisories.push(
`ADVISORY (D2, advise-never-block): ${reasons.join(" and ")}. ` +
`Uncovered contract item(s): ${uncovered.map((u) => `"${u}"`).join(", ")}. ` +
`The Packet-2 promised→delivered join will show these as MISSING (the ▲ insufficient-proof signal).`
);
}
}
// FR-11 / C8 — the UX-slice video advisory (SHOULD/steer, exit 0,
// no configuration can make it blocking). Trigger: slice frontmatter
// ux-change: true (spec-time flag; never a qitem tag, never
// diff-inference). Satisfied when this drop is a video or the
// proof/ dir already holds one.
const uxChange = slice.frontmatter["ux-change"] === true;
if (uxChange) {
const proofDir = path.join(slice.absPath, "proof");
const existingVideo = fs.existsSync(proofDir)
&& fs.readdirSync(proofDir).some((f) => isVideoFile(f));
const droppingVideo = (opts.file ? isVideoFile(opts.file) : false) || mediaRefs.some((r) => isVideoFile(r));
if (!existingVideo && !droppingVideo) {
advisories.push(
"ADVISORY (C8, SHOULD/steer): this slice is UX-tagged (ux-change: true) and its proof set has no video. " +
"UX-change slices SHOULD produce screenshot + video together — capture a screencast via the agent-browser-screencast method " +
"and hold it to the money-shot-edit bar. This never blocks a drop."
);
}
}
// Write the artifact: YAML frontmatter + body into proof/.
const proofDir = path.join(slice.absPath, "proof");
const defaultName = `${opts.artifactType}-${opts.verdict}-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
const fileName = opts.name ?? (opts.file ? path.basename(opts.file) : defaultName);
// rev1-r2 BLOCKING fix (a7dedd93 review): --name is a FILENAME, never
// a path. Reject separators / dot-dot / absolute shapes BEFORE any
// filesystem effect, so the drop can only land inside proof/ (the
// FR-8 contract) — a traversal name like ../README.md must not reach
// slice control files.
if (fileName.includes("/") || fileName.includes("\\") || fileName.startsWith("..") || path.isAbsolute(fileName)) {
throw new ScopeCliError({
fact: `--name '${fileName}' is not a plain filename (path separators, '..', and absolute paths are rejected).`,
consequence: "The artifact was NOT dropped — proof drops land inside the slice proof/ dir only (FR-8).",
action: "Pass a bare filename like qa-clear.md; the drop path owns the directory.",
});
}
const target = path.resolve(proofDir, fileName);
// Defense-in-depth: even a name that slips the shape check must
// resolve INSIDE proof/ (same containment discipline as
// scope-approve's path-escape guard).
if (!target.startsWith(path.resolve(proofDir) + path.sep)) {
throw new ScopeCliError({
fact: `--name '${fileName}' resolves outside the slice proof/ dir.`,
consequence: "The artifact was NOT dropped.",
action: "Pass a bare filename; the drop path owns the directory.",
});
}
fs.mkdirSync(proofDir, { recursive: true });
const frontmatter = YAML.stringify(header).trimEnd();
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, "utf8");
// Echo the parsed header — the seat sees what the composer will see.
const echo = {
dropped: path.relative(process.cwd(), target),
header: header as C1Header,
contractItemsDeclared: contractItems?.length ?? 0,
contractItemsCovered: coveredItems,
mediaRefs,
warnings: warns,
advisories,
};
if (json) {
console.log(JSON.stringify(echo, null, 2));
} else {
console.log(`Dropped: ${echo.dropped}`);
console.log(`Parsed C1 header:\n${frontmatter}`);
if (contractItems) console.log(`Proof contract: ${coveredItems.length}/${contractItems.length} item(s) covered by this drop.`);
for (const w of warns) console.error(`warning: ${w}`);
for (const a of advisories) console.error(a);
}
// Advisories + warns NEVER change the exit code (BR-7).
} catch (err) {
if (err instanceof ScopeCliError) {
if (json) {
console.log(JSON.stringify({ ok: false, error: { fact: err.fact, consequence: err.consequence, action: err.action } }, null, 2));
} else {
console.error(`${err.fact}\n${err.consequence}\n${err.action}`);
}
process.exitCode = 1;
return;
}
throw err;
}
});
return cmd;
}
+376 -124
View File
@@ -7,6 +7,7 @@ import { loadHostRegistry, resolveHost, hostDisplayTarget, resolveRemoteBearer,
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import type { AggregatedPayload, PerHostStatus } from "../lib/hosts/fanout-contract.js";
interface PsEntry {
rigId: string;
@@ -24,6 +25,9 @@ interface PsEntry {
* derived from tmux output. */
hasWorkCount?: number;
status: "running" | "partial" | "stopped";
/** OPR.0.4.4.21 — additive: seats needing attention (lifecycle/startup
* attention, needs_input, held, startup error) folded daemon-side. */
attentionCount?: number;
lifecycleState?: "running" | "recoverable" | "stopped" | "degraded" | "attention_required";
uptime: string | null;
latestSnapshot: string | null;
@@ -145,6 +149,7 @@ const NUMERIC_OPERATORS: NumericComparator[] = [">=", "<=", ">", "<", "="];
// rig-level only; `rigName` is the alias and exists at both levels (per the
// closed `rig ps trust and scale-safety` slice at openrig 0a9fb43).
const ALLOWED_RIG_FIELDS = new Set([
"attentionCount",
"rigId",
"name",
"rigName",
@@ -338,6 +343,56 @@ function parseFields(input: string, allowed: Set<string>, level: "rig" | "nodes"
return fields;
}
interface ParsedPsControls {
parsedFilter: ParsedFilter | null;
limit: number | null;
fields: string[] | null;
useEnvelope: boolean;
}
function parsePsControls(opts: PsCliOptions): ParsedPsControls | { error: string } {
let effectiveFilter = opts.filter;
if (opts.active) {
if (effectiveFilter) {
return {
error:
`--active and --filter cannot be combined. ` +
`--active is sugar for --filter agentActivity.state=running. ` +
`Pick one form, or compose by upgrading to --filter directly.`,
};
}
effectiveFilter = "agentActivity.state=running";
}
let parsedFilter: ParsedFilter | null = null;
if (effectiveFilter) {
const result = parseFilter(effectiveFilter);
if ("error" in result) return result;
parsedFilter = result;
}
const limit = opts.limit !== undefined ? Number(opts.limit) : null;
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) {
return { error: `--limit must be a non-negative integer; got '${opts.limit}'` };
}
let fields: string[] | null = null;
if (opts.fields !== undefined) {
const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig";
const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS;
const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel);
if ("error" in fieldsResult) return fieldsResult;
fields = fieldsResult;
}
return {
parsedFilter,
limit,
fields,
useEnvelope: parsedFilter !== null || limit !== null || fields !== null || opts.summary === true,
};
}
function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
return entries.filter((e) => {
if (filter.key === "status") return e.status === filter.value;
@@ -390,6 +445,71 @@ 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 fanOutNodesError(): string {
return [
"rig ps --all-hosts/--hosts --nodes: per-node fan-out requires the FULL explicit ladder.",
" rig ps --all-hosts --nodes -A (fleet nodes per host, projected rows)",
" rig ps --all-hosts --nodes -A --full (complete per-node records — the last rung)",
"One rig's seats on one host: rig ps --host <id> --nodes --rig <name>.",
].join("\n");
}
/**
* OPR.0.4.4.21 FR-2/FR-3 — THE centralized disclosure-ladder validator.
* Runs BEFORE the local/HTTP-host/SSH/fan-out dispatch split so every path
* obeys one grammar (qa1 plan-review: remote mode bypassed the session-rig
* injection and fanned wide). Principle (arch-ratified): IMPLICIT SCOPE
* DEFAULTS DON'T CROSS HOST BOUNDARIES — the session-rig default has no
* stable referent on a remote host (a same-named rig there would silently
* misresolve), so remote per-node views are explicit-or-error.
* Returns the teaching error text, or null when the invocation is valid.
*/
export function validatePsLadder(opts: PsCliOptions, callerRig: string | undefined): string | null {
const isFanOut = !!(opts.allHosts || opts.hosts);
const isSingleRemote = !!opts.host;
// FR-3: -A/--all-rigs has exactly ONE meaning — the --nodes fleet widener.
if (opts.allRigs && !opts.nodes) {
return [
"rig ps -A: '-A/--all-rigs' now has exactly one meaning — the --nodes fleet widener.",
"The consolidated all-rigs view IS the default: just run 'rig ps'.",
"Fleet nodes: rig ps --nodes -A (add --full for complete per-node records).",
"Archived history: rig ps --include-archived.",
].join("\n");
}
// FR-5: multi-host fan-out is rollup-only UNLESS the full explicit
// ladder is requested — `--nodes -A` per host (`--full` for complete
// records), exactly as the PRD writes it. Anything less explicit under
// --nodes errors; never silent rig-tier data under a --nodes flag.
if (opts.nodes && isFanOut && !opts.allRigs) {
return fanOutNodesError();
}
// FR-2: --nodes always names its scope — session default locally,
// explicit --rig / -A everywhere else. Never an implicit fan-out.
if (opts.nodes && !opts.rig && !opts.allRigs) {
if (isFanOut) {
return fanOutNodesError();
}
if (isSingleRemote) {
return [
`rig ps --host ${opts.host} --nodes: no target — the local session's rig is not a remote scope`,
"(implicit scope defaults don't cross host boundaries; a same-named remote rig would silently misresolve).",
`Name one: rig ps --host ${opts.host} --nodes --rig <name>, or that host's fleet explicitly: rig ps --host ${opts.host} --nodes -A.`,
].join("\n");
}
if (!callerRig) {
return [
"rig ps --nodes: no target — outside a managed session there is no current rig to default to.",
"Name one: rig ps --nodes --rig <name>, or go fleet-wide explicitly: rig ps --nodes -A.",
].join("\n");
}
}
return null;
}
function psApiPath(opts: PsCliOptions): string {
return opts.includeArchived ? "/api/ps?includeArchived=true" : "/api/ps";
}
@@ -402,6 +522,13 @@ function selectFields<T extends Record<string, unknown>>(entries: T[], fields: s
});
}
function selectFanOutFields(entries: Array<Record<string, unknown>>, fields: string[]): Array<Record<string, unknown>> {
return entries.map((e) => {
const [selected] = selectFields([e], fields);
return { ...selected, hostId: e.hostId };
});
}
function needsAttention(node: NodeEntry): boolean {
return node.lifecycleState === "attention_required"
|| node.startupStatus === "attention_required"
@@ -508,22 +635,39 @@ function abbrevNodeLifecycle(state: NodeEntry["lifecycleState"] | undefined): st
}
/**
* `rig ps` — list running rigs and optionally their nodes.
* `rig ps` — the consolidated fleet map + explicit disclosure ladder.
*
* L3-followup adds CLI-side shaping flags (`--limit`, `--fields`, `--summary`,
* `--filter`, `--full`) for context-window-safe output on large hosts. Default
* `rig ps` (human) truncates to ~50 rigs with an explicit footer naming totals
* and the `--full` opt-out. Default `rig ps --json` keeps the bare-array shape
* for back-compat; the truncation/envelope shape only applies when at least
* one of `--limit`/`--summary`/`--fields`/`--filter` is specified.
* OPR.0.4.4.21: the default is ALL active rigs, one compact O(rigs) row each
* (the current-rig-only default is retired). The token-safety invariant: no
* invocation returns per-node detail across all rigs unless explicitly
* flagged (--nodes -A), and even then rows are projected unless --full.
* Default `rig ps --json` keeps the bare-array shape for back-compat; the
* truncation/envelope shape only applies when at least one of
* `--limit`/`--summary`/`--fields`/`--filter` is specified.
*/
export function psCommand(depsOverride?: PsDeps): Command {
const cmd = new Command("ps")
.description("List rigs and their status")
.addHelpText("after", `
Default: current-rig scope (derived from OPENRIG_SESSION_NAME's @<rig> suffix).
Shows ALL states (stopped, recoverable, attention, idle — non-running IS the
actionable signal for topology/readiness). Use -A/--all-rigs for fleet breadth.
Default (OPR.0.4.4.21): ALL active rigs, ONE compact row each — O(rigs), never
a fleet node fan-out — plus the host rollup line ("N rigs · M seats · K need
attention"), the archived/stopped count line, and the drill-ladder footer.
STATED contract: default --json is a bare array of ALL non-archived rigs
INCLUDING stopped ones (existing keys preserved; additive attentionCount);
only the HUMAN table folds stopped rigs into the count line.
The disclosure ladder (each heavier view is an explicit step):
rig ps the consolidated map (this default)
rig ps --rig <name> one rig's detail
rig ps --nodes per-node, CURRENT rig (session default, local only)
rig ps --nodes --rig <name> per-node, named rig
rig ps --nodes -A fleet nodes, projected rows
rig ps --nodes -A --full complete per-node records (the only full fan-out)
-A/--all-rigs has exactly ONE meaning: the --nodes fleet widener. Bare -A
errors (all-rigs IS the default; archived history stays behind
--include-archived). The session-rig default never crosses host boundaries:
remote --nodes requires an explicit --rig or -A.
Compact defaults: 'rig ps --nodes' shows a compact summary per node
(rig, session, lifecycle, activity state, reason when attention, queue counts,
@@ -537,11 +681,10 @@ null even with --full; fetch the full recovery guidance + currentUsage from the
single-node detail (/api/rigs/:rigId/nodes/:logicalId) or 'rig whoami'.
Examples:
rig ps Current rig summary (compact)
rig ps -A All rigs (fleet breadth)
rig ps --full Current rig, full detail
rig ps --json Current rig, compact JSON
rig ps -A --json All rigs, compact JSON
rig ps All active rigs, one row each + rollup
rig ps --rig <name> One rig's detail
rig ps --full All rigs, no table truncation
rig ps --json All non-archived rigs, bare JSON array
rig ps --json --limit 20 Bounded JSON envelope
rig ps --json --summary Aggregate-only JSON
rig ps --json --fields rigName,status,lifecycleState
@@ -561,12 +704,16 @@ Examples:
rig ps --nodes --running Same as --active
rig ps --nodes --filter agentActivity.state=running
Same as --active (the explicit form)
rig ps --host vm-claude-test --nodes --json Remote host (no current-rig default)
rig ps --host vm-1 --nodes --rig <name> --json Remote host per-node (explicit target required)
rig ps --all-hosts Per-host O(rigs) rollups (AggregatedPayload JSON)
rig ps --include-archived Include archived rigs (marked with *); hidden by default
--rig <name> overrides the current-rig default. -A/--all-rigs disables it.
--session <name> filters within the effective rig scope; use -A if the target
session is in a different rig.
--rig <name> scopes to one rig. -A/--all-rigs is ONLY the --nodes fleet widener.
--session <name> filters within the effective rig scope; use --nodes -A if the
target session is in a different rig.
Multi-host fan-out (--all-hosts/--hosts) is rollup-only by default; the full
explicit ladder (--all-hosts --nodes -A, --full for complete records) fans out
per-node with hostId-stamped projected rows.
--active/--running narrow to agentActivity.state=running. Cannot combine with --filter.
@@ -574,7 +721,8 @@ session is in a different rig.
contextUsage.percent, contextUsage.state. Other keys are rejected.
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
activeCount, hasWorkCount, status, lifecycleState, uptime, latestSnapshot.
activeCount, hasWorkCount, attentionCount, status, lifecycleState, uptime,
latestSnapshot.
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
podNamespace, canonicalSessionName, nodeKind, runtime, sessionStatus,
startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
@@ -612,15 +760,53 @@ Exit codes:
if (opts.verbose) opts.full = true;
if (opts.running) opts.active = true;
const isRemote = !!(opts.host || opts.allHosts || opts.hosts);
if (!opts.allRigs && !opts.rig && !isRemote) {
// OPR.0.4.4.21 FR-1: the rig tier is consolidated ALL-ACTIVE-RIGS by
// default (the current-rig-only default is RETIRED — it hid running
// rigs from the operator's field of view). The session-rig default
// now applies ONLY to the node tier (FR-2's scoped --nodes), and
// ONLY locally (the ladder validator enforces explicit-or-error on
// every remote path).
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
const callerRig = sessionName ? extractRigName(sessionName) : undefined;
if (callerRig) opts.rig = callerRig;
const ladderError = validatePsLadder(opts, callerRig);
if (ladderError) {
console.error(ladderError);
process.exitCode = 1;
return;
}
if (opts.nodes && !opts.allRigs && !opts.rig && !isRemote && callerRig) {
opts.rig = callerRig;
}
const deps = getDepsF();
// OPR.0.4.4.21 rev1-r2 fixback: the shared shaping controls
// (--active/--filter/--limit/--fields/--summary) parse + validate
// BEFORE any dispatch — local, single-host, or fan-out — so every
// path honors the same composition contract and rejections stay
// pre-HTTP on remote paths too.
// Parse shared composition controls once up front so local, single-host,
// and fan-out paths reject malformed --filter/--limit/--fields before
// any HTTP call and apply the same shaping semantics.
const controls = parsePsControls(opts);
if ("error" in controls) {
console.error(controls.error);
process.exitCode = 1;
return;
}
const { parsedFilter, limit, fields, useEnvelope } = controls;
if (opts.allHosts || opts.hosts) {
await runFanOutPs(opts, deps);
// AggregatedPayload is the closed fan-out contract (items + hosts).
// Do not add an ad-hoc summary member here; teach the per-host form.
if (opts.summary) {
console.error(
"rig ps --all-hosts/--hosts --summary: summary does not compose with the merged fan-out payload.\n" +
"Summarize one host: rig ps --host <id> --summary; or drop --summary for the merged AggregatedPayload.",
);
process.exitCode = 1;
return;
}
await runFanOutPs(opts, deps, { parsedFilter, limit, fields });
return;
}
@@ -636,62 +822,6 @@ Exit codes:
return;
}
// Parse filter once up front so unknown keys/malformed filters surface
// as exit-1 errors before any HTTP call.
//
// PL-019 item 1: --active is sugar for --filter agentActivity.state=running.
// Combining --active with --filter is rejected (composition is
// ambiguous — pick one explicit form). Parity is verified end-to-end
// by the focused test: `--active` and the explicit filter must yield
// identical output on the same fixture.
let effectiveFilter = opts.filter;
if (opts.active) {
if (effectiveFilter) {
console.error(
`--active and --filter cannot be combined. ` +
`--active is sugar for --filter agentActivity.state=running. ` +
`Pick one form, or compose by upgrading to --filter directly.`
);
process.exitCode = 1;
return;
}
effectiveFilter = "agentActivity.state=running";
}
let parsedFilter: ParsedFilter | null = null;
if (effectiveFilter) {
const result = parseFilter(effectiveFilter);
if ("error" in result) {
console.error(result.error);
process.exitCode = 1;
return;
}
parsedFilter = result;
}
const limit = opts.limit !== undefined ? Number(opts.limit) : null;
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) {
console.error(`--limit must be a non-negative integer; got '${opts.limit}'`);
process.exitCode = 1;
return;
}
// C9a: validate --fields against the per-level allow-list before any HTTP
// call, mirroring the --filter validation above. Level is determined by
// --nodes; both branches downstream use the same `fields` variable, so
// one up-front validation covers both paths.
let fields: string[] | null = null;
if (opts.fields !== undefined) {
const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig";
const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS;
const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel);
if ("error" in fieldsResult) {
console.error(fieldsResult.error);
process.exitCode = 1;
return;
}
fields = fieldsResult;
}
const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true;
const client = deps.clientFactory(getDaemonUrl(status));
if (opts.nodes) {
@@ -699,7 +829,10 @@ Exit codes:
return;
}
const res = await client.get<PsEntry[]>(psApiPath(opts));
// OPR.0.4.4.21 FR-1: ONE O(rigs) fetch including archived so the
// count line can be computed; visibility is split client-side below
// (JSON keeps today's default of excluding archived — parity).
const res = await client.get<PsEntry[]>("/api/ps?includeArchived=true");
if (res.status >= 400) {
console.error(`Failed to fetch rig list from daemon (HTTP ${res.status}). Check daemon status with: rig status`);
@@ -709,9 +842,17 @@ Exit codes:
const all = res.data;
// OPR.0.4.4.21 — archived visibility parity: the daemon call above
// always includes archived (for the count line); without
// --include-archived they are dropped from BOTH renders here, exactly
// as the daemon default used to do. archivedCount feeds the FR-1
// count line only.
const archivedCount = all.filter((e) => e.isArchived === true).length;
const visible = opts.includeArchived ? all : all.filter((e) => e.isArchived !== true);
const rigScoped = opts.rig
? all.filter((e) => (e.rigName ?? e.name) === opts.rig)
: all;
? visible.filter((e) => (e.rigName ?? e.name) === opts.rig)
: visible;
// Apply CLI-side filter (Amendment A: prefer CLI shaping).
const filtered = parsedFilter ? applyRigFilter(rigScoped, parsedFilter) : rigScoped;
@@ -756,17 +897,35 @@ Exit codes:
if (limited.length === 0) {
if (parsedFilter) {
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else if (archivedCount > 0 && !opts.includeArchived) {
// Proven-empty with the history pointer, never a bare "No rigs"
// while archived history exists.
console.log(`No active rigs · ${archivedCount} archived (rig ps --include-archived)`);
} else {
console.log("No rigs");
}
return;
}
// Human output: apply default truncation budget unless --full.
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET;
// OPR.0.4.4.21 FR-1 (human table ONLY — JSON keeps ALL non-archived
// entries including stopped; this scope split is a STATED contract
// sentence in the help text): on the BARE default (no filter, no
// --rig, no --include-archived) stopped rigs fold into the count
// line — history is what the default drops, not field of view.
const bareDefault = !parsedFilter && !opts.rig && !opts.includeArchived;
const tableRows = bareDefault ? limited.filter((e) => e.status !== "stopped") : limited;
const stoppedCount = bareDefault ? limited.length - tableRows.length : 0;
const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
// FR-1 display element 1: the host rollup line.
const rollupSeats = tableRows.reduce((n, e) => n + e.nodeCount, 0);
const rollupAttention = tableRows.reduce((n, e) => n + (e.attentionCount ?? 0), 0);
console.log(`${tableRows.length} rig${tableRows.length === 1 ? "" : "s"} · ${rollupSeats} seat${rollupSeats === 1 ? "" : "s"} · ${rollupAttention} need${rollupAttention === 1 ? "s" : ""} attention`);
// Human output: apply default truncation budget unless --full.
const humanList = (opts.full || limit !== null) ? tableRows : tableRows.slice(0, HUMAN_RIG_BUDGET);
const humanTruncated = !opts.full && limit === null && tableRows.length > HUMAN_RIG_BUDGET;
const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "ATTN", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
console.log(header);
let anyArchivedShown = false;
for (const e of humanList as PsEntry[]) {
@@ -781,6 +940,9 @@ Exit codes:
// Slice 15 — "—" when daemon predates the field; honest absence.
e.activeCount !== undefined ? String(e.activeCount) : "—",
e.hasWorkCount !== undefined ? String(e.hasWorkCount) : "—",
// OPR.0.4.4.21 — the founder's field-of-view anchor: where is
// something that might concern me. "—" = daemon predates the field.
e.attentionCount !== undefined ? (e.attentionCount > 0 ? `▲${e.attentionCount}` : "0") : "—",
e.status,
abbrevRigLifecycle(e.lifecycleState),
e.uptime ?? "—",
@@ -790,6 +952,17 @@ Exit codes:
if (anyArchivedShown) {
console.log("* = archived (hidden from the default view; shown via --include-archived). Reverse with: rig unarchive <rig>");
}
// FR-1 display element 2: history as ONE count line, never rows.
if (stoppedCount > 0 || (!opts.includeArchived && archivedCount > 0)) {
const parts: string[] = [];
if (stoppedCount > 0) parts.push(`${stoppedCount} stopped (rig ps --filter status=stopped)`);
if (!opts.includeArchived && archivedCount > 0) parts.push(`${archivedCount} archived (rig ps --include-archived)`);
console.log(`not shown: ${parts.join(" · ")}`);
}
// FR-1 display element 3: the affordance footer — the drill ladder.
if (bareDefault) {
console.log("drill: rig ps --rig <name> (one rig) · rig ps --nodes --rig <name> (its seats) · --full (everything)");
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_RIG_BUDGET;
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_RIG_BUDGET}).`);
@@ -835,7 +1008,12 @@ async function handleNodes(
console.error(`Warning: failed to fetch nodes for rig "${rig.rigName ?? rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
continue;
}
allNodes.push(...nodesRes.data);
const parentRigName = rig.rigName ?? rig.name;
allNodes.push(...nodesRes.data.map((n) => ({
...n,
rigId: n.rigId ?? rig.rigId,
rigName: n.rigName ?? parentRigName,
})));
}
let narrowed = allNodes;
@@ -961,7 +1139,7 @@ function fitCell(value: string, width: number): string {
return truncate(value, width).padEnd(width);
}
function padRigRow(rig: string, nodes: string, running: string, active: string, work: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
function padRigRow(rig: string, nodes: string, running: string, active: string, work: string, attn: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
return [
fitCell(rig, 24),
fitCell(nodes, 7),
@@ -972,6 +1150,8 @@ function padRigRow(rig: string, nodes: string, running: string, active: string,
// operators see which dimension differs at a glance.
fitCell(active, 8),
fitCell(work, 6),
// OPR.0.4.4.21 — ATTN: seats needing attention (the field-of-view anchor).
fitCell(attn, 6),
fitCell(status, 10),
fitCell(lifecycle, 11),
fitCell(uptime, 11),
@@ -1077,7 +1257,10 @@ async function runCrossHostPs(
// OPR.0.4.0.34: forward the breadth flag so `--host h -A` keeps all-rigs
// breadth across the hop (the current-rig default is local-only and never
// applied to a remote call, but an explicit -A must still reach the remote).
if (opts.allRigs) argv.push("--all-rigs");
// OPR.0.4.4.21 FR-3: -A is legal only alongside --nodes (the validator
// already rejected the bare form before any dispatch; this guard keeps
// the reconstructed remote argv obeying the same grammar).
if (opts.allRigs && opts.nodes) argv.push("--all-rigs");
if (opts.limit !== undefined) argv.push("--limit", opts.limit);
if (opts.fields !== undefined) argv.push("--fields", opts.fields);
if (opts.summary) argv.push("--summary");
@@ -1127,39 +1310,16 @@ async function runHttpPs(
return;
}
let effectiveFilter = opts.filter;
if (opts.active) {
if (effectiveFilter) {
console.error("--active and --filter cannot be combined.");
const controls = parsePsControls(opts);
if ("error" in controls) {
console.error(controls.error);
process.exitCode = 1;
return;
}
effectiveFilter = "agentActivity.state=running";
}
let parsedFilter: ParsedFilter | null = null;
if (effectiveFilter) {
const result = parseFilter(effectiveFilter);
if ("error" in result) { console.error(result.error); process.exitCode = 1; return; }
parsedFilter = result;
}
const limit = opts.limit !== undefined ? Number(opts.limit) : null;
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) {
console.error(`--limit must be a non-negative integer; got '${opts.limit}'`);
process.exitCode = 1;
return;
}
let fields: string[] | null = null;
if (opts.fields !== undefined) {
const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig";
const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS;
const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel);
if ("error" in fieldsResult) { console.error(fieldsResult.error); process.exitCode = 1; return; }
fields = fieldsResult;
}
const { parsedFilter, limit, fields, useEnvelope } = controls;
const client = deps.clientFactory(host.url);
const headers = buildRemoteHeaders(bearerResult.token);
const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true;
try {
if (opts.nodes) {
@@ -1211,15 +1371,38 @@ async function runHttpPs(
}
}
/** INTERNAL transport result of one fan-out leg (arch adjudication: the
* shared P4 contract is fanout-contract's AggregatedPayload/PerHostStatus;
* this shape survives only as the fan-out's internal carriage, adapted
* below before anything is emitted). */
interface FanOutHostResult {
host: string;
ok: boolean;
failedStep: import("../cross-host-types.js").FailedStep;
failedStep: import("../cross-host-types.js").FailedStep | "unsupported-transport";
data?: unknown;
error?: string;
}
async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise<void> {
/** OPR.0.4.4.21 FR-5 — adapter to the intra-P4 shared contract
* (fanout-contract.ts, slice 15 first-lander at 0ecd329b; the closed
* status enum is THE contract, failedStep rides as additive detail). */
function toPerHostStatus(r: FanOutHostResult): PerHostStatus {
const status: PerHostStatus["status"] =
r.ok ? "ok"
: r.failedStep === "unsupported-transport" ? "unsupported-transport"
: r.failedStep === "permission-gate" ? "auth-failed"
: "unreachable";
const out: PerHostStatus = { hostId: r.host, status };
if (r.error) out.error = r.error;
if (!r.ok && r.failedStep !== "unsupported-transport") out.failedStep = r.failedStep;
return out;
}
async function runFanOutPs(
opts: PsCliOptions,
deps: PsDeps,
shaping: { parsedFilter: ParsedFilter | null; limit: number | null; fields: string[] | null },
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const registry = loader();
if (!registry.ok) {
@@ -1239,7 +1422,10 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise<void> {
return;
}
} else {
targetIds = allHosts.filter((h) => h.transport === "http").map((h) => h.id);
// OPR.0.4.4.21 fixback (qa1 F1): target EVERY declared host — non-HTTP
// hosts must appear in hosts[] as unsupported-transport (R15-2), never
// be silently absent. The per-host leg classifies transport.
targetIds = allHosts.map((h) => h.id);
}
const results: FanOutHostResult[] = await Promise.all(
@@ -1247,7 +1433,10 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise<void> {
const host = allHosts.find((h) => h.id === id);
if (!host) return { host: id, ok: false, failedStep: "remote-daemon-unreachable", error: `unknown host ${id}` };
if (host.transport !== "http") {
return { host: id, ok: false, failedStep: "remote-command-failed", error: `host ${id} uses transport ${host.transport}; HTTP fan-out requires transport: http` };
// R15-2 (shared contract): an SSH-declared host is a STRUCTURED
// unsupported-transport status — never prose-only, never silently
// thinner output.
return { host: id, ok: false, failedStep: "unsupported-transport", error: `host ${id} uses transport ${host.transport}; HTTP fan-out requires transport: http` };
}
const httpHost = host as HttpHostEntry;
const bearerResult = resolveRemoteBearer(httpHost);
@@ -1255,16 +1444,35 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise<void> {
return { host: id, ok: false, failedStep: bearerResult.failedStep, error: bearerResult.error };
}
const client = deps.clientFactory(httpHost.url);
const headers = buildRemoteHeaders(bearerResult.token);
try {
const psQuery = opts.includeArchived ? "?includeArchived=true" : "";
const res = await client.get<unknown>(`/api/ps${psQuery}`, {
headers: buildRemoteHeaders(bearerResult.token),
});
const res = await client.get<unknown>(`/api/ps${psQuery}`, { headers });
const failedStep = classifyHttpFailedStep(res.status);
if (failedStep !== "none") {
return { host: id, ok: false, failedStep, error: `HTTP ${res.status}` };
}
if (!opts.nodes) {
return { host: id, ok: true, failedStep: "none", data: res.data };
}
// OPR.0.4.4.21 FR-5 — the full explicit ladder (--nodes -A, --full
// for complete records): per-host node fan-out, projected unless
// --full (the invariant's last rung; validated as -A-only upstream).
const rigs = Array.isArray(res.data) ? (res.data as PsEntry[]) : [];
const nodes: NodeEntry[] = [];
for (const rig of rigs) {
const nodesRes = await client.get<NodeEntry[]>(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`, { headers });
if (nodesRes.status >= 400) {
return { host: id, ok: false, failedStep: classifyHttpFailedStep(nodesRes.status), error: `HTTP ${nodesRes.status} fetching nodes for rig ${rig.rigName ?? rig.name}` };
}
const parentRigName = rig.rigName ?? rig.name;
nodes.push(...nodesRes.data.map((n) => ({
...n,
rigId: n.rigId ?? rig.rigId,
rigName: n.rigName ?? parentRigName,
})));
}
return { host: id, ok: true, failedStep: "none", data: nodes };
} catch (err) {
return { host: id, ok: false, failedStep: classifyHttpError(err), error: (err as Error).message };
}
@@ -1274,7 +1482,51 @@ async function runFanOutPs(opts: PsCliOptions, deps: PsDeps): Promise<void> {
const hasFailure = results.some((r) => !r.ok);
if (opts.json) {
console.log(JSON.stringify({ hosts: results }));
// OPR.0.4.4.21 FR-5 — the intra-P4 shared payload (ONE contract with
// slice 15): items = each host's rows stamped with their origin hostId
// (flat-mergeable; origin never positional), hosts = the per-host
// structured status array. EVERY targeted host appears in hosts[] —
// ok or not (no silent thinning).
const rawItems: Array<Record<string, unknown>> = results.flatMap((r) =>
r.ok && Array.isArray(r.data)
? (r.data as Array<Record<string, unknown>>).map((row) => ({ ...row, hostId: r.host }))
: [],
);
const hostStatuses = results.map(toPerHostStatus);
let narrowed = rawItems;
if (opts.rig) narrowed = narrowed.filter((e) => (e.rigName ?? e.name) === opts.rig);
if (opts.nodes && opts.session) narrowed = narrowed.filter((n) => n.canonicalSessionName === opts.session);
const filtered = shaping.parsedFilter
? opts.nodes
? (applyNodeFilter(narrowed as unknown as NodeEntry[], shaping.parsedFilter) as unknown as Array<Record<string, unknown>>)
: (applyRigFilter(narrowed as unknown as PsEntry[], shaping.parsedFilter) as unknown as Array<Record<string, unknown>>)
: narrowed;
let items: Array<Record<string, unknown>>;
if (opts.summary) {
items = [
opts.nodes
? summarizeNodes(filtered as unknown as NodeEntry[])
: summarizeRigs(filtered as unknown as PsEntry[]),
];
} else {
const limited = shaping.limit !== null ? filtered.slice(0, shaping.limit) : filtered;
items = shaping.fields
? selectFanOutFields(limited, shaping.fields)
: opts.nodes && !opts.full
? compactNodeProjection(limited as unknown as NodeEntry[]).map((row, i) => ({
...row,
hostId: limited[i]?.hostId,
}))
: limited;
}
const payload: AggregatedPayload<Record<string, unknown>> = {
items,
hosts: hostStatuses,
};
console.log(JSON.stringify(payload));
} else {
for (const r of results) {
if (r.ok) {
+99
View File
@@ -214,6 +214,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.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[])")
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary of the work (feeds the Story node label; the agent-speak --body stays the source of truth). Warned-if-missing; pre-18 qitems exempt.")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: pointer to the durable artifact a human judges (e.g. a PROOF.md path). Required by the daemon when the item is human-routed; optional otherwise.")
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
.option("--json", "JSON output for agents")
.action(async (opts: {
@@ -231,6 +232,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
id?: string;
targetRepo?: string;
summary?: string;
evidenceRef?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -277,6 +279,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
destinationSession: opts.destination,
body: resolvedBody,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
priority: opts.priority,
tier: opts.tier,
tags,
@@ -331,6 +334,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
.option("--closure-reason <reason>", "Required for state=done")
.option("--closure-target <target>", "Required for handed_off_to, blocked_on, escalation")
.option("--blocked-on <blocker>", "For state=blocked: the blocker (a qitem id, or a human seat for the OPR.0.4.4.19 FR-6 park — human parks require summary + evidence_ref on the item)")
.option("--summary <text>", "OPR.0.4.4.19 FR-6: park-time summary persisted onto the item (human-seat parks only)")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-6: park-time durable-artifact pointer persisted onto the item (human-seat parks only)")
.option("--note <text>", "Transition note for the audit log")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -338,6 +344,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
state: string;
closureReason?: string;
closureTarget?: string;
blockedOn?: string;
summary?: string;
evidenceRef?: string;
note?: string;
json?: boolean;
}) => {
@@ -350,12 +359,96 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
state: opts.state,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
blockedOn: opts.blockedOn,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
// OPR.0.4.4.19 FR-6 — the first-class park affordance (C5 leg 1). One verb,
// both blocker kinds: --on takes a qitem id (today's shipped blocked-on
// usage, nothing new required) or a human-seat session (the leg-1 park —
// summary + evidence_ref enforced by the daemon validator). A THIN client
// of the same update write path — enforcement lives in the daemon domain
// layer, never verb-only. The park is NON-TERMINAL: the owner keeps the
// potato and no closure_reason is involved.
cmd
.command("block <qitemId>")
.description("Park a qitem as state=blocked on a blocker (qitem id, or a human seat for a needs-human park). Non-terminal: the owner keeps the item; resolution returns to it (rig queue resolve).")
.requiredOption("--on <blocker>", "The blocker: a qitem id, or a human-seat session (e.g. human-review@kernel) for the needs-human park")
.option("--actor <session>", "Acting session (defaults to OPENRIG_SESSION_NAME)")
.option("--summary <text>", "Plain-language summary of the decision owed (required for human-seat parks unless already on the item)")
.option("--evidence-ref <path>", "Durable artifact the human judges (required for human-seat parks unless already on the item)")
.option("--note <text>", "Transition note for the audit log")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
on: string;
actor?: string;
summary?: string;
evidenceRef?: string;
note?: string;
json?: boolean;
}) => {
const actor = resolveCurrentSession(opts.actor, "actor");
if (!actor) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
actorSession: actor,
state: "blocked",
blockedOn: opts.on,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
transitionNote: opts.note ?? `parked on ${opts.on}`,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
// OPR.0.4.4.19 FR-7 — the resolve verb's CLI wrapper: a THIN client of the
// ONE write path (POST /api/mission-control/action, verb=resolve). Exists
// so proof walks and the relay session are scriptable before the Packet-2
// surface ships; the founder's path is the surface/feed card invoking the
// same endpoint. Resolution returns to the PARKED OWNER (blocked →
// in-progress on the SAME item) — never a closure, never a new owner.
cmd
.command("resolve <qitemId>")
.description("Resolve a leg-1 parked qitem (state=blocked on a human seat): records the decision text durably in queue_transitions, unparks blocked -> in-progress, and nudges the owner. Non-closure.")
.requiredOption("--decision <text>", "The human's decision text (non-empty; lands in transition_note + the audit row)")
.option("--actor <session>", "Resolving session (defaults to OPENRIG_SESSION_NAME)")
.option("--bearer <token>", "Operator bearer token for the mission-control write gate (or set OPENRIG_AUTH_BEARER_TOKEN; loopback daemons without a configured bearer need none)")
.option("--no-notify", "Skip the best-effort owner nudge (the unpark still commits)")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
decision: string;
actor?: string;
bearer?: string;
notify?: boolean;
json?: boolean;
}) => {
const actor = resolveCurrentSession(opts.actor, "actor");
if (!actor) return;
const deps = getDeps();
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
await withClient(deps, async (client) => {
const res = await client.post<unknown>(
"/api/mission-control/action",
{
verb: "resolve",
qitemId,
actorSession: actor,
decision: opts.decision,
notify: opts.notify,
},
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined,
);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("handoff <qitemId>")
.description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to")
@@ -369,6 +462,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -382,6 +476,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
gate?: string;
targetRepo?: string;
summary?: string;
evidenceRef?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -410,6 +505,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
toSession: opts.to,
body: opts.body,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
priority: opts.priority,
tier: opts.tier,
@@ -436,6 +532,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.")
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -449,6 +546,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
gate?: string;
targetRepo?: string;
summary?: string;
evidenceRef?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -477,6 +575,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
toSession: opts.to,
body: opts.body,
summary: opts.summary,
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
priority: opts.priority,
tier: opts.tier,
+148 -2
View File
@@ -10,6 +10,9 @@ import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import {
CLOSE_REASONS,
@@ -49,6 +52,7 @@ import {
updateFrontmatter,
} from "../lib/scope/scope-fs.js";
import {
renderImplementationPrdTemplate,
renderMissionBriefTemplate,
renderMissionNotesTemplate,
renderMissionProgressTemplate,
@@ -255,7 +259,7 @@ function buildSliceShowCommand(): Command {
function buildSliceCreateCommand(): Command {
return new Command("create")
.description("Create a new slice in a mission")
.description("Create a new slice in a mission. Scaffolds the SDLC convention sections (## Intent / ## Mini-requirements / ## Proof contract) + proof/ + PROOF.md + IMPLEMENTATION-PRD.md for EVERY template kind — the shapes the Living Notes UI projects. Conventions SSOT: docs/reference/sdlc-conventions.md.")
.argument("<mission>", "Mission name")
.argument("<slug>", "Short slug (becomes the folder name's suffix)")
.option("--template <kind>", `Template: ${SLICE_TEMPLATE_KINDS.join(" | ")}`, "placeholder")
@@ -311,6 +315,18 @@ function buildSliceCreateCommand(): Command {
created_date: createdDate,
});
const proofBody = renderSliceProofTemplate({ id, title });
// OPR.0.4.4.23 — every template kind also scaffolds the
// IMPLEMENTATION-PRD skeleton (convention sections + elastic-middle
// note): the PRD is what the Living Notes UI's proof-contract join
// reads, so a slice without one doesn't project its DELIVERED pairing.
const prdBody = renderImplementationPrdTemplate({
id,
slice_number: pad2(nn),
slug,
mission: mission.name,
title,
created_date: createdDate,
});
fs.mkdirSync(sliceAbs, { recursive: true });
fs.mkdirSync(path.join(sliceAbs, "proof"), { recursive: true });
const readmePath = path.join(sliceAbs, "README.md");
@@ -327,6 +343,7 @@ function buildSliceCreateCommand(): Command {
fs.writeFileSync(progressPath, renderSliceProgressTemplate(title), "utf8");
}
fs.writeFileSync(path.join(sliceAbs, "PROOF.md"), proofBody, "utf8");
fs.writeFileSync(path.join(sliceAbs, "IMPLEMENTATION-PRD.md"), prdBody, "utf8");
const payload = {
ok: true,
slice: {
@@ -883,7 +900,8 @@ function buildAuditCommand(): Command {
continue;
}
const sliceFm = extractFrontmatterRaw(fs.readFileSync(sliceReadme, "utf-8"));
const sliceReadmeContent = fs.readFileSync(sliceReadme, "utf-8");
const sliceFm = extractFrontmatterRaw(sliceReadmeContent);
const readmeOnlyMarker = sliceFm !== null && /^progress_rail\s*:\s*readme-only/m.test(sliceFm);
// committed-without-PROGRESS inputs (CLI-only; inert when git is
@@ -919,6 +937,14 @@ function buildAuditCommand(): Command {
hasProofPacket: hasProofPacketForSlice(dogfoodEvidenceRoot, entry),
sliceTouchedByRecentCommit,
progressTouchedByRecentCommit,
// OPR.0.4.4.19 FR-10 backstop inputs.
proofArtifacts: listProofArtifactsForAudit(proofDir),
implementationPrdExists: fs.existsSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md")),
// OPR.0.4.4.23 convention-section advisory inputs.
readmeContent: sliceReadmeContent,
implementationPrdContent: fs.existsSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md"))
? fs.readFileSync(path.join(sliceDir, "IMPLEMENTATION-PRD.md"), "utf-8")
: null,
});
if (!/^\d{2}-/.test(entry)) {
@@ -998,6 +1024,23 @@ function defaultDogfoodEvidenceRoot(missionsRoot: string): string {
return path.join(path.dirname(missionsRoot), "dogfood-evidence");
}
// OPR.0.4.4.19 FR-10 (C1 backstop input) — list the slice's proof/ markdown
// artifacts with raw frontmatter. Media files are exempt by construction.
// Undefined when the dir is absent/unreadable so the classifier stays inert.
function listProofArtifactsForAudit(proofDir: string): Array<{ path: string; frontmatterRaw: string | null }> | undefined {
if (!fs.existsSync(proofDir)) return undefined;
try {
return fs.readdirSync(proofDir)
.filter((f) => f.toLowerCase().endsWith(".md"))
.map((f) => {
const artifactPath = path.join(proofDir, f);
return { path: artifactPath, frontmatterRaw: extractFrontmatterRaw(fs.readFileSync(artifactPath, "utf-8")) };
});
} catch {
return undefined;
}
}
function hasProofPacketForSlice(dogfoodEvidenceRoot: string, sliceName: string): boolean {
let entries: fs.Dirent[];
try {
@@ -1537,6 +1580,107 @@ function buildMissionRepairCommand(): Command {
});
}
// ---------------------------------------------------------------------
// Approve (OPR.0.4.4.19 FR-9)
// ---------------------------------------------------------------------
// `rig scope slice|mission approve` — a THIN client of the daemon's ONE
// write path (POST /api/scope/approve): frontmatter stamp + append-only
// audit row land together daemon-side (no half-stamp by construction).
// STAGED: --scope spec ("the PRD matches my intent") | delivery (the
// terminal sign-off + future freeze trigger); omitted = delivery.
// DELEGATED: --on-behalf-of records whose decision this is in the audit
// notes; the actor stays the REAL invoking session (honest provenance).
// Two-regime rule (BR-6): approval is freeze/sign-off — NEVER proven-green.
function buildApproveCommand(tier: "slice" | "mission"): Command {
return new Command("approve")
.description(
tier === "slice"
? "Approve a slice: writes the frontmatter stamp + an append-only audit row (daemon-side, one operation). --scope spec = the PLAN-LOCK (PRD-matches-intent; this artifact set gets built); delivery (default) = the PROOF-LOCK (terminal sign-off). Approval is freeze/sign-off, never proven-green. Conventions SSOT: docs/reference/sdlc-conventions.md."
: "Approve a mission: same staged/delegated semantics as slice approve, at mission tier."
)
.argument(tier === "slice" ? "<slice-path>" : "<mission>", tier === "slice" ? "Slice path (absolute, relative, or NN-slug)" : "Mission name")
.option("--mission <name>", tier === "slice" ? "Hint mission when slice-path is just NN-slug" : "(unused at mission tier)")
.option("--scope <scope>", "Approval scope: spec | delivery (default delivery)")
.option("--actor <session>", "Approving session (defaults to OPENRIG_SESSION_NAME)")
.option("--on-behalf-of <human>", "Record the delegation: whose decision this stamp records (actor stays the real invoking session)")
.option("--json", "Machine-readable output")
.action(async (target: string, opts: {
mission?: string;
scope?: string;
actor?: string;
onBehalfOf?: string;
json?: boolean;
}, command: Command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
if (opts.scope !== undefined && opts.scope !== "spec" && opts.scope !== "delivery") {
throw new ScopeCliError({
fact: `Unknown --scope value "${opts.scope}".`,
consequence: "Command did not run.",
action: "Pick one of: spec, delivery (omit for delivery).",
});
}
const actor = opts.actor ?? process.env.OPENRIG_SESSION_NAME;
if (!actor) {
throw new ScopeCliError({
fact: "No acting session: --actor not passed and OPENRIG_SESSION_NAME is unset.",
consequence: "The approval stamp requires honest actor provenance.",
action: "Pass --actor <session> or run from a managed seat.",
});
}
// Resolve the scope target LOCALLY (rich NN-slug resolution), then
// send the canonical missions-root-relative path to the daemon.
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
let scopeAbsPath: string;
if (tier === "slice") {
const slice = findSlice(missionsRoot, target, opts.mission ?? null);
scopeAbsPath = slice.absPath;
} else {
const mission = findMission(missionsRoot, target);
scopeAbsPath = mission.absPath;
}
const scopePath = path.relative(missionsRoot, scopeAbsPath).split(path.sep).join("/");
const lifecycleDeps = realDeps();
const status = await getDaemonStatus(lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new ScopeCliError({
fact: "Daemon not running.",
consequence: "scope approve writes the stamp + audit row through the daemon (one operation).",
action: "Start it with: rig daemon start",
});
}
const client = new DaemonClient(getDaemonUrl(status));
const res = await client.post<Record<string, unknown>>("/api/scope/approve", {
scopeTier: tier,
scopePath,
approvalScope: opts.scope,
actorSession: actor,
onBehalfOf: opts.onBehalfOf ?? null,
});
if (res.status >= 400) {
const err = res.data as { error?: string; message?: string };
throw new ScopeCliError({
fact: `Approve failed (${err.error ?? res.status}): ${err.message ?? "unknown error"}.`,
consequence: "No stamp and no audit row were left behind (no half-stamp).",
action: err.error === "already_approved"
? "The scope already carries this stamp; un-approve/re-approve is out of scope v1."
: "Fix the named issue and re-run.",
});
}
const data = res.data;
emit(out, { ok: true, ...data }, json, [
`Approved (${String(data.approvalScope)}) ${tier} ${String(data.scopeId)} — ${String(data.approvedBy)} at ${String(data.approvedAt)}${data.onBehalfOf ? ` on behalf of ${String(data.onBehalfOf)}` : ""}`,
`Audit action: ${String(data.actionId)} (scope_path=${String(data.scopePath)})`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------
@@ -1557,6 +1701,7 @@ export function scopeCommand(): Command {
slice.addCommand(buildSliceRepairCommand());
slice.addCommand(buildSliceStageCommand());
slice.addCommand(buildSliceVerifiedCommand());
slice.addCommand(buildApproveCommand("slice"));
cmd.addCommand(slice);
const mission = new Command("mission").description("Mission-tier commands");
@@ -1567,6 +1712,7 @@ export function scopeCommand(): Command {
mission.addCommand(buildMissionRepairCommand());
mission.addCommand(buildMissionStageCommand());
mission.addCommand(buildMissionVerifiedCommand());
mission.addCommand(buildApproveCommand("mission"));
cmd.addCommand(mission);
cmd.addCommand(buildAuditCommand());
+13 -4
View File
@@ -221,7 +221,7 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
const res = await client.post<Record<string, unknown>>("/api/transport/send", {
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
dangerouslyInteract: opts.dangerouslyInteract, reason: opts.reason, actorSession: senderSession ?? null,
}, { ...waitForIdleRequestOptions(waitForIdleMs), headers: terminalAuthHeaders() });
}, transportRequestOptions(waitForIdleMs));
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -359,9 +359,7 @@ async function runFanOutSend(params: {
body.envelopeSender = senderSession && senderSession.trim().length > 0 ? senderSession : SENDER_FALLBACK;
}
const res = await client.post<Record<string, unknown>>("/api/transport/broadcast", body, {
headers: terminalAuthHeaders(),
});
const res = await client.post<Record<string, unknown>>("/api/transport/broadcast", body, transportRequestOptions());
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -403,3 +401,14 @@ function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeout
if (waitForIdleMs === undefined) return undefined;
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
}
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs?: number; headers?: Record<string, string> } | undefined {
const waitOptions = waitForIdleRequestOptions(waitForIdleMs);
const headers = terminalAuthHeaders();
const hasHeaders = Object.keys(headers).length > 0;
if (!waitOptions && !hasHeaders) return undefined;
return {
...(waitOptions ?? {}),
...(hasHeaders ? { headers } : {}),
};
}
+67 -2
View File
@@ -1,4 +1,6 @@
import nodePath from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { parse as parseYamlDoc } from "yaml";
import { Command } from "commander";
import { DaemonClient, DaemonConnectionError } from "../client.js";
import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "../daemon-lifecycle.js";
@@ -28,6 +30,29 @@ function isPathInsideRoot(candidate: string, root: string): boolean {
return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
}
// OPR.0.4.4.11 (arch return R11-2) — pre-dispatch detection of a topology
// source for the --host rejection. Mirrors the daemon's FR-1 detection
// contract without importing the daemon package: `.rigtopology` extension,
// or a readable path-form YAML document with a top-level `rigs:` LIST.
export function sourceLooksLikeTopology(source: string): boolean {
if (/\.rigtopology$/i.test(source)) return true;
// Name-form sources (no slash, no known extension) keep rig-name
// semantics — never sniffed (same precedence as the daemon router).
if (!source.includes("/") && !source.match(/\.(ya?ml)$/i)) return false;
try {
const p = nodePath.resolve(source);
if (!existsSync(p)) return false;
const parsed: unknown = parseYamlDoc(readFileSync(p, "utf-8"));
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
return Array.isArray((parsed as Record<string, unknown>)["rigs"]);
} catch {
return false; // unreadable/unparseable — existing flows own the error
}
}
export const HOST_TOPOLOGY_REJECTION =
"rig up --host cannot take a topology source: per-entry 'host:' in the manifest is the ONLY placement mechanism for topologies (two placement mechanisms must not coexist). Put 'host: <id>' on the entries you want placed remotely and run 'rig up <topology>' without --host.";
export function upCommand(
depsOverride?: StatusDeps & {
lifecycleDeps?: LifecycleDeps;
@@ -61,6 +86,19 @@ Examples:
const deps = getDepsF();
if (opts.host) {
// OPR.0.4.4.11 R11-2: --host + topology source is REJECTED before
// any dispatch — per-entry 'host:' is the only topology placement
// mechanism. The daemon route carries the same rejection on its
// public write path (double-sided enforcement, arch ruling 4).
if (sourceLooksLikeTopology(source)) {
if (opts.json) {
console.log(JSON.stringify({ ok: false, error: HOST_TOPOLOGY_REJECTION, code: "host_flag_topology" }));
} else {
console.error(HOST_TOPOLOGY_REJECTION);
}
process.exitCode = 1;
return;
}
const { runRemoteHttpOp } = await import("../remote-host-ops.js");
const body = {
sourceRef: source,
@@ -163,8 +201,13 @@ Examples:
const client = deps.clientFactory(getDaemonUrl(status));
// Detect rig name vs file path: names don't contain / and don't end in .yaml/.yml/.rigbundle
const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle)$/i);
// Detect rig name vs file path: names don't contain / and don't end in
// .yaml/.yml/.rigbundle/.rigtopology. OPR.0.4.4.11 (guard G-1): the
// topology extension resolves as a PATH (same as yaml/rigbundle) so a
// bare `factory.rigtopology` reaches the daemon's topology routing
// instead of library/existing-rig name resolution. Extensionless
// no-slash sources keep rig_name precedence byte-for-byte.
const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle|rigtopology)$/i);
let sourceRef = isRigName ? source : nodePath.resolve(source);
// If it looks like a name, check for library spec match
@@ -368,6 +411,25 @@ Examples:
return;
}
// OPR.0.4.4.11 — topology aggregate rendering (full success OR honest
// partial; the daemon returns the same closed {rigRef, host, status,
// error?} entries either way, skipped entries explicitly present).
const topoEntries = res.data["entries"] as Array<{ rigRef: string; host: string; status: string; error?: string }> | undefined;
if (typeof res.data["topology"] === "string" && Array.isArray(topoEntries)) {
const topoOk = res.data["ok"] === true;
console.log(
topoOk
? `Topology up: all ${topoEntries.length} rigs launched.`
: "Topology up FAILED — honest partial state (started rigs stay up; no rollback):",
);
for (const e of topoEntries) {
const tag = e.status === "ok" ? " ok " : e.status === "failed" ? "FAIL" : "skip";
console.log(` [${tag}] ${e.rigRef} @ ${e.host}${e.error ? ` — ${e.error}` : ""}`);
}
if (!topoOk) process.exitCode = 1;
return;
}
if (res.status >= 400) {
const code = res.data["code"] as string | undefined;
if (code === "cycle_error") {
@@ -380,6 +442,9 @@ Examples:
console.error(`Preflight check failed:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: resolve the issues above and retry.`);
} else if (code === "pre_restore_validation_failed") {
printRestoreNotAttempted(res.data as RestoreNotAttemptedData);
} else if (code === "invalid_topology_manifest") {
const errors = (res.data["errors"] as string[]) ?? [];
console.error(`Topology manifest invalid:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: the manifest key set is CLOSED — rigs[]{source, host?} plus optional concurrency.`);
} else {
const errorText = String(res.data["error"] ?? "unknown error");
console.error(`Up failed: ${errorText} (HTTP ${res.status}). Check daemon logs or validate your spec with: rig spec validate <path>`);
+6 -6
View File
@@ -4,9 +4,9 @@
// - `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.
// - `rig workspace doctor` (slice-21 FR-5; +1 check OPR.0.4.4.23) — runs
// the 8-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";
@@ -76,7 +76,7 @@ function emit3PartError(json: boolean, fact: string, consequence: string, action
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
const cmd = new Command("workspace").description(
"PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 7-check workspace-readiness diagnostic.",
"PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 8-check workspace-readiness diagnostic.",
);
const getDeps = (): WorkspaceDeps =>
@@ -148,7 +148,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
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.",
"Run the 8-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, MISSION_NOTES, and SDLC convention sections. Read-only.",
)
.option(
"--workspace <path>",
@@ -243,7 +243,7 @@ interface DoctorReport {
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: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence", "sdlc_convention_sections"] },
{ category: "daemon", checks: ["daemon_points_at_this_workspace", "daemon_reload_needed"] },
];
+64
View File
@@ -375,6 +375,41 @@ function isValidKey(key: string): key is ValidKey {
return (VALID_KEYS as readonly string[]).includes(key);
}
// ── OPR.0.4.4.15 (G15-P1) — TWIN of the daemon settings-store's ONE
// registered dynamic key class: `feed.subscriptions.<hostId>.enabled`
// (boolean; v1 per-host key set CLOSED to {enabled}; no env mapping).
// Kept in lockstep with packages/daemon/src/domain/user-settings/
// settings-store.ts — the parity test pins both twins (host-registry twin
// discipline). Reserved segments cover key-level snake_case AND FILE-level
// camelCase toggle names so no host id shadows a flat toggle at either
// layer. Every other unknown key keeps the reject-loud behavior.
const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_-]+)\.enabled$/;
export const FEED_HOST_RESERVED_SEGMENTS = new Set([
"action_required",
"actionRequired",
"approvals",
"shipped",
"progress",
"audit_log",
"auditLog",
"enabled",
]);
export function parseFeedHostSubscriptionKey(key: string): { hostId: string } | null {
const m = key.match(FEED_HOST_KEY_RE);
if (!m) return null;
const hostId = m[1]!;
if (FEED_HOST_RESERVED_SEGMENTS.has(hostId)) return null;
return { hostId };
}
function coerceFeedHostSubscriptionValue(key: string, raw: string): boolean {
const v = raw.trim().toLowerCase();
if (v === "true") return true;
if (v === "false") return false;
throw new Error(`Invalid value for ${key}: expected "true" or "false", got "${raw}"`);
}
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
let current: unknown = obj;
for (const part of parts) {
@@ -657,6 +692,14 @@ export class ConfigStore {
* default).
*/
resolveWithSource(key: string): ResolvedSetting {
// OPR.0.4.4.15: dynamic class resolves file-or-default (no env in v1).
const feedHost = parseFeedHostSubscriptionKey(key);
if (feedHost) {
const fcDyn = this.readConfigFile();
const fileVal = getNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"]);
if (typeof fileVal === "boolean") return { value: fileVal, source: "file", defaultValue: false };
return { value: false, source: "default", defaultValue: false };
}
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
@@ -722,6 +765,17 @@ export class ConfigStore {
}
set(key: string, value: string): void {
// OPR.0.4.4.15: the registered dynamic class is accepted; every other
// unknown key keeps the reject-loud behavior below byte-for-byte.
const feedHost = parseFeedHostSubscriptionKey(key);
if (feedHost) {
const coercedDyn = coerceFeedHostSubscriptionValue(key, value);
const fcDyn = this.readConfigFile();
setNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn);
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
return;
}
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
@@ -745,6 +799,16 @@ export class ConfigStore {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
}
// OPR.0.4.4.15: dynamic-class reset removes the whole host node.
const feedHost = parseFeedHostSubscriptionKey(key);
if (feedHost) {
if (!existsSync(this.configPath)) return;
const fcDyn = this.readConfigFile();
const subsParent = getNestedValue(fcDyn, ["feed", "subscriptions"]) as Record<string, unknown> | undefined;
if (subsParent && feedHost.hostId in subsParent) delete subsParent[feedHost.hostId];
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
return;
}
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
+4 -1
View File
@@ -167,7 +167,10 @@ const PERMISSION_GATE_PATTERNS = [
/no mutual signature algorithm/i,
];
function looksLikePermissionGate(stderr: string): boolean {
// OPR.0.4.4.18: exported so `rig file` classifies rsync stderr with the SAME
// signature (single-source classification per arch ruling — never a second
// pattern copy). Behavior unchanged.
export function looksLikePermissionGate(stderr: string): boolean {
return PERMISSION_GATE_PATTERNS.some((re) => re.test(stderr));
}
+4 -1
View File
@@ -143,7 +143,10 @@ export const LEGACY_LOG_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.log");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 7433;
const DEFAULT_DB = "openrig.sqlite";
const HEALTHZ_RETRIES = 20;
// Fresh Linux hosts can take longer than a warmed dev machine to load native
// modules, run startup reconciliation, and bind healthz. Keep the retry loop
// bounded, but do not kill a healthy daemon during first-boot slack.
const HEALTHZ_RETRIES = 80;
const HEALTHZ_DELAY_MS = 250;
const HEALTHZ_PROBE_TIMEOUT_MS = 250;
// OPR.0.4.2.1 — status-probe bounded settle: a single /healthz fetch loses to the post-restart
+49 -2
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { parse as parseYaml } from "yaml";
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import { getDefaultOpenRigPath } from "./openrig-compat.js";
import type { FailedStep } from "./cross-host-types.js";
@@ -226,3 +227,49 @@ export function resolveHost(registry: HostRegistry, id: string): HostResolution
error: `unknown host id '${id}'.${idsHint}`,
};
}
// ---------------------------------------------------------------------------
// OPR.0.4.4.13 FR-1 — the registry WRITE path (rig host add).
//
// ONE validation source: the candidate registry (existing entries + the new
// raw entry) is validated by the SAME validateHostRegistry the loader uses —
// add-time errors are load-time errors, verbatim (incl. duplicate ids,
// transport-appropriate fields, exactly-one-bearer). The standard path never
// hand-edits YAML; note: add REWRITES the file canonically (hand-authored
// comments are not preserved — hand-editing remains the path for exotica).
// ---------------------------------------------------------------------------
export type AddHostResult =
| { ok: true; path: string; entry: HostEntry }
| { ok: false; error: string };
export function addHostEntry(rawEntry: Record<string, unknown>, path: string = defaultHostRegistryPath()): AddHostResult {
// Load what exists; a MISSING file is a valid starting point for `add`
// (the verb exists so operators never hand-create the YAML), but a present-
// but-invalid file is a loud error — never silently clobber operator state.
let existing: HostEntry[] = [];
if (existsSync(path)) {
const loaded = loadHostRegistry(path);
if (!loaded.ok) {
return { ok: false, error: `refusing to modify an invalid registry: ${loaded.error}` };
}
existing = loaded.registry.hosts;
}
const candidate = { hosts: [...existing, rawEntry] };
const validated = validateHostRegistry(candidate, path);
if (!validated.ok) {
return { ok: false, error: validated.error };
}
const entry = validated.registry.hosts[validated.registry.hosts.length - 1]!;
try {
mkdirSync(dirname(path), { recursive: true });
const tmp = join(dirname(path), `.hosts.yaml.tmp-${process.pid}`);
writeFileSync(tmp, stringifyYaml({ hosts: validated.registry.hosts }), { mode: 0o600 });
renameSync(tmp, path);
} catch (err) {
return { ok: false, error: `failed to write host registry at ${path}: ${(err as Error).message}` };
}
return { ok: true, path, entry };
}
+7
View File
@@ -18,11 +18,13 @@ import { bindCommand } from "./commands/bind.js";
import { adoptCommand, type AdoptDeps } from "./commands/adopt.js";
import { bundleCommand } from "./commands/bundle.js";
import { scopeCommand } from "./commands/scope.js";
import { proofCommand } from "./commands/proof.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 { hostCommand } from "./commands/host.js";
import { mcpCommand } from "./commands/mcp.js";
import { agentCommand, type AgentDeps } from "./commands/agent.js";
import { rigCommand, type RigDeps } from "./commands/rig.js";
@@ -38,6 +40,7 @@ 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 { fileCommand } from "./commands/file.js";
import { preflightCommand } from "./commands/preflight.js";
import { authCommand } from "./commands/auth.js";
import { doctorCommand } from "./commands/doctor.js";
@@ -168,6 +171,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
// 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(hostCommand());
program.addCommand(psCommand(depsOverride?.psDeps));
program.addCommand(mcpCommand(depsOverride?.mcpDeps));
program.addCommand(agentCommand(depsOverride?.agentDeps));
@@ -194,6 +198,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(rigPolicyCommand(depsOverride?.rigPolicyDeps));
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
program.addCommand(configCommand(depsOverride?.configPath));
program.addCommand(fileCommand());
program.addCommand(preflightCommand());
program.addCommand(authCommand());
program.addCommand(doctorCommand());
@@ -219,6 +224,8 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(startupProofCommand(depsOverride?.startupProofDeps));
// release-0.3.2 slice 12 — rig scope CLI primitive.
program.addCommand(scopeCommand());
// OPR.0.4.4.19 FR-8 — rig proof: the C1 proof-drop write path.
program.addCommand(proofCommand());
return program;
}
+358
View File
@@ -0,0 +1,358 @@
// OPR.0.4.4.18 — `rig file copy` core: path grammar, the FR-4 security wall,
// the closed rsync argv builder, and result classification.
//
// SECURITY CELL (plan §3 + §7a/§7a-2, guard-cleared over three rounds):
// five independent layers, every rejection BEFORE any spawn —
// 1. GRAMMAR (fail-closed): a path is remote iff it carries an explicit
// `<hostId>:` qualifier; no inference from cwd/env/session (FR-2).
// Local-prefix escapes (`/ ./ ../ ~`); an id-shaped colon prefix MUST
// resolve or fail loudly (N18-1); leading-dash operands are refused
// outright with the ./ escape taught (G18-P1 belt).
// 2. NORMALIZATION: local paths are ~-expanded + path.resolve'd and the
// RESOLVED form is what is checked and transported; remote paths
// reject ANY raw '..' SEGMENT BEFORE normalization (G18-C1: normalize
// COLLAPSES '..' in absolute paths, so a post-normalize check is dead
// code), then '.'/'//' are collapsed for the shipped form.
// 3. THE REMOTE CHARSET WALL (G18-P3 — supersedes a --protect-args pin:
// this platform's rsync is OPENRSYNC, which rejects -s; never assume
// GNU parity on macOS): remote paths must be ABSOLUTE (arch Q5) and
// match ^[A-Za-z0-9._/-]+$ — every allowed character is POSIX-shell-
// inert, so the ssh-invoked remote shell has NOTHING to interpret:
// no word-split (space excluded), no glob (*?[] excluded), no
// expansion (~ $ excluded), no metacharacters. Rejections TEACH
// (offending char named + workaround; arch note, N18-2-revisitable).
// 4. THE `--` OPERAND PIN (G18-P1 suspenders): the builder ALWAYS places
// `--` before the two path operands — a flag-shaped path can never
// parse as an option even if a future caller bypasses the refusal.
// 5. NO SHELL locally: spawn("rsync", argv) with argv arrays; the -e ssh
// string is built ONLY from registry fields (user shape-checked).
// Deletion-class flags are UNREACHABLE (property-tested, incl. the
// `-s`/--protect-args ABSENCE uniformity pin).
//
// DENY WALL (FR-4, arch-endorsed SHORT CLOSED NAMED list — extension = a
// ruling, never a growing blocklist): local side denies resolved paths under
// ~/.openrig (live rig state — crash-safety class), ~/.ssh, ~/.codex,
// ~/.claude (credential/shared-singleton classes); remote side denies any
// path CONTAINING one of those dot-directory segments (conservative-over-
// broad: /srv/backup/.ssh/x is still a credentials dir; false positives are
// loud + revisitable, false negatives are the corruption class).
import os from "node:os";
import path from "node:path";
import { spawn as nodeSpawn } from "node:child_process";
import { loadHostRegistry, resolveHost, type SshHostEntry } from "./../host-registry.js";
import { looksLikePermissionGate } from "./../cross-host-executor.js";
import { getDefaultOpenRigPath, getOpenRigHome } from "./../openrig-compat.js";
// ── grammar ────────────────────────────────────────────────────────────────
export type ParsedFileArg =
| { kind: "local"; path: string }
| { kind: "remote"; hostId: string; path: string };
export type FileArgParse = { ok: true; arg: ParsedFileArg } | { ok: false; error: string };
const HOST_ID_SHAPE = /^[A-Za-z0-9_-]+$/;
const SSH_USER_SHAPE = /^[A-Za-z0-9._-]+$/;
export const REMOTE_PATH_CHARSET = /^[A-Za-z0-9._/-]+$/;
/** The FR-4 short closed named deny list (dot-directory segments). */
export const DENIED_SEGMENTS = [".openrig", ".ssh", ".codex", ".claude"] as const;
export function parseFilePathArg(raw: string): FileArgParse {
if (raw === "") return { ok: false, error: "empty path operand" };
if (raw.startsWith("-")) {
// G18-P1 belt: a flag-shaped operand is refused outright; the ./ escape
// is the honest way to address such a file.
return {
ok: false,
error: `path operand '${raw}' begins with '-' and could be mistaken for an option. If this is a real local file, address it with the ./ prefix (./${raw}).`,
};
}
// Local-prefix escapes: these are ALWAYS local, colon or not (N18-1).
if (raw.startsWith("/") || raw.startsWith("./") || raw.startsWith("../") || raw.startsWith("~")) {
return { ok: true, arg: { kind: "local", path: raw } };
}
const colon = raw.indexOf(":");
if (colon >= 0) {
const prefix = raw.slice(0, colon);
const rest = raw.slice(colon + 1);
if (HOST_ID_SHAPE.test(prefix)) {
// Id-shaped prefix: this IS a host qualifier — it must resolve or the
// command fails loudly (fail-closed; never a silent local fallback).
if (rest === "") return { ok: false, error: `remote path missing after '${prefix}:' — expected ${prefix}:<absolute-path>` };
return { ok: true, arg: { kind: "remote", hostId: prefix, path: rest } };
}
// Non-id-shaped prefix (contains '/', empty, etc.): scp's lived
// behavior — the whole operand is a local path.
return { ok: true, arg: { kind: "local", path: raw } };
}
return { ok: true, arg: { kind: "local", path: raw } };
}
// ── the deny wall + normalization ──────────────────────────────────────────
function expandLocalTilde(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export type PathCheck = { ok: true; normalizedPath: string } | { ok: false; error: string };
/** Local side: ~-expand, resolve (traversal collapses here), then deny-check
* the RESOLVED form. The resolved form is what rsync receives. */
export function checkLocalPath(raw: string): PathCheck {
const resolved = path.resolve(expandLocalTilde(raw));
const home = os.homedir();
const activeOpenRigHome = path.resolve(getOpenRigHome());
const activeHostsRegistry = path.resolve(getDefaultOpenRigPath("hosts.yaml"));
if (resolved === activeHostsRegistry) {
return {
ok: false,
error: `refused: '${raw}' resolves to the active hosts registry (${activeHostsRegistry}) — the registry itself is not a copy source/target in v0. This is the FR-4 default-deny wall (live OpenRig state; extension requires a ruling).`,
};
}
for (const segment of DENIED_SEGMENTS) {
const deniedRoot = path.join(home, segment);
if (resolved === deniedRoot || resolved.startsWith(deniedRoot + path.sep)) {
return {
ok: false,
error: `refused: '${raw}' resolves into ${deniedRoot} — ${segment === ".openrig" ? "live OpenRig state (incl. the hosts registry) is not a copy source/target in v0 (crash-safety)" : "credential/agent-home directories are not copy sources/targets"}. This is the FR-4 default-deny wall (a short closed list; extension requires a ruling).`,
};
}
}
if (
activeOpenRigHome !== path.join(home, ".openrig") &&
(resolved === activeOpenRigHome || resolved.startsWith(activeOpenRigHome + path.sep))
) {
return {
ok: false,
error: `refused: '${raw}' resolves into the active OPENRIG_HOME (${activeOpenRigHome}) — live OpenRig state is not a copy source/target in v0 (crash-safety). This is the FR-4 default-deny wall (a short closed list; extension requires a ruling).`,
};
}
return { ok: true, normalizedPath: resolved };
}
/** Remote side: posix-normalize, then the wall — absolute-only (arch Q5),
* no surviving traversal, the shell-inert charset (G18-P3), and the
* denied-segment list. Errors TEACH (offending char + workaround). */
export function checkRemotePath(raw: string, hostId: string): PathCheck {
const charsetViolation = [...raw].find((ch) => !REMOTE_PATH_CHARSET.test(ch));
if (charsetViolation !== undefined) {
const shown = charsetViolation === " " ? "a space" : `'${charsetViolation}'`;
return {
ok: false,
error: `refused: remote path for '${hostId}' contains ${shown}, which is outside the v0 remote-path character set [A-Za-z0-9._/-]. v0 keeps remote paths shell-inert by construction${charsetViolation === " " ? " — use a space-free staging path and rename on the far side" : ""}. (v0-conservative; widening is a ruling.)`,
};
}
if (!raw.startsWith("/")) {
return { ok: false, error: `refused: remote paths are ABSOLUTE-only in v0 (got '${raw}' for host '${hostId}') — remote ~/relative resolution would smuggle in remote-side semantics. Use the full path.` };
}
// G18-C1 (guard code-review): the RAW path is checked for '..' BEFORE
// normalization — posix.normalize fully COLLAPSES '..' in absolute paths
// (/srv/../../etc/passwd → /etc/passwd), so a post-normalize "surviving
// .." check is dead code and the climb escapes silently. In an
// absolute-only grammar a '..' segment is never necessary; ANY '..'
// rejects outright (no benign/escaping distinction — remote symlinks
// make that unreasonable to judge from here).
if (raw.split("/").includes("..")) {
return { ok: false, error: `refused: remote path '${raw}' contains a '..' traversal segment (FR-4). Remote paths are absolute-only — write the final path without '..'.` };
}
const normalized = path.posix.normalize(raw);
for (const part of normalized.split("/")) {
if ((DENIED_SEGMENTS as readonly string[]).includes(part)) {
return {
ok: false,
error: `refused: remote path '${raw}' contains the denied directory segment '${part}' (credential/rig-state class; conservative-over-broad by design — FR-4's short closed list).`,
};
}
}
return { ok: true, normalizedPath: normalized };
}
// ── planning ────────────────────────────────────────────────────────────────
export interface CopySide {
kind: "local" | "remote";
/** Normalized path (resolved local / posix-normalized remote). */
path: string;
/** Present on remote sides. */
host?: SshHostEntry;
}
export interface CopyPlan {
src: CopySide;
dst: CopySide;
dryRun: boolean;
}
export type PlanResult = { ok: true; plan: CopyPlan } | { ok: false; error: string; code: string };
export interface PlanDeps {
registryLoader?: typeof loadHostRegistry;
}
function resolveRemoteSide(hostId: string, rawPath: string, deps: PlanDeps): { ok: true; side: CopySide } | { ok: false; error: string; code: string } {
const loader = deps.registryLoader ?? loadHostRegistry;
const reg = loader();
if (!reg.ok) return { ok: false, error: reg.error, code: "registry_error" };
const resolved = resolveHost(reg.registry, hostId);
if (!resolved.ok) return { ok: false, error: resolved.error, code: "unknown_host" };
if (resolved.host.transport !== "ssh") {
return {
ok: false,
error: `host '${hostId}' uses transport '${resolved.host.transport}' — v0 file movement is ssh/rsync only. Register an ssh entry for this host or move the file another way.`,
code: "unsupported_transport",
};
}
if (resolved.host.user !== undefined && !SSH_USER_SHAPE.test(resolved.host.user)) {
return { ok: false, error: `registry entry '${hostId}' has a user field outside [A-Za-z0-9._-] — refusing to place it on an ssh command line`, code: "invalid_registry_user" };
}
const pathCheck = checkRemotePath(rawPath, hostId);
if (!pathCheck.ok) return { ok: false, error: pathCheck.error, code: "denied_path" };
return { ok: true, side: { kind: "remote", path: pathCheck.normalizedPath, host: resolved.host } };
}
/** Validate the whole invocation — EVERY rejection here happens before any
* spawn (the fail-closed set; spawn-spy-asserted in tests). */
export function planFileCopy(rawSrc: string, rawDst: string, opts: { dryRun?: boolean } & PlanDeps = {}): PlanResult {
const srcParse = parseFilePathArg(rawSrc);
if (!srcParse.ok) return { ok: false, error: srcParse.error, code: "bad_operand" };
const dstParse = parseFilePathArg(rawDst);
if (!dstParse.ok) return { ok: false, error: dstParse.error, code: "bad_operand" };
if (srcParse.arg.kind === "remote" && dstParse.arg.kind === "remote") {
return { ok: false, error: "remote-to-remote is not in v0; pull then push (two explicit transfers — never a silent relay through this host)", code: "remote_to_remote" };
}
const sides: CopySide[] = [];
for (const parsed of [srcParse.arg, dstParse.arg]) {
if (parsed.kind === "remote") {
const side = resolveRemoteSide(parsed.hostId, parsed.path, opts);
if (!side.ok) return { ok: false, error: side.error, code: side.code };
sides.push(side.side);
} else {
const check = checkLocalPath(parsed.path);
if (!check.ok) return { ok: false, error: check.error, code: "denied_path" };
sides.push({ kind: "local", path: check.normalizedPath });
}
}
return { ok: true, plan: { src: sides[0]!, dst: sides[1]!, dryRun: opts.dryRun === true } };
}
// ── the CLOSED argv builder ─────────────────────────────────────────────────
function sideOperand(side: CopySide): string {
if (side.kind === "local") return side.path;
return `${side.host!.target}:${side.path}`;
}
/** The ONLY place rsync arguments are assembled. Properties (test-asserted):
* no deletion-class flag ever present; NO -s/--protect-args (uniform argv
* across GNU rsync and openrsync — the G18-P3 absence pin); `--` ALWAYS
* precedes the two path operands; option positions are fixed. */
export function buildRsyncArgv(plan: CopyPlan): string[] {
const argv = ["--archive", "--itemize-changes", "--stats"];
if (plan.dryRun) argv.push("--dry-run");
const remote = plan.src.kind === "remote" ? plan.src : plan.dst.kind === "remote" ? plan.dst : null;
if (remote) {
const sshParts = ["ssh", "-o", "ConnectTimeout=10"];
if (remote.host!.user) sshParts.push("-l", remote.host!.user);
argv.push("-e", sshParts.join(" "));
}
argv.push("--", sideOperand(plan.src), sideOperand(plan.dst));
return argv;
}
// ── execution + classification ──────────────────────────────────────────────
export type FileCopyFailedStep = "none" | "rsync-missing" | "permission-gate" | "ssh-unreachable" | "remote-command-failed";
export interface FileCopyResult {
ok: boolean;
failedStep: FileCopyFailedStep;
exitCode: number | null;
stdout: string;
stderr: string;
/** Parsed from rsync --stats when available. */
bytesTransferred?: number;
filesTransferred?: number;
hint?: string;
}
type SpawnFn = typeof nodeSpawn;
const CONNECTION_FAILURE_PATTERNS = [
/connection refused/i,
/connection timed out/i,
/connection unexpectedly closed/i,
/could not resolve hostname/i,
/no route to host/i,
/operation timed out/i,
];
function parseStats(stdout: string): { bytesTransferred?: number; filesTransferred?: number } {
const bytes = stdout.match(/Total transferred file size:\s*([\d,.]+)\s*bytes/i);
const files = stdout.match(/Number of (?:regular )?files transferred:\s*([\d,.]+)/i);
const num = (m: RegExpMatchArray | null) => (m ? Number(m[1]!.replace(/[,.](?=\d{3})/g, "").replace(/,/g, "")) : undefined);
return { bytesTransferred: num(bytes), filesTransferred: num(files) };
}
/** rsync's classes are the executor taxonomy MINUS remote-daemon-unreachable
* (rsync needs no daemon — arch ruling 2). */
export function classifyRsyncResult(exitCode: number | null, stdout: string, stderr: string): FileCopyResult {
if (exitCode === 0) {
return { ok: true, failedStep: "none", exitCode, stdout, stderr, ...parseStats(stdout) };
}
if (looksLikePermissionGate(stderr)) {
return {
ok: false,
failedStep: "permission-gate",
exitCode,
stdout,
stderr,
hint: "See openrig-work/field-notes/2026-04-29-l4-3-d6-claude-keychain-over-ssh-diagnostic.md for guidance on Keychain-over-SSH issues.",
};
}
if (exitCode === 255 || CONNECTION_FAILURE_PATTERNS.some((re) => re.test(stderr))) {
return { ok: false, failedStep: "ssh-unreachable", exitCode, stdout, stderr };
}
return { ok: false, failedStep: "remote-command-failed", exitCode, stdout, stderr };
}
export async function runFileCopy(plan: CopyPlan, deps: { spawn?: SpawnFn } = {}): Promise<FileCopyResult> {
const argv = buildRsyncArgv(plan);
const spawn = deps.spawn ?? nodeSpawn;
const child = spawn("rsync", argv, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
let spawnFailed: NodeJS.ErrnoException | null = null;
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
const exitCode: number | null = await new Promise((resolve) => {
child.on("error", (err: NodeJS.ErrnoException) => {
spawnFailed = err;
resolve(null);
});
child.on("close", (code: number | null) => resolve(code));
});
if (spawnFailed !== null && (spawnFailed as NodeJS.ErrnoException).code === "ENOENT") {
return {
ok: false,
failedStep: "rsync-missing",
exitCode: null,
stdout,
stderr,
hint: "rsync is not installed locally. macOS: `brew install rsync` (or use the system openrsync ≥ the one shipped with your OS); Linux: install the rsync package.",
};
}
return classifyRsyncResult(exitCode, stdout, stderr);
}
@@ -0,0 +1,50 @@
// OPR.0.4.4.15 — THE shared intra-P4 fan-out payload contract.
//
// ONE contract for the whole P4 packet (arch adjudication 2026-07-05,
// cross-PRD interface cell): slice 15's aggregated For-You attention feed
// AND slice 21's `rig ps --all-hosts` rollup both speak AggregatedPayload —
// items plus a per-host structured status array. Slice 15 defines this
// module (first lander, per the whichever-builds-first rule named in both
// PRDs); slice 21 IMPORTS it. Any change here is a CROSS-PRD contract
// change requiring re-review of BOTH slices — never driver discretion.
/** The local host's id literal in every P3/P4 payload (arch pin: defined
* ONCE here, imported everywhere, never retyped). Matches slice 11's
* topology aggregate literal. */
export const LOCAL_HOST_ID = "local";
/** CLOSED enum (arch pin A). `unsupported-transport` is R15-2's explicit
* class (an SSH-declared host is never a silently thinner payload);
* `auth-failed` is distinct from `unreachable` because the operator fix
* differs (rotate/set the bearer vs check the host). Extending this set =
* a cross-PRD contract change (slices 15 AND 21 re-review). */
export type PerHostStatusKind = "ok" | "unreachable" | "unsupported-transport" | "auth-failed";
export interface PerHostStatus {
hostId: string;
status: PerHostStatusKind;
/** Honest failure detail, rendered muted next to the host chip/row. */
error?: string;
/** ADDITIVE OPTIONAL detail (arch adjudication): the shipped CLI
* FailedStep vocabulary when a transport-level step classified the
* failure. Never load-bearing — `status` is the contract. */
failedStep?: string;
}
/** items + per-host status: EVERY subscribed host appears in `hosts` on
* EVERY payload — ok, unreachable, auth-failed, or unsupported-transport.
* Absence is a contract violation (omission-proof; never all-or-nothing,
* never silent thinning). */
export interface AggregatedPayload<T> {
items: T[];
hosts: PerHostStatus[];
}
/** Contract-level completeness predicate (arch pin B: asserted as near to
* the contract as tests allow): true iff every expected host id appears
* exactly once in the payload's hosts array. */
export function hostsCovered(payload: AggregatedPayload<unknown>, expectedHostIds: string[]): boolean {
const seen = new Map<string, number>();
for (const h of payload.hosts) seen.set(h.hostId, (seen.get(h.hostId) ?? 0) + 1);
return expectedHostIds.every((id) => seen.get(id) === 1) && payload.hosts.length === expectedHostIds.length;
}
@@ -10,6 +10,18 @@ created: {{created_date}}
# Slice {{slice_number}} — {{title}}
## Intent
[The recorded intent — what is being deprecated and why. The Living Notes UI projects this text as the INTENT section.]
## Mini-requirements
1. [The deprecation path as observable outcomes. For a small deprecation this may BE the whole plan.]
## Proof contract
- [ ] [The migration landed / the removal is clean — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).]
## Target
[What is being deprecated]
@@ -25,3 +37,7 @@ created: {{created_date}}
## Removal
[When/how the deprecated thing gets deleted]
---
> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`.
@@ -10,6 +10,18 @@ created: {{created_date}}
# Slice {{slice_number}} — {{title}}
## Intent
[The recorded intent — the debt to pay down and why now. The Living Notes UI projects this text as the INTENT section.]
## Mini-requirements
1. [The paydown as observable outcomes. For small debt this may BE the whole plan.]
## Proof contract
- [ ] [The debt is paid and pinned (tests/measures) — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).]
## Current state
[How the code/system looks today]
@@ -21,3 +33,7 @@ created: {{created_date}}
## Proposal
[How to pay it down]
---
> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`.
@@ -10,6 +10,18 @@ created: {{created_date}}
# Slice {{slice_number}} — {{title}}
## Intent
[The recorded intent — the defect to fix and why it matters. The Living Notes UI projects this text as the INTENT section.]
## Mini-requirements
1. [The fix as an observable outcome. For a bug fix this may BE the whole plan.]
## Proof contract
- [ ] [The repro no longer reproduces / the regression test pins it — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).]
## Repro
[Steps to reproduce]
@@ -29,3 +41,7 @@ created: {{created_date}}
## Fix proposal
[Optional: thinking on the fix]
---
> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`.
@@ -0,0 +1,33 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: intent
stage: wip
created: {{created_date}}
---
<!-- ELASTIC MIDDLE (three-capture-points doctrine): the SDLC fixes exactly
three capture points — intent → proportional structured requirement →
proof. Everything between is ELASTIC. For a small slice the
mini-requirements below may BE the whole PRD; add depth only where the
work demands it. The scaffold must not mint ceremony.
Conventions SSOT: docs/reference/sdlc-conventions.md -->
# Slice {{slice_number}} — {{title}}
## Intent
[The recorded intent, verbatim — kept in sync with the slice README.]
## Mini-requirements
1. [The concise one-glance requirement tier — this is where approval starts.]
## Proof contract
- [ ] [One promised deliverable, written as an observable outcome — captured. This list is the source the DELIVERED section pairs proof against — pair each item via `rig proof add … --evidences --media` (never hand-place evidence without the drop); UI deliverables name their planned mockup (`plannedRef`).]
## Notes (elastic)
[Design, seams, risks, sequencing — only as much as the slice needs.]
@@ -24,7 +24,7 @@ created: {{created_date}}
## Mission/Slice SOP — how you work this mission & slice
> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure.
> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure. **Conventions SSOT: `docs/reference/sdlc-conventions.md`** — the slice section names (`## Intent` / `## Mini-requirements` / `## Proof contract`), the proof-contract format, the two locks (`rig scope slice approve --scope spec|delivery`), and the C1 proof headers all live there.
### The canonical files (the operating surface)
@@ -1,6 +1,6 @@
# Progress — {{missionName}}
> **WHO/WHEN:** the orchestrator owns the current-state summary; every agent logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the `markdown-control-plane` convention.
> **WHO/WHEN:** the orchestrator owns the current-state summary; every agent logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the conventions SSOT (`docs/reference/sdlc-conventions.md`).
## Milestones
@@ -29,7 +29,7 @@ created: {{created_date}}
## Mission/Slice SOP — how you work this mission & slice
> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure.
> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work. Load the `mission-slice-sop` skill for the full operating procedure. **Conventions SSOT: `docs/reference/sdlc-conventions.md`** — the slice section names (`## Intent` / `## Mini-requirements` / `## Proof contract`), the proof-contract format, the two locks (`rig scope slice approve --scope spec|delivery`), and the C1 proof headers all live there.
### The canonical files (the operating surface)
@@ -10,9 +10,17 @@ created: {{created_date}}
# Slice {{slice_number}} — {{title}}
## Goal
## Intent
[1-2 sentence goal.]
[The recorded intent, verbatim — what was asked for and why. The Living Notes UI projects this text as the INTENT section.]
## Mini-requirements
1. [The concise one-glance requirement tier — numbered observable outcomes. For a small slice this may BE the whole plan.]
## Proof contract
- [ ] [One promised deliverable, written as an observable outcome — captured. Each item pairs with its proof via `rig proof add … --evidences` (media attached with `--media`); UI deliverables name their planned mockup.]
## Source material
@@ -34,10 +42,6 @@ Non-visual slices: mark this section N/A.
- [Cross-slice / cross-release]
## Acceptance
- [3-5 bullets]
---
> **How you work this slice (SOP):** track on PROGRESS.md, prove on PROOF.md (+ `proof/`), carry state in the mission's MISSION_NOTES.md, and hand off via `rig queue`. A slice is **not done until PROOF.md exists** and every acceptance bullet has evidence. The full operating procedure lives in the mission README's Mission/Slice SOP section and the `mission-slice-sop` skill. Verify with `rig scope audit`.
> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`.
@@ -1,6 +1,8 @@
# PROOF — {{id}} {{title}}
> **WHO/WHEN:** the impl/QA pair that worked the slice, at slice-close — a slice is NOT done until this file exists and every plan success-criterion has evidence (mapped 1:1, artifacts under `proof/`). See the `mission-slice-sop` skill + the `markdown-control-plane` convention.
> **WHO/WHEN:** the impl/QA pair that worked the slice, at slice-close — a slice is NOT done until this file exists and every proof-contract item has evidence (mapped 1:1, artifacts under `proof/`). See the `mission-slice-sop` skill + the conventions SSOT (`docs/reference/sdlc-conventions.md`).
>
> **HOW (the drop verb, not hand-placement):** put media files under `proof/`, then ATTACH them with `rig proof add {{id}} --artifact-type qa --verdict PASS --candidate-sha <tip> --money-evidence "<one line>" --evidences "1" --media "screenshot-01.png"` — the drop writes the C1 header the Living Notes DELIVERED pairing joins on. Hand-placing files without a drop leaves the deliverable unpaired and `unverified`.
Closed by: <seat> Date: <date> Verdict: <pass | pass-with-residue | ...>
@@ -10,6 +12,8 @@ Closed by: <seat> Date: <date> Verdict: <pass | pass-with-residue | ...>
## Artifacts (media in proof/)
Dropped via `rig proof add … --evidences … --media …` (one drop per verdict; media attached, never only hand-listed):
- proof/screenshot-01.png — <what it shows>
- proof/capture-behavior.gif — <what it shows>
- proof/command-output.txt — <what it proves>
@@ -10,18 +10,26 @@ created: {{created_date}}
# Slice {{slice_number}} — {{title}}
## Goal
## Intent
[What this feature ships and why now]
[The recorded intent, verbatim — what this feature ships and why now. The Living Notes UI projects this text as the INTENT section.]
## Mini-requirements
1. [The concise one-glance requirement tier — numbered observable outcomes.]
## Proof contract
- [ ] [One promised deliverable, written as an observable outcome — captured. Each item pairs with its proof via `rig proof add … --evidences` (media attached with `--media`); UI deliverables name their planned mockup.]
## Scope
[In / out for v0]
## Acceptance
- [3-5 bullets]
## Risks
[Known unknowns]
---
> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`.
@@ -10,6 +10,18 @@ created: {{created_date}}
# Slice {{slice_number}} — {{title}}
## Intent
[The recorded intent — the thing we are trying to learn and why. The Living Notes UI projects this text as the INTENT section.]
## Mini-requirements
1. [What a sufficient answer looks like. For research this may BE the whole plan.]
## Proof contract
- [ ] [The findings artifact answering the question — captured. Pair with proof via `rig proof add … --evidences` (media attached with `--media`).]
## Question
[The thing we are trying to learn]
@@ -21,3 +33,7 @@ created: {{created_date}}
## Findings
[Captured as work progresses]
---
> **How you work this slice (SOP):** conventions SSOT: `docs/reference/sdlc-conventions.md`; full flow: the `mission-slice-sop` skill. Author intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --evidences --media` drops into `proof/` (never hand-place evidence without the drop) → proof-lock (`--scope delivery`). Track on PROGRESS.md; a slice is **not done** until every proof-contract item has evidence. Verify with `rig scope audit`.
@@ -1,6 +1,6 @@
# Progress — {{sliceName}}
> **WHO/WHEN:** every agent working this slice logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the `markdown-control-plane` convention.
> **WHO/WHEN:** every agent working this slice logs its own outcomes here after every slice-done AND every commit. One line per outcome, link down for detail. See the `mission-slice-sop` skill + the conventions SSOT (`docs/reference/sdlc-conventions.md`).
## Acceptance
+207 -2
View File
@@ -13,7 +13,18 @@ export type FindingKind =
| "malformed_mission_brief"
| "missing_mission_notes"
| "missing_proof"
| "progress_not_updated_on_commit";
| "progress_not_updated_on_commit"
// OPR.0.4.4.19 FR-10 — the belt-and-suspenders BACKSTOPS (never the
// primary enforcement; the primary is the drop path / write path):
| "proof_artifact_c1_invalid"
| "missing_impl_prd"
// OPR.0.4.4.23 — SDLC convention-section advisories (fail-open by
// construction: low/info severities never flip the audit exit code;
// conventions SSOT: docs/reference/sdlc-conventions.md):
| "missing_intent_section"
| "mini_requirements_missing_or_malformed"
| "proof_contract_missing_or_malformed"
| "ui_slice_missing_mockup";
export interface AuditFinding {
kind: FindingKind;
@@ -52,6 +63,21 @@ export interface ScopeAuditInput {
// is unavailable the CLI leaves them undefined: no false-green, no false-positive.
sliceTouchedByRecentCommit?: boolean;
progressTouchedByRecentCommit?: boolean;
// OPR.0.4.4.19 FR-10 (C1 backstop) — the slice's proof/ dir markdown
// artifacts with their raw frontmatter, caller-listed. Undefined = the
// caller has no proof-dir context; the check is inert (no false findings).
// Media files (video/screenshot) are exempt by construction — callers list
// .md artifacts only.
proofArtifacts?: Array<{ path: string; frontmatterRaw: string | null }>;
// OPR.0.4.4.19 FR-10 (C7 backstop) — whether IMPLEMENTATION-PRD.md exists
// at the slice root. Undefined = inert (caller has no fs context).
implementationPrdExists?: boolean;
// OPR.0.4.4.23 — convention-section advisory inputs: full file contents,
// caller-read. Undefined = the caller has no content context and every
// section check is inert (no false findings). null = the file does not
// exist (the proof-contract check falls back to the README on a null PRD).
readmeContent?: string | null;
implementationPrdContent?: string | null;
}
export interface ScopeAuditResult {
@@ -60,7 +86,11 @@ export interface ScopeAuditResult {
frontmatterError: string | null;
}
const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"];
// OPR.0.4.4.20 FR-8: exported so the review brief-spine writer conforms to
// the SAME pinned exact-order schema this audit enforces (parity by
// construction — the generated output can never trip malformed_mission_brief
// without this file changing too).
export const MISSION_BRIEF_HEADERS = ["What & why", "Building", "Progress", "Proven", "Needs you", "Pointers"];
function childPath(parent: string, child: string): string {
return parent.endsWith("/") ? `${parent}${child}` : `${parent}/${child}`;
@@ -105,6 +135,72 @@ function statusRequiresProof(status: string | null | undefined): boolean {
|| normalized.includes("promoted");
}
// OPR.0.4.4.19 FR-10 (C7) — the statusRequiresProof pattern applied one
// phase earlier: a slice whose status implies SPECCED/BUILDING (or later)
// must carry IMPLEMENTATION-PRD.md at its root ("no build without a
// specifying doc"). Shaping / pre-spec statuses are NOT violations.
function statusRequiresSpec(status: string | null | undefined): boolean {
if (!status) return false;
const normalized = status.toLowerCase().trim();
return normalized.includes("build")
|| normalized.includes("implement")
|| normalized.includes("in-progress")
|| normalized.includes("review")
|| normalized.includes("qa")
|| statusRequiresProof(normalized);
}
// OPR.0.4.4.19 FR-10 (C1) — the ratified closed sets (BR-4; source of truth
// for the drop path lives in the CLI proof command; this mirrored file
// carries its own copy because both scope-audit copies must stay
// self-contained + byte-identical. Extending the sets is a pm-lead
// convention change, made in BOTH places).
const C1_REQUIRED_FIELDS = ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"] as const;
const C1_ARTIFACT_TYPES = ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const;
const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEAR"] as const;
/** Validate one proof artifact's raw frontmatter against the C1 contract.
* Returns null when valid; else the human-readable problem list. */
function c1ArtifactProblems(frontmatterRaw: string | null): string[] | null {
if (frontmatterRaw === null) {
return [`no frontmatter header at all (required C1 fields: ${C1_REQUIRED_FIELDS.join(", ")})`];
}
let parsed: unknown = null;
try {
parsed = YAML.parse(frontmatterRaw);
} catch (err) {
return [`frontmatter fails to parse: ${err instanceof Error ? err.message : String(err)}`];
}
const fm = parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
const problems: string[] = [];
const missing = C1_REQUIRED_FIELDS.filter((f) => typeof fm[f] !== "string" || (fm[f] as string).trim().length === 0);
if (missing.length > 0) problems.push(`missing field(s): ${missing.join(", ")}`);
if (typeof fm.artifact_type === "string" && !(C1_ARTIFACT_TYPES as readonly string[]).includes(fm.artifact_type)) {
problems.push(`artifact_type '${fm.artifact_type}' not in closed set (${C1_ARTIFACT_TYPES.join(" | ")})`);
}
if (typeof fm.verdict === "string" && !(C1_VERDICTS as readonly string[]).includes(fm.verdict)) {
problems.push(`verdict '${fm.verdict}' not in closed set (${C1_VERDICTS.join(" | ")})`);
}
return problems.length > 0 ? problems : null;
}
// OPR.0.4.4.23 — markdown H2-section helpers for the convention-section
// advisories. Headings are literals owned by this file ("Intent",
// "Proof contract", "Intent visual") — no user input reaches the regex.
function hasH2(content: string, heading: string): boolean {
return new RegExp(`^##\\s+${heading}\\s*$`, "m").test(content);
}
function h2Body(content: string, heading: string): string | null {
const match = new RegExp(`^##\\s+${heading}\\s*$`, "m").exec(content);
if (!match) return null;
const rest = content.slice(match.index + match[0].length);
const next = rest.search(/^##\s+/m);
return next === -1 ? rest : rest.slice(0, next);
}
// A mission is ACTIVE unless its status names a terminal / archived state.
// The SOP wants missing_mission_notes to fire for an ACTIVE mission only — a
// shipped/archived mission no longer needs a live continuity file. No status
@@ -264,6 +360,115 @@ export function classifyScopeItem(input: ScopeAuditInput): ScopeAuditResult {
});
}
// OPR.0.4.4.19 FR-10 (C1 backstop) — flag proof/ artifacts missing the
// C1 header or carrying out-of-set values. The backstop catches what
// bypassed the drop path (raw file writes are never gated at write
// time; this is where they surface).
for (const artifact of input.proofArtifacts ?? []) {
const problems = c1ArtifactProblems(artifact.frontmatterRaw);
if (problems) {
findings.push({
kind: "proof_artifact_c1_invalid",
severity: "medium",
path: artifact.path,
message: `Proof artifact violates the C1 header contract: ${problems.join("; ")}.`,
remediation: `Re-drop via: rig proof add <slice> --artifact-type <${C1_ARTIFACT_TYPES.join("|")}> --verdict <${C1_VERDICTS.join("|")}> --candidate-sha <sha> --money-evidence "<line>" — or add the missing frontmatter fields in place.`,
});
}
}
// OPR.0.4.4.19 FR-10 (C7 backstop) — a specced/building-or-later slice
// must carry IMPLEMENTATION-PRD.md (the pinned expected filename) at its
// root. Status-gated: shaping is not a violation. Inert when the caller
// provided no fs context (implementationPrdExists undefined).
if (input.implementationPrdExists === false && statusRequiresSpec(status)) {
findings.push({
kind: "missing_impl_prd",
severity: "medium",
path: childPath(input.path, "IMPLEMENTATION-PRD.md"),
message: `Slice status "${status}" implies specced/building but there is no IMPLEMENTATION-PRD.md at the slice root (C7: no build without a specifying doc).`,
remediation: "Author (or relocate) the slice's IMPLEMENTATION-PRD.md at the slice root — the pinned C7 filename — before building continues.",
});
}
// OPR.0.4.4.23 — SDLC convention-section advisories (SSOT:
// docs/reference/sdlc-conventions.md). Structurally fail-open: the
// audit command flips its exit code on HIGH findings only, and these
// are low/info by construction — they record and advise, never gate.
// Inert when the caller provided no content context (undefined inputs).
if (typeof input.readmeContent === "string" && !hasH2(input.readmeContent, "Intent")) {
findings.push({
kind: "missing_intent_section",
severity: "low",
path: childPath(input.path, "README.md"),
message: "Slice README has no `## Intent` section, so the Living Notes UI cannot project its INTENT section.",
remediation: "Add `## Intent` carrying the recorded intent verbatim (conventions SSOT: docs/reference/sdlc-conventions.md; `rig scope slice create` scaffolds it).",
});
}
const contractSource = typeof input.implementationPrdContent === "string"
? { content: input.implementationPrdContent, path: childPath(input.path, "IMPLEMENTATION-PRD.md") }
: typeof input.readmeContent === "string"
? { content: input.readmeContent, path: childPath(input.path, "README.md") }
: null;
if (contractSource) {
// OPR.0.4.4.23 rev1-r2 B1 (PRD L34 guard F-3): well-formed
// `## Mini-requirements` — the PLAN leg of the Living Notes
// projection. Same source-selection policy as the proof contract
// (PRD preferred, README fallback). Well-formed = the heading plus
// at least one numbered list item; a heading over prose-only is
// malformed (no usable requirements projection).
const miniBody = h2Body(contractSource.content, "Mini-requirements");
const hasNumberedItem = miniBody !== null && /^\s*\d+\.\s+\S/m.test(miniBody);
if (!hasNumberedItem) {
findings.push({
kind: "mini_requirements_missing_or_malformed",
severity: "low",
path: contractSource.path,
message: miniBody === null
? "No `## Mini-requirements` section — the Living Notes PLAN section has no concise requirements tier to project."
: "`## Mini-requirements` carries no numbered items (`1. …`) — the one-glance requirement tier is where approval starts.",
remediation: "Add `## Mini-requirements` with a numbered list of observable outcomes (conventions SSOT: docs/reference/sdlc-conventions.md; for a small slice this may BE the whole PRD).",
});
}
const contractBody = h2Body(contractSource.content, "Proof contract");
const hasCheckboxItem = contractBody !== null && /^\s*-\s*\[[ xX]\]/m.test(contractBody);
if (!hasCheckboxItem) {
findings.push({
kind: "proof_contract_missing_or_malformed",
severity: "low",
path: contractSource.path,
message: contractBody === null
? "No `## Proof contract` section — the DELIVERED pairing has no promised-deliverables source to join proof against."
: "`## Proof contract` carries no checkbox deliverables (`- [ ] …`) — the DELIVERED pairing joins proof against those items.",
remediation: "Add `## Proof contract` with one checkbox line per promised deliverable, written as an observable outcome (conventions SSOT: docs/reference/sdlc-conventions.md).",
});
}
if (typeof input.readmeContent === "string") {
const visualBody = h2Body(input.readmeContent, "Intent visual");
const isUiSlice = visualBody !== null && !/\bN\/A\b/i.test(visualBody);
// rev1-r2 B2: a mockup is PRESENT only via a real markdown
// image/media ref in `## Intent visual` or an explicit plannedRef
// token in the proof contract. Generic prose containing the word
// "mockup" (the scaffold placeholder says "name their planned
// mockup") is NOT a reference and must not suppress the advisory.
const hasMockupRef = /!\[/.test(visualBody ?? "")
|| /plannedRef/i.test(contractBody ?? "")
|| /!\[/.test(contractBody ?? "");
if (isUiSlice && !hasMockupRef) {
findings.push({
kind: "ui_slice_missing_mockup",
severity: "info",
path: childPath(input.path, "README.md"),
message: "Slice declares an Intent visual (UI slice) but no mockup reference is present — a UI slice with no mockup in its locked set is an incomplete plan.",
remediation: "Attach the planned mockup: an image ref in `## Intent visual` or a plannedRef on the proof-contract deliverable (conventions SSOT: docs/reference/sdlc-conventions.md §3).",
});
}
}
}
// committed-without-PROGRESS. Fires only on POSITIVE evidence: the most
// recent commit (HEAD) touched this slice AND definitively did NOT touch its
// PROGRESS.md. Inputs are git-derived and CLI-only; when git context is
+10
View File
@@ -72,6 +72,16 @@ export function renderSliceTemplate(kind: SliceTemplateKind, opts: RenderOpts):
return applyPlaceholders(raw, opts);
}
// OPR.0.4.4.23 — every scaffolded slice also gets an IMPLEMENTATION-PRD.md
// skeleton (the convention sections + the elastic-middle header note), so a
// fresh install authors what the Living Notes UI projects. One skeleton for
// all SliceTemplateKind values; proportionality lives in the note, not in
// kind-specific PRD shapes. Conventions SSOT: docs/reference/sdlc-conventions.md.
export function renderImplementationPrdTemplate(opts: RenderOpts): string {
const raw = fs.readFileSync(resolveTemplate("implementation-prd.md"), "utf8");
return applyPlaceholders(raw, opts);
}
export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpts): string {
const filename = kind === "release" ? "mission-release.md" : "mission-placeholder.md";
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
+11 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs";
import { BUILD_INFO, type BuildInfo } from "./build-info.js";
type PackageJson = {
version?: string;
@@ -10,4 +11,13 @@ function readPackageVersion(): string {
return parsed.version ?? "0.0.0";
}
export const CLI_VERSION = readPackageVersion();
/** OPR.0.4.4.11 FR-7 — a STAMPED build prints `<semver> (<shortsha>)` (plus
* a dirty marker when dirty); an unstamped dev run prints the semver alone
* exactly as before (negative AC: no fake identity). */
export function formatCliVersion(semver: string, info: BuildInfo): string {
if (!info.commit) return semver;
const short = info.commit.slice(0, 8);
return info.dirty ? `${semver} (${short}, dirty)` : `${semver} (${short})`;
}
export const CLI_VERSION = formatCliVersion(readPackageVersion(), BUILD_INFO);
+1 -1
View File
@@ -180,7 +180,7 @@ describe("DaemonClient", () => {
cwd: resolve(import.meta.dirname, ".."),
});
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(?: \([0-9a-f]{8}(?:, dirty)?\))?$/);
});
// Test 7: Client non-2xx (409) -> returns { status: 409, data: errorBody }
@@ -327,11 +327,11 @@ describe("ps --host (cross-host short-circuit)", () => {
},
}));
await cmd.parseAsync([
"--host", "vm-a", "--nodes", "--full", "--limit", "20",
"--fields", "rigName,status", "--summary", "--filter", "status=running", "--json",
"--host", "vm-a", "--nodes", "-A", "--full", "--limit", "20",
"--fields", "rigName,sessionStatus", "--summary", "--filter", "status=running", "--json",
], { from: "user" });
expect(captureCalls.argv).toEqual([
"rig", "ps", "--nodes", "--full", "--limit", "20", "--fields", "rigName,status",
"rig", "ps", "--nodes", "--full", "--all-rigs", "--limit", "20", "--fields", "rigName,sessionStatus",
"--summary", "--filter", "status=running", "--json",
]);
});
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { LOCAL_HOST_ID, hostsCovered, type AggregatedPayload } from "../src/lib/hosts/fanout-contract.js";
const REPO_ROOT = path.resolve(import.meta.dirname, "..", "..", "..");
// OPR.0.4.4.21 × OPR.0.4.4.15 — THE intra-P4 shared fan-out contract.
// Slice 15 defines the module daemon-side (first lander, 0ecd329b); the CLI
// cannot import across the package boundary (deliberately daemon-free), so
// it carries a BYTE-IDENTICAL copy enforced here — the shipped scope-audit
// parity pattern. Any intentional contract change lands in BOTH copies and
// is a cross-PRD re-review (slices 15 + 21) per the arch adjudication.
describe("fanout-contract CLI/daemon parity (CI-FAILING)", () => {
it("fanout-contract.ts is byte-equivalent across CLI and daemon", () => {
const cliContent = fs.readFileSync(path.join(REPO_ROOT, "packages/cli/src/lib/hosts/fanout-contract.ts"), "utf-8");
const daemonContent = fs.readFileSync(path.join(REPO_ROOT, "packages/daemon/src/domain/hosts/fanout-contract.ts"), "utf-8");
expect(cliContent).toBe(daemonContent);
});
it("exports the pinned contract members", () => {
expect(LOCAL_HOST_ID).toBe("local");
const payload: AggregatedPayload<{ x: number }> = {
items: [{ x: 1 }],
hosts: [{ hostId: "h1", status: "ok" }, { hostId: "h2", status: "unsupported-transport", error: "ssh" }],
};
expect(hostsCovered(payload, ["h1", "h2"])).toBe(true);
expect(hostsCovered(payload, ["h1"])).toBe(false); // extra host = violation
expect(hostsCovered({ items: [], hosts: [] }, ["h1"])).toBe(false); // omission = violation
});
});
+125
View File
@@ -0,0 +1,125 @@
// OPR.0.4.4.18 — `rig file copy` CLI wiring: rejection surfaces render the
// core's structured errors verbatim (fail-closed, spawn NEVER invoked on any
// rejection — the spawn-spy negative), dry-run reports the planned transfer,
// JSON mode is agent-consumable and exposes the closed builder's argv.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fileCommand } from "../src/commands/file.js";
import type { FileCopyResult } from "../src/lib/file-transfer.js";
import type { SshHostEntry } from "../src/host-registry.js";
const VPS: SshHostEntry = { id: "vps-a", transport: "ssh", target: "vps-a.tail.net", user: "openrig" };
const registryLoader = (() => ({ ok: true as const, registry: { hosts: [VPS] } })) as never;
let logs: string[];
let errs: string[];
beforeEach(() => {
logs = [];
errs = [];
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => {
logs.push(a.join(" "));
});
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => {
errs.push(a.join(" "));
});
process.exitCode = 0;
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = 0;
});
function okResult(partial: Partial<FileCopyResult> = {}): FileCopyResult {
return { ok: true, failedStep: "none", exitCode: 0, stdout: "", stderr: "", ...partial };
}
async function runCopy(args: string[], run?: (plan: unknown) => Promise<FileCopyResult>) {
const runs: unknown[] = [];
const cmd = fileCommand({
registryLoader,
run: (async (plan: unknown) => {
runs.push(plan);
return run ? run(plan) : okResult({ bytesTransferred: 128, filesTransferred: 1 });
}) as never,
});
await cmd.parseAsync(["copy", ...args], { from: "user" });
return { runs };
}
describe("rig file copy — rejection surfaces (spawn NEVER invoked)", () => {
it("remote:remote rejects with the pull-then-push text; exit 1; no run", async () => {
const { runs } = await runCopy(["vps-a:/a.md", "vps-a:/b.md"]);
expect(errs.join("\n")).toContain("remote-to-remote is not in v0; pull then push");
expect(process.exitCode).toBe(1);
expect(runs).toHaveLength(0);
});
it("unknown host (the N18-1 colon-file case) fails loudly; no run", async () => {
const { runs } = await runCopy(["notes:v2.md", "./out.md"]);
expect(errs.join("\n")).toContain("unknown host id 'notes'");
expect(runs).toHaveLength(0);
expect(process.exitCode).toBe(1);
});
it("denied location + charset violations reject before any transfer; JSON mode carries {ok:false, code}", async () => {
await runCopy(["--json", "~/.ssh/id_ed25519", "vps-a:/tmp/k"]);
const payload = JSON.parse(logs[0]!) as Record<string, unknown>;
expect(payload["ok"]).toBe(false);
expect(payload["code"]).toBe("denied_path");
expect(process.exitCode).toBe(1);
logs = [];
const { runs } = await runCopy(["--json", "./a.md", "vps-a:/srv/a b.md"]);
const payload2 = JSON.parse(logs[0]!) as Record<string, unknown>;
expect(payload2["code"]).toBe("denied_path");
expect(String(payload2["error"])).toContain("a space");
expect(runs).toHaveLength(0);
});
it("leading-dash operand refused, teaching the ./ escape; no run", async () => {
const { runs } = await runCopy(["--", "--delete-after", "vps-a:/srv/x"]);
expect(errs.join("\n")).toContain("./--delete-after");
expect(runs).toHaveLength(0);
});
});
describe("rig file copy — success + dry-run rendering", () => {
it("success prints src → dst + bytes", async () => {
await runCopy(["./a.md", "vps-a:/srv/briefs/a.md"]);
expect(logs.join("\n")).toMatch(/Copied .*a\.md → vps-a:\/srv\/briefs\/a\.md \(128 bytes\)/);
expect(process.exitCode).toBe(0);
});
it("dry-run prints the planned transfer and moves nothing (native rsync --dry-run rides the plan)", async () => {
const { runs } = await runCopy(
["--dry-run", "./a.md", "vps-a:/srv/a.md"],
async () => okResult({ stdout: ">f+++++++++ a.md\nNumber of regular files transferred: 1\nTotal transferred file size: 128 bytes\n", bytesTransferred: 128, filesTransferred: 1 }),
);
expect((runs[0] as { dryRun: boolean }).dryRun).toBe(true);
const out = logs.join("\n");
expect(out).toContain("DRY RUN — nothing moved");
expect(out).toContain("→ vps-a:/srv/a.md");
expect(out).toContain("files: 1, bytes: 128");
});
it("JSON success exposes the closed builder's argv (transparency; '--' pin visible)", async () => {
await runCopy(["--json", "./a.md", "vps-a:/srv/a.md"]);
const payload = JSON.parse(logs[0]!) as { ok: boolean; rsyncArgv: string[]; src: string; dst: string };
expect(payload.ok).toBe(true);
expect(payload.dst).toBe("vps-a:/srv/a.md");
expect(payload.rsyncArgv.indexOf("--")).toBe(payload.rsyncArgv.length - 3);
});
it("transfer failure renders [failedStep] + stderr + hint; exit 1", async () => {
await runCopy(["./a.md", "vps-a:/srv/a.md"], async () =>
({ ok: false, failedStep: "permission-gate", exitCode: 255, stdout: "", stderr: "Permission denied (publickey).", hint: "See the keychain field note." }) as FileCopyResult,
);
const err = errs.join("\n");
expect(err).toContain("[permission-gate]");
expect(err).toContain("Permission denied");
expect(err).toContain("Hint:");
expect(process.exitCode).toBe(1);
});
});
+302
View File
@@ -0,0 +1,302 @@
// OPR.0.4.4.18 — the security cell, test-asserted (plan §3/§7a/§7a-2,
// guard-cleared over three rounds). The property tests ARE the contract:
// deletion-class flags unreachable, -s/--protect-args ABSENT (the G18-P3
// uniformity pin), '--' always pinned before operands (G18-P1), the remote
// charset shell-inert, every rejection before any spawn.
import { describe, it, expect } from "vitest";
import os from "node:os";
import path from "node:path";
import {
parseFilePathArg,
checkLocalPath,
checkRemotePath,
planFileCopy,
buildRsyncArgv,
classifyRsyncResult,
runFileCopy,
DENIED_SEGMENTS,
type CopyPlan,
type CopySide,
} from "../src/lib/file-transfer.js";
import type { SshHostEntry } from "../src/host-registry.js";
const VPS: SshHostEntry = { id: "vps-a", transport: "ssh", target: "vps-a.tail.net", user: "openrig" };
const REGISTRY_OK = () => ({ ok: true as const, registry: { hosts: [VPS, { id: "http-1", transport: "http" as const, url: "http://h:7433", bearer_env: "T" }] } });
function local(p: string): CopySide {
return { kind: "local", path: p };
}
function remote(p: string): CopySide {
return { kind: "remote", path: p, host: VPS };
}
// ── grammar (FR-1/FR-2/N18-1/G18-P1) ────────────────────────────────────────
describe("parseFilePathArg — the explicit-or-local grammar", () => {
it("local-prefix escapes are ALWAYS local, colon or not", () => {
for (const p of ["/abs/file.md", "./notes:v2.md", "../up.md", "~/docs/x.md", "~"]) {
expect(parseFilePathArg(p)).toEqual({ ok: true, arg: { kind: "local", path: p } });
}
});
it("id-shaped colon prefix parses as a HOST qualifier (resolve-or-fail-loud happens at planning)", () => {
expect(parseFilePathArg("vps-a:/srv/x.md")).toEqual({ ok: true, arg: { kind: "remote", hostId: "vps-a", path: "/srv/x.md" } });
expect(parseFilePathArg("mac_mini2:/tmp/y")).toEqual({ ok: true, arg: { kind: "remote", hostId: "mac_mini2", path: "/tmp/y" } });
});
it("N18-1: a bare colon-named file parses as an (unknown) host — fail-closed at planning, never silent-local", () => {
expect(parseFilePathArg("notes:v2.md")).toEqual({ ok: true, arg: { kind: "remote", hostId: "notes", path: "v2.md" } });
});
it("non-id-shaped colon prefixes are local (scp's lived behavior)", () => {
expect(parseFilePathArg("dir/sub:file.md")).toEqual({ ok: true, arg: { kind: "local", path: "dir/sub:file.md" } });
});
it("G18-P1: leading-dash operands are REFUSED with the ./ escape taught", () => {
const res = parseFilePathArg("--delete-after");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("./--delete-after");
});
it("empty remote path after the qualifier rejects", () => {
const res = parseFilePathArg("vps-a:");
expect(res.ok).toBe(false);
});
});
// ── the deny wall (FR-4) ────────────────────────────────────────────────────
describe("checkLocalPath — resolve-then-recheck deny wall", () => {
it("denies resolved paths under each short-closed-list root, incl. via traversal and ~", () => {
const home = os.homedir();
for (const seg of DENIED_SEGMENTS) {
const direct = checkLocalPath(path.join(home, seg, "x"));
expect(direct.ok).toBe(false);
const viaTilde = checkLocalPath(`~/${seg}/hosts.yaml`);
expect(viaTilde.ok).toBe(false);
// Traversal resolving INTO a denied root is caught on the RESOLVED form.
const viaTraversal = checkLocalPath(path.join(home, "safe", "..", seg, "x"));
expect(viaTraversal.ok).toBe(false);
}
});
it("denies the active hosts registry and custom OPENRIG_HOME state", () => {
const saved = process.env["OPENRIG_HOME"];
const customHome = path.join(os.tmpdir(), `openrig-s18-active-${Date.now()}`);
try {
process.env["OPENRIG_HOME"] = customHome;
const registry = checkLocalPath(path.join(customHome, "hosts.yaml"));
expect(registry.ok).toBe(false);
if (!registry.ok) expect(registry.error).toContain("active hosts registry");
const state = checkLocalPath(path.join(customHome, "workspace", "artifact.md"));
expect(state.ok).toBe(false);
if (!state.ok) expect(state.error).toContain("active OPENRIG_HOME");
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
it("allows ordinary paths and returns the RESOLVED form (what rsync receives)", () => {
const res = checkLocalPath("./some/dir/../file.md");
expect(res.ok).toBe(true);
if (res.ok) {
expect(path.isAbsolute(res.normalizedPath)).toBe(true);
expect(res.normalizedPath.includes("..")).toBe(false);
}
});
});
describe("checkRemotePath — the shell-inert charset wall (G18-P3) + absolute-only + traversal + segments", () => {
it("rejects every shell-interpretable character class, TEACHING the offending char", () => {
const cases: Array<[string, string]> = [
["/srv/a b.md", "a space"],
["/srv/$HOME/x", "'$'"],
["/srv/`id`.md", "'`'"],
["/srv/x;rm", "';'"],
["/srv/x|y", "'|'"],
["/srv/x&y", "'&'"],
["/srv/'q'.md", `"'"`.slice(1, 2) === "'" ? "'''" : "quote"],
["/srv/x>y", "'>'"],
["/srv/(x)", "'('"],
["/srv/{x}", "'{'"],
["/srv/x\\y", "'\\'"],
["/srv/x\ny", "\n"],
["/srv/*.md", "'*'"],
["/srv/x?.md", "'?'"],
["~/x.md", "'~'"],
];
for (const [p] of cases) {
const res = checkRemotePath(p, "vps-a");
expect(res.ok, `should reject: ${JSON.stringify(p)}`).toBe(false);
}
// The teaching shape, spot-checked on the space case (arch note 1).
const space = checkRemotePath("/srv/a b.md", "vps-a");
if (!space.ok) {
expect(space.error).toContain("a space");
expect(space.error).toContain("space-free staging path");
}
});
it("rejects relative remote paths (absolute-only, arch Q5)", () => {
const res = checkRemotePath("srv/x.md", "vps-a");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("ABSOLUTE-only");
});
it("G18-C1: ANY raw '..' segment rejects BEFORE normalization (normalize COLLAPSES '..' in absolute paths — a post-normalize check is dead code)", () => {
expect(checkRemotePath("/srv/../../etc/passwd", "vps-a").ok).toBe(false); // the root-climb guard caught escaping
expect(checkRemotePath("/srv/sub/../x.md", "vps-a").ok).toBe(false); // 'benign' collapsible '..' rejects too — never necessary in an absolute path
const res = checkRemotePath("/srv/..hidden/x.md", "vps-a"); // '..' as a NAME PREFIX is not a traversal segment
expect(res.ok).toBe(true);
// Normalization still collapses '.' and '//' — the normalized form is what ships.
const collapsed = checkRemotePath("/srv//sub/./x.md", "vps-a");
expect(collapsed.ok).toBe(true);
if (collapsed.ok) expect(collapsed.normalizedPath).toBe("/srv/sub/x.md");
});
it("rejects denied dot-directory segments ANYWHERE in the path (conservative-over-broad)", () => {
for (const seg of DENIED_SEGMENTS) {
expect(checkRemotePath(`/home/u/${seg}/f`, "vps-a").ok).toBe(false);
expect(checkRemotePath(`/srv/backup/${seg}/f`, "vps-a").ok).toBe(false);
}
// Also via traversal that normalizes INTO a denied segment.
expect(checkRemotePath("/srv/x/../.ssh/id_ed25519", "vps-a").ok).toBe(false);
});
});
// ── planning: the fail-closed set (all BEFORE any spawn) ────────────────────
describe("planFileCopy — fail-closed set", () => {
const deps = { registryLoader: REGISTRY_OK as never };
it("remote:remote rejects with the pull-then-push message", () => {
const res = planFileCopy("vps-a:/a.md", "vps-a:/b.md", deps);
expect(res).toMatchObject({ ok: false, code: "remote_to_remote" });
});
it("unknown host fails loudly (the N18-1 colon-file case lands here)", () => {
const res = planFileCopy("notes:v2.md", "./out.md", deps);
expect(res).toMatchObject({ ok: false, code: "unknown_host" });
if (!res.ok) expect(res.error).toContain("unknown host id 'notes'");
});
it("http-transport host rejects (ssh/rsync only in v0)", () => {
const res = planFileCopy("http-1:/x.md", "./out.md", deps);
expect(res).toMatchObject({ ok: false, code: "unsupported_transport" });
});
it("two bare paths = a local-only plan (explicit-or-local; no inference)", () => {
const res = planFileCopy("./a.md", "b.md", deps);
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.plan.src.kind).toBe("local");
expect(res.plan.dst.kind).toBe("local");
}
});
it("denied locations reject at planning on either side", () => {
expect(planFileCopy("~/.ssh/id_ed25519", "vps-a:/tmp/key", deps)).toMatchObject({ ok: false, code: "denied_path" });
expect(planFileCopy("./ok.md", "vps-a:/home/u/.openrig/db.sqlite", deps)).toMatchObject({ ok: false, code: "denied_path" });
});
});
// ── the CLOSED argv builder: property tests ─────────────────────────────────
const ADVERSARIAL_LOCALS = ["/tmp/--delete-after", "/tmp/--remove-source-files", "/tmp/-e", "/tmp/--rsh=x", "/tmp/-- evil"];
function allPlans(): CopyPlan[] {
const plans: CopyPlan[] = [];
for (const dryRun of [false, true]) {
plans.push({ src: local("/tmp/a.md"), dst: remote("/srv/a.md"), dryRun });
plans.push({ src: remote("/srv/a.md"), dst: local("/tmp/a.md"), dryRun });
plans.push({ src: local("/tmp/a.md"), dst: local("/tmp/b.md"), dryRun });
for (const adv of ADVERSARIAL_LOCALS) {
plans.push({ src: local(adv), dst: remote("/srv/x"), dryRun });
plans.push({ src: local(adv), dst: local("/tmp/y"), dryRun });
}
}
return plans;
}
describe("buildRsyncArgv — closed-builder properties (every permutation)", () => {
it("NO deletion-class flag, NO -s/--protect-args (G18-P3 absence pin), '--' ALWAYS pinned before the two operands, flag-shaped operands only in post-'--' positions (G18-P1)", () => {
for (const plan of allPlans()) {
const argv = buildRsyncArgv(plan);
const sep = argv.indexOf("--");
expect(sep, JSON.stringify(argv)).toBeGreaterThan(-1);
expect(sep).toBe(argv.length - 3); // exactly two operands after the pin
const options = argv.slice(0, sep);
for (const opt of options) {
expect(opt.startsWith("--delete")).toBe(false);
expect(opt.startsWith("--remove")).toBe(false);
expect(opt.startsWith("--force")).toBe(false);
expect(opt).not.toBe("-s");
expect(opt).not.toBe("--protect-args");
}
// Adversarial flag-shaped paths appear ONLY as operands (index > sep).
for (const adv of ADVERSARIAL_LOCALS) {
for (let i = 0; i < argv.length; i++) {
if (argv[i] === adv) expect(i).toBeGreaterThan(sep);
}
}
// Dry-run is rsync's NATIVE flag, present iff requested.
expect(options.includes("--dry-run")).toBe(plan.dryRun);
}
});
it("the -e ssh command is built ONLY from registry fields; local-local plans carry no -e at all", () => {
const withRemote = buildRsyncArgv({ src: local("/tmp/a"), dst: remote("/srv/b"), dryRun: false });
const e = withRemote[withRemote.indexOf("-e") + 1]!;
expect(e).toBe("ssh -o ConnectTimeout=10 -l openrig");
const localOnly = buildRsyncArgv({ src: local("/tmp/a"), dst: local("/tmp/b"), dryRun: false });
expect(localOnly.includes("-e")).toBe(false);
});
it("remote operands render as target:path from the registry target", () => {
const argv = buildRsyncArgv({ src: local("/tmp/a.md"), dst: remote("/srv/briefs/a.md"), dryRun: false });
expect(argv[argv.length - 1]).toBe("vps-a.tail.net:/srv/briefs/a.md");
});
});
// ── classification (executor taxonomy minus the daemon class) ───────────────
describe("classifyRsyncResult / runFileCopy", () => {
it("exit 0 → ok with --stats parsed", () => {
const res = classifyRsyncResult(0, "Number of regular files transferred: 1\nTotal transferred file size: 2,048 bytes\n", "");
expect(res).toMatchObject({ ok: true, failedStep: "none", bytesTransferred: 2048, filesTransferred: 1 });
});
it("permission-gate stderr signature → permission-gate (single-source via the exported executor matcher)", () => {
expect(classifyRsyncResult(255, "", "user@host: Permission denied (publickey).").failedStep).toBe("permission-gate");
});
it("exit 255 / connection-class stderr → ssh-unreachable; other non-zero → remote-command-failed; NO daemon class exists", () => {
expect(classifyRsyncResult(255, "", "kex_exchange: Connection refused").failedStep).toBe("ssh-unreachable");
expect(classifyRsyncResult(12, "", "rsync: connection unexpectedly closed").failedStep).toBe("ssh-unreachable");
expect(classifyRsyncResult(23, "", "rsync: some files could not be transferred").failedStep).toBe("remote-command-failed");
});
it("missing local rsync (spawn ENOENT) → rsync-missing with the install hint (arch Q6)", async () => {
const fakeSpawn = (() => {
const handlers: Record<string, (arg?: unknown) => void> = {};
return {
stdout: { on: () => {} },
stderr: { on: () => {} },
on: (evt: string, cb: (arg?: unknown) => void) => {
handlers[evt] = cb;
if (evt === "close") {
const err = new Error("spawn rsync ENOENT") as NodeJS.ErrnoException;
err.code = "ENOENT";
handlers["error"]?.(err);
}
},
};
}) as never;
const res = await runFileCopy({ src: local("/tmp/a"), dst: local("/tmp/b"), dryRun: true }, { spawn: fakeSpawn });
expect(res.failedStep).toBe("rsync-missing");
expect(res.hint).toContain("brew install rsync");
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { addHostEntry, loadHostRegistry, validateHostRegistry } from "../src/host-registry.js";
// OPR.0.4.4.13 FR-1 — the registry write path: ONE validation source (the
// loader's rules, verbatim), atomic write, duplicate refusal, and the
// qa1-guard ROUND-TRIP proof (add → loader reads back the identical entry).
describe("addHostEntry (rig host add write path)", () => {
let dir: string;
let regPath: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostreg-"));
regPath = path.join(dir, "hosts.yaml");
});
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));
it("round-trip: add on a MISSING file creates it and the loader reads back the identical entry", () => {
const res = addHostEntry({ id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig" }, regPath);
expect(res.ok).toBe(true);
const loaded = loadHostRegistry(regPath);
expect(loaded.ok).toBe(true);
if (loaded.ok) {
expect(loaded.registry.hosts).toEqual([{ id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig" }]);
}
});
it("round-trip: http entry with bearer_env survives identically", () => {
const res = addHostEntry({ id: "h1", transport: "http", url: "http://100.1.2.3:7433", bearer_env: "H1_TOKEN", notes: "factory" }, regPath);
expect(res.ok).toBe(true);
const loaded = loadHostRegistry(regPath);
expect(loaded.ok && loaded.registry.hosts[0]).toEqual({ id: "h1", transport: "http", url: "http://100.1.2.3:7433", bearer_env: "H1_TOKEN", notes: "factory" });
});
it("validation parity: add-time errors ARE the loader's errors (same fixture, same message)", () => {
const badEntry = { id: "h1", transport: "http", url: "http://x", bearer_env: "A", bearer_file: "/b" };
const addRes = addHostEntry(badEntry, regPath);
const loadRes = validateHostRegistry({ hosts: [badEntry] }, regPath);
expect(addRes.ok).toBe(false);
expect(loadRes.ok).toBe(false);
if (!addRes.ok && !loadRes.ok) expect(addRes.error).toBe(loadRes.error);
});
it("refuses duplicate host ids with the loader's own duplicate error", () => {
expect(addHostEntry({ id: "dup", transport: "ssh", target: "a" }, regPath).ok).toBe(true);
const res = addHostEntry({ id: "dup", transport: "ssh", target: "b" }, regPath);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("duplicate host id 'dup'");
});
it("refuses to modify a present-but-invalid registry (never clobbers operator state)", () => {
fs.writeFileSync(regPath, "hosts: {not-an-array: true}\n");
const res = addHostEntry({ id: "h1", transport: "ssh", target: "a" }, regPath);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("refusing to modify an invalid registry");
expect(fs.readFileSync(regPath, "utf-8")).toContain("not-an-array");
});
it("appends to an existing valid registry, preserving prior entries", () => {
addHostEntry({ id: "a", transport: "ssh", target: "a.host" }, regPath);
addHostEntry({ id: "b", transport: "http", url: "http://b", bearer_file: "/tok" }, regPath);
const loaded = loadHostRegistry(regPath);
expect(loaded.ok && loaded.registry.hosts.map((h) => h.id)).toEqual(["a", "b"]);
});
it("transport-appropriate field validation fires at add-time (ssh without target)", () => {
const res = addHostEntry({ id: "x", transport: "ssh" }, regPath);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("target: required");
});
});
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { Command } from "commander";
import { hostCommand, doctorLegs, postureCheck, type DoctorDeps, type CheckRow } from "../src/commands/host.js";
import type { HostEntry } from "../src/host-registry.js";
import type { CrossHostResult } from "../src/cross-host-executor.js";
// OPR.0.4.4.13 FR-1 — rig host add / list. Registry path is steered via
// OPENRIG_HOME so tests never touch the operator's real ~/.openrig.
describe("rig host add/list", () => {
let dir: string;
let savedHome: string | undefined;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostcmd-"));
savedHome = process.env.OPENRIG_HOME;
process.env.OPENRIG_HOME = dir;
});
afterEach(() => {
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = savedHome;
fs.rmSync(dir, { recursive: true, force: true });
});
function capture(fn: () => Promise<void>): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const out: string[] = []; const err: string[] = [];
const ol = console.log; const oe = console.error; const oc = process.exitCode;
process.exitCode = undefined;
console.log = (...a: unknown[]) => out.push(a.join(" "));
console.error = (...a: unknown[]) => err.push(a.join(" "));
try { await fn(); } finally { console.log = ol; console.error = oe; }
const exitCode = process.exitCode as number | undefined;
process.exitCode = oc;
resolve({ out, err, exitCode });
});
}
function run(argv: string[]) {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand());
return prog.parseAsync(["node", "rig", "host", ...argv]);
}
it("add writes the entry and teaches the doctor next step", async () => {
const { out, exitCode } = await capture(() => run(["add", "--id", "vps-1", "--transport", "ssh", "--target", "vps-1.tailnet", "--user", "openrig"]));
expect(exitCode).toBeUndefined();
expect(out.join("\n")).toContain("Added host 'vps-1' (ssh)");
expect(out.join("\n")).toContain("rig host doctor vps-1");
});
it("add surfaces the loader's own validation error at add-time (both bearers)", async () => {
const { err, exitCode } = await capture(() => run(["add", "--id", "h1", "--transport", "http", "--url", "http://x", "--bearer-env", "A", "--bearer-file", "/b"]));
expect(exitCode).toBe(1);
expect(err.join("\n")).toContain("exactly one of bearer_env or bearer_file");
});
it("list renders pointers and NEVER a resolved secret value (qa1 hygiene guard)", async () => {
vi.stubEnv("FACTORY_TOKEN", "super-secret-bearer-value");
await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN"]));
const { out } = await capture(() => run(["list"]));
const text = out.join("\n");
expect(text).toContain("env:FACTORY_TOKEN");
expect(text).not.toContain("super-secret-bearer-value");
const { out: jsonOut } = await capture(() => run(["list", "--json"]));
expect(jsonOut.join("")).toContain("FACTORY_TOKEN");
expect(jsonOut.join("")).not.toContain("super-secret-bearer-value");
});
it("list on an empty registry teaches add", async () => {
const { out } = await capture(() => run(["list"]));
expect(out.join("\n")).toContain("rig host add --id");
});
});
// ---------------------------------------------------------------------------
// OPR.0.4.4.13 FR-1/FR-2 — doctor legs + posture (mocked deps; no network).
// ---------------------------------------------------------------------------
describe("rig host doctor — stepwise distinct errors + three-valued posture", () => {
// Same OPENRIG_HOME steering as the add/list suite: the doctor-CLI case
// seeds a registry via `host add` and must NEVER touch the operator's
// real ~/.openrig (the shared-singleton doctrine).
let dir: string;
let savedHome: string | undefined;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostdoc-"));
savedHome = process.env.OPENRIG_HOME;
process.env.OPENRIG_HOME = dir;
});
afterEach(() => {
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = savedHome;
fs.rmSync(dir, { recursive: true, force: true });
});
const sshHost: HostEntry = { id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig" };
const okRun = (stdout: string): CrossHostResult => ({ ok: true, failedStep: "none", stdout, stderr: "", remoteExitCode: 0 });
const failRun = (): CrossHostResult => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "not found", remoteExitCode: 127 });
function depsFromScript(script: (argv: readonly string[]) => CrossHostResult, probe: "open" | "closed" = "closed"): DoctorDeps {
return {
run: async (_h, argv) => script(argv),
httpGet: async () => ({ status: 200, body: "ok" }),
tcpProbe: async () => probe,
};
}
it("distinct error: SSH unreachable stops at the first leg", async () => {
const deps = depsFromScript(() => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "connect refused" }));
const rows = await doctorLegs(sshHost, deps);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ step: "transport-reachability", status: "fail" });
expect(rows[0]!.fix).toContain("ssh");
});
it("distinct error: SSH works but rig binary missing", async () => {
const deps = depsFromScript((argv) => (argv[0] === "true" ? okRun("") : failRun()));
const rows = await doctorLegs(sshHost, deps);
expect(rows[0]!.status).toBe("pass");
expect(rows[1]).toMatchObject({ step: "remote-rig-binary", status: "fail" });
expect(rows[1]!.fix).toContain("npm install -g @openrig/cli");
});
it("distinct error: rig installed but daemon down", async () => {
const deps = depsFromScript((argv) => {
if (argv[0] === "true") return okRun("");
if (argv[1] === "--version") return okRun("0.4.4");
if (argv[1] === "daemon") return okRun("Daemon stopped");
return okRun("{}");
});
const rows = await doctorLegs(sshHost, deps);
expect(rows[2]).toMatchObject({ step: "remote-daemon-health", status: "fail" });
expect(rows[2]!.fix).toContain("rig daemon start");
expect(rows).toHaveLength(3); // identity leg not attempted without a daemon
});
it("all legs green when the remote answers", async () => {
const calls: string[][] = [];
const deps = depsFromScript((argv) => {
calls.push([...argv]);
if (argv[0] === "true") return okRun("");
if (argv[1] === "--version") return okRun("0.4.4");
if (argv[1] === "daemon") return okRun("Daemon running (pid 1)");
if (argv[1] === "ps") return okRun(JSON.stringify({ entries: [{ rigName: "kernel" }] }));
return failRun();
});
const rows = await doctorLegs(sshHost, deps);
expect(rows.map((r) => r.status)).toEqual(["pass", "pass", "pass", "pass"]);
expect(calls).toContainEqual(["rig", "ps", "--json", "--limit", "5"]);
expect(calls).not.toContainEqual(["rig", "whoami", "--json"]);
});
it("posture: UNKNOWN is never pass — unreadable sshd/ufw report unknown WITH fixes", async () => {
const deps = depsFromScript((argv) => {
const cmd = String(argv[2] ?? "");
if (cmd.startsWith("id -u")) return okRun("1001");
return okRun(""); // sshd -T, ufw, tailscale, ss all unreadable
});
const rows = await postureCheck(sshHost, deps);
const byStep = Object.fromEntries(rows.map((r) => [r.step, r]));
expect(byStep["nonroot-openrig-user"]!.status).toBe("pass");
for (const step of ["root-ssh-disabled", "key-only-ssh", "ufw-default-deny-incoming", "tailnet-ingress-allowed", "tailscale-minimal-trust", "daemon-bind-not-public"]) {
expect(byStep[step]!.status, step).toBe("unknown");
expect(byStep[step]!.fix, step).toBeTruthy();
}
// no public addr given -> probes are unknown, never silently pass
expect(byStep["public-daemon-port-unreachable"]!.status).toBe("unknown");
});
it("posture: publicly reachable daemon port FAILS LOUDLY (the smoke-test hardening bar)", async () => {
const deps = depsFromScript(() => okRun(""), "open");
const rows = await postureCheck(sshHost, deps, { publicAddr: "203.0.113.7" });
const probe = rows.find((r) => r.step === "public-daemon-port-unreachable")!;
expect(probe.status).toBe("fail");
expect(probe.detail).toContain("203.0.113.7:7433");
});
// R2-B1 regressions — the two false-green classes, pinned.
it("posture: a DENY rule on tailscale0 must NOT pass the ingress item", async () => {
const deps = depsFromScript((argv) => {
const cmd = String(argv[2] ?? "");
if (cmd.includes("ufw status")) return okRun("Status: active\nDefault: deny (incoming), allow (outgoing)\nAnywhere on tailscale0 DENY IN Anywhere\n");
return okRun("");
});
const rows = await postureCheck(sshHost, deps);
const ingress = rows.find((r) => r.step === "tailnet-ingress-allowed")!;
expect(ingress.status).toBe("fail");
expect(ingress.detail).toContain("no ALLOW IN");
});
it("posture: daemon bound to a SPECIFIC public IP fails exactly like a wildcard", async () => {
const deps = depsFromScript((argv) => {
const cmd = String(argv[2] ?? "");
if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 148.113.200.37:7433 0.0.0.0:*");
return okRun("");
});
const rows = await postureCheck(sshHost, deps);
const bind = rows.find((r) => r.step === "daemon-bind-not-public")!;
expect(bind.status).toBe("fail");
expect(bind.detail).toContain("148.113.200.37");
});
it("posture: tailnet CGNAT bind (100.64/10) passes; out-of-range 100.x fails", async () => {
const mk = (addr: string) => depsFromScript((argv) => {
const cmd = String(argv[2] ?? "");
if (cmd.includes("ss -tln")) return okRun(`LISTEN 0 511 ${addr}:7433 0.0.0.0:*`);
return okRun("");
});
const inRange = await postureCheck(sshHost, mk("100.103.199.127"));
expect(inRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("pass");
const outOfRange = await postureCheck(sshHost, mk("100.1.2.3"));
expect(outOfRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("fail");
});
it("posture: multi-listener where ONE is public fails, naming the offender", async () => {
const deps = depsFromScript((argv) => {
const cmd = String(argv[2] ?? "");
if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 127.0.0.1:7433 0.0.0.0:*\nLISTEN 0 511 203.0.113.9:7433 0.0.0.0:*");
return okRun("");
});
const rows = await postureCheck(sshHost, deps);
const bind = rows.find((r) => r.step === "daemon-bind-not-public")!;
expect(bind.status).toBe("fail");
expect(bind.detail).toContain("203.0.113.9");
expect(bind.detail).not.toContain("127.0.0.1:");
});
it("posture: Tailscale SSH enabled FAILS the minimal-trust item", async () => {
const deps = depsFromScript((argv) => {
const cmd = String(argv[2] ?? "");
if (cmd.includes("tailscale status")) return okRun(JSON.stringify({ Self: { PrimaryRoutes: [] } }));
if (cmd.includes("tailscale debug prefs")) return okRun(JSON.stringify({ RunSSH: true }));
return okRun("");
});
const rows = await postureCheck(sshHost, deps);
const ts = rows.find((r) => r.step === "tailscale-minimal-trust")!;
expect(ts.status).toBe("fail");
expect(ts.detail).toContain("Tailscale SSH enabled: true");
expect(ts.fix).toContain("tailscale set --ssh=false");
});
it("posture: green path parses real-shaped outputs", async () => {
const deps = depsFromScript((argv) => {
const cmd = String(argv[2] ?? "");
if (cmd.startsWith("id -u")) return okRun("1001");
if (cmd.includes("sshd -T")) return okRun("permitrootlogin no\npasswordauthentication no\n");
if (cmd.includes("ufw status")) return okRun("Status: active\nDefault: deny (incoming), allow (outgoing)\nAnywhere on tailscale0 ALLOW IN Anywhere\n");
if (cmd.includes("tailscale status")) return okRun(JSON.stringify({ Self: { PrimaryRoutes: [] } }));
if (cmd.includes("tailscale debug prefs")) return okRun(JSON.stringify({ RunSSH: false }));
if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 127.0.0.1:7433 0.0.0.0:*");
return okRun("");
});
const rows = await postureCheck(sshHost, deps, { publicAddr: "203.0.113.7" });
const nonPass = rows.filter((r) => r.status !== "pass");
expect(nonPass).toEqual([]);
});
it("posture over http transport: EVERY item unknown with the ssh fix (honest, no shell)", async () => {
const httpHost: HostEntry = { id: "h1", transport: "http", url: "http://x", bearer_env: "T" };
const rows = await postureCheck(httpHost, { run: async () => okRun(""), httpGet: async () => ({ status: 200, body: "" }), tcpProbe: async () => "closed" });
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r: CheckRow) => r.status === "unknown")).toBe(true);
});
it("doctor CLI: unknown host id is the DISTINCT registry error", async () => {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand({ run: async () => okRun(""), httpGet: async () => ({ status: 200, body: "" }), tcpProbe: async () => "closed" }));
const err: string[] = [];
const oe = console.error; const oc = process.exitCode; process.exitCode = undefined;
console.error = (...a: unknown[]) => err.push(a.join(" "));
try {
await capturePrelude();
await prog.parseAsync(["node", "rig", "host", "doctor", "nope"]);
} finally { console.error = oe; }
const code = process.exitCode; process.exitCode = oc;
expect(code).toBe(1);
expect(err.join("\n")).toContain("registry:");
expect(err.join("\n")).toContain("unknown host id 'nope'");
});
// seed a registry for the doctor CLI test above
async function capturePrelude(): Promise<void> {
const prog = new Command();
prog.exitOverride();
prog.addCommand(hostCommand());
const ol = console.log; console.log = () => {};
try { await prog.parseAsync(["node", "rig", "host", "add", "--id", "seeded", "--transport", "ssh", "--target", "s.host"]); } finally { console.log = ol; }
}
});
+1 -1
View File
@@ -306,7 +306,7 @@ describe("Preflight CLI", () => {
const homeDir = join(tmpDir, ".openrig");
mkdirSync(homeDir, { recursive: true });
const cmd = preflightCommand({
exec: makeExec(false), // tmux missing
exec: makeExec("missing"),
configPath: join(tmpDir, "config.json"),
riggedHome: homeDir,
getDaemonStatus: makeStatus(),
+258
View File
@@ -0,0 +1,258 @@
// OPR.0.4.4.19 FR-8 + FR-11 — rig proof add: C1 header validation at drop
// time, D2 attestation echo, contract + C8 advisories (advise-never-block).
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import YAML from "yaml";
import {
proofCommand,
validateC1Header,
parseProofContract,
C1_ARTIFACT_TYPES,
C1_VERDICTS,
} from "../src/commands/proof.js";
describe("validateC1Header (pure)", () => {
const valid = {
slice: "OPR.0.4.4.19",
candidate_sha: "abc1234",
artifact_type: "qa",
verdict: "CLEAR",
money_evidence: "park->resolve walk transitions row shows decision text",
};
it("accepts the five required fields with closed-set values", () => {
expect(validateC1Header(valid).ok).toBe(true);
});
it("names every missing field", () => {
const r = validateC1Header({ artifact_type: "qa" });
expect(r.ok).toBe(false);
expect(r.missing).toEqual(["slice", "candidate_sha", "verdict", "money_evidence"]);
});
it("rejects out-of-set artifact_type/verdict naming the allowed values (BR-4 closed sets)", () => {
const r = validateC1Header({ ...valid, artifact_type: "designer", verdict: "SHIP-IT" });
expect(r.ok).toBe(false);
expect(r.invalid).toHaveLength(2);
expect(r.invalid[0]!.allowed).toEqual(C1_ARTIFACT_TYPES);
expect(r.invalid[1]!.allowed).toEqual(C1_VERDICTS);
});
});
describe("parseProofContract (pure)", () => {
it("returns null when no ## Proof contract section exists (zero-noise degrade)", () => {
expect(parseProofContract("# PRD\n\n## Acceptance\n- [ ] thing\n")).toBeNull();
});
it("parses checkbox items until the next section", () => {
const prd = [
"# PRD",
"## Proof contract",
"- [ ] the live park->resolve walk with the transitions row shown",
"- [x] approve run showing frontmatter + audit row together",
"- plain item without checkbox",
"## Next section",
"- [ ] NOT a contract item",
].join("\n");
expect(parseProofContract(prd)).toEqual([
"the live park->resolve walk with the transitions row shown",
"approve run showing frontmatter + audit row together",
"plain item without checkbox",
]);
});
});
describe("rig proof add (fs-level, temp workspace)", () => {
let workRoot: string;
let sliceDir: string;
let logs: string[];
let errs: string[];
beforeEach(() => {
workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "proof-test-"));
const missionDir = path.join(workRoot, "missions", "release-x", "slices", "19-signal-layer");
fs.mkdirSync(missionDir, { recursive: true });
fs.writeFileSync(path.join(workRoot, "missions", "release-x", "README.md"), "---\nid: OPR.X\n---\n# m\n");
sliceDir = missionDir;
fs.writeFileSync(
path.join(sliceDir, "README.md"),
"---\nid: OPR.X.19\nstatus: building\n---\n# slice\n",
);
logs = [];
errs = [];
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { logs.push(a.join(" ")); });
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.join(" ")); });
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(workRoot, { recursive: true, force: true });
process.exitCode = undefined;
});
async function run(args: string[]): Promise<void> {
const cmd = proofCommand();
cmd.exitOverride();
await cmd.parseAsync(["node", "proof", "--workspace", workRoot, ...args]);
}
const baseArgs = (extra: string[] = []) => [
"add", "19-signal-layer", "--mission", "release-x",
"--artifact-type", "qa", "--verdict", "CLEAR",
"--candidate-sha", "abc1234",
"--money-evidence", "one line of money",
"--body", "evidence body",
"--name", "qa-clear.md",
...extra,
];
it("happy drop: writes proof/<name> with valid YAML frontmatter and echoes the parsed header; exit 0", async () => {
await run(baseArgs());
const target = path.join(sliceDir, "proof", "qa-clear.md");
expect(fs.existsSync(target)).toBe(true);
const content = fs.readFileSync(target, "utf8");
const fm = content.split("---")[1]!;
const parsed = YAML.parse(fm) as Record<string, unknown>;
expect(parsed.slice).toBe("OPR.X.19");
expect(parsed.candidate_sha).toBe("abc1234");
expect(parsed.artifact_type).toBe("qa");
expect(parsed.verdict).toBe("CLEAR");
expect(content).toContain("evidence body");
expect(logs.join("\n")).toContain("Parsed C1 header");
expect(process.exitCode).toBeUndefined();
});
it("out-of-set verdict is rejected naming the allowed values; nothing written; exit 1", async () => {
await run([
"add", "19-signal-layer", "--mission", "release-x",
"--artifact-type", "qa", "--verdict", "SHIP-IT",
"--candidate-sha", "abc1234", "--money-evidence", "m", "--body", "b",
]);
expect(process.exitCode).toBe(1);
expect(errs.join("\n")).toContain("closed set");
expect(fs.existsSync(path.join(sliceDir, "proof"))).toBe(false);
});
it("D2: evidences + self_check are parsed and echoed; unknown refs warn (never reject)", async () => {
fs.writeFileSync(
path.join(sliceDir, "IMPLEMENTATION-PRD.md"),
"# PRD\n## Proof contract\n- [ ] item one\n- [ ] item two\n",
);
await run(baseArgs(["--evidences", "1,bogus-ref", "--self-check", "I looked; the walk shows the decision text"]));
expect(process.exitCode).toBeUndefined();
const content = fs.readFileSync(path.join(sliceDir, "proof", "qa-clear.md"), "utf8");
expect(content).toContain("self_check");
expect(errs.join("\n")).toContain("bogus-ref");
});
it("contract advisory: declared contract + no covered item/self_check => drop SUCCEEDS with advisory naming uncovered items", async () => {
fs.writeFileSync(
path.join(sliceDir, "IMPLEMENTATION-PRD.md"),
"# PRD\n## Proof contract\n- [ ] item one\n",
);
await run(baseArgs());
expect(process.exitCode).toBeUndefined();
expect(fs.existsSync(path.join(sliceDir, "proof", "qa-clear.md"))).toBe(true);
expect(errs.join("\n")).toContain("ADVISORY (D2");
expect(errs.join("\n")).toContain("item one");
});
it("zero noise: NO contract declared => no contract advisory", async () => {
await run(baseArgs());
expect(errs.join("\n")).not.toContain("ADVISORY (D2");
});
it("C8: ux-change slice + no video => drop succeeds with the screencast advisory; exit 0", async () => {
fs.writeFileSync(
path.join(sliceDir, "README.md"),
"---\nid: OPR.X.19\nstatus: building\nux-change: true\n---\n# slice\n",
);
await run(baseArgs());
expect(process.exitCode).toBeUndefined();
expect(errs.join("\n")).toContain("ADVISORY (C8");
expect(errs.join("\n")).toContain("agent-browser-screencast");
});
it("C8 zero noise: no ux-change flag => no video advisory; existing video also silences it", async () => {
await run(baseArgs());
expect(errs.join("\n")).not.toContain("ADVISORY (C8");
// Now flag the slice AND plant a video — advisory stays silent.
fs.writeFileSync(
path.join(sliceDir, "README.md"),
"---\nid: OPR.X.19\nux-change: true\n---\n# slice\n",
);
fs.writeFileSync(path.join(sliceDir, "proof", "walk.mp4"), "fake video bytes");
errs.length = 0;
await run(baseArgs(["--name", "qa-clear-2.md"]));
expect(errs.join("\n")).not.toContain("ADVISORY (C8");
});
});
// rev1-r2 BLOCKING regression (candidate a7dedd93 review): --name must be a
// filename, never a path — a traversal name can never escape proof/.
describe("rig proof add --name traversal rejection (rev1-r2 fixback)", () => {
let workRoot: string;
let sliceDir: string;
let errs: string[];
beforeEach(() => {
workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "proof-trav-"));
sliceDir = path.join(workRoot, "missions", "release-x", "slices", "19-signal-layer");
fs.mkdirSync(sliceDir, { recursive: true });
fs.writeFileSync(path.join(workRoot, "missions", "release-x", "README.md"), "---\nid: OPR.X\n---\n# m\n");
fs.writeFileSync(path.join(sliceDir, "README.md"), "---\nid: OPR.X.19\nstatus: building\n---\n# ORIGINAL README BODY\n");
errs = [];
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { errs.push(a.join(" ")); });
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(workRoot, { recursive: true, force: true });
process.exitCode = undefined;
});
async function runAdd(name: string): Promise<void> {
const cmd = proofCommand();
cmd.exitOverride();
await cmd.parseAsync([
"node", "proof", "--workspace", workRoot,
"add", "19-signal-layer", "--mission", "release-x",
"--artifact-type", "qa", "--verdict", "CLEAR",
"--candidate-sha", "abc1234", "--money-evidence", "m",
"--body", "traversal attempt body",
"--name", name,
]);
}
it("--name ../README.md is REJECTED and the slice README is NOT modified", async () => {
const readmePath = path.join(sliceDir, "README.md");
const before = fs.readFileSync(readmePath, "utf8");
await runAdd("../README.md");
expect(process.exitCode).toBe(1);
expect(errs.join("\n")).toContain("not a plain filename");
expect(fs.readFileSync(readmePath, "utf8")).toBe(before);
// Nothing landed in proof/ either.
const proofDir = path.join(sliceDir, "proof");
expect(!fs.existsSync(proofDir) || fs.readdirSync(proofDir).length === 0).toBe(true);
});
it("other escape shapes are rejected: nested path, backslash, absolute, dot-dot", async () => {
for (const name of ["sub/dir.md", "..\\\\evil.md", "/tmp/abs.md", ".."]) {
process.exitCode = undefined;
await runAdd(name);
expect(process.exitCode, `name '${name}' must be rejected`).toBe(1);
}
});
it("a plain filename still drops normally after the fix", async () => {
await runAdd("qa-clear.md");
expect(process.exitCode).toBeUndefined();
expect(fs.existsSync(path.join(sliceDir, "proof", "qa-clear.md"))).toBe(true);
});
});
+119 -9
View File
@@ -112,9 +112,11 @@ describe("OPR.0.4.0.34 — rig ps current-rig default", () => {
let server: http.Server;
let port: number;
let savedSession: string | undefined;
const requestLog: string[] = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
requestLog.push(req.url ?? "");
const m = req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes/);
if (m && req.method === "GET") {
const rigId = decodeURIComponent(m[1]!);
@@ -152,18 +154,40 @@ describe("OPR.0.4.0.34 — rig ps current-rig default", () => {
process.env.OPENRIG_SESSION_NAME = name;
}
// AC-1: rig-level defaults to current rig
it("AC-1: bare rig ps --json returns only the caller's rig", async () => {
// OPR.0.4.4.21 FR-1: the rig tier is consolidated ALL-rigs by default —
// the current-rig-only default is RETIRED (it hid running rigs from the
// operator's field of view). Session context no longer narrows the rig tier.
it("OPR.0.4.4.21 AC-1: bare rig ps --json returns ALL rigs (consolidated default; bare array)", async () => {
setSession("dev1-driver@openrig-delivery");
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBe(1);
expect(parsed[0].rigName ?? parsed[0].name).toBe("openrig-delivery");
expect(parsed.length).toBe(2);
const names = parsed.map((e: { rigName?: string; name: string }) => e.rigName ?? e.name).sort();
expect(names).toEqual(["openrig-comms", "openrig-delivery"]);
});
// OPR.0.4.4.21 §1 — THE token-safety invariant: the consolidated default
// fetches NO per-node records (one O(rigs) call; no /api/rigs/:id/nodes).
it("OPR.0.4.4.21 invariant: bare rig ps fetches zero per-node records", async () => {
setSession("dev1-driver@openrig-delivery");
requestLog.length = 0;
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps"]);
});
expect(requestLog.length).toBeGreaterThan(0);
expect(requestLog.filter((u) => u.includes("/nodes"))).toEqual([]);
// and the same for JSON encoding
requestLog.length = 0;
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json"]);
});
expect(requestLog.filter((u) => u.includes("/nodes"))).toEqual([]);
});
// AC-1: node-level defaults to current rig
it("AC-1: bare rig ps --nodes --json returns only the current rig's nodes", async () => {
setSession("dev1-driver@openrig-delivery");
@@ -178,14 +202,100 @@ describe("OPR.0.4.0.34 — rig ps current-rig default", () => {
}
});
// AC-1: -A restores fleet breadth
it("AC-1: -A shows all rigs", async () => {
// OPR.0.4.4.21 FR-3: -A has exactly ONE meaning — the --nodes fleet
// widener. Bare -A is a structured teaching error naming the new default,
// the --nodes -A rung, and --include-archived for history.
it("OPR.0.4.4.21 FR-3: -A without --nodes errors with the new grammar", async () => {
setSession("dev1-driver@openrig-delivery");
const { logs } = await captureLogs(async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "-A"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.length).toBe(2);
const output = logs.join("\n");
expect(exitCode).toBe(1);
expect(output).toContain("exactly one meaning");
expect(output).toContain("rig ps --nodes -A");
expect(output).toContain("--include-archived");
});
// OPR.0.4.4.21 FR-2 — the no-context error (local): outside a managed
// session, --nodes has no current rig to default to; explicit-or-error.
it("OPR.0.4.4.21 FR-2: --nodes outside session context errors asking for a target", async () => {
savedSession = process.env.OPENRIG_SESSION_NAME;
delete process.env.OPENRIG_SESSION_NAME;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
});
const output = logs.join("\n");
expect(exitCode).toBe(1);
expect(output).toContain("no target");
expect(output).toContain("--rig <name>");
expect(output).toContain("rig ps --nodes -A");
});
// OPR.0.4.4.21 FR-2/FR-5 — remote combos error BEFORE any dispatch
// (implicit scope defaults don't cross host boundaries). No host registry
// or HTTP fixture is needed: the validator fires first.
it("OPR.0.4.4.21: --host h --nodes without --rig/-A errors (session default never crosses hosts)", async () => {
setSession("dev1-driver@openrig-delivery");
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--host", "vm-1", "--nodes"]);
});
const output = logs.join("\n");
expect(exitCode).toBe(1);
expect(output).toContain("not a remote scope");
expect(output).toContain("--rig <name>");
});
it("OPR.0.4.4.21: --all-hosts --nodes without -A errors (full ladder taught)", async () => {
setSession("dev1-driver@openrig-delivery");
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes"]);
});
const output = logs.join("\n");
expect(exitCode).toBe(1);
expect(output).toContain("FULL explicit ladder");
expect(output).toContain("--nodes -A");
});
it("OPR.0.4.4.21 FR-3: --host h -A (no --nodes) errors on the remote path too", async () => {
setSession("dev1-driver@openrig-delivery");
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--host", "vm-1", "-A"]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("exactly one meaning");
});
it("OPR.0.4.4.21 FR-3: --all-hosts -A (no --nodes) errors on the fan-out path too", async () => {
setSession("dev1-driver@openrig-delivery");
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--all-hosts", "-A"]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("exactly one meaning");
});
// Positive rungs: the explicit ladder still works.
it("OPR.0.4.4.21 FR-2 positive: --nodes -A works WITHOUT session context (explicit fleet)", async () => {
savedSession = process.env.OPENRIG_SESSION_NAME;
delete process.env.OPENRIG_SESSION_NAME;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json"]);
});
expect(exitCode).toBeUndefined();
const nodes = JSON.parse(logs.join(""));
expect(nodes.length).toBe(5); // 3 delivery + 2 comms — the whole fixture fleet
});
it("OPR.0.4.4.21 FR-2 positive: --nodes --rig <name> works WITHOUT session context (explicit target)", async () => {
savedSession = process.env.OPENRIG_SESSION_NAME;
delete process.env.OPENRIG_SESSION_NAME;
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--rig", "openrig-comms", "--json"]);
});
expect(exitCode).toBeUndefined();
const nodes = JSON.parse(logs.join(""));
expect(nodes.length).toBe(2);
});
// AC-1: explicit --rig overrides caller default
+195 -10
View File
@@ -84,7 +84,9 @@ describe("rig ps --host HTTP", () => {
]),
};
await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--json"]);
// OPR.0.4.4.21 FR-2: remote --nodes requires an explicit target
// (implicit scope defaults don't cross host boundaries).
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--rig", "test-rig", "--json"]);
});
const nodesCalls = client._calls.filter((c) => c.path.includes("/nodes"));
expect(nodesCalls.length).toBe(1);
@@ -195,7 +197,7 @@ describe("rig ps --host HTTP", () => {
]),
};
const { stdout } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--json"]);
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "-A", "--json"]);
});
const parsed = JSON.parse(stdout.join(""));
expect(Array.isArray(parsed)).toBe(true);
@@ -221,7 +223,7 @@ describe("rig ps --host HTTP", () => {
]),
};
const { stdout } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--json", "--limit", "1", "--fields", "logicalId,rigName"]);
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--rig", "alpha", "--json", "--limit", "1", "--fields", "logicalId,rigName"]);
});
const parsed = JSON.parse(stdout.join(""));
expect(parsed.entries).toBeDefined();
@@ -248,7 +250,7 @@ describe("rig ps --host HTTP", () => {
]),
};
const { stdout } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--summary", "--limit", "1", "--json"]);
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--host", "host-b", "--nodes", "--rig", "alpha", "--summary", "--limit", "1", "--json"]);
});
const parsed = JSON.parse(stdout.join(""));
expect(parsed.totalNodes).toBe(3);
@@ -308,12 +310,195 @@ describe("rig ps --all-hosts fan-out", () => {
const { stdout } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--hosts", "ssh-host", "--json"]);
});
const output = stdout.join("");
if (output.includes("hosts")) {
const parsed = JSON.parse(output);
const sshEntry = parsed.hosts?.find((h: { host: string }) => h.host === "ssh-host");
// OPR.0.4.4.21 FR-5 — the shared P4 contract: an SSH-declared host is a
// STRUCTURED unsupported-transport status in hosts[], never silence.
const parsed = JSON.parse(stdout.join(""));
expect(Array.isArray(parsed.items)).toBe(true);
const sshEntry = parsed.hosts.find((h: { hostId: string }) => h.hostId === "ssh-host");
expect(sshEntry).toBeDefined();
expect(sshEntry.ok).toBe(false);
}
expect(sshEntry.status).toBe("unsupported-transport");
});
// OPR.0.4.4.21 FR-5 — the intra-P4 shared payload contract
// (fanout-contract.ts): items stamped with origin hostId + the per-host
// structured status array covering EVERY targeted host.
it("fan-out --json emits AggregatedPayload: hostId-stamped items + closed-enum statuses", async () => {
vi.stubEnv("TOK_B", "secret-b");
delete process.env.TOK_C; // host-c bearer missing -> auth-failed
const client = mockClient({
"/api/ps": { status: 200, data: [{ rigId: "rig-1", name: "remote-rig", nodeCount: 2, runningCount: 2, status: "running" }] },
});
const deps: PsDeps = {
lifecycleDeps: {} as PsDeps["lifecycleDeps"],
clientFactory: () => client,
hostRegistryLoader: mockRegistry([
{ id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" },
{ id: "host-c", transport: "http", url: "http://c", bearer_env: "TOK_C" },
{ id: "ssh-d", transport: "ssh", target: "d.local" },
]),
};
const { stdout } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json"]);
});
const parsed = JSON.parse(stdout.join(""));
// items: only reachable hosts contribute rows; every row carries its origin
expect(parsed.items).toHaveLength(1);
expect(parsed.items[0].hostId).toBe("host-b");
expect(parsed.items[0].name).toBe("remote-rig");
// hosts[]: every targeted host appears with a closed-enum status
const byId = Object.fromEntries(parsed.hosts.map((h: { hostId: string; status: string }) => [h.hostId, h.status]));
expect(byId["host-b"]).toBe("ok");
expect(byId["host-c"]).toBe("auth-failed");
expect(byId["ssh-d"]).toBe("unsupported-transport");
expect(parsed.hosts).toHaveLength(3);
});
// OPR.0.4.4.21 FR-5 — fan-out per-node requires the FULL explicit ladder
// (--nodes -A); anything less errors (never silent rig-tier data under a
// --nodes flag).
it("fan-out --nodes WITHOUT -A errors, teaching the full ladder", async () => {
const deps: PsDeps = {
lifecycleDeps: {} as PsDeps["lifecycleDeps"],
clientFactory: () => mockClient({}),
hostRegistryLoader: mockRegistry([
{ id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" },
]),
};
const { stderr } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes", "--json"]);
});
const err = stderr.join("\n");
expect(err).toContain("FULL explicit ladder");
expect(err).toContain("--nodes -A");
});
// OPR.0.4.4.21 FR-5 — the PRD's full-ladder exception implemented:
// --all-hosts --nodes -A fans out per-node, PROJECTED rows stamped with
// their origin hostId; non-HTTP hosts still appear as
// unsupported-transport statuses.
it("fan-out --nodes -A returns AggregatedPayload of projected node rows (full ladder)", async () => {
vi.stubEnv("TOK_B", "secret-b");
const client = mockClient({
"/api/ps": { status: 200, data: [{ rigId: "rig-1", name: "remote-rig", rigName: "remote-rig" }] },
"/api/rigs/rig-1/nodes": { status: 200, data: [
{ rigId: "rig-1", rigName: "remote-rig", logicalId: "dev.impl", sessionStatus: "running", resumeToken: "SECRET" },
] },
});
const deps: PsDeps = {
lifecycleDeps: {} as PsDeps["lifecycleDeps"],
clientFactory: () => client,
hostRegistryLoader: mockRegistry([
{ id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" },
{ id: "ssh-d", transport: "ssh", target: "d.local" },
]),
};
const { stdout } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes", "-A", "--json"]);
});
const parsed = JSON.parse(stdout.join(""));
expect(parsed.items).toHaveLength(1);
expect(parsed.items[0].hostId).toBe("host-b");
expect(parsed.items[0].logicalId).toBe("dev.impl");
// projected rows: resumeTokenPresent boolean, never the token value
expect(parsed.items[0].resumeTokenPresent).toBe(true);
expect(JSON.stringify(parsed)).not.toContain("SECRET");
const byId = Object.fromEntries(parsed.hosts.map((h: { hostId: string; status: string }) => [h.hostId, h.status]));
expect(byId["host-b"]).toBe("ok");
expect(byId["ssh-d"]).toBe("unsupported-transport");
});
// OPR.0.4.4.21 rev1-r2 B1 — the SECURITY pin: explicit --fields on the
// full fan-out ladder projects ONLY the named fields (+ the hostId origin
// carriage) — resume material can never survive a projection.
it("fan-out --nodes -A --full --fields projects named fields only (no resume leakage)", async () => {
vi.stubEnv("TOK_B", "secret-b");
const client = mockClient({
"/api/ps": { status: 200, data: [{ rigId: "rig-1", name: "remote-rig", rigName: "remote-rig" }] },
"/api/rigs/rig-1/nodes": { status: 200, data: [
{ rigId: "rig-1", rigName: "remote-rig", logicalId: "dev.impl", sessionStatus: "running",
resumeToken: "SECRET-TOKEN", resumeCommand: "claude --resume SECRET-TOKEN" },
] },
});
const deps: PsDeps = {
lifecycleDeps: {} as PsDeps["lifecycleDeps"],
clientFactory: () => client,
hostRegistryLoader: mockRegistry([
{ id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" },
]),
};
const { stdout } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--nodes", "-A", "--full", "--json", "--fields", "logicalId"]);
});
const raw = stdout.join("");
const parsed = JSON.parse(raw);
expect(parsed.items).toHaveLength(1);
expect(Object.keys(parsed.items[0]).sort()).toEqual(["hostId", "logicalId"]);
expect(parsed.items[0]).toEqual({ logicalId: "dev.impl", hostId: "host-b" });
expect(raw).not.toContain("SECRET-TOKEN");
expect(raw).not.toContain("resumeToken");
expect(raw).not.toContain("resumeCommand");
});
it("fan-out honors --limit and --filter on the merged items (shared composition)", async () => {
vi.stubEnv("TOK_B", "secret-b");
vi.stubEnv("TOK_C", "secret-c");
const client = mockClient({
"/api/ps": { status: 200, data: [
{ rigId: "rig-1", name: "run-rig", rigName: "run-rig", status: "running" },
{ rigId: "rig-2", name: "stop-rig", rigName: "stop-rig", status: "stopped" },
] },
});
const deps: PsDeps = {
lifecycleDeps: {} as PsDeps["lifecycleDeps"],
clientFactory: () => client,
hostRegistryLoader: mockRegistry([
{ id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" },
{ id: "host-c", transport: "http", url: "http://c", bearer_env: "TOK_C" },
]),
};
// --filter status=running: 2 hosts × 1 matching rig each
const { stdout: filteredOut } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json", "--filter", "status=running"]);
});
const filtered = JSON.parse(filteredOut.join(""));
expect(filtered.items).toHaveLength(2);
expect(filtered.items.every((i: { name: string }) => i.name === "run-rig")).toBe(true);
// --limit 1 applies to the MERGED list; hosts[] stays complete
const { stdout: limitedOut } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json", "--limit", "1"]);
});
const limited = JSON.parse(limitedOut.join(""));
expect(limited.items).toHaveLength(1);
expect(limited.hosts).toHaveLength(2);
});
it("fan-out --summary errors honestly (no summary member in the shared contract)", async () => {
const deps: PsDeps = {
lifecycleDeps: {} as PsDeps["lifecycleDeps"],
clientFactory: () => mockClient({}),
hostRegistryLoader: mockRegistry([
{ id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" },
]),
};
const { stderr } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--summary", "--json"]);
});
expect(stderr.join("\n")).toContain("does not compose with the merged fan-out payload");
});
it("fan-out rejects bogus --fields BEFORE any HTTP call", async () => {
const client = mockClient({});
const deps: PsDeps = {
lifecycleDeps: {} as PsDeps["lifecycleDeps"],
clientFactory: () => client,
hostRegistryLoader: mockRegistry([
{ id: "host-b", transport: "http", url: "http://b", bearer_env: "TOK_B" },
]),
};
const { stderr } = await captureLogs(async () => {
await makeCmd(deps).parseAsync(["node", "rig", "ps", "--all-hosts", "--json", "--fields", "bogus"]);
});
expect(stderr.join("\n")).toContain("bogus");
expect(client._calls).toHaveLength(0);
});
});
+63 -17
View File
@@ -65,7 +65,9 @@ describe("Ps CLI", () => {
psData = [];
nodesData = {};
server = http.createServer(async (req, res) => {
if (req.url === "/api/ps" && req.method === "GET") {
// OPR.0.4.4.21: the rig tier always requests ?includeArchived=true
// (client-side visibility split) — match by prefix, not exact URL.
if (req.url?.startsWith("/api/ps") && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(psData));
} else if (req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes(?:\?.*)?$/) && req.method === "GET") {
@@ -113,12 +115,54 @@ describe("Ps CLI", () => {
expect(output).toContain("RUNNING");
expect(output).toContain("STATUS");
expect(output).toContain("review-rig");
expect(output).toContain("dev-rig");
// OPR.0.4.4.21 FR-1: on the bare default, stopped rigs are HISTORY —
// one count line, not rows (field of view stays O(active rigs)).
expect(output).not.toContain("dev-rig");
expect(output).toContain("not shown: 1 stopped (rig ps --filter status=stopped)");
// FR-1 display elements: host rollup line + affordance footer.
expect(output).toContain("1 rig · 3 seats · 0 need attention");
expect(output).toContain("drill: rig ps --rig <name>");
expect(output).toContain("running");
expect(output).toContain("stopped");
expect(exitCode).toBeUndefined(); // 0
});
// OPR.0.4.4.21 FR-1 (arch pin, STATED contract): the JSON-vs-table scope
// split — default --json keeps ALL non-archived entries INCLUDING stopped
// rigs (a stopped rig vanishing from default JSON would silently break
// status-monitoring scripts); only the HUMAN table folds them.
it("OPR.0.4.4.21: default --json keeps stopped rigs while the table folds them", async () => {
psData = [
{ rigId: "rig-1", name: "live-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "1m", latestSnapshot: null },
{ rigId: "rig-2", name: "halted-rig", nodeCount: 2, runningCount: 0, status: "stopped", uptime: null, latestSnapshot: null },
];
const { logs: jsonLogs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json"]);
});
const parsed = JSON.parse(jsonLogs.join(""));
expect(parsed.map((e: { name: string }) => e.name).sort()).toEqual(["halted-rig", "live-rig"]);
const { logs: humanLogs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps"]);
});
const output = humanLogs.join("\n");
expect(output).toContain("live-rig");
expect(output).not.toContain("halted-rig");
expect(output).toContain("not shown: 1 stopped (rig ps --filter status=stopped)");
});
// OPR.0.4.4.21 fixback (qa1 F4): attentionCount is selectable via --fields
// (it is emitted by default JSON, so the allow-list must carry it).
it("OPR.0.4.4.21: --fields attentionCount projects the additive field", async () => {
psData = [
{ rigId: "rig-1", name: "attn-rig", rigName: "attn-rig", nodeCount: 2, runningCount: 2, status: "running", uptime: "1m", latestSnapshot: null, attentionCount: 1 },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--fields", "rigName,attentionCount"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.entries[0]).toEqual({ rigName: "attn-rig", attentionCount: 1 });
});
// T10: ps --json outputs PsEntry[]
it("ps --json outputs parseable JSON array", async () => {
psData = [
@@ -199,7 +243,7 @@ describe("Ps CLI", () => {
},
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--full"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--full"]);
});
const output = logs.join("\n");
expect(output).toContain("RIG");
@@ -235,7 +279,7 @@ describe("Ps CLI", () => {
},
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--full"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--full"]);
});
const output = logs.join("\n");
expect(output).toContain("demo-rig#rig-old");
@@ -262,7 +306,7 @@ describe("Ps CLI", () => {
},
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--full"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--full"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed)).toBe(true);
@@ -298,7 +342,7 @@ describe("Ps CLI", () => {
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]);
});
const output = logs.join("\n");
@@ -321,7 +365,7 @@ describe("Ps CLI", () => {
},
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]);
});
const output = logs.join("\n");
expect(output).toContain("infra-daemon@test-rig");
@@ -360,7 +404,7 @@ describe("Ps CLI", () => {
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]);
});
const lines = logs.join("\n").split("\n");
expect(lines[1]).toContain("…");
@@ -395,7 +439,7 @@ describe("Ps CLI", () => {
];
nodesData = {}; // no nodes data → server returns 404
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]);
});
const output = logs.join("\n");
expect(output).toContain("Warning");
@@ -429,7 +473,9 @@ describe("Ps CLI", () => {
{ rigId: "rig-5", name: "att-rig", nodeCount: 2, runningCount: 2, status: "running", lifecycleState: "attention_required", uptime: "10m", latestSnapshot: null },
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps"]);
// OPR.0.4.4.21 — --include-archived is the full-breadth view (no
// stopped fold), so every lifecycle abbreviation stays visible here.
await makeCmd().parseAsync(["node", "rig", "ps", "--include-archived"]);
});
const output = logs.join("\n");
expect(output).toContain("LIFECYCLE");
@@ -476,7 +522,7 @@ describe("Ps CLI", () => {
},
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A"]);
});
const output = logs.join("\n");
expect(output).toContain("LIFECYCLE");
@@ -498,7 +544,7 @@ describe("Ps CLI", () => {
},
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed[0].lifecycleState).toBe("recoverable");
@@ -705,7 +751,7 @@ describe("Ps CLI", () => {
it("ps --nodes --json --fields rejects 'name' with rigName hint (load-bearing aliasing decision)", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--fields", "name,logicalId"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--fields", "name,logicalId"]);
});
const stderr = logs.join("\n");
expect(stderr).toContain("Unknown --fields key 'name'");
@@ -715,7 +761,7 @@ describe("Ps CLI", () => {
it("ps --nodes --json --fields rejects bogus key with sorted node-level supported list", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--fields", "rigName,bogus"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--fields", "rigName,bogus"]);
});
const stderr = logs.join("\n");
expect(stderr).toContain("Unknown --fields key 'bogus'");
@@ -822,7 +868,7 @@ describe("Ps CLI", () => {
tmuxAttachCommand: null, resumeCommand: null, latestError: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(Array.isArray(parsed)).toBe(true);
@@ -840,7 +886,7 @@ describe("Ps CLI", () => {
tmuxAttachCommand: null, resumeCommand: null, latestError: null,
}));
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--limit", "2"]);
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--json", "--limit", "2"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.entries).toHaveLength(2);
+198
View File
@@ -379,3 +379,201 @@ describe("scope-audit classifier", () => {
expect(result.findings.some((f) => f.kind === "progress_not_updated_on_commit")).toBe(false);
});
});
// OPR.0.4.4.23 — SDLC convention-section advisories. Structurally
// fail-open: the audit exit code flips on HIGH findings only, and every
// finding below asserts a low/info severity; the checks are inert when the
// caller provides no content context.
describe("OPR.0.4.4.23 convention-section advisories", () => {
const CONVENTION_README = [
"# Slice 05 — Probe",
"## Intent", "the recorded intent",
"## Mini-requirements", "1. one",
"## Proof contract", "- [ ] a deliverable — captured",
].join("\n");
function sliceInput(overrides: Partial<ScopeAuditInput>): ScopeAuditInput {
return {
id: null,
path: "/w/missions/release-x/slices/05-probe",
readmeFrontmatterRaw: "id: OPR.0.4.4.5\nstatus: wip",
progressFileExists: true,
readmeOnlyMarker: false,
isActiveRelease: true,
level: "slice",
...overrides,
};
}
it("inert when no content context is provided (undefined inputs)", () => {
const result = classifyScopeItem(sliceInput({}));
expect(result.findings.some((f) =>
f.kind === "missing_intent_section"
|| f.kind === "mini_requirements_missing_or_malformed"
|| f.kind === "proof_contract_missing_or_malformed"
|| f.kind === "ui_slice_missing_mockup",
)).toBe(false);
});
it("clean convention README yields no section findings", () => {
const result = classifyScopeItem(sliceInput({ readmeContent: CONVENTION_README, implementationPrdContent: null }));
expect(result.findings.some((f) =>
f.kind === "missing_intent_section"
|| f.kind === "mini_requirements_missing_or_malformed"
|| f.kind === "proof_contract_missing_or_malformed",
)).toBe(false);
});
it("README without ## Intent -> missing_intent_section at LOW (never high)", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# Slice\n## Goal\nold shape\n## Proof contract\n- [ ] x\n",
implementationPrdContent: null,
}));
const finding = result.findings.find((f) => f.kind === "missing_intent_section");
expect(finding).toBeDefined();
expect(finding!.severity).toBe("low");
});
it("## Intent visual does NOT satisfy ## Intent (exact-heading match)", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# Slice\n## Intent visual\n![x](./x.png)\n## Proof contract\n- [ ] x\n",
implementationPrdContent: null,
}));
expect(result.findings.some((f) => f.kind === "missing_intent_section")).toBe(true);
});
it("PRD provided: proof contract is checked on the PRD path", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: CONVENTION_README,
implementationPrdContent: "# PRD\n## Intent\nx\n## Mini-requirements\n1. y\n",
}));
const finding = result.findings.find((f) => f.kind === "proof_contract_missing_or_malformed");
expect(finding).toBeDefined();
expect(finding!.severity).toBe("low");
expect(finding!.path.endsWith("IMPLEMENTATION-PRD.md")).toBe(true);
});
it("PRD absent (null): proof contract falls back to the README", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# Slice\n## Intent\nx\n",
implementationPrdContent: null,
}));
const finding = result.findings.find((f) => f.kind === "proof_contract_missing_or_malformed");
expect(finding).toBeDefined();
expect(finding!.path.endsWith("README.md")).toBe(true);
});
it("## Proof contract heading with no checkbox items is malformed", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# Slice\n## Intent\nx\n## Proof contract\nprose only, no checkboxes\n",
implementationPrdContent: null,
}));
expect(result.findings.some((f) => f.kind === "proof_contract_missing_or_malformed")).toBe(true);
});
it("visual slice without any mockup ref -> ui_slice_missing_mockup at INFO", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# Slice\n## Intent\nx\n## Proof contract\n- [ ] the panel renders\n## Intent visual\nwill add later\n",
implementationPrdContent: null,
}));
const finding = result.findings.find((f) => f.kind === "ui_slice_missing_mockup");
expect(finding).toBeDefined();
expect(finding!.severity).toBe("info");
});
it("visual slice with an image ref (or N/A marking) does not fire the mockup advisory", () => {
const withImage = classifyScopeItem(sliceInput({
readmeContent: "# Slice\n## Intent\nx\n## Proof contract\n- [ ] y\n## Intent visual\n![Intent visual](./intent.png)\n",
implementationPrdContent: null,
}));
expect(withImage.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false);
const nonVisual = classifyScopeItem(sliceInput({
readmeContent: "# Slice\n## Intent\nx\n## Proof contract\n- [ ] y\n## Intent visual\nN/A\n",
implementationPrdContent: null,
}));
expect(nonVisual.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false);
});
it("FAIL-OPEN PIN: planted section violations never produce a HIGH finding (exit semantics unchanged)", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# Slice\nno sections at all\n## Intent visual\nno image\n",
implementationPrdContent: null,
}));
const sectionFindings = result.findings.filter((f) =>
f.kind === "missing_intent_section"
|| f.kind === "mini_requirements_missing_or_malformed"
|| f.kind === "proof_contract_missing_or_malformed"
|| f.kind === "ui_slice_missing_mockup",
);
expect(sectionFindings.length).toBeGreaterThan(0);
expect(sectionFindings.every((f) => f.severity === "low" || f.severity === "info")).toBe(true);
});
});
// OPR.0.4.4.23 rev1-r2 fixback — B1 (PRD L34 guard F-3: well-formed
// ## Mini-requirements) + B2 (generic "mockup" prose is not a mockup ref).
describe("OPR.0.4.4.23 rev1-r2 fixback — mini-requirements + mockup-ref tightening", () => {
function sliceInput(overrides: Partial<ScopeAuditInput>): ScopeAuditInput {
return {
id: null,
path: "/w/missions/release-x/slices/07-r2fix",
readmeFrontmatterRaw: "id: OPR.0.4.4.7\nstatus: wip",
progressFileExists: true,
readmeOnlyMarker: false,
isActiveRelease: true,
level: "slice",
...overrides,
};
}
it("B1: Intent + Proof contract but NO ## Mini-requirements -> mini_requirements_missing_or_malformed at LOW (the r2 false-green probe)", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# S\n## Intent\nx\n## Proof contract\n- [ ] y\n",
implementationPrdContent: "# PRD\n## Intent\nx\n## Proof contract\n- [ ] y\n",
}));
const finding = result.findings.find((f) => f.kind === "mini_requirements_missing_or_malformed");
expect(finding).toBeDefined();
expect(finding!.severity).toBe("low");
expect(finding!.path.endsWith("IMPLEMENTATION-PRD.md")).toBe(true);
});
it("B1: ## Mini-requirements heading with prose-only body (no numbered item) is malformed", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# S\n## Intent\nx\n## Mini-requirements\njust prose, no list\n## Proof contract\n- [ ] y\n",
implementationPrdContent: null,
}));
expect(result.findings.some((f) => f.kind === "mini_requirements_missing_or_malformed")).toBe(true);
});
it("B1: a numbered mini-requirements list is clean; PRD absent falls back to README", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. one observable outcome\n## Proof contract\n- [ ] y\n",
implementationPrdContent: null,
}));
expect(result.findings.some((f) => f.kind === "mini_requirements_missing_or_malformed")).toBe(false);
});
it("B2 REGRESSION: generic proof-contract prose containing the word 'mockup' does NOT suppress ui_slice_missing_mockup (the scaffold-placeholder false-green)", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. y\n## Proof contract\n- [ ] UI deliverables name their planned mockup\n## Intent visual\nwill add later\n",
implementationPrdContent: null,
}));
expect(result.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(true);
});
it("B2: an explicit plannedRef token in the proof contract IS a mockup ref", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. y\n## Proof contract\n- [ ] the panel renders (plannedRef: mockups/panel.png)\n## Intent visual\nwill add later\n",
implementationPrdContent: null,
}));
expect(result.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false);
});
it("B2: a markdown image ref inside the proof contract IS a mockup ref", () => {
const result = classifyScopeItem(sliceInput({
readmeContent: "# S\n## Intent\nx\n## Mini-requirements\n1. y\n## Proof contract\n- [ ] the panel renders ![planned](mockups/panel.png)\n## Intent visual\nwill add later\n",
implementationPrdContent: null,
}));
expect(result.findings.some((f) => f.kind === "ui_slice_missing_mockup")).toBe(false);
});
});
@@ -0,0 +1,177 @@
// OPR.0.4.4.23 — the scaffold emits the SDLC convention sections for EVERY
// SliceTemplateKind (the Rev-2 exhaustive contract): a slice that doesn't
// carry `## Intent` / `## Mini-requirements` / `## Proof contract` doesn't
// project in the Living Notes UI, whatever its template kind. The tests
// ENUMERATE the exported kind set, so a future kind added to
// SLICE_TEMPLATE_KINDS fails here until its template carries the sections.
// Conventions SSOT: docs/reference/sdlc-conventions.md.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { Command } from "commander";
import { scopeCommand } from "../src/commands/scope.js";
import { renderImplementationPrdTemplate, renderSliceProofTemplate, renderSliceTemplate } from "../src/lib/scope/templates.js";
import { MISSION_TEMPLATE_KINDS, SLICE_TEMPLATE_KINDS } from "../src/lib/scope/types.js";
import { renderMissionTemplate } from "../src/lib/scope/templates.js";
const CONVENTION_SECTIONS = ["## Intent", "## Mini-requirements", "## Proof contract"] as const;
const SSOT_POINTER = "docs/reference/sdlc-conventions.md";
const RENDER_OPTS = {
id: "OPR.0.4.4.99",
slice_number: "99",
slug: "conventions-probe",
mission: "release-0.4.4",
title: "Conventions Probe",
created_date: "2026-07-06",
};
function mktemp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-conventions-"));
}
function seedSubstrate(): { root: string; missionsRoot: string } {
const root = mktemp();
const missionsRoot = path.join(root, "internal-docs", "missions");
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
fs.mkdirSync(path.join(missionsRoot, "release-0.4.4"), { recursive: true });
fs.writeFileSync(
path.join(missionsRoot, "release-0.4.4", "README.md"),
"---\nid: OPR.0.4.4\nstage: wip\n---\n# release-0.4.4\n",
"utf8",
);
return { root, missionsRoot };
}
async function run(args: string[], missionsRoot: string): Promise<{ exitCode: number; stdout: string }> {
const stdoutBuf: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
const origErrWrite = process.stderr.write.bind(process.stderr);
const origExit = process.exit;
let exitCode = 0;
process.stdout.write = ((chunk: unknown) => { stdoutBuf.push(String(chunk)); return true; }) as typeof process.stdout.write;
process.stderr.write = (() => true) as typeof process.stderr.write;
process.exit = ((code?: number) => { exitCode = code ?? 0; throw new Error(`__EXIT__${exitCode}`); }) as typeof process.exit;
const program = new Command();
program.addCommand(scopeCommand());
program.exitOverride();
try {
await program.parseAsync(["node", "rig", "scope", ...args, "--workspace", path.dirname(missionsRoot)]);
} catch {
// Commander/process.exit paths are captured above.
} finally {
process.stdout.write = origWrite;
process.stderr.write = origErrWrite;
process.exit = origExit;
}
return { exitCode, stdout: stdoutBuf.join("") };
}
describe("OPR.0.4.4.23 convention scaffold — exhaustive over SliceTemplateKind", () => {
it("every SliceTemplateKind template emits the three convention sections + the SSOT pointer", () => {
for (const kind of SLICE_TEMPLATE_KINDS) {
const rendered = renderSliceTemplate(kind, RENDER_OPTS);
for (const section of CONVENTION_SECTIONS) {
expect(rendered, `template kind "${kind}" is missing "${section}"`).toContain(section);
}
expect(rendered, `template kind "${kind}" is missing the SSOT pointer`).toContain(SSOT_POINTER);
expect(rendered, `template kind "${kind}" is missing the mission-slice-sop skill pointer`).toContain("mission-slice-sop");
}
});
it("the convention sections come FIRST — kind-specific body sits below them", () => {
for (const kind of SLICE_TEMPLATE_KINDS) {
const rendered = renderSliceTemplate(kind, RENDER_OPTS);
const firstSectionIdx = rendered.indexOf("## ");
expect(
rendered.slice(firstSectionIdx).startsWith("## Intent"),
`template kind "${kind}" does not open its sections with ## Intent`,
).toBe(true);
const miniIdx = rendered.indexOf("## Mini-requirements");
const proofIdx = rendered.indexOf("## Proof contract");
expect(firstSectionIdx, `kind "${kind}" section order broken`).toBeLessThan(miniIdx);
expect(miniIdx, `kind "${kind}" section order broken`).toBeLessThan(proofIdx);
}
});
it("the IMPLEMENTATION-PRD skeleton carries the sections + the elastic-middle note", () => {
const rendered = renderImplementationPrdTemplate(RENDER_OPTS);
for (const section of CONVENTION_SECTIONS) {
expect(rendered).toContain(section);
}
expect(rendered).toContain("ELASTIC MIDDLE");
expect(rendered).toContain("may BE the whole PRD");
expect(rendered).toContain(SSOT_POINTER);
});
it("mission templates carry the convention pointer + SOP reference (missions are not slice projections)", () => {
for (const kind of MISSION_TEMPLATE_KINDS) {
const rendered = renderMissionTemplate(kind, RENDER_OPTS);
expect(rendered, `mission template "${kind}" is missing the SSOT pointer`).toContain(SSOT_POINTER);
expect(rendered, `mission template "${kind}" is missing the mission-slice-sop pointer`).toContain("mission-slice-sop");
}
});
});
describe("OPR.0.4.4.23 slice create — every kind lands the full convention artifact set on disk", () => {
let substrate: { root: string; missionsRoot: string };
beforeEach(() => { substrate = seedSubstrate(); });
afterEach(() => { fs.rmSync(substrate.root, { recursive: true, force: true }); });
it("scaffolds README sections + IMPLEMENTATION-PRD.md + proof/ + PROOF.md for each SliceTemplateKind", async () => {
for (const kind of SLICE_TEMPLATE_KINDS) {
const r = await run(
["slice", "create", "release-0.4.4", `probe-${kind}`, "--template", kind, "--json"],
substrate.missionsRoot,
);
expect(r.exitCode, `slice create failed for kind "${kind}"`).toBe(0);
const slicePath = JSON.parse(r.stdout).slice.path as string;
const readme = fs.readFileSync(path.join(slicePath, "README.md"), "utf8");
for (const section of CONVENTION_SECTIONS) {
expect(readme, `created README for kind "${kind}" is missing "${section}"`).toContain(section);
}
const prdPath = path.join(slicePath, "IMPLEMENTATION-PRD.md");
expect(fs.existsSync(prdPath), `kind "${kind}" did not scaffold IMPLEMENTATION-PRD.md`).toBe(true);
const prd = fs.readFileSync(prdPath, "utf8");
for (const section of CONVENTION_SECTIONS) {
expect(prd, `created PRD for kind "${kind}" is missing "${section}"`).toContain(section);
}
expect(prd, `created PRD for kind "${kind}" is missing the elastic-middle note`).toContain("ELASTIC MIDDLE");
expect(fs.statSync(path.join(slicePath, "proof")).isDirectory(), `kind "${kind}" did not scaffold proof/`).toBe(true);
expect(fs.existsSync(path.join(slicePath, "PROOF.md")), `kind "${kind}" did not scaffold PROOF.md`).toBe(true);
}
});
});
// OPR.0.4.4.23 PM-acceptance fixback — the shipped teaching surfaces must
// LEAD a naive agent to the C1 drop verb: every scaffolded artifact that
// mentions proving names `rig proof add` and `--media` explicitly (the
// naive-agent rerun showed generic "rig proof drops" prose produced manual
// proof-dir curation and no C1 drops).
describe("OPR.0.4.4.23 teaching surfaces name the drop verb", () => {
it("every slice template's proving guidance names rig proof add and --media", () => {
for (const kind of SLICE_TEMPLATE_KINDS) {
const rendered = renderSliceTemplate(kind, RENDER_OPTS);
expect(rendered, `template kind "${kind}" does not name rig proof add`).toContain("rig proof add");
expect(rendered, `template kind "${kind}" does not name --media`).toContain("--media");
}
});
it("the IMPLEMENTATION-PRD skeleton and the PROOF.md template name rig proof add and --media", () => {
const prd = renderImplementationPrdTemplate(RENDER_OPTS);
expect(prd).toContain("rig proof add");
expect(prd).toContain("--media");
const proof = renderSliceProofTemplate({ id: RENDER_OPTS.id, title: RENDER_OPTS.title });
expect(proof).toContain("rig proof add");
expect(proof).toContain("--media");
expect(proof).toContain("Hand-placing files without a drop");
});
});
@@ -0,0 +1,40 @@
// OPR.0.4.4.11 — R11-2 CLI leg: `rig up --host <id> <topology>` is rejected
// pre-dispatch; per-entry `host:` is the ONLY topology placement mechanism.
// (The daemon route carries the same rejection on its public write path —
// the double-sided ruling; that leg is pinned in the daemon's route tests.)
import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sourceLooksLikeTopology, HOST_TOPOLOGY_REJECTION } from "../src/commands/up.js";
describe("sourceLooksLikeTopology (the R11-2 pre-dispatch detector)", () => {
it("detects the .rigtopology extension in bare and path forms without touching the fs", () => {
expect(sourceLooksLikeTopology("factory.rigtopology")).toBe(true);
expect(sourceLooksLikeTopology("./nonexistent-dir/factory.rigtopology")).toBe(true);
expect(sourceLooksLikeTopology("FACTORY.RIGTOPOLOGY")).toBe(true);
});
it("detects a path-form YAML file carrying a top-level rigs: list", () => {
const dir = mkdtempSync(join(tmpdir(), "r11-2-"));
const topo = join(dir, "factory.yaml");
writeFileSync(topo, "rigs:\n - source: a\n");
const spec = join(dir, "rig.yaml");
writeFileSync(spec, "name: x\npods: []\n");
expect(sourceLooksLikeTopology(topo)).toBe(true);
expect(sourceLooksLikeTopology(spec)).toBe(false); // rig specs pass through untouched
rmSync(dir, { recursive: true, force: true });
});
it("never sniffs name-form sources (rig-name precedence preserved) and stays false on missing/unparseable files", () => {
expect(sourceLooksLikeTopology("factory")).toBe(false); // bare name — --host + name stays a valid remote restore
expect(sourceLooksLikeTopology("my-rig-name")).toBe(false);
expect(sourceLooksLikeTopology("./no/such/file.yaml")).toBe(false);
});
it("the rejection message names per-entry host: as the only placement mechanism", () => {
expect(HOST_TOPOLOGY_REJECTION).toContain("per-entry 'host:'");
expect(HOST_TOPOLOGY_REJECTION).toContain("ONLY placement mechanism");
});
});
@@ -0,0 +1,33 @@
// OPR.0.4.4.11 FR-7 — rig --version identity, CLI side.
//
// Stamped: `<semver> (<shortsha>)` + dirty marker when dirty. Unstamped dev
// run: the semver alone EXACTLY as today (negative AC: no fake identity).
// The end-to-end stamp (build-package.sh writes the generated module) is a
// VM-gate proof.
import { describe, it, expect } from "vitest";
import { formatCliVersion, CLI_VERSION } from "../src/version.js";
import { BUILD_INFO } from "../src/build-info.js";
describe("formatCliVersion", () => {
it("unstamped dev run prints the semver alone", () => {
expect(formatCliVersion("0.4.3", { semver: null, commit: null, dirty: null, builtAt: null })).toBe("0.4.3");
});
it("stamped build prints <semver> (<shortsha>)", () => {
expect(
formatCliVersion("0.4.3", { semver: "0.4.3", commit: "abcdef0123456789", dirty: false, builtAt: "t" }),
).toBe("0.4.3 (abcdef01)");
});
it("dirty stamp carries the dirty marker", () => {
expect(
formatCliVersion("0.4.3", { semver: "0.4.3", commit: "abcdef0123456789", dirty: true, builtAt: "t" }),
).toBe("0.4.3 (abcdef01, dirty)");
});
it("the source tree's committed stub is the dev stub, so CLI_VERSION here is the bare semver (interim SHA-deploys disambiguate ONLY when stamped)", () => {
expect(BUILD_INFO.commit).toBeNull();
expect(CLI_VERSION).not.toContain("(");
});
});
@@ -57,8 +57,9 @@ function healthyReport(workspaceRoot = "/ws"): DoctorReportFixture {
{ 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" },
{ check: "sdlc_convention_sections", status: "ok", message: "every slice README carries the SDLC convention sections" },
],
summary: { ok: 7, warn: 0, fail: 0 },
summary: { ok: 8, warn: 0, fail: 0 },
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
};
}
@@ -223,8 +224,8 @@ describe("rig workspace doctor — output formatters", () => {
}
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);
expect(parsed.summary).toEqual({ ok: 8, warn: 0, fail: 0 });
expect(parsed.checks).toHaveLength(8);
});
it("default human output groups checks under workspace / missions / daemon categories", async () => {
@@ -237,7 +238,7 @@ describe("rig workspace doctor — output formatters", () => {
}
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("summary: 8 ok, 0 warn, 0 fail");
expect(joined).toContain("workspace:");
expect(joined).toContain("missions:");
expect(joined).toContain("daemon:");
@@ -17,6 +17,12 @@ That skill covers the broader surface, including chatroom, discovery, adopt/bind
Your per-session startup guidance may also name additional packaged skills for your role and pod.
Load those too. This shared overlay is only the common bootstrap floor, not the full operating manual for your seat.
## Working missions and slices (the SDLC)
If your seat works missions or slices, load the packaged `mission-slice-sop` skill BEFORE authoring or building — it teaches the flow the Living Notes UI projects: intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock (`rig scope slice approve --scope spec`) → build the locked set → QA visual compare → `rig proof add … --media` drops (the C1 drop verb — never hand-place proof files) → proof-lock (`--scope delivery`).
The conventions themselves (section names, proof-contract format, the two locks, C1 proof headers) live in ONE shipped document: `docs/reference/sdlc-conventions.md` (in the CLI package). `rig scope slice create` scaffolds the convention sections for every template kind; `rig scope audit` is the advisory backstop — it records and advises, it never blocks your work.
## Identity
Run this first after launch or compaction to recover your identity:
@@ -1,19 +1,20 @@
---
name: mission-slice-sop
description: Use when working a mission or slice — how to track, prove, hand off, and carry state across agents and compaction via the canonical files (PROGRESS.md / PROOF.md / MISSION_NOTES.md / MISSION_BRIEF.md / README.md). Covers the per-file WHO/WHEN/HOW rules, the four-legs lifecycle (SCAFFOLD/POPULATE/PROJECT/VERIFY), hot-potato queue handoffs, the moment-of-truth checklist, the deterministic `rig scope audit` backstop, and the known ghost-text gotcha when reading terminal captures.
description: Use when working a mission or slice — the full SDLC flow (intent → mini-requirements + proof contract → mockups → plan-lock → build → QA visual compare → `rig proof` drops → proof-lock) plus how to track, prove, hand off, and carry state across agents and compaction via the canonical files (PROGRESS.md / PROOF.md / MISSION_NOTES.md / MISSION_BRIEF.md / README.md / IMPLEMENTATION-PRD.md). Covers the convention sections the Living Notes UI projects, the two locks, the three role contracts, per-file WHO/WHEN/HOW rules, the four-legs lifecycle, hot-potato queue handoffs, the moment-of-truth checklist, the deterministic `rig scope audit` backstop, and the ghost-text capture gotcha.
metadata:
openrig:
stage: shipped
last_verified: "2026-07-03"
last_verified: "2026-07-06"
distribution_scope: product-bound
source_evidence: |
Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop). The
canonical SOP is the operating surface for mission/slice work: the
PROGRESS / PROOF / MISSION_NOTES / MISSION_BRIEF files drive the workspace
UI and carry state across agents + compaction. Operationalizes the
markdown-control-plane convention (loose freeform write + deterministic
verify) and the shipped `rig scope audit` classifier — the deterministic
backstop is the audit, not standalone check scripts.
Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop);
moved into the canonical product skill source and updated in
release-0.4.4 slice 23 (operationalize the SDLC control plane) to
teach the full SDLC flow the Living Notes UI projects: the convention
sections, the proof-contract pairing, the two staged-approval locks,
C1 proof drops, and the three role contracts. Conventions SSOT:
docs/reference/sdlc-conventions.md (shipped with the CLI package).
The deterministic backstop is the `rig scope audit` classifier.
sibling_skills:
- queue-handoff
- seat-continuity-and-handover
@@ -22,17 +23,43 @@ metadata:
# Mission/Slice SOP — how you work a mission & slice
Use this skill to actually **do** mission/slice work the way OpenRig expects: track on the canonical files, prove on them, hand off through them, and survive compaction on them. **Do the work described here; do not merely explain the protocol.** The deterministic backstop is `rig scope audit` — there are no separate bundled check scripts; the audit classifier is the source of truth for adherence.
Use this skill to actually **do** mission/slice work the way OpenRig expects: author the convention sections, track on the canonical files, prove on them, hand off through them, and survive compaction on them. **Do the work described here; do not merely explain the protocol.** The deterministic backstop is `rig scope audit` — the audit classifier is the source of truth for adherence. The conventions themselves live in ONE place: **`docs/reference/sdlc-conventions.md`** (shipped with the CLI package) — this skill teaches the flow; the SSOT defines the formats.
> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work.
## The SDLC flow (intent → proof)
```
intent → mini-requirements + proof contract → (UI slices: mockups)
→ plan-lock (rig scope slice approve --scope spec)
→ build the LOCKED set
→ QA: mockup ↔ delivered VISUAL compare
→ proof drops (rig proof add <slice> …)
→ proof-lock (rig scope slice approve --scope delivery)
```
1. **Record intent** verbatim in the slice's `## Intent` section (`rig scope slice create` scaffolds it — every template kind).
2. **Author the mini-requirements + proof contract**: `## Mini-requirements` is the concise one-glance tier (approval starts there); `## Proof contract` is a checkbox list of promised deliverables, each written as an observable outcome. UI deliverables name their planned mockup. The IMPLEMENTATION-PRD opens with the mini-requirements; everything between intent and proof is **elastic** — for a small slice the mini-requirements may BE the whole PRD.
3. **Plan-lock**: `rig scope slice approve <slice> --scope spec` — "the PRD matches the intent; THIS artifact set is what gets built." One daemon-side write: frontmatter stamp + append-only audit row.
4. **Build the locked set** — look at the mockups, not just the spec text.
5. **QA visual compare**: for each deliverable, load the planned mockup, produce the real artifact in a test/demo environment, visually compare, and record the verdict.
6. **Drop proof**: `rig proof add <slice> --artifact-type qa --verdict PASS --candidate-sha <tip> --money-evidence "…" --evidences "1,3" --media "walk.webm,panel.png" --self-check "…"` — the C1 header's closed sets validate at drop time; `--evidences` joins the drop to its proof-contract items and `--media` names the curated proof/-relative media the drop stands behind (that pairing + media set is what the UI's DELIVERED section renders).
7. **Proof-lock**: `rig scope slice approve <slice> --scope delivery` — the terminal sign-off. Approval is freeze/sign-off, **never** proven-green: proven-green requires the recorded C1 verdicts.
## The three role contracts
- **Planning agent:** authors intent + mini-requirements + the proof contract; produces mockups for UI deliverables and attaches them to the locked set (a UI slice with no mockup is an incomplete plan; non-UI slices have none — not a gate); locks the plan.
- **Build agent:** builds against the LOCKED set only; looks at the mockups.
- **QA agent (owns the compare):** visually compares planned vs delivered per deliverable, records verdict + note via proof drops, and **curates** the canonical proof set (bounded, mapped to deliverables; the fix-loop pile stays in `proof/`, one drill-in down). On mismatch: fix-and-re-review or kick back with the reason — never escalate a raw mismatch to the human.
## The canonical files (the operating surface)
- **README.md** (mission + slice) — the overview: what the mission/slice is + why. The **mission README carries this SOP at its bottom.**
> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work.
- **README.md** (mission + slice) — the overview, OPENING with the convention sections (`## Intent` / `## Mini-requirements` / `## Proof contract`). The **mission README carries this SOP at its bottom.**
- **IMPLEMENTATION-PRD.md** — the full PRD; opens with the mini-requirements; the `## Proof contract` here is what the UI's DELIVERED pairing joins proof against.
- **PROGRESS.md** — the live delivery state. One line per outcome; links down for detail.
- **MISSION_NOTES.md** — the accruing handoff + tribal-knowledge doc. Blank-slate onboarding + **compaction-restore** read it. `§1` top-of-mind + per-seat `§A–§X`.
- **MISSION_BRIEF.md** — the steering doc (the UI "Steering" tab). The 7-section schema.
- **PROOF.md** (+ `proof/`) — acceptance evidence. **A slice is not done until every plan success-criterion has proof.**
- **PROOF.md** (+ `proof/`) — acceptance evidence. **A slice is not done until every proof-contract item has evidence.**
## Per-file rules — WHO / WHEN / HOW
@@ -41,10 +68,10 @@ Use this skill to actually **do** mission/slice work the way OpenRig expects: tr
- **WHEN:** after every slice-done **AND every commit**; on any material state change.
- **HOW:** one line per outcome (checkbox), link down for detail (workstream-continuity format); keep frontmatter `stage`/`verified` honest.
### PROOF.md
### PROOF.md + proof/
- **WHO:** the impl/QA pair that worked the slice.
- **WHEN:** a slice is **NOT "done"** until PROOF.md exists and every plan success-criterion has evidence.
- **HOW:** proof maps **1:1 to the plan's acceptance/success criteria** — each criterion gets an evidence artifact (screenshot/capture/log) in `proof/` + a line in PROOF.md stating what it proves. No proof → not done.
- **WHEN:** a slice is **NOT "done"** until every proof-contract item has evidence.
- **HOW:** proof maps **1:1 to the proof contract's deliverables** — put media under `proof/`, then ATTACH it with `rig proof add <slice> … --evidences <item> --media <files>` (the drop writes the C1 header the DELIVERED pairing joins on) + a line in PROOF.md stating what it proves. **Hand-placing files in `proof/` without a drop is the anti-pattern** — the deliverable stays unpaired and `unverified`. No proof → not done.
### MISSION_NOTES.md
- **WHO:** any agent updates `§1` (top-of-mind); each seat owns + appends to its own `§A–§X` section.
@@ -59,11 +86,11 @@ Use this skill to actually **do** mission/slice work the way OpenRig expects: tr
### README.md
- **WHO:** the author at creation; refreshed on rescope.
- **WHEN:** at mission/slice creation + when scope/theme changes.
- **HOW:** overview + honest frontmatter (`id`/`stage`/`verified`); the mission README carries this SOP at its bottom.
- **HOW:** overview + honest frontmatter (`id`/`stage`/`verified`) + the convention sections up top; the mission README carries this SOP at its bottom.
## The lifecycle (4 legs)
**SCAFFOLD** (`rig scope` creates the files from templates) → **POPULATE** (agents fill them as work happens, per the rules above) → **PROJECT** (the workspace UI reads them into tabs) → **VERIFY** (`rig scope audit` + the validation scripts check adherence). "Loose freeform write + deterministic verify."
**SCAFFOLD** (`rig scope` creates the files from templates — every slice kind emits the convention sections + `proof/` + the IMPLEMENTATION-PRD skeleton) → **POPULATE** (agents fill them as work happens, per the rules above) → **PROJECT** (the Living Notes UI reads them into INTENT → PLAN → DELIVERED) → **VERIFY** (`rig scope audit` checks adherence). "Loose freeform write + deterministic verify."
## Hot-potato (handoffs)
@@ -71,7 +98,7 @@ End every turn by passing the ball — a `rig queue` handoff to the next agent (
## Verify (deterministic backstop)
Run `rig scope audit` at slice-close. The validation scripts flag: committed-without-touching-PROGRESS; slice-marked-done-without-PROOF; active-mission-without-MISSION_NOTES; MISSION_BRIEF off-schema. **Fix the flag, don't suppress it.**
Run `rig scope audit` at slice-close. It flags: committed-without-touching-PROGRESS; slice-marked-done-without-PROOF; active-mission-without-MISSION_NOTES; MISSION_BRIEF off-schema; proof artifacts violating the C1 header; missing IMPLEMENTATION-PRD on a building slice; missing convention sections (`## Intent`, a well-formed `## Proof contract`, the UI-slice mockup ref). Every convention check is **advisory / fail-open** — it records and advises, never blocks a write. **Fix the flag, don't suppress it.**
## Reading terminal captures — KNOWN GOTCHA: ghost-text autocomplete is NOT real
@@ -81,7 +108,8 @@ When you `rig capture` a pane, **greyed / ghost autocomplete suggestions are NOT
## Moment-of-truth checklist
- **Finishing a slice?** → PROOF complete (every criterion has evidence)? PROGRESS updated? MISSION_NOTES `§1` refreshed? Handed off via queue?
- **Starting a slice?** → intent recorded verbatim? mini-requirements + proof contract authored? mockups attached (UI slices)? plan locked (`--scope spec`)?
- **Finishing a slice?** → every proof-contract item has curated evidence via `rig proof add … --evidences --media` (C1 drops — never only hand-placed files)? PROGRESS updated? MISSION_NOTES `§1` refreshed? proof locked (`--scope delivery`)? Handed off via queue?
- **Committing?** → PROGRESS updated?
- **Compacting?** → filed your state in MISSION_NOTES?
- **Starting on a mission?** → read the mission README (incl. this SOP) + MISSION_NOTES?
- **Starting on a mission?** → read the mission README (incl. this SOP) + MISSION_NOTES + `docs/reference/sdlc-conventions.md`?
@@ -61,7 +61,7 @@ Default posture on this host now:
Most work in OpenRig reduces to this loop:
- recover identity: `rig whoami --json`
- inspect inventory: `rig ps --nodes --json`
- inspect inventory: `rig ps` (fleet map) / `rig ps --nodes -A --json` (fleet nodes)
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
@@ -90,13 +90,13 @@ If the daemon is unreachable but identity can still be inferred, `--json` may re
## Inventory and Monitoring
```bash
rig ps
rig ps --json
rig ps --nodes
rig ps --nodes --json
rig ps # ALL active rigs, one compact row each (v0.4.4 default)
rig ps --json # bare array, all non-archived rigs
rig ps --nodes # current rig's nodes (session default, local only)
rig ps --nodes -A --json # fleet node inventory (explicit)
```
Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for:
Use `rig ps --nodes -A --json` for the current node inventory across rigs (v0.4.4: `--nodes` needs an explicit scope outside a managed session). It is the best machine-readable operator surface for:
- session name
- runtime
- session/startup status
@@ -386,7 +386,7 @@ rig whoami --json
Notes:
- for tmux-backed self-attach, `rig whoami --json` is the right verification
- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface
- for raw/external self-attach, `rig ps --nodes -A --json` is currently the more reliable verification surface
- if the current shell is outside tmux, pass `--display-name <name>` when you want a stable human session label recorded
### Adopt a topology and bind live sessions
@@ -453,13 +453,13 @@ Verification loop:
```bash
rig discover --json
rig adopt <fragment.yaml> --bindings-file <bindings.yaml> --target-rig <rigId>
rig ps --nodes --json
rig ps --nodes -A --json
rig export <rigId> -o rig.yaml
```
Success looks like:
- the new sessions stop appearing in `rig discover`
- the new logical IDs appear in `rig ps --nodes --json`
- the new logical IDs appear in `rig ps --nodes -A --json`
- `rig export` includes the new pod
### Mixed-origin rigs are allowed
@@ -477,7 +477,7 @@ Current safety rule:
The proven operator pattern is:
- keep one OpenRig manager session outside the rig it manages
- address the target by rig name, not cached rig ID
- resolve the current owner from fresh `rig ps --nodes --json`
- resolve the current owner from fresh `rig ps --nodes -A --json`
- send the manager the spec path, bindings path, and verification steps with `rig send`
This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert.
@@ -548,7 +548,7 @@ When the CLI behaves strangely, use the smallest truthful check first:
```bash
rig whoami --json
rig daemon status
rig ps --nodes --json
rig ps --nodes -A --json
```
Specific operator rules:
@@ -584,7 +584,7 @@ Design assumptions that hold in the shipped CLI:
1. `rig whoami --json`
2. `rig transcript <your-session> --tail 100`
3. `rig ps --nodes --json`
3. `rig ps --nodes -A --json`
4. `rig chatroom history <rig> --limit 50`
## Commands That Do Not Exist
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.4.3",
"version": "0.4.4",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [openrig-user, development-team, test-driven-development, systematic-debugging, executing-plans, verification-before-completion]
skills: [openrig-user, mission-slice-sop, development-team, test-driven-development, systematic-debugging, executing-plans, verification-before-completion]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ reviewer can inspect.
## Skills Loaded
- `openrig-user`
- `mission-slice-sop`
- `development-team`
- `test-driven-development`
- `systematic-debugging`
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [openrig-user, orchestration-team, backlog-capture, writing-plans, executing-plans, verification-before-completion, brainstorming]
skills: [openrig-user, mission-slice-sop, orchestration-team, backlog-capture, writing-plans, executing-plans, verification-before-completion, brainstorming]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -8,6 +8,7 @@ ready.
## Skills Loaded
- `openrig-user`
- `mission-slice-sop`
- `orchestration-team`
- `backlog-capture`
- `writing-plans`
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [openrig-user, requirements-writer, context-builder, writing-plans, verification-before-completion]
skills: [openrig-user, mission-slice-sop, requirements-writer, context-builder, writing-plans, verification-before-completion]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ execute without guessing.
## Skills Loaded
- `openrig-user`
- `mission-slice-sop`
- `requirements-writer`
- `context-builder`
- `writing-plans`
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [openrig-user, review-team, plan-review, systematic-debugging, verification-before-completion]
skills: [openrig-user, mission-slice-sop, review-team, plan-review, systematic-debugging, verification-before-completion]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ send targeted rework back to build or clear the packet for close.
## Skills Loaded
- `openrig-user`
- `mission-slice-sop`
- `review-team`
- `plan-review`
- `systematic-debugging`
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, development-team, frontend-design, verification-before-completion, writing-plans, brainstorming]
skills: [using-superpowers, openrig-user, mission-slice-sop, development-team, frontend-design, verification-before-completion, writing-plans, brainstorming]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ You are the design side of the product team. Your job is to turn rough product i
Load these packaged skills now before giving product direction:
- `using-superpowers`
- `openrig-user`
- `mission-slice-sop`
- `development-team`
- `frontend-design`
- `brainstorming`
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging, verification-before-completion, writing-plans, executing-plans]
skills: [using-superpowers, openrig-user, mission-slice-sop, development-team, test-driven-development, systematic-debugging, verification-before-completion, writing-plans, executing-plans]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ You are the implementation side of a dev pair. Your job is to write code, not to
Load these packaged skills now before editing:
- `using-superpowers`
- `openrig-user`
- `mission-slice-sop`
- `development-team`
- `test-driven-development`
- `systematic-debugging`
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood, writing-plans, executing-plans]
skills: [using-superpowers, openrig-user, mission-slice-sop, development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood, writing-plans, executing-plans]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ You are the quality assurance side of a dev pair. You gate every edit the implem
Load these packaged skills now before reviewing work or dogfooding:
- `using-superpowers`
- `openrig-user`
- `mission-slice-sop`
- `development-team`
- `systematic-debugging`
- `agent-browser`
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, orchestration-team, systematic-debugging, verification-before-completion, writing-plans, executing-plans, brainstorming]
skills: [using-superpowers, openrig-user, mission-slice-sop, orchestration-team, systematic-debugging, verification-before-completion, writing-plans, executing-plans, brainstorming]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ You are the orchestrator. You coordinate the work of other agents in this rig, m
Load these packaged skills now before doing substantive work:
- `using-superpowers`
- `openrig-user`
- `mission-slice-sop`
- `orchestration-team`
- `systematic-debugging`
- `brainstorming`
@@ -16,6 +16,7 @@ profiles:
uses:
skills:
- openrig-user
- mission-slice-sop
- office-hours
- context-builder
- requirements-writer
@@ -3,6 +3,19 @@
## Role
Senior Product Manager. Own the "what" and "why" — never architecture, estimates, or implementation details.
## Skills Loaded
Load these packaged skills now before shaping product work:
- `openrig-user`
- `mission-slice-sop`
- `office-hours`
- `context-builder`
- `requirements-writer`
- `ui-mockup`
- `plan-review`
- `exec-summary`
- `backlog-capture`
## 8-Step Feature Flow
1. **Capture** — run `backlog-capture` and record the idea in `idea_backlog.md`.
@@ -11,7 +11,7 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, review-team, systematic-debugging, verification-before-completion, writing-plans, brainstorming]
skills: [using-superpowers, openrig-user, mission-slice-sop, review-team, systematic-debugging, verification-before-completion, writing-plans, brainstorming]
guidance: []
subagents: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
@@ -7,6 +7,7 @@ You are an independent code reviewer. You analyze code on your own merits withou
Load these packaged skills now before reviewing:
- `using-superpowers`
- `openrig-user`
- `mission-slice-sop`
- `review-team`
- `systematic-debugging`
- `brainstorming`
@@ -26,6 +26,8 @@ resources:
path: skills/core/openrig-architect
- id: rig-lifecycle
path: skills/core/rig-lifecycle
- id: mission-slice-sop
path: skills/core/mission-slice-sop
- id: systematic-debugging
path: skills/process/systematic-debugging
- id: test-driven-development
@@ -0,0 +1,115 @@
---
name: mission-slice-sop
description: Use when working a mission or slice — the full SDLC flow (intent → mini-requirements + proof contract → mockups → plan-lock → build → QA visual compare → `rig proof` drops → proof-lock) plus how to track, prove, hand off, and carry state across agents and compaction via the canonical files (PROGRESS.md / PROOF.md / MISSION_NOTES.md / MISSION_BRIEF.md / README.md / IMPLEMENTATION-PRD.md). Covers the convention sections the Living Notes UI projects, the two locks, the three role contracts, per-file WHO/WHEN/HOW rules, the four-legs lifecycle, hot-potato queue handoffs, the moment-of-truth checklist, the deterministic `rig scope audit` backstop, and the ghost-text capture gotcha.
metadata:
openrig:
stage: shipped
last_verified: "2026-07-06"
distribution_scope: product-bound
source_evidence: |
Authored + embedded in release-0.4.3 slice 32 (mission-slice-sop);
moved into the canonical product skill source and updated in
release-0.4.4 slice 23 (operationalize the SDLC control plane) to
teach the full SDLC flow the Living Notes UI projects: the convention
sections, the proof-contract pairing, the two staged-approval locks,
C1 proof drops, and the three role contracts. Conventions SSOT:
docs/reference/sdlc-conventions.md (shipped with the CLI package).
The deterministic backstop is the `rig scope audit` classifier.
sibling_skills:
- queue-handoff
- seat-continuity-and-handover
- claude-compaction-restore
---
# Mission/Slice SOP — how you work a mission & slice
Use this skill to actually **do** mission/slice work the way OpenRig expects: author the convention sections, track on the canonical files, prove on them, hand off through them, and survive compaction on them. **Do the work described here; do not merely explain the protocol.** The deterministic backstop is `rig scope audit` — the audit classifier is the source of truth for adherence. The conventions themselves live in ONE place: **`docs/reference/sdlc-conventions.md`** (shipped with the CLI package) — this skill teaches the flow; the SSOT defines the formats.
## The SDLC flow (intent → proof)
```
intent → mini-requirements + proof contract → (UI slices: mockups)
→ plan-lock (rig scope slice approve --scope spec)
→ build the LOCKED set
→ QA: mockup ↔ delivered VISUAL compare
→ proof drops (rig proof add <slice> …)
→ proof-lock (rig scope slice approve --scope delivery)
```
1. **Record intent** verbatim in the slice's `## Intent` section (`rig scope slice create` scaffolds it — every template kind).
2. **Author the mini-requirements + proof contract**: `## Mini-requirements` is the concise one-glance tier (approval starts there); `## Proof contract` is a checkbox list of promised deliverables, each written as an observable outcome. UI deliverables name their planned mockup. The IMPLEMENTATION-PRD opens with the mini-requirements; everything between intent and proof is **elastic** — for a small slice the mini-requirements may BE the whole PRD.
3. **Plan-lock**: `rig scope slice approve <slice> --scope spec` — "the PRD matches the intent; THIS artifact set is what gets built." One daemon-side write: frontmatter stamp + append-only audit row.
4. **Build the locked set** — look at the mockups, not just the spec text.
5. **QA visual compare**: for each deliverable, load the planned mockup, produce the real artifact in a test/demo environment, visually compare, and record the verdict.
6. **Drop proof**: `rig proof add <slice> --artifact-type qa --verdict PASS --candidate-sha <tip> --money-evidence "…" --evidences "1,3" --media "walk.webm,panel.png" --self-check "…"` — the C1 header's closed sets validate at drop time; `--evidences` joins the drop to its proof-contract items and `--media` names the curated proof/-relative media the drop stands behind (that pairing + media set is what the UI's DELIVERED section renders).
7. **Proof-lock**: `rig scope slice approve <slice> --scope delivery` — the terminal sign-off. Approval is freeze/sign-off, **never** proven-green: proven-green requires the recorded C1 verdicts.
## The three role contracts
- **Planning agent:** authors intent + mini-requirements + the proof contract; produces mockups for UI deliverables and attaches them to the locked set (a UI slice with no mockup is an incomplete plan; non-UI slices have none — not a gate); locks the plan.
- **Build agent:** builds against the LOCKED set only; looks at the mockups.
- **QA agent (owns the compare):** visually compares planned vs delivered per deliverable, records verdict + note via proof drops, and **curates** the canonical proof set (bounded, mapped to deliverables; the fix-loop pile stays in `proof/`, one drill-in down). On mismatch: fix-and-re-review or kick back with the reason — never escalate a raw mismatch to the human.
## The canonical files (the operating surface)
> The canonical files below are the **operating surface of the work** — you track on them, prove on them, hand off through them, and survive compaction on them. They are NOT side-artifacts to "maintain"; keeping them current *is* the work.
- **README.md** (mission + slice) — the overview, OPENING with the convention sections (`## Intent` / `## Mini-requirements` / `## Proof contract`). The **mission README carries this SOP at its bottom.**
- **IMPLEMENTATION-PRD.md** — the full PRD; opens with the mini-requirements; the `## Proof contract` here is what the UI's DELIVERED pairing joins proof against.
- **PROGRESS.md** — the live delivery state. One line per outcome; links down for detail.
- **MISSION_NOTES.md** — the accruing handoff + tribal-knowledge doc. Blank-slate onboarding + **compaction-restore** read it. `§1` top-of-mind + per-seat `§A–§X`.
- **MISSION_BRIEF.md** — the steering doc (the UI "Steering" tab). The 7-section schema.
- **PROOF.md** (+ `proof/`) — acceptance evidence. **A slice is not done until every proof-contract item has evidence.**
## Per-file rules — WHO / WHEN / HOW
### PROGRESS.md
- **WHO:** the orchestrator owns `§1` (current state); every agent logs its own outcomes.
- **WHEN:** after every slice-done **AND every commit**; on any material state change.
- **HOW:** one line per outcome (checkbox), link down for detail (workstream-continuity format); keep frontmatter `stage`/`verified` honest.
### PROOF.md + proof/
- **WHO:** the impl/QA pair that worked the slice.
- **WHEN:** a slice is **NOT "done"** until every proof-contract item has evidence.
- **HOW:** proof maps **1:1 to the proof contract's deliverables** — put media under `proof/`, then ATTACH it with `rig proof add <slice> … --evidences <item> --media <files>` (the drop writes the C1 header the DELIVERED pairing joins on) + a line in PROOF.md stating what it proves. **Hand-placing files in `proof/` without a drop is the anti-pattern** — the deliverable stays unpaired and `unverified`. No proof → not done.
### MISSION_NOTES.md
- **WHO:** any agent updates `§1` (top-of-mind); each seat owns + appends to its own `§A–§X` section.
- **WHEN:** on any material change; a compacting agent **files its state here BEFORE compaction and reads it on restore.**
- **HOW:** accruing tribal knowledge — `§1` ≤ 5–15 lines (gates, open decisions, surprises); per-seat continuation entries (latest = truth; other seats read-only). Pointer-first; don't duplicate.
### MISSION_BRIEF.md
- **WHO:** product/design (steering owner).
- **WHEN:** when steering changes.
- **HOW:** the 7-section steering schema (the UI "Steering" tab reads it).
### README.md
- **WHO:** the author at creation; refreshed on rescope.
- **WHEN:** at mission/slice creation + when scope/theme changes.
- **HOW:** overview + honest frontmatter (`id`/`stage`/`verified`) + the convention sections up top; the mission README carries this SOP at its bottom.
## The lifecycle (4 legs)
**SCAFFOLD** (`rig scope` creates the files from templates — every slice kind emits the convention sections + `proof/` + the IMPLEMENTATION-PRD skeleton) → **POPULATE** (agents fill them as work happens, per the rules above) → **PROJECT** (the Living Notes UI reads them into INTENT → PLAN → DELIVERED) → **VERIFY** (`rig scope audit` checks adherence). "Loose freeform write + deterministic verify."
## Hot-potato (handoffs)
End every turn by passing the ball — a `rig queue` handoff to the next agent (close the qitem with a `closure_reason`). The **durable ball-pass is the queue close, not a chat message.** Never go idle without a handoff.
## Verify (deterministic backstop)
Run `rig scope audit` at slice-close. It flags: committed-without-touching-PROGRESS; slice-marked-done-without-PROOF; active-mission-without-MISSION_NOTES; MISSION_BRIEF off-schema; proof artifacts violating the C1 header; missing IMPLEMENTATION-PRD on a building slice; missing convention sections (`## Intent`, a well-formed `## Proof contract`, the UI-slice mockup ref). Every convention check is **advisory / fail-open** — it records and advises, never blocks a write. **Fix the flag, don't suppress it.**
## Reading terminal captures — KNOWN GOTCHA: ghost-text autocomplete is NOT real
When you `rig capture` a pane, **greyed / ghost autocomplete suggestions are NOT real content** — they are autocomplete *previews* (shell autosuggestion, the input-box ghost-text completion), not typed, staged, or committed input. **This is known, expected, and has been faking agents out a lot** — reading a ghost suggestion in a peer's input box as "staged text they're about to send," or as a real prompt, and then reasoning/acting on a string that was never actually there.
**Rule:** ignore ghost/autosuggest text entirely. Only *committed/rendered* pane output is real. If a `❯` input line shows text, treat it as an autocomplete artifact unless you have independent evidence it was actually entered. Do not build decisions (or worry about "colliding with staged text") on ghost text. When it matters, verify at source (git, the queue, the actual event) — never off a capture's ghost line.
## Moment-of-truth checklist
- **Starting a slice?** → intent recorded verbatim? mini-requirements + proof contract authored? mockups attached (UI slices)? plan locked (`--scope spec`)?
- **Finishing a slice?** → every proof-contract item has curated evidence via `rig proof add … --evidences --media` (C1 drops — never only hand-placed files)? PROGRESS updated? MISSION_NOTES `§1` refreshed? proof locked (`--scope delivery`)? Handed off via queue?
- **Committing?** → PROGRESS updated?
- **Compacting?** → filed your state in MISSION_NOTES?
- **Starting on a mission?** → read the mission README (incl. this SOP) + MISSION_NOTES + `docs/reference/sdlc-conventions.md`?
@@ -14,7 +14,7 @@ Use it when the problem may be the daemon, the shell/runtime surface, or stale h
Use this skill when you see:
- `rig whoami --json` returning partial identity
- `rig ps --nodes --json` failing while some other `rig` commands still work
- `rig ps --nodes -A --json` failing while some other `rig` commands still work
- `Sent to ...` plus `Verified: no`
- repeated unified-exec-process warnings
- suspicion that stale helper processes are accumulating
@@ -28,13 +28,13 @@ Start with the minimum truthful operator read:
```bash
rig whoami --json
rig daemon status
rig ps --nodes --json
rig ps --nodes -A --json
```
Interpret them together, not in isolation:
- partial `whoami` can mean identity is inferable while daemon-backed surfaces are degraded
- `daemon status` tells you whether the host daemon is up, not whether every seat can reach it cleanly
- `ps --nodes --json` is the best machine-readable topology check when it works
- `ps --nodes -A --json` is the best machine-readable fleet topology check when it works (v0.4.4: `--nodes` needs an explicit scope outside a managed session — `-A` for fleet, `--rig <name>` for one rig; the bare `rig ps` map is the cheap first glance)
## Verification Drift Vs Send Failure
@@ -104,7 +104,7 @@ If the warning or failure remains after stale-wrapper cleanup, re-check:
```bash
rig daemon status
rig ps --nodes --json
rig ps --nodes -A --json
```
If those remain healthy, the residual issue may still be in the host/tool/session layer rather than in OpenRig topology state.
@@ -435,27 +435,47 @@ honestly and the UI exposes a one-click open-missing affordance.
v0.4.0 flips the five most frequently invoked read-commands from firehose-by-default to compact-by-default, and `rig queue list` adopts the docker / kubectl read-command grammar. **All defaults preserve breadth and capability — the firehose is one explicit flag away.**
### `rig ps` — current-rig default + compact-by-default + `-A`/`--all-rigs` + `--full`
### `rig ps` — consolidated all-rigs default + the disclosure ladder (v0.4.4)
```bash
rig ps # CURRENT-RIG, all-states, compact rig summaries (default)
rig ps --json # compact JSON array (TL;DR per node: session, rig, activity, assigned/pending, resumeTokenPresent boolean)
rig ps -A # all-rigs (fleet breadth; was the v0.3.4 default)
rig ps --rig <name> # explicit-rig (overrides current-rig default)
rig ps --nodes # compact node inventory (current rig)
rig ps --nodes -A # cross-rig node inventory (was v0.3.4 default)
rig ps --nodes --full # complete record (the v0.3.4 per-node default shape; resumeToken VALUE retained here for downstream consumers)
rig ps # ALL ACTIVE RIGS, one compact row each + rollup line + count line + ladder footer (default)
rig ps --json # bare array of ALL non-archived rigs (incl. stopped; existing keys + additive attentionCount)
rig ps --rig <name> # one rig's detail
rig ps --nodes # compact node inventory (current rig — session default, LOCAL only)
rig ps --nodes --rig <name> # compact node inventory, named rig
rig ps --nodes -A # fleet node inventory, projected rows
rig ps --nodes -A --full # complete per-node records (the ONLY full fan-out; resumeToken VALUE retained here for downstream consumers)
rig ps --nodes --session <sess> # narrow to one canonical session
rig ps --include-archived # archived history as rows (otherwise ONE count line)
rig ps --active # opt-in active-state filter (does NOT change the all-states default — ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal)
```
**v0.4.0 breadth + projection changes**:
- **Slice 34**: default breadth is CURRENT-RIG (derived from `OPENRIG_SESSION_NAME`'s `@<rig>` suffix), not all-rigs. `-A` / `--all-rigs` widens to fleet. Matches the `rig queue list -A` pattern.
- **Slice 25 + 26**: per-node TL;DR projection (compact) is the default; `--full` returns the raw byte-equivalent passthrough. Daemon-side `recoveryGuidance` relocated to a guidance-by-reference map (no longer duplicated per-node) — even `--full` benefits.
- **All-states stays default** (different from `rig queue list` which defaults to active-only) — for `ps`, non-running states ARE often the actionable signal.
- **Resume-token security (slice 34)**: `--full` JSON emits `resumeTokenPresent` (boolean) — the actual `resumeToken` value also remains in `--full` for downstream consumers that legitimately need it, but the compact default never carries it (an orch glance never accidentally leaks token material).
**v0.4.4 consolidated default + disclosure ladder (OPR.0.4.4.21)**:
- **The default is the fleet MAP**: every active rig as one O(rigs) compact row, with the host rollup ("N rigs · M seats · K need attention"), the archived/stopped count line, and the drill-ladder footer. The v0.4.0 current-rig default is RETIRED — it hid running rigs from the operator's field of view.
- **`-A` has exactly ONE meaning**: the `--nodes` fleet widener. Bare `rig ps -A` errors (all-rigs IS the default; archived history stays behind `--include-archived`).
- **The session-rig default applies ONLY to `--nodes`, and only locally.** Implicit scope defaults don't cross host boundaries: `rig ps --host <id> --nodes` requires an explicit `--rig` or `-A`; multi-host fan-out is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows.
- **STATED JSON contract**: default `--json` = bare array of ALL non-archived rigs INCLUDING stopped ones (scope-not-shape: existing keys preserved, additive `attentionCount`); only the human table folds stopped rigs into the count line.
- **Resume-token security unchanged**: compact output never carries token material; `--full` retains it for consumers that legitimately need it.
**STOP using `rig ps --nodes --json` as a fleet-wide casual status check assuming the v0.3.4 shape.** The v0.4.0 default is CURRENT-RIG + compact; explicit `-A --full` is the fleet firehose. The ~77,000-token status-glance incident is closed twice over (compact + scope).
**The casual status glance is just `rig ps`.** The old fleet firehose is the explicit last rung: `rig ps --nodes -A --full`. The ~77,000-token incident class stays closed (compact + O(rigs) default + explicit ladder).
### `rig host` — the multi-host registry verbs (v0.4.4)
```bash
rig host add --id <id> --transport ssh --target <tailnet-alias> --user openrig
rig host add --id <id> --transport http --url http://100.x.y.z:7433 --bearer-env MY_TOKEN
rig host list # pointers only — never secret values
rig host doctor <id> # stepwise: transport -> rig binary -> daemon -> identity
rig host doctor <id> --posture product-factory-vps [--public-addr <ip>]
```
Exactly three verbs (capped); the VPS factory bootstrap is script + runbook
(`docs/reference/product-factory-vps-runbook.md`). **Transport posture is
DECIDED and documented** (cli-reference §Cross-host execution): ssh = pane ops
(`send`/`capture`), http = daemon REST (`up`/`down`/`launch`), `ps`/`whoami`
follow the DECLARED transport; fan-out is http-only; no cross-transport
fallback; no http parity for send/capture in 0.4.4. Posture/doctor UNKNOWN is
never pass — each unknown carries the command that verifies it.
### `rig whoami` — compact-by-default + `--full` (`--verbose` alias)
@@ -543,6 +563,30 @@ rig scope slice show <slice> # derives read-time effect
Composes with slice 33's `progress` + scaffolding to make `rig scope` the **deterministic enforcer** of `conventions/scope-and-versioning` §1 (dot-IDs) + §2 (maturity vocabulary). Agents update `stage` / `verified` / `id` through commands rather than hand-editing markdown and drifting. The `--against` MANDATORY rule on `verified` is the anti-stale keystone: bare timestamps are rejected because a bare timestamp is exactly what lets stale trackers lie while looking fresh. **STOP hand-editing the `stage` / `verified` / `id` fields in scope frontmatter; use the new verbs.** Existing missions / slices with `id:null` ghosts or missing `PROGRESS.md` are repaired idempotently via `reconcile`.
### The SDLC control plane — convention sections, the two locks, `rig proof` (v0.4.4)
The Living Notes UI is a plain projection of well-formed on-disk markdown. The conventions live in ONE shipped document — **`docs/reference/sdlc-conventions.md`** (in the CLI package) — and the operating procedure is the **`mission-slice-sop`** skill. The command surface:
```bash
rig scope slice create <mission> <slug> [--template <kind>] # scaffolds ## Intent / ## Mini-requirements / ## Proof contract
# + proof/ + PROOF.md + IMPLEMENTATION-PRD.md — EVERY template kind
rig scope slice approve <slice> --scope spec # PLAN-LOCK: "the PRD matches my intent; this set gets built"
rig scope slice approve <slice> --scope delivery # PROOF-LOCK: the terminal sign-off (default scope; fires the freeze)
rig proof add <slice> --artifact-type qa --verdict PASS \
--candidate-sha <tip> --money-evidence "<one line>" \
--evidences "1,3" --media "walk.webm" \
--self-check "<looked at it>" # C1 proof drop into proof/; --evidences joins the drop to its
# proof-contract items and --media names the curated proof/-relative
# media it stands behind (what the DELIVERED section pairs + renders)
rig scope audit <mission> # deterministic backstop — flags missing sections / malformed
# proof contract / invalid C1 headers; ADVISORY, never blocks
```
The flow: intent → mini-requirements + proof contract (→ mockups for UI slices) → plan-lock → build the locked set → QA visual compare → `rig proof` drops → proof-lock. Approval is freeze/sign-off, **never** proven-green — proven-green requires recorded C1 verdicts.
### `rig skill audit` — skill cascade provenance (slice 10)
```bash
@@ -584,7 +628,7 @@ Typical operator loop:
```bash
rig up secrets-manager --cwd /path/to/project
rig ps --nodes --json
rig ps --nodes --rig secrets-manager --json
rig send vault-specialist@secrets-manager "Check Vault health and report back." --verify
rig env status secrets-manager
rig env logs secrets-manager
@@ -634,10 +678,12 @@ JSON form exposes `peersNote` directly for programmatic consumers.
rig ps
rig ps --json
rig ps --nodes
rig ps --nodes --json
rig ps --nodes --rig <name> --json
rig ps --nodes -A --json
rig ps --nodes -A --full --json
```
Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for:
Use `rig ps --nodes --rig <name> --json` for one rig's node inventory, or `rig ps --nodes -A --json` for fleet-wide projected node inventory. Use `--full` only when a downstream consumer truly needs complete per-node records. The node inventory surface carries:
- session name
- runtime
- session/startup status
@@ -964,7 +1010,7 @@ rig whoami --json
Notes:
- for tmux-backed self-attach, `rig whoami --json` is the right verification
- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface
- for raw/external self-attach, `rig ps --nodes --rig <rigId-or-name> --json` is currently the more reliable verification surface
- if the current shell is outside tmux, pass `--display-name <name>` when you want a stable human session label recorded
### Adopt a topology and bind live sessions
@@ -1031,13 +1077,13 @@ Verification loop:
```bash
rig discover --json
rig adopt <fragment.yaml> --bindings-file <bindings.yaml> --target-rig <rigId>
rig ps --nodes --json
rig ps --nodes --rig <target-rig-name> --json
rig export <rigId> -o rig.yaml
```
Success looks like:
- the new sessions stop appearing in `rig discover`
- the new logical IDs appear in `rig ps --nodes --json`
- the new logical IDs appear in `rig ps --nodes --rig <target-rig-name> --json`
- `rig export` includes the new pod
### Mixed-origin rigs are allowed
@@ -1055,7 +1101,7 @@ Current safety rule:
The proven operator pattern is:
- keep one OpenRig manager session outside the rig it manages
- address the target by rig name, not cached rig ID
- resolve the current owner from fresh `rig ps --nodes --json`
- resolve the current owner from fresh `rig ps --nodes -A --json`
- send the manager the spec path, bindings path, and verification steps with `rig send`
This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert.
@@ -1152,7 +1198,7 @@ When the CLI behaves strangely, use the smallest truthful check first:
```bash
rig whoami --json
rig daemon status
rig ps --nodes --json
rig ps --nodes
```
Specific operator rules:
@@ -1188,7 +1234,7 @@ Design assumptions that hold in the shipped CLI:
1. `rig whoami --json`
2. `rig transcript <your-session> --tail 100`
3. `rig ps --nodes --json`
3. `rig ps --nodes`
4. `rig chatroom history <rig> --limit 50`
## Commands That Do Not Exist
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more