mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
Compare commits
140
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503424aa09 | ||
|
|
4f4db0fe13 | ||
|
|
d87d1a85cc | ||
|
|
d7910f8980 | ||
|
|
941a2e3149 | ||
|
|
784222d30d | ||
|
|
6b21ce3c86 | ||
|
|
bc5ec05fce | ||
|
|
e5d61983ae | ||
|
|
a4b4b67dae | ||
|
|
e154a5cef1 | ||
|
|
c698838a46 | ||
|
|
a66e1488f1 | ||
|
|
164a67bef2 | ||
|
|
50b17c73d5 | ||
|
|
207ad5f1cb | ||
|
|
2f731b8410 | ||
|
|
58713e0c58 | ||
|
|
30fd7993dc | ||
|
|
eba8fbe4f6 | ||
|
|
236597084f | ||
|
|
4b6a541044 | ||
|
|
7865fd6bac | ||
|
|
f8b8dbe5bf | ||
|
|
9bf770e33f | ||
|
|
8eac96bcd4 | ||
|
|
43f2259ff7 | ||
|
|
ea03680de4 | ||
|
|
275873b507 | ||
|
|
79d06f8db9 | ||
|
|
64f8aa8bc6 | ||
|
|
ad6a522b69 | ||
|
|
a29c78834f | ||
|
|
5048842701 | ||
|
|
0c9e63f10e | ||
|
|
a0177ded00 | ||
|
|
dc4df12fc6 | ||
|
|
233dd16d72 | ||
|
|
79a89d4061 | ||
|
|
257b51b94a | ||
|
|
13d2adf992 | ||
|
|
e7a0b253b0 | ||
|
|
38f4206ed7 | ||
|
|
cb0bf7b943 | ||
|
|
3cd581e3c4 | ||
|
|
d491eca96e | ||
|
|
a0e7e0e196 | ||
|
|
329e2e228d | ||
|
|
16ebb8af07 | ||
|
|
d81456dc3d | ||
|
|
9f9ebe0a51 | ||
|
|
d43b7729f9 | ||
|
|
a2726e8f51 | ||
|
|
08a74ceacb | ||
|
|
1e4b229da0 | ||
|
|
da286abddd | ||
|
|
4f50d12735 | ||
|
|
e7e5192c18 | ||
|
|
1698f4e02a | ||
|
|
f6b2184940 | ||
|
|
76c5f0dbff | ||
|
|
5aec0ef529 | ||
|
|
5f410eeef7 | ||
|
|
2bf9c6606f | ||
|
|
595e9550d1 | ||
|
|
9de91ad43a | ||
|
|
0c13c7c661 | ||
|
|
f8bf875c84 | ||
|
|
1b9f15ee91 | ||
|
|
80f8f9f3b7 | ||
|
|
f7c5a477fa | ||
|
|
63ee7dd793 | ||
|
|
b0ae54ad57 | ||
|
|
0af652dca7 | ||
|
|
a660b47592 | ||
|
|
8752bfe972 | ||
|
|
8865ee6f4f | ||
|
|
97803a8fb3 | ||
|
|
5b00abf712 | ||
|
|
6401fe4be5 | ||
|
|
fbf92f9ce1 | ||
|
|
becce5e932 | ||
|
|
29a9027734 | ||
|
|
f10a19901a | ||
|
|
1fd11b762b | ||
|
|
77bed48ba6 | ||
|
|
6b81d3b6d5 | ||
|
|
adac0dcf51 | ||
|
|
3b7432e41c | ||
|
|
41fce1a5c9 | ||
|
|
82d397156f | ||
|
|
1b96a3b0b8 | ||
|
|
612be91d88 | ||
|
|
2e809917e9 | ||
|
|
ef659f0648 | ||
|
|
fc13c2be3e | ||
|
|
dc57b1e0c1 | ||
|
|
5264b19ae4 | ||
|
|
33547403bf | ||
|
|
d34c7a1aca | ||
|
|
33e6853bc2 | ||
|
|
7f4085e684 | ||
|
|
1aa0c5237e | ||
|
|
cfec550801 | ||
|
|
9ecd28b81c | ||
|
|
76edf9246a | ||
|
|
55e01f2e47 | ||
|
|
27e7fc1ab1 | ||
|
|
1cccc22678 | ||
|
|
168b97fa5f | ||
|
|
12d7faa79d | ||
|
|
fcd621a425 | ||
|
|
cef0dc543a | ||
|
|
ecd8dcc7f0 | ||
|
|
1909f7cf09 | ||
|
|
59f60cb637 | ||
|
|
4b8044816d | ||
|
|
7a12c930b6 | ||
|
|
02dfa8c4a9 | ||
|
|
07f4d35b2a | ||
|
|
e8c8298fc5 | ||
|
|
16b85d9c41 | ||
|
|
f3449bafdd | ||
|
|
777cd5b769 | ||
|
|
15b2526ec2 | ||
|
|
bb0ae3ccd7 | ||
|
|
0db030ce3f | ||
|
|
2adf3c6d84 | ||
|
|
2b4772c785 | ||
|
|
58bd1bb56b | ||
|
|
2b45c8efba | ||
|
|
c21252962e | ||
|
|
c77f5190a1 | ||
|
|
28a2b88a22 | ||
|
|
a3e3d4a0cb | ||
|
|
a28f9b710e | ||
|
|
92a993d37d | ||
|
|
2adf6a10a3 | ||
|
|
7eaf524cc4 | ||
|
|
1483215a45 |
No files matched your search
+183
@@ -8,6 +8,189 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
|
||||
|
||||
---
|
||||
|
||||
## [0.3.2] - 2026-06-02
|
||||
|
||||
**Status**: released. npm `@openrig/[email protected]` (latest); GitHub Release
|
||||
`v0.3.2`; git tag `v0.3.2`.
|
||||
|
||||
### Summary For Installing Agents
|
||||
|
||||
- **Package version**: package metadata bumped at release-manager step; CLI
|
||||
reports the new version after `npm publish`.
|
||||
- **Migrations**: one new migration in 0.3.2 — `041_rig_policy`
|
||||
(`CREATE TABLE` for the operator-context-mode bindings store; no impact
|
||||
on existing data; binding rows are operator-authored at runtime).
|
||||
Existing databases upgrade by running `rig daemon start`.
|
||||
- **Node engines**: unchanged from 0.3.1 (CLI accepts Node `>=20`).
|
||||
- **Backward compatibility**: existing CLI argument shapes, daemon route
|
||||
paths, RigSpec/AgentSpec schemas, and persisted settings remain backward
|
||||
compatible. New routes are additive. New CLI commands (`rig policy`,
|
||||
`rig scope`, `rig workspace doctor`) are additive. New `rig queue create`
|
||||
flags (`--body-file`, `--mission`, `--slice`) are additive.
|
||||
|
||||
### Rigbundles First-Class
|
||||
|
||||
`rig bundle` ships cross-primitive bundling end-to-end:
|
||||
|
||||
- **Five content kinds routed**: skills + plugins (hybrid) +
|
||||
workflow_specs + context_packs + agent_images. Each kind lands in
|
||||
its canonical library under `$OPENRIG_HOME`; consumer-scan
|
||||
visibility preserved.
|
||||
- **`bundle.yaml` author manifests** auto-detected when present.
|
||||
Vendoring uses both-sides path containment + symlink-escape
|
||||
protection + integrity hashing in the manifest.
|
||||
- **`rig bundle create` new flags**: `--notes <text>`,
|
||||
`--min-daemon-version <ver>`, `--min-cli-version <ver>` — operator
|
||||
notes captured in bundle provenance metadata; min-version gates
|
||||
power the install-time compatibility check.
|
||||
- **`rig bundle install` new flags**: `--skip-version-check` (operator-
|
||||
explicit override of the install-time compatibility check),
|
||||
`--force` (operator-explicit override of the install-time conflict
|
||||
check). NOT recommended for routine use; provided for known-good
|
||||
operator scenarios.
|
||||
- **`rig bundle history`** — new subcommand reading
|
||||
`~/.openrig/bundle-audit.jsonl` with optional `--rig` /
|
||||
`--since` filters.
|
||||
- **Install timeout bumped** — the prior 5-second cap was too short
|
||||
for tmux-session-bootstrapping installs.
|
||||
|
||||
### Workspace + Workflow GA
|
||||
|
||||
Operator-facing surface hardened:
|
||||
|
||||
- **`rig workspace validate --max-files`** — strict-int regex enforced;
|
||||
out-of-range values produce a 3-part error before `client.post`.
|
||||
- **`rig workflow project --exit`** — enum guard against
|
||||
`handoff | waiting | done | failed`.
|
||||
- **14 new discriminator tests** on validator paths.
|
||||
|
||||
### `rig up <starter>` Paper-Cut Fix-Round
|
||||
|
||||
Four bounded fixes unblocking the homepage quick-start on fresh
|
||||
0.3.1→0.3.2 installs:
|
||||
|
||||
- **HTTP 4xx surface for pre-launch failures**: `cycle_error`,
|
||||
`preflight_failed`, `validation_failed`, `service_boot_failed` —
|
||||
replaces bare 500.
|
||||
- **No orphan rig record on pre-launch failure**: instantiator
|
||||
re-ordered + rollback path + compose teardown order tightened.
|
||||
- **Path-form `rig up <install-internal-spec>` defaults cwd**: closes
|
||||
the divergence between path-form and bare-form invocation; library
|
||||
specs now match path-form behavior.
|
||||
- **`walkYamlFiles` skip standard noise dirs**: `.worktrees`,
|
||||
`node_modules`, `.git`, `dist`, `.turbo`, `.next`. Plus stale
|
||||
`workflow_specs` rows prune at startup with an install-root
|
||||
preservation guard (shipped built-in specs survive the prune).
|
||||
|
||||
### Coordination + First-User Setup Fix-Round (slice 21)
|
||||
|
||||
Five bounded follow-rounds:
|
||||
|
||||
- **FR-1 — Coordination-model boot instinct**: `rig send` vs
|
||||
`rig queue` vs `rig queue handoff` + §1b doctrine surfaced in
|
||||
`core/openrig-user/SKILL.md`.
|
||||
- **FR-2 — First-user workspace + workflow setup teaching**: skill
|
||||
+ docs content for the path from fresh install to first
|
||||
workflow_spec authoring.
|
||||
- **FR-3 — MISSION_NOTES durable-pattern hardening**: convention
|
||||
codemap + auto-scaffold via `rig scope mission create` (uses
|
||||
`conventions/mission-notes/TEMPLATE.md`); `--no-mission-notes`
|
||||
opts out.
|
||||
- **FR-4 — Queue ergonomics**:
|
||||
- `rig queue create --body-file <path>` (use `-` for stdin) kills
|
||||
the backtick-shell-corruption class for multiline bodies.
|
||||
Mutually exclusive with `--body`.
|
||||
- First-class `--mission <id>` / `--slice <id>` flags translate
|
||||
to `mission:<id>` / `slice:<id>` tags (compose with `--tags`).
|
||||
- `lastNudgeResult` wording fix; closure-vs-acceptance docs.
|
||||
- **FR-5 — `rig workspace doctor`**: 7-check workspace-readiness
|
||||
diagnostic (workspace root, missions folder, file allowlist,
|
||||
daemon alignment, daemon reload, optional slice docs,
|
||||
MISSION_NOTES presence). Default exit-code: non-zero only on
|
||||
`fail`; `--strict` makes warn-or-fail non-zero. CLI overlays
|
||||
`OPENRIG_FILES_ALLOWLIST` from operator's shell env.
|
||||
|
||||
### Daemon Test Substrate-Path Scrub (slice 14)
|
||||
|
||||
Internal-team substrate path shape scrubbed from 4 daemon source
|
||||
sites + 10 test files. Hook-constants de-duplicated. Privacy class
|
||||
closed for tracked source + test surface.
|
||||
|
||||
### ConceptCard Data Source (slice 17)
|
||||
|
||||
`ConceptCard` wired to shaped backlog candidates; storytelling-adapter
|
||||
completion deferred from 0.3.1 is now complete.
|
||||
|
||||
### For-You Priority Windowing (slice 20)
|
||||
|
||||
Server-side attention query (Option 3): SQL predicate pushdown +
|
||||
exact-match attention regex + dismissal as string-keyed for
|
||||
queue-derived cards.
|
||||
|
||||
### Operator Context-Mode Bindings (slice 09)
|
||||
|
||||
New `rig policy` command surface paired with daemon-side typed-primitive
|
||||
store (migration `041_rig_policy`):
|
||||
|
||||
- **Six modes**: `sleep | desk | mobile | away | focus | debug`.
|
||||
- **Four scopes**: `global_host | rig | workstream | qitem`.
|
||||
- **Restate-and-confirm posture (HG-4)**: `set` is restate-only
|
||||
until `--confirm` is passed; scripts cannot silently apply a
|
||||
binding.
|
||||
- **`--qualifier` strict reject for `global_host` (HG-7 guard
|
||||
finding)**: operators who type `--scope global_host --qualifier
|
||||
<id>` get an error and the daemon is never contacted.
|
||||
- **Operator-edit verbs require bearer token**: `set --confirm`
|
||||
and `unset` require `--bearer <token>` (or
|
||||
`OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
- **6 subcommands**: `set`, `show`, `effective`, `cite`, `unset`,
|
||||
`defaults`.
|
||||
|
||||
### Scope Tree Primitive (slice 12)
|
||||
|
||||
New `rig scope` command for operating the substrate scope tree
|
||||
(missions, slices, sub-slices) per
|
||||
`conventions/scope-and-versioning`:
|
||||
|
||||
- **Mission tier**: `ls`, `show`, `create` (auto-mints stable
|
||||
dot-IDs into mission frontmatter; auto-scaffolds MISSION_NOTES
|
||||
by default; `--template` auto-selects `release` when name matches
|
||||
`release-X.Y.Z`).
|
||||
- **Slice tier**: `ls`, `show`, `create`, `ship`, `close`, `move`.
|
||||
- **Templates ship under `dist/lib/scope-templates/`**: `release-
|
||||
feature`, `placeholder`, `mission-notes`, `bug-fix`,
|
||||
`research`, `backlog-deprecation`, `backlog-tech-debt`,
|
||||
`mission-placeholder`, `mission-release`.
|
||||
- **Top-level option `--workspace <path>`** overrides workspace
|
||||
root (otherwise inferred from cwd or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
### Known Limitations / 0.3.3 Deferrals
|
||||
|
||||
- **Slice-05 Item-3 sub-scopes deferred to 0.3.3**: agent/port/managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-rig
|
||||
pathway acceptance (Item 4.4); the `--target-name` CLI flag.
|
||||
Design-contingent on the CLI surface decision.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema deferred to 0.3.3**:
|
||||
the new `accepted` queue state is a schema model change; carried
|
||||
forward per the release-triage philosophy.
|
||||
- **Onboarding journey + battle-hardening deferred to 0.3.3**: slices
|
||||
04 (new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) physically moved to the `release-0.3.3` mission tree.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots is a follow-up
|
||||
from slice-21 FR-5 QA; non-blocking for 0.3.2.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental` only;
|
||||
not split-deferred to 0.3.3 (stays held).
|
||||
- **`rig down <name>` still returns HTTP 404** at v0.3.2; use
|
||||
`rig down <rigId> --delete` instead.
|
||||
- **`rig view list/show --json` flag inconsistency** — wrapper-layer
|
||||
routing path remains; daemon's `view show <name>` route returns
|
||||
JSON correctly when invoked directly. Workaround: human-readable
|
||||
output for now.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.1] - 2026-05-15
|
||||
|
||||
**Status**: released. npm `@openrig/cli@0.3.1` (latest); GitHub Release
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Docs — Map of Territory + Module Index
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Starting any technical task that needs the shipped OpenRig system as it
|
||||
actually is. Read this first to learn what the as-built tree contains and
|
||||
which module to open; then go to codemap.md for use-case navigation or
|
||||
straight to the named module.
|
||||
siblings: [codemap.md, cli-reference.md, frontmatter-schema.md]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# OpenRig As-Built Docs
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies — a
|
||||
multi-agent harness that manages your Claude Code and Codex sessions as a
|
||||
single system, with a daemon (`@openrig/daemon`), a CLI (`@openrig/cli`), a UI
|
||||
(`@openrig/ui`), and an MCP server all sitting on one SQLite-backed core. This
|
||||
tree is the **source-verified** description of that system as it actually
|
||||
ships — every load-bearing claim is grounded to `packages/*/src` at a named
|
||||
commit, not to memory, chat, or older docs.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
|
||||
> packages; HEAD carries 6 commits of unreleased 0.3.2 work, no `v0.3.2` tag.
|
||||
|
||||
## How this tree is organized
|
||||
|
||||
The as-built corpus was modularized (slice 08, `context-architecture-v1`) from
|
||||
two monolithic files into a folder of thematic modules, each independently
|
||||
loadable, each frontmatter-tagged for retrieval, each ≤300 lines (author-mode
|
||||
modules with no prior prose may run to ≤400 — see the slice-08 ACK).
|
||||
|
||||
```
|
||||
docs/as-built/
|
||||
├── README.md ← you are here: map-of-territory + index
|
||||
├── codemap.md ← navigation index (use-case lookup, source-root pointers)
|
||||
├── frontmatter-schema.md← the frontmatter convention these docs follow
|
||||
├── cli-reference.md ← full rig CLI surface (kept whole)
|
||||
├── architecture/ ← 13 backend/runtime modules
|
||||
└── ui/ ← 4 operator-surface modules
|
||||
```
|
||||
|
||||
`docs/DESIGN.md` (the canonical visual / brand / design-system spec) **stays
|
||||
at the repo `docs/` root** by design — it is referenced by many existing
|
||||
`docs/DESIGN.md` paths and carries no source drift. This tree points at it
|
||||
(see `ui/library-specs-and-design-system.md`); it is not copied here.
|
||||
|
||||
## Module index
|
||||
|
||||
### `architecture/` — backend, daemon, runtime
|
||||
|
||||
| Module | What it covers |
|
||||
|---|---|
|
||||
| [daemon-core.md](architecture/daemon-core.md) | How the daemon boots, `createDaemon` wiring, the SQLite schema/40-migration set, the route-mount surface. |
|
||||
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | The five-method RuntimeAdapter contract, the Claude/Codex/Terminal adapters (launch/resume/fork in tmux), and the resume-honesty layer (honest resumed-vs-fresh assessment). |
|
||||
| [coordination-primitive.md](architecture/coordination-primitive.md) | PL-004 Phase A stream/queue/inbox/outbox; the hot-potato closure contract; where queue closure is enforced. |
|
||||
| [workflow-runtime.md](architecture/workflow-runtime.md) | PL-004 Phase D Workflow Runtime — spec cache, instance state, step trails, transactional-scribe projection, watchdog policies. |
|
||||
| [mission-control.md](architecture/mission-control.md) | PL-005 queue-observability surface — seven views, seven write verbs, action audit, bearer-token middleware. |
|
||||
| [agent-spec-and-startup.md](architecture/agent-spec-and-startup.md) | AgentSpec/RigSpec types, profile resolution, additive startup layering, StartupOrchestrator, whoami/materialize/bind/adopt identity. |
|
||||
| [lifecycle-snapshot-restore.md](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, honest restore (resume vs rebuild vs fresh), restore-honesty enforcement, restore-check / restore-packet probes. |
|
||||
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, pipe-pane transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key distinction. |
|
||||
| [workspace-primitive.md](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration (root/repos/defaultRepo/knowledgeRoot), migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. |
|
||||
| [content-surfaces.md](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + JSONL edit audit, PROGRESS.md tree indexer, the one-screen Steering composer. |
|
||||
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | The filesystem-canonical content layer — plugin discovery, agent images, context packs, the Claude auto-compaction policy enforcer. |
|
||||
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 pod bundles + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
|
||||
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules, the RigEvent union + SSE delivery, intentional compatibility limits. |
|
||||
|
||||
### `ui/` — operator surfaces
|
||||
|
||||
| Module | What it covers |
|
||||
|---|---|
|
||||
| [shell-and-routing.md](ui/shell-and-routing.md) | The UI shell (rail / Explorer / center workspace / drawer / preview stack), the actual route tree the shipped UI mounts, the shared detail drawer + event consumption. |
|
||||
| [topology.md](ui/topology.md) | The topology surface — host hybrid graph, table/terminal views, activity-ring / hot-potato visual language, terminal-preview popovers, navigation/overlay contracts. |
|
||||
| [project-and-for-you.md](ui/project-and-for-you.md) | The operator destination surfaces — the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, the Dashboard landing on the vellum brand system. |
|
||||
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/skills/plugins/agent-images surfaces, the spec-review + spec-library + live-identity flows, the design-system pointer. |
|
||||
|
||||
### Root docs
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [codemap.md](codemap.md) | Navigation index — module map, structural relationship diagram, fast-lookup-by-use-case, source-root pointer table. Start here when you know *what* you need but not *which module*. |
|
||||
| [cli-reference.md](cli-reference.md) | The full `rig` CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. Kept as one doc (slice-08 Q4). |
|
||||
| [frontmatter-schema.md](frontmatter-schema.md) | Which frontmatter convention governs every doc in this tree, plus the one as-built-unique field (`last-verified-against-source`). |
|
||||
| `../DESIGN.md` | The canonical visual / brand / design-system spec (lives at `docs/` root by design; pointer only — not duplicated here). |
|
||||
|
||||
## Source-grounding contract
|
||||
|
||||
Every module declares `last-verified-against-source: <commit-sha>` — the
|
||||
commit its claims were checked against. Load-bearing corrections are recorded
|
||||
inline with an auditable annotation (`> Drift-fix Dx — said X; corrected to Y;
|
||||
slice-00 §z; re-confirmed <file:line> @HEAD`). OPEN items (counts that are
|
||||
definitional or runtime-only) are carried verbatim, never smoothed. The
|
||||
schema is defined in [frontmatter-schema.md](frontmatter-schema.md) and the
|
||||
governing convention is `openrig-work/conventions/frontmatter-for-context/`.
|
||||
+53
-1142
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,228 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Adapters and Runtimes — Claude/Codex/Terminal, tmux/cmux, Resume Honesty
|
||||
status: active
|
||||
topics: [agent-runtime, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need the runtime-adapter contract — how OpenRig launches and resumes a
|
||||
Claude Code, Codex, or terminal harness inside tmux, what the five adapter
|
||||
methods do, or how the daemon honestly assesses whether a harness actually
|
||||
resumed vs fresh-launched (the resume-honesty layer).
|
||||
siblings: [daemon-core.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Adapters and Runtimes
|
||||
|
||||
How OpenRig drives the agent harnesses. The daemon never talks to Claude Code,
|
||||
Codex, or a shell directly — it talks to a `RuntimeAdapter`. Three adapters
|
||||
implement one five-method contract; a separate resume-honesty layer answers the
|
||||
question "did this harness *actually* resume, or did it silently fresh-launch?"
|
||||
truthfully rather than optimistically.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). All source in this module is **present at `v0.3.0`**
|
||||
> (`git cat-file -e v0.3.0:<path>` for all 7 files → present) — the adapter
|
||||
> layer is core reboot-era machinery, **not** a 0.3.x feature; do not
|
||||
> back-attribute (§10.8 version-attribution proof technique).
|
||||
|
||||
> Drift-fix (scope) — `architecture.md` §5 "Runtime adapters" carries **no
|
||||
> slice-00 numeric drift** (proposed-structure §4.2: "adapter contract is
|
||||
> current"). The corrections below are *precision* refinements where the source
|
||||
> is more specific than the prose, not stale-count fixes. Each is annotated
|
||||
> inline and re-confirmed at HEAD.
|
||||
|
||||
## 1. The five-method RuntimeAdapter contract
|
||||
|
||||
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:127`
|
||||
(`interface RuntimeAdapter`). Every adapter declares a `readonly runtime`
|
||||
string and implements exactly five methods (`runtime-adapter.ts:128–153`):
|
||||
|
||||
| Method | Signature (`runtime-adapter.ts`) | Responsibility |
|
||||
|---|---|---|
|
||||
| `listInstalled` | `(binding)` `:131` | List currently installed/projected resources for a node. |
|
||||
| `project` | `(plan, binding)` `:134` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
|
||||
| `deliverStartup` | `(files, binding)` `:137` | Deliver resolved startup files to the runtime. |
|
||||
| `launchHarness` | `(binding, opts)` `:147` | Launch the harness inside the bound tmux session; return a resume token. |
|
||||
| `checkReady` | `(binding)` `:153` | Probe whether the harness is responsive and ready. |
|
||||
|
||||
Startup *action* execution (`slash_command` / `send_text`) is explicitly **not**
|
||||
part of this contract — the contract docstring (`runtime-adapter.ts:121–125`)
|
||||
states actions belong to the `StartupOrchestrator` *after* `checkReady()`. The
|
||||
orchestrator delivery split is in `agent-spec-and-startup.md`.
|
||||
|
||||
### `launchHarness` opts and the fork seam
|
||||
|
||||
`launchHarness` opts is `{ name: string; resumeToken?: string; forkSource?:
|
||||
ForkSource }` (`runtime-adapter.ts:147–150`).
|
||||
|
||||
> Drift-fix (precision) — `architecture.md` §4 said `launchHarness(binding,
|
||||
> opts: { name, resumeToken? })`. Source adds a third, mutually-exclusive
|
||||
> `forkSource` field. Per the contract docstring (`runtime-adapter.ts:142–146`)
|
||||
> `resumeToken` and `forkSource` are mutually exclusive — if both are provided
|
||||
> the adapter **must refuse** with a clear error, not guess; `forkSource`
|
||||
> triggers a fork and the captured token is the NEW post-fork token, never the
|
||||
> parent. `ForkSource` is `runtime-adapter.ts:116` (`kind: "native_id" |
|
||||
> "artifact_path" | "name" | "last"`; v1 MVP accepts `native_id` only — other
|
||||
> shapes rejected at schema validation, docstring `:106–119`).
|
||||
|
||||
### `HarnessLaunchResult` is a discriminated union with an honest failure arm
|
||||
|
||||
> Drift-fix (precision) — `architecture.md` §4 said `HarnessLaunchResult` is
|
||||
> `{ ok, resumeToken?, resumeType?, error? }` (a single optional-field shape).
|
||||
> Source is a **discriminated union** (`runtime-adapter.ts:81–86`):
|
||||
> `| { ok: true; resumeToken?; resumeType? }`
|
||||
> `| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence? }`.
|
||||
> The failure arm carries a typed `recovery` hint
|
||||
> (`HarnessLaunchRecovery = "retry_fresh" | "attention_required"`,
|
||||
> `:79`) and optional `evidence` (last-N pane lines, flowed through to
|
||||
> `RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
|
||||
> `:83–86`). This is the honest-failure shape, not a smoothed optional `error`.
|
||||
|
||||
## 2. The three adapters
|
||||
|
||||
All three live under `packages/daemon/src/adapters/` and implement
|
||||
`RuntimeAdapter` (Architecture Rule 1: zero Hono in `adapters/`).
|
||||
|
||||
### ClaudeCodeAdapter (`claude-code-adapter.ts:41`)
|
||||
|
||||
- `readonly runtime = "claude-code"` (`:42`).
|
||||
- **Projects** to `.claude/` targets: `guidance_merge` → `<cwd>/CLAUDE.md`
|
||||
(`:127`); `skill_install` → `<cwd>/.claude/skills/<name>/` (`:71,133`);
|
||||
subagents → `.claude/agents`, plugins → `.claude/plugins/<id>`,
|
||||
runtime resources → `.claude/extensions/<id>`, settings fragments merged
|
||||
into `.claude/settings.local.json` (`:388–400`).
|
||||
- **Launches** (`:213–215`): fresh = `claude <permissionMode> --session-id
|
||||
<generatedId> --name <name>`; resume = `claude <permissionMode> --resume
|
||||
<token> --name <name>`; fork = `claude <permissionMode> --resume <parentId>
|
||||
--fork-session --name <seat>` (`:188`).
|
||||
> Drift-fix (precision) — `architecture.md` §5 said only "launches via
|
||||
> `claude --name <name>`, resumes via `claude --resume <token>`". Source
|
||||
> shows fresh launch uses an explicit `--session-id` (so a deterministic
|
||||
> resume token exists immediately) and the fork branch exists. Re-confirmed
|
||||
> `claude-code-adapter.ts:188,213–215` @HEAD.
|
||||
- **Readiness** (`checkReady`, `:239`): verifies tmux session alive, captures
|
||||
40 pane lines + pane command, delegates to `assessNativeResumeProbe` (§3);
|
||||
ready only when probe `status === "resumed"`. The resume-launch verification
|
||||
loop `verifyResumeLaunch` retries up to **16 attempts** (`:273`), failing
|
||||
loudly with `recovery: "retry_fresh"` on `no_conversation_found`
|
||||
(`:284–289`) — no silent fresh fallback.
|
||||
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:37`)
|
||||
|
||||
- `readonly runtime = "codex"` (`:38`).
|
||||
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
|
||||
(`:139,330`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:89,145`);
|
||||
skills resolve under `.agents/skills/<id>` (`:377`).
|
||||
- **Launches/resumes** (`:205,225–226`): fresh launch then capture a fresh
|
||||
thread id; resume = `codex<profileArg> resume<queueStateDirArg> <token>`;
|
||||
fork = `codex<profileArg> fork<queueStateDirArg> <parentId>` (`:205`). On
|
||||
success returns `{ ok: true, resumeToken: threadId, resumeType: "codex_id" }`
|
||||
(`:222,245,250`).
|
||||
> Drift-fix (precision) — `architecture.md` §5 said "launches via `codex`,
|
||||
> resumes via `codex resume <threadId>`". Source confirms the `codex resume
|
||||
> <token>` shape (`:226`) and adds the fork branch + the
|
||||
> `resumeType: "codex_id"` tag. Re-confirmed @HEAD.
|
||||
- Refuses `resumeToken` + `forkSource` together with a clear error
|
||||
(`:180–181`) — honors the mutual-exclusivity contract.
|
||||
|
||||
### TerminalAdapter (`terminal-adapter.ts:19`)
|
||||
|
||||
- `readonly runtime = "terminal"` (`:20`).
|
||||
- **All operations are no-ops** — "the shell IS the harness"
|
||||
(`terminal-adapter.ts:15`): no-op `project`/`deliverStartup`/`launchHarness`
|
||||
(`:34`), and `checkReady` returns ready immediately as soon as the tmux
|
||||
session exists (`:47`). Used for infrastructure nodes — servers, log tails,
|
||||
build watchers. (A terminal node cannot fork; the runtime-adapter docstring
|
||||
notes fork-unsupported adapters refuse with a runtime-mismatch error,
|
||||
`runtime-adapter.ts:113–115`.)
|
||||
|
||||
These three are constructed by `createDaemon` step 4 (`startup.ts`; see
|
||||
`daemon-core.md` §4 "Startup sequence").
|
||||
|
||||
## 3. Resume honesty
|
||||
|
||||
The daemon does not assume a harness resumed just because the launch command
|
||||
ran. Three domain files (`packages/daemon/src/domain/`, all present @v0.3.0)
|
||||
make resume assessment honest:
|
||||
|
||||
### `native-resume-probe.ts`
|
||||
|
||||
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:43`) reads pane
|
||||
command + pane content and returns one of four honest statuses
|
||||
(`NativeResumeProbeStatus`, `:6`):
|
||||
|
||||
- `resumed` — runtime-specific indicators confirm a resumed session.
|
||||
- `failed` — terminal failure (e.g. Claude printed "No conversation found" →
|
||||
code `no_conversation_found`, `:51–57`).
|
||||
- `inconclusive` — we don't know yet (e.g. Claude trust gate, code
|
||||
`trust_gate`, `:65–70`).
|
||||
- `attention_required` — alive and recoverable but **needs operator action**
|
||||
(e.g. Claude resume-selection prompt, code `claude_resume_selection_prompt`,
|
||||
`:58–64`). This is the proxy for "an operator must choose the conversation";
|
||||
it is *distinct* from `inconclusive` and `failed` (docstring `:3–5`).
|
||||
|
||||
`buildNativeResumeCommand` (`:27`) builds the resume command per runtime:
|
||||
claude → `claude --resume <token> [--name <name>]` (`:35`); codex → `codex
|
||||
resume <token>` (`:38`); other runtimes → `null` (`:40`).
|
||||
|
||||
This is Architecture Rule 15 in code: a failed resume is FAILED loudly; there
|
||||
is no automatic fresh fallback (the adapter's `verifyResumeLaunch` returns
|
||||
`ok:false` with a `retry_fresh` recovery hint, never a silent relaunch).
|
||||
|
||||
### `resume-metadata-refresher.ts`
|
||||
|
||||
`ResumeMetadataRefresher` (`resume-metadata-refresher.ts:37`). Post-launch
|
||||
resume-token capture: `refresh(sessions)` (`:62`) skips sessions that already
|
||||
have a `resumeToken` (`:65`), and for `claude-code` sessions with a token runs
|
||||
a `probeClaudeResume` returning `"resumable" | "not_resumable" |
|
||||
"inconclusive"` (`:31,74–75`) — a real launch of the resume command in a
|
||||
throwaway probe tmux session (`:106–111`), not a metadata guess.
|
||||
|
||||
### `codex-thread-id.ts`
|
||||
|
||||
Codex thread-id extraction (`codex-thread-id.ts`). Reads the Codex thread id
|
||||
from the Codex *logs* SQLite databases under `~/.codex/`:
|
||||
`readCodexThreadIdFromCandidateHomes(...)` (`:22`) →
|
||||
`readCodexThreadIdFromLogs(...)` (`:49`) → `resolveCodexLogDbPaths(homeDir)`
|
||||
(`:79`) which globs `<homeDir>/.codex/logs_<N>.sqlite` (`:84–89`,
|
||||
regex `^logs_(\d+)\.sqlite$`) and falls back to `logs_1.sqlite` (`:97`).
|
||||
Uses `better-sqlite3` (`:5`). Resolves the home dir by the harness PID
|
||||
(`defaultResolveHomeDirByPid`, `:9`).
|
||||
|
||||
> Precision note — `architecture.md` §5 "Resume honesty" says codex thread IDs
|
||||
> come from "the Codex SQLite database". Source is more specific: the
|
||||
> per-version Codex *logs* DBs `~/.codex/logs_N.sqlite`. Stated precisely here;
|
||||
> re-confirmed `codex-thread-id.ts:79–97` @HEAD.
|
||||
|
||||
## 4. Relevant Architecture Rules (source-verified at HEAD)
|
||||
|
||||
From `architecture.md` §7 (re-confirmed against the source cited inline):
|
||||
|
||||
- **Rule 5** — Runtime is member-authoritative in the pod-aware model.
|
||||
- **Rule 13** — Readiness checking is a retry loop with exponential backoff and
|
||||
a configurable timeout, using adapter-specific probes (Claude TUI indicator,
|
||||
Codex ready message, terminal immediate). Re-confirmed: `checkReady`
|
||||
delegates to `assessNativeResumeProbe`; the retry loop is
|
||||
`claude-code-adapter.ts:273` (16 attempts).
|
||||
- **Rule 14** — Resume states are locked: `resumed` / `rebuilt` / `fresh`;
|
||||
`rebuilt` = new process assembled from artifacts. (The probe layer adds
|
||||
`inconclusive` / `attention_required` as honest *transient* states, not
|
||||
outcomes — see §3.)
|
||||
- **Rule 15** — Restore honesty: failed resume is FAILED loudly; no automatic
|
||||
fresh fallback; fresh launch is an explicit follow-up only. Enforced in code
|
||||
by §2/§3 (`verifyResumeLaunch` returns `ok:false`, never relaunches).
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — where `createDaemon` constructs the three adapters.
|
||||
- `agent-spec-and-startup.md` — the `StartupOrchestrator` that calls these
|
||||
adapters and owns startup-action execution after `checkReady()`.
|
||||
- `lifecycle-snapshot-restore.md` — how persisted resume tokens flow into
|
||||
snapshot/restore (resume vs rebuild vs fresh).
|
||||
- Source roots: `packages/daemon/src/domain/runtime-adapter.ts`,
|
||||
`packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`,
|
||||
`packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts`.
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Agent/Rig Spec, Resolution, Startup, Identity
|
||||
status: active
|
||||
topics: [specification-and-bundles, agent-runtime]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know the canonical AgentSpec/RigSpec/pod-aware reboot types, how
|
||||
profile resolution and additive startup layering work, the StartupOrchestrator
|
||||
pre-launch-vs-interactive delivery split, or how whoami/materialize/bind/adopt
|
||||
resolve and preserve identity.
|
||||
siblings: [daemon-core.md, adapters-and-runtimes.md, lifecycle-snapshot-restore.md, packaging-bootstrap-bundles.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Agent/Rig Spec, Resolution, Startup, Identity
|
||||
|
||||
How the daemon turns authored YAML specs into a resolved, projected, launched,
|
||||
and identity-addressable topology. The spec-and-startup contract: parse →
|
||||
resolve → project → deliver → launch → wait → persist replay context.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
|
||||
> packages (slice-00 §1.1). Source feed located by `architecture.md` headings
|
||||
> (§4 Canonical Reboot Types, §5 Domain Services, §6 Execution Flows, §7
|
||||
> Architecture Rules) per slice-08 §10.1 — line numbers are advisory only.
|
||||
|
||||
## 1. Canonical spec and topology types
|
||||
|
||||
(`architecture.md` §4 "Spec and topology")
|
||||
|
||||
- **AgentSpec** — parsed from `agent.yaml`. Owns imports, defaults, startup,
|
||||
resources, and profiles. Canonical parse/normalize/validate is
|
||||
`domain/agent-manifest.ts`.
|
||||
- **RigSpec** — canonical pod-aware rig topology. Uses `version: "0.2"` and
|
||||
`pods[]`; owns cross-pod `edges[]`, rig-level startup overlays, and
|
||||
`cultureFile`.
|
||||
|
||||
> Drift-fix D-spec-version — `architecture.md` §4 says RigSpec
|
||||
> "Uses `version: "0.2"`". This is the **spec-schema version, NOT the
|
||||
> package version** — a different versioning axis from the 0.3.1 package
|
||||
> version. The value is NOT a code constant: `rigspec-schema.ts:52`
|
||||
> validates `version` only as a "required non-empty string"
|
||||
> (`if (!obj["version"] || typeof obj["version"] !== "string")`); `:163`
|
||||
> carries it through as `raw["version"] as string`. `"0.2"` is the
|
||||
> canonical authored value, correctly described as a schema axis. Do NOT
|
||||
> "correct" it toward 0.3.1 (slice-08 §4.6 binding). Re-confirmed
|
||||
> `rigspec-schema.ts:52,163` @HEAD.
|
||||
|
||||
- **RigServicesSpec** — optional `services` block on a pod-aware RigSpec.
|
||||
Shipped kind is Compose-backed env management with `composeFile`,
|
||||
`projectName?`, `profiles?`, `downPolicy?`, `waitFor?`, `surfaces?`,
|
||||
`checkpoints?`.
|
||||
- **RigSpecPod** — pod-local bounded context with `members[]`, pod-local
|
||||
`edges[]`, pod startup, optional continuity policy.
|
||||
- **RigSpecPodMember** — member-level runtime/startup surface: `agentRef`,
|
||||
`profile`, `runtime`, `model?`, `cwd`, `restorePolicy?`, member startup
|
||||
overlays.
|
||||
- **Pod** — persisted DB entity for a pod.
|
||||
- **ContinuityState** — persisted live continuity row keyed by
|
||||
`podId + nodeId`.
|
||||
|
||||
## 2. Execution and projection types
|
||||
|
||||
(`architecture.md` §4 "Execution and restore" — restore/snapshot members are
|
||||
detailed in `lifecycle-snapshot-restore.md`; the spec/projection members live
|
||||
here)
|
||||
|
||||
- **ResolvedNodeConfig** — output of profile resolution. Carries effective
|
||||
runtime/model/cwd, narrowed restore policy, selected resources, layered
|
||||
startup block, resolved spec identity.
|
||||
- **ProjectionPlan** — runtime projection plan for a node: runtime, cwd,
|
||||
projection entries, startup block, diagnostics, conflict/no-op
|
||||
classifications.
|
||||
- **RuntimeAdapter** — the five-method contract (adapter detail in
|
||||
`adapters-and-runtimes.md`): `listInstalled(binding)`
|
||||
(`runtime-adapter.ts:131`), `project(plan, binding)` (`:134`),
|
||||
`deliverStartup(files, binding)` (`:137`), `launchHarness(binding, opts)`
|
||||
(`:147`), `checkReady(binding)` (`:153`). Re-confirmed @HEAD — the contract
|
||||
is current.
|
||||
- **HarnessLaunchResult** — `{ ok, resumeToken?, resumeType?, error? }`
|
||||
returned by `launchHarness`.
|
||||
- **StartupOrchestrator** — drives the full startup sequence (§4 below).
|
||||
|
||||
## 3. Parsing, validation, resolution pipeline
|
||||
|
||||
(`architecture.md` §5 "Parsing and validation" + "Resolution pipeline"; all
|
||||
under `packages/daemon/src/domain/`, zero Hono imports per Architecture Rule 1)
|
||||
|
||||
**Parse / validate:**
|
||||
|
||||
- `agent-manifest.ts` — canonical AgentSpec parse/normalize/validate.
|
||||
- `rigspec-schema.ts` — dual-format RigSpec validation.
|
||||
- `rigspec-codec.ts` — dual-format YAML codec.
|
||||
- `startup-validation.ts` — shared startup-block validation.
|
||||
- `path-safety.ts` — shared relative-path safety checks.
|
||||
- `spec-validation-service.ts` — pure raw-YAML validation helpers.
|
||||
- `spec-review-service.ts` — daemon-owned structured review model for
|
||||
RigSpec/AgentSpec YAML, incl. topology preview, provenance state, managed-app
|
||||
services metadata (`waitFor`, `surfaces`, `composePreview`).
|
||||
|
||||
**Resolve:**
|
||||
|
||||
- `agent-resolver.ts` — resolves `agent_ref`, imports, collision metadata.
|
||||
- `agent-preflight.ts` — single-agent resolution/preflight.
|
||||
- `profile-resolver.ts` — applies defaults, profile uses, resource selection,
|
||||
startup layering, restore-policy narrowing.
|
||||
- `startup-resolver.ts` — additive startup layering.
|
||||
- `projection-planner.ts` — runtime resource projection planning.
|
||||
|
||||
All eleven files re-confirmed present in `packages/daemon/src/domain/` @HEAD.
|
||||
|
||||
## 4. Startup orchestration (the spec-startup contract)
|
||||
|
||||
(`architecture.md` §4 "StartupOrchestrator" + §5 "Startup, runtime, and
|
||||
instantiation" + §7 Architecture Rule 6)
|
||||
|
||||
`StartupOrchestrator` (`domain/startup-orchestrator.ts`) drives:
|
||||
mark pending → project resources → deliver pre-launch files → launch harness
|
||||
→ wait for ready → deliver interactive files → execute actions → persist
|
||||
context → mark ready.
|
||||
|
||||
**Pre-launch vs interactive delivery split** — the load-bearing seam,
|
||||
re-confirmed at source:
|
||||
|
||||
- Pre-launch (filesystem, before harness boot): `guidance_merge`,
|
||||
`skill_install` (`startup-orchestrator.ts:78,167` — "Deliver pre-launch
|
||||
files (guidance_merge, skill_install → filesystem)").
|
||||
- Post-launch (TUI, after harness is ready): `send_text`
|
||||
(`startup-orchestrator.ts:82,160,263` — partition by concrete hint at
|
||||
`:141`, deliver after readiness at `:263`).
|
||||
|
||||
The orchestrator persists replay context including the resume token for future
|
||||
restores (consumed by `lifecycle-snapshot-restore.md`).
|
||||
|
||||
**Architecture Rule 6 — startup layering is additive and ordered** (carried
|
||||
verbatim, `architecture.md` §7): (1) agent base, (2) profile, (3) rig culture
|
||||
file, (4) rig startup, (5) pod startup, (6) member startup, (7) operator debug
|
||||
append. This is the spec-startup contract's invariant; the full 25-rule list
|
||||
lives in `architecture-rules-and-event-system.md`.
|
||||
|
||||
**Startup action constraints** (`architecture.md` §7 "Current startup action
|
||||
constraints"): no shell startup actions; action types are `slash_command` and
|
||||
`send_text` only; non-idempotent actions must not apply on restore; retrying
|
||||
failed startup is handled as restore.
|
||||
|
||||
**Remote import constraints** (§7): the reboot supports `local:...` and
|
||||
`path:/abs/...`. Remote `agent_ref` sources remain unsupported and fail in
|
||||
preflight.
|
||||
|
||||
## 5. Instantiation, preflight, export
|
||||
|
||||
(`architecture.md` §5 "Startup, runtime, and instantiation" + §6 "RigSpec
|
||||
import / validate / preflight / export")
|
||||
|
||||
- `runtime-adapter.ts` — adapter contract + bridge types.
|
||||
- `rigspec-preflight.ts` — dual-stack legacy preflight plus rebooted
|
||||
`rigPreflight(...)`.
|
||||
- `rigspec-instantiator.ts` — dual-stack `RigInstantiator` plus
|
||||
`PodRigInstantiator`.
|
||||
- `rigspec-exporter.ts` — dual-format live rig export to YAML/JSON.
|
||||
- `pod-repository.ts` — pod CRUD plus live continuity-state CRUD.
|
||||
|
||||
`routes/rigspec.ts` is the dual-format seam: validate
|
||||
(pod-aware → `RigSpecSchema.validate`; legacy → `LegacyRigSpecSchema.validate`),
|
||||
preflight (`rigPreflight({ rigSpecYaml, rigRoot, fsOps })` vs
|
||||
`RigSpecPreflight.check(spec)`), import
|
||||
(`podInstantiator.instantiate(yaml, rigRoot)` vs
|
||||
`RigInstantiator.instantiate(spec)`), export (pod-aware exports canonical
|
||||
`version: "0.2"` RigSpec; legacy exports flat-node v1).
|
||||
|
||||
## 6. Identity: whoami, materialize, bind, adopt
|
||||
|
||||
(`architecture.md` §6 "Whoami and adopted-session parity flow" +
|
||||
"Materialize / bind / adopt flow")
|
||||
|
||||
**Whoami resolution** — the daemon owns the truth surface through
|
||||
`/api/whoami` (`routes/whoami.ts`); tmux metadata is an adopted-session
|
||||
anchor, not sovereign truth. `whoami-service.ts:8` declares
|
||||
`resolvedBy: "node_id" | "session_name"`. The route requires `nodeId` or
|
||||
`sessionName` (`routes/whoami.ts:10-15`). Resolution order:
|
||||
explicit `--node-id` → explicit `--session` → env vars → tmux metadata →
|
||||
raw tmux session-name fallback.
|
||||
|
||||
- Managed sessions prefer projected `OPENRIG_NODE_ID` /
|
||||
`OPENRIG_SESSION_NAME`.
|
||||
- Adopted sessions use tmux-owned metadata written at claim/bind time.
|
||||
|
||||
> Drift-nuance D5 (the careful axis — see also `transport-and-transcripts.md`):
|
||||
> the tmux metadata keys `@rigged_node_id` / `@rigged_session_name` /
|
||||
> `@rigged_rig_id` / `@rigged_rig_name` / `@rigged_logical_id` in
|
||||
> `architecture.md` §6 are **CORRECT as-is** and were verified literally at
|
||||
> source — `claim-service.ts:77-81` writes exactly these `@rigged_*` keys;
|
||||
> `claim-service.ts` and `rig-lifecycle-service.ts` read them. This is a
|
||||
> SEPARATE axis from MCP tool names (which are `rig_*`, slice-00 §1.4). Do
|
||||
> NOT blanket-replace `rigged` → `rig` (banked
|
||||
> `feedback_release_prep_three_layer_depersonalization`: the rename was
|
||||
> scoped, not global). The tmux metadata keys were NOT renamed.
|
||||
> Re-confirmed `claim-service.ts:77-81` @HEAD.
|
||||
|
||||
**Materialize / bind / adopt** (`architecture.md` §6): `POST
|
||||
/api/rigs/import/materialize` creates a pod-aware topology without launching
|
||||
sessions; `POST /api/discovery/:id/bind` attaches a discovered live session to
|
||||
an existing logical node; `POST /api/discovery/:id/adopt` is the composite
|
||||
route (bind to existing node, or create a new member in a target pod and bind
|
||||
immediately). CLI mirrors via `rig bind` / `rig adopt`. Authored pod namespace
|
||||
is preserved through adoption so logical ids stay
|
||||
`${podNamespace}.${memberName}` (Architecture Rule 24: adopted-session parity
|
||||
is tmux-metadata parity, not fake env-var parity).
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D-spec-version (resolved-as-correct):** RigSpec `version: "0.2"` is the
|
||||
spec-schema axis, validated only as a non-empty string in source. Documented,
|
||||
not "corrected".
|
||||
- **D5 (careful, resolved-as-correct for this module):** tmux `@rigged_*`
|
||||
metadata keys are literally still `@rigged_*` at HEAD — architecture.md §6 is
|
||||
accurate. The MCP-tool-name `rigged_*` drift lands in
|
||||
`transport-and-transcripts.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — createDaemon wiring; the migration/route surface.
|
||||
- `adapters-and-runtimes.md` — the five-method RuntimeAdapter contract detail.
|
||||
- `lifecycle-snapshot-restore.md` — snapshot/restore consumes persisted replay
|
||||
context.
|
||||
- `architecture-rules-and-event-system.md` — the full 25 architecture rules.
|
||||
- Source roots: `packages/daemon/src/domain/{agent-manifest,rigspec-schema,
|
||||
profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`,
|
||||
`packages/daemon/src/routes/{rigspec,whoami}.ts`.
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Architecture Invariants, Event System, Compatibility Notes
|
||||
status: active
|
||||
topics: [runtime-control, observability, doctrine]
|
||||
domains: [engineering-advisor, operating-advisor, review]
|
||||
applies-when: |
|
||||
Need the cross-cutting architecture invariants the codebase enforces (the
|
||||
25 architecture rules + startup/import constraints), the shape of the
|
||||
RigEvent union and its SSE delivery surfaces, or the intentional
|
||||
compatibility limits that still describe the shipped system.
|
||||
siblings: [daemon-core.md, coordination-primitive.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Architecture Invariants, Event System, Compatibility Notes
|
||||
|
||||
This module collects the cross-cutting invariants that do not belong to any
|
||||
single subsystem: the architecture rules the codebase holds itself to, the
|
||||
event-system shape, and the intentional compatibility limits.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`); package version **0.3.1** across all three packages
|
||||
> (slice-00 §1.1). HEAD carries 6 commits of unreleased 0.3.2 work; no
|
||||
> `v0.3.2` tag exists.
|
||||
|
||||
## 1. Architecture rules
|
||||
|
||||
These are the invariants the codebase is built to preserve (`architecture.md`
|
||||
§7). Rule 6 (startup layering) and rule 7 (restore-policy narrowing) are the
|
||||
spec/startup contract — see `agent-spec-and-startup.md` for their flow detail;
|
||||
they are restated here as system-level invariants.
|
||||
|
||||
1. Zero Hono in `domain/` and `adapters/`.
|
||||
2. Routes depend on the domain; the domain never depends on routes.
|
||||
3. Shared DB-handle invariants are enforced at construction time.
|
||||
4. The reboot is engine-first: domain services land before public-surface
|
||||
rewiring.
|
||||
5. Runtime is member-authoritative in the pod-aware model.
|
||||
6. Startup layering is additive and ordered: agent base → profile → rig
|
||||
culture file → rig startup → pod startup → member startup → operator debug
|
||||
append.
|
||||
7. Restore-policy narrowing is one-way only: `resume_if_possible` →
|
||||
`relaunch_fresh` → `checkpoint_only`.
|
||||
8. Base/import collisions warn; ambiguous import/import unqualified refs fail
|
||||
loudly.
|
||||
9. Bundle assembly and startup-file resolution use containment checks rooted
|
||||
in the owning artifact.
|
||||
10. Restore replay uses classification-free projection intent, not stale
|
||||
startup-time `no_op` / conflict classifications.
|
||||
11. Startup status is explicit session state: `pending`, `ready`, `failed`.
|
||||
12. Session recency depends on monotonic ULIDs: `session-registry.ts` uses
|
||||
`monotonicFactory()`; restore selects the newest session by max ULID.
|
||||
13. Readiness checking is a retry loop with exponential backoff and
|
||||
configurable timeout, using adapter-specific probes (Claude TUI
|
||||
indicator, Codex ready message, terminal immediate).
|
||||
14. Resume states are locked: `resumed` / `rebuilt` / `fresh`. `rebuilt` =
|
||||
new process assembled from artifacts.
|
||||
15. Restore honesty: failed resume is FAILED loudly. No automatic fresh
|
||||
fallback. Fresh launch is explicit follow-up only.
|
||||
16. Post-command handoff required on `up`, `down`, `restore`,
|
||||
`snapshot create`: what happened + current state + next action.
|
||||
17. Session naming: `{pod}-{member}@{rig}` — human-authored,
|
||||
system-validated. No generation, no slugification.
|
||||
18. Communication: tmux is transport, not truth. `send/capture/broadcast`
|
||||
wrap tmux reliably with honest errors.
|
||||
19. Transcripts: raw capture via pipe-pane, ANSI strip on read. `rg`
|
||||
preferred, `grep -E` fallback.
|
||||
20. Config precedence: CLI flag > env var > config file
|
||||
(`~/.openrig/config.json`, with legacy fallback from
|
||||
`~/.rigged/config.json`) > default.
|
||||
21. Semi-deterministic calibration: build what agents use constantly. Agent
|
||||
handles edge cases from error messages.
|
||||
22. `rig ask` is context engineering: gathers evidence, does NOT call an
|
||||
external LLM. The agent IS the LLM.
|
||||
23. Spec library truth is YAML on disk; daemon owns the structured
|
||||
review/index/cache layer.
|
||||
24. Adopted-session parity is tmux-metadata parity, not fake env-var parity.
|
||||
25. Human-readable IDs are UI-only presentation helpers. CLI/API/MCP/backend
|
||||
keep full canonical ids.
|
||||
|
||||
### Startup action constraints
|
||||
|
||||
- No shell startup actions.
|
||||
- Action types are `slash_command` and `send_text` only.
|
||||
- Non-idempotent actions must not apply on restore.
|
||||
- Retrying failed startup is handled as restore.
|
||||
|
||||
### Remote import constraints
|
||||
|
||||
The reboot supports `local:...` and `path:/abs/...` agent refs. Remote
|
||||
`agent_ref` sources remain unsupported and fail in preflight
|
||||
(`architecture.md` §7 "Remote import constraints"; restated in compat note 1).
|
||||
|
||||
## 2. Event system
|
||||
|
||||
The daemon's event surface is the single `RigEvent` discriminated union.
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §8
|
||||
> says "PL-004 Phase A adds 9 coordination events" and (in §3) "Existing 32
|
||||
> PL-004 events" / "Existing 20 PL-004 events" — internally inconsistent
|
||||
> contested PL-004 sub-counts. **Do NOT carry the 9 / 32 / 20 figures.** The
|
||||
> `architecture.md` §8 "Currently emitted in production code" / "Present in
|
||||
> the union but not yet emitted" lists also predate 0.3.x and are stale. The
|
||||
> union shape below is re-derived from source at HEAD, not migrated.
|
||||
|
||||
`RigEvent` is declared at `packages/daemon/src/domain/types.ts:94`
|
||||
(`export type RigEvent =`) and runs through `types.ts:218`. It has **73
|
||||
union members** (slice-00 §1.8, re-confirmed at HEAD:
|
||||
`grep -cE '^\s*\| \{ type:'` over L94–218 = 73). Every one of the 73
|
||||
declared `type:` literals is also constructed somewhere in
|
||||
`packages/daemon/src/{domain,routes}` (re-verified at HEAD: the set of
|
||||
`type: "<x>"` literals in domain+routes exactly equals the 73 union members
|
||||
— zero union-only-but-never-referenced types).
|
||||
|
||||
### Per-prefix event families (grep-verified at HEAD)
|
||||
|
||||
Each count below is a fresh, primary-source-grep-verified, explicitly-labeled
|
||||
per-prefix family count over the union body
|
||||
(`types.ts:94–218`) — NOT the contested PL-004 sub-count (OPEN-4 ruling:
|
||||
labeled grep-verified family counts are ground truth; the `9`/`32`/`20`
|
||||
figures are forbidden).
|
||||
|
||||
| Prefix | Members | Sample / role |
|
||||
|---|---|---|
|
||||
| `node.*` | 7 | `node.added` (`types.ts:97`) … `node.startup_failed` (`:139`) — lifecycle/startup |
|
||||
| `workflow.*` | 6 | PL-004 Phase D workflow runtime (detail in `workflow-runtime.md`) |
|
||||
| `watchdog.*` | 5 | `watchdog.evaluation_fired` (`:187`) … `watchdog.job_stopped` (`:191`) — PL-004 Phase C |
|
||||
| `rig.*` | 5 | `rig.created` / `rig.deleted` / `rig.imported` / `rig.stopped` / `rig.expanded` (`:151`) |
|
||||
| `queue.*` | 5 | PL-004 Phase A queue lifecycle (`:156`–`:159`, `:169`; detail in `coordination-primitive.md`) |
|
||||
| `package.*` | 5 | legacy package/install engine events |
|
||||
| `mission_control.*` | 5 | PL-005 audit/notification (`:212`–`:218`; detail in `mission-control.md`) |
|
||||
| `bootstrap.*` | 5 | legacy bootstrap-run events |
|
||||
| `session.*` | 4 | session discovery / status / detach / vanish |
|
||||
| `classifier.*` | 4 | PL-004 Phase B classifier-lease lifecycle |
|
||||
| `restore.*` | 3 | restore start/complete/reconcile (detail in `lifecycle-snapshot-restore.md`) |
|
||||
| `qitem.*` | 2 | `qitem.fallback_routed` (`:160`), `qitem.closure_overdue` (`:161`) |
|
||||
| `pod.*` | 2 | `pod.created` (`:135`), `pod.deleted` (`:136`) |
|
||||
| `inbox.*` | 2 | `inbox.absorbed` (`:162`), `inbox.denied` (`:163`) |
|
||||
| `continuity.*` | 2 | `continuity.sync` (`:140`), `continuity.degraded` (`:141`) |
|
||||
| singletons | 11 | one member each: `workflow_spec.*`, `view.*`, `stream.*` (`:155`), `snapshot.*`, `seat.*`, `project.*`, `kernel.*`, `chat.*` (`:149`), `bundle.*`, `binding.*`, `agent.*` |
|
||||
|
||||
Family counts sum to 73 (15 multi-member families totalling 62 + 11
|
||||
singletons), re-confirmed at HEAD.
|
||||
|
||||
### Emission and delivery
|
||||
|
||||
Events are constructed and emitted via `eventBus.emit({ type: ... })` across
|
||||
domain services (`stream-store.ts`, `workflow-runtime.ts`,
|
||||
`restore-orchestrator.ts`, `node-launcher.ts`, etc.) and route handlers. The
|
||||
event log is append-only and SQLite-backed.
|
||||
|
||||
Three SSE delivery surfaces (re-confirmed at HEAD):
|
||||
|
||||
- `GET /api/events` — global stream of all events (`server.ts:457`
|
||||
`app.route("/api/events", eventsRoute)`).
|
||||
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:117`).
|
||||
- `GET /api/queue/watch` — queue/inbox coordination events
|
||||
(`routes/queue.ts:357`).
|
||||
- The chat SSE stream `GET /api/rigs/:rigId/chat/watch` delivers
|
||||
`chat.message` for one rig (rig-scoped; see compat note 6).
|
||||
|
||||
> OPEN-4 (carried verbatim, slice-00): the precise PL-004-only vs PL-005-only
|
||||
> sub-partition is NOT asserted here. `architecture.md`'s `32`/`20`/`9`
|
||||
> figures are internally inconsistent and not reconcilable without
|
||||
> classifying all 73 members by introducing slice — flagged, not smoothed.
|
||||
> The grep-verified per-prefix family counts above are the substituted ground
|
||||
> truth.
|
||||
|
||||
## 3. Remaining compatibility notes
|
||||
|
||||
Intentional limits that still describe the shipped system (`architecture.md`
|
||||
§11), verified as still-current at HEAD:
|
||||
|
||||
1. Remote `agent_ref` imports remain unsupported (see §1 remote import
|
||||
constraints).
|
||||
2. Startup actions remain intentionally constrained (`slash_command`,
|
||||
`send_text`).
|
||||
3. Legacy compatibility seams still ship for pre-reboot data and v1
|
||||
artifacts.
|
||||
4. `rig ask` gathers context only — does not call an external LLM (rule 22).
|
||||
5. Transcript search prefers `rg`, falls back to `grep -E`; quality/perf
|
||||
varies by backend.
|
||||
6. Chat is rig-scoped only — no cross-rig channels or DMs.
|
||||
7. `--verify` on `rig send` checks pane content for message visibility but
|
||||
can produce false positives from pre-existing matching content. Known
|
||||
limitation.
|
||||
8. Terminal node readiness is shell-ready only — no service health probes.
|
||||
9. `rig env down --volumes` exists in the CLI surface, but the explicit
|
||||
daemon-side override is not fully plumbed through yet.
|
||||
10. Managed-app service surfaces are descriptive only — OpenRig does not
|
||||
auto-inject service URLs/tokens into agent prompts beyond authored
|
||||
startup/context files.
|
||||
11. Specialist delegation is conventional, not automatic — addressed by
|
||||
session name or normal communication surfaces.
|
||||
|
||||
## 4. Cross-references
|
||||
|
||||
`architecture.md` §12 names itself the architecture-level source of truth and
|
||||
points at `codemap.md` for file-by-file structure. Under the modular
|
||||
as-built, that role is distributed: this module owns the invariants + event
|
||||
shape; the source-of-truth pointer is the rewritten `../codemap.md`
|
||||
navigation index.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — wiring, DB, migrations, startup; footprint drift-fixes.
|
||||
- `coordination-primitive.md` — PL-004 Phase A queue/stream/inbox/outbox
|
||||
events.
|
||||
- `workflow-runtime.md` — PL-004 Phase D `workflow.*` events.
|
||||
- `mission-control.md` — PL-005 `mission_control.*` events.
|
||||
- Source roots: `packages/daemon/src/domain/types.ts` (RigEvent union),
|
||||
`packages/daemon/src/routes/{stream,queue}.ts` (SSE watch),
|
||||
`packages/daemon/src/server.ts` (`/api/events`).
|
||||
@@ -0,0 +1,308 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
status: active
|
||||
topics: [observability, specification-and-bundles]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the operator-allowlisted file browser enforces path
|
||||
safety, how atomic conflict-checked writes + the JSONL edit audit work,
|
||||
how the workspace PROGRESS.md tree is indexed, or how the one-screen
|
||||
Steering surface (priority stack + roadmap rail + lane rails + health
|
||||
gates) is composed. Author-mode module — no prior architecture.md prose;
|
||||
every load-bearing claim is sourced to file:line at HEAD.
|
||||
siblings: [workspace-primitive.md, daemon-core.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, workspace-primitive.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
|
||||
The **content surfaces** are the operator-allowlisted, filesystem-canonical
|
||||
read/write layer that the Project / Steering UI sits on: a fail-closed file
|
||||
browser, an atomic conflict-checked write service with a JSONL edit audit,
|
||||
the recursive PROGRESS.md tree indexer, and the one-screen Steering composer
|
||||
(+ its compact health-summary gates). None adds SQLite state.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
|
||||
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
|
||||
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative
|
||||
> to `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
>
|
||||
> **SPLIT NOTE (§10.6).** This is the content-surfaces half of the
|
||||
> `workspace-and-content-primitives` SPLIT (sibling `workspace-primitive.md`
|
||||
> carries the workspace primitive + migrations 038/039 +
|
||||
> missions/projects/slices). See `workspace-primitive.md` § header SPLIT
|
||||
> NOTE for the §10.6 rationale and the transparent-noted-deviation
|
||||
> disclosure.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| `domain/files/{path-safety,file-write-service}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/progress/progress-indexer.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/steering/{steering-composer,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `routes/{files,progress,steering,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
|
||||
> Source: §10.8 proof re-run at HEAD `7eaf524c` — every listed path
|
||||
> resolves at `v0.3.0` (no failures). All content surfaces are
|
||||
> **≤0.3.0** features (UI Enhancement Pack v0 + Operator Surface
|
||||
> Reconciliation v0; slice-00 0.3.0-GT §1.7 "Files / Markdown /
|
||||
> Progress / Proofs" confirms the cluster shipped 0.3.0). Do **not**
|
||||
> back-attribute any of these to 0.3.1 — the gap is missing
|
||||
> architecture.md prose, not a new feature.
|
||||
|
||||
## 1. Path safety — the fail-closed allowlist (UI Enhancement Pack v0)
|
||||
|
||||
The file browser is gated by a daemon-enforced, **fail-closed** allowlist.
|
||||
Roots are decoded from `OPENRIG_FILES_ALLOWLIST` (env), comma-separated
|
||||
`<name>:<absolute-path>` pairs (legacy fallback `RIGGED_FILES_ALLOWLIST`
|
||||
via `||` so an empty string falls through). Invalid pairs (no colon, empty
|
||||
name, non-absolute path) are **silently skipped**; duplicate names →
|
||||
last-wins. Empty/unset → no roots → routes return an empty list with a
|
||||
structured "configure OPENRIG_FILES_ALLOWLIST" hint. The safe default after
|
||||
a fresh install is **nothing allowlisted**.
|
||||
|
||||
> Source: `domain/files/path-safety.ts:50-51` (env vars),
|
||||
> `:60-93` (`decodeAllowlist` / `readAllowlistFromEnv`; silent-skip
|
||||
> L67-71, last-wins L78, `||` fallback comment L89-91) @HEAD.
|
||||
|
||||
`resolveAllowedPath(allowlist, rootName, relativePath)` is the load-bearing
|
||||
guard. Algorithm: (1) reject unknown root (`root_unknown`); (2) reject
|
||||
expressed-intent `..` segments **before** filesystem resolution
|
||||
(`path_escape`) — segment-split so `foo..bar` is not a false positive;
|
||||
(3) reject an absolute `relativePath` (`path_invalid`); (4) resolve
|
||||
symlinks via `fs.realpathSync` and reject when the realpath does not start
|
||||
with `<canonicalRoot><sep>` (`path_escape`) — a `path.sep` boundary so
|
||||
`/foo/bar` does not match `/foo/bar-other`; (5) `path === ""` resolves to
|
||||
the root itself (so callers can list the root). Symlinks **inside** the
|
||||
tree resolve normally; symlinks pointing **outside** are escape attempts.
|
||||
A non-existent candidate falls back to the unresolved path so the
|
||||
subsequent stat surfaces the absence with a specific code.
|
||||
`resolveAllowedFile` / `resolveAllowedDirectory` add a stat assertion
|
||||
(`not_a_file` / `not_a_directory`).
|
||||
|
||||
> Source: `domain/files/path-safety.ts:95-171` (`resolveAllowedPath`;
|
||||
> `..`-before-fs L124-140, absolute-reject L141-147, sep-boundary
|
||||
> L158-169, empty-path base case in the doc-comment L108-110),
|
||||
> `:173-225` (file/dir convenience asserts) @HEAD.
|
||||
|
||||
## 2. Atomic write + JSONL audit (UI Enhancement Pack v0 item 4)
|
||||
|
||||
`FileWriteService.writeAtomic(req)` is the operator-actionable write surface
|
||||
for `STEERING.md` / `PROGRESS.md` / spec YAML / any allowlisted file.
|
||||
Sequence: (1) resolve target under allowlist (reuses §1 path-safety);
|
||||
(2) re-stat + re-hash the target — if `mtime !== expectedMtime` OR
|
||||
`contentHash !== expectedContentHash`, throw `WriteConflictError` carrying
|
||||
the **current** mtime + hash for the UI to surface (optimistic-concurrency,
|
||||
not a lock); (3) write to a temp file in the **same directory** (PID +
|
||||
random suffix so concurrent writes don't collide); (4) `fsync` the temp fd
|
||||
for durability before the rename; (5) atomic `renameSync` over the target
|
||||
(single inode swap on POSIX — readers see old or new, never partial);
|
||||
(6) recompute mtime + hash + byte delta; (7) append one JSONL audit row.
|
||||
|
||||
> Source: `domain/files/file-write-service.ts:9-25` (documented
|
||||
> atomic-write semantics), `:111-212` (`writeAtomic`; conflict-detect
|
||||
> L129-135, same-dir temp L137-141, fsync L146, atomic rename L161)
|
||||
> @HEAD.
|
||||
|
||||
The audit file is `~/.openrig/file-edit-audit.jsonl` by default
|
||||
(`HOME`/`USERPROFILE` → `/tmp` fallback). **Append-only; never rotates at
|
||||
v0.** A failed audit append throws `FileWriteError("audit_write_failed")`
|
||||
but does **NOT** undo the landed write — the user's edit succeeded; an
|
||||
audit-system flake must not silently revert canon. (The route still
|
||||
surfaces it as a 500 so the failure is honest, not swallowed.)
|
||||
|
||||
> Source: `domain/files/file-write-service.ts:87-91` (default path +
|
||||
> fallback), `:24-25` (never-rotates-at-v0), `:182-204` (audit append;
|
||||
> "does NOT undo the write" comment L183-184) @HEAD.
|
||||
|
||||
## 3. Files routes — browse + read + asset + write
|
||||
|
||||
`filesRoutes()` (mounted `server.ts:507` as `/api/files`). All routes are
|
||||
**literal** (no `/:param` catchall) so order doesn't matter for shadowing
|
||||
(Phase A R1 SSE lesson):
|
||||
|
||||
- `GET /roots` — allowlist root list (or `{roots:[], hint}` when none).
|
||||
- `GET /list?root&path` — directory entries (dirs first, then files,
|
||||
name-sorted; dotfiles always included — allowlisting a root expresses
|
||||
inspection intent).
|
||||
- `GET /read?root&path` — file content + `mtime` + `contentHash` (SHA-256)
|
||||
+ `size`. **Content is capped at `FILE_READ_TRUNCATION_BYTES` (1 MB)** but
|
||||
the hash is computed over the **FULL** file so atomic-write conflict
|
||||
detection stays honest even on a truncated read; response carries
|
||||
`truncated` / `truncatedAtBytes` / `totalBytes`.
|
||||
- `GET /asset?root&path` — raw bytes for embedded images/video/pdf (inferred
|
||||
`Content-Type`, 5-min cache).
|
||||
- `POST /write` — atomic write (§2); 503 when no write service
|
||||
(`OPENRIG_FILES_ALLOWLIST` empty); 409 `write_conflict` on stale
|
||||
mtime/hash with the current values for the UI's refresh prompt.
|
||||
|
||||
503 `files_routes_unavailable` when the allowlist dep is unwired.
|
||||
Path-safety errors map to HTTP per code (`root_unknown`/`path_*` → 400,
|
||||
`stat_failed` → 404).
|
||||
|
||||
> Source: `routes/files.ts:51-242` (handlers; route-order comment
|
||||
> L17-19, truncation L139-154, write 503/409 L187-191/L225-231,
|
||||
> path-safety→HTTP `:61-69`); `FILE_READ_TRUNCATION_BYTES = 1_048_576`
|
||||
> `:49`; mounted `server.ts:507`; CLI: no `rig files` verb — files are
|
||||
> a UI-only surface @HEAD.
|
||||
|
||||
> **§10.7 route-reality (source-verified at `packages/ui/src/routes.tsx`):**
|
||||
> `/files` is a **REAL route** (`FilesWorkspace`) the Phase-8.1 survey
|
||||
> MISSED. There is **NO `/markdown` route** — markdown rendering is a
|
||||
> *component* inside file / drawer surfaces, not a navigable destination.
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:192-195` (`path: "/files"` +
|
||||
> `FilesWorkspace`); grep-confirmed no `path: "/markdown"` declaration
|
||||
> anywhere in routes.tsx @HEAD.
|
||||
|
||||
## 4. Progress tree indexer (UI Enhancement Pack v0 item 1B)
|
||||
|
||||
`ProgressIndexer` walks operator-allowlisted scan roots
|
||||
(`OPENRIG_PROGRESS_SCAN_ROOTS`, same `<name>:<abs-path>` shape as the file
|
||||
allowlist; legacy `RIGGED_PROGRESS_SCAN_ROOTS`), finds **`PROGRESS.md` AND
|
||||
`STEERING.md`** files (STEERING.md is anchored as the constraint-frame node
|
||||
per OSR v0 Item 2; the row-machinery is filename-agnostic), and parses each
|
||||
into a checkbox-hierarchy tree. Recursion is depth-bounded (default 6 —
|
||||
enough for mission/lane/slice nesting without descending into
|
||||
`node_modules`/`.git`/`.worktrees`/`dist`/`build`/`.turbo`/`.next` or
|
||||
dotfiles). Checkbox status: `[x]` → `done`, `[~]` → `blocked`, `[ ]` →
|
||||
`active`; headings (`##`–`####`) become hierarchy rows; depth from
|
||||
2-space indent or heading level. Per-file `counts` + a scan-wide
|
||||
`aggregate`. **In-memory walk per request; no caching at v0** (bounded by
|
||||
the operator's allowlist scope).
|
||||
|
||||
> Source: `domain/progress/progress-indexer.ts:79-84` (STEERING anchor +
|
||||
> env vars), `:81` (SKIP_DIRS), `:71` (`DEFAULT_MAX_DEPTH = 6`),
|
||||
> `:218-235` (checkbox status mapping), `:204-215` (heading rows),
|
||||
> `:21-23` (no-caching-at-v0) @HEAD.
|
||||
|
||||
`progressRoutes()` (mounted `server.ts:508` as `/api/progress`):
|
||||
`GET /tree` — the indexed hierarchy; 503 `progress_indexer_unavailable`
|
||||
when unwired; 503 `progress_scan_roots_not_configured` + setup hint when
|
||||
no roots.
|
||||
|
||||
> Source: `routes/progress.ts:18-40` (handler; 503s L29-35); mounted
|
||||
> `server.ts:508` @HEAD.
|
||||
|
||||
> **§10.7 route-reality:** `/progress` is a **`<Navigate to="/project" />`
|
||||
> redirect-stub** — the progress system lives at the daemon
|
||||
> `/api/progress` route, but the UI URL folds into Project tabs (Phase 3).
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:464-468` ("folds into Project tabs
|
||||
> (Phase 3) — redirect to /project") @HEAD.
|
||||
|
||||
## 5. Steering composer (Operator Surface Reconciliation v0 item 1)
|
||||
|
||||
`SteeringComposer.compose()` is the **one-screen composed steering surface**.
|
||||
It is deliberately narrow: it composes only the **filesystem-derived**
|
||||
pieces; the UI fetches PL-005 queue views (in-motion / loop-state) and
|
||||
health gates via their own existing endpoints so the composer stays
|
||||
testable. Three sources, all resolved from a single workspace root
|
||||
(`OPENRIG_STEERING_WORKSPACE`; per-piece overrides
|
||||
`OPENRIG_STEERING_PATH` / `OPENRIG_ROADMAP_PATH` /
|
||||
`OPENRIG_DELIVERY_READY_DIR` trump the root-derived defaults; legacy
|
||||
`RIGGED_STEERING_WORKSPACE`):
|
||||
|
||||
- **priorityStack** — verbatim `STEERING.md` content + mtime + byteCount.
|
||||
- **roadmapRail** — `roadmap/PROGRESS.md` checkbox rows; detects `PL-XXX`
|
||||
rail-item codes; marks the **first unchecked** item as `isNextUnchecked`.
|
||||
- **laneRails** — `delivery-ready/mode-{N}/PROGRESS.md` per-lane: reuses
|
||||
`ProgressIndexer` (maxDepth 3) for identical checkbox semantics to the
|
||||
Progress view; "next pull" = first non-done, non-blocked row (Priority
|
||||
Rail Rule — shelf/queue recency does NOT override); top-N (default 3)
|
||||
prefers active+blocked, falls back to done only to fill N.
|
||||
|
||||
Missing sources are reported in `unavailableSources` (each names the env
|
||||
var that would resolve it) rather than failing the whole payload.
|
||||
`isReady()` is true when **at least one** source resolves.
|
||||
|
||||
> Source: `domain/steering/steering-composer.ts:1-25` (narrow-by-design
|
||||
> rationale + 3-source list), `:103-119` (env vars +
|
||||
> `steeringOptsFromEnv`), `:155-169` (`isReady` / `compose`), `:198-250`
|
||||
> (roadmap rail; next-unchecked L231-235), `:282-316` (lane next-pull
|
||||
> L288-289, top-N L293-296), `RAIL_CODE_REGEX` `:346` @HEAD.
|
||||
|
||||
`steeringRoutes()` (mounted `server.ts:510` as `/api/steering`):
|
||||
`GET /` — the composed payload; 503 `steering_composer_unavailable` when
|
||||
unwired; 503 `steering_workspace_not_configured` + a hint pointing at
|
||||
`rig config init-workspace` / `workspace.steering_path` /
|
||||
`OPENRIG_STEERING_PATH` when no source resolves.
|
||||
|
||||
> Source: `routes/steering.ts:18-34` (handler; 503s L23-29); mounted
|
||||
> `server.ts:510` @HEAD.
|
||||
|
||||
> **§10.7 route-reality:** `/steering` is a **`<Navigate to="/project" />`
|
||||
> redirect-stub** — the composer lives at daemon `/api/steering`; the UI
|
||||
> URL folds into the Project workspace overview tab (Phase 3).
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:470-474` ("folds into Project
|
||||
> workspace overview tab (Phase 3) — redirect to /project") @HEAD.
|
||||
|
||||
## 6. Health-summary aggregator (OSR v0 item 1F)
|
||||
|
||||
`computeNodeHealthSummary` / `computeContextHealthSummary` are the compact
|
||||
health gates on the steering surface — **daemon-side aggregation, not a CLI
|
||||
shell-out** (no per-request subprocess). Node summary: cross-rig roll-up of
|
||||
`sessionStatus` + `lifecycleState` via `getNodeInventory` per rig, with an
|
||||
`attentionRequired` tally. Context summary: reads `context_usage` directly
|
||||
(ContextUsageStore exposes per-node accessors only; listing all rows is a
|
||||
steering-surface concern) and buckets by urgency
|
||||
(`usedPercentage` ≥80 critical / ≥60 warning / else low / null unknown) +
|
||||
freshness (`sampledAt` age vs 300 s → fresh/stale/none). A missing
|
||||
`context_usage` table (test harness without the migration) yields an empty
|
||||
summary, not an error.
|
||||
|
||||
> Source: `domain/steering/health-summary.ts:44-46` (thresholds:
|
||||
> `FRESHNESS_THRESHOLD_S=300`, `URGENCY_CRITICAL_PCT=80`,
|
||||
> `URGENCY_WARNING_PCT=60`), `:48-66` (node roll-up), `:75-117`
|
||||
> (context roll-up; table-absent → empty L81-84) @HEAD.
|
||||
|
||||
`healthSummaryRoutes()` (mounted `server.ts:511` as `/api/health-summary`):
|
||||
`GET /nodes`, `GET /context`; 503 `health_summary_unavailable` when the
|
||||
rigRepo dep is unwired.
|
||||
|
||||
> Source: `routes/health-summary.ts:23-44` (handlers; 503 L33,L40);
|
||||
> mounted `server.ts:511` @HEAD.
|
||||
|
||||
## 7. Cross-cutting properties
|
||||
|
||||
- **Filesystem-canonical, no new SQLite:** all four surfaces read/write the
|
||||
filesystem + the JSONL audit; health-summary reads existing
|
||||
`context_usage` only. No content-surface migration
|
||||
(`progress-indexer.ts:21-23`; `health-summary.ts:81-84`) @HEAD.
|
||||
- **Fail-closed, honest errors:** path-safety rejects every escape attempt
|
||||
before fs resolution; write conflicts surface the current mtime/hash for
|
||||
an explicit refresh; missing config returns 503 + a setup hint naming the
|
||||
exact env var — never a silent empty success
|
||||
(`path-safety.ts:124-169`; `file-write-service.ts:129-135`;
|
||||
`routes/steering.ts:25-29`) @HEAD.
|
||||
- **Atomic-or-nothing writes:** same-directory temp + fsync + atomic rename;
|
||||
a rename failure leaves the target untouched (never a partial write)
|
||||
(`file-write-service.ts:160-172`) @HEAD.
|
||||
- **Composer narrowness is deliberate:** the steering composer owns only
|
||||
filesystem-derived pieces; queue/health come from their own endpoints —
|
||||
keeps it testable (`steering-composer.ts:6-16`) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — the JSONL edit audit at `~/.openrig/file-edit-audit.jsonl`
|
||||
**never rotates at v0** by explicit design
|
||||
(`file-write-service.ts:24-25`); unbounded growth is a known accepted v0
|
||||
state (PRD § Item 4 defers rotation), stated here rather than smoothed.
|
||||
- **OPEN-B** — `OPENRIG_FILES_ALLOWLIST` vs `OPENRIG_PROGRESS_SCAN_ROOTS`
|
||||
vs `OPENRIG_STEERING_WORKSPACE` are three independent operator-config
|
||||
surfaces with the same encoding but separate resolution; there is no
|
||||
single "workspace allowlist" that unifies them at v0. Source-real;
|
||||
noted, not reconciled.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration-count / route-group / PL-004-event-count claim.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator]
|
||||
applies-when: |
|
||||
Need to know how the daemon-backed coordination primitive works — the
|
||||
stream/queue/inbox/outbox tables, the hot-potato closure contract, the
|
||||
transactional handoff guarantee, or where queue closure is enforced.
|
||||
siblings: [workflow-runtime.md, mission-control.md, daemon-core.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
|
||||
The PL-004 Phase A coordination primitive is the SQLite-canonical
|
||||
durable-work layer the daemon exposes through `/api/stream` and `/api/queue`.
|
||||
It replaces the POC filesystem `rigx queue` / `rigx stream` path for
|
||||
daemon-backed work; the POC filesystem path remains untouched and the daemon
|
||||
`rig queue` / `rig stream` write only to SQLite (`architecture.md` §5 L651).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
|
||||
## 1. The five host-scoped tables
|
||||
|
||||
Five host-scoped tables back the primitive (`architecture.md` §3 L416–438;
|
||||
migrations re-confirmed at HEAD `packages/daemon/src/db/migrations/`):
|
||||
|
||||
- **`stream_items`** (`023_stream_items.ts`) — L1 append-only intake/audit
|
||||
root. Columns: `stream_item_id` (ULID PK), `ts_emitted`,
|
||||
`stream_sort_key`, `source_session`, `body`, `format` (default `text`),
|
||||
`hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON),
|
||||
`interrupt`, `archived_at`. Items immutable after emit (only `archived_at`
|
||||
may be set).
|
||||
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue. `qitem_id`
|
||||
is TEXT PK preserving the POC `qitem-YYYYMMDDHHMMSS-<hex>` shape. State
|
||||
enum (8 values): `pending | in-progress | done | blocked | failed |
|
||||
denied | canceled | handed-off`. Carries `closure_reason`,
|
||||
`closure_target`, `closure_required_at`, `chain_of_record` (JSON),
|
||||
`blocked_on`, `handed_off_to`/`handed_off_from`, nudge/heartbeat fields.
|
||||
- **`queue_transitions`** (`025_queue_transitions.ts`) — L3 append-only
|
||||
transition log; authoritative audit trail for state evolution. No
|
||||
UPDATE/DELETE from domain code.
|
||||
- **`inbox_entries`** (`026_inbox_entries.ts`) — mailbox-style asynchronous
|
||||
deposit; idempotent on `inbox_id`. State: `pending | absorbed | denied`.
|
||||
- **`outbox_entries`** (`027_outbox_entries.ts`) — sender-side audit;
|
||||
symmetric to inbox; idempotent on `outbox_id`. Delivery state:
|
||||
`pending | delivered | failed`.
|
||||
|
||||
> Drift-fix D3 — `architecture.md` §3 L243 frames the schema as "27
|
||||
> migrations (22 existing plus 5 added by PL-004 Phase A)". The 5 PL-004
|
||||
> Phase A coordination tables (`023`–`027`) are still correct as a *range*,
|
||||
> but the headline migration count is **40**, not 27 (slice-00 §1.3,
|
||||
> re-confirmed at HEAD: `startup.ts:206` 40-element `migrate()` array; full
|
||||
> drift-fix authority and the correction live in `daemon-core.md`).
|
||||
|
||||
## 2. The six host-scoped services
|
||||
|
||||
Six host-scoped domain services implement the layer (`architecture.md` §5
|
||||
L649–658; files re-confirmed in `packages/daemon/src/domain/` at HEAD).
|
||||
Routes import these; services are Hono-free:
|
||||
|
||||
- **`stream-store.ts`** — L1 stream: idempotent emit (on `stream_item_id`),
|
||||
chronological list with cursor pagination, soft archive.
|
||||
- **`queue-repository.ts`** — L3 queue: create, claim/unclaim, update
|
||||
(general state mutator with hot-potato strict-rejection on `done`),
|
||||
transactional handoff (close source as `handed-off` plus create new owned
|
||||
qitem in a single transaction), pod-fallback rerouting, overdue lookup,
|
||||
nudge/heartbeat tracking. Cross-rig validation hook exposed as
|
||||
`validateRig` constructor option.
|
||||
- **`queue-transition-log.ts`** — append-only state-transition log; used by
|
||||
`queue-repository.ts`, exposed read-only on
|
||||
`queue_repository.transitionLog`.
|
||||
- **`hot-potato-enforcer.ts`** — pure validator for the load-bearing API
|
||||
contract (see §3).
|
||||
- **`inbox-handler.ts`** — mailbox handler: authenticated drop (idempotent
|
||||
on `inbox_id`), absorb (promotes a pending entry to a `queue_item`,
|
||||
idempotent), deny (records reason). Auth check is a pluggable constructor
|
||||
hook.
|
||||
- **`outbox-handler.ts`** — sender-side outbox: idempotent record, mark
|
||||
delivered/failed, list. Emits no event-bus events (pure audit).
|
||||
|
||||
## 3. The hot-potato closure contract (where queue closure is enforced)
|
||||
|
||||
`hot-potato-enforcer.ts` is the pure validator for the load-bearing API
|
||||
contract. Re-confirmed at HEAD (`hot-potato-enforcer.ts:10–24`):
|
||||
|
||||
`state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied,
|
||||
canceled, no-follow-on, escalation}`. The reasons
|
||||
`handed_off_to | blocked_on | escalation` additionally require
|
||||
`closure_target`:
|
||||
|
||||
- `handed_off_to` — work continues with a different seat (`closure_target` =
|
||||
new owner).
|
||||
- `blocked_on` — work is parked pending another qitem (`closure_target` =
|
||||
blocker `qitem_id`).
|
||||
- `denied` — receiver rejected the work (`closure_target` = reason text).
|
||||
- `canceled` — sender or receiver withdrew (`closure_target` = note).
|
||||
- `no-follow-on` — terminal completion, nothing else needed.
|
||||
- `escalation` — kicked up to a higher tier (`closure_target` = escalation
|
||||
target).
|
||||
|
||||
Tier→SLA mapping for `closure_required_at` also lives in
|
||||
`hot-potato-enforcer.ts`. This validator is invoked by
|
||||
`QueueRepository.update()` (and `updateWithinTransaction()`), so closure is
|
||||
enforced at the daemon transaction boundary — the workflow runtime
|
||||
*projects* on closure but does not gate it (see `workflow-runtime.md`).
|
||||
|
||||
## 4. Coordination events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md`
|
||||
> §3 L394 says "Existing 32 PL-004 events untouched" and L410 says "Existing
|
||||
> 20 PL-004 events are unchanged" — these two body claims are internally
|
||||
> inconsistent with each other and predate 0.3.x. **Do NOT carry either
|
||||
> number.** The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
|
||||
> has **73 union members** total (slice-00 §1.8, re-confirmed at HEAD:
|
||||
> `grep -cE '^\s*\| \{ type: ' = 73`). A precise PL-004-only vs PL-005-only
|
||||
> sub-partition is not reconcilable from static inspection without
|
||||
> classifying all 73 members by introducing slice; slice-00 OPEN-4 flags
|
||||
> this and it is carried, not smoothed.
|
||||
|
||||
Coordination events emitted by these services (re-confirmed in
|
||||
`domain/types.ts` at HEAD): `stream.emitted` (StreamStore.emit,
|
||||
`types.ts:155`); `queue.created` / `queue.handed_off` / `queue.claimed` /
|
||||
`queue.unclaimed` / `qitem.fallback_routed` / `qitem.closure_overdue`
|
||||
(QueueRepository); `inbox.absorbed` (`types.ts:162`) / `inbox.denied`
|
||||
(InboxHandler). `architecture.md` §8 L987–990 listed these as "9
|
||||
coordination events"; the count of the broader `stream|queue|inbox|qitem`
|
||||
event family at HEAD is 10 (`stream` 1 + `queue` 5 + `inbox` 2 + `qitem` 2)
|
||||
— stated as a re-derived family count, not as a contested PL-004 sub-count
|
||||
(OPEN-4).
|
||||
|
||||
Two SSE surfaces stream coordination events: `/api/stream/watch`
|
||||
(aliased `/api/stream/sse`, `routes/stream.ts:117–118`) for new stream
|
||||
items, and `/api/queue/watch` for queue/inbox events. The event log remains
|
||||
append-only and SQLite-backed.
|
||||
|
||||
## 5. Route surface
|
||||
|
||||
- `/api/stream` (`server.ts:489`) — `POST /emit` (`routes/stream.ts:24`),
|
||||
`GET /list` (`:56`), `GET /watch` + `/sse` SSE (`:117`),
|
||||
`GET /:streamItemId` (`:121`), `POST /:streamItemId/archive` (`:130`).
|
||||
- `/api/queue` (`server.ts:490`) — `POST /create` (`routes/queue.ts:99`),
|
||||
`POST /:qitemId/claim` (`:144`), `POST /:qitemId/unclaim` (`:157`),
|
||||
`POST /:qitemId/update` (`:170`), plus handoff/list/watch surfaces.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — daemon wiring, the 40-migration set, route surface.
|
||||
- `workflow-runtime.md` — PL-004 Phase D runtime that projects on closure.
|
||||
- `mission-control.md` — PL-005 queue observability over `queue_items`.
|
||||
- Source roots: `packages/daemon/src/domain/{stream-store,queue-repository,
|
||||
queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`,
|
||||
`packages/daemon/src/routes/{stream,queue}.ts`.
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Daemon Core — Wiring, DB, Migrations, Startup
|
||||
status: active
|
||||
topics: [runtime-control, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the daemon boots, how createDaemon wires the dependency
|
||||
graph, the SQLite schema/migration set, or the route-mount surface.
|
||||
siblings: [coordination-primitive.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Daemon Core — Wiring, DB, Migrations, Startup
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies. The daemon
|
||||
(`@openrig/daemon`) is the framework-free SQLite-backed core that the CLI
|
||||
(`@openrig/cli`), UI (`@openrig/ui`), and MCP server all sit on top of.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Current package version is **0.3.1** across all three
|
||||
> packages (`package.json` root `"version": "0.3.1"`; slice-00 §1.1). HEAD
|
||||
> carries 6 commits of unreleased release-0.3.2 work; no `v0.3.2` tag exists.
|
||||
|
||||
## 1. System overview
|
||||
|
||||
The system has six architectural layers (`architecture.md` §1 L23–30):
|
||||
|
||||
1. **AgentSpec / pod-aware core** — spec parsing, resolution, precedence,
|
||||
startup orchestration, snapshot/restore, bundles.
|
||||
2. **Operator and topology layer** — harness auto-launch, node inventory,
|
||||
session naming, infrastructure nodes, explorer UI, existing-rig power-on,
|
||||
auto-snapshot, post-command handoff.
|
||||
3. **Communication and history layer** — transcript capture (pipe-pane),
|
||||
communication primitives (send/capture/broadcast), config/preflight,
|
||||
`rig ask` context packs, durable rig chat.
|
||||
4. **Authoring and identity layer** — spec review + spec library, `whoami`,
|
||||
adopted-session tmux-metadata parity, bind/materialize/adopt workflows.
|
||||
5. **Rig environment layer** — rig-scoped services records, Compose-backed
|
||||
service orchestration, readiness gates, env snapshot/restore integration.
|
||||
6. **Agent-managed software layer** — managed-app classification, app-focused
|
||||
browse/review/runtime UI, and the canonical `secrets-manager` example.
|
||||
|
||||
Legacy flat-node/package flows remain for backward compatibility.
|
||||
|
||||
### Verified source footprint at HEAD `7eaf524c`
|
||||
|
||||
> Drift-fix D1/D6 — `architecture.md` L6,11 said `OpenRig v0.2.0` /
|
||||
> "Current v0.2.0 release verification" and L7 said `376` source files. Both
|
||||
> are a frozen pre-v0.2.0-era header (slice-00 §1.9 — last edited `72982bb2`,
|
||||
> 2026-03-30). Corrected to the values below.
|
||||
|
||||
| Metric | Value @ HEAD | Source (independently re-confirmed) |
|
||||
|---|---|---|
|
||||
| Package version | **0.3.1** (HEAD +6 unreleased 0.3.2 commits) | slice-00 §1.1; `package.json:version` |
|
||||
| Total source footprint | **601** files | slice-00 §1.6; `find packages/*/src` non-test = 279+87+235 |
|
||||
| Daemon footprint | **279** total / **173** domain / **49** route mounts (46 route files) / **11** adapters / **40** migrations | slice-00 §1.5/§1.6; `find packages/daemon/src` non-test |
|
||||
| CLI footprint | **87** files | slice-00 §1.6; `find packages/cli/src` non-test |
|
||||
| UI footprint | **235** files | slice-00 §1.6; `find packages/ui/src` `.ts`+`.tsx` non-test |
|
||||
|
||||
> Footprint counts use the `find packages/*/src -type f \( -name '*.ts' -o
|
||||
> -name '*.tsx' \) ! -name '*.test.*'` predicate (tests live in separate
|
||||
> `packages/*/test/` dirs). Counts are reproducible with that predicate; a
|
||||
> different counting convention shifts absolute numbers (slice-00 OPEN-5).
|
||||
|
||||
### The stack
|
||||
|
||||
> Drift-fix D2 — `architecture.md` L37,125 said `CLI (53 command groups)`.
|
||||
> Corrected to **58** at HEAD (slice-00 §1.2: 53 at v0.2.0 tag, 56 at v0.3.0,
|
||||
> 57 at v0.3.1, **58 at HEAD** — the 58th is `scopeCommand()` added post-0.3.1
|
||||
> by `0b77cba4`, re-confirmed `index.ts:20,187`).
|
||||
>
|
||||
> Drift-fix D4 — `architecture.md` L40 said `31 route groups` and L76 said
|
||||
> "`createApp()` now mounts 22 route groups". Corrected to **49 `app.route()`
|
||||
> mounts + 4 dedicated handlers** (slice-00 §1.5; re-confirmed `server.ts`
|
||||
> 49×`app.route(` L450–513, plus `app.get("/healthz")` :446,
|
||||
> `handleExportYaml` :461, `handleExportJson` :462, `app.get("*")` :519).
|
||||
> Carry slice-00 **OPEN-2**: "route-group count is definitional" — there is
|
||||
> no single canonical "route group" definition in source; reported as mount
|
||||
> count + dedicated handlers.
|
||||
>
|
||||
> Drift-fix D3 — `architecture.md` L54 said `SQLite state (36 migrations)`.
|
||||
> Corrected to **40** (slice-00 §1.3; re-confirmed below).
|
||||
|
||||
```text
|
||||
CLI (58 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (49 app.route() mounts + 4 dedicated health/export/static handlers)
|
||||
|
|
||||
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
|
||||
+-- env routes (status / logs / down)
|
||||
+-- transport routes (send/capture/broadcast)
|
||||
+-- transcript routes (tail/grep)
|
||||
+-- ask routes (context evidence packs)
|
||||
+-- chat routes (durable rig messaging + SSE)
|
||||
+-- spec review/library routes (managed-app enrichment + compose preview)
|
||||
+-- whoami identity + context-usage route
|
||||
+-- coordination routes (stream / queue / workflow / mission-control)
|
||||
|
|
||||
v
|
||||
Framework-free domain services (173 daemon domain files)
|
||||
|
|
||||
+-- SQLite state (40 migrations)
|
||||
+-- tmux / cmux / resume adapters
|
||||
+-- runtime adapters (Claude Code / Codex / Terminal)
|
||||
+-- RigEnv substrate (compose adapter, readiness, orchestrator)
|
||||
+-- transport / transcript / chat / ask layers
|
||||
+-- whoami identity service
|
||||
```
|
||||
|
||||
The core product loop: `down (auto-snapshot) → up <rig-name> (auto-restore) →
|
||||
handoff → inspect/attach → work → repeat`.
|
||||
|
||||
> MCP tool count is **17** (`rig_*`) — slice-00 §1.4 confirms the count is
|
||||
> correct and the names are `rig_*` (NOT `rigged_*`; the `rigged_*` text in
|
||||
> `architecture.md` §2 is stale — the rename predates v0.2.0). Per-occurrence
|
||||
> verification of any `rigged_*` reference lands in `transport-and-transcripts.md`
|
||||
> (D5); the tmux `@rigged_*` metadata keys are a separate axis, not blanket-replaced.
|
||||
|
||||
## 2. Database schema
|
||||
|
||||
> Drift-fix D3 — `architecture.md` L243 said "27 migrations (22 existing plus
|
||||
> 5 added by PL-004 Phase A)" and L1002 repeated "27 migrations"; the §1 stack
|
||||
> diagram L54 said "36 migrations". All corrected to **40** migrations
|
||||
> (`001`–`040`). Triangulated (slice-00 §1.3, re-confirmed at HEAD): (1)
|
||||
> filesystem `packages/daemon/src/db/migrations/[0-9][0-9][0-9]_*.ts` → 40
|
||||
> files, `001_core_schema.ts` … `040_workflow_specs_diagnostic.ts`; (2)
|
||||
> `startup.ts:206` `migrate(db, [...])` passes a 40-element schema array
|
||||
> (`coreSchema` … `workflowSpecsDiagnosticSchema`); (3) `migrate.ts:13`
|
||||
> applies them sorted by name, tracked in `schema_migrations`.
|
||||
|
||||
### Core state tables (`001_core_schema.ts`)
|
||||
|
||||
`rigs` (topology container, `001_core_schema.ts:7`), `nodes` (logical node
|
||||
identity, `:17`), `edges` (logical topology relationships, `:30`), plus
|
||||
`bindings` (physical tmux/cmux surface attachment), `sessions` (live execution
|
||||
state), `events` (append-only event log), `snapshots` (serialized rig state),
|
||||
`checkpoints` (per-node recovery state). `architecture.md` §3 L247–289.
|
||||
|
||||
### Reboot-era schema
|
||||
|
||||
- `014_agentspec_reboot.ts` — reboot schema shape; adds `pods`,
|
||||
`continuity_state`, and reboot columns on `nodes`/`sessions`/`checkpoints`
|
||||
(`pod_id`, `agent_ref`, `resolved_spec_*`, `startup_status`,
|
||||
`continuity_source`). `architecture.md` §3 L253–289.
|
||||
- `015_startup_context.ts` — persisted startup replay context for restore.
|
||||
- `016_chat_messages.ts` — durable rig-scoped chat (SQLite-backed; transcripts
|
||||
remain filesystem-backed via pipe-pane).
|
||||
- `017_pod_namespace.ts` — first-class authored pod namespace for export/adoption.
|
||||
- `018_context_usage.ts` — per-node context-usage snapshots.
|
||||
- `019_external_cli_attachment.ts` — binding-row extension for external CLI attach.
|
||||
- `020_rig_services.ts` — rig-scoped environment record for service-backed rigs.
|
||||
- `021_seat_handover_observability.ts`, `022_node_codex_config_profile.ts`.
|
||||
|
||||
### Coordination / PL-004 / PL-005 / workspace migrations
|
||||
|
||||
- `023_stream_items.ts` … `027_outbox_entries.ts` — PL-004 Phase A
|
||||
coordination tables (detail in `coordination-primitive.md`).
|
||||
- `028_project_classifications.ts`, `029_classifier_leases.ts`,
|
||||
`030_views_custom.ts` — PL-004 Phase B classifier + view tables. (Note:
|
||||
`architecture.md` L361 said "028 through 030"; the middle migration is
|
||||
literally `029_classifier_leases.ts` — re-confirmed at HEAD.)
|
||||
- `031_watchdog_jobs.ts`, `032_watchdog_history.ts` — PL-004 Phase C watchdog.
|
||||
- `033_workflow_specs.ts`, `034_workflow_instances.ts`,
|
||||
`035_workflow_step_trails.ts` — PL-004 Phase D Workflow Runtime tables
|
||||
(detail in `workflow-runtime.md`). `036_watchdog_policy_enum_extension.ts`
|
||||
is a documenting no-op.
|
||||
- `037_mission_control_actions.ts` — PL-005 Phase A audit table
|
||||
(`037_mission_control_actions.ts:54` `CREATE TABLE … mission_control_actions`;
|
||||
detail in `mission-control.md`).
|
||||
- `038_workspace_primitive.ts`, `039_queue_target_repo.ts` — PL-007 typed
|
||||
workspace primitive (`bab24bf7`).
|
||||
- `040_workflow_specs_diagnostic.ts` — slice-11 (`f68f453a`); an
|
||||
`ALTER TABLE ADD COLUMN` adding parser/validator diagnostic columns to
|
||||
`workflow_specs` (no new table). **Net-new since `architecture.md` was last
|
||||
edited** — slice-00 §1.3 provenance; carried into `workflow-runtime.md`.
|
||||
|
||||
Legacy package/bootstrap/discovery tables (`packages`, `package_installs`,
|
||||
`install_journal`, `bootstrap_runs`, `bootstrap_actions`,
|
||||
`runtime_verifications`, `discovered_sessions`) remain active.
|
||||
|
||||
## 3. Route-mount surface
|
||||
|
||||
`createApp(deps)` (`packages/daemon/src/server.ts:295`) mounts **49**
|
||||
`app.route()` route-group mounts (`server.ts` L450–513) plus 4 dedicated
|
||||
non-route handlers: `GET /healthz` (`:446`), `GET /api/rigs/:rigId/spec`
|
||||
(`handleExportYaml`, `:461`), `GET /api/rigs/:rigId/spec.json`
|
||||
(`handleExportJson`, `:462`), and the static/deep-link `app.get("*")`
|
||||
catch-all (`:519`).
|
||||
|
||||
> OPEN-2 (carried verbatim, slice-00): the route-group "count" is definitional
|
||||
> — "49" is the count of `app.route()` mounts in `server.ts`. The older
|
||||
> "31 route groups" / "createApp now mounts 22 route groups" framing
|
||||
> (`architecture.md` L40,76) used a different, smaller accounting. Independent
|
||||
> corroboration: `packages/daemon/src/routes/` has 46 non-test route `.ts`
|
||||
> files (some groups composed from shared modules; the 49 mounts are the
|
||||
> authoritative count of mounted groups — slice-00 §1.5).
|
||||
|
||||
Mount families include reboot-era rig/session/spec routes plus the coordination
|
||||
routes (`/api/stream` `server.ts:489`, `/api/queue` `:490`,
|
||||
`/api/workflow` `:495`, `missionControlRoutes(...)` `:498`),
|
||||
`/api/health-summary` (`:511`), `/api/rigs/:rigId/env` (`:512`), and
|
||||
`/api/restore-check` (`:513`).
|
||||
|
||||
## 4. Startup sequence (`createDaemon`)
|
||||
|
||||
`createDaemon(opts?)` is `packages/daemon/src/startup.ts:203` (async,
|
||||
returns `DaemonResult`). It returns `{ app, db, deps, contextMonitor }`
|
||||
(`startup.ts:1204`). The sequence (`architecture.md` §9 L1000–1028,
|
||||
corrected against source):
|
||||
|
||||
1. Open SQLite and run **all 40 migrations** (`startup.ts:206`
|
||||
`migrate(db, [coreSchema … workflowSpecsDiagnosticSchema])` — a 40-element
|
||||
array; `architecture.md` L1002 said "27 migrations (22 existing plus the 5
|
||||
PL-004 Phase A coordination tables)" — corrected to 40, drift-fix D3).
|
||||
2. Construct core repositories and legacy services.
|
||||
3. Construct package/bootstrap/discovery services.
|
||||
4. Construct rebooted startup/runtime services: `StartupOrchestrator`,
|
||||
`ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`,
|
||||
`PodRigInstantiator`, `PodBundleSourceResolver`.
|
||||
5. Construct rig environment services: `ComposeServicesAdapter`,
|
||||
`ServiceOrchestrator`.
|
||||
6. Construct operator/transport/history services: `TranscriptStore`,
|
||||
`SessionTransport`, `ChatRepository`, `AskService` (with `HistoryQuery`),
|
||||
`ResumeMetadataRefresher`, `ContextUsageStore`, `ContextMonitor`,
|
||||
`NodeInventory`.
|
||||
7. Construct authoring/identity/managed-app services: `SpecReviewService`,
|
||||
`SpecLibraryService`, `WhoamiService`.
|
||||
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
9. Construct PL-004 Phase A coordination services from a shared
|
||||
`QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue`
|
||||
write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`,
|
||||
`OutboxHandler`.
|
||||
10. Build `AppDeps`, enforce shared-DB invariants, and call
|
||||
`createApp(deps)` (`startup.ts:1202`) to mount the full route tree.
|
||||
|
||||
The daemon entrypoint `packages/daemon/src/index.ts:36` calls
|
||||
`createDaemon({ dbPath, bearerToken })`.
|
||||
|
||||
## 5. Test and verification state
|
||||
|
||||
> Drift-fix D7 / OPEN-3 (carried verbatim, slice-00): `architecture.md` L12,13
|
||||
> and §10 L1036–1046 assert daemon `2561/2561` / CLI `794/794` / total
|
||||
> `2422/2422` test pass counts and per-package file counts (`127`/`37`/`37`).
|
||||
> **These are runtime claims and are NOT re-run here** — a read-only static
|
||||
> trace counted `*.test.ts` files only: daemon **255**, cli **234**
|
||||
> (slice-00 §1.7; ui test-file count not separately gathered). Pass tallies
|
||||
> are marked `unverified-runtime-claim`; do not assert them as current. To
|
||||
> assert pass counts, run `pnpm/npm test` and re-verify.
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — PL-004 Phase A stream/queue/inbox/outbox.
|
||||
- `agent-spec-and-startup.md` — spec parsing/resolution/startup contract.
|
||||
- `lifecycle-snapshot-restore.md` — snapshot/restore/continuity.
|
||||
- Source roots: `packages/daemon/src/{startup.ts,server.ts,index.ts}`,
|
||||
`packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts`.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Lifecycle — Snapshot, Restore, Continuity
|
||||
status: active
|
||||
topics: [continuity, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how OpenRig captures a snapshot, restores a rig (resume vs
|
||||
rebuild vs fresh), enforces restore honesty, consults live continuity state,
|
||||
or how the daemon-side restore-check / restore-packet readiness probes work.
|
||||
siblings: [daemon-core.md, agent-spec-and-startup.md, transport-and-transcripts.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Lifecycle — Snapshot, Restore, Continuity
|
||||
|
||||
The durable-state half of the core product loop:
|
||||
`down (auto-snapshot) → up <rig-name> (auto-restore) → handoff`. Snapshot
|
||||
captures serialized rig state; restore replays it honestly (no silent
|
||||
fresh-fallback); restore-check is a separate read-only readiness probe.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§4 Execution and restore, §5
|
||||
> Snapshot/restore/continuity, §6 Snapshot/restore + Auto-snapshot, §7 rules)
|
||||
> per slice-08 §10.1 — line numbers advisory only. This module is a pure
|
||||
> split EXCEPT §6 (restore-check/restore-packet) which is the D15
|
||||
> author-from-source piece — flagged explicitly there.
|
||||
|
||||
## 1. Snapshot / restore / continuity types
|
||||
|
||||
(`architecture.md` §4 "Execution and restore" — spec/projection members live
|
||||
in `agent-spec-and-startup.md`; the snapshot/restore members live here)
|
||||
|
||||
- **NodeRestoreOutcome** — `"resumed" | "rebuilt" | "fresh" | "failed" |
|
||||
"n-a"` — the locked restore vocabulary. Re-confirmed at source:
|
||||
`restore-orchestrator.ts:725-726` sets `baseStatus = "resumed"` on a
|
||||
resumed harness; `:763` sets `"rebuilt"` when a checkpoint is replayed; the
|
||||
outcome union appears at `:944` (`{ kind: "resumed" }` … ).
|
||||
- **SnapshotData** — current serialized snapshot payload. Reboot extensions
|
||||
are optional for older-snapshot compatibility: `pods?`,
|
||||
`continuityStates?`, `nodeStartupContext?`.
|
||||
- **NodeStartupSnapshot** — persisted restore replay input:
|
||||
classification-free projection entries, resolved startup files, startup
|
||||
actions, runtime.
|
||||
- **PersistedProjectionEntry** — the classification-free restore replay seam:
|
||||
persists only entry identity + source metadata, NOT stale `classification`,
|
||||
`conflicts`, or `noOps` (Architecture Rule 10).
|
||||
|
||||
## 2. Snapshot, restore, continuity domain services
|
||||
|
||||
(`architecture.md` §5 "Snapshot, restore, and continuity")
|
||||
|
||||
- `checkpoint-store.ts` — checkpoint persistence with pod/continuity context.
|
||||
- `snapshot-capture.ts` — captures pods, continuity state, startup replay
|
||||
context, and latest env receipt. Re-confirmed: `snapshot-capture.ts:66-77`
|
||||
pulls `pods`, `continuity_state`, and `node_startup_context` rows per rig.
|
||||
- `snapshot-repository.ts` — snapshot CRUD.
|
||||
- `restore-orchestrator.ts` — resume, checkpoint delivery, startup replay,
|
||||
live continuity consultation, topology ordering, and RigEnv boot gating
|
||||
before agent restore.
|
||||
|
||||
## 3. Restore flow and restore honesty
|
||||
|
||||
(`architecture.md` §6 "Snapshot / restore" + §7 rules 7/14/15/16)
|
||||
|
||||
Restore behavior, each point re-confirmed at `restore-orchestrator.ts`:
|
||||
|
||||
- Reads the newest session by **monotonic ULID**, not timestamp alone
|
||||
(`:700` — "Find the NEWEST session for this node. ULIDs are monotonic, so
|
||||
latest = max id").
|
||||
- Consults live `continuity_state`; preserves state when a node is already
|
||||
`restoring` (`:611-615` — `SELECT status FROM continuity_state …`; if
|
||||
`status === "restoring"` the node is skipped with a warning).
|
||||
- Replays restore-safe startup using persisted startup context;
|
||||
prefilters missing optional artifacts into warnings; **hard-fails a node if
|
||||
a required startup file is missing** (`:799` — status `"failed"`, error
|
||||
"Missing required startup files: …").
|
||||
- Writes a **transcript boundary marker before re-launch** (`:652` — "Write
|
||||
transcript boundary marker BEFORE launch (before pipe-pane attaches)").
|
||||
- Refuses to restore over live sessions (`:168` — `rig_not_stopped`: "Rig …
|
||||
has live sessions. Stop the rig with 'rig down' before restoring").
|
||||
- Uses `nativeResumeProbe` to honestly assess whether the harness actually
|
||||
resumed (`:857` continuity-outcome reconciliation).
|
||||
|
||||
**Restore-honesty rules (carried verbatim, `architecture.md` §7):**
|
||||
|
||||
- Rule 7 — restore-policy narrowing is one-way only:
|
||||
`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`.
|
||||
- Rule 14 — resume states are locked: `resumed` / `rebuilt` / `fresh`.
|
||||
`rebuilt` = new process assembled from artifacts.
|
||||
- Rule 15 — failed resume is FAILED loudly. No automatic fresh fallback.
|
||||
Fresh launch is explicit follow-up only.
|
||||
- Rule 16 — post-command handoff required on `up`, `down`, `restore`,
|
||||
`snapshot create`: what happened + current state + next action.
|
||||
|
||||
(The full 25-rule list lives in `architecture-rules-and-event-system.md`.)
|
||||
|
||||
## 4. Auto-snapshot and existing-rig power-on
|
||||
|
||||
(`architecture.md` §6 "Auto-snapshot and existing-rig power-on")
|
||||
|
||||
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before
|
||||
teardown.
|
||||
- `rig up <rig-name>` (no file extension) searches for an existing rig by
|
||||
name and restores from the latest `auto-pre-down` snapshot.
|
||||
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot
|
||||
from a spec or bundle path.").
|
||||
- Post-command handoff: `down` output includes snapshot ID + restore command;
|
||||
`up` output includes node statuses + attach command.
|
||||
|
||||
## 5. Daemon-side restore-check / restore-packet — AUTHORED FROM SOURCE (D15)
|
||||
|
||||
> **D15 author bit (flagged per slice-08 §4.7 binding):** `restore-check` and
|
||||
> `restore-packet` have NO `architecture.md` narrative. This section is the
|
||||
> one author-from-source piece in this otherwise pure-split module — authored
|
||||
> at the slice-00 forensic bar from `routes/restore-check.ts` +
|
||||
> `domain/restore-check-service.ts` + `commands/restore-packet.ts`, file:line
|
||||
> cited, version-attribution forensically checked.
|
||||
>
|
||||
> **Version attribution (forensic):** restore-check is NOT a 0.3.1 feature.
|
||||
> `routes/restore-check.ts` first-created `277e279c` ("feat: native rig
|
||||
> restore-check command", 2026-04-23, pre-0.3.0); present at both `v0.3.0`
|
||||
> and `v0.3.1` git trees (`git ls-tree` confirmed). `restore-packet`
|
||||
> first-created `23f2921e` ("Restore-Packet vertical M2a", 2026-05-01),
|
||||
> likewise present at v0.3.0 and v0.3.1. The gap is missing
|
||||
> architecture.md *prose*, not a version-attribution drift.
|
||||
|
||||
### 5.1 `rig restore-check` — readiness probe
|
||||
|
||||
`GET /api/restore-check?rig=<name>&noQueue=<bool>&noHooks=<bool>`
|
||||
(`routes/restore-check.ts:131`). The route assembles a framework-free
|
||||
`RestoreCheckDeps` (`:138-168`) over existing daemon projections —
|
||||
`listRigs` from `rigRepo`, `getNodeInventory` (joining `node_id` by
|
||||
`logical_id`, `:21-26,143-149`), `getStartupContext` (reads
|
||||
`node_startup_context`, parses `projection_entries_json` /
|
||||
`resolved_files_json` / `startup_actions_json`, `:44-128`), `hasSnapshot`
|
||||
/ `getLatestSnapshot` from `snapshotRepo`, and `probeDaemonHealth`
|
||||
(self-evident: "We're inside the daemon — if this route is responding,
|
||||
daemon is healthy", `:160-163`). It then runs
|
||||
`new RestoreCheckService(deps).check(...)` (`:170-171`).
|
||||
|
||||
`RestoreCheckService.check()` (`restore-check-service.ts:244`) layers:
|
||||
|
||||
1. **Host checks** — `checkDaemonReachable` (probe-throw → `verdict:
|
||||
unknown`, NOT `not_restorable`, `:252-259`), `checkStateDirWritable`
|
||||
(`:261`), `checkHostInfraDeclaration` (`:262`, impl `:406`).
|
||||
2. **Rig enumeration** — `listRigs()` throw → `buildUnknown` (`:267-274`);
|
||||
`--rig` filter; unknown rig → red `rig.<name>.exists` (`:276-284`).
|
||||
3. **Per-rig checks** — `checkSnapshot` (`:290`, impl `:675`),
|
||||
`checkSpecPresent` (`:295`).
|
||||
4. **Per-seat checks** — `checkSeatReadiness` (`:311`), `checkStartupContext`
|
||||
(`:315`, impl `:763`; `unknownChecks` → `buildUnknown`),
|
||||
`checkTranscript` (`:325`), `checkResumePath` (`:329`), and unless opted
|
||||
out: `checkQueueFile` (`:334`, gated by `--no-queue`, impl `:870`) and
|
||||
`checkHooks` (`:339`, gated by `--no-hooks`, impl `:896`).
|
||||
5. **Verdict** — `buildResult` aggregates: any red → `not_restorable`; any
|
||||
yellow → `restorable_with_caveats`; else `restorable`; probe-uninspectable
|
||||
→ `unknown` (`:1074-1085`, `:1362-1379`). Plus a `RecoveryPlan`
|
||||
(`buildRecovery`, `:1186`) and a `RepairStep[]` packet
|
||||
(`buildRepairPacket`, `:1392`; `null` when fully restorable).
|
||||
|
||||
The result shape is `RestoreCheckResult` (`restore-check-service.ts:120-130`):
|
||||
`verdict`, `readiness`, `continuity`, `rigs[]`, `hostInfra`, `recovery`,
|
||||
`counts {red,yellow,green}`, `checks[]`, `repairPacket`.
|
||||
|
||||
**Honest-error design (slice-00 bar):** a daemon-probe *exception* produces
|
||||
`verdict: unknown` (uninspectable state), distinct from a daemon
|
||||
definitely-down state which is `red` / `not_restorable`
|
||||
(`restore-check-service.ts:249-259`). The route's catch-all returns the same
|
||||
`unknown`-shaped body with HTTP 500 + a `probe.error` red check
|
||||
(`routes/restore-check.ts:174-222`). `CheckEntry.remediationSafe` defaults to
|
||||
`false` (conservative — unclassified remediations are NOT
|
||||
auto-execution-safe, `restore-check-service.ts:18-24`).
|
||||
|
||||
CLI surface (`cli-reference.md` `### rig restore-check`):
|
||||
`rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`. Exit
|
||||
codes: `0` restorable (or with caveats), `1` not restorable (red), `2`
|
||||
unknown / probe error.
|
||||
|
||||
### 5.2 `rig restore-packet` — cross-runtime restore packet
|
||||
|
||||
CLI-side, no daemon route. `commands/restore-packet.ts` (528 lines)
|
||||
implements three subcommands (`cli-reference.md` `### rig restore-packet`):
|
||||
`write [options]` (generate a packet directory from a source session or JSONL
|
||||
file, with `omitted-records` accounting), `read <packet-dir> [--json]`
|
||||
(render contents; non-mutating), `validate <packet-dir> [--json]` (validate
|
||||
against the v0 schema; non-mutating). Packet shape is the cross-runtime v0
|
||||
standard — Claude Code and Codex transcripts both supported via runtime
|
||||
parsers + redaction.
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D15 (author-from-source, completed):** restore-check / restore-packet
|
||||
authored from source above; flagged as the single author bit in this
|
||||
module. Version attribution forensically resolved (both pre-0.3.0; the gap
|
||||
was missing prose, not drift).
|
||||
- No slice-00 numeric drift applies to this module's split content (the
|
||||
footprint/migration/route counts land in `daemon-core.md`).
|
||||
|
||||
## See also
|
||||
|
||||
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
|
||||
context that restore consumes.
|
||||
- `daemon-core.md` — `/api/restore-check` is one of the 49 route mounts
|
||||
(`server.ts:513`).
|
||||
- `transport-and-transcripts.md` — transcript boundary markers written on
|
||||
restore.
|
||||
- Source roots: `packages/daemon/src/domain/{restore-orchestrator,
|
||||
snapshot-capture,snapshot-repository,checkpoint-store,
|
||||
restore-check-service}.ts`, `packages/daemon/src/routes/restore-check.ts`,
|
||||
`packages/cli/src/commands/restore-packet.ts`.
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
|
||||
applies-when: |
|
||||
Need to know how the daemon-backed Mission Control surface works — the
|
||||
seven views, the seven write verbs, the action audit table, the
|
||||
bearer-token middleware, or how queue observability maps to PL-004 sources.
|
||||
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, coordination-primitive.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
|
||||
Mission Control (PL-005) is the daemon-backed queue-observability surface: an
|
||||
integrated product UI inside the existing shell (a top-level `/mission-control`
|
||||
route, NOT a new managed app) over the PL-004 Phase A coordination primitive.
|
||||
Per the PRD acceptance criteria: seven views, seven verbs, first-class human
|
||||
seats, recent-ships=10, and a daemon-backed action audit table; no
|
||||
old-dashboard migration/cutover (`architecture.md` §3 L368, L390).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`. Mission Control is PL-005 (an
|
||||
> early-0.3.0 graft); plugins and Claude auto-compaction are 0.3.1 features
|
||||
> and are NOT back-attributed here (slice-00 0.3.0-ground-truth seams a/row-7).
|
||||
|
||||
## 1. The seven views
|
||||
|
||||
`MISSION_CONTROL_VIEWS` is the canonical view list (re-confirmed at HEAD
|
||||
`packages/daemon/src/domain/mission-control/mission-control-read-layer.ts:32–42`):
|
||||
`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`,
|
||||
`recently-active`, `recent-observations`.
|
||||
|
||||
All seven return rows in the load-bearing 9-field phone-friendly content
|
||||
model (rig/mission name, current phase,
|
||||
active|idle|attention|blocked|degraded, next-action,
|
||||
pending-human-decision, read-cost, last-update timestamp,
|
||||
confidence/freshness, evidence link). The model is non-negotiable across all
|
||||
7 views; UI may render compact, JSON preserves all 9 (`architecture.md` §3
|
||||
L370).
|
||||
|
||||
`MissionControlReadLayer` maps each view to its source-of-truth path
|
||||
(`mission-control-read-layer.ts:4–14`, re-confirmed at HEAD):
|
||||
|
||||
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004
|
||||
Phase A `queue_items` via `QueueRepository`.
|
||||
- `fleet` consumes the per-rig CLI capability cache + queue summary.
|
||||
- `recently-active` delegates to PL-004 Phase B
|
||||
`ViewProjector.show("recently-active")`.
|
||||
- `recent-observations` reads PL-004 Phase A `stream_items` via
|
||||
`StreamStore`.
|
||||
|
||||
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep)
|
||||
are graceful-degradation aids, NOT the primary path (`architecture.md` §3
|
||||
L378).
|
||||
|
||||
> Scope note (slice-00 0.3.0-ground-truth OPEN-3, carried): the exact
|
||||
> For-You verb subset is an unresolved velocity slice-01 ruling. This module
|
||||
> describes the **system-level** 7-verb vocabulary (proven, slice-00
|
||||
> §1.6/seam-c). It does NOT enumerate a For-You-surface subset — that
|
||||
> surface is described in `../ui/project-and-for-you.md` when authored.
|
||||
|
||||
## 2. The seven verbs (the write contract)
|
||||
|
||||
The seven verbs execute through the load-bearing
|
||||
`MissionControlWriteContract` (re-confirmed at HEAD
|
||||
`packages/daemon/src/domain/mission-control/mission-control-write-contract.ts:27–36`):
|
||||
|
||||
| Verb | Effect |
|
||||
|---|---|
|
||||
| `approve` | `state="done"`, `closure_reason="no-follow-on"` |
|
||||
| `deny` | `state="done"`, `closure_reason="denied"` |
|
||||
| `route` | `state="done"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates new qitem at the route target (1-hop) |
|
||||
| `annotate` | no queue mutation; audit record only |
|
||||
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
|
||||
| `drop` | `state="done"`, `closure_reason="canceled"` |
|
||||
| `handoff` | the 4-step shape (see below) |
|
||||
|
||||
Each verb is one atomic daemon transaction: queue mutation via
|
||||
`QueueRepository.updateWithinTransaction()` (which preserves Phase A
|
||||
hot-potato closure validation — see `coordination-primitive.md` §3) + an
|
||||
audit row in `mission_control_actions` + a persisted
|
||||
`mission_control.action_executed` event, all in one `db.transaction`. The
|
||||
4-step `handoff` shape (source-update + destination-create + opt-in
|
||||
best-effort notify + audit-record append) is verified atomic; notify failure
|
||||
does NOT roll back durable mutations (PRD invariant). Failure-injection rolls
|
||||
back source closure + audit row + new qitem together (`architecture.md` §3
|
||||
L380; `mission-control-write-contract.ts:5,15`).
|
||||
|
||||
## 3. The action audit table
|
||||
|
||||
`mission_control_actions` (`037_mission_control_actions.ts:54` `CREATE TABLE
|
||||
IF NOT EXISTS mission_control_actions`) is append-only at the API surface:
|
||||
it records every operator action through Mission Control with before/after
|
||||
qitem snapshots for forensic reconstruction. Columns include `action_verb`
|
||||
(TEXT, app-layer enum enforcement, `:56`) and `acted_at` (TEXT NOT NULL ISO
|
||||
timestamp); indexes `(acted_at DESC, action_verb)`,
|
||||
`(qitem_id, acted_at DESC)`, `(actor_session, acted_at DESC)`
|
||||
(`037_mission_control_actions.ts:28–44`). Phase B added NO migrations — this
|
||||
Phase A table is the only data source (`architecture.md` §3 L364, L392).
|
||||
|
||||
## 4. PL-005 Phase B — bearer middleware, notifications, audit browse
|
||||
|
||||
Phase B extends the daemon Mission Control surface:
|
||||
|
||||
- **Bearer-token middleware** —
|
||||
`packages/daemon/src/middleware/auth-bearer-token.ts` (re-confirmed at
|
||||
HEAD: header `auth-bearer-token.ts:1–9` "PL-005 Phase B"). Constant-time
|
||||
bearer comparison via Node `crypto.timingSafeEqual`; the daemon refuses to
|
||||
start with a non-loopback bind interface AND an empty bearer config
|
||||
(startup-side check). Bearer enforced on the write verbs:
|
||||
`app.post("/action", requireAuth)` (`routes/mission-control.ts:294`) and
|
||||
`app.post("/notifications/test", requireAuth)` (`:295`). v0 is bearer-on-write;
|
||||
no OAuth/SSO/per-user model.
|
||||
- **Notification dispatcher** — two adapters
|
||||
(`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts`
|
||||
alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env) plus
|
||||
`notification-dispatcher.ts` (re-confirmed in
|
||||
`domain/mission-control/`). ntfy.sh recommended for the operator's phone
|
||||
via tailnet.
|
||||
- **Read-only audit-history browse** — `audit-browse.ts` over
|
||||
`mission_control_actions`, exposed at `GET /api/mission-control/audit`
|
||||
(`routes/mission-control.ts:346`) with filters and `(limit, before_id)`
|
||||
pagination cursored on SQLite `rowid` (`architecture.md` §3 L392).
|
||||
|
||||
## 5. Mission Control events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
|
||||
> L394 says "Existing 32 PL-004 events untouched" — internally inconsistent
|
||||
> with §3 L410's "20" and predates 0.3.x. **Do NOT carry either number.**
|
||||
> The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
|
||||
> has **73 members** total (slice-00 §1.8, re-confirmed at HEAD); the
|
||||
> additive `mission_control.*` events are described below WITHOUT asserting
|
||||
> a contested PL-004 sub-count.
|
||||
|
||||
Re-confirmed `domain/types.ts:212–218`: Phase A added
|
||||
`mission_control.action_executed`, `mission_control.cli_drift_detected`,
|
||||
`mission_control.view_refreshed`; Phase B added
|
||||
`mission_control.notification_sent`, `mission_control.notification_failed`
|
||||
(5 `mission_control.*` members at HEAD).
|
||||
|
||||
## 6. Route surface
|
||||
|
||||
`missionControlRoutes({ bearerToken })` is mounted at `server.ts:498`. Key
|
||||
routes (`routes/mission-control.ts`): `GET /views` (`:245`),
|
||||
`GET /cli-capabilities` (`:250`), `POST /action` (`:298`, auth-gated),
|
||||
`GET /audit` (`:346`), `POST /notifications/test` (auth-gated), plus an SSE
|
||||
surface. The integrated UI is the `/mission-control` top-level route in
|
||||
`packages/ui/src/routes.tsx` mounting `MissionControlSurface`
|
||||
(`architecture.md` §3 L390); UI detail lands in
|
||||
`../ui/project-and-for-you.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — the PL-004 Phase A `queue_items`/`stream_items`
|
||||
sources Mission Control reads, and the hot-potato closure contract verbs honor.
|
||||
- `workflow-runtime.md` — PL-004 Phase D transactional-scribe runtime.
|
||||
- `../ui/project-and-for-you.md` — the UI surface counterpart (authored phase).
|
||||
- Source roots: `packages/daemon/src/domain/mission-control/`,
|
||||
`packages/daemon/src/middleware/auth-bearer-token.ts`,
|
||||
`packages/daemon/src/routes/mission-control.ts`,
|
||||
`packages/daemon/src/db/migrations/037_mission_control_actions.ts`.
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Packaging, Bootstrap, Bundles, Legacy Install Engine
|
||||
status: active
|
||||
topics: [specification-and-bundles, release-and-versioning]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how rig/pod bundles are assembled (schema-version-2 vs legacy
|
||||
v1), how bundle create/inspect/install and /api/up route across source kinds,
|
||||
the staged BootstrapOrchestrator plan/apply flow, or which legacy install-
|
||||
engine seams still ship for pre-reboot data.
|
||||
siblings: [agent-spec-and-startup.md, plugin-agent-image-context-pack.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Packaging, Bootstrap, Bundles, Legacy Install Engine
|
||||
|
||||
How OpenRig packages a topology into a shareable bundle and reconstitutes it
|
||||
on another host. Fully dual-format: schema-version-2 pod bundles plus legacy
|
||||
v1 artifacts, routed deterministically by the bootstrap orchestrator.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§5 Bundles/bootstrap/legacy
|
||||
> compatibility, §6 Bundle create/inspect/install + /api/up, §11 Compat
|
||||
> note 3) per slice-08 §10.1 — line numbers advisory only.
|
||||
|
||||
## 1. Bundle, bootstrap, legacy domain services
|
||||
|
||||
(`architecture.md` §5 "Bundles, bootstrap, and legacy compatibility")
|
||||
|
||||
- `pod-bundle-assembler.ts` — schema-version-2 bundle assembler.
|
||||
Re-confirmed: emits `schemaVersion: 2` (`pod-bundle-assembler.ts:167`).
|
||||
- `bundle-types.ts` — v1 and v2 manifest types plus parse/validate/serialize.
|
||||
Re-confirmed: v2 `PodBundleManifest` type carries `schemaVersion: 2`
|
||||
(`:23`); `validatePodBundleManifest` rejects unless `schema_version === 2`
|
||||
(`:38`); `serializePodBundleManifest` writes `schema_version: 2` (`:62`);
|
||||
`parsePodBundleManifest` (`:87`); legacy path
|
||||
`validateLegacyBundleManifest` (`:143`) — both formats live in one file.
|
||||
- `bundle-source-resolver.ts` — `LegacyBundleSourceResolver`
|
||||
(`bundle-source-resolver.ts:25`) plus `PodBundleSourceResolver` (`:132`).
|
||||
- `bootstrap-orchestrator.ts` — staged bootstrap flow with direct pod-aware
|
||||
rig and v2 bundle delegation. `BootstrapMode = "plan" | "apply"`
|
||||
(`bootstrap-orchestrator.ts:26`).
|
||||
- `up-command-router.ts` — spec/bundle source classification for `/api/up`.
|
||||
`SourceKind = "rig_spec" | "rig_bundle" | "rig_name"`
|
||||
(`up-command-router.ts:6`).
|
||||
|
||||
All re-confirmed present in `packages/daemon/src/domain/` @HEAD.
|
||||
|
||||
## 2. Bundle create / inspect / install
|
||||
|
||||
(`architecture.md` §6 "Bundle create / inspect / install")
|
||||
|
||||
`routes/bundles.ts` is fully dual-format:
|
||||
|
||||
- **create** — detects a pod-aware RigSpec and uses `PodBundleAssembler`
|
||||
(accepts an optional `rigRoot`); legacy create still uses
|
||||
`LegacyBundleAssembler`.
|
||||
- **inspect** — safely extracts the archive, detects `schema_version`; v2
|
||||
returns `schemaVersion: 2`, `agents[]`, and integrity data; v1 returns the
|
||||
legacy manifest shape.
|
||||
- **install** — uses the full bootstrap plan/apply; bootstrap peeks the
|
||||
manifest and routes deterministically to `pod_bundle` or `rig_bundle`.
|
||||
Re-confirmed at source: `bootstrap-orchestrator.ts:134-143` — when
|
||||
`sourceKind === "rig_bundle"` it unpacks to a temp peek dir, reads
|
||||
`bundle.yaml`, and calls `parsePodBundleManifest(...)` to detect the
|
||||
schema version before routing.
|
||||
|
||||
## 3. `/api/up` source routing
|
||||
|
||||
(`architecture.md` §6 "`/api/up`")
|
||||
|
||||
`UpCommandRouter` + `BootstrapOrchestrator` own:
|
||||
|
||||
- direct pod-aware rig specs,
|
||||
- legacy rig specs,
|
||||
- v1 bundle installs,
|
||||
- v2 pod-bundle installs.
|
||||
|
||||
Plan mode and apply mode both work across those source kinds.
|
||||
|
||||
> Definitional note (carried, not a numeric drift) — `architecture.md` §6
|
||||
> describes bootstrap routing to `pod_bundle` / `rig_bundle`. The
|
||||
> `UpCommandRouter` *classification* enum is `SourceKind = "rig_spec" |
|
||||
> "rig_bundle" | "rig_name"` (`up-command-router.ts:6`); the
|
||||
> `pod_bundle`-vs-`rig_bundle` distinction is resolved one layer deeper in
|
||||
> `bootstrap-orchestrator.ts` by peeking the manifest schema version
|
||||
> (`:134-143`). Both statements are accurate at different layers; documented
|
||||
> here so the layering is explicit rather than appearing contradictory.
|
||||
|
||||
## 4. Legacy install engine (still ships)
|
||||
|
||||
(`architecture.md` §5 "Legacy systems that still ship" + §11 compat note 3)
|
||||
|
||||
These pre-reboot seams remain active for backward compatibility:
|
||||
|
||||
- package install engine: `package-install-service.ts`,
|
||||
`package-manifest.ts`, `package-repository.ts`, `install-engine.ts`,
|
||||
`conflict-detector.ts`, `role-resolver.ts` (all re-confirmed present
|
||||
@HEAD).
|
||||
- bootstrap and requirement-probe support.
|
||||
- discovery and claim services.
|
||||
- tmux/cmux adapters and resume adapters.
|
||||
|
||||
> Drift-check D-pkg — `architecture.md` §5/§6 carries no slice-00 numeric
|
||||
> drift in this content; the v2 bundle-assembler + dual-format flows were
|
||||
> verified accurate at HEAD (§1–§3 above). The footprint/migration counts
|
||||
> that ARE stale land in `daemon-core.md`, not here.
|
||||
|
||||
Compat note 3 (carried verbatim, `architecture.md` §11): "Legacy
|
||||
compatibility seams still ship for pre-reboot data and v1 artifacts." (The
|
||||
full §11 list lives in `architecture-rules-and-event-system.md`.)
|
||||
|
||||
> Provenance note (carried, not asserted): `bootstrap-orchestrator.ts:3-5`
|
||||
> still imports `LegacyRigSpec` / `LegacyRigSpecCodec` / `LegacyRigSpecSchema`
|
||||
> with in-source `TODO: AS-T08b — migrate to pod-aware RigSpec` markers, and
|
||||
> `:16` `TODO: AS-T12 — migrate to pod-aware bundle source resolver`. The
|
||||
> legacy seam is intentional, in-progress migration scaffolding — recorded
|
||||
> as-is, not smoothed.
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D-pkg (resolved-as-current):** no slice-00 numeric drift in this
|
||||
module's split content; v2 assembler + dual-format flows verified accurate
|
||||
at HEAD. Bundle-routing layering documented explicitly to pre-empt an
|
||||
apparent §6-vs-source contradiction.
|
||||
- Legacy-migration TODOs carried from source verbatim (AS-T08b / AS-T12).
|
||||
|
||||
## See also
|
||||
|
||||
- `agent-spec-and-startup.md` — `RigInstantiator` / `PodRigInstantiator` and
|
||||
the dual-format spec seam that bundles wrap.
|
||||
- `plugin-agent-image-context-pack.md` — the 0.3.0/0.3.1 reusable
|
||||
starter-state / content-provenance cluster (authored separately in 8.4b).
|
||||
- `daemon-core.md` — `/api/up` + `/api/bundles` are among the 49 route
|
||||
mounts; `BootstrapOrchestrator` is constructed in the createDaemon
|
||||
sequence.
|
||||
- Source roots: `packages/daemon/src/domain/{pod-bundle-assembler,
|
||||
bundle-types,bundle-source-resolver,bootstrap-orchestrator,
|
||||
up-command-router}.ts`, `packages/daemon/src/routes/{bundles,up}.ts`.
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
|
||||
status: active
|
||||
topics: [extension-and-user-workspace, continuity, skill-management]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how OpenRig discovers plugins, captures/forks agent images,
|
||||
assembles/sends context packs, or how the Claude auto-compaction enforcer
|
||||
decides to send /compact. Author-mode module — no prior architecture.md
|
||||
prose; every claim is sourced to file:line at HEAD.
|
||||
siblings: [packaging-bootstrap-bundles.md, agent-spec-and-startup.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
|
||||
|
||||
Four filesystem-canonical content primitives the daemon discovers and
|
||||
serves: **plugins**, **agent images**, **context packs**, and the **Claude
|
||||
auto-compaction policy enforcer**. None adds SQLite state — all are
|
||||
filesystem-canonical with in-memory daemon caches.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists
|
||||
> for this. Every load-bearing claim carries `> Source: <file:line>
|
||||
> @HEAD`; ambiguity is declared as an OPEN item, never smoothed. Paths are
|
||||
> relative to `packages/daemon/src/` unless prefixed `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
|
||||
## 0. Release attribution (forensic seam — read first)
|
||||
|
||||
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| Context packs | **0.3.0** | `v0.3.0:domain/context-packs/context-pack-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
|
||||
| Agent images | **0.3.0** | `v0.3.0:domain/agent-images/agent-image-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
|
||||
| Plugins | **0.3.1, NOT 0.3.0** | `v0.3.0:domain/plugin-discovery-service.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT seam (a); map row 5) |
|
||||
| Claude auto-compaction | **0.3.1 headline, no 0.3.0 antecedent** | `v0.3.0:domain/claude-compaction-enforcer.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT map row 7) |
|
||||
|
||||
> Source: re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/daemon/src/domain/plugin-discovery-service.ts`
|
||||
> and `...claude-compaction-enforcer.ts` both fail ("exists on disk, but
|
||||
> not in 'v0.3.0'"); the two 0.3.0 library services resolve at `v0.3.0`.
|
||||
> Code present on disk at HEAD does NOT make plugins/compaction a 0.3.0
|
||||
> feature — ancestry is truth; the same-calendar-day commit date is the
|
||||
> forensic trap slice-00 0.3.0-GT seam (a) warns about.
|
||||
|
||||
**Do not back-attribute plugins or compaction to 0.3.0.** The CLI
|
||||
reference already encodes this ("## Plugin Inspection (v0.3.1)").
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:943` @HEAD.
|
||||
|
||||
## 1. Context packs (PL-014) — 0.3.0
|
||||
|
||||
A **context pack** is a directory of `manifest.yaml` + included markdown /
|
||||
yaml / txt files: operator-authored, library-discoverable, reviewable,
|
||||
sendable. No SQLite tables; in-memory cache at daemon scope.
|
||||
|
||||
> Source: `domain/context-packs/context-pack-types.ts:1-11` @HEAD.
|
||||
|
||||
`ContextPackLibraryService.scan()` walks roots, parses each
|
||||
`manifest.yaml`, replaces the in-memory index; collisions are
|
||||
**last-wins** in discovery order (workspace > user_file > builtin).
|
||||
Startup wires three roots: builtin (`../context-packs`, first), user-file
|
||||
(`~/.openrig/context-packs`), and workspace-local
|
||||
`<workspaceRoot>/.openrig/context-packs` when present + distinct. Stable
|
||||
id `context-pack:<name>:<version>`.
|
||||
|
||||
> Source: `domain/context-packs/context-pack-library-service.ts:59-94`
|
||||
> (scan; last-wins L79-81), `:31-33` (id); `startup.ts:481-502` (3-root
|
||||
> IIFE; builtin `unshift` L497, workspace L491-493) @HEAD.
|
||||
|
||||
The parser is pure (no fs; caller passes raw YAML). It rejects malformed
|
||||
YAML, missing `name`/`version`, non-array `files`, per-file path traversal
|
||||
(`..`/leading `/`), and unsupported suffixes (`.md .markdown .yaml .yml
|
||||
.txt`); `version` coerced to string. Per-file token estimate is
|
||||
`ceil(bytes/4)`.
|
||||
|
||||
> Source: `domain/context-packs/manifest-parser.ts:11` (suffixes),
|
||||
> `:13-121` (rejects; traversal `:83-89`); token estimate
|
||||
> `context-pack-library-service.ts:46-48` @HEAD.
|
||||
|
||||
`assembleBundle` concatenates files into one paste-ready string framed
|
||||
`# OpenRig Context Pack: <name> v<version>` + optional purpose + `## File:
|
||||
<path> (role: <role>)` headers. Missing files are **skipped and surfaced
|
||||
in `missingFiles`** (operator repair), not a hard fail; a present-but-
|
||||
unreadable file throws `file_read_failed`.
|
||||
|
||||
> Source: `domain/context-packs/bundle-assembler.ts:38-39` (prefixes),
|
||||
> `:73-100` (missing skip L74-76; read-fail throw L81-87) @HEAD.
|
||||
|
||||
`contextPacksRoutes()` (mounted `server.ts:482`): `GET /library`,
|
||||
`POST /library/sync`, `GET /library/:id`, `GET /library/:id/preview`
|
||||
(assembled, no send), `POST /library/:id/send`
|
||||
(`{ destinationSession, dryRun }`; dry-run returns text without
|
||||
`SessionTransport`; real send → 502 on transport failure). `id==="sync"`
|
||||
→ 404 so it cannot shadow the sync route; 503 when unprovisioned. CLI:
|
||||
`rig context-pack list|show|preview|sync|add|send` (`send --dry-run`).
|
||||
|
||||
> Source: `routes/context-packs.ts:25-138` (table; sync-guard L51; 503
|
||||
> L31; dry-run L121-123; 502 L126-133); `server.ts:482`;
|
||||
> `docs/as-built/cli-reference.md:914-929` @HEAD.
|
||||
|
||||
## 2. Agent images (PL-016) — 0.3.0
|
||||
|
||||
An **agent image** is a snapshot bundle of a productive seat's resumable
|
||||
state: runtime-specific resume token (Claude `resume_token` / Codex
|
||||
`thread_id`), source-seat lineage, optional cwd-deltas + notes. Consumable
|
||||
by AgentSpec `session_source: mode: agent_image`. Filesystem-canonical at
|
||||
`~/.openrig/agent-images/<name>/` + workspace-local; **no SQLite tables**.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-types.ts:1-13` @HEAD.
|
||||
|
||||
`AgentImageLibraryService` mirrors `ContextPackLibraryService` (scan / list
|
||||
/ get / last-wins) with three source-stated differences: `sourceResumeToken`
|
||||
passes through to consumers (instantiator consumes; operator surfaces
|
||||
redact); `stats.json` is a separate mutable file updated atomically on
|
||||
fork-count increment; a `.pinned` sentinel pins from prune. Id
|
||||
`agent-image:<name>:<version>`.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-library-service.ts:5-14`
|
||||
> (3-difference comment), `:38-41` (id) @HEAD.
|
||||
|
||||
`discoverResumeToken(db, sourceSession)` queries `sessions` then `nodes`
|
||||
and returns a typed failure (`session_not_found`/`runtime_unsupported`)
|
||||
rather than fabricating a token; only `claude-code`/`codex` have a native
|
||||
fork primitive. Claude prefers `context_usage.session_id` over persisted
|
||||
`sessions.resume_token`; Codex uses `resume_token`, falling back to a
|
||||
binding's `external_session_name` for `external_cli` seats; returns
|
||||
`nativeId: null` honestly when none.
|
||||
|
||||
> Source: `domain/agent-images/resume-token-discovery.ts:37-85` (honest
|
||||
> null L84; Claude pref L64-69; external_cli L78-83); honesty comment
|
||||
> `:9-10` @HEAD.
|
||||
|
||||
`SnapshotCapturer.capture()` routes through `discoverResumeToken`, throws
|
||||
`AgentImageError` on failure or missing native id (no fabricated token, no
|
||||
auto-fallback to fresh), writes manifest + empty `stats.json` via
|
||||
`install()`, re-scans. Captured `nodeCwd` → manifest `source_cwd` so the
|
||||
Use-as-starter snippet emits `cwd:` (fork starts in the parent's directory
|
||||
where Claude's project-dir-scoped jsonl lives); the daemon does NOT
|
||||
override cwd at fork dispatch.
|
||||
|
||||
> Source: `domain/agent-images/snapshot-capturer.ts:60-104` (throw
|
||||
> L65-79; source_cwd L88-93; install+scan L101-102);
|
||||
> `routes/agent-images.ts:256-265` (no-cwd-override contract) @HEAD.
|
||||
|
||||
**Evidence guard (CATASTROPHIC-bounce; fails closed).**
|
||||
`evaluateProtection()` protects an image from deletion if ANY: pinned;
|
||||
referenced by an active `agent.yaml`; referenced by a rig spec; or a
|
||||
lineage descendant of a protected image (transitive, fixed-point
|
||||
iteration). Spec-root scanning parses YAML structure (not string-grep)
|
||||
and is deliberately conservative — false positives (over-protection)
|
||||
acceptable; false negatives = catastrophic data loss. `--force` /
|
||||
`?force=true` overrides.
|
||||
|
||||
> Source: `domain/agent-images/evidence-guard.ts:1-16` (bounce +
|
||||
> fail-closed), `:53-115` (direct + transitive; fixed point L92-112),
|
||||
> `:117-122` (conservative) @HEAD.
|
||||
|
||||
**Resume-token redaction at route boundary (LOAD-BEARING).**
|
||||
`/api/agent-images` redacts `sourceResumeToken` → `"(redacted)"` on every
|
||||
operator-facing path; tokens are **never returned over the wire**; only
|
||||
the in-process rigspec-instantiator consumes the real token.
|
||||
|
||||
> Source: `routes/agent-images.ts:15-17` + `:44-46` (`redactResumeToken`),
|
||||
> applied `:60`,`:67`,`:94`,`:161`; slice-00 0.3.0-GT §1.9 flags this
|
||||
> LOAD-BEARING @HEAD.
|
||||
|
||||
`agentImagesRoutes()` (mounted `server.ts:483-485`): `GET /library`,
|
||||
`POST /library/sync`, `GET /library/:id`, `GET /library/:id/preview`,
|
||||
`POST /library/:id/pin`, `POST /library/:id/unpin`,
|
||||
`DELETE /library/:id` (evidence-guarded unless `force=true`),
|
||||
`POST /snapshot`, `POST /prune` (**dry-run by default**,
|
||||
`dryRun !== false`); guard `specRoots` injected via
|
||||
`deps.agentImageSpecRoots`. CLI: `rig agent-image
|
||||
list|show|preview|create|delete|pin|unpin|prune|sync`.
|
||||
|
||||
> Source: `routes/agent-images.ts:54-250` (table; prune default L112;
|
||||
> delete-guard L224-239); `server.ts:483-485`;
|
||||
> `docs/as-built/cli-reference.md:895-912` @HEAD.
|
||||
|
||||
## 3. Plugins (plugin-primitive Phase 3a) — **0.3.1, NOT 0.3.0**
|
||||
|
||||
> **Forensic trap (slice-00 0.3.0-GT seam (a)):** the plugin-discovery
|
||||
> file's first-creating commit is dated the same day as the 0.3.0 release
|
||||
> commit but is NOT an ancestor of `v0.3.0`. Plugins are **0.3.1**.
|
||||
> Re-confirmed at HEAD by the §0 tag-presence test.
|
||||
|
||||
`PluginDiscoveryService` is a read-only filesystem aggregator — no SQLite,
|
||||
no mutation routes (SC-29 EXCEPTION #8, declared verbatim in the source
|
||||
header). It scans four source kinds, unioned with provenance labels:
|
||||
vendored (`~/.openrig/plugins/<id>/`), Claude cache
|
||||
(`~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`), Codex cache
|
||||
(same under `~/.codex`), and rig-cwd (`<cwd>/.claude/plugins/*` +
|
||||
`<cwd>/.codex/plugins/*`). Detection = presence of
|
||||
`.claude-plugin/plugin.json` and/or `.codex-plugin/plugin.json` (declares
|
||||
`runtimes`). Slice 28 added `skillCount` (readdir of `<plugin>/skills/`)
|
||||
to the list response so the index page avoids an N+1 detail fetch
|
||||
(SC-29 EXCEPTION #11, verbatim).
|
||||
|
||||
> Source: `domain/plugin-discovery-service.ts:1-29` (SC-29 #8 + scan
|
||||
> desc), `:39` (4-kind `PluginSourceKind`), `listPlugins` `:205-276`
|
||||
> (4 scan blocks), `scanCwdBundledPlugins` `:283-307`, `detectPlugin`
|
||||
> `:393-453` (marker rule L399-404; `skillCount` L433-451 + #11
|
||||
> L75-80) @HEAD.
|
||||
|
||||
`getPlugin(id)` returns detail (manifests + skills + hooks + MCP servers);
|
||||
`rig-cwd:` ids are self-resolvable (parses the cwd out of the id prefix
|
||||
and re-scans so `/api/plugins/:id` does not 404 on an id `?cwd=`
|
||||
returned). `findUsedBy(id)` walks `agent.yaml` files parsing YAML
|
||||
structure (not string-grep — comments don't false-positive) to collect
|
||||
`resources.plugins[].id` + referencing profiles.
|
||||
|
||||
> Source: `domain/plugin-discovery-service.ts:309-369` (getPlugin;
|
||||
> rig-cwd self-resolve L310-321), `extractCwdFromRigCwdId` `:466-479`,
|
||||
> `findUsedBy` `:371-389`, `readResourcesPlugins` `:599-615`,
|
||||
> `readProfilesUsingPlugin` `:617-633` (structure-not-grep `:20-23`)
|
||||
> @HEAD.
|
||||
|
||||
`PluginVendorService.ensureLatest()` runs `ensureVendored` (hash-skip
|
||||
idempotent copy from `packages/daemon/assets/plugins/<name>/` to
|
||||
`~/.openrig/plugins/<name>/`) then `attemptAutoFetch`
|
||||
(`github.com/mvschwarz/openrig-plugins`, 5s timeout,
|
||||
**404/network/timeout tolerated silently** — vendored is ALWAYS the
|
||||
fallback; upstream repo intentionally empty at v0 so 404 is normal-state;
|
||||
NO tarball extraction at v0). Wired at startup for `openrig-core`.
|
||||
|
||||
> Source: `domain/plugin-vendor-service.ts:1-24`, `ensureVendored`
|
||||
> `:80-103` (hash-skip L97-99), `attemptAutoFetch` `:111-131`
|
||||
> (404-tolerant L116-117; no-extract L123-125), `ensureLatest`
|
||||
> `:138-141`; `startup.ts:434-472` @HEAD.
|
||||
|
||||
`pluginsRoutes()` (mounted `server.ts:478`, read-only): `GET /` (filters
|
||||
`?runtime=`, `?source=`, `?cwd=`), `GET /:id/used-by`,
|
||||
`GET /:id/files/list?path=`, `GET /:id/files/read?path=` (slice 28
|
||||
docs-browser), `GET /:id`. Literal sub-paths are mounted **before** the
|
||||
bare `/:id` catchall (route-order discipline). The files endpoints reuse
|
||||
the path-safety machinery with the discovered plugin's absolute path as a
|
||||
synthetic single-root allowlist (operator need not declare plugin paths
|
||||
in `OPENRIG_FILES_ALLOWLIST`; plugin folders read-only at v0 so the
|
||||
content hash is informational). CLI: `rig plugin
|
||||
list|show|used-by|validate`; no `install` verb at v0 (deferred to 0.3.2).
|
||||
|
||||
> Source: `routes/plugins.ts:29-39` + `:107-231` (table; before-catchall
|
||||
> L128-130/L141-142; `pluginRootAllowlist` :74-92; read-only-hash
|
||||
> L182-184); `server.ts:478`;
|
||||
> `docs/as-built/cli-reference.md:943-963` @HEAD.
|
||||
|
||||
> **OPEN-A** — `parseSourceFilter` (`routes/plugins.ts:69-72`) accepts
|
||||
> only `vendored|claude-cache|codex-cache`; it does NOT accept `rig-cwd`
|
||||
> though `PluginSourceKind` (`domain/plugin-discovery-service.ts:39`) and
|
||||
> `ListPluginsOpts.sourceFilter` include it. `?source=rig-cwd` is
|
||||
> silently dropped at the route while `?cwd=` still surfaces rig-cwd
|
||||
> entries unfiltered. Stated as-is; intent (rig-cwd reachable only via
|
||||
> `?cwd=`) vs filter-gap is not resolvable from source alone.
|
||||
|
||||
## 4. Claude auto-compaction enforcer (slice 27) — **0.3.1 headline**
|
||||
|
||||
> **No 0.3.0 antecedent (slice-00 0.3.0-GT map row 7).** The enforcer
|
||||
> file is absent at `v0.3.0`, present at `v0.3.1` (§0).
|
||||
|
||||
`ClaudeCompactionEnforcer.maybeAutoCompact()` decides per seat whether
|
||||
`ContextMonitor` should send `/compact`, driven by operator
|
||||
`policies.claude_compaction.*` settings. Decoupled from `ContextMonitor`
|
||||
scheduling; `ContextMonitor` is constructed with the enforcer at startup.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:7-12`, `maybeAutoCompact`
|
||||
> `:205-331`; `startup.ts:1191-1199` (enforcer →
|
||||
> `new ContextMonitor(db, contextUsageStore, claudeAdapter,
|
||||
> compactionEnforcer)`) @HEAD.
|
||||
|
||||
**Defensive contract** (source classifies compaction lifecycle as
|
||||
load-bearing per the banked permission-layer foot-gun rule):
|
||||
|
||||
- **Opt-in, default-off:** `enabled=false` → never triggers.
|
||||
- **Runtime filter:** triggers only when `runtime === "claude-code"`.
|
||||
- **Invalid-policy = disabled:** a hand-edited non-integer / out-of-
|
||||
`[1,100]` `thresholdPercent` is treated as disabled (safer-failure).
|
||||
- **Re-arm via threshold crossing:** session must drop below threshold
|
||||
before another auto-compact fires; window state NOT persisted (restart
|
||||
resets — safer-failure direction).
|
||||
- **Send-failure graceful-degrade:** returns `{ triggered: false,
|
||||
reason: "send_failed" }`, never throws; dedup timestamp set only on
|
||||
successful send so a transient failure retries next tick.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:14-44` (risk-class +
|
||||
> contract), runtime `:206-208`, invalid-policy `:224-232`,
|
||||
> dedup/threshold `:285-300`, send-fail `:312-314`/`:323-325` @HEAD.
|
||||
|
||||
**Active handshake state machine** (Claude hooks can supply context but
|
||||
do not create a new assistant turn): (1) **pre-compact prep** — first
|
||||
eligible above-threshold tick sends a normal user-channel prep prompt;
|
||||
state → `prep_prompt_sent`. (2) **/compact** — next eligible tick sends
|
||||
`/compact <instruction> + trust-channel bridge note`; sets dedup
|
||||
timestamp + `triggeredAboveThreshold`; queues post stage
|
||||
`turn_boundary`. (3) **post-compact, below threshold** — staged sends
|
||||
`turn_boundary` (ack-only) → `restore_prompt` (points at the pending
|
||||
marker `<openrigHome>/compaction/restore-pending/<key>.json`, transcript
|
||||
/ session-id fallbacks) → `compliance_prompt` (read-depth audit); final
|
||||
stage sets post-restore cooldown + clears the above-threshold latch.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:71-77` (compact+bridge),
|
||||
> `:79-98` (prep), `:108-145` (restore + marker path), `:147-163`
|
||||
> (compliance), `:165-171` (turn boundary), state machine `:233-330`
|
||||
> (below-threshold L233-283; above-threshold L302-330) @HEAD.
|
||||
|
||||
Policy fields via `SettingsStore.resolveClaudeCompactionPolicy()`:
|
||||
`enabled`, `thresholdPercent`, `preCompactInstruction`,
|
||||
`compactInstruction`, `messageInline`, `messageFilePath`,
|
||||
`postRestoreAuditInstruction` — each backed by a
|
||||
`policies.claude_compaction.*` key + `OPENRIG_POLICIES_CLAUDE_COMPACTION_*`
|
||||
env override. Defaults: dedup `60_000` ms; post-compact restore cooldown
|
||||
`10 * 60_000` ms.
|
||||
|
||||
> Source: `domain/user-settings/settings-store.ts:553-565` (resolver),
|
||||
> keys `:97-103`, env `:145-151`;
|
||||
> `domain/claude-compaction-enforcer.ts:45-46` (default windows) @HEAD.
|
||||
|
||||
## 5. Cross-cutting properties
|
||||
|
||||
- **Filesystem-canonical, no new SQLite** across all four; daemon caches
|
||||
in-memory; compaction window state intentionally not persisted
|
||||
(context-pack-types.ts:8-11; agent-image-types.ts:12-13;
|
||||
routes/plugins.ts:6; claude-compaction-enforcer.ts:26-29) @HEAD.
|
||||
- **Honest failure, no fabrication** — discovery returns null; capturer
|
||||
throws; enforcer degrades + never throws; evidence guard fails closed
|
||||
(resume-token-discovery.ts:9-10; snapshot-capturer.ts:11-12;
|
||||
claude-compaction-enforcer.ts:30-33; evidence-guard.ts:16) @HEAD.
|
||||
- **503-when-unprovisioned** is the uniform route pattern when the
|
||||
backing service is absent from context (routes/context-packs.ts:31;
|
||||
routes/agent-images.ts:59; routes/plugins.ts:113) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — plugin `?source=` filter omits `rig-cwd` (§3); intent vs
|
||||
filter-gap unresolvable from source.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration / route-group / PL-004-event counts. Slice-00 0.3.0-GT
|
||||
OPEN-3 (For-You verb subset) is out of scope (UI surface, not
|
||||
content-layer).
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Transport, Transcripts, Chat, Ask
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how rig send/capture/broadcast works, how pipe-pane transcript
|
||||
capture and rg/grep search behave, how durable rig chat (SQLite + SSE) is
|
||||
modeled, what rig ask gathers, or the exact MCP-tool-name vs tmux-metadata-key
|
||||
naming distinction.
|
||||
siblings: [daemon-core.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Transport, Transcripts, Chat, Ask
|
||||
|
||||
The communication-and-history layer: tmux is transport, not truth. Send/
|
||||
capture/broadcast wrap tmux with honest errors; transcripts are raw pipe-pane
|
||||
captures; chat is daemon-backed SQLite; `rig ask` gathers evidence but never
|
||||
calls an LLM.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§5 Transport and communication, §6
|
||||
> Communication/Transcript/Chat flows, §11 Compat notes) per slice-08 §10.1
|
||||
> — line numbers advisory only.
|
||||
|
||||
## 1. The naming axes (D5 — read this first)
|
||||
|
||||
This module carries the D5 drift, which has **two independent axes** that
|
||||
must NOT be conflated or blanket-replaced (banked
|
||||
`feedback_release_prep_three_layer_depersonalization`: the rename was scoped,
|
||||
not global). Per-occurrence source verification:
|
||||
|
||||
**Axis 1 — MCP tool names: `rig_*` (architecture.md is STALE → corrected).**
|
||||
slice-00 §1.4 confirms all 17 MCP tools are `rig_*`. The `rigged_*`
|
||||
references in `architecture.md` are stale text from a rename that predates
|
||||
v0.2.0:
|
||||
|
||||
> Drift-fix D5 (MCP-tool-name axis) — `architecture.md` §5 `node-inventory.ts`
|
||||
> description says MCP `rigged_rig_nodes`; §6 chat flow says MCP
|
||||
> `rigged_chatroom_send` + `rigged_chatroom_watch`. Corrected to **`rig_*`**.
|
||||
> Re-confirmed at source: `mcp-server.ts:288` registers `"rig_rig_nodes"`,
|
||||
> `:305` `"rig_send"`, `:339` `"rig_capture"`, `:364` `"rig_chatroom_send"`,
|
||||
> `"rig_chatroom_watch"` (tool #17). Zero `rigged_chatroom_send` /
|
||||
> `rigged_send` / `rigged_rig_nodes` in non-test product source (grep clean
|
||||
> @HEAD). slice-00 §1.4; the rename landed in `b183c50c` pre-v0.2.0.
|
||||
|
||||
**Axis 2 — tmux metadata keys: `@rigged_*` (architecture.md is CORRECT → do
|
||||
NOT change).** The tmux metadata keys written at claim/bind time are a
|
||||
SEPARATE thing from MCP tool names and were NOT renamed:
|
||||
|
||||
> Drift-nuance D5 (tmux-metadata-key axis) — `architecture.md` §6 "Whoami
|
||||
> and adopted-session parity flow" lists `@rigged_node_id`,
|
||||
> `@rigged_session_name`, `@rigged_rig_id`, `@rigged_rig_name`,
|
||||
> `@rigged_logical_id`. These are **CORRECT as-is** — verified literally at
|
||||
> source: `claim-service.ts:77-81` writes exactly these five `@rigged_*`
|
||||
> keys. Do NOT blanket-sed `rigged` → `rig`; this is a per-occurrence
|
||||
> verify, not a global substitution. (The metadata-key axis is detailed in
|
||||
> `agent-spec-and-startup.md` §6.) Re-confirmed `claim-service.ts:77-81`
|
||||
> @HEAD.
|
||||
|
||||
## 2. Transport and communication domain services
|
||||
|
||||
(`architecture.md` §5 "Transport and communication")
|
||||
|
||||
- `session-transport.ts` — communication primitives: send/capture/broadcast
|
||||
with session resolution (canonical + legacy names), mid-work detection,
|
||||
honest error reporting, pod/rig/global targeting.
|
||||
- `transcript-store.ts` — pipe-pane transcript management: ANSI stripping on
|
||||
read, boundary markers, readTail, grep. Filesystem-backed, NOT SQLite.
|
||||
- `history-query.ts` — transcript + chat search. Prefers `rg` when
|
||||
available, falls back to `grep -E`, surfaces which backend was used.
|
||||
Re-confirmed: `history-query.ts:7` `backend: "rg" | "grep" | "none"`;
|
||||
`:103` execs `rg -i --no-filename -e <pattern>`; `:108` returns
|
||||
`backend: "rg"`.
|
||||
- `ask-service.ts` — context-engineering evidence pack: gathers rig summary
|
||||
plus transcript excerpts, chat excerpts, insufficiency state, guidance.
|
||||
Does NOT call an external LLM.
|
||||
- `chat-repository.ts` — durable rig-scoped chat: CRUD for `chat_messages`
|
||||
table, SSE-compatible event emission.
|
||||
|
||||
Routes: `routes/{transport,transcripts,ask,chat,whoami}.ts` — all confirmed
|
||||
present @HEAD.
|
||||
|
||||
## 3. Communication flow
|
||||
|
||||
(`architecture.md` §6 "Communication flow")
|
||||
|
||||
`rig send <session> "message"` → CLI → `POST /api/transport/send` →
|
||||
`SessionTransport`:
|
||||
|
||||
1. Resolve session name (canonical or legacy; by session/rig/pod/global).
|
||||
2. Check mid-work state (unless `--force`) — re-confirmed
|
||||
`session-transport.ts:136-141` (`findPatternEvidence(recentLines,
|
||||
MID_WORK_PATTERNS)`); legacy mid-work check at `:666`.
|
||||
3. Two-step tmux send: `send-keys -l` → ~200ms delay → `C-m`
|
||||
(`session-transport.ts:717` submits `C-m`).
|
||||
4. Optional `--verify`: capture post-send pane, check message visibility
|
||||
(`session-transport.ts:305` `verify?`; `:694` `if (opts?.verify)`).
|
||||
5. Honest result with reason on failure.
|
||||
|
||||
Architecture Rule 18 (carried, `architecture.md` §7): tmux is transport, not
|
||||
truth — `send/capture/broadcast` wrap tmux reliably with honest errors.
|
||||
|
||||
## 4. Transcript flow
|
||||
|
||||
(`architecture.md` §6 "Transcript flow")
|
||||
|
||||
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation
|
||||
(before harness boot).
|
||||
2. Raw terminal output streams to
|
||||
`~/.openrig/transcripts/{rig-name}/{session-name}.log`.
|
||||
3. `TranscriptStore` owns path convention, ANSI stripping on read, boundary
|
||||
markers, `readTail`, `grep`.
|
||||
4. `rig transcript <session> --tail N / --grep "pattern"` provides
|
||||
agent-facing access.
|
||||
5. On restore: a boundary marker is written before re-launch; pipe-pane
|
||||
reconnects to the same file (append). (Restore-side detail in
|
||||
`lifecycle-snapshot-restore.md` §3.)
|
||||
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts,
|
||||
insufficiency state, and guidance.
|
||||
|
||||
Architecture Rule 19 (carried): transcripts are raw capture via pipe-pane,
|
||||
ANSI strip on read; `rg` preferred, `grep -E` fallback. Rule 22: `rig ask`
|
||||
is context engineering — gathers evidence, does NOT call an external LLM;
|
||||
the agent IS the LLM.
|
||||
|
||||
## 5. Chat flow
|
||||
|
||||
(`architecture.md` §6 "Chat flow")
|
||||
|
||||
1. `rig chatroom send <rig> "message"` → `POST
|
||||
/api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`.
|
||||
2. SSE: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages.
|
||||
3. History: `GET /api/rigs/:rigId/chat/history` returns full channel
|
||||
history; `POST /api/rigs/:rigId/chat/topic` persists topic markers.
|
||||
4. UI: chat-room tab in the rig drawer.
|
||||
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (D5 axis-1 correction
|
||||
— `architecture.md` §6 said `rigged_*`; source `mcp-server.ts:364` +
|
||||
tool #17).
|
||||
6. Source of truth: daemon-backed SQLite (`chat_messages` table), NOT tmux
|
||||
scrollback.
|
||||
|
||||
**ChatMessage** type (`architecture.md` §4): durable rig-scoped message —
|
||||
`id`, `rigId`, `sender`, `kind`, `body`, `topic`, `createdAt`.
|
||||
|
||||
## 6. Compatibility notes (carried verbatim, `architecture.md` §11)
|
||||
|
||||
The intentional limits in this layer:
|
||||
|
||||
- Note 4 — `rig ask` gathers context only; does not call an external LLM.
|
||||
The agent reasons about the gathered evidence.
|
||||
- Note 5 — transcript search prefers `rg` but falls back to `grep -E`;
|
||||
search quality/performance varies by backend.
|
||||
- Note 6 — chat is rig-scoped only: no cross-rig channels or DMs.
|
||||
- Note 7 — `--verify` on `rig send` checks pane content for message
|
||||
visibility but can produce false positives from pre-existing matching
|
||||
content. Known limitation.
|
||||
|
||||
(The full §11 compatibility-notes list lives in
|
||||
`architecture-rules-and-event-system.md`.)
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D5 (the careful one — resolved per-occurrence, NOT blanket-replaced):**
|
||||
Axis 1 (MCP tool names) corrected `rigged_*` → `rig_*` (3 sites: §5
|
||||
node-inventory, §6 chat flow ×2). Axis 2 (tmux `@rigged_*` metadata keys)
|
||||
verified literally correct at `claim-service.ts:77-81` and left unchanged.
|
||||
No slice-00 numeric drift applies to this module's split content.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — transport/transcript/chat/ask are among the 49 route
|
||||
mounts; MCP tool count (17, `rig_*`) is anchored there.
|
||||
- `agent-spec-and-startup.md` §6 — the tmux `@rigged_*` metadata-key axis
|
||||
detail (whoami/adopt).
|
||||
- `lifecycle-snapshot-restore.md` §3 — restore-side transcript boundary
|
||||
markers.
|
||||
- Source roots: `packages/daemon/src/domain/{session-transport,
|
||||
transcript-store,history-query,ask-service,chat-repository}.ts`,
|
||||
`packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`,
|
||||
`packages/cli/src/mcp-server.ts`.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
status: active
|
||||
topics: [coordination, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the daemon-native Workflow Runtime works — workflow specs
|
||||
cache, instance state, step trails, the transactional-scribe projection
|
||||
contract, or the watchdog policy set including workflow-keepalive.
|
||||
siblings: [coordination-primitive.md, mission-control.md]
|
||||
prerequisite-reads: [../README.md, coordination-primitive.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
|
||||
The daemon-native Workflow Runtime (PL-004 Phase D) turns an intended
|
||||
sequence of work into durable SQLite state: declarative workflow specs, live
|
||||
instance state, append-only step trails, and the load-bearing
|
||||
transactional-scribe contract. The PRD §L4 operating model is
|
||||
owner-as-author semantically plus workflow-as-transactional-scribe
|
||||
mechanically (`architecture.md` §3 L398).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
|
||||
## 1. The three Phase D tables (+ the diagnostic addition)
|
||||
|
||||
Re-confirmed at HEAD in `packages/daemon/src/db/migrations/`:
|
||||
|
||||
- **`workflow_specs`** (`033_workflow_specs.ts:33` `CREATE TABLE … workflow_specs`)
|
||||
— a read-through cache of human-authored markdown/YAML spec files. Sources
|
||||
are workspace-surface; the daemon caches by `(name, version)` with a
|
||||
content `source_hash` so valid operator edits to the spec file win at next
|
||||
read (workspace-surface reconciliation). Spec authoring stays
|
||||
markdown-authoritative; the cache exists for fast lookup and runtime
|
||||
resolution (`architecture.md` §3 L400).
|
||||
- **`workflow_instances`** (`034_workflow_instances.ts:45` `CREATE TABLE …
|
||||
workflow_instances`) — live state per running workflow: `status`
|
||||
(`active|waiting|completed|failed`), `current_frontier_json` (active qitem
|
||||
ids), `hop_count` (loop-guard counter), `last_continuation_decision_json`.
|
||||
Instances survive daemon restart from SQLite — no filesystem
|
||||
reconciliation (`architecture.md` §3 L402).
|
||||
- **`workflow_step_trails`** (`035_workflow_step_trails.ts:32` `CREATE TABLE …
|
||||
workflow_step_trails`) — append-only history of meaningful step
|
||||
transitions. Every closure produces one trail row pairing the prior qitem
|
||||
with the next qitem (null on terminal). `WorkflowStepTrailLog.record()` is
|
||||
the only writer (`architecture.md` §3 L404).
|
||||
- **`040_workflow_specs_diagnostic.ts`** — slice-11 (`f68f453a`,
|
||||
slice-00 §1.3 provenance). An `ALTER TABLE ADD COLUMN` adding
|
||||
parser/validator diagnostic columns to `workflow_specs` (**no new table,
|
||||
no constraint changes beyond a DEFAULT**; the cache carries the
|
||||
parse/validation diagnostic for the UI to render — `040_..._diagnostic.ts:5–25`).
|
||||
**This migration is net-new since `architecture.md` was last edited** and
|
||||
is not described in any prior as-built doc (slice-00 §1.3 — `f68f453a`
|
||||
postdates the §3 body) — authored here from source per the slice-08
|
||||
drift-to-fix register.
|
||||
|
||||
`036_watchdog_policy_enum_extension.ts` is a documenting no-op that records
|
||||
the Phase D watchdog enum extension (Phase C uses application-layer
|
||||
enforcement via the `PHASE_D_POLICIES` array, so no DDL is needed —
|
||||
`architecture.md` §3 L363).
|
||||
|
||||
## 2. The transactional-scribe contract
|
||||
|
||||
The load-bearing Phase D guarantee is implemented in
|
||||
`WorkflowProjector.project()` (`packages/daemon/src/domain/workflow-projector.ts`).
|
||||
Re-confirmed at HEAD: the projector header (`workflow-projector.ts:1–20`)
|
||||
declares "transactional-scribe contract" and `project()` runs a single
|
||||
`db.transaction` (`workflow-projector.ts:184` `const txn = this.db.transaction(...)`).
|
||||
Inside that one transaction:
|
||||
|
||||
1. Close the current packet (state mutation on `queue_items`).
|
||||
2. Create the next-step packet (`QueueRepository.createWithinTransaction()`,
|
||||
`workflow-projector.ts:230`).
|
||||
3. Record the trail entry.
|
||||
4. Update the instance frontier + status.
|
||||
5. Persist workflow events.
|
||||
|
||||
Either everything commits or everything rolls back; lost handoffs are
|
||||
impossible by design. Post-commit, subscribers are notified and the next
|
||||
owner is nudged (`architecture.md` §3 L406).
|
||||
|
||||
`WorkflowRuntime` (`packages/daemon/src/domain/workflow-runtime.ts:61`
|
||||
`export class WorkflowRuntime`) is the orchestration class above the
|
||||
projector.
|
||||
|
||||
**Phase D scope boundary** (`architecture.md` §3 L412): excludes multi-hop
|
||||
chaining, gate-return-sweep, and the closure-enforcement path. The daemon's
|
||||
transactional state remains the closure authority via Phase A's hot-potato
|
||||
strict-rejection (see `coordination-primitive.md` §3); the workflow runtime
|
||||
**projects on closure, it does not gate closure**.
|
||||
|
||||
## 3. The workflow-keepalive watchdog policy
|
||||
|
||||
`workflow-keepalive` is the Phase C-deferred watchdog policy, a TypeScript
|
||||
port of the POC `lib/policies/workflow-keepalive.mjs` adapted to read SQLite
|
||||
(`packages/daemon/src/domain/policies/workflow-keepalive.ts:1–5`).
|
||||
Re-confirmed at HEAD (`workflow-keepalive.ts:5–16`):
|
||||
|
||||
- **LOAD-BEARING:** it MUST read `workflow_instances` directly via SQLite —
|
||||
never the markdown source.
|
||||
- Eligibility: `status === "active" || status === "waiting"`. Else
|
||||
`action=terminal, reason="workflow_not_active"`.
|
||||
- Frontier empty + no fallback target: skip with `reason="empty_frontier"`.
|
||||
- Resolves frontier qitem owners by querying `queue_items`; combines with
|
||||
explicit observer/created-by targets; sends to the first resolved target.
|
||||
|
||||
The watchdog supervision tree itself (PL-004 Phase C, `031_watchdog_jobs.ts` /
|
||||
`032_watchdog_history.ts`) records only meaningful evaluations; quiet skip
|
||||
reasons (`not_due`, `no_actionable_artifacts`, `active_wake_not_due`) are
|
||||
NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are
|
||||
not woken about scheduler polls (`architecture.md` §3 L362). The Phase D
|
||||
policy enum extends Phase C's three values with `workflow-keepalive`.
|
||||
|
||||
## 4. Workflow events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
|
||||
> L410 says "Existing 20 PL-004 events are unchanged" — internally
|
||||
> inconsistent with L394's "32 PL-004 events untouched". **Do NOT carry
|
||||
> either number.** The current `RigEvent` union
|
||||
> (`packages/daemon/src/domain/types.ts:94`) has **73 members** total
|
||||
> (slice-00 §1.8, re-confirmed at HEAD). The additive Phase D `workflow.*`
|
||||
> events are described below WITHOUT asserting a contested PL-004 sub-count.
|
||||
|
||||
Phase D extends `RigEvent` with the additive `workflow.*` events
|
||||
(re-confirmed `domain/types.ts:196–201`): `workflow.instantiated`,
|
||||
`workflow.step_closed`, `workflow.next_qitem_projected`,
|
||||
`workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`
|
||||
(6 members; a separate `workflow_spec` event also exists in the union).
|
||||
|
||||
## 5. Route surface
|
||||
|
||||
`/api/workflow` (`server.ts:495`) — `POST /validate`
|
||||
(`routes/workflow.ts:82`), `POST /instantiate` (`:93`,
|
||||
`getRuntime(c).instantiate(...)`), `POST /project` (`:118`,
|
||||
`getRuntime(c).project(...)` — the transactional-scribe entry),
|
||||
`GET /:instance_id/trace` (instance + trail), `POST /:instance_id/continue`
|
||||
(idempotent inspect). Surface enumerated `routes/workflow.ts:21–28`.
|
||||
Cross-ref: the `rig workflow` CLI surface — see `../cli-reference.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — PL-004 Phase A; the closure authority the
|
||||
runtime projects against.
|
||||
- `mission-control.md` — PL-005 queue observability + 7-verb contract.
|
||||
- Source roots: `packages/daemon/src/domain/{workflow-projector,
|
||||
workflow-runtime,workflow-instance-store,workflow-spec-cache,
|
||||
workflow-step-trail-log,workflow-validator}.ts`,
|
||||
`packages/daemon/src/domain/policies/workflow-keepalive.ts`,
|
||||
`packages/daemon/src/routes/workflow.ts`.
|
||||
@@ -0,0 +1,416 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
|
||||
status: active
|
||||
topics: [specification-and-bundles, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how a rig declares a typed workspace (workspaceRoot / repos /
|
||||
defaultRepo / knowledgeRoot), how that block is persisted and resolved into
|
||||
whoami / node-inventory, how per-item target_repo scope is validated, or how
|
||||
the file-backed missions/slices tree is indexed and projected into the
|
||||
Project UI. Author-mode module — no prior architecture.md prose; every
|
||||
load-bearing claim is sourced to file:line at HEAD.
|
||||
siblings: [content-surfaces.md, daemon-core.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
|
||||
|
||||
The **workspace primitive (PL-007)** is the typed declaration that lets a rig
|
||||
name *where its work lives* — a workspace root, a set of named repos with
|
||||
kinds, an optional default repo, and an optional knowledge root — and have
|
||||
that surface through `whoami` / node-inventory and gate per-item repo scope.
|
||||
Alongside it, a **file-backed missions/slices tree** is indexed read-only and
|
||||
projected into the Project UI's mission / slice surfaces.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
|
||||
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
|
||||
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative to
|
||||
> `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2 commits;
|
||||
> no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
>
|
||||
> **SPLIT NOTE (§10.6).** The honest source-grounded scope of
|
||||
> `workspace-and-content-primitives` (proposed-structure.md §4.9 / D13)
|
||||
> exceeds ~400 lines across two distinct subsystem clusters with distinct
|
||||
> source roots. Per the §10.6 SPLIT DIRECTIVE it is split by subsystem
|
||||
> cluster into this module (workspace primitive + migrations 038/039 +
|
||||
> missions/projects/slices) and the sibling `content-surfaces.md`
|
||||
> (files/markdown/progress/steering). This is a transparent noted deviation
|
||||
> from ratified Q7 "18", a faithful application of the founder's own
|
||||
> ratified Q3 principle ("distinct primitive / distinct source root →
|
||||
> split"); surfaced at the slice-08 review gate, not a mid-execution
|
||||
> interrupt.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| `domain/workspace/{workspace-resolver,frontmatter-validator,default-workspace-scaffold}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| migrations `038_workspace_primitive.ts`, `039_queue_target_repo.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/slices/{slice-indexer,slice-detail-projector}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `routes/{workspace,slices,projects}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/workspace/getting-started-narrative.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
|
||||
| `routes/missions.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
|
||||
|
||||
> Source: §10.8 proof re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/daemon/src/domain/workspace/getting-started-narrative.ts`
|
||||
> and `...routes/missions.ts` both fail ("exists on disk, but not in
|
||||
> 'v0.3.0'"); all other listed paths resolve at `v0.3.0`. The workspace
|
||||
> primitive itself is a **0.3.0** feature (slice-00 0.3.0-GT §1.4, PL-007,
|
||||
> migrations 038/039) — do not back-attribute it to 0.3.1. The
|
||||
> `missions` route + the getting-started narrative scaffold are the
|
||||
> **0.3.1** additions layered on top (slice 12 mission scope; slice 21
|
||||
> onboarding-conveyor).
|
||||
|
||||
slice-00 0.3.0-GT §1.4 independently confirms the primitive shipped 0.3.0:
|
||||
workspace-primitive merge `2ce54abc` (PL-007) with migrations
|
||||
`038_workspace_primitive` + `039_queue_target_repo`, "fresh databases apply
|
||||
migrations through `039_queue_target_repo`".
|
||||
|
||||
## 1. The typed workspace declaration (PL-007) — 0.3.0
|
||||
|
||||
`RigSpec.workspace` is an **optional** typed block. Rigs without it stay valid;
|
||||
`whoami` / node-inventory return a null workspace block in that case.
|
||||
|
||||
> Source: `domain/types.ts:762-770` (`WorkspaceSpec`), `:751-758`
|
||||
> (`WorkspaceRepoSpec`), `:780` (`RigSpec.workspace?`) @HEAD.
|
||||
|
||||
| Field | Shape | Notes |
|
||||
|---|---|---|
|
||||
| `workspaceRoot` | string | Verbatim from spec |
|
||||
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time; authors may declare relative to `workspaceRoot` in YAML |
|
||||
| `defaultRepo?` | string | active repo when no env override / cwd match |
|
||||
| `knowledgeRoot?` | string | treated as `kind=knowledge` when surfaced |
|
||||
|
||||
`WorkspaceKind` is a closed 5-member union: `user`, `project`, `knowledge`,
|
||||
`lab`, `delivery`.
|
||||
|
||||
> Source: `domain/types.ts:748-749` (`WORKSPACE_KINDS` /
|
||||
> `WorkspaceKind`) @HEAD.
|
||||
|
||||
### 1.1 Persistence — migration 038 + RigRepository
|
||||
|
||||
Migration **038** adds `workspace_json TEXT` to the `rigs` table. It holds the
|
||||
typed `RigSpec.workspace` block as JSON when a rig declares one; NULL for rigs
|
||||
without a workspace block.
|
||||
|
||||
> Source: `db/migrations/038_workspace_primitive.ts:16-21`
|
||||
> (`ALTER TABLE rigs ADD COLUMN workspace_json TEXT`); doc-comment
|
||||
> `:3-14` @HEAD.
|
||||
|
||||
`RigRepository.setRigWorkspace(rigId, workspace)` persists it (UPDATE
|
||||
`workspace_json` + `updated_at`); `getRigWorkspace(rigId)` reads it back,
|
||||
JSON-parsing to `WorkspaceSpec` and returning `null` on parse failure. Both
|
||||
are **defensive no-ops** when the column is absent (`hasRigColumn` probe) —
|
||||
older test fixtures that bypass the canonical migration list don't have the
|
||||
column; the setter's contract is "best-effort persistence".
|
||||
|
||||
> Source: `domain/rig-repository.ts:98-105` (setter, column probe
|
||||
> L99), `:106-117` (getter; parse-fail → null L114-116) @HEAD.
|
||||
|
||||
The instantiator persists the block at rig-create time **only when declared**:
|
||||
|
||||
> Source: `domain/rigspec-instantiator.ts:653-655` (`if
|
||||
> (rigSpec.workspace) … setRigWorkspace(rigId, rigSpec.workspace)`) @HEAD.
|
||||
|
||||
### 1.2 Runtime resolution — workspace-resolver
|
||||
|
||||
`resolveWorkspaceContext({ spec, cwd, envOverride })` (consumed by
|
||||
`whoami-service`) returns the `WhoamiWorkspaceBlock` or `null` when no spec.
|
||||
`activeRepo` resolution: an `envOverride` (non-empty, trimmed) wins
|
||||
**verbatim** — even an unknown repo name is honored, because operators set
|
||||
`OPENRIG_TARGET_REPO` consciously (PL-007 PRD § Item 3); otherwise
|
||||
`defaultRepo` is used **only if it names a declared repo**. `knowledgeKind`
|
||||
is `"knowledge"` when `knowledgeRoot` is declared, else `null`.
|
||||
|
||||
> Source: `domain/workspace/workspace-resolver.ts:26-52` (resolver;
|
||||
> env-override-verbatim L34-43, including the "honored verbatim"
|
||||
> comment L35-36) @HEAD.
|
||||
|
||||
`whoami-service` reads the persisted spec and resolves with the query's
|
||||
`targetRepoOverride`, falling back to `process.env["OPENRIG_TARGET_REPO"]`:
|
||||
|
||||
> Source: `domain/whoami-service.ts:319-324` (`getRigWorkspace` +
|
||||
> `resolveWorkspaceContext`, env fallback L323); `whoami` returns
|
||||
> `workspace` in its payload `:335` @HEAD.
|
||||
|
||||
`resolveNodeWorkspace({ spec, cwd })` (consumed by `node-inventory`) derives a
|
||||
per-node `NodeWorkspaceInfo` by walking the node's `cwd` up the directory
|
||||
tree for the **longest-prefix repo path** that contains it; falls back to
|
||||
`knowledge` when cwd is under `knowledgeRoot`, then to the rig's
|
||||
`defaultRepo` when cwd doesn't resolve. Containment uses a `path.relative`
|
||||
boundary check (not a string `startsWith`) so `/foo/bar` does not match
|
||||
`/foo/bar-other`.
|
||||
|
||||
> Source: `domain/workspace/workspace-resolver.ts:57-95`
|
||||
> (longest-prefix L67-73; knowledge fallback L77-79; default-repo
|
||||
> fallback L82-88), `isInside` `:97-103`;
|
||||
> `domain/node-inventory.ts:397` (`workspace: resolveNodeWorkspace(...)`),
|
||||
> `:434-437` (`NodeWorkspaceInfo`) @HEAD.
|
||||
|
||||
### 1.3 Per-item repo scope — migration 039 + queue validation
|
||||
|
||||
Migration **039** adds `target_repo TEXT` to `queue_items` plus
|
||||
`idx_queue_items_target_repo`. It carries the per-item typed repo scope when
|
||||
an operator passes `--target-repo <name>`; NULL when the qitem is
|
||||
unambiguous against the rig's `default_repo` or no workspace is declared.
|
||||
Mission Control views surface the field for cross-rig handoff clarity.
|
||||
|
||||
> Source: `db/migrations/039_queue_target_repo.ts:16-22`
|
||||
> (`ALTER TABLE queue_items ADD COLUMN target_repo TEXT` +
|
||||
> `CREATE INDEX … idx_queue_items_target_repo`); doc-comment
|
||||
> `:3-14` @HEAD.
|
||||
|
||||
The queue route validates `target_repo` at the route layer against the
|
||||
**source rig's** `RigSpec.workspace.repos[]`: it parses the rig name out of
|
||||
`source_session` (`<member>@<rig>`), looks up the rig, reads
|
||||
`getRigWorkspace`, and rejects an unknown repo with `unknown_target_repo` +
|
||||
the known-repo list. It **fails open** (`{ ok: true }`) when there is no
|
||||
rigRepo, no parseable rig, the rig is unknown, or no workspace is declared —
|
||||
validation only bites when a workspace actively declares repos.
|
||||
|
||||
> Source: `routes/queue.ts:49-57` (fail-open guards), `:55`
|
||||
> (`getRigWorkspace`), `:58-66` (`unknown_target_repo` +
|
||||
> `knownRepos`) @HEAD.
|
||||
|
||||
## 2. Workspace HTTP route — frontmatter validator (0.3.0)
|
||||
|
||||
`workspaceRoutes()` (mounted `server.ts:491` as `/api/workspace`) exposes a
|
||||
**single read-only endpoint** at v0:
|
||||
|
||||
`POST /api/workspace/validate` — body `{ root, workspaceKind?, recursive?,
|
||||
requireFrontmatter?, maxFiles? }`; returns a `FrontmatterValidationReport`.
|
||||
`root` is required (400 `root_required`); an out-of-vocabulary
|
||||
`workspaceKind` is rejected 400 `invalid_workspace_kind`; validator throws
|
||||
→ 500 `validate_failed`. **No filesystem mutation.**
|
||||
|
||||
> Source: `routes/workspace.ts:23-65` (handler; root-required L35-37;
|
||||
> kind-enum L39-47; 500 L59-62); mounted `server.ts:491` @HEAD.
|
||||
|
||||
`validateWorkspaceFrontmatter()` walks a root, parses each `.md` file's
|
||||
YAML frontmatter (delimited `---` on the first line), and emits a structured
|
||||
gap report — **advisory only, never modifies files**. Gap kinds:
|
||||
`missing-required-field`, `unrecognized-status-value`, `parse-error`,
|
||||
`missing-frontmatter`. Per-kind required fields: `user`/`project` →
|
||||
`["doc"]`; `knowledge`/`lab`/`delivery` → `["doc","status","created","owner"]`.
|
||||
Valid `status` enum: `active|draft|archived|superseded`. Default behavior
|
||||
skips files without `---` silently (informal notes) unless
|
||||
`requireFrontmatter`; recurses by default; skips `node_modules` / `.git` /
|
||||
`.worktrees` / `dist` / `build`; hard cap `maxFiles` default 10000.
|
||||
|
||||
> Source: `domain/workspace/frontmatter-validator.ts:53` (status enum),
|
||||
> `:56-62` (per-kind required), `:23-27` (gap kinds), `:90-122` (walk;
|
||||
> skip-dirs L105-111), `:160-172` (missing-frontmatter behavior),
|
||||
> `:201-226` (required + status check) @HEAD.
|
||||
|
||||
CLI surface: `rig workspace validate [root]` — v0 is intentionally narrow
|
||||
(`validate` only); future versions add typed-kind authoring on the same
|
||||
walker.
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:931-941` @HEAD.
|
||||
|
||||
## 3. Default workspace scaffold (0.3.0 spine; 0.3.1 narrative layer)
|
||||
|
||||
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the
|
||||
mission-aware default workspace (`~/.openrig/workspace/` or `--root`). The
|
||||
same scaffold is used **idempotently by daemon startup** so a fresh install
|
||||
has a browsable Project workspace before the operator discovers
|
||||
`rig config init-workspace`. Canonical subdirs: `missions/`, `artifacts/`,
|
||||
`evidence/`, `progress/`, `field-notes/`, `specs/`, `dogfood-evidence/`,
|
||||
plus per-mission `missions/<id>/slices/<slice-id>` folders. Drops
|
||||
`README.md` + `STEERING.md` (placeholder) + per-mission `README.md` /
|
||||
`PROGRESS.md` + per-slice `README.md` / `PROGRESS.md` /
|
||||
`IMPLEMENTATION-PRD.md`.
|
||||
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts:44-59`
|
||||
> (`workspaceScaffoldDirs`), `:247-283` (`workspaceScaffoldFiles`),
|
||||
> `WORKSPACE_README` `:82-94`, `STEERING_PLACEHOLDER` `:96-120`;
|
||||
> CLI `packages/cli/src/commands/config-init-workspace.ts:1-16`
|
||||
> (idempotent; `--dry-run`/`--force`; never deletes operator
|
||||
> content L12-16); cli-reference workspace note "See `rig config
|
||||
> init-workspace`" `:939` @HEAD.
|
||||
|
||||
**0.3.1 narrative layer (slice 21 onboarding-conveyor).** The
|
||||
`getting-started` mission's two slices ship rich teaching content from
|
||||
`GETTING_STARTED_NARRATIVE` (the click-through-to-learn surface) including a
|
||||
`timeline.md` the slice Story tab renders via slice-06's
|
||||
`useSliceTimelineMarkdown` hook; other slices keep the boilerplate.
|
||||
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts:1-4`
|
||||
> (slice-21 header), `:153-190` (narrative branch in `sliceReadme`),
|
||||
> `:192-200` (`sliceTimeline`), `:270-279` (timeline emit); §0 proof:
|
||||
> `getting-started-narrative.ts` [email protected] ⇒ **0.3.1** @HEAD.
|
||||
|
||||
## 4. File-backed missions / slices tree
|
||||
|
||||
### 4.1 SliceIndexer (Slice Story View v0) — 0.3.0
|
||||
|
||||
`SliceIndexer` reads slice folders from a configured filesystem root. The
|
||||
**default workspace contract** is
|
||||
`workspace/missions/<mission>/slices/<slice>`; explicitly configured flat
|
||||
roots (`workspace/slices/<slice>`) remain supported for compatibility.
|
||||
A nested `slices/` child folder makes the parent a mission
|
||||
(`missionId = <mission-folder>`); a bare folder is a flat slice
|
||||
(`missionId = null`). NO new SQLite migration, NO new event type: read-only
|
||||
projection over existing tables (`queue_items`, `queue_transitions`,
|
||||
`mission_control_actions`) + dogfood-evidence directories. Time-bounded
|
||||
listing + detail caches (`invalidate()` drops both). `isReady()` is true
|
||||
when any configured slice root exists on disk.
|
||||
|
||||
> Source: `domain/slices/slice-indexer.ts:1-14` (contract + no-new-state),
|
||||
> `:174-176` (`isReady`), `:179-182` (`invalidate`), `:212-256`
|
||||
> (`readSliceLocations`; nested-vs-flat L233-253), `:62-89`
|
||||
> (`SliceListEntry` shape: `missionId` / `slicePath` / `qitemIds` /
|
||||
> `proofPacket`) @HEAD.
|
||||
|
||||
### 4.2 SliceDetailProjector (Slice Story View v0 + v1) — 0.3.0
|
||||
|
||||
Given a `SliceRecord`, the projector assembles the full per-slice payload
|
||||
across six tabs (Story, Acceptance, Decisions, Docs, Tests/Verification,
|
||||
Topology). Read-only; composes already-shipped tables +
|
||||
`workflow_specs`/`workflow_instances`/`workflow_step_trails` + slice docs on
|
||||
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase enum**
|
||||
(`discovery`/`product-lab`/`delivery`/…): `StoryEvent.phase` is now an
|
||||
open-ended string-or-null — the spec-defined `step.id` when bound to a
|
||||
`workflow_instance`, else `null` (UI groups under "Untagged"). When no
|
||||
workflow runtime is constructed the projector silently degrades to v0
|
||||
behavior (`workflowBinding=null`, `specGraph=null`).
|
||||
|
||||
> Source: `domain/slices/slice-detail-projector.ts:1-21` (six-tab
|
||||
> contract + v1 enrichment), `:18-21` (v0 phase enum REMOVED at v1);
|
||||
> startup degrade-path `startup.ts:1063-1076` (projector built with
|
||||
> `workflowRuntime?.specCache`; comment L1064-1071) @HEAD.
|
||||
|
||||
### 4.3 Slices routes — 0.3.0
|
||||
|
||||
`slicesRoutes()` (mounted `server.ts:501` as `/api/slices`):
|
||||
`GET /` (filter `all|active|done|blocked`, default `all`; `?refresh=1`
|
||||
invalidates; optional `?boundToWorkflow=<name>:<version>` lens narrows to
|
||||
slices bound to a workflow instance), `POST /refresh` (drops both indexer
|
||||
caches — no daemon restart), `GET /:name/proof-asset/*` (path-traversal
|
||||
guarded; immutable 1-day cache), `GET /:name/doc/*` (markdown for the Docs
|
||||
tab; traversal guarded), `GET /:name` (full per-tab payload). 503
|
||||
`slices_indexer_unavailable` when unwired; 503 `slices_root_not_configured`
|
||||
+ setup hint when not ready. **Route-order discipline:** literal `/` /
|
||||
`/refresh` / `/:name/proof-asset/*` / `/:name/doc/*` are registered BEFORE
|
||||
the dynamic `/:name` so they are not shadowed.
|
||||
|
||||
> Source: `routes/slices.ts:31-177` (handlers; route-order L34-35,
|
||||
> L101-102, L109-113, L166-167; 503s L38-44; `boundToWorkflow`
|
||||
> L60-86); mounted `server.ts:501` @HEAD.
|
||||
|
||||
### 4.4 Missions route — **0.3.1** (slice 12 + slice 13 + slice 18)
|
||||
|
||||
`missionsRoutes()` (mounted `server.ts:505` as `/api/missions`) is the
|
||||
mission scope data layer. `GET /:missionId` returns
|
||||
`{ missionId, missionPath, slices, workflow_spec, topology, status }` —
|
||||
slices filtered from the SliceIndexer by `missionId`; `missionPath` derived
|
||||
by going up two levels from any slice's `slicePath`; `workflow_spec` parsed
|
||||
lazily from `<missionPath>/README.md` frontmatter (same `parseWorkflowSpecRef`
|
||||
helper the slice-indexer uses); `topology.specGraph` projected via
|
||||
`projectSpecGraph(spec, null)` when the spec is cached, `{ specGraph: null }`
|
||||
when declared-but-not-cached, `null` when nothing declared; `status` read
|
||||
from README frontmatter. `POST /:missionId/complete` writes
|
||||
`status: complete` to the mission README frontmatter (idempotent; preserves
|
||||
unrelated fields) — the daemon is the audit-trail surface, the UI keeps an
|
||||
optimistic localStorage mirror. 404 `mission_not_found` when no slices match.
|
||||
|
||||
> Source: `routes/missions.ts:39-118` (handlers), `:124-142`
|
||||
> (`writeMissionStatusComplete`), `:148-150` (`computeMissionPath`
|
||||
> up-two-levels), `:171-177` (`readMissionWorkflowSpec`), `:205-215`
|
||||
> (`computeMissionTopology`); mounted `server.ts:505`; §0 proof:
|
||||
> `routes/missions.ts` [email protected] ⇒ **0.3.1** @HEAD.
|
||||
|
||||
### 4.5 Projects route — Coordination L2 classifier (PL-004 Phase B) — 0.3.0
|
||||
|
||||
`projectsRoutes()` (mounted `server.ts:492` as `/api/projects`) backs the
|
||||
`rig project` CLI verb — this is the **PL-004 Phase B project *classifier***
|
||||
(lease lifecycle + idempotent classify + operator-verb reclaim + SSE), NOT
|
||||
the Project *workspace UI* (that is the slices/missions surface above; the
|
||||
naming overlap is a documented seam). Endpoints: `POST /lease/acquire`
|
||||
(optional `evaluateDeadnessFirst` clears stale/dead leases first),
|
||||
`POST /lease/heartbeat`, `POST /reclaim-classifier`, `POST /project`
|
||||
(idempotent on `stream_item_id`), `GET /lease`, `GET /list`,
|
||||
`GET /sse` + `GET /watch` (SSE), `GET /:projectId`. **Route-order
|
||||
discipline:** literal `/lease`, `/list`, `/sse`, `/watch` are registered
|
||||
BEFORE the bare `/:projectId` catchall (Phase A R1 SSE lesson).
|
||||
|
||||
> Source: `routes/projects.ts:18-194` (handlers; route-order note
|
||||
> L15-17, L140-141, L158-159, L185); error-code → HTTP mapping
|
||||
> `:31-52`; mounted `server.ts:492` @HEAD.
|
||||
|
||||
## 5. UI route-reality (§10.7 — source-verified at routes.tsx@HEAD)
|
||||
|
||||
`packages/ui/src/routes.tsx` is **542 lines** and uses TanStack Router
|
||||
(`createRoute({ path, component })` objects), NOT JSX `<Route>`. The
|
||||
Phase-8.1 survey's grep-derived missions/projects expectations are
|
||||
corrected here against routes.tsx reality:
|
||||
|
||||
| Survey expectation | routes.tsx@HEAD reality | routes.tsx:NN |
|
||||
|---|---|---|
|
||||
| `/project` (workspace) | **REAL route** — `WorkspaceScopePage` | `:128-131` |
|
||||
| `/project/mission/$missionId` | **REAL route** — `MissionScopePage` | `:134-137` |
|
||||
| `/project/slice/$sliceId` | **REAL route** — `SliceScopePage` | `:140-143` |
|
||||
| `/files` | **REAL route** — `FilesWorkspace` (see content-surfaces.md) | `:192-195` |
|
||||
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (DELETED per SC-18; the Mission Control *system* lives at daemon/PL-005, not a UI destination) | `:440-444` |
|
||||
| `/slices` | **`<Navigate to="/project" />`** redirect-stub (DELETED per project-tree.md) | `:447-451` |
|
||||
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId" />`** redirect-stub | `:453-460` |
|
||||
| `/progress` | **`<Navigate to="/project" />`** redirect-stub (folds into Project tabs, Phase 3) | `:464-468` |
|
||||
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into Project workspace overview tab, Phase 3) | `:470-474` |
|
||||
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | (absent) |
|
||||
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md §4) | (absent) |
|
||||
|
||||
> Source: `packages/ui/src/routes.tsx:90-195` (real routes),
|
||||
> `:435-474` (redirect-stub block; each `<Navigate>` + DELETED/folds
|
||||
> comment), `:476-490` (route tree) @HEAD. Grep-confirmed absent:
|
||||
> no `path: "/missions"` / `path: "/markdown"` declaration anywhere
|
||||
> in routes.tsx @HEAD.
|
||||
|
||||
Net §10.7 ruling: the daemon `/api/missions` + `/api/slices` +
|
||||
`/api/projects` routes are real and load-bearing; the **operator-facing UI**
|
||||
consumes them through `/project*` destinations only. `/mission-control`,
|
||||
`/slices`, `/progress`, `/steering` are redirect-stubs (the systems exist at
|
||||
the daemon layer, the URLs were collapsed into `/project` / `/for-you`).
|
||||
|
||||
## 6. Cross-cutting properties
|
||||
|
||||
- **Optional + valid-without:** rigs without a `workspace` block stay valid;
|
||||
whoami / node-inventory / queue-target-repo all return null / fail-open
|
||||
(`workspace-resolver.ts:32`; `rig-repository.ts:99,107`;
|
||||
`routes/queue.ts:55`) @HEAD.
|
||||
- **Defensive column probes:** every `workspace_json` / `target_repo` access
|
||||
guards on column presence so partial test fixtures don't crash
|
||||
(`rig-repository.ts:99,107`; migrations 038/039 ship separately so a
|
||||
fixture can apply only the half it needs — `038.ts:11-14`) @HEAD.
|
||||
- **Read-only projection, no new state for the tree:** SliceIndexer /
|
||||
SliceDetailProjector add NO migration / event type; only the workspace
|
||||
declaration (038) + per-item scope (039) touch SQLite
|
||||
(`slice-indexer.ts:12-14`) @HEAD.
|
||||
- **Advisory, never mutates:** the frontmatter validator and the
|
||||
init-workspace scaffold never delete operator content
|
||||
(`frontmatter-validator.ts:12-13`; `config-init-workspace.ts:12-16`) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — `resolveWorkspaceContext` honors an `envOverride`
|
||||
**verbatim even when it names no declared repo** (`workspace-resolver.ts:34-43`,
|
||||
by explicit PL-007 PRD § Item 3 design), whereas the queue route
|
||||
**rejects** an unknown `target_repo` against the same `repos[]`
|
||||
(`routes/queue.ts:58-66`). These two surfaces apply opposite policies to
|
||||
an unknown repo name (whoami trusts the operator; queue validates). Stated
|
||||
as-is — the asymmetry is source-real and intentional per the cited
|
||||
comments, but the divergence is not reconciled in source.
|
||||
- **OPEN-B** — the `/api/projects` *classifier* (PL-004 Phase B) and the
|
||||
`/project*` *workspace UI* (slices/missions) share the word "project" but
|
||||
are unrelated subsystems. Documented as a naming seam; no source defect.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration-count / route-group / PL-004-event-count claim (those land in
|
||||
daemon-core / coordination-primitive / architecture-rules).
|
||||
@@ -1,6 +1,21 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: OpenRig CLI Reference — Full rig Command Surface
|
||||
status: active
|
||||
topics: [runtime-control, agent-runtime]
|
||||
domains: [operating-advisor, engineering-advisor, orchestrator]
|
||||
applies-when: |
|
||||
Need the exact rig CLI surface — command groups, subcommands, flags,
|
||||
JSON output, cross-host, coordination primitives.
|
||||
siblings: [README.md, architecture/daemon-core.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 4f4db0fe
|
||||
last-updated: 2026-06-02
|
||||
---
|
||||
|
||||
# OpenRig CLI Reference
|
||||
|
||||
Verified against the shipped CLI on 2026-05-15 (v0.3.1) using:
|
||||
Verified against the shipped CLI on 2026-06-02 (v0.3.2) using:
|
||||
- `packages/cli/src/index.ts`
|
||||
- `packages/cli/src/commands/*.ts`
|
||||
- `packages/cli/src/mcp-server.ts`
|
||||
@@ -11,7 +26,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
## Overview
|
||||
|
||||
- Binary: `rig`
|
||||
- Top-level command groups: `57`
|
||||
- Top-level command groups: `59`
|
||||
- Output mode: human-readable by default; many commands also support `--json`
|
||||
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
|
||||
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
|
||||
@@ -78,6 +93,8 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `context-pack` | Browse, preview, send, and install operator-authored context packs |
|
||||
| `workspace` | Workspace primitive — typed-kind tooling (frontmatter validation) |
|
||||
| `plugin` | Inspect plugins (read-only) — list, show, used-by, validate |
|
||||
| `scope` | Scope tree primitive — missions, slices, sub-slices |
|
||||
| `policy` | Operator context-mode bindings (sleep/desk/mobile/away/focus/debug) |
|
||||
|
||||
## Core Daemon and System Commands
|
||||
|
||||
@@ -225,7 +242,7 @@ Notes:
|
||||
|
||||
### `rig up`
|
||||
|
||||
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
|
||||
Usage: `rig up <source> [--plan] [--yes] [--cwd <path>] [--target <root>] [--existing] [--json]`
|
||||
|
||||
Arguments:
|
||||
- `source`: path to `.yaml` or `.rigbundle`, or a bare name
|
||||
@@ -239,10 +256,13 @@ Actual source resolution:
|
||||
- if both a library spec and existing rig share the same name, exits with an ambiguity error
|
||||
|
||||
Current behavior notes:
|
||||
- `--cwd <path>` overrides launch working directory for all members for this run only. For path-form `rig up <install-internal-spec>` invocations the CLI defaults `cwd` to the caller's directory (slice-22 Bug 3) so library specs match path-form behavior.
|
||||
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
|
||||
- `--existing` skips the library-spec name resolution and treats `<source>` as an existing rig name directly (disambiguates when a library spec and a stopped rig share the same name).
|
||||
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
|
||||
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
|
||||
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
|
||||
- v0.3.2 paper-cut fix-round (slice-22): pre-launch failures now return structured HTTP 4xx (`cycle_error` / `preflight_failed` / `validation_failed` / `service_boot_failed`) instead of bare 500; failed boots no longer leave orphan rig records on disk.
|
||||
|
||||
Success modes:
|
||||
- fresh boot
|
||||
@@ -364,12 +384,15 @@ Notes:
|
||||
Usage: `rig bundle <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `create <spec> -o <path> [--name <name>] [--bundle-version <ver>] [--include-packages <refs...>] [--rig-root <root>] [--json]`
|
||||
- `inspect <path> [--json]`
|
||||
- `install <path> [--plan] [--yes] [--target <root>] [--json]`
|
||||
- `create <spec> -o <path> [--name <name>] [--bundle-version <ver>] [--include-packages <refs...>] [--rig-root <root>] [--notes <text>] [--min-daemon-version <ver>] [--min-cli-version <ver>] [--json]` — pack a rig spec + its declared content into a `.rigbundle`. v0.3.2 slice-05 ships first-class cross-primitive bundling: skills + plugins (hybrid) + workflow_specs + context_packs + agent_images vendor end-to-end with both-sides path containment, symlink escape protection, and integrity hashing.
|
||||
- `inspect <path> [--json]` — inspect a `.rigbundle` manifest. v0.3.2 surfaces the cross-primitive content fields as first-class.
|
||||
- `install <path> [--plan] [--yes] [--target <root>] [--skip-version-check] [--force] [--json]` — install a `.rigbundle`. Routes each declared content kind to its canonical library under `$OPENRIG_HOME`. `--skip-version-check` is an operator-explicit override of the install-time daemon/CLI compatibility gate (NOT recommended). `--force` is an operator-explicit override of the install-time conflict check (NOT recommended; conflicts may produce partial install state).
|
||||
- `history [--rig <name>] [--since <iso>] [--json]` — list bundle install audit records from `~/.openrig/bundle-audit.jsonl`. Filters by target rig name and earliest `installedAt`.
|
||||
|
||||
Important:
|
||||
- `bundle create` requires `-o, --output <path>` by source definition.
|
||||
- v0.3.2 install timeout bumped (was 5s — too short for tmux-session-bootstrapping installs).
|
||||
- Deferred to 0.3.3 (per release packet): agent/port/managed-app collision detection (Item 4.3), broader install-into-existing-rig pathway acceptance (Item 4.4), and the `--target-name` CLI flag (slice-05 Item-3 sub-scopes; design-contingent on CLI surface decision).
|
||||
|
||||
### `rig package` (legacy)
|
||||
|
||||
@@ -721,7 +744,7 @@ Notes:
|
||||
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
|
||||
|
||||
Subcommands:
|
||||
- `create --source <session> --destination <session> --body <text> [--priority <p>] [--tier <t>] [--tags <csv>] [--expires-at <iso>] [--id <qitemId>] [--json]`
|
||||
- `create --source <session> --destination <session> (--body <text> | --body-file <path>) [--mission <id>] [--slice <id>] [--priority <p>] [--tier <t>] [--tags <csv>] [--target-repo <name>] [--no-nudge] [--expires-at <iso>] [--id <qitemId>] [--json]` — v0.3.2 slice-21 FR-4 adds `--body-file <path>` (use `-` for stdin) which kills the backtick-shell-corruption class for multiline bodies, and first-class `--mission <id>` / `--slice <id>` flags that translate to `mission:<id>` / `slice:<id>` tags (compose with `--tags`). Exactly one of `--body` / `--body-file` must be provided.
|
||||
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
|
||||
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
|
||||
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
|
||||
@@ -868,7 +891,7 @@ Notes:
|
||||
|
||||
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only."
|
||||
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture.md` § Mission Control / Queue Observability. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture/mission-control.md`. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
|
||||
Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
|
||||
|
||||
@@ -917,10 +940,11 @@ Notes:
|
||||
Usage: `rig workspace <subcommand>` — Workspace Primitive (PL-007), v0 typed-kind tooling.
|
||||
|
||||
Subcommands:
|
||||
- `validate [root]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`.
|
||||
- `validate [root] [--kind <kind>] [--no-recursive] [--require-frontmatter] [--max-files <n>] [--json]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`. `--kind` validates against a specific workspace kind (`user | project | knowledge | lab | delivery`). `--max-files` (default `10000`) hard-caps the walk; v0.3.2 slice-01 GA enforces strict-int regex on this flag.
|
||||
- `doctor [--workspace <path>] [--strict] [--json]` — v0.3.2 slice-21 FR-5. Run a 7-check workspace-readiness diagnostic against the daemon's resolved workspace (workspace root, missions folder, file allowlist, daemon alignment, daemon reload, optional slice docs, MISSION_NOTES presence). Read-only. Default exit-code: non-zero only on `fail`; `--strict` makes warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST` from the operator's shell env so doctor reflects the operator's intended allowlist even when the daemon's env is unchanged.
|
||||
|
||||
Notes:
|
||||
- v0 surface is intentionally narrow (`validate` only) — read-only frontmatter audit.
|
||||
- v0 surface was intentionally narrow (`validate` only); v0.3.2 added `doctor` as the operator-facing readiness diagnostic.
|
||||
- Future versions will add typed-kind authoring/refactor tooling on the same root walker.
|
||||
- See `rig config init-workspace` to scaffold a fresh default workspace.
|
||||
|
||||
@@ -946,6 +970,67 @@ Notes:
|
||||
- `openrig-core` ships bundled with the daemon (11 skills). Additional plugins (`gstack` — 45 skills; `obra-superpowers` — 14 skills) ship as substrate references for plugin authors to copy-install per the `OPENRIG-INSTALL.md` workflow inside each plugin's source tree.
|
||||
- A first-class `rig plugin install <substrate-path>` verb is deferred to 0.3.2.
|
||||
|
||||
## Scope Tree Primitive (v0.3.2)
|
||||
|
||||
One top-level command first shipped in v0.3.2 (`scopeCommand`,
|
||||
`packages/cli/src/index.ts:20,187`; defined in
|
||||
`packages/cli/src/commands/scope.ts`; release-0.3.2 slice 12). Operates
|
||||
the scope tree (missions, slices, sub-slices) per
|
||||
`conventions/scope-and-versioning`.
|
||||
|
||||
### `rig scope`
|
||||
|
||||
Usage: `rig scope <subcommand>` — scope tree primitive: missions, slices, sub-slices.
|
||||
|
||||
Top-level option:
|
||||
- `--workspace <path>` — override workspace root (otherwise inferred from cwd or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
Two subcommand groups: `slice` and `mission`.
|
||||
|
||||
`rig scope slice <subcommand>` — slice-tier commands:
|
||||
- `ls [--mission <name>] [--state <state>] [--json]` — list slices in a mission (or across all missions). `--state` filter: `active | closed | shipped | all` (default `active`).
|
||||
- `show <slice-path> [--mission <name>] [--json]` — inspect a single slice (frontmatter + README + children). `slice-path` is absolute, relative-to-substrate, or `NN-slug`; `--mission` hints the mission when path is just `NN-slug`.
|
||||
- `create <mission> <slug> [--template <kind>] [--title <text>] [--json]` — create a new slice in a mission. `--template` default `placeholder`.
|
||||
- `ship <slice-path> <release-mission> [--mission <name>] [--json]` — ship a slice to a release mission (preserves git history).
|
||||
- `close <slice-path> [--note <text>] [--mission <name>] [--json]` — close a slice (move to `<mission>/closed/`, update status). `--note` is an optional closure note.
|
||||
- `move <slice-path> <dest-mission> [--mission <name>] [--json]` — move a slice between missions (re-numbers in destination).
|
||||
|
||||
`rig scope mission <subcommand>` — mission-tier commands:
|
||||
- `ls [--json]` — list missions (top-level folders with `README.md`).
|
||||
- `show <mission> [--json]` — inspect a single mission.
|
||||
- `create <name> [--template <kind>] [--id <dot-id>] [--title <text>] [--json]` — create a new mission (mints a stable dot-ID into frontmatter). `--template` auto-selects when name matches `release-X.Y.Z`; `--id` overrides name-pattern inference.
|
||||
|
||||
Notes:
|
||||
- Surface source-verified against `packages/cli/src/commands/scope.ts` at `4f4db0fe` (v0.3.2).
|
||||
|
||||
## Operator Context-Mode Bindings (v0.3.2)
|
||||
|
||||
One top-level command first shipped in v0.3.2 (`rigPolicyCommand`,
|
||||
defined in `packages/cli/src/commands/rig-policy.ts`; release-0.3.2
|
||||
slice 09). Pairs with the daemon's typed-primitive store at
|
||||
`packages/daemon/src/db/migrations/041_rig_policy.ts`. Operates the
|
||||
operator context-mode binding surface (sleep / desk / mobile / away /
|
||||
focus / debug) used by mode-aware agent posture.
|
||||
|
||||
### `rig policy`
|
||||
|
||||
Usage: `rig policy <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `set <mode> [--scope <scope>] [--qualifier <id>] [--<field> ...] [--evidence <citation>] [--confirm] [--bearer <token>] [--json]` — propose a binding. Without `--confirm` the CLI echoes the proposed binding and exits with `exit 2` so scripts cannot accidentally apply; `--confirm` is the explicit operator action. `<scope>` is one of `global_host | rig | workstream | qitem` (defaults to the per-mode recommendation). `--qualifier <id>` is required for `rig | workstream | qitem` scopes and rejected for `global_host`. Per-field tuning flags: `--autonomy-scope`, `--heartbeat-cadence`, `--inspection-depth`, `--update-detail`, `--escalation-threshold`, `--concurrency-limit`, `--permission-prompt-posture` (one of `normal | batch_for_human | do_not_prompt_unless_blocked`; `auto_accept` is FORBIDDEN by convention), `--expiry-or-stale-rule`. `--evidence` carries a free-text operator citation (message id, file pointer, chatroom topic, etc.).
|
||||
- `show [--json]` — list all operator-context-mode bindings.
|
||||
- `effective [--rig <id>] [--workstream <id>] [--qitem <id>] [--json]` — resolve the effective mode for a (rig, workstream, qitem) read context. Surfaces `unknown_posture` when no binding matches.
|
||||
- `cite [--rig <id>] [--workstream <id>] [--qitem <id>]` — emit the short-prose citation line for the effective mode at the read context (per convention §Citation Rules).
|
||||
- `unset <scope> [qualifier] [--bearer <token>] [--json]` — delete one binding (operator-only).
|
||||
- `defaults [--json]` — print the recommended per-mode 6×7 field defaults + default-scope mapping + stale rule.
|
||||
|
||||
Notes:
|
||||
- The 6 modes are `sleep | desk | mobile | away | focus | debug`. Bare-word invocation is accepted (`set desk`); `mode:<word>` is the disambiguated prefix form.
|
||||
- Restate-and-confirm posture (HG-4): `set` is restate-only until `--confirm` is passed. This prevents accidental script application.
|
||||
- `--qualifier` strict reject for `global_host` (HG-7 guard finding): operators who type `--scope global_host --qualifier <id>` get an error and the daemon is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
- Operator-edit mutations (`set --confirm`, `unset`) require an operator bearer token (`--bearer` or `OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
- Surface source-verified against `packages/cli/src/commands/rig-policy.ts` at `4f4db0fe` (v0.3.2).
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
|
||||
+168
-399
@@ -1,425 +1,194 @@
|
||||
# OpenRig Source Codemap
|
||||
---
|
||||
kind: as-built
|
||||
title: OpenRig Codemap — Navigation Index / Map of Territory
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
You know what you need to learn about the shipped OpenRig system but not
|
||||
which as-built module holds it. Use the module map, the use-case lookup,
|
||||
or the source-root table to route to the right doc — or jump straight to
|
||||
the source root the codemap points at.
|
||||
siblings: [README.md, cli-reference.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
|
||||
# OpenRig Codemap — Navigation Index
|
||||
|
||||
Current footprint:
|
||||
- daemon: 211 source files (126 domain, 31 routes, 11 adapters, 36 migrations, 7 other)
|
||||
- CLI: 78 source files
|
||||
- UI: 87 source files
|
||||
- total: 376 source files
|
||||
This is a **map of territory**, not a content dump. It tells you which
|
||||
as-built module answers a question and which `packages/*/src` root that
|
||||
module is grounded in. The deep content lives in the modules; the code lives
|
||||
in the source roots; this file points at both.
|
||||
|
||||
How to use this doc:
|
||||
- `Exports` lists the public symbols defined by the file.
|
||||
- `Related` lists the file's primary local outgoing dependencies so you can follow the code path quickly.
|
||||
- The package/area groupings are structural, not ownership metadata.
|
||||
> The old codemap was a 425-line file-by-file `Exports:`/`Related:` flat dump
|
||||
> that drifted fastest (its own header carried a stale v0.2.0 footprint and an
|
||||
> internally-contradictory command-group count) and never fit a context
|
||||
> window. That anti-pattern is retired (slice-08 Q5, founder-ratified). The
|
||||
> genuinely-useful part — the source-root index — is preserved below as the
|
||||
> source-root pointer table; the per-file transcription is not.
|
||||
|
||||
Fast lookup:
|
||||
- Daemon app wiring and mounted HTTP surface: `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/routes/*`
|
||||
- AgentSpec / pod-aware engine: `packages/daemon/src/domain/agent-*`, `profile-resolver.ts`, `projection-planner.ts`, `startup-*`, `rigspec-*`, `pod-repository.ts`
|
||||
- Rig environment / managed-app substrate: `packages/daemon/src/adapters/compose-services-adapter.ts`, `packages/daemon/src/domain/service-orchestrator.ts`, `packages/daemon/src/domain/services-readiness.ts`, `packages/daemon/src/routes/env.ts`
|
||||
- Snapshot / restore / continuity: `packages/daemon/src/domain/snapshot-*`, `checkpoint-store.ts`, `restore-orchestrator.ts`
|
||||
- Transport / communication: `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/routes/transport.ts`
|
||||
- Transcripts / persistence: `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/domain/history-query.ts`, `packages/daemon/src/routes/transcripts.ts`
|
||||
- Ask / context packs: `packages/daemon/src/domain/ask-service.ts`, `packages/daemon/src/routes/ask.ts`
|
||||
- Chat: `packages/daemon/src/domain/chat-repository.ts`, `packages/daemon/src/routes/chat.ts`
|
||||
- Spec review / library / identity: `packages/daemon/src/domain/spec-review-service.ts`, `packages/daemon/src/domain/spec-library-service.ts`, `packages/daemon/src/domain/whoami-service.ts`, `packages/daemon/src/routes/spec-review.ts`, `packages/daemon/src/routes/spec-library.ts`, `packages/daemon/src/routes/whoami.ts`
|
||||
- Node inventory: `packages/daemon/src/domain/node-inventory.ts`
|
||||
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
|
||||
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
|
||||
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (53 groups)
|
||||
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
|
||||
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
|
||||
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
|
||||
- UI spec/library surfaces: `packages/ui/src/components/RigSpecDisplay.tsx`, `packages/ui/src/components/AgentSpecDisplay.tsx`, `packages/ui/src/components/LibraryReview.tsx`, `packages/ui/src/components/SpecsWorkspace.tsx`
|
||||
- UI managed-app runtime surfaces: `packages/ui/src/components/RigEnvPanel.tsx`, `packages/ui/src/hooks/useRigEnv.ts`, `packages/ui/src/lib/build-setup-prompt.ts`
|
||||
- UI live identity surfaces: `packages/ui/src/components/LiveIdentityDisplay.tsx`, `packages/ui/src/components/LiveNodeDetails.tsx`
|
||||
- UI data layer: `packages/ui/src/hooks/*`
|
||||
## (a) Product frame
|
||||
|
||||
## Daemon
|
||||
OpenRig is a local control plane for multi-agent coding topologies — a
|
||||
multi-agent harness that manages your Claude Code and Codex sessions as a
|
||||
single system. The daemon (`@openrig/daemon`) is the framework-free
|
||||
SQLite-backed core; the CLI (`@openrig/cli`), UI (`@openrig/ui`), and MCP
|
||||
server all sit on top of it.
|
||||
|
||||
### Entrypoints And Wiring
|
||||
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
|
||||
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
|
||||
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 36 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, Final Stretch 3 managed-app services, and PL-004 coordination services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
> Source-verified at HEAD `7eaf524c` (`v0.3.1-6-g7eaf524c`); package version
|
||||
> **0.3.1**, 6 unreleased 0.3.2 commits on HEAD, no `v0.3.2` tag. Frame
|
||||
> sentence triangulated against `architecture/daemon-core.md` §1 +
|
||||
> `openrig-internal/product/positioning.md` ("a multi-agent harness, a local
|
||||
> control plane that manages your Claude Code and Codex sessions as a single
|
||||
> system").
|
||||
|
||||
### Adapters
|
||||
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/claude-resume.ts`: Resume adapter for claude sessions. Exports: ClaudeResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux-transport.ts`: Adapter/util module for cmux transport integration. Exports: createCmuxCliTransport. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux.ts`: Adapter/util module for cmux integration. Exports: CmuxAdapter, CmuxResult, CmuxStatus, CmuxSurface, CmuxTransport, CmuxTransportFactory, CmuxWorkspace. Related: none.
|
||||
- `packages/daemon/src/adapters/compose-services-adapter.ts`: Compose-backed rig-environment adapter for `up`, `ps`, `logs`, and teardown. Exports: ComposePsService, ComposeServicesAdapter, ComposeServicesUpParams, ComposeServicesUpResult, ComposeServicesLogsParams, ComposeServicesLogsResult, ComposeServicesDownParams, ComposeServicesDownResult. Related: `packages/daemon/src/adapters/tmux-exec.js`.
|
||||
- `packages/daemon/src/adapters/codex-resume.ts`: Resume adapter for codex sessions. Exports: CodexResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`: Adapter implementation for codex runtime integration. Exports: CodexAdapterFsOps, CodexRuntimeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/terminal-adapter.ts`: Infrastructure runtime adapter for terminal/shell nodes. No-op project/deliver/launch, immediate readiness. Exports: TerminalAdapter. Related: `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/shell-quote.ts`: Adapter/util module for shell quote integration. Exports: shellQuote. Related: none.
|
||||
- `packages/daemon/src/adapters/tmux-exec.ts`: Adapter/util module for tmux exec integration. Exports: execCommand. Related: `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/tmux.ts`: Adapter/util module for tmux integration. Exports: ExecFn, TmuxAdapter, TmuxPane, TmuxResult, TmuxSession, TmuxWindow. Related: none.
|
||||
## (b) Module map
|
||||
|
||||
### DB Core
|
||||
- `packages/daemon/src/db/connection.ts`: Creates the SQLite connection with daemon-wide pragmas. Exports: createDb. Related: none.
|
||||
- `packages/daemon/src/db/migrate.ts`: Migration runner and `Migration` type shared by all schema files. Exports: Migration, migrate. Related: none.
|
||||
One row per module. "Reach for this when…" mirrors each module's actual
|
||||
`applies-when` frontmatter.
|
||||
|
||||
### DB Migrations
|
||||
- `packages/daemon/src/db/migrations/001_core_schema.ts`: Database migration for core schema. Exports: coreSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/002_bindings_sessions.ts`: Database migration for bindings sessions. Exports: bindingsSessionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/003_events.ts`: Database migration for events. Exports: eventsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/004_snapshots.ts`: Database migration for snapshots. Exports: snapshotsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/005_checkpoints.ts`: Database migration for checkpoints. Exports: checkpointsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/006_resume_metadata.ts`: Database migration for resume metadata. Exports: resumeMetadataSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/007_node_spec_fields.ts`: Database migration for node spec fields. Exports: nodeSpecFieldsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/008_packages.ts`: Database migration for packages. Exports: packagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/009_install_journal.ts`: Database migration for install journal. Exports: installJournalSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/010_journal_seq.ts`: Database migration for journal seq. Exports: journalSeqSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/011_bootstrap.ts`: Database migration for bootstrap. Exports: bootstrapSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/012_discovery.ts`: Database migration for discovery. Exports: discoverySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/013_discovery_fk_fix.ts`: Database migration for discovery fk fix. Exports: discoveryFkFix. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/014_agentspec_reboot.ts`: Database migration for agentspec reboot. Exports: agentspecRebootSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/015_startup_context.ts`: Database migration for startup context. Exports: startupContextSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/016_chat_messages.ts`: Database migration for durable rig-scoped chat messages. Exports: chatMessagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/017_pod_namespace.ts`: Database migration that persists authored pod namespace separately from pod DB id for export, adoption, and identity parity. Exports: podNamespaceSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/021_seat_handover_observability.ts`: Database migration for seat-handover observability fields. Exports: seatHandoverObservabilitySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/022_node_codex_config_profile.ts`: Database migration adding Codex config-profile field on nodes. Exports: nodeCodexConfigProfileSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/023_stream_items.ts`: PL-004 Phase A — L1 stream items table (append-only intake/audit root). Exports: streamItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/024_queue_items.ts`: PL-004 Phase A — L3 queue items table (owned-work queue with closure obligation). Exports: queueItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/025_queue_transitions.ts`: PL-004 Phase A — append-only queue state transition log. Exports: queueTransitionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/026_inbox_entries.ts`: PL-004 Phase A — mailbox-style inbox for asynchronous deposit. Exports: inboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/027_outbox_entries.ts`: PL-004 Phase A — sender-side outbox audit. Exports: outboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/028_project_classifications.ts`: PL-004 Phase B — L2 project classifications table (idempotent on stream_item_id; FK to stream_items). Exports: projectClassificationsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/029_classifier_leases.ts`: PL-004 Phase B — single-writer classifier lease table (partial UNIQUE INDEX on state='active'). Exports: classifierLeasesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/030_views_custom.ts`: PL-004 Phase B — custom view registry. Exports: viewsCustomSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/031_watchdog_jobs.ts`: PL-004 Phase C — watchdog jobs (canonical scheduler state; policy enum + intervals + lifecycle). Exports: watchdogJobsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/032_watchdog_history.ts`: PL-004 Phase C — append-only watchdog evaluation history (sent/skipped/terminal; not_due polls not recorded). Exports: watchdogHistorySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/033_workflow_specs.ts`: PL-004 Phase D — workflow specs (cached read-through from markdown/YAML; UNIQUE on name+version; source_hash invalidation). Exports: workflowSpecsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/037_mission_control_actions.ts`: PL-005 Phase A — Mission Control action audit table (append-only; 7-verb enum at app layer; before/after qitem snapshots; FK to queue_items). Exports: missionControlActionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
### architecture/
|
||||
|
||||
### Domain: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-action-log.ts`: Append-only writer for mission_control_actions. API surface lacks update/delete/remove (contract enforcement). Validates verb-specific required fields (annotate→annotation, hold/drop→reason). Exports: MissionControlActionLog, MissionControlActionLogError, MissionControlVerb, MISSION_CONTROL_VERBS. Related: `packages/daemon/src/db/migrations/037_mission_control_actions.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-write-contract.ts`: LOAD-BEARING. Atomic 7-verb action coordinator. Each verb is one db.transaction: queue mutation via Phase D's QueueRepository.updateWithinTransaction (preserves Phase A hot-potato closure validation; emits queue.updated) + audit row + persisted mission_control.action_executed event. The 4-step handoff shape (source-update + destination-create + opt-in best-effort notify + audit append) is verified atomic; notify failure does NOT roll back durable state. Exports: MissionControlWriteContract, MissionControlWriteContractError, MissionControlActionInput, MissionControlActionResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/mission-control/mission-control-action-log.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-read-layer.ts`: Maps 7 Mission Control views to source-of-truth paths. my-queue/human-gate/active-work/recent-ships query queue_items via QueueRepository; fleet uses fleetCliCapability roll-up; recently-active delegates to PL-004 Phase B ViewProjector.show("recently-active"); recent-observations reads stream_items via StreamStore. All rows return the 9-field phone-friendly content model (PRD non-negotiable). Exports: MissionControlReadLayer, MissionControlViewName, CompactStatusRow, MISSION_CONTROL_VIEWS. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/view-projector.js`, `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts`: Per-rig CLI capability cache + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: per-field availability check, per-rig honesty, once-per-session-per-rig logging, "rigs running stale CLI" indicator. Synthesizes per-rig activity state (active|idle|attention|blocked|degraded) from queue_items aggregation. Exports: MissionControlFleetCliCapability, FleetRollupRow, FleetRollup, MISSION_CONTROL_DESIRED_FIELDS. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/db/migrations/024_queue_items.js`.
|
||||
- `packages/daemon/src/routes/mission-control.ts`: Hono route module for `/api/mission-control` (`GET /views` literal list; `GET /views/:view-name` view fetch; `POST /action` 7-verb atomic execute; `GET /sse` + `GET /watch` SSE; `GET /cli-capabilities` fleet drift cache). SSE/literal paths mounted before /views/:view-name catchall per PL-004 Phase A R1 route-order discipline. Exports: missionControlRoutes. Related: `packages/daemon/src/domain/mission-control/*`, `packages/daemon/src/domain/event-bus.js`.
|
||||
| Module | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `daemon-core.md` | The framework-free SQLite-backed core the CLI/UI/MCP all sit on. | You need to know how the daemon boots, how `createDaemon` wires the dependency graph, the SQLite schema/migration set, or the route-mount surface. |
|
||||
| `adapters-and-runtimes.md` | The five-method runtime-adapter contract + the resume-honesty layer. | You need the runtime-adapter contract, how OpenRig launches/resumes a Claude Code, Codex, or terminal harness inside tmux, or how the daemon honestly assesses whether a harness actually resumed vs fresh-launched. |
|
||||
| `coordination-primitive.md` | PL-004 Phase A: the SQLite-canonical durable-work layer (`/api/stream`, `/api/queue`). | You need the stream/queue/inbox/outbox tables, the hot-potato closure contract, the transactional handoff guarantee, or where queue closure is enforced. |
|
||||
| `workflow-runtime.md` | PL-004 Phase D: turns an intended sequence of work into durable SQLite state. | You need workflow specs cache, instance state, step trails, the transactional-scribe projection contract, or the watchdog policy set including workflow-keepalive. |
|
||||
| `mission-control.md` | PL-005: the daemon-backed queue-observability surface inside the existing shell. | You need the seven views, the seven write verbs, the action audit table, the bearer-token middleware, or how queue observability maps to PL-004 sources. |
|
||||
| `agent-spec-and-startup.md` | How authored YAML specs become a resolved, launched, identity-addressable topology. | You need the AgentSpec/RigSpec/pod-aware reboot types, profile resolution + additive startup layering, the StartupOrchestrator delivery split, or how whoami/materialize/bind/adopt resolve identity. |
|
||||
| `lifecycle-snapshot-restore.md` | The durable-state half of `down → up → handoff`. | You need how OpenRig captures a snapshot, restores a rig (resume vs rebuild vs fresh), enforces restore honesty, consults live continuity, or how restore-check / restore-packet readiness probes work. |
|
||||
| `transport-and-transcripts.md` | The communication-and-history layer; tmux is transport, not truth. | You need how rig send/capture/broadcast works, pipe-pane transcript capture + search, durable SQLite chat, what `rig ask` gathers, or the MCP-tool-name vs tmux-metadata-key distinction. |
|
||||
| `workspace-primitive.md` | PL-007: the typed declaration of where a rig's work lives. | You need how a rig declares a typed workspace (root/repos/defaultRepo/knowledgeRoot), how it persists/resolves into whoami / node-inventory, how per-item `target_repo` scope is validated, or how the file-backed missions/slices tree is indexed and projected into Project. |
|
||||
| `content-surfaces.md` | The operator-allowlisted, filesystem-canonical read/write layer Project/Steering sits on. | You need how the file browser enforces path safety, how atomic conflict-checked writes + the JSONL edit audit work, how the PROGRESS.md tree is indexed, or how the one-screen Steering surface is composed. |
|
||||
| `plugin-agent-image-context-pack.md` | Four filesystem-canonical content primitives the daemon discovers and serves. | You need how OpenRig discovers plugins, captures/forks agent images, assembles/sends context packs, or how the Claude auto-compaction enforcer decides to send `/compact`. |
|
||||
| `packaging-bootstrap-bundles.md` | How a topology is packaged into a shareable bundle and reconstituted elsewhere. | You need how rig/pod bundles are assembled (schema-v2 vs legacy v1), how bundle create/inspect/install + `/api/up` route across source kinds, the staged BootstrapOrchestrator flow, or which legacy install-engine seams still ship. |
|
||||
| `architecture-rules-and-event-system.md` | The cross-cutting invariants that belong to no single subsystem. | You need the 25 architecture rules + startup/import constraints, the shape of the RigEvent union and its SSE delivery, or the intentional compatibility limits that still describe the shipped system. |
|
||||
|
||||
### Product UI: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/ui/src/components/mission-control/MissionControlSurface.tsx`: Top-level Mission Control surface inside the existing product UI shell. Tab navigation across the 7 view names. Mounted at the `/mission-control` route. Exports: MissionControlSurface.
|
||||
- `packages/ui/src/components/mission-control/views/{MyQueueView,HumanGateView,FleetView,ActiveWorkView,RecentShipsView,RecentlyActiveView,RecentObservationsView}.tsx`: 7 view components. Most wrap a shared GenericListView; FleetView combines CliDriftIndicator + per-row MissingFieldPlaceholder; MyQueueView combines HumanSeatCard + GenericListView with verb actions enabled.
|
||||
- `packages/ui/src/components/mission-control/components/CompactStatusRow.tsx`: 9-field phone-friendly content-model atom (PRD non-negotiable). Renders compact / expanded density modes; UI may collapse but JSON preserves all 9 fields.
|
||||
- `packages/ui/src/components/mission-control/components/VerbActions.tsx`: Shared 7-verb action sub-component. Renders verb buttons + per-verb required-field input (destination for route/handoff, annotation for annotate, reason for hold/drop). Submits to `POST /api/mission-control/action`. Invalidates Mission Control view queries on success.
|
||||
- `packages/ui/src/components/mission-control/components/HumanSeatCard.tsx`: First-class human-seat rendering. Shows session identity, pending load, blocked count, verb capabilities. NOT invisible config-layer convention.
|
||||
- `packages/ui/src/components/mission-control/components/CliDriftIndicator.tsx`: Cross-CLI-version drift indicator (PRD sub-clause 4). Shows fleet-level "rigs running stale CLI" badge + per-row "field unavailable on this rig's daemon version" placeholders via the MissingFieldPlaceholder export.
|
||||
- `packages/ui/src/components/mission-control/hooks/{useMissionControlView,useMissionControlAction,useMissionControlCliCapabilities}.ts`: TanStack Query hooks fetching from `/api/mission-control/{views/:name,action,cli-capabilities}`.
|
||||
### ui/
|
||||
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
- `packages/daemon/src/domain/watchdog-history-log.ts`: Append-only writer for watchdog_history. Records meaningful evaluations only (sent/skipped/terminal). API surface intentionally lacks update/delete. Exports: WatchdogHistoryLog, WatchdogHistoryEntry, WatchdogOutcome. Related: `packages/daemon/src/db/migrations/032_watchdog_history.js`.
|
||||
- `packages/daemon/src/domain/watchdog-policy-engine.ts`: Resolves policy by name, parses spec_yaml context, dispatches policy.evaluate(), records meaningful outcomes via history-log, emits watchdog.* events, calls injected delivery callback for action=send. Exports: WatchdogPolicyEngine, DeliveryFn, DeliveryRequest, DeliveryOutcome. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/watchdog-scheduler.ts`: Tick-driven daemon-native scheduler that joins the supervision tree. Filters listActive() through isDue(), evaluates due jobs sequentially, supports start/stop with graceful in-flight await. Exports: WatchdogScheduler, isDue. Related: `packages/daemon/src/domain/watchdog-policy-engine.js`, `packages/daemon/src/domain/watchdog-jobs-repository.js`.
|
||||
- `packages/daemon/src/domain/policies/types.ts`: Policy contract (Policy, PolicyJob, PolicyEvaluation). Exports: Policy, PolicyJob, PolicyEvaluation. Related: none.
|
||||
- `packages/daemon/src/domain/policies/periodic-reminder.ts`: Policy that returns send {target, message} unconditionally; throws policy_spec_invalid if context.target.session or context.message missing. Translated from POC `lib/policies/periodic-reminder.mjs`. Exports: periodicReminderPolicy. Related: `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-ready.ts`: Policy that scans context.pools and skips with no_actionable_artifacts when empty; otherwise sends a formatted message capped at max_items. Translated from POC `lib/policies/artifact-pool-ready.mjs`. Exports: artifactPoolReadyPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/edge-artifact-required.ts`: Policy that scans source_pools and target_pools, computes source-key per artifact, and sends an edge-repair message for sources lacking matching downstream artifacts. Skips with no_missing_edge_artifacts when all sources matched. Translated from POC `lib/policies/edge-artifact-required.mjs`. Exports: edgeArtifactRequiredPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-helpers.ts`: Shared filesystem scanner + formatter for artifact-pool-ready and edge-artifact-required. Translated from POC `lib/policies/artifact-pool.js`. Exports: scanArtifactPools, formatArtifactList, sourceKeyFor, ArtifactPoolSpec, ScannedArtifact. Related: none.
|
||||
- `packages/daemon/src/routes/watchdog.ts`: Hono route module for `/api/watchdog` (`POST /register`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:job_id`, `GET /:job_id/status`, `POST /:job_id/stop`). SSE/literal paths mounted before /:job_id catchall per Phase A R1 route-order discipline. Exports: watchdogRoutes. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/policies/workflow-keepalive.ts`: PL-004 Phase D — workflow-keepalive policy (carry-forward from Phase C deferral). Reads workflow_instances directly via SQLite (audit row 18 LOAD-BEARING); requires status active|waiting; resolves frontier qitem owners via queue_items. Translated from POC `lib/policies/workflow-keepalive.mjs`; source-of-truth switched from markdown to SQLite. Exports: makeWorkflowKeepalivePolicy. Related: `packages/daemon/src/domain/policies/types.js`, `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
| Module | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `shell-and-routing.md` | The shell-first, route-first, primitive-driven operator surface. | You need how the UI shell (rail / Explorer / center workspace / drawer / preview stack) is assembled, the actual route tree the shipped UI mounts, or how the shared detail drawer and event consumption work. |
|
||||
| `topology.md` | The operator's live picture of host → rig → pod → seat. | You need how the topology surface is built — the host hybrid graph, the table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, and the navigation/overlay contracts. |
|
||||
| `project-and-for-you.md` | The three operator-facing destination surfaces (For You / Project / Dashboard). | You need how the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, and the Dashboard landing on the vellum brand system are built. |
|
||||
| `library-specs-and-design-system.md` | The Library (`/specs`) destination + the design-system pointer. | You need how the Library UI is assembled — the spec/skills/plugins surfaces, the spec-review + spec-library + live-identity flows that feed it — or where the canonical visual/design-system spec lives. |
|
||||
|
||||
### Domain: Workflow Runtime (PL-004 Phase D)
|
||||
- `packages/daemon/src/domain/workflow-types.ts`: Shared types (WorkflowSpec, WorkflowInstance, WorkflowStepTrailEntry, WorkflowExitKind). Reverse-engineered from POC `workflow-runtime-v0/lib/workflow-runtime.rb` + fixture YAMLs. Exports: WorkflowSpec, WorkflowStepSpec, WorkflowRoleSpec, WorkflowInvariants, WorkflowClosureMessages, WorkflowLoopGuards, WorkflowInstance, WorkflowInstanceStatus, WorkflowStepTrailEntry, WorkflowExitKind, WorkflowSpecRow. Related: none.
|
||||
- `packages/daemon/src/domain/workflow-spec-cache.ts`: Read-through cache from markdown/YAML spec files into workflow_specs table. Workspace-surface reconciliation: source_hash invalidates the cache on next read; valid operator edits to spec files win. Uses the `yaml` package for parsing. Exports: WorkflowSpecCache, WorkflowSpecError, parseWorkflowSpec. Related: `packages/daemon/src/db/migrations/033_workflow_specs.js`.
|
||||
- `packages/daemon/src/domain/workflow-validator.ts`: Validates a workflow spec against role resolution, step uniqueness, allowed-exits consistency, and (optional via injected callback) seat liveness. Returns structured ValidationResult with severity-tagged issues. Exports: WorkflowValidator, ValidationIssue, ValidationResult, SeatLivenessCheckFn. Related: `packages/daemon/src/domain/workflow-types.js`.
|
||||
- `packages/daemon/src/domain/workflow-instance-store.ts`: CRUD on workflow_instances + frontier tracking. Survives daemon restart (queries SQLite directly). Exports: WorkflowInstanceStore, WorkflowInstanceError, CreateWorkflowInstanceInput. Related: `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
- `packages/daemon/src/domain/workflow-step-trail-log.ts`: Append-only writer/reader for workflow_step_trails. API surface lacks update/delete (contract enforcement). Mirrors Phase A's queue-transition-log + Phase C's watchdog-history-log shape. Exports: WorkflowStepTrailLog, WorkflowStepTrailRecordInput. Related: `packages/daemon/src/db/migrations/035_workflow_step_trails.js`.
|
||||
- `packages/daemon/src/domain/workflow-projector.ts`: LOAD-BEARING. Implements the transactional-scribe contract: closing a workflow_instances frontier qitem AND creating the next-step queue_items row AND appending the workflow_step_trails entry AND updating instance frontier+status all happen in the SAME daemon transaction (db.transaction). If any side throws, all roll back. Lost handoffs are impossible by design. Post-commit, fans out persisted events + nudges next owner. Uses Phase A QueueRepository.createWithinTransaction() (Phase D extension point). Exports: WorkflowProjector, WorkflowProjectorError, ProjectStepInput, ProjectStepResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/workflow-instance-store.js`, `packages/daemon/src/domain/workflow-step-trail-log.js`.
|
||||
- `packages/daemon/src/domain/workflow-runtime.ts`: Facade over spec-cache + validator + instance-store + projector + step-trail-log. Operations: validate, instantiate (creates instance + entry qitem in same transaction), project (delegates to projector), continue (idempotent inspector). Exports: WorkflowRuntime, WorkflowRuntimeDeps, InstantiateInput, InstantiateResult, WorkflowInstanceError, WorkflowProjectorError, WorkflowSpecError. Related: `packages/daemon/src/domain/workflow-projector.js`, `packages/daemon/src/domain/workflow-spec-cache.js`.
|
||||
- `packages/daemon/src/routes/workflow.ts`: Hono route module for `/api/workflow` (`POST /validate`, `POST /instantiate`, `POST /project`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:instance_id`, `GET /:instance_id/trace`, `POST /:instance_id/continue`). SSE/literal paths mounted before /:instance_id catchall per Phase A R1 route-order discipline. Exports: workflowRoutes. Related: `packages/daemon/src/domain/workflow-runtime.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
### root
|
||||
|
||||
### Domain: Core Runtime
|
||||
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
|
||||
- `packages/daemon/src/domain/event-bus.ts`: Persistent event log plus in-process pub/sub for routes, SSE, and domain emitters. Exports: EventBus. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/graph-projection.ts`: Pure projection from persisted rig/session state to React Flow graph nodes and edges. Exports: ReactFlowGraph, RigGraphInput, projectRigToGraph. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/context-monitor.ts`: Daemon-owned context-usage recorder that samples live runtime state and updates persistent usage summaries. Exports: ContextMonitor. Related: `packages/daemon/src/domain/context-usage-store.js`.
|
||||
- `packages/daemon/src/domain/context-usage-store.ts`: SQLite-backed store for per-node context usage snapshots used by whoami/node inventory surfaces. Exports: ContextUsageStore, ContextUsageSnapshot, ContextUsageStoreOpts. Related: none.
|
||||
- `packages/daemon/src/domain/node-launcher.ts`: Atomic node launch service that creates tmux, session rows, bindings, and launch events together. Exports: LaunchResult, NodeLauncher. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-name.js`, +2 more.
|
||||
- `packages/daemon/src/domain/ps-projection.ts`: Builds the `/api/ps` summary view over rigs, nodes, sessions, and latest snapshots. Exports: PsEntry, PsProjectionService. Related: none.
|
||||
- `packages/daemon/src/domain/reconciler.ts`: Marks missing tmux sessions as detached and keeps persisted session state aligned with reality. Exports: ReconcileResult, Reconciler. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, edge, and rig-services state. Also projects `hasServices` into rig summaries for runtime/UI gating. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rig-teardown.ts`: Graceful rig shutdown/delete orchestrator used by `/api/down`. Exports: RigTeardownOrchestrator, TeardownResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +2 more.
|
||||
- `packages/daemon/src/domain/service-orchestrator.ts`: Rig-scoped services orchestrator for env boot, readiness gating, receipt capture, and teardown. Exports: ServiceBootResult, ServiceOrchestrator, ServiceTeardownResult. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/services-readiness.js`.
|
||||
- `packages/daemon/src/domain/session-name.ts`: Tmux session-name derivation and validation helpers. Exports: deriveSessionName, validateSessionName. Related: none.
|
||||
- `packages/daemon/src/domain/session-registry.ts`: Session and binding lifecycle repository, including claimed-session registration and startup-status updates. Exports: SessionRegistry. Related: `packages/daemon/src/domain/session-name.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/services-readiness.ts`: Shared readiness evaluator for RigEnv wait targets (`url`, `tcp`, `service + condition`). Exports: WaitTargetFailure, WaitTargetSuccess, evaluateWaitTargets. Related: none.
|
||||
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, RigEnv/service contracts, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, EnvCheckpoint, EnvReceipt, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigServicesRecord, RigServicesSpec, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
|
||||
- `packages/daemon/src/domain/up-command-router.ts`: Source router that classifies a path as a rig spec or rig bundle before bootstrap. Exports: RouteResult, SourceKind, UpCommandRouter. Related: `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
| Doc | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `cli-reference.md` | The full `rig` CLI surface, kept as one doc. | You need the exact rig CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. |
|
||||
| `frontmatter-schema.md` | The frontmatter convention these docs follow + the as-built-unique field. | You are authoring or updating a doc under `docs/as-built/`. |
|
||||
| `../DESIGN.md` | The canonical visual / brand / design-system spec (repo `docs/` root). | You need the visual system, brand identity, or design-system tokens. (Stays at root by design; pointer only.) |
|
||||
|
||||
### Domain: Specs, Resolution, Startup
|
||||
- `packages/daemon/src/domain/agent-manifest.ts`: Canonical `agent.yaml` parser, validator, and normalizer for AgentSpec files. Exports: normalizeAgentSpec, parseAgentSpec, validateAgentSpec. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/agent-preflight.ts`: Lightweight preflight for a single `agent_ref`, used before full rig instantiation. Exports: agentPreflight. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/agent-resolver.ts`: Resolves `agent_ref` roots, imported AgentSpecs, and cross-spec resource-collision metadata. Exports: AgentResolverFsOps, ResolveResult, ResolvedAgentSpec, ResourceCollision, resolveAgentRef, resolveImports. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/pod-repository.ts`: SQLite-backed repository for pods and live continuity-state rows. Exports: PodRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/profile-resolver.ts`: Applies AgentSpec defaults, profile uses, startup layering, and restore-policy narrowing to produce a `ResolvedNodeConfig`. Exports: QualifiedResource, ResolutionContext, ResolutionResult, ResolvedNodeConfig, ResolvedResources, resolveNodeConfig. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/startup-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/projection-planner.ts`: Converts resolved node config plus collision data into runtime projection entries, diagnostics, conflicts, and no-ops. Exports: PlanResult, ProjectionClassification, ProjectionEntry, ProjectionFsOps, ProjectionInput, ProjectionPlan, planProjection. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/profile-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-codec.ts`: YAML codec layer for both legacy flat-node RigSpec and rebooted pod-aware RigSpec shapes. Exports: LegacyRigSpecCodec, RigSpecCodec. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-exporter.ts`: Exports live rigs back into RigSpec YAML/JSON. Dual-format: detects pod-aware rigs (podId non-null) and exports canonical v0.2 RigSpec with pods/members/edges; legacy rigs export as flat-node v1. Exports: RigSpecExporter. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/pod-repository.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-instantiator.ts`: Dual-stack instantiation layer containing both legacy `RigInstantiator` and rebooted `PodRigInstantiator`. Exports: PodRigInstantiator, RigInstantiator. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/node-launcher.js`, `packages/daemon/src/domain/pod-repository.js`, +10 more.
|
||||
- `packages/daemon/src/domain/rigspec-preflight.ts`: Dual-stack preflight layer: legacy `RigSpecPreflight` plus rebooted pure `rigPreflight`. Exports: RigPreflightInput, RigSpecPreflight, rigPreflight. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/profile-resolver.js`, `packages/daemon/src/domain/rig-repository.js`, +4 more.
|
||||
- `packages/daemon/src/domain/rigspec-schema.ts`: Validation and normalization for both legacy and rebooted RigSpec schemas. Exports: LegacyRigSpecSchema, RigSpecSchema. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/runtime-adapter.ts`: Runtime-adapter contract plus bridge types used by startup orchestration and runtime-specific adapters. Exports: InstalledResource, NodeBinding, ProjectionResult, ReadinessResult, ResolvedStartupFile, RuntimeAdapter, StartupDeliveryResult. Related: `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/spec-validation-service.ts`: Pure validation helpers for raw AgentSpec and RigSpec YAML strings. Exports: validateAgentSpecFromYaml, validateRigSpecFromYaml. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, provenance/source-state, and managed-app services metadata used by preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, RigSpecServicesReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, marks service-backed rigs with `hasServices`, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/domain/whoami-service.ts`: Daemon-side runtime identity projector used by `/api/whoami`. Resolves by node or session, expands peers/edges/transcript helpers, and surfaces ambiguity honestly. Exports: WhoamiResult, WhoamiAmbiguousError, WhoamiService. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/transcript-store.js`.
|
||||
- `packages/daemon/src/domain/startup-orchestrator.ts`: Startup execution engine for projection, startup-file delivery, action execution, readiness, and persisted replay context. Exports: StartupInput, StartupOrchestrator, StartupResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`, +2 more.
|
||||
- `packages/daemon/src/domain/startup-resolver.ts`: Pure additive startup-layer merge function across agent, profile, rig, pod, member, and operator layers. Exports: StartupLayerInputs, resolveStartup. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/startup-validation.ts`: Shared validation and normalization for startup files and startup actions. Exports: normalizeStartupBlock, validateStartupAction, validateStartupBlock, validateStartupFile. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/types.js`.
|
||||
## (c) Structural relationship diagram
|
||||
|
||||
### Domain: Snapshots And Restore
|
||||
- `packages/daemon/src/domain/checkpoint-store.ts`: Checkpoint persistence, lookup, and rig-wide checkpoint aggregation. Exports: CheckpointStore. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, topology ordering, and RigEnv boot gating before agent restore. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
|
||||
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, startup context, and the latest rig-env receipt. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
|
||||
- `packages/daemon/src/domain/snapshot-repository.ts`: SQLite-backed repository for snapshot CRUD and listing. Exports: SnapshotRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
Built from each module's `siblings` / `prerequisite-reads` frontmatter.
|
||||
`daemon-core.md` is the spine (prerequisite for almost every architecture
|
||||
module); `shell-and-routing.md` is the UI spine.
|
||||
|
||||
### Domain: Packages And Install Engine
|
||||
- `packages/daemon/src/domain/conflict-detector.ts`: Content-aware conflict classification shared by package installs and rebooted projection planning. Exports: GuidanceConflictMeta, RefinedInstallPlan, classifyResourceProjection, detectConflicts. Related: `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/package-resolver.js`, `packages/daemon/src/domain/projection-planner.js`.
|
||||
- `packages/daemon/src/domain/install-engine.ts`: Journaled apply/rollback engine for package installs. Exports: EngineFsOps, InstallEngine, InstallResult, RollbackResult. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/install-policy.js`, `packages/daemon/src/domain/install-repository.js`.
|
||||
- `packages/daemon/src/domain/install-planner.ts`: Computes classified package install entries and target paths. Exports: ActionClassification, ConflictInfo, InstallPlan, InstallPlanEntry, InstallPlanner, PlanOptions. Related: `packages/daemon/src/domain/package-resolver.js`, `packages/daemon/src/domain/role-resolver.js`.
|
||||
- `packages/daemon/src/domain/install-policy.ts`: Approval-policy gate for package install plans. Exports: PolicyRejection, PolicyResult, applyPolicy. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-planner.js`.
|
||||
- `packages/daemon/src/domain/install-repository.ts`: SQLite-backed repository for package install records and journal entries. Exports: Install, InstallRepository, InstallSummary, JournalEntry. Related: none.
|
||||
- `packages/daemon/src/domain/install-verifier.ts`: Post-apply verification for install records and filesystem targets. Exports: Check, EntryVerification, InstallVerifier, VerificationResult, VerifierFsOps. Related: `packages/daemon/src/domain/install-repository.js`, `packages/daemon/src/domain/package-repository.js`.
|
||||
- `packages/daemon/src/domain/package-install-service.ts`: Top-level package validate/plan/install/rollback service used by routes and bootstrap. Exports: PackageInstallOutcome, PackageInstallService. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/install-policy.js`, +4 more.
|
||||
- `packages/daemon/src/domain/package-manifest.ts`: Parser and validator for `package.yaml` manifests. Exports: AgentExport, GuidanceExport, HookExport, InstallPolicy, McpExport, PackageExports, PackageManifest, PackageRequirements, RoleDefinition, SkillExport, ValidationResult, VerificationCheck, VerificationConfig, normalizeManifest, parseManifest, serializeManifest, validateManifest. Related: none.
|
||||
- `packages/daemon/src/domain/package-repository.ts`: SQLite-backed repository for package metadata and install summaries. Exports: Package, PackageRepository, PackageSummary. Related: none.
|
||||
- `packages/daemon/src/domain/package-resolve-helper.ts`: Shared helper that wraps package resolution for routes and orchestrators. Exports: ResolveResult, resolvePackage. Related: `packages/daemon/src/domain/package-manifest.js`, `packages/daemon/src/domain/package-resolver.js`.
|
||||
- `packages/daemon/src/domain/package-resolver.ts`: Resolves local package sources and computes manifest hashes. Exports: FsOps, PackageResolver, ResolvedPackage. Related: `packages/daemon/src/domain/package-manifest.js`.
|
||||
- `packages/daemon/src/domain/path-safety.ts`: Shared safe-path validator used by manifests and startup config. Exports: validateSafePath. Related: none.
|
||||
- `packages/daemon/src/domain/role-resolver.ts`: Filters package exports according to selected roles/runtime scope. Exports: DeferredExport, ResolvedExports, resolveExports. Related: `packages/daemon/src/domain/package-manifest.js`.
|
||||
```
|
||||
README.md (entry — prerequisite for all)
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
architecture/ ui/
|
||||
│ │
|
||||
daemon-core.md ◀── (prerequisite spine for the column below)
|
||||
│ │ │ │
|
||||
│ │ │ └──▶ adapters-and-runtimes.md
|
||||
│ │ │ (five-method contract + resume honesty;
|
||||
│ │ │ consumed by agent-spec startup orchestration)
|
||||
│ │ └────────────────────────────┐
|
||||
│ └──────────────┐ │
|
||||
▼ ▼ ▼
|
||||
coordination- agent-spec-and- transport-and-
|
||||
primitive.md startup.md transcripts.md
|
||||
│ │ │ │
|
||||
▼ ▼ │ ▼
|
||||
workflow- mission- │ lifecycle-snapshot-restore.md
|
||||
runtime.md control.md │ ▲ (consumes persisted replay context)
|
||||
│ │ │
|
||||
└────┬─────┘ ├──▶ packaging-bootstrap-bundles.md
|
||||
▼ │ ▲
|
||||
architecture-rules- │ │ (0.3.x reusable starter-state cluster)
|
||||
and-event-system.md │ ▼
|
||||
(consumes all └──▶ plugin-agent-image-context-pack.md
|
||||
PL-004/005 events)
|
||||
|
||||
### Domain: Bootstrap, Discovery, Bundles
|
||||
- `packages/daemon/src/domain/bootstrap-orchestrator.ts`: Staged bootstrap workflow that composes runtime verification, requirement probes, external installs, package installs, legacy rig instantiation, direct pod-aware rig handling, and schema-version-2 pod-bundle delegation. Exports: BootstrapMode, BootstrapOptions, BootstrapOrchestrator, BootstrapResult, BootstrapStageResult. Related: `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bootstrap-types.js`, `packages/daemon/src/domain/bundle-source-resolver.js`, `packages/daemon/src/domain/external-install-executor.js`, +10 more.
|
||||
- `packages/daemon/src/domain/bootstrap-repository.ts`: SQLite-backed repository for bootstrap runs and action journals. Exports: BootstrapRepository. Related: `packages/daemon/src/domain/bootstrap-types.js`.
|
||||
- `packages/daemon/src/domain/bootstrap-types.ts`: Shared type definitions for bootstrap data. Exports: ActionKind, ActionStatus, BootstrapAction, BootstrapRun, BootstrapStatus, RuntimeStatus, RuntimeVerification. Related: none.
|
||||
- `packages/daemon/src/domain/bundle-archive.ts`: Archive pack/unpack and digest verification for `.rigbundle` files. Exports: pack, unpack, verifyArchiveDigest. Related: `packages/daemon/src/domain/bundle-integrity.js`, `packages/daemon/src/domain/bundle-types.js`.
|
||||
- `packages/daemon/src/domain/bundle-assembler.ts`: Legacy rig-bundle assembler for package-based bundles. Exports: AssembleOptions, AssemblerFsOps, LegacyBundleAssembler, PackageInput. Related: `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/bundle-integrity.ts`: Per-file integrity manifest generation and verification for bundles. Exports: IntegrityFsOps, VerifyResult, computeIntegrity, verifyIntegrity, writeIntegrity. Related: `packages/daemon/src/domain/bundle-types.js`.
|
||||
- `packages/daemon/src/domain/bundle-source-resolver.ts`: Dual-stack bundle resolver containing legacy package-bundle and rebooted pod-bundle extraction flows. Exports: BundleResolvedSource, LegacyBundleSourceResolver, PodBundleResolvedSource, PodBundleSourceResolver. Related: `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/package-resolve-helper.js`, `packages/daemon/src/domain/package-resolver.js`.
|
||||
- `packages/daemon/src/domain/bundle-types.ts`: Bundle manifest types plus parse/validate/serialize helpers for schema v1 and schema v2 bundles. Exports: BundleIntegrity, LegacyBundleManifest, LegacyBundlePackageEntry, PodBundleAgentEntry, PodBundleAgentImportEntry, PodBundleManifest, isRelativeSafePath, normalizeLegacyBundleManifest, parseLegacyBundleManifest, parsePodBundleManifest, serializeLegacyBundleManifest, serializePodBundleManifest, validateLegacyBundleManifest, validatePodBundleManifest. Related: none.
|
||||
- `packages/daemon/src/domain/claim-service.ts`: Claims discovered sessions into managed rig/node state. Exports: ClaimResult, ClaimService. Related: `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/domain/discovery-coordinator.ts`: Coordinates tmux scans, fingerprinting, enrichment, persistence, and event emission for discovery. Exports: DiscoveryCoordinator. Related: `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-enricher.js`, +3 more.
|
||||
- `packages/daemon/src/domain/discovery-repository.ts`: SQLite-backed repository for discovered session records. Exports: DiscoveryRepository, UpsertData. Related: `packages/daemon/src/domain/discovery-types.js`.
|
||||
- `packages/daemon/src/domain/discovery-types.ts`: Shared type definitions for discovery data. Exports: Confidence, DiscoveredSession, DiscoveryStatus, RuntimeHint, SessionOrigin. Related: none.
|
||||
- `packages/daemon/src/domain/external-install-executor.ts`: Runs approved external install actions and journals execution results. Exports: ExecutionResult, ExecutionSummary, ExternalInstallExecutor, TaggedAction. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/external-install-planner.js`.
|
||||
- `packages/daemon/src/domain/external-install-planner.ts`: Turns requirement-probe results into auto-approvable or manual external install actions. Exports: ApprovalClassification, ExternalInstallAction, ExternalInstallPlan, ExternalInstallPlanner. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/domain/requirements-probe.js`.
|
||||
- `packages/daemon/src/domain/pod-bundle-assembler.ts`: Rebooted bundle assembler that vendors AgentSpecs, imports, rig startup files, and rewritten `agent_ref`s. Exports: PodAssembleOptions, PodAssembleResult, PodAssemblerFsOps, PodBundleAssembler. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`, +1 more.
|
||||
- `packages/daemon/src/domain/requirements-probe.ts`: Requirement-probe registry and probe result types used during bootstrap. Exports: ProbeResult, ProbeStatus, RequirementSpec, RequirementsProbeRegistry. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/runtime-verifier.ts`: Verifies runtime/tool availability and persists verification results. Exports: RuntimeVerifier. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-types.js`.
|
||||
- `packages/daemon/src/domain/session-enricher.ts`: Adds repository/config hints to discovered sessions based on filesystem inspection. Exports: EnrichmentResult, SessionEnricher. Related: none.
|
||||
- `packages/daemon/src/domain/session-fingerprinter.ts`: Infers runtime/session identity from tmux, cmux, process, and pane evidence. Exports: FingerprintEvidence, FingerprintResult, SessionFingerprinter. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/tmux-discovery-scanner.js`.
|
||||
- `packages/daemon/src/domain/tmux-discovery-scanner.ts`: Low-level tmux scanner that enumerates panes/sessions for discovery. Exports: ScanResult, ScannedPane, TmuxDiscoveryScanner. Related: `packages/daemon/src/adapters/tmux.js`.
|
||||
workspace-primitive.md ──▶ content-surfaces.md
|
||||
(PL-007 declaration) (filesystem read/write layer on top)
|
||||
│ │
|
||||
└──────────┬───────────────┘
|
||||
▼ (UI counterpart)
|
||||
ui/project-and-for-you.md
|
||||
|
||||
### Domain: North Star Operator Layer
|
||||
- `packages/daemon/src/domain/node-inventory.ts`: Universal node-level projection consumed by CLI, UI, and MCP. Core fields (session name, status, startup status, restore outcome, attach command) plus extended fields (model, agent ref, startup files, installed resources). Exports: NodeInventoryService. Related: `packages/daemon/src/domain/types.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/domain/demo-rig-selector.ts`: Helper for existing-rig power-on — finds the right rig by name from ps summary entries. Exports: selectCurrentRigSummary. Related: none.
|
||||
- `packages/daemon/src/domain/draft-rig-generator.ts`: Synthesizes a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs. Exports: generateDraftRig. Related: `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/native-resume-probe.ts`: Honest assessment of whether a harness actually resumed vs fresh-launched — probes pane command and content for runtime-specific indicators. Exports: NativeResumeProbeResult, assessNativeResumeProbe, buildNativeResumeCommand, isProbeShellReady. Related: `packages/daemon/src/adapters/shell-quote.js`.
|
||||
- `packages/daemon/src/domain/resume-metadata-refresher.ts`: Post-launch resume token capture — reads Claude conversation IDs from state files and Codex thread IDs from SQLite. Exports: ResumeMetadataRefresher. Related: `packages/daemon/src/domain/codex-thread-id.js`, `packages/daemon/src/domain/native-resume-probe.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite state database. Exports: defaultResolveHomeDirByPid, readCodexThreadIdFromCandidateHomes, ResolveHomeDirByPid. Related: none.
|
||||
shell-and-routing.md ──▶ topology.md
|
||||
│ └──▶ project-and-for-you.md ◀──▶ architecture/mission-control.md
|
||||
└──────────────▶ library-specs-and-design-system.md ──▶ ../DESIGN.md (pointer)
|
||||
```
|
||||
|
||||
### Domain: Post-North-Star Transport and History
|
||||
- `packages/daemon/src/domain/session-transport.ts`: Communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection via pane content heuristics, honest error reporting, pod/rig/global targeting. Exports: SessionTransport, TargetSpec, SendResult, CaptureResult, BroadcastResult, SendOpts. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/transcript-store.ts`: Pipe-pane transcript management — ANSI stripping on read (preserves raw on disk), boundary markers for session lifecycle events, readTail, grep. Filesystem-backed, not SQLite. Exports: TranscriptStore, TranscriptStoreOpts. Related: none (filesystem-only).
|
||||
- `packages/daemon/src/domain/history-query.ts`: Unified transcript + chat search — prefers `rg` when available, falls back to `grep -E`, surfaces which backend was used. Exports: HistoryQuery, SearchResult, ChatSearchResult, ExecDep. Related: `packages/daemon/src/domain/transcript-store.js`.
|
||||
- `packages/daemon/src/domain/ask-service.ts`: Context engineering evidence pack — gathers topology snapshot, transcript excerpts, chat history excerpts, restore metadata. Returns structured context for the agent to reason about. Does NOT call an external LLM. Exports: AskService, AskDeps. Related: `packages/daemon/src/domain/ps-projection.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/history-query.js`.
|
||||
- `packages/daemon/src/domain/chat-repository.ts`: Durable rig-scoped chat messages — CRUD for chat_messages table, monotonic ULID IDs, rig-scoped queries. Exports: ChatRepository, ChatMessage. Related: none (SQLite-only).
|
||||
Reading-order rule: open a module's `prerequisite-reads` first
|
||||
(`README.md`, then `daemon-core.md` for most architecture modules;
|
||||
`README.md` then `shell-and-routing.md` for most ui modules).
|
||||
|
||||
### Domain: Coordination Primitive (PL-004 Phase A)
|
||||
- `packages/daemon/src/domain/stream-store.ts`: L1 coordination stream — append-only intake/audit root. Idempotent emit on stream_item_id, chronological list with cursor pagination, soft archive. Exports: StreamStore, StreamItem, StreamEmitInput, StreamListOptions. Related: `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/queue-repository.ts`: L3 coordination queue — create, claim/unclaim, update with hot-potato strict-rejection, transactional handoff (close source + create new in one transaction), pod-fallback rerouting, overdue lookup. POC qitem_id format preserved. Exports: QueueRepository, QueueRepositoryError, QueueItem, QueueState, QueuePriority, QueueCreateInput, QueueUpdateInput, QueueHandoffInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-transition-log.js`, `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/queue-transition-log.ts`: Append-only state transition log for queue_items. No UPDATE / DELETE from domain code. Exports: QueueTransitionLog, QueueTransition, QueueTransitionInput. Related: `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/hot-potato-enforcer.ts`: Pure validator for the load-bearing hot-potato API contract — state=done requires closure_reason from the 6-value enum. Tier→SLA mapping for closure_required_at. Exports: CLOSURE_REASONS, ClosureReason, validateClosure, isClosureReason, computeClosureRequiredAt. Related: none.
|
||||
- `packages/daemon/src/domain/inbox-handler.ts`: Mailbox handler — authenticated drop (idempotent on inbox_id), absorb (promote pending entry to a queue_item), deny (record reason). Authentication is a pluggable constructor hook. Exports: InboxHandler, InboxHandlerError, InboxEntry, InboxState, InboxDropInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-repository.js`.
|
||||
- `packages/daemon/src/domain/outbox-handler.ts`: Sender-side outbox — symmetric to inbox; idempotent record, mark delivered/failed, list. No event-bus events emitted. Exports: OutboxHandler, OutboxHandlerError, OutboxEntry, OutboxDeliveryState, OutboxRecordInput. Related: none (SQLite-only).
|
||||
## (d) Fast lookup by use-case
|
||||
|
||||
### Routes
|
||||
- `packages/daemon/src/routes/adapters.ts`: Hono route module for adapters API endpoints. Exports: adaptersRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/routes/agents.ts`: Hono route module for AgentSpec validation. POST `/api/agents/validate` using `validateAgentSpecFromYaml`. Exports: agentsRoutes. Related: `packages/daemon/src/domain/spec-validation-service.js`.
|
||||
- `packages/daemon/src/routes/bootstrap.ts`: Hono route module for bootstrap API endpoints. Exports: bootstrapRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/bundles.ts`: Hono route module for dual-format bundle create/inspect/install. Supports legacy v1 package bundles and schema-version-2 pod bundles, including v2 inspect responses with `agents[]` and integrity data. Exports: bundleRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-assembler.js`, +7 more.
|
||||
- `packages/daemon/src/routes/discovery.ts`: Hono route module for discovery scan/list/detail plus Final Stretch flows: `draft-rig`, `bind`, and composite `adopt`. Exports: discoveryRoutes. Related: `packages/daemon/src/domain/claim-service.js`, `packages/daemon/src/domain/discovery-coordinator.js`, `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/draft-rig-generator.js`.
|
||||
- `packages/daemon/src/routes/down.ts`: Hono route module for down API endpoints. Exports: downRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rig-teardown.js`.
|
||||
- `packages/daemon/src/routes/env.ts`: Hono route module for rig-environment status, logs, and teardown. Refreshes env receipts best-effort and returns additive managed-app/runtime metadata (`hasServices`, `surfaces`, `receipt`). Exports: envRoutes. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/service-orchestrator.js`.
|
||||
- `packages/daemon/src/routes/events.ts`: Hono route module for events API endpoints. Exports: eventsRoute. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/routes/packages.ts`: Hono route module for the legacy package-install surface (`/validate`, `/plan`, `/install`, history, rollback). AgentSpec validation no longer lives here. Exports: packagesRoutes. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, +6 more.
|
||||
- `packages/daemon/src/routes/ps.ts`: Hono route module for ps API endpoints. Exports: psRoutes. Related: `packages/daemon/src/domain/ps-projection.js`.
|
||||
- `packages/daemon/src/routes/rigs.ts`: Hono route module for rig CRUD, graph projection, summary, delete, and existing-rig power-on by rig id. Exports: rigsRoutes. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/graph-projection.js`, `packages/daemon/src/domain/node-inventory.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/routes/rigspec.ts`: Hono route module for dual-format RigSpec import, validate, preflight, materialize, and export. Pod-aware import/preflight/materialize require `X-Rig-Root`; export emits v0.2 pod-aware YAML/JSON when the live rig has pods. Exports: handleExportJson, handleExportYaml, rigspecImportRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-exporter.js`, `packages/daemon/src/domain/rigspec-instantiator.js`, +2 more.
|
||||
- `packages/daemon/src/routes/sessions.ts`: Hono route module for rig sessions and node inventory/detail/launch/focus. `GET /api/rigs/:rigId/nodes/:logicalId` is the backend contract for the live node drawer and full-details workspace. Exports: nodesRoutes, sessionsRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/domain/node-inventory.js`, `packages/daemon/src/domain/node-launcher.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/routes/snapshots.ts`: Hono route module for snapshots API endpoints. Exports: restoreRoutes, snapshotsRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/restore-orchestrator.js`, `packages/daemon/src/domain/snapshot-capture.js`, `packages/daemon/src/domain/snapshot-repository.js`.
|
||||
- `packages/daemon/src/routes/transport.ts`: Hono route module for communication primitives (`POST /send`, `/capture`, `/broadcast`). Resolves session targets, delegates to SessionTransport. Exports: transportRoutes. Related: `packages/daemon/src/domain/session-transport.js`.
|
||||
- `packages/daemon/src/routes/transcripts.ts`: Hono route module for transcript access (`GET /:session/tail`, `/:session/grep`). Resolves session to rig/node, delegates to TranscriptStore. Exports: transcriptRoutes. Related: `packages/daemon/src/domain/transcript-store.js`, `packages/daemon/src/domain/rig-repository.js`.
|
||||
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
|
||||
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/stream.ts`: Hono route module for the PL-004 Phase A coordination L1 stream (`POST /emit`, `GET /list`, `GET /:id`, `POST /:id/archive`, `GET /watch` SSE). Host-scoped (no rigId). Exports: streamRoutes. Related: `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/queue.ts`: Hono route module for the PL-004 Phase A coordination L3 queue plus inbox/outbox (`POST /create`, `POST /:id/{claim,unclaim,update,handoff,fallback}`, `GET /list`, `GET /overdue`, `GET /:id`, `GET /:id/transitions`, inbox/outbox subroutes, `GET /watch` SSE). Surfaces hot-potato strict-rejection structured errors with validReasons enum. Exports: queueRoutes. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/inbox-handler.js`, `packages/daemon/src/domain/outbox-handler.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Enriches service-backed reviews with best-effort compose preview data for managed-app UI surfaces. Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/whoami.ts`: Hono route module for daemon-backed runtime identity lookup by `nodeId` or `sessionName`. Exports: whoamiRoutes. Related: `packages/daemon/src/domain/whoami-service.js`.
|
||||
- `packages/daemon/src/routes/up.ts`: Hono route module for `/api/up`, routing direct rig specs, bundles, and rig-name restore into the staged bootstrap flow. Exports: upRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/up-command-router.js`.
|
||||
| I need to know… | → see |
|
||||
|---|---|
|
||||
| How the daemon boots / `createDaemon` wiring / migration set / route mounts | `architecture/daemon-core.md` |
|
||||
| The runtime-adapter contract / how a harness is launched or resumed in tmux | `architecture/adapters-and-runtimes.md` |
|
||||
| Where Claude resume honesty lives / resumed-vs-fresh assessment | `architecture/adapters-and-runtimes.md` |
|
||||
| Where queue closure is enforced / hot-potato contract / durable handoff | `architecture/coordination-primitive.md` |
|
||||
| How workflow specs project on closure / transactional-scribe / watchdog policies | `architecture/workflow-runtime.md` |
|
||||
| The Mission Control views/verbs / queue-observability / action audit | `architecture/mission-control.md` |
|
||||
| AgentSpec/RigSpec types, profile resolution, startup layering, whoami/bind/adopt | `architecture/agent-spec-and-startup.md` |
|
||||
| Snapshot/restore, restore honesty, restore-check / restore-packet probes | `architecture/lifecycle-snapshot-restore.md` |
|
||||
| rig send/capture/broadcast, transcripts, durable chat, `rig ask`, MCP-name vs tmux-key | `architecture/transport-and-transcripts.md` |
|
||||
| The workspace primitive (root/repos), `target_repo` scope gating, missions/slices indexing | `architecture/workspace-primitive.md` |
|
||||
| The file browser path safety, atomic writes + edit audit, PROGRESS tree, Steering composer | `architecture/content-surfaces.md` |
|
||||
| Plugin discovery / agent images / context packs / Claude auto-compaction enforcer | `architecture/plugin-agent-image-context-pack.md` |
|
||||
| Bundle assembly, bundle install / `/api/up`, BootstrapOrchestrator, legacy install seams | `architecture/packaging-bootstrap-bundles.md` |
|
||||
| The 25 architecture rules / RigEvent union / SSE delivery / compatibility limits | `architecture/architecture-rules-and-event-system.md` |
|
||||
| The UI shell, the real route tree, the shared detail drawer | `ui/shell-and-routing.md` |
|
||||
| The topology graph/table/terminal views, activity-ring / hot-potato visuals | `ui/topology.md` |
|
||||
| The For-You attention feed, Project scope pages, the Dashboard landing | `ui/project-and-for-you.md` |
|
||||
| The vellum brand system on the destination surfaces (0.3.1 brand identity) | `ui/project-and-for-you.md` (vellum brand = **0.3.1** per slice-00 0.3.0-GT seam (b); slice-00 §2 row 1 = vellum-primitives → brand-identity, row 2 = destination-model → polished-destinations) |
|
||||
| The Library `/specs` UI + spec-review/spec-library/live-identity flows | `ui/library-specs-and-design-system.md` |
|
||||
| The full `rig` CLI surface | `cli-reference.md` |
|
||||
| The visual / brand / design-system spec | `../DESIGN.md` (repo `docs/` root) |
|
||||
| Which frontmatter these docs use + the as-built-unique field | `frontmatter-schema.md` |
|
||||
|
||||
## CLI
|
||||
## (e) Source-root pointer table
|
||||
|
||||
### Entrypoints And Infrastructure
|
||||
- `packages/cli/src/client.ts`: Shared HTTP client for talking to the daemon API, including raw JSON posts and text-body posts with optional extra headers such as `X-Rig-Root`. Exports: DaemonClient, DaemonConnectionError, DaemonResponse. Related: none.
|
||||
- `packages/cli/src/daemon-lifecycle.ts`: Starts, stops, inspects, and tails the local daemon process. Spawns the daemon with `process.execPath` and bypasses local `daemon.json` gating when `OPENRIG_URL` is set by probing remote `/healthz` directly. Exports: DaemonState, DaemonStatus, LOG_FILE, LifecycleDeps, OPENRIG_DIR, STATE_FILE, StartOptions, getDaemonPath, getDaemonStatus, readLogs, startDaemon, stopDaemon, tailLogs. Related: none.
|
||||
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (40 mounted command groups, including env status/logs/down, `destroy`, and Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +19 more.
|
||||
- `packages/cli/src/mcp-server.ts`: Implements the CLI-hosted MCP server over stdio, wrapping 17 daemon-backed tools including node inventory, send/capture, bundle/spec validation, and chatroom. Exports: createMcpServer. Related: `packages/cli/src/client.js`.
|
||||
Module → primary `packages/*/src/...` roots. This replaces the old per-file
|
||||
`Exports:`/`Related:` dump: it points at the code, it does not transcribe it.
|
||||
Source-grounding for each claim lives inside the module with file:line cites.
|
||||
|
||||
### Commands
|
||||
- `packages/cli/src/commands/agent.ts`: Commander command module for `agent`. Subcommands: `validate` (POST `/api/agents/validate`). Supports `--json`. Exports: AgentDeps, agentCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bootstrap.ts`: Commander command module for `bootstrap`. Supports library-name resolution before falling back to raw source refs. Exports: bootstrapCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bind.ts`: Commander command module for `bind` — bind a discovered session to an existing logical node. Exports: bindCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bundle.ts`: Commander command module for `bundle`. Supports dual-format bundle create/inspect/install, `--json`, and pod-aware `--rig-root` on create. Exports: bundleCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/claim.ts`: Commander command module for `claim`. Exports: claimCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/daemon.ts`: Commander command module for `daemon`. Exports: daemonCommand, realDeps. Related: `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/discover.ts`: Commander command module for `discover`. Exports: discoverCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/down.ts`: Commander command module for `down`. Exports: downCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/env.ts`: Commander command module for `env` — rig environment status/logs/down for service-backed rigs and managed apps. Exports: envCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/commands/status.js`.
|
||||
- `packages/cli/src/commands/export.ts`: Commander command module for `export`. Exports: ExportDeps, exportCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/import.ts`: Commander command module for `import`. Locally parses YAML to detect pod-aware rigs, sends `X-Rig-Root` for pod-aware preflight/instantiate flows, and supports `--rig-root`. Exports: ImportDeps, importCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/mcp.ts`: Commander command module for `mcp`. Exports: mcpCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`, +1 more.
|
||||
- `packages/cli/src/commands/package.ts`: Commander command module for the legacy package-management surface. Exports: packageCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/ps.ts`: Commander command module for `ps`. Exports: psCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/requirements.ts`: Commander command module for `requirements`. Exports: requirementsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/restore.ts`: Commander command module for `restore`. Exports: restoreCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/rig.ts`: Commander command module for `rig`. Subcommands: `validate` (POST `/api/rigs/import/validate`), `preflight` (POST `/api/rigs/import/preflight` with `X-Rig-Root` header). Both support `--json`. Exports: RigDeps, rigCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/snapshot.ts`: Commander command module for `snapshot`. Exports: snapshotCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/status.ts`: Commander command module for `status`. Exports: StatusDeps, statusCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. `add` accepts single YAML files and full spec directories containing `rig.yaml` or `agent.yaml`. Help/examples and not-found guidance explicitly call out managed apps. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/ui.ts`: Commander command module for `ui`. Exports: UiDeps, uiCommand. Related: `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Help/examples now treat managed apps like `secrets-manager` as first-class launch targets. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
|
||||
- `packages/cli/src/commands/whoami.ts`: Commander command module for `whoami`. Resolves identity from explicit args, env vars, tmux metadata, and session-name fallback, then delegates to daemon `/api/whoami`. Exports: resolveIdentitySource, whoamiCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/adopt.ts`: Commander command module for `adopt` — materialize topology from a pod-aware RigSpec, then bind discovered live sessions according to `--bind` mappings. Exports: AdoptDeps, adoptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/import.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
| Module | Primary source roots |
|
||||
|---|---|
|
||||
| `architecture/daemon-core.md` | `packages/daemon/src/{startup.ts,server.ts,index.ts}`, `packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts` |
|
||||
| `architecture/adapters-and-runtimes.md` | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`, `packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts` |
|
||||
| `architecture/coordination-primitive.md` | `packages/daemon/src/domain/{stream-store,queue-repository,queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`, `packages/daemon/src/routes/{stream,queue}.ts` |
|
||||
| `architecture/workflow-runtime.md` | `packages/daemon/src/domain/{workflow-projector,workflow-runtime,workflow-instance-store,workflow-spec-cache,workflow-step-trail-log,workflow-validator}.ts`, `packages/daemon/src/domain/policies/workflow-keepalive.ts`, `packages/daemon/src/routes/workflow.ts` |
|
||||
| `architecture/mission-control.md` | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/middleware/auth-bearer-token.ts`, `packages/daemon/src/routes/mission-control.ts`, `packages/daemon/src/db/migrations/037_mission_control_actions.ts` |
|
||||
| `architecture/agent-spec-and-startup.md` | `packages/daemon/src/domain/{agent-manifest,rigspec-schema,profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`, `packages/daemon/src/routes/{rigspec,whoami}.ts` |
|
||||
| `architecture/lifecycle-snapshot-restore.md` | `packages/daemon/src/domain/{restore-orchestrator,snapshot-capture,snapshot-repository,checkpoint-store}.ts`, `packages/daemon/src/routes/restore-check.ts`, `packages/cli/src/commands/restore-packet.ts` |
|
||||
| `architecture/transport-and-transcripts.md` | `packages/daemon/src/domain/{session-transport,transcript-store,history-query,ask-service,chat-repository}.ts`, `packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`, `packages/cli/src/mcp-server.ts` |
|
||||
| `architecture/workspace-primitive.md` | `packages/daemon/src/domain/workspace/`, `packages/cli/src/commands/config-init-workspace.ts`, `packages/daemon/src/db/migrations/{038,039}*`, `packages/ui/src/routes.tsx` |
|
||||
| `architecture/content-surfaces.md` | `packages/daemon/src/domain/{files,progress,steering}*`, `packages/daemon/src/routes/{files,progress,steering}.ts`, `packages/ui/src/routes.tsx` |
|
||||
| `architecture/plugin-agent-image-context-pack.md` | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/{agent-images,context-packs}/`, `packages/daemon/src/domain/claude-compaction-enforcer.ts` |
|
||||
| `architecture/packaging-bootstrap-bundles.md` | `packages/daemon/src/domain/{pod-bundle-assembler,bootstrap-orchestrator,bundle-*,package-*,install-*}.ts`, `packages/daemon/src/routes/{bundles,up}.ts` |
|
||||
| `architecture/architecture-rules-and-event-system.md` | `packages/daemon/src/domain/types.ts` (RigEvent union), `packages/daemon/src/routes/{stream,queue}.ts` (SSE watch), `packages/daemon/src/server.ts` (`/api/events`) |
|
||||
| `ui/shell-and-routing.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/AppShell.tsx` |
|
||||
| `ui/topology.md` | `packages/ui/src/components/topology/`, `packages/ui/src/lib/{graph,hybrid,multi-rig}-layout.ts` |
|
||||
| `ui/project-and-for-you.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/dashboard/vellum/` |
|
||||
| `ui/library-specs-and-design-system.md` | `packages/ui/src/components/specs/`, `packages/daemon/src/domain/{spec-review-service,spec-library-service}.ts`, `../DESIGN.md` (pointer) |
|
||||
| `cli-reference.md` | `packages/cli/src/index.ts`, `packages/cli/src/commands/*` |
|
||||
|
||||
### Commands: Post-North-Star Additions
|
||||
- `packages/cli/src/commands/send.ts`: Commander command module for `send` — send message to agent terminal via reliable tmux two-step. Supports `--verify` and `--force`. Exports: sendCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/capture.ts`: Commander command module for `capture` — capture agent pane content. Supports `--lines N` and `--json`. Exports: captureCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/broadcast.ts`: Commander command module for `broadcast` — send to all agents in a rig or pod. Reports per-session delivery status. Exports: broadcastCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/transcript.ts`: Commander command module for `transcript` — composable transcript access via `--tail N` and `--grep "pattern"`. Exports: transcriptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/stream.ts`: Commander command module for `rig stream` (PL-004 Phase A) — coordination L1 intake stream. Subcommands: `emit`, `list`, `show`, `archive`. Speaks only to the daemon HTTP API; does not touch POC filesystem state. Exports: streamCommand, StreamDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/queue.ts`: Commander command module for `rig queue` (PL-004 Phase A) — coordination L3 queue plus inbox/outbox. Subcommands: `create`, `claim`, `unclaim`, `update`, `handoff`, `fallback`, `show`, `transitions`, `list`, `overdue`, `inbox-drop`, `inbox-absorb`, `inbox-deny`, `inbox-pending`, `outbox-record`, `outbox-list`. Hot-potato strict-rejection enforced at daemon. Exports: queueCommand, QueueDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/config.ts`: Commander command module for `config` — read/write/reset config keys. Supports `--json`. Exports: configCommand. Related: `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/preflight.ts`: Commander command module for `preflight` — system readiness checks. Supports `--json`. Exports: preflightCommand. Related: `packages/cli/src/system-preflight.js`, `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/ask.ts`: Commander command module for `ask` — context evidence pack over transcripts + topology + chat. Supports `--json`. Exports: askCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/chatroom.ts`: Commander command module for `chatroom` — durable group chat with subcommands `send`, `watch`, `history`, and `topic`. Exports: chatroomCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
### CLI Infrastructure: Post-North-Star Additions
|
||||
- `packages/cli/src/config-store.ts`: Config file management at `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys with env var overrides. Precedence: CLI flag > env var > config file > default. Exports: ConfigStore and config helpers. Related: none (filesystem-only).
|
||||
- `packages/cli/src/system-preflight.ts`: System readiness checks: Node.js version, tmux availability, writable directories, port availability. Every error follows the 3-part pattern (what + why + fix). Exports: SystemPreflight, PreflightCheck, PreflightResult. Related: `packages/cli/src/config-store.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
## UI
|
||||
|
||||
### App Entry And Routing
|
||||
- `packages/ui/src/App.tsx`: Thin app wrapper that renders the TanStack Router provider. Exports: App. Related: `packages/ui/src/routes.js`.
|
||||
- `packages/ui/src/globals.css`: Tailwind/theme tokens and global UI styling. Exports: none. Related: none.
|
||||
- `packages/ui/src/main.tsx`: Bootstraps the React app, fonts, and global styles. Exports: none. Related: `packages/ui/src/App.js`, `packages/ui/src/globals.css`.
|
||||
- `packages/ui/src/routes.tsx`: Declares the explorer-first route tree and root shell composition, including specs review/library routes, discovery/specs drawer bridges, and live-node full-details route `/rigs/$rigId/nodes/$logicalId`. Exports: router. Related: `packages/ui/src/components/AppShell.js`, `packages/ui/src/components/BootstrapWizard.js`, `packages/ui/src/components/BundleInspector.js`, `packages/ui/src/components/BundleInstallFlow.js`, +13 more.
|
||||
|
||||
### Components: Application Surfaces
|
||||
- `packages/ui/src/components/ActivityFeed.tsx`: Rolling global activity/event overlay driven by SSE. Exports: ActivityFeed. Related: `packages/ui/src/hooks/useActivityFeed.js`.
|
||||
- `packages/ui/src/components/AppShell.tsx`: Persistent explorer/workspace/drawer shell. Owns drawer selection context, discovery placement state, specs workspace provider, explorer visibility, and top-level discovery/specs/system toggles. Exports: DrawerSelectionContext, DrawerSelectionContextValue, DiscoveryPlacementContext, DiscoveryPlacementContextValue, ExplorerVisibilityContext, ExplorerVisibilityContextValue, NodeSelectionContext, AppShell, useDiscoveryPlacement, useDrawerSelection, useExplorerVisibility, useNodeSelection. Related: `packages/ui/src/components/ActivityFeed.js`, `packages/ui/src/components/Explorer.js`, `packages/ui/src/components/SharedDetailDrawer.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useActivityFeed.js`, `packages/ui/src/hooks/useGlobalEvents.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/BootstrapWizard.tsx`: Multi-step bootstrap planning/apply flow UI for both rig specs and `.rigbundle` archives. Includes the spec-or-bundle entry field and bundle-inspector handoff link. Exports: BootstrapWizard. Related: `packages/ui/src/components/RequirementsPanel.js`, `packages/ui/src/hooks/useBootstrap.js`.
|
||||
- `packages/ui/src/components/BundleInspector.tsx`: Bundle inspection screen for manifest and integrity results. Renders `agents[]` plus schema-version badge for v2 pod bundles and legacy package content for v1 bundles. Exports: BundleInspector. Related: `packages/ui/src/hooks/useBundles.js`.
|
||||
- `packages/ui/src/components/BundleInstallFlow.tsx`: Bundle install plan/apply screen. Labels the install as pod-aware or legacy from bootstrap `resolve_spec.detail.source`. Exports: BundleInstallFlow. Related: `packages/ui/src/hooks/useBundles.js`.
|
||||
- `packages/ui/src/components/Dashboard.tsx`: Home dashboard that renders rig cards and high-level rig actions. Exports: Dashboard. Related: `packages/ui/src/components/RigCard.js`, `packages/ui/src/hooks/mutations.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/DiscoveryOverlay.tsx`: Session discovery and claim surface. Exports: DiscoveryOverlay. Related: `packages/ui/src/hooks/useDiscovery.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/ImportFlow.tsx`: RigSpec validate/preflight/instantiate flow UI. Supports pod-aware `X-Rig-Root`, structured preflight diagnostics, and rebooted RigSpec terminology. Exports: ImportFlow. Related: `packages/ui/src/hooks/mutations.js`.
|
||||
- `packages/ui/src/components/AgentSpecValidateFlow.tsx`: Standalone validate surface for one AgentSpec YAML, backed by `/api/agents/validate`. Exports: AgentSpecValidateFlow. Related: none.
|
||||
- `packages/ui/src/components/WorkflowScaffold.tsx`: Shared page-shell primitives for multi-step/spec-review flows (`WorkflowHeader`, `WorkflowStepIndicator`, `WorkflowSection`, summary cards, code preview). Exports: WorkflowHeader, WorkflowStepIndicator, WorkflowSection, WorkflowSummaryGrid, WorkflowSummaryCard, WorkflowCodePreview. Related: none.
|
||||
- `packages/ui/src/components/WorkspacePage.tsx`: Shared center-workspace page shell used by specs/library/live-details surfaces. Exports: WorkspacePage. Related: none.
|
||||
- `packages/ui/src/components/SpecsWorkspace.tsx`: Local persisted workspace state for spec authoring/review tasks, drafts, and active task list. Exports: SpecsDraft, SpecsTask, SPECS_WORKSPACE_STORAGE_KEYS, SpecsWorkspaceProvider, useSpecsWorkspace. Related: none.
|
||||
- `packages/ui/src/components/PackageDetail.tsx`: Package detail screen with install history, journal, and rollback actions. Exports: PackageDetail. Related: `packages/ui/src/hooks/usePackageDetail.js`.
|
||||
- `packages/ui/src/components/PackageInstallFlow.tsx`: Legacy package validate/configure/plan/apply flow UI, explicitly labeled `(Legacy)`. Exports: PackageInstallFlow. Related: none.
|
||||
- `packages/ui/src/components/PackageList.tsx`: Legacy package summary list/grid screen presented under the rebooted `SPECS` navigation, with an empty-state handoff into RigSpec import. Exports: PackageList. Related: `packages/ui/src/hooks/usePackages.js`.
|
||||
- `packages/ui/src/components/RequirementsPanel.tsx`: Visualizes requirement probe results inside bootstrap flows. Exports: RequirementResult, RequirementsPanel. Related: none.
|
||||
- `packages/ui/src/components/RigCard.tsx`: Dashboard card for one rig with counts, status, and primary actions. Exports: RigCard, RigSummary. Related: `packages/ui/src/hooks/useCountUp.js`, `packages/ui/src/hooks/usePsEntries.js`.
|
||||
- `packages/ui/src/components/RigGraph.tsx`: Rig graph view that renders topology, discovery overlays, and focus interactions. Exports: RigGraph. Related: `packages/ui/src/components/RigNode.js`, `packages/ui/src/hooks/useDiscovery.js`, `packages/ui/src/hooks/useRigEvents.js`, `packages/ui/src/hooks/useRigGraph.js`.
|
||||
- `packages/ui/src/components/RigNode.tsx`: Custom graph-node renderer for React Flow topology cards. Exports: RigNode. Related: none.
|
||||
- `packages/ui/src/components/Sidebar.tsx`: App navigation sidebar. Exports: Sidebar. Related: none.
|
||||
- `packages/ui/src/components/SnapshotPanel.tsx`: Snapshot list/create/restore panel shown beside rig graphs. Exports: SnapshotPanel. Related: `packages/ui/src/hooks/mutations.js`, `packages/ui/src/hooks/useSnapshots.js`.
|
||||
|
||||
### Components: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/components/Explorer.tsx`: Hierarchical tree navigator — rigs → pods → agents with status indicators. Primary navigation surface. Click rig loads graph, click node opens detail drawer. Exports: Explorer. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/SharedDetailDrawer.tsx`: Unified drawer surface keyed by `DrawerSelection`. Routes to rig, node, system, discovery, or specs panels without leaving the current workspace. Exports: DrawerSelection, SharedDetailDrawer. Related: `packages/ui/src/components/DiscoveryPanel.js`, `packages/ui/src/components/NodeDetailPanel.js`, `packages/ui/src/components/RigDetailPanel.js`, `packages/ui/src/components/SpecsPanel.js`, `packages/ui/src/components/SystemPanel.js`.
|
||||
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots/actions, and runtime drawer tabs (`Info`, conditional `Env`, `Chat Room`). Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/RigEnvPanel.tsx`: Runtime env surface for service-backed rigs. Shows overall env state, per-service health, health gates, surfaces, logs, and teardown action. Exports: RigEnvPanel. Related: `packages/ui/src/hooks/useRigEnv.js`.
|
||||
- `packages/ui/src/components/NodeDetailPanel.tsx`: Runtime-first node drawer. Shows rig/session/runtime identity, peers, directional edges, transcript helpers, compact spec summary, status, actions (tmux/cmux/resume/full-details), startup files, and recent events. Exports: NodeDetailPanel. Related: `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`, `packages/ui/src/lib/copy-text.js`, `packages/ui/src/lib/display-name.js`.
|
||||
- `packages/ui/src/components/NodeDetailPlaceholder.tsx`: Empty state for node detail when no node is selected. Exports: NodeDetailPlaceholder. Related: none.
|
||||
- `packages/ui/src/components/DiscoveryPanel.tsx`: Drawer-native discovery/adoption surface. Supports scan/list, draft-rig generation, placement-first bind/adopt, and pod/member targeting inside the shared drawer. Exports: DiscoveryPlacementTarget, DiscoveryPanel. Related: `packages/ui/src/hooks/useDiscovery.js`.
|
||||
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Presents a unified library list with `All / Apps / Rigs / Agents`, type/stability badges, richer managed-app rows, and review-route handoff. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/RigChatPanel.tsx`: Chat room tab in rig drawer — send messages, watch live stream, view history. Exports: RigChatPanel. Related: `packages/ui/src/hooks/useRigChat.js`.
|
||||
- `packages/ui/src/components/LiveIdentityDisplay.tsx`: Reusable runtime-identity subsection for peers, edges, transcript helpers, and compact spec summary. Used by the node drawer and full-details route. Exports: LiveIdentityDisplay. Related: none.
|
||||
- `packages/ui/src/components/LiveNodeDetails.tsx`: Explicit center-workspace route for deep inspection of one live node. Composes runtime identity plus reusable spec display components. Exports: LiveNodeDetails. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`.
|
||||
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Supports a library-only managed-app Environment tab with compose preview, health gates, and surfaces. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
|
||||
- `packages/ui/src/components/AgentSpecDisplay.tsx`: Canonical AgentSpec display primitive used by preview, library, and live-details flows. Exports: AgentSpecDisplay. Related: none.
|
||||
- `packages/ui/src/components/RigSpecReview.tsx`: Route-level rig review page that consumes `RigSpecDisplay` and specs workspace state. Exports: RigSpecReview. Related: `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/AgentSpecReview.tsx`: Route-level agent review page that consumes `AgentSpecDisplay` and specs workspace state. Exports: AgentSpecReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Service-backed rigs surface managed-app copy, specialist identity, setup-prompt copy, and env-aware review details. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/SpecTopologyPreview.tsx`: Lightweight topology preview for structured rig review models. Exports: SpecTopologyPreview. Related: none.
|
||||
- `packages/ui/src/components/SystemPanel.tsx`: Drawer surface for system-wide event log/status tabs. Replaces the old status-bar role with a richer right-drawer system surface. Exports: SystemPanel. Related: `packages/ui/src/hooks/useActivityFeed.js`.
|
||||
- `packages/ui/src/components/WorkspaceHome.tsx`: Landing page when no rig is selected. Entry point for explorer-first workflow. Exports: WorkspaceHome. Related: `packages/ui/src/components/AppShell.js`.
|
||||
|
||||
### Components: UI Primitives
|
||||
- `packages/ui/src/components/ui/alert.tsx`: Wrapped/customized shadcn-style UI primitive for alert. Exports: Alert, AlertDescription, AlertTitle. Related: none.
|
||||
- `packages/ui/src/components/ui/badge.tsx`: Wrapped/customized shadcn-style UI primitive for badge. Exports: Badge, BadgeProps, badgeVariants. Related: none.
|
||||
- `packages/ui/src/components/ui/button.tsx`: Wrapped/customized shadcn-style UI primitive for button. Exports: Button, ButtonProps, buttonVariants. Related: none.
|
||||
- `packages/ui/src/components/ui/card.tsx`: Wrapped/customized shadcn-style UI primitive for card. Exports: Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle. Related: none.
|
||||
- `packages/ui/src/components/ui/dialog.tsx`: Wrapped/customized shadcn-style UI primitive for dialog. Exports: Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger. Related: none.
|
||||
- `packages/ui/src/components/ui/input.tsx`: Wrapped/customized shadcn-style UI primitive for input. Exports: Input. Related: none.
|
||||
- `packages/ui/src/components/ui/separator.tsx`: Wrapped/customized shadcn-style UI primitive for separator. Exports: Separator. Related: none.
|
||||
- `packages/ui/src/components/ui/table.tsx`: Wrapped/customized shadcn-style UI primitive for table. Exports: Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow. Related: none.
|
||||
- `packages/ui/src/components/ui/tabs.tsx`: Wrapped/customized shadcn-style UI primitive for tabs. Exports: Tabs, TabsContent, TabsList, TabsTrigger. Related: none.
|
||||
- `packages/ui/src/components/ui/textarea.tsx`: Wrapped/customized shadcn-style UI primitive for textarea. Exports: Textarea. Related: none.
|
||||
- `packages/ui/src/components/ui/tooltip.tsx`: Wrapped/customized shadcn-style UI primitive for tooltip. Exports: Tooltip, TooltipContent, TooltipProvider, TooltipTrigger. Related: none.
|
||||
|
||||
### Hooks
|
||||
- `packages/ui/src/hooks/mutations.ts`: Shared mutation hooks for rig import, teardown, snapshot creation, and restore. `useImportRig` supports optional `rigRoot` and sends `X-Rig-Root` for pod-aware import. Exports: ImportError, useCreateSnapshot, useImportRig, useRestoreSnapshot, useTeardownRig. Related: none.
|
||||
- `packages/ui/src/hooks/useActivityFeed.ts`: SSE-backed activity-feed model and event-formatting helpers. Exports: ActivityEvent, UseActivityFeedResult, eventColor, eventRoute, eventSummary, formatRelativeTime, useActivityFeed. Related: none.
|
||||
- `packages/ui/src/hooks/useBootstrap.ts`: Bootstrap plan/apply hooks. Exports: BootstrapApplyResult, BootstrapPlanResult, useBootstrapApply, useBootstrapPlan. Related: none.
|
||||
- `packages/ui/src/hooks/useBundles.ts`: Bundle inspect/install hooks. Typed for v2 inspect responses (`schemaVersion`, `agents`, integrity) and install-stage `detail.source` labeling. Exports: BundleInstallResult, InspectResult, useBundleInspect, useBundleInstall. Related: none.
|
||||
- `packages/ui/src/hooks/useCountUp.ts`: Small animation helper for count-up telemetry. Exports: useCountUp. Related: none.
|
||||
- `packages/ui/src/hooks/useDiscovery.ts`: Discovery polling plus claim/bind/adopt mutations and draft-rig generation helpers. Exports: DiscoveredSession, useAdoptSession, useBindSession, useClaimSession, useDiscoveredSessions, useDiscoveredSessionsConditional, useDiscoveryPoll, useDiscoveryScan. Related: none.
|
||||
- `packages/ui/src/hooks/usePackageDetail.ts`: Package detail, journal, history, and rollback hooks. Exports: InstallSummary, JournalEntry, PackageInfo, useInstallHistory, useJournalEntries, usePackageInfo, useRollbackInstall. Related: none.
|
||||
- `packages/ui/src/hooks/usePackages.ts`: Package summary list hook. Exports: PackageSummary, usePackages. Related: none.
|
||||
- `packages/ui/src/hooks/usePsEntries.ts`: `/api/ps` summary hook. Exports: PsEntry, usePsEntries. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEvents.ts`: Rig-scoped SSE subscription and invalidation hook. Exports: UseRigEventsResult, useRigEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useRigGraph.ts`: Rig graph fetch/transformation hook. Exports: useRigGraph. Related: none.
|
||||
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Includes `hasServices` so runtime/UI can gate managed-app env surfaces. Exports: RigSummary, useRigSummary. Related: none.
|
||||
- `packages/ui/src/hooks/useSnapshots.ts`: Snapshot list hook for one rig. Exports: Snapshot, useSnapshots. Related: none.
|
||||
|
||||
### Hooks: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/hooks/useNodeInventory.ts`: Fetches node inventory for a rig from the canonical `GET /api/rigs/:rigId/nodes` endpoint. Exports: useNodeInventory. Related: none.
|
||||
- `packages/ui/src/hooks/useNodeDetail.ts`: Fetches extended node detail from `GET /api/rigs/:rigId/nodes/:logicalId`, including peers, edges, transcript helpers, and compact spec summary for the adopted-parity/live-identity drawer. Exports: NodeDetailData, useNodeDetail. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEnv.ts`: Fetches `GET /api/rigs/:rigId/env` for service-backed rig status, surfaces, and receipt data, with runtime-friendly refetch behavior. Exports: RigEnvData, useRigEnv. Related: none.
|
||||
- `packages/ui/src/hooks/useRigChat.ts`: Chat send/watch/history hooks for the rig chat panel. Exports: useRigChat. Related: none.
|
||||
- `packages/ui/src/hooks/useGlobalEvents.ts`: Global SSE subscription for explorer status updates. Exports: useGlobalEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results and `hasServices`-aware managed-app classification. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. The rig review type now carries managed-app services metadata and compose preview. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
|
||||
|
||||
### Lib Utilities
|
||||
- `packages/ui/src/lib/edge-styles.ts`: Edge-style resolver for graph links. Exports: EdgeStyleResult, getEdgeStyle. Related: none.
|
||||
- `packages/ui/src/lib/graph-layout.ts`: Tree-layout helper for rig graph positioning. Exports: applyTreeLayout. Related: none.
|
||||
- `packages/ui/src/lib/instantiate-status-colors.ts`: Color-class lookup for instantiation states. Exports: getInstantiateStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/query-client.ts`: Shared TanStack Query client singleton. Exports: queryClient. Related: none.
|
||||
- `packages/ui/src/lib/restore-status-colors.ts`: Color-class lookup for restore states. Exports: getRestoreStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/status-colors.ts`: Generic rig/session status color mapping. Exports: getStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/utils.ts`: Shared UI utility helpers, currently `cn` class merging. Exports: cn. Related: none.
|
||||
|
||||
### Lib Utilities: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/lib/display-id.ts`: Short ULID display utility — uses ULID tail (not head) for better uniqueness in same-view contexts. UI-only, no backend changes. Exports: shortId. Related: none.
|
||||
- `packages/ui/src/lib/display-name.ts`: Pod name extraction from qualified logical IDs (e.g., "dev1" from "dev1.impl"). Exports: displayPodName, inferPodName. Related: none.
|
||||
- `packages/ui/src/lib/build-setup-prompt.ts`: Managed-app setup-prompt generator used by library review copy surfaces. Exports: buildSetupPrompt. Related: none.
|
||||
- `packages/ui/src/lib/copy-text.ts`: Clipboard copy utility for the "Copy tmux attach" and "Copy resume command" buttons. Exports: copyText. Related: none.
|
||||
> No per-file flat dump. File-level detail belongs in the code; this codemap
|
||||
> points at the source root and the module that explains it. The per-file
|
||||
> index anti-pattern (old codemap) is retired, not relocated.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
status: active
|
||||
topics: [knowledge-and-context, frontmatter]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Authoring or updating a doc under docs/as-built/. Tells you which
|
||||
frontmatter convention governs these docs and the one field that is
|
||||
unique to as-built docs.
|
||||
siblings: [README.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
|
||||
Every doc under `docs/as-built/` carries YAML frontmatter. The shape is
|
||||
**not** defined here. It is the substrate-wide context-frontmatter
|
||||
convention; this doc only points to it and documents the one field that
|
||||
is unique to as-built docs.
|
||||
|
||||
## Canonical convention (do not duplicate — read it)
|
||||
|
||||
`openrig-work/conventions/frontmatter-for-context/README.md` is the
|
||||
authoritative schema: required floor (`kind` + `title` + `status` +
|
||||
`applies-when`), recommended fields (`topics`, `domains`, `siblings`,
|
||||
`prerequisite-reads`), and the controlled vocabularies for `kind:`,
|
||||
`topics:`, and `domains:`. Author against that convention. This doc adds
|
||||
nothing to it except the field below.
|
||||
|
||||
## `kind: as-built`
|
||||
|
||||
These docs use `kind: as-built`. That value is in the convention's
|
||||
controlled `kind:` vocabulary (first row of its "Controlled vocabulary —
|
||||
`kind:`" table, source `openrig/docs/as-built/*`). Using it is conformant,
|
||||
not an extension.
|
||||
|
||||
## `last-verified-against-source: <sha>` (as-built-unique)
|
||||
|
||||
As-built docs describe the running system. They drift when the source
|
||||
drifts. The as-built-unique field records the exact commit the doc was
|
||||
last verified against:
|
||||
|
||||
```yaml
|
||||
last-verified-against-source: 7eaf524c
|
||||
```
|
||||
|
||||
- **Value:** the short SHA of the source HEAD the doc was verified
|
||||
against at its last edit. Pair with `last-updated: <iso-date>`.
|
||||
- **Why it is here and not in the convention:** the convention is
|
||||
substrate-wide; most context docs do not track a source SHA. This
|
||||
field is meaningful only for docs that mirror code, i.e. as-built
|
||||
docs. It is the per-module drift-detection anchor the
|
||||
context-architecture-v1 mission dogfoods on its own production.
|
||||
- **Discipline:** when re-verifying a module against a newer HEAD,
|
||||
bump this field (and `last-updated`) and carry an inline
|
||||
`re-confirmed <file:line> @HEAD` annotation on each load-bearing
|
||||
claim that was re-checked.
|
||||
|
||||
## See also
|
||||
|
||||
- `openrig-work/conventions/frontmatter-for-context/README.md` — the canonical schema
|
||||
- `README.md` — the as-built corpus map of territory
|
||||
+36
-230
@@ -1,238 +1,44 @@
|
||||
# OpenRig UI As-Built
|
||||
---
|
||||
kind: as-built
|
||||
title: ui.md — Reorganized into the Modular As-Built Tree (Redirect Stub)
|
||||
status: superseded
|
||||
topics: [knowledge-and-context]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
You followed an old reference to docs/as-built/ui.md. The monolith was
|
||||
reorganized (slice-08, context-architecture-v1) into the ui/ module folder.
|
||||
Go to README.md (map of territory) or codemap.md (use-case lookup), then the
|
||||
named ui/ module.
|
||||
siblings: [README.md, codemap.md, architecture.md]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
This document describes the current UI architecture and component system. The
|
||||
brand/design rules live in `docs/DESIGN.md`; this file focuses on how the shipped UI
|
||||
is assembled.
|
||||
# ui.md was reorganized into the modular as-built tree
|
||||
|
||||
## Package Shape
|
||||
This single-file UI monolith was split into a folder of independently-loadable,
|
||||
frontmatter-tagged thematic modules (slice-08, `context-architecture-v1`). The
|
||||
canonical as-built description of the shipped operator UI now lives in `ui/`.
|
||||
This stub is a forwarding pointer so old references still resolve.
|
||||
|
||||
UI package: `packages/ui`
|
||||
**Start here:**
|
||||
|
||||
Primary implementation areas:
|
||||
- [`./README.md`](./README.md) — the map of territory and full module index.
|
||||
- [`./codemap.md`](./codemap.md) — navigation index: use-case lookup table and
|
||||
source-root pointers. Use this when you know *what* you need but not *which*
|
||||
module.
|
||||
|
||||
- `src/routes.tsx` - TanStack Router route tree
|
||||
- `src/components/AppShell.tsx` - global shell, rail, Explorer, drawer, preview stack
|
||||
- `src/components/ui/` - reusable vellum and base UI primitives
|
||||
- `src/components/graphics/` - runtime, tool, and actor marks
|
||||
- `src/components/topology/` - host/rig/pod/seat graph, table, terminal, activity
|
||||
- `src/components/project/` - workspace/mission/slice observability and project metadata primitives
|
||||
- `src/components/for-you/` - attention feed and actionable queue cards
|
||||
- `src/components/specs/` - Library and skill file viewing
|
||||
- `src/components/preview/` - terminal/session preview panes
|
||||
- `src/hooks/` - route data hooks and event consumption
|
||||
- `src/lib/` - classifiers, formatters, layout helpers, brand helpers, activity logic
|
||||
## Where the major content went
|
||||
|
||||
## Shell Model
|
||||
The UI content is now the **4 modules under `ui/`**:
|
||||
|
||||
`AppShell` wraps every route with:
|
||||
| Module | What moved here |
|
||||
|---|---|
|
||||
| [`ui/shell-and-routing.md`](ui/shell-and-routing.md) | Package shape, the `AppShell` shell model (rail / Explorer / center workspace / drawer / preview stack), the route tree, design primitives, the shared detail-drawer/viewer system, event/activity consumption. |
|
||||
| [`ui/topology.md`](ui/topology.md) | The topology surface — host hybrid graph, table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, navigation/overlay contracts. |
|
||||
| [`ui/project-and-for-you.md`](ui/project-and-for-you.md) | The operator destination surfaces — Project observability (workspace/mission/slice scope tabs), the For-You attention feed (5-card classifier + queue actions), the Dashboard landing on the vellum brand system. |
|
||||
| [`ui/library-specs-and-design-system.md`](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/applications/skills surfaces, the graphics layer, the current design constraints, and the design-system pointer to `../DESIGN.md`. |
|
||||
|
||||
- Desktop rail
|
||||
- Route-aware Explorer sidebar
|
||||
- Center workspace
|
||||
- Shared detail drawer
|
||||
- Preview stack
|
||||
- Topology overlay state provider
|
||||
- Shared drawer selection context
|
||||
- Shared discovery placement context
|
||||
|
||||
The rail destinations are:
|
||||
|
||||
- Dashboard: `/`
|
||||
- Topology: `/topology`
|
||||
- For You: `/for-you`
|
||||
- Project: `/project`
|
||||
- Library: `/specs`
|
||||
- Settings: `/settings`
|
||||
- Advisor and Operator placeholders/settings links
|
||||
|
||||
The Explorer is contextual. It changes its tree based on the current destination
|
||||
rather than behaving as a generic file browser.
|
||||
|
||||
## Route Model
|
||||
|
||||
Main routes:
|
||||
|
||||
- `/` - Dashboard
|
||||
- `/topology` - host topology scope
|
||||
- `/topology/rig/$rigId` - rig scope
|
||||
- `/topology/pod/$rigId/$podName` - pod scope
|
||||
- `/topology/seat/$rigId/$logicalId` - seat scope with live node details
|
||||
- `/for-you` - attention feed
|
||||
- `/project` - workspace project scope
|
||||
- `/project/mission/$missionId` - mission scope
|
||||
- `/project/slice/$sliceId` - slice scope
|
||||
- `/specs` - Library
|
||||
- `/specs/applications` - application section
|
||||
- `/specs/skills/$skillToken` - skill viewer defaulting to `skill.md`
|
||||
- `/specs/skills/$skillToken/file/$fileToken` - specific skill file viewer
|
||||
- `/settings` - settings center
|
||||
- `/search` - audit/history view
|
||||
- `/lab/project-graphics-preview` - graphics preview lab route
|
||||
|
||||
Legacy routes remain for compatibility where needed, including package flows,
|
||||
bootstrap flows, validation flows, and `/rigs/$rigId`.
|
||||
|
||||
## Design Primitives
|
||||
|
||||
The UI moved from route-specific one-off panels toward reusable primitives.
|
||||
|
||||
Base primitives:
|
||||
|
||||
- `VellumCard` - paper card with optional dark header, hard shadow, registration marks
|
||||
- `VellumSheet` - side sheet/drawer with vellum-heavy surface and registration marks
|
||||
- `RegistrationMarks` - corner registration marks
|
||||
- `StatusPip` - semantic status only
|
||||
- `SectionHeader`, `EmptyState`, `Button`, `Tabs`, `Table`, and form controls
|
||||
|
||||
Graphics primitives:
|
||||
|
||||
- `RuntimeMark` and `RuntimeBadge`
|
||||
- `ToolMark` and `ToolBadge`
|
||||
- `ActorMark` and `OperatorMoodMark`
|
||||
- Runtime and tool normalization in `runtime-brand.ts` and `tool-brand.ts`
|
||||
|
||||
Project metadata primitives:
|
||||
|
||||
- `EventBadge`
|
||||
- `QueueStateBadge`
|
||||
- `TagPill`
|
||||
- `ActorChip`
|
||||
- `DateChip`
|
||||
- `FlowChips`
|
||||
- `ProofThumbnailGrid`
|
||||
- `ProofPacketHeader`
|
||||
|
||||
These primitives centralize visual language for runtime identity, queue state,
|
||||
actor flow, event labels, dates, tags, and proof images.
|
||||
|
||||
## Topology
|
||||
|
||||
Topology is a scoped workspace with graph, table, and terminal views.
|
||||
|
||||
Current topology pieces:
|
||||
|
||||
- `HostMultiRigGraph` renders the host-level hybrid React Flow graph.
|
||||
- `HybridAgentNode` renders compact agent cards with runtime badges, context
|
||||
percentage, token totals, activity state, terminal preview, and CMUX actions.
|
||||
- `HybridPodGroupNode` renders soft dashed pod frames.
|
||||
- `RigGroupNode` renders soft rig frames with registration marks and aggregate
|
||||
activity.
|
||||
- `ActivityRing` and card activity classes show active, needs-input, and blocked
|
||||
states.
|
||||
- `HotPotatoEdge` visualizes directional queue movement through the graph.
|
||||
- `TopologyTableView` mirrors topology data in a dense table.
|
||||
- `TopologyTerminalView` shows terminal-oriented topology state.
|
||||
- `TerminalPreviewPopover` provides black-glass quick terminal preview above the
|
||||
graph via a portal.
|
||||
|
||||
Important topology contracts:
|
||||
|
||||
- Graph/table/tree navigation resolves to seat URLs.
|
||||
- Host-level graph uses cross-rig node ID prefixing.
|
||||
- Expanded rig state is managed by `TopologyOverlayProvider`.
|
||||
- Graph data is lazy-fetched for expanded rigs.
|
||||
- Terminal preview actions are hover/focus visible.
|
||||
- The terminal popover must escape React Flow stacking contexts and stay within
|
||||
the viewport.
|
||||
|
||||
## Project Observability
|
||||
|
||||
Project is organized as a hierarchy:
|
||||
|
||||
- Workspace
|
||||
- Mission
|
||||
- Slice
|
||||
|
||||
The current route scope acts as a filter. Higher scopes show broader rollups;
|
||||
lower scopes show more specific story, queue, proof, artifact, test, and topology
|
||||
data.
|
||||
|
||||
Current project tabs:
|
||||
|
||||
- Story - narrative progress and queue body content
|
||||
- Progress - status and activity rollup
|
||||
- Artifacts - files, commits, proof, and outputs
|
||||
- Tests - proof material, screenshots, and verification detail
|
||||
- Queue - operational qitems with actions and drawer detail
|
||||
- Topology - workflow/agent diagram where available
|
||||
|
||||
Project primitives translate raw queue and event data into readable badges,
|
||||
chips, dates, flows, proof thumbnails, and action outcome panels.
|
||||
|
||||
## For You
|
||||
|
||||
For You is the attention-routing surface. It uses live event feed data plus queue
|
||||
details to classify cards into:
|
||||
|
||||
- Action Required
|
||||
- Approval
|
||||
- Shipped
|
||||
- Progress
|
||||
- Observation
|
||||
|
||||
The card renderer prioritizes readable body content, proof thumbnails, actor
|
||||
flow, friendly dates, and action affordances. Queue actions reuse existing
|
||||
mission-control action endpoints.
|
||||
|
||||
## Library And Skills
|
||||
|
||||
`/specs` is the Library route. It contains specs, applications, context packs,
|
||||
agent specs, agent images, and skills.
|
||||
|
||||
Skills are folder-based. The skill tree in Explorer treats each skill as a
|
||||
folder. Clicking a skill opens a skill detail route that defaults to `skill.md`
|
||||
case-insensitively. The skill detail page uses the existing `FileViewer` and
|
||||
does not add a second in-page file browser; file navigation belongs in the
|
||||
Explorer tree.
|
||||
|
||||
## Drawer And Viewer System
|
||||
|
||||
The shared detail drawer is used for transient focused detail:
|
||||
|
||||
- Queue item viewer
|
||||
- File viewer
|
||||
- Subspec preview
|
||||
- Other detail selections
|
||||
|
||||
Drawer triggers carry root/path provenance so the viewer can fetch and render the
|
||||
correct content. The drawer supports outside-click dismissal.
|
||||
|
||||
Image proof viewing is handled by `ProofImageViewer`, which respects the shell
|
||||
layout and uses black-glass styling.
|
||||
|
||||
## Event And Activity Consumption
|
||||
|
||||
UI event consumption is centralized through shared event hooks so the app avoids
|
||||
spawning duplicate SSE connections for related surfaces.
|
||||
|
||||
Current activity consumers include:
|
||||
|
||||
- For You feed hydration
|
||||
- Topology activity rings and hot-potato movement
|
||||
- Activity feed/system surfaces
|
||||
- Rig event surfaces where appropriate
|
||||
|
||||
Reduced-motion preference is honored for pulse and packet animations.
|
||||
|
||||
## Graphics Layer
|
||||
|
||||
The graphics package is intentionally centralized:
|
||||
|
||||
- Runtime identity: Claude, Codex, Terminal, Unknown
|
||||
- Tool identity: CMUX, tmux, VS Code, Terminal, file, markdown, config, code,
|
||||
screenshot, proof, transcript, commit, folder, skill, video, trace
|
||||
- Actor identity: human/operator, runtime agents, terminal fallback
|
||||
|
||||
The goal is to make dense operator surfaces scannable without adding more raw
|
||||
metadata text.
|
||||
|
||||
## Current Design Constraints
|
||||
|
||||
- Use the shared primitives before creating new card styles.
|
||||
- Keep vellum and black-glass separate.
|
||||
- Keep status semantics separate from activity animation.
|
||||
- Do not duplicate runtime/tool brand detection outside the central helpers.
|
||||
- Do not add new daemon shape for UI polish unless the slice explicitly declares
|
||||
and verifies that boundary.
|
||||
- Do not make page-level content look like a raw terminal log unless the content
|
||||
is actually terminal output.
|
||||
The brand/design rules still live at `docs/DESIGN.md` (repo `docs/` root, by
|
||||
design — see `ui/library-specs-and-design-system.md`).
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Library/Specs Surfaces + Design-System Pointer
|
||||
status: active
|
||||
topics: [specification-and-bundles, observability]
|
||||
domains: [engineering-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Need to know how the Library (`/specs`) UI is assembled — the spec/skills/
|
||||
plugins surfaces, the spec-review + spec-library + live-identity flows that
|
||||
feed it — or where the canonical visual/design-system spec lives.
|
||||
siblings: [shell-and-routing.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Library/Specs Surfaces + Design-System Pointer
|
||||
|
||||
The Library destination keeps `/specs` as the route and presents the product
|
||||
label "Library": specs, applications, context packs, agent specs, agent
|
||||
images, plugins, and skills.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). Component/primitive names below are reconciled against
|
||||
> `packages/ui/src/components/` at HEAD — NOT taken on trust from `ui.md`'s
|
||||
> or `DESIGN.md`'s lists.
|
||||
|
||||
## 1. Library / specs surfaces
|
||||
|
||||
Components in `packages/ui/src/components/specs/` (re-confirmed at HEAD):
|
||||
|
||||
- `SpecsLibraryPage.tsx` — the `/specs` Library page (unified list:
|
||||
All / Apps / Rigs / Agents; service-backed entries render as `APP`).
|
||||
- `SpecsTable.tsx`, `SpecsTreeView.tsx` — list + Explorer tree.
|
||||
- `SkillsIndexPage.tsx`, `SkillDetailPage.tsx` — folder-based skills.
|
||||
The skill tree treats each skill as a folder; clicking a skill opens a
|
||||
detail route defaulting to `skill.md` case-insensitively, rendered via the
|
||||
shared `FileViewer` in the center workspace (no second in-page file
|
||||
browser — file navigation belongs in the Explorer tree).
|
||||
- `PluginsIndexPage.tsx`, `PluginDetailPage.tsx`, `AgentPluginsList.tsx` —
|
||||
the plugin surfaces.
|
||||
|
||||
> Drift-note (slice-00 0.3.0-GT seam a): the plugin primitive is a **0.3.1**
|
||||
> feature (git-ancestry proven; absent at `v0.3.0`, present at `v0.3.1`). Do
|
||||
> NOT back-attribute the `/specs/plugins`, `/plugins/$pluginId` routes or
|
||||
> these plugin components to 0.3.0. `ui.md` (which predates 0.3.x) has **no
|
||||
> narrative for plugins at all** — these surfaces are authored from source +
|
||||
> the slice-00 0.3.0-GT seam, not migrated.
|
||||
|
||||
## 2. Spec review / library / live-identity flows
|
||||
|
||||
These daemon-backed flows feed the Library UI (`architecture.md` §6
|
||||
"Spec review and spec library flow", "Live identity / specs UI flow",
|
||||
re-confirmed accurate at HEAD):
|
||||
|
||||
- **Raw YAML preview:** UI/CLI posts YAML to `/api/specs/review/rig` or
|
||||
`/api/specs/review/agent`; `SpecReviewService` parses/validates/returns
|
||||
structured review models; UI renders them through `RigSpecDisplay` /
|
||||
`AgentSpecDisplay` (the same primitives reused for draft preview, library
|
||||
review, and live full-details).
|
||||
- **Filesystem-backed library:** `SpecLibraryService` scans builtin + user
|
||||
roots (`packages/daemon/specs`, `~/.openrig/specs`, legacy fallback
|
||||
`~/.rigged/specs`); each YAML is classified via structured review;
|
||||
service-backed rigs are marked `hasServices`; `/api/specs/library` serves
|
||||
list/get/review/sync.
|
||||
- **CLI mirror:** `rig specs ls/show/preview/add/sync`; `rig specs add`
|
||||
installs a single YAML spec or a full spec directory; `rig up` /
|
||||
`rig bootstrap` resolve library names before other source kinds.
|
||||
- **Live identity:** Explorer/graph selection opens the shared right drawer
|
||||
(runtime-first node detail: live identity, peers, directional edges,
|
||||
transcript helpers, compact spec summary); `Open Full Details` navigates to
|
||||
`/rigs/$rigId/nodes/$logicalId` in the center workspace.
|
||||
|
||||
## 3. UI primitive inventory (reconciled against source)
|
||||
|
||||
> Drift-fix — `ui.md` "Design Primitives" (L75–107) omits `ProjectPill`.
|
||||
> `docs/DESIGN.md` L195 lists it; re-confirmed at HEAD it is a real export
|
||||
> (`packages/ui/src/components/project/ProjectMetaPrimitives.tsx:196`
|
||||
> `export function ProjectPill`). Reconciled inventory below is from source,
|
||||
> not from either doc's list.
|
||||
|
||||
- **Vellum/base** (`components/ui/`): `VellumCard`, `VellumSheet`,
|
||||
`RegistrationMarks`, `StatusPip`, `SectionHeader`, `EmptyState`,
|
||||
`Button`, `Tabs`, `Table`, form controls, plus `rig-stamp.tsx`
|
||||
(`RigStamp`) — present at HEAD; not in ui.md's list.
|
||||
- **Graphics** (`components/graphics/RuntimeMark.tsx`, exports re-confirmed):
|
||||
`RuntimeMark`, `RuntimeBadge`, `ToolMark`, `ToolBadge`, `ActorMark`,
|
||||
`OperatorMoodMark`; normalization in `lib/runtime-brand.ts` +
|
||||
`lib/tool-brand.ts`.
|
||||
- **Project metadata** (`components/project/ProjectMetaPrimitives.tsx`,
|
||||
exports re-confirmed): `ProjectPill` (`:196`), `EventBadge` (`:224`),
|
||||
`QueueStateBadge` (`:228`), `TagPill` (`:274`), `ActorChip` (`:283`),
|
||||
`DateChip` (`:303`), `FlowChips` (`:315`), `ProofThumbnailGrid` (`:334`),
|
||||
`ProofPacketHeader` (`:380`) — plus `QueueCountIcon` / `StatusDot` (in
|
||||
source, not in ui.md/DESIGN.md primitive lists).
|
||||
- **Viewers**: `FileViewer` (`components/drawer-viewers/FileViewer.tsx`),
|
||||
`MarkdownViewer` (`components/markdown/MarkdownViewer.tsx`),
|
||||
`ProofImageViewer`, `SessionPreviewPane` (`components/preview/`).
|
||||
|
||||
## 4. Design-system pointer (NOT a copy — Q1)
|
||||
|
||||
The canonical OpenRig visual/design system is **`docs/DESIGN.md`** (at the
|
||||
repo `docs/` root, NOT under `docs/as-built/`). Q1 is ratified: DESIGN.md
|
||||
stays at root, byte-identical, and this module **points to it rather than
|
||||
copying it**. DESIGN.md is the source for: Vellum-paper vs Black-glass
|
||||
surface languages, colour tokens (`packages/ui/src/globals.css` +
|
||||
`tailwind.config.ts`), typography (`font-body` Inter / `font-headline` Space
|
||||
Grotesk / `font-mono` JetBrains Mono), layout (48px rail + Explorer + center
|
||||
+ drawer + preview stack), the core-primitive list, the graphics system, and
|
||||
the interaction/motion/accessibility/do-and-do-not rules.
|
||||
|
||||
> Reconciliation note: DESIGN.md's primitive lists were independently
|
||||
> verified against `packages/ui/src/components/` at HEAD — every primitive
|
||||
> DESIGN.md names (Vellum, Graphics, Project-metadata, Topology, Preview)
|
||||
> resolves to a real export at HEAD. DESIGN.md is accurate and carries no
|
||||
> slice-00 numeric drift, so it is referenced as-is. Do not duplicate its
|
||||
> content here; read `docs/DESIGN.md` directly for the visual spec.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/DESIGN.md` — the canonical visual/design-system spec (pointer; do
|
||||
not copy).
|
||||
- `shell-and-routing.md` — the `/specs` route family + shell + drawer.
|
||||
- `../architecture/agent-spec-and-startup.md` — the spec parsing/resolution
|
||||
contract behind the review flows.
|
||||
- `../architecture/plugin-agent-image-context-pack.md` — the 0.3.1 content
|
||||
layer behind the plugins/agent-image/context-pack Library surfaces.
|
||||
- Source roots: `packages/ui/src/components/specs/`,
|
||||
`packages/daemon/src/domain/{spec-review-service,spec-library-service}*`.
|
||||
@@ -0,0 +1,377 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Project Observability, For You, Dashboard
|
||||
status: active
|
||||
topics: [observability, coordination]
|
||||
domains: [engineering-advisor, product-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the operator-facing destination surfaces are built —
|
||||
the For-You attention feed (5-card classifier + verb actions), the
|
||||
Project workspace/mission/slice scope pages (tabbed rollups), and the
|
||||
Dashboard landing surface on the vellum brand system. Author-mode
|
||||
module — ui.md prose for these surfaces predates 0.3.x; every claim is
|
||||
sourced to file:line at HEAD.
|
||||
siblings: [shell-and-routing.md, ../architecture/mission-control.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Project Observability, For You, Dashboard
|
||||
|
||||
The three operator-facing destination surfaces: **For You** (`/for-you`,
|
||||
the attention feed), **Project** (`/project*`, the workspace/mission/slice
|
||||
scope pages), and the **Dashboard** (`/`, the landing surface on the
|
||||
vellum brand system). All read live daemon state; none adds UI-local
|
||||
persistence beyond per-event soft-dismiss.
|
||||
|
||||
> **AUTHOR-HEAVY module.** `ui.md`'s Project Observability / For You /
|
||||
> Graphics-Layer sections are thin and predate the 0.3.1 dashboard/vellum
|
||||
> brand refresh. Every load-bearing claim carries `> Source: <file:line>
|
||||
> @HEAD`; ambiguity is declared as an OPEN item, never smoothed. Paths
|
||||
> are relative to `packages/ui/src/` unless prefixed `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
|
||||
## 0. Release attribution (forensic seam — read first)
|
||||
|
||||
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Layer | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| Project-observability foundation (For-You feed, feed-classifier, project scope pages, the Mission Control 7-verb vocabulary) | **0.3.0** | `v0.3.0:components/for-you/Feed.tsx`, `v0.3.0:lib/feed-classifier.ts`, `v0.3.0:components/project/ScopePages.tsx`, `v0.3.0:components/mission-control/components/VerbActions.tsx` all resolve at `v0.3.0` (slice-00 0.3.0-GT §1.5/§1.6/§1.7) |
|
||||
| Vellum **brand-identity system** the Dashboard/For-You now render on (`dashboard/vellum/*`: CornerBracket, VellumDestinationCard, marks, graphics, single-source-of-truth barrel) | **0.3.1, NOT 0.3.0** | `git cat-file -e v0.3.0:components/dashboard/vellum/index.ts` → **ABSENT** ("exists on disk, but not in 'v0.3.0'"); `v0.3.1:` → present (slice-00 0.3.0-GT seam (b); §2 rows 1/2) |
|
||||
|
||||
> Source: re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/ui/src/components/dashboard/vellum/index.ts`
|
||||
> fails; `v0.3.0:.../for-you/Feed.tsx`, `.../lib/feed-classifier.ts`,
|
||||
> `.../project/ScopePages.tsx`, `.../mission-control/components/VerbActions.tsx`
|
||||
> all resolve at `v0.3.0`. Vellum surface *primitives*
|
||||
> (`components/ui/vellum-*.tsx`) shipped 0.3.0; the coherent vellum
|
||||
> *brand system* under `dashboard/vellum/` is 0.3.1 — slice-00 0.3.0-GT
|
||||
> seam (b) is the exact split. **Do not back-attribute the vellum brand
|
||||
> system to 0.3.0.**
|
||||
|
||||
## 1. Route reality (source-verified at `routes.tsx`@HEAD — BINDING)
|
||||
|
||||
Before narrating any surface, each route was verified at
|
||||
`packages/ui/src/routes.tsx`@HEAD (542 lines). The Phase-8.1 survey's
|
||||
"missing route" expectations are grep-derived and partially wrong; this
|
||||
table is authored from `routes.tsx` reality, not the survey:
|
||||
|
||||
| Path | Reality @HEAD | Component | Source |
|
||||
|---|---|---|---|
|
||||
| `/for-you` | **REAL destination route** | `Feed` | `routes.tsx:122-126` |
|
||||
| `/project` | **REAL destination route** | `WorkspaceScopePage` | `routes.tsx:128-132` |
|
||||
| `/project/mission/$missionId` | **REAL destination route** | `MissionScopePage` | `routes.tsx:134-138` |
|
||||
| `/project/slice/$sliceId` | **REAL destination route** | `SliceScopePage` | `routes.tsx:140-144` |
|
||||
| `/` (index) | **REAL destination route** | `Dashboard` | `routes.tsx:88-92` |
|
||||
| `/mission-control` | **DELETED `<Navigate to="/for-you">` redirect-stub** | `() => <Navigate to="/for-you">` | `routes.tsx:440-444` |
|
||||
| `/progress` | **`<Navigate to="/project">` redirect-stub** (folds into Project tabs) | `() => <Navigate to="/project">` | `routes.tsx:463-467` |
|
||||
| `/slices` | **DELETED `<Navigate to="/project">` redirect-stub** | `() => <Navigate to="/project">` | `routes.tsx:447-451` |
|
||||
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId">` redirect-stub** | `useParams` → `<Navigate>` | `routes.tsx:453-460` |
|
||||
| `/steering` | **`<Navigate to="/project">` redirect-stub** | `() => <Navigate to="/project">` | `routes.tsx:470-474` |
|
||||
| `/markdown` | **NOT a route** — `MarkdownViewer` is a component used inside `MissionScopePage` | (no route) | grep `routes.tsx`@HEAD: zero `path: "/markdown"` |
|
||||
|
||||
> Source: `routes.tsx:88-92`,`:122-144`,`:440-474` @HEAD; component
|
||||
> imports `routes.tsx:40-41` (`Dashboard`, `Feed`), `:60-63`
|
||||
> (`WorkspaceScopePage`/`MissionScopePage`/`SliceScopePage` from
|
||||
> `components/project/ScopePages.js`) @HEAD.
|
||||
|
||||
> **Route-reality contract (slice-08 §10.7):** the **Mission Control
|
||||
> *system*** lives at daemon/PL-005 (see `../architecture/mission-control.md`)
|
||||
> — `/mission-control` is **NOT a UI destination**; it is a deleted
|
||||
> redirect-stub (SC-18). The 7-verb *action vocabulary* surfaces in this
|
||||
> module via `VerbActions` embedded in For-You cards, but the system
|
||||
> itself is not this module's subject. `/progress`, `/slices`,
|
||||
> `/steering` all fold into `/project` tabs — they are redirect-stubs,
|
||||
> not surfaces to narrate.
|
||||
|
||||
## 2. For You — the attention feed (`/for-you` → `Feed`)
|
||||
|
||||
`Feed` is the operator's attention surface: a chronologically-sorted,
|
||||
subscription-filtered, soft-dismissable card stream over the live
|
||||
activity feed. Header reads `Attention` / `For You`; max width 720px.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:364-369` (`data-testid="for-you-feed"`,
|
||||
> max-w-720 + `For You` header), feed-centerpiece design note `:3-12`
|
||||
> (PRIMARY UX = the feed; subscriptions NOT dominating — LOAD-BEARING
|
||||
> SC-16) @HEAD.
|
||||
|
||||
### 2.1 The 5-card classifier (0.3.0 spine)
|
||||
|
||||
`classifyFeed(events)` maps every `ActivityEvent` to exactly one of five
|
||||
`FeedCardKind` values — `action-required`, `approval`, `shipped`,
|
||||
`progress`, `observation` — then sorts by `receivedAt` descending.
|
||||
**Nothing is silently dropped**: any unmatched event type falls through
|
||||
to `observation`. Queue-visibility events are sub-classified by
|
||||
`queueKind(type, state)` (e.g. `*.closed` → shipped; `human-gate` /
|
||||
`pending-approval` → action-required; `closeout-pending-ratify` →
|
||||
approval); a human-seat destination forces `action-required`.
|
||||
|
||||
> Source: `lib/feed-classifier.ts:9-14` (`FeedCardKind` union),
|
||||
> `classifyEvent` `:145-218` (default-observation fallthrough L216-217),
|
||||
> `queueKind` `:93-110`, `classifyFeed` `:220-223` (receivedAt-desc sort),
|
||||
> `isHumanSeat` `:141-144` @HEAD.
|
||||
|
||||
The feed pipeline (`Feed.tsx`): `classifyFeed` → cap at
|
||||
`HISTORY_LIMIT = 50` → hydrate each card's kind from the live queue-item
|
||||
map + action-audit (`hydratedCardKind`: a recorded outcome on an
|
||||
action/approval card → `approval`; a `done|closed|completed` qitem →
|
||||
`shipped`) → **subscription filter** (`isCardKindSubscribed` against the
|
||||
5 `feed.subscriptions.*` config keys; `action-required` is always
|
||||
visible, `observation` only when the audit subscription is on) → the
|
||||
transient **lens-chip** filter (All / Action req / Approvals / Shipped /
|
||||
Progress / Audit; not persisted) → per-event-seq soft-dismiss filter.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:63` (`HISTORY_LIMIT = 50`),
|
||||
> `:238` (`classifyFeed(events).slice(0, HISTORY_LIMIT)`),
|
||||
> `hydratedCardKind` `:156-180`, subscription+lens+dismiss pipeline
|
||||
> `:269-285` (subscription FIRST L280-282; lens L283; dismiss L284),
|
||||
> `LENS_CHIPS` `:54-61` @HEAD;
|
||||
> subscription forced/default rules `Feed.tsx:3-12` (action_required
|
||||
> forced ON L9; observation gated on audit_log L10) @HEAD.
|
||||
|
||||
### 2.2 Queue-item hydration + proof previews
|
||||
|
||||
For cards carrying a `qitemId` payload, `useQueueItemMap` hydrates the
|
||||
full queue-item body; for `shipped` cards `sliceForCard` matches a slice
|
||||
by tag/text and `proofPreviewForSlice` pulls the first proof packet with
|
||||
screenshots so the card renders an inline `ProofThumbnailGrid` →
|
||||
`ProofImageViewer`. This is the slice-00 §1.5 "queue cards hydrate qitem
|
||||
bodies + proof previews" foundation, live-wired.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:263` (`useQueueItemMap`),
|
||||
> `sliceForCard` `:182-201`, `proofPreviewForSlice` `:203-213`,
|
||||
> per-card proof wiring `:421-427`; `FeedCard.tsx` proof block
|
||||
> `:453-471` (`ProofPacketHeader`+`ProofThumbnailGrid`),
|
||||
> `ProofImageViewer` `:521` @HEAD.
|
||||
|
||||
### 2.3 Verb actions on action/approval cards (the 7-verb system; OPEN-3)
|
||||
|
||||
An actionable card (`action-required` or `approval`, no recorded
|
||||
outcome, non-terminal qitem) embeds `VerbActions`. The **canonical
|
||||
Mission Control action vocabulary is the 7-verb system** —
|
||||
`MISSION_CONTROL_VERBS = [approve, deny, route, annotate, hold, drop,
|
||||
handoff]` — defined at the Mission Control system level (slice-00
|
||||
0.3.0-GT §1.6/seam (c): the vocabulary *originates in 0.3.0 source*).
|
||||
|
||||
> Source: `components/mission-control/hooks/useMissionControlAction.ts:5-15`
|
||||
> (`MISSION_CONTROL_VERBS` 7-element const `:5-13` + `MissionControlVerb`
|
||||
> type `:15`), `components/mission-control/components/VerbActions.tsx:82`
|
||||
> (defaults to all 7 via `enabledVerbs = [...MISSION_CONTROL_VERBS]`),
|
||||
> per-verb input needs `:97-99` (route/handoff→destination L97;
|
||||
> annotate→annotation L98; hold/drop→reason L99) @HEAD; slice-00
|
||||
> 0.3.0-GT §1.6 + seam (c).
|
||||
|
||||
> **OPEN — For-You verb-subset (slice-00 0.3.0-GT OPEN-3; NOT smoothed).**
|
||||
> `VerbActions` accepts an `enabledVerbs` prop to restrict the offered
|
||||
> verbs (`VerbActions.tsx:21-22`). The For-You `FeedCard` currently
|
||||
> passes `enabledVerbs={["approve", "deny", "route"]}`
|
||||
> (`FeedCard.tsx:492`). **The exact shipped For-You-surface verb subset
|
||||
> is an unresolved velocity slice-01 ruling** (slice-00 0.3.0-GT OPEN-3:
|
||||
> CHANGELOG `[0.3.1]` enumerates the full 7 on `FeedCard`, while
|
||||
> source-material §6 frames it as a "subset" — the discrepancy is OPEN).
|
||||
> Per the slice-08 contract this module describes the **7-verb
|
||||
> system-level vocabulary only** and does NOT assert a definitive
|
||||
> For-You verb subset. The `enabledVerbs` prop value above is reported
|
||||
> as the literal current source state, explicitly NOT as a resolution
|
||||
> of OPEN-3.
|
||||
|
||||
On mutation success `VerbActions` fires `onOptimisticOutcome`; `Feed`
|
||||
keeps an optimistic-outcome map keyed by `qitemId` so the
|
||||
`ActionOutcomePanel` receipt renders instantly without waiting for the
|
||||
audit-log re-fetch (the audit re-fetch eventually surfaces the same
|
||||
shape). A terminal qitem with no recorded outcome derives a fallback
|
||||
receipt from its closure reason.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:224-236` (optimistic-outcome
|
||||
> map + `setOptimisticOutcome`), `:426-428` (optimistic-first, audit
|
||||
> fallback: `optimisticOutcomes.get ?? actionOutcomes.get ?? null`);
|
||||
> `FeedCard.tsx:473-500` (`isActionableCard` gate L473 + `VerbActions`
|
||||
> wiring L489-499), `isActionableCard` `:166-175`,
|
||||
> `fallbackOutcomeFromQueueItem` `:177-198`, `ActionOutcomePanel`
|
||||
> `:271-310`; `VerbActions.tsx:138-145` (`onOptimisticOutcome` on
|
||||
> mutation `onSuccess`) @HEAD.
|
||||
|
||||
### 2.4 Card surface — vellum-coherent (0.3.1 brand layer)
|
||||
|
||||
`FeedCard` renders on the **0.3.1 vellum brand system**: a
|
||||
`bg-stone-100/45 backdrop-blur` surface with a 3-stop ambient box-shadow
|
||||
(no border), four `CornerBracket` marks registering the bounds through
|
||||
the vellum, a mono-uppercase kind tag + colored dot, and runtime
|
||||
graphics marks (`ActorMark` for sessions) — the slice-00 §1.7 "shared
|
||||
graphics marks across drawers / proof rows / queue refs / story rows"
|
||||
foundation, now expressed in the matured vellum vocabulary. Cards are
|
||||
keyboard-dismissable (Backspace/Delete) and swipe-dismissable, with an
|
||||
`UndoToast`.
|
||||
|
||||
> Source: `components/for-you/FeedCard.tsx:72-73` (`CARD_SURFACE_CLASS`),
|
||||
> `:74-80` (`CARD_SHADOW_STYLE` 3-stop box-shadow), corner brackets
|
||||
> `:401-404` (4× `<CornerBracket position=…>`), `KIND_DOT` `:33-39`,
|
||||
> kind tag span `:410-414`, `ActorMark` import `:25` + usage `:229`;
|
||||
> `CornerBracket` from the 0.3.1 brand barrel
|
||||
> `dashboard/vellum/index.ts:17`; slice-00 0.3.0-GT seam (b) + §1.7 @HEAD.
|
||||
|
||||
### 2.5 Storytelling preview band
|
||||
|
||||
Above the legacy card list `Feed` renders a `StorytellingFeed` preview
|
||||
built by `buildStorytellingFeedItems` from discovered missions
|
||||
(`useMissionDiscovery` → `ProgressCard`, first 2) + slices
|
||||
(`useSlices` → `ShippedCard` for shipped/done, `IncidentCard`
|
||||
otherwise, capped at 3). Mission rows carry a daemon-derived
|
||||
`status` so the "Getting Started"-style complete-and-hide filters
|
||||
durably (`status === "complete"`), with an optimistic local hide +
|
||||
best-effort `POST /api/missions/:id/complete` audit write that swallows
|
||||
network errors so a partial-air-gapped daemon does not block the hide.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:319-343` (mission/slice adapters:
|
||||
> `useMissionDiscovery` L319, `missionsWithStatus` L329-335,
|
||||
> `buildStorytellingFeedItems` L336-343; adapter comment L305-318),
|
||||
> `handleMarkMissionComplete` `:351-361` (best-effort `void fetch` L354,
|
||||
> error-swallowed L357), preview render `:399-409`;
|
||||
> `components/feed/cards/storytelling-cards.tsx:36` (`CardKind`),
|
||||
> `ShippedCard` `:184`, `IncidentCard` `:235`, `ProgressCard` `:289`
|
||||
> @HEAD.
|
||||
|
||||
## 3. Project — workspace / mission / slice scope pages (0.3.0 spine)
|
||||
|
||||
`/project*` mounts three scope pages, all built on a shared
|
||||
`ScopeShell` + tabbed-rollup pattern (slice-00 §1.5 project
|
||||
observability foundation). A `SHARED_TABS` set —
|
||||
`overview / story / progress / artifacts / tests / queue / topology` —
|
||||
drives `WorkspaceScopePage` and `MissionScopePage`; `SLICE_TABS`
|
||||
reorders `story` first for `SliceScopePage` but `SliceScopePage`'s
|
||||
`useState` default is `overview` (README + readiness first, not a
|
||||
metric grid).
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:75-76` (`SharedTab` /
|
||||
> `SliceTab` types), `SHARED_TABS` `:78-86`, `SLICE_TABS` `:88-96`,
|
||||
> `ScopeShell` `:137-166`, `TabNav` `:98-135` @HEAD.
|
||||
|
||||
### 3.1 WorkspaceScopePage (`/project`)
|
||||
|
||||
Reads the live workspace name (`useWorkspaceName`); honest empty-state
|
||||
(`NO WORKSPACE CONNECTED` with an Open-settings action) when unset. The
|
||||
`overview` tab renders `WorkspaceOverviewPanel`: slices grouped into
|
||||
missions, then `partitionProjectMissions` splits them into a
|
||||
two-column **Current Work / Archive** layout (slice-00 §1.5
|
||||
"current/archive grouping"). Each mission card lists its slices as
|
||||
`Link`s to `/project/slice/$sliceId` with a `QueueCountIcon` +
|
||||
`StatusDot`. The other tabs are the scope rollups (§3.4).
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:676-749` (`WorkspaceScopePage`;
|
||||
> no-workspace empty-state guard `:682-693`, `workspace-scope-no-workspace`
|
||||
> `:689`), `WorkspaceOverviewPanel` `:534-674` (mission bucketing
|
||||
> `:536-551`, `partitionProjectMissions` `:552`, two-column panel `:631`,
|
||||
> Current section `:632-651`, Archive section `:652-671`) @HEAD.
|
||||
|
||||
### 3.2 MissionScopePage (`/project/mission/$missionId`)
|
||||
|
||||
Same `ScopeShell`/`SHARED_TABS`. `overview` renders the mission README
|
||||
(`useScopeMarkdown(missionPath, "README.md")` via `MarkdownViewer`)
|
||||
above a slice rail; `progress` renders a `MissionProgressHeatmap` + the
|
||||
mission `PROGRESS.md` + the shared `ScopeProgressRollup`. The `topology`
|
||||
tab renders the **projected workflow spec graph** via `TopologyTab`
|
||||
when the mission's README frontmatter declares a `workflow_spec` that is
|
||||
in the `WorkflowSpecCache` (daemon returns `topology.specGraph`),
|
||||
falling back to session-name aggregation otherwise.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:751-895` (`MissionScopePage`;
|
||||
> `useMission` `:759`, `missionPath` `:760-761`, README/PROGRESS
|
||||
> markdown `:762-763`, overview README section `:775`, progress
|
||||
> heatmap `:832`, `mission-progress-readme` `:838`, spec-graph topology
|
||||
> branch `:875-891` — `missionTopology` `:875`, `specGraph` check
|
||||
> `:879`, session-name fallback `:890`) @HEAD.
|
||||
|
||||
### 3.3 SliceScopePage (`/project/slice/$sliceId`)
|
||||
|
||||
Default tab `overview` (`SliceOverviewTab` — README + current step +
|
||||
readiness). Loading/error states are honest empty-states (the error
|
||||
state names `rig config get workspace.slices_root` as the likely
|
||||
misconfiguration). `progress` folds in `AcceptanceTab` (acceptance is
|
||||
the canonical slice-scope progress proof); `story` renders `TimelineTab`
|
||||
with curated `timeline.md` (`useSliceTimelineMarkdown`) above the
|
||||
auto-captured event feed; `topology` renders the slice's
|
||||
workflow-instance-aware `TopologyTab`.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:1187-1297` (`SliceScopePage`;
|
||||
> default `overview` `:1192`, timeline md `:1204`, loading-state
|
||||
> guard `:1206`, error-state guard `:1225` (slices_root remediation
|
||||
> hint `:1239`), story=`TimelineTab` `:1258-1265`,
|
||||
> progress=`AcceptanceTab` `:1269-1273`, topology `:1294`) @HEAD.
|
||||
|
||||
### 3.4 Shared scope rollups
|
||||
|
||||
`ScopeStoryRollup`, `ScopeProgressRollup`, `ScopeArtifactsRollup`,
|
||||
`ScopeTestsRollup`, `ScopeQueueRollup`, `ScopeTopologyRollup` (the
|
||||
non-overview tabs) all read from one `useProjectScopeRollup(missionId,
|
||||
loadDetails)` hook so workspace and mission scopes share identical
|
||||
rollup behavior over their respective slice sets — the slice-00 §1.5
|
||||
"workspace/mission rollups" foundation. Detail fetching is gated on the
|
||||
active tab (`active !== "overview"`) so the overview path stays cheap.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:198-217`
|
||||
> (`useProjectScopeRollup`; `rowsForScope` `:193-196`),
|
||||
> rollup components `:219-532`; gate `:679`,`:754` (`active !== "overview"`)
|
||||
> @HEAD.
|
||||
|
||||
## 4. Dashboard — the landing surface (`/` → `Dashboard`)
|
||||
|
||||
`Dashboard` is a thin composition over the **0.3.1 vellum brand
|
||||
system**: it imports `MidLayerContent`, `TopLayerContent`,
|
||||
`DestinationsLayer` from `dashboard/vellum/index.js` — the SAME barrel
|
||||
`/lab/vellum-lab` imports, so the production dashboard tracks the design
|
||||
lab exactly (single source of truth). Real-data wiring: `useRigSummary`
|
||||
→ totalRigs/totalAgents, `usePsEntries` → activeAgents, `useSpecLibrary`
|
||||
→ librarySize, `window.location.hostname` → classification eyebrow. Per
|
||||
a 2026-05-15 founder dispatch the heavy `BackLayerContent` /
|
||||
`BackVellumSheet` back-layer was removed so the dashboard sits on the
|
||||
page-level cream paper-grid; the barrel still exports them (used by the
|
||||
lab).
|
||||
|
||||
> Source: `components/dashboard/Dashboard.tsx:1-52` (vellum-barrel
|
||||
> import `:12-16`, single-source-of-truth note `:2-5`, real-data hooks
|
||||
> `:29-39`, back-layer-removal note `:21-27`, render `:40-50`);
|
||||
> `components/dashboard/vellum/index.ts:1-26` (barrel; `BackLayerContent`
|
||||
> `:5` / `BackVellumSheet` `:6` still exported); slice-00 0.3.0-GT
|
||||
> seam (b) + §2 rows 1/2 @HEAD.
|
||||
|
||||
## 5. Cross-cutting properties
|
||||
|
||||
- **Live daemon state, no UI-local persistence beyond soft-dismiss** —
|
||||
every surface reads daemon hooks; the only UI-local state is the
|
||||
per-event-seq dismiss set + transient lens chips + optimistic-outcome
|
||||
map (`Feed.tsx:217` lens, `:224` optimistic map, `:240-241` dismiss
|
||||
set; `ScopePages.tsx` `useState` tab state `:677`,`:753`,`:1192`)
|
||||
@HEAD.
|
||||
- **Honest empty/error states** — `WorkspaceScopePage` no-workspace
|
||||
(`ScopePages.tsx:682-693`), `WorkspaceOverviewPanel` index-unavailable
|
||||
(`:565-573`, label `:568`), `SliceScopePage` not-available
|
||||
(`:1225-1245`, label `:1235`) all surface the cause + a remediation
|
||||
pointer, never a blank screen @HEAD.
|
||||
- **0.3.0 observability spine on the 0.3.1 vellum brand layer** — the
|
||||
classifier / verb-action / scope-rollup *logic* is the 0.3.0
|
||||
foundation (§0); the *visual surface* (vellum cards, corner brackets,
|
||||
graphics marks, dashboard) is the 0.3.1 brand maturation. The split is
|
||||
slice-00 0.3.0-GT seam (b); do not collapse either direction @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN — For-You verb subset (slice-00 0.3.0-GT OPEN-3).** The exact
|
||||
shipped For-You-surface verb subset is an unresolved velocity slice-01
|
||||
ruling (CHANGELOG `[0.3.1]` "all 7" vs source-material §6 "subset"
|
||||
discrepancy). This module describes the **7-verb system-level
|
||||
vocabulary only** and reports the literal current `enabledVerbs`
|
||||
prop value (§2.3) as source state, NOT as a resolution. Defer
|
||||
For-You-subset enumeration to the pending velocity ruling.
|
||||
- **OPEN — `/project` redirect-stub consolidation.** `/progress`,
|
||||
`/slices`, `/steering` are `<Navigate to="/project">` stubs and
|
||||
`/slices/$name` redirects into `/project/slice/$sliceId` (§1). The
|
||||
former standalone surfaces fold into Project tabs; whether the stubs
|
||||
are permanent or transitional is a product decision not resolvable
|
||||
from source.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration / route-group / PL-004-event counts.
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Shell, Routing, Drawer System
|
||||
status: active
|
||||
topics: [observability]
|
||||
domains: [engineering-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Need to know how the UI shell (AppShell rail / Explorer / center workspace /
|
||||
drawer / preview stack) is assembled, the actual route tree the shipped UI
|
||||
mounts, or how the shared detail drawer and event consumption work.
|
||||
siblings: [topology.md, project-and-for-you.md, library-specs-and-design-system.md]
|
||||
prerequisite-reads: [../README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Shell, Routing, Drawer System
|
||||
|
||||
The `@openrig/ui` package is the shell-first, route-first, primitive-driven
|
||||
operator surface. Brand/visual rules live in `docs/DESIGN.md` (pointer from
|
||||
`library-specs-and-design-system.md`); this module is how the shell is
|
||||
assembled and what routes it mounts.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). UI footprint is **235** source files
|
||||
> (`packages/ui/src`, `.ts`+`.tsx`, non-test; slice-00 §1.6 / D6).
|
||||
|
||||
## 1. Package shape
|
||||
|
||||
UI package: `packages/ui`. Primary implementation areas:
|
||||
|
||||
- `src/routes.tsx` — TanStack Router route tree.
|
||||
- `src/components/AppShell.tsx` — global shell: rail, Explorer, drawer,
|
||||
preview stack.
|
||||
- `src/components/ui/` — reusable vellum + base UI primitives.
|
||||
- `src/components/graphics/RuntimeMark.tsx` — runtime/tool/actor marks.
|
||||
- `src/components/topology/` — graph/table/terminal topology surfaces.
|
||||
- `src/components/project/`, `for-you/`, `dashboard/`, `feed/` —
|
||||
observability surfaces.
|
||||
- `src/components/specs/` — Library / skills / plugins surfaces.
|
||||
- `src/components/{preview,markdown,drawer-viewers}/` — viewers.
|
||||
- `src/hooks/`, `src/lib/` — route data hooks, classifiers, formatters,
|
||||
layout/brand helpers.
|
||||
|
||||
## 2. Shell model
|
||||
|
||||
`AppShell` wraps every route. `AppShell.tsx` lays a 48px icon rail + a
|
||||
route-aware Explorer sidebar + center workspace + shared right drawer +
|
||||
preview stack + topology overlay provider + shared drawer-selection /
|
||||
discovery-placement contexts.
|
||||
|
||||
The rail destinations (`AppShell.tsx` `RAIL_ICONS`, re-confirmed at HEAD)
|
||||
are exactly six destination icons plus Advisor/Operator entries:
|
||||
|
||||
- Dashboard `/` (`rail-dashboard`)
|
||||
- Topology `/topology` (`rail-topology`)
|
||||
- For You `/for-you` (`rail-for-you`)
|
||||
- Project `/project` (`rail-project`)
|
||||
- Library `/specs` (`rail-specs`)
|
||||
- Settings `/settings` (`rail-settings`)
|
||||
|
||||
The Explorer is contextual: its tree changes with the current destination
|
||||
rather than acting as a generic file browser.
|
||||
|
||||
## 3. Route model
|
||||
|
||||
> Drift-fix D16 — `ui.md` "Route Model" (the L52–73 list) and
|
||||
> `architecture.md` §2 "UI architecture" predate 0.3.x. The route table
|
||||
> below is **re-derived from `packages/ui/src/routes.tsx` at HEAD** (542
|
||||
> lines). The route tree is assembled at `routes.tsx:480`
|
||||
> (`rootRoute.addChildren([...])`).
|
||||
>
|
||||
> Correction to the D16 expectation itself (source-verified at HEAD): the
|
||||
> survey expected `/mission-control`, `/progress`, `/markdown` as net-new
|
||||
> *real* routes. At HEAD `/mission-control` and `/progress` are **redirect
|
||||
> stubs**, not destination routes, and there is **no `/markdown` route**.
|
||||
> There IS a real `/files` route (markdown is rendered by the `MarkdownViewer`
|
||||
> component inside file/drawer surfaces, not via a `/markdown` route). The
|
||||
> route table is reported as source says, not as the survey predicted.
|
||||
|
||||
### Primary destination routes
|
||||
|
||||
| Path | Component | Notes |
|
||||
|---|---|---|
|
||||
| `/` | Dashboard (`indexRoute` `routes.tsx:88`) | rail destination |
|
||||
| `/topology` | host topology (`:98`) | + rig/pod/seat scopes (see `topology.md`) |
|
||||
| `/topology/rig/$rigId` | rig scope (`:104`) | |
|
||||
| `/topology/pod/$rigId/$podName` | pod scope (`:110`) | |
|
||||
| `/topology/seat/$rigId/$logicalId` | seat scope (`:116`) | live node details |
|
||||
| `/for-you` | attention feed (`:122`) | rail destination |
|
||||
| `/project` | workspace project scope (`:128`) | rail destination |
|
||||
| `/project/mission/$missionId` | mission scope (`:134`) | |
|
||||
| `/project/slice/$sliceId` | slice scope (`:140`) | |
|
||||
| `/specs` | Library (`:146`) | rail destination |
|
||||
| `/specs/applications` | applications section (`:152`) | |
|
||||
| `/specs/skills` | skills index (`:160`) | |
|
||||
| `/specs/skills/$skillToken` | skill viewer (`:166`) | defaults to `skill.md` |
|
||||
| `/specs/skills/$skillToken/file/$fileToken` | skill file viewer (`:175`) | |
|
||||
| `/specs/plugins` | plugins index (`:186`) | 0.3.1 (see `library-specs…`) |
|
||||
| `/plugins/$pluginId` | plugin detail (`:201`) | 0.3.1 |
|
||||
| `/specs/$specKind/$specName` | generic spec → library redirect (`:213`) | |
|
||||
| `/files` | Files workspace (`:192`) | net-new vs ui.md/D16 |
|
||||
| `/settings` | settings center (`:222`) | rail destination |
|
||||
| `/settings/policies` | Policies (`:232`) | slice 27 Claude-compaction form |
|
||||
| `/settings/log` | Log (`:237`) | slice 26 4-item Settings explorer |
|
||||
| `/settings/status` | Status (`:242`) | |
|
||||
| `/search` | audit/history view (`:248`) | |
|
||||
|
||||
### Lab routes (design experiments)
|
||||
|
||||
`/lab/project-graphics-preview` (`:254`), `/lab/card-previews` (`:262`),
|
||||
`/lab/vellum-lab` (`:272`), `/lab/vellum-bg/{a-large,b-small,c-allover}`
|
||||
(`:283`–`:293`). The vellum-lab + vellum-bg routes are the 0.3.1 vellum
|
||||
brand-system experiment surface (slice-00 0.3.0-GT seam b: vellum brand
|
||||
identity matured in 0.3.1; the `dashboard/vellum/*` system is 0.3.1).
|
||||
|
||||
### Legacy / compatibility routes
|
||||
|
||||
`/rigs/$rigId` (`:318`), `/rigs/$rigId/nodes/$logicalId` (`:324`),
|
||||
`/import` (`:333`), `/packages`, `/packages/install`, `/packages/$packageId`
|
||||
(`:339`–`:351`), `/bootstrap` (`:357`), `/agents/validate` (`:363`),
|
||||
`/specs/rig` + `/specs/agent` review (`:372`/`:378`),
|
||||
`/specs/library/$entryId` (`:384`), `/discovery` + `/discovery/inventory`
|
||||
(`:395`/`:410`), `/bundles/inspect` + `/bundles/install` (`:416`/`:422`).
|
||||
|
||||
### Redirect stubs (deleted routes)
|
||||
|
||||
`routes.tsx:432`–`:474` mount `<Navigate>` redirects for removed routes —
|
||||
NOT destination pages: `/context` → `/topology`; `/mission-control` →
|
||||
`/for-you` (SC-18: `/mission-control` deleted, For-You replaces it);
|
||||
`/slices` + `/slices/$name` → `/project`; `/progress` → `/project`;
|
||||
`/steering` → `/project`. The Mission Control *system* still exists at the
|
||||
daemon/PL-005 layer (`../architecture/mission-control.md`); only the old UI
|
||||
*route* was retired in favour of For-You.
|
||||
|
||||
## 4. Drawer and viewer system
|
||||
|
||||
`SharedDetailDrawer` is the shared transient detail surface for queue-item
|
||||
viewer, file viewer, subspec preview, and other detail selections. Drawer
|
||||
triggers carry root/path provenance so the viewer fetches/render the correct
|
||||
content; the drawer supports outside-click dismissal. Image proof viewing is
|
||||
handled by `ProofImageViewer` with black-glass styling honouring the shell
|
||||
layout. Skill detail uses Library Explorer navigation + `FileViewer`
|
||||
(`components/drawer-viewers/FileViewer.tsx`) in the center workspace, not a
|
||||
nested three-column browser.
|
||||
|
||||
## 5. Event and activity consumption
|
||||
|
||||
UI event consumption is centralized through shared event hooks so the app
|
||||
avoids duplicate SSE connections for related surfaces. Current consumers: For
|
||||
You feed hydration, topology activity rings + hot-potato movement, the
|
||||
activity feed/system surfaces, and rig event surfaces. Reduced-motion
|
||||
preference is honoured for pulse/packet animation. (Daemon SSE surfaces:
|
||||
`/api/events`, `/api/stream/watch`, `/api/queue/watch` —
|
||||
`architecture-rules-and-event-system.md` §2.4.)
|
||||
|
||||
## See also
|
||||
|
||||
- `topology.md` — graph/table/terminal topology surface.
|
||||
- `project-and-for-you.md` — project observability + For-You feed.
|
||||
- `library-specs-and-design-system.md` — Library/specs + DESIGN.md pointer.
|
||||
- Source roots: `packages/ui/src/routes.tsx`,
|
||||
`packages/ui/src/components/AppShell.tsx`.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Topology — Graph/Table/Terminal, HotPotato, ActivityRing
|
||||
status: active
|
||||
topics: [observability, coordination]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the topology surface is built — the host hybrid graph,
|
||||
the table/terminal views, the activity-ring / hot-potato visual language,
|
||||
terminal-preview popovers, and the topology navigation/overlay contracts.
|
||||
siblings: [shell-and-routing.md, project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Topology — Graph/Table/Terminal, HotPotato, ActivityRing
|
||||
|
||||
Topology is a scoped workspace with graph, table, and terminal views at the
|
||||
`/topology` route family (`shell-and-routing.md` §3). It is the operator's
|
||||
live picture of host → rig → pod → seat.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). All component names below are re-confirmed against
|
||||
> `packages/ui/src/components/topology/` at HEAD — `ui.md` and `DESIGN.md`
|
||||
> name *exported symbols*; the source file that defines the hybrid nodes is
|
||||
> `HybridTopologyNodes.tsx` (DESIGN.md L344 implementation reference).
|
||||
|
||||
## 1. Topology pieces
|
||||
|
||||
> Drift-fix — `ui.md` "Topology" (L108–137) lists `HybridAgentNode` /
|
||||
> `HybridPodGroupNode` as if top-level files. Re-confirmed at HEAD: both are
|
||||
> exports inside `packages/ui/src/components/topology/HybridTopologyNodes.tsx`
|
||||
> (`HybridPodGroupNode` is `memo(...)` at `HybridTopologyNodes.tsx:94`;
|
||||
> `HybridAgentNode` is `memo(HybridAgentNodeInner, ...)` at `:272`). The
|
||||
> component names are accurate; their file location is consolidated.
|
||||
|
||||
Components in `packages/ui/src/components/topology/` (re-confirmed at HEAD):
|
||||
|
||||
- `HostMultiRigGraph.tsx` — host-level hybrid React Flow graph (multi-rig
|
||||
single canvas).
|
||||
- `HybridTopologyNodes.tsx` — exports `HybridAgentNode` (compact agent cards:
|
||||
runtime badges, context %, token totals, activity state, terminal preview,
|
||||
CMUX actions) and `HybridPodGroupNode` (soft dashed pod frames).
|
||||
- `RigGroupNode.tsx` — soft rig frames with registration marks + aggregate
|
||||
activity.
|
||||
- `ActivityRing.tsx` — active / needs-input / blocked activity ring; card
|
||||
activity classes in `activity-card-visuals.ts`.
|
||||
- `HotPotatoEdge.tsx` — directional queue-movement edge animation.
|
||||
- `TopologyTableView.tsx` — dense table mirror of topology data.
|
||||
- `TopologyTerminalView.tsx` — terminal-oriented topology state.
|
||||
- `TopologyTreeView.tsx` — tree navigation view.
|
||||
- `TopologyViewModeTabs.tsx` — graph/table/terminal/tree mode switch.
|
||||
- `TerminalPreviewPopover.tsx` — black-glass quick terminal preview, portaled
|
||||
above the graph.
|
||||
- `LaunchCmuxButton.tsx` — hover/focus CMUX launch action.
|
||||
- `ScopePages.tsx` — host/rig/pod/seat scope page wrappers.
|
||||
- `topology-overlay-context.tsx` — `TopologyOverlayProvider` (expanded-rig
|
||||
state).
|
||||
|
||||
> Note (vs `DESIGN.md` "Topology" L205–216): DESIGN.md lists the same
|
||||
> exported primitives (`HostMultiRigGraph`, `HybridAgentNode`,
|
||||
> `HybridPodGroupNode`, `RigGroupNode`, `ActivityRing`, `HotPotatoEdge`,
|
||||
> `TerminalPreviewPopover`, `TopologyTableView`, `TopologyTerminalView`) —
|
||||
> reconciled against source: all present at HEAD. `TopologyTreeView` /
|
||||
> `TopologyViewModeTabs` / `LaunchCmuxButton` exist in source but are not in
|
||||
> DESIGN.md's primitive list (DESIGN.md is the brand primitive list, not an
|
||||
> exhaustive component inventory; not a drift, a scope difference). DESIGN.md
|
||||
> stays byte-identical (Q1).
|
||||
|
||||
## 2. Topology contracts
|
||||
|
||||
From `architecture.md` §2 "Current topology UI" + `ui.md` "Important
|
||||
topology contracts", re-confirmed against source behaviour at HEAD:
|
||||
|
||||
- Graph / table / tree navigation all resolve to seat detail URLs
|
||||
(`/topology/seat/$rigId/$logicalId`).
|
||||
- The host-level graph uses cross-rig node-ID prefixing for multi-rig single
|
||||
canvas.
|
||||
- Expanded-rig state is owned by `TopologyOverlayProvider`
|
||||
(`topology-overlay-context.tsx`); graph data is lazy-fetched for expanded
|
||||
rigs only.
|
||||
- Compact agent cards show context percentage, token totals, and activity
|
||||
card tint; `ActivityRing` + activity classes surface active / needs-input /
|
||||
blocked.
|
||||
- `HotPotatoEdge` animates directional queue movement at graph zoom levels;
|
||||
reduced-motion preference removes pulse/travel animation and keeps static
|
||||
state signals.
|
||||
- Terminal-preview actions are hover/focus-visible; the
|
||||
`TerminalPreviewPopover` must escape React Flow stacking contexts (portaled
|
||||
above the canvas) and stay within the viewport.
|
||||
|
||||
## 3. Layout helpers
|
||||
|
||||
Topology layout is computed by `src/lib/` helpers (re-confirmed at HEAD):
|
||||
`graph-layout.ts`, `hybrid-layout.ts`, `multi-rig-layout.ts`,
|
||||
`topology-activity.ts`, `activity-visuals.ts`. Runtime/tool identity on the
|
||||
cards comes from the central `runtime-brand.ts` / `tool-brand.ts` +
|
||||
`RuntimeMark.tsx` (do not duplicate brand logic — DESIGN.md "Do not";
|
||||
restated here as the topology-card brand-source contract).
|
||||
|
||||
## See also
|
||||
|
||||
- `shell-and-routing.md` — the `/topology` route family and shell.
|
||||
- `project-and-for-you.md` — sibling observability surface.
|
||||
- `../architecture/coordination-primitive.md` — the queue movement the
|
||||
hot-potato edge visualizes.
|
||||
- Source root: `packages/ui/src/components/topology/`,
|
||||
`packages/ui/src/lib/{graph,hybrid,multi-rig}-layout.ts`.
|
||||
@@ -0,0 +1,362 @@
|
||||
# OpenRig v0.3.2
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.2 ships Rigbundles as a first-class cross-primitive bundling
|
||||
mechanism, GAs the workspace + workflow operator surface, lands a
|
||||
4-bug `rig up <starter>` paper-cut fix-round, ships a 5-fix
|
||||
coordination + first-user-setup round (slice 21), scrubs internal
|
||||
substrate paths from daemon source + tests, wires `ConceptCard` to a
|
||||
shaped data source, and adds server-side priority windowing on For You.
|
||||
|
||||
No schema-breaking migrations. Existing v0.3.1 databases upgrade on first
|
||||
`rig daemon start` (adds migration `041_rig_policy` for the new
|
||||
operator-context-mode bindings table — `CREATE TABLE` with no impact on
|
||||
existing data; binding rows are operator-authored at runtime).
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **Rigbundles First-Class (slice 05)** — cross-primitive bundling for
|
||||
five content kinds (skills + plugins HYBRID + workflow_specs +
|
||||
context_packs + agent_images) routed end-to-end. `rig bundle create`
|
||||
auto-detects an author `bundle.yaml`, vendors declared content with
|
||||
both-sides path containment and symlink-escape protection, and
|
||||
emits an integrity-hashed manifest. `rig bundle install` routes
|
||||
each content kind to its canonical library at `$OPENRIG_HOME`
|
||||
with consumer-scan visibility. `rig bundle inspect` surfaces the
|
||||
cross-primitive content fields as first-class. CLI install timeout
|
||||
bumped (the 5-second cap was too short for tmux-session-bootstrapping
|
||||
installs).
|
||||
- **Workspace + Workflow GA (slice 01)** — operator-facing surface
|
||||
hardened. `rig workspace validate --max-files` strict-int regex;
|
||||
`rig workflow project --exit` enum guard; 3-part error returned
|
||||
before `client.post` for early-fail clarity; 14 new discriminator
|
||||
tests on the validator paths.
|
||||
- **`rig up <starter>` Paper-Cut Fix-Round (slice 22)** — unblocks
|
||||
the homepage quick-start on fresh 0.3.1→0.3.2 installs. Four bounded
|
||||
fixes:
|
||||
- HTTP 4xx surface for pre-launch failures
|
||||
(`cycle_error` / `preflight_failed` / `validation_failed` /
|
||||
`service_boot_failed`) — was bare 500.
|
||||
- No orphan rig record on pre-launch failure — instantiator
|
||||
re-ordered + rollback path + compose teardown order tightened.
|
||||
- Path-form `rig up <install-internal-spec>` defaults `cwd` to the
|
||||
caller directory (closes the divergence between path-form and
|
||||
bare-form invocation).
|
||||
- `walkYamlFiles` skips standard noise dirs (`.worktrees`,
|
||||
`node_modules`, `.git`, `dist`, `.turbo`, `.next`) + stale
|
||||
`workflow_specs` rows prune at startup with an install-root
|
||||
preservation guard (so shipped built-in specs survive the prune).
|
||||
- **Coordination + First-User Setup Fix-Round (slice 21)** — five
|
||||
bounded follow-rounds:
|
||||
- FR-1: Coordination-model boot instinct surfaced in
|
||||
`core/openrig-user/SKILL.md` (`rig send` vs `rig queue` vs
|
||||
`rig queue handoff` + §1b doctrine).
|
||||
- FR-2: First-user workspace + workflow setup teaching content
|
||||
(skill + docs).
|
||||
- FR-3: MISSION_NOTES durable-pattern hardening (template +
|
||||
codemap convention + scope-primitive auto-scaffold via
|
||||
`rig scope mission create`).
|
||||
- FR-4: Queue ergonomics — `rig queue create --body-file <path>`
|
||||
(use `-` for stdin) kills the backtick-shell-corruption class for
|
||||
multiline bodies; first-class `--mission` / `--slice` flags
|
||||
compose into tags; `lastNudgeResult` wording fix;
|
||||
closure-vs-acceptance documented.
|
||||
- FR-5: `rig workspace doctor` subcommand — 7-check workspace
|
||||
readiness diagnostic (workspace root, missions folder, file
|
||||
allowlist, daemon alignment, daemon reload, optional slice
|
||||
docs, MISSION_NOTES presence).
|
||||
- **Daemon Test Substrate-Path Scrub (slice 14)** — internal-team
|
||||
substrate path shape scrubbed from 4 daemon source sites + 10 test
|
||||
files. Hook-constants de-duplicated. Privacy class closed for
|
||||
tracked source/test surface.
|
||||
- **`ConceptCard` Data Source (slice 17)** — wired to shaped backlog
|
||||
candidates; storytelling-adapter completion deferred from 0.3.1
|
||||
is now complete.
|
||||
- **For-You Priority Windowing (slice 20)** — server-side attention
|
||||
query (Option 3): SQL predicate pushdown, exact-match attention
|
||||
regex, dismissal as string-keyed for queue-derived cards.
|
||||
- **Operator Context-Mode Bindings (slice 09)** — new `rig policy`
|
||||
command surface paired with a daemon-side typed-primitive store
|
||||
(migration `041_rig_policy`). Six modes
|
||||
(`sleep | desk | mobile | away | focus | debug`) bind to four
|
||||
scope tiers (`global_host | rig | workstream | qitem`) with a
|
||||
restate-and-confirm posture so scripts cannot silently apply a
|
||||
binding without explicit `--confirm`. Citation, effective-mode
|
||||
resolve, and `defaults` commands round out the surface.
|
||||
- **Scope Tree Primitive (slice 12)** — new `rig scope` command for
|
||||
operating the substrate scope tree (missions, slices, sub-slices)
|
||||
per `conventions/scope-and-versioning`. Mission-tier
|
||||
(`ls` / `show` / `create`) + slice-tier
|
||||
(`ls` / `show` / `create` / `ship` / `close` / `move`) commands.
|
||||
Auto-mints stable dot-IDs into mission frontmatter; mission
|
||||
templates include `release` / `placeholder` / `mission-notes`.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### Rigbundles First-Class
|
||||
|
||||
`rig bundle` now ships first-class cross-primitive bundling:
|
||||
|
||||
```
|
||||
rig bundle create <spec> -o <path>
|
||||
[--name <n>] [--bundle-version <v>] [--include-packages <refs...>]
|
||||
[--rig-root <r>] [--notes <text>]
|
||||
[--min-daemon-version <ver>] [--min-cli-version <ver>] [--json]
|
||||
rig bundle inspect <path> [--json]
|
||||
rig bundle install <path>
|
||||
[--plan] [--yes] [--target <root>]
|
||||
[--skip-version-check] [--force] [--json]
|
||||
rig bundle history [--rig <name>] [--since <iso>] [--json]
|
||||
```
|
||||
|
||||
Content kinds routed at install: `skills`, `plugins` (hybrid), `workflow_specs`,
|
||||
`context_packs`, `agent_images`. Each kind lands in its canonical
|
||||
library under `$OPENRIG_HOME`; consumer-scan visibility is preserved.
|
||||
|
||||
`bundle.yaml` author manifests are auto-detected when present.
|
||||
Vendoring uses both-sides path containment (source and destination)
|
||||
and symlink-escape protection. Integrity hashing is captured in the
|
||||
bundle manifest.
|
||||
|
||||
`--skip-version-check` and `--force` on `install` are operator-explicit
|
||||
overrides for the install-time daemon/CLI compatibility gate and the
|
||||
install-time conflict gate respectively (NOT recommended for routine
|
||||
use; the overrides exist for known-good operator scenarios).
|
||||
|
||||
`rig bundle history` reads `~/.openrig/bundle-audit.jsonl` — the
|
||||
audit ledger of every install — with optional filters on
|
||||
target rig name and earliest install time.
|
||||
|
||||
### Workspace Doctor (`rig workspace doctor`)
|
||||
|
||||
7-check workspace-readiness diagnostic against the daemon's resolved
|
||||
workspace:
|
||||
|
||||
```
|
||||
rig workspace doctor [--workspace <path>] [--strict] [--json]
|
||||
```
|
||||
|
||||
Checks: workspace root, missions folder, file allowlist, daemon
|
||||
alignment, daemon reload, optional slice docs, MISSION_NOTES presence.
|
||||
|
||||
Default exit-code: non-zero only on `fail`. `--strict` makes
|
||||
warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST`
|
||||
from the operator's shell env so the diagnostic reflects the
|
||||
operator's intended allowlist even when the daemon's env is unchanged.
|
||||
|
||||
### Queue Ergonomics
|
||||
|
||||
`rig queue create` accepts a body from a file or stdin and exposes
|
||||
first-class mission/slice scope:
|
||||
|
||||
```
|
||||
rig queue create --source <s> --destination <d> \
|
||||
(--body <text> | --body-file <path>) \
|
||||
[--mission <id>] [--slice <id>] \
|
||||
[--priority <p>] [--tier <t>] [--tags <csv>] \
|
||||
[--target-repo <name>] [--no-nudge] \
|
||||
[--expires-at <iso>] [--id <qitemId>] [--json]
|
||||
```
|
||||
|
||||
`--body-file -` reads from stdin. `--mission <id>` / `--slice <id>`
|
||||
translate to `mission:<id>` / `slice:<id>` tags (compose with
|
||||
`--tags`).
|
||||
|
||||
### Operator Context-Mode Bindings (`rig policy`)
|
||||
|
||||
```
|
||||
rig policy set <mode> [--scope <scope>] [--qualifier <id>] \
|
||||
[--<field> ...] [--evidence <citation>] \
|
||||
[--confirm] [--bearer <token>] [--json]
|
||||
rig policy show [--json]
|
||||
rig policy effective [--rig <id>] [--workstream <id>] [--qitem <id>] [--json]
|
||||
rig policy cite [--rig <id>] [--workstream <id>] [--qitem <id>]
|
||||
rig policy unset <scope> [qualifier] [--bearer <token>] [--json]
|
||||
rig policy defaults [--json]
|
||||
```
|
||||
|
||||
`set` is restate-only without `--confirm` (the CLI echoes the proposed
|
||||
binding and exits with `exit 2` so scripts cannot silently apply).
|
||||
|
||||
Six modes: `sleep | desk | mobile | away | focus | debug`. Four
|
||||
scopes: `global_host | rig | workstream | qitem`. `--qualifier` is
|
||||
required for `rig | workstream | qitem` scopes and explicitly
|
||||
rejected for `global_host`.
|
||||
|
||||
Operator-edit verbs (`set --confirm`, `unset`) require an operator
|
||||
bearer token (`--bearer` or `OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
|
||||
### Scope Tree Primitive (`rig scope`)
|
||||
|
||||
```
|
||||
rig scope slice ls [--mission <m>] [--state <s>] [--json]
|
||||
rig scope slice show <slice-path> [--mission <m>] [--json]
|
||||
rig scope slice create <mission> <slug> [--template <k>] [--title <t>] [--json]
|
||||
rig scope slice ship <slice-path> <release-mission> [--mission <m>] [--json]
|
||||
rig scope slice close <slice-path> --reason <r> [--note <t>] [--mission <m>] [--json]
|
||||
rig scope slice move <slice-path> <dest-mission> [--mission <m>] [--json]
|
||||
rig scope mission ls [--json]
|
||||
rig scope mission show <mission> [--json]
|
||||
rig scope mission create <name> [--template <k>] [--id <dot-id>] [--title <t>] [--json]
|
||||
```
|
||||
|
||||
Top-level option `--workspace <path>` overrides workspace root
|
||||
(otherwise inferred from `cwd` or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
### `rig up` Paper-Cut Fixes
|
||||
|
||||
```
|
||||
rig up <source> [--plan] [--yes] [--cwd <path>] [--target <root>] [--existing] [--json]
|
||||
```
|
||||
|
||||
- Pre-launch failures now return structured HTTP 4xx
|
||||
(`cycle_error` / `preflight_failed` / `validation_failed` /
|
||||
`service_boot_failed`) instead of bare 500.
|
||||
- Failed boots no longer leave orphan rig records on disk.
|
||||
- Path-form invocations default `cwd` to the caller directory.
|
||||
- `--existing` skips library-spec name resolution and treats
|
||||
`<source>` as an existing rig name directly.
|
||||
|
||||
### MISSION_NOTES Durable Pattern (slice 21 FR-3)
|
||||
|
||||
`rig scope mission create` auto-scaffolds `MISSION_NOTES.md` from
|
||||
`conventions/mission-notes/TEMPLATE.md` by default; `--no-mission-notes`
|
||||
opts out (rare). The convention codifies the durable-pattern model
|
||||
documented in workspace skills + canonical templates so missions
|
||||
ship with a uniform top-of-mind notes surface.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/[email protected]
|
||||
rig --version # expect 0.3.2
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
Migration `041_rig_policy` applies automatically on first post-upgrade
|
||||
daemon start. It is a non-destructive `CREATE TABLE` with no impact on
|
||||
existing data; binding rows are operator-authored at runtime through
|
||||
`rig policy set --confirm`.
|
||||
|
||||
For the new `rig policy` surface: opt-in default-none. With no
|
||||
bindings set, no behavior change. Use `rig policy defaults` to inspect
|
||||
the recommended per-mode 6×7 + default-scope mapping before authoring.
|
||||
|
||||
For the new `rig scope` surface: read-only against workspace by
|
||||
default (`ls`/`show`); authoring verbs (`create`/`ship`/`close`/`move`)
|
||||
operate on the substrate scope tree as advertised. Use `--workspace
|
||||
<path>` to override the resolved workspace root.
|
||||
|
||||
## Known Limitations / 0.3.3 Deferrals
|
||||
|
||||
- **Slice-05 Item-3 sub-scopes deferred to 0.3.3**: agent/port/managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-rig
|
||||
pathway acceptance (Item 4.4); the `--target-name` CLI flag for
|
||||
install-time rig-name overrides. Design-contingent on the CLI surface
|
||||
decision (A-breaking rename vs A-additive new flag).
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema deferred to 0.3.3**:
|
||||
the new `accepted` queue state is a schema model change; carried
|
||||
forward per the banked release-triage philosophy.
|
||||
- **Onboarding journey + battle-hardening deferred to 0.3.3**: slices
|
||||
04 (new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) physically moved to the `release-0.3.3` mission tree
|
||||
at wrap.
|
||||
- **Workspace symlink alignment**: the daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots is a follow-up
|
||||
surfaced by slice-21 FR-5 QA; non-blocking for 0.3.2.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental` only;
|
||||
not split-deferred to 0.3.3 (stays held).
|
||||
- **`rig down <name>` still returns HTTP 404** — name-based teardown
|
||||
still doesn't resolve through the daemon down route at v0.3.2; use
|
||||
`rig down <rigId> --delete` instead. Carries forward as known issue.
|
||||
- **`rig view list/show --json` flag inconsistency** — flag scaffolding
|
||||
on the wrapper-layer routing path remains; the daemon's
|
||||
`view show <name>` route returns JSON correctly when invoked directly
|
||||
(per v0.3.1 cutover evidence). Workaround: use human-readable output
|
||||
for now. Wrapper-layer fix is the documented target.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- `npm run test:repo` green.
|
||||
- Daemon test suite: 3996 PASS at slice-22 close.
|
||||
- TypeScript checks clean on every touched file at per-commit guard.
|
||||
- Per-commit guard CLEAR across every shipped slice;
|
||||
velocity-qa adversarial dogfood + orch heavy pre-merge verify on
|
||||
each merge.
|
||||
- Post-QA-fix deltas (when present) routed back to guard before
|
||||
merge per the banked qa-fix-lands-post-guard pattern.
|
||||
- Review-lead continuous-parallel-review ADVISORY-CLEAN on slice-22
|
||||
close with five POSITIVE observations.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.1 → v0.3.2 delta is 143 commits / 203 files /
|
||||
+29,028 insertions / −2,081 deletions. The list below groups
|
||||
load-bearing commits by theme:
|
||||
|
||||
### Rigbundles First-Class
|
||||
- `feat(daemon,cli)`: cross-primitive bundle routing — five content
|
||||
kinds plumbed end-to-end with both-sides path containment +
|
||||
symlink-escape protection + integrity hashing.
|
||||
- `feat(cli)`: `rig bundle` surface — `create` / `inspect` /
|
||||
`install` / `history` subcommands with operator-explicit override
|
||||
flags on install.
|
||||
|
||||
### Workspace + Workflow GA
|
||||
- `feat(daemon,cli)`: workspace `validate --max-files` strict-int
|
||||
regex; workflow `project --exit` enum guard; 3-part error before
|
||||
`client.post`.
|
||||
- `feat(cli)`: 14 new discriminator tests on the validator paths.
|
||||
|
||||
### `rig up <starter>` Paper-Cut Fix-Round
|
||||
- `fix(daemon)`: HTTP 4xx surface for pre-launch failures.
|
||||
- `fix(daemon)`: no orphan rig record on pre-launch failure.
|
||||
- `fix(cli)`: path-form `rig up` defaults `cwd` to caller dir.
|
||||
- `fix(daemon)`: `walkYamlFiles` skips standard noise dirs + stale
|
||||
`workflow_specs` row prune with install-root preservation.
|
||||
|
||||
### Coordination + First-User Setup Fix-Round (slice 21)
|
||||
- `feat(skill)`: coordination-model boot instinct surfaced in
|
||||
`core/openrig-user/SKILL.md`.
|
||||
- `feat(docs)`: first-user workspace + workflow setup teaching.
|
||||
- `feat(cli)`: MISSION_NOTES auto-scaffold via
|
||||
`rig scope mission create`.
|
||||
- `feat(cli)`: `rig queue create --body-file` + first-class
|
||||
`--mission` / `--slice` flags.
|
||||
- `feat(daemon,cli)`: `rig workspace doctor` 7-check diagnostic.
|
||||
|
||||
### Daemon Test Substrate-Path Scrub
|
||||
- `chore(privacy)`: substrate path shape scrubbed from daemon source
|
||||
+ test surface; hook-constants de-duplicated.
|
||||
|
||||
### ConceptCard Data Source
|
||||
- `feat(ui,daemon)`: wire `ConceptCard` to shaped backlog candidates.
|
||||
|
||||
### For-You Priority Windowing
|
||||
- `feat(daemon)`: server-side attention query with SQL predicate
|
||||
pushdown.
|
||||
- `feat(ui)`: dismissal as string-keyed for queue-derived cards.
|
||||
|
||||
### Operator Context-Mode Bindings
|
||||
- `feat(daemon)`: migration `041_rig_policy` + typed-primitive store.
|
||||
- `feat(cli)`: `rig policy` command surface
|
||||
(`set` / `show` / `effective` / `cite` / `unset` / `defaults`).
|
||||
|
||||
### Scope Tree Primitive
|
||||
- `feat(cli)`: `rig scope` command surface — mission + slice tiers.
|
||||
- `feat(cli)`: scope templates (release, placeholder, mission-notes,
|
||||
bug-fix, research, etc.) ship under `dist/lib/scope-templates/`.
|
||||
|
||||
### Privacy and Release Prep
|
||||
- `chore(privacy)`: pre-release scrub of residual founder-attribution
|
||||
comments + an internal-shape substrate-path JSDoc example.
|
||||
|
||||
See `CHANGELOG.md` for the full agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -7419,7 +7419,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7450,7 +7450,7 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7474,7 +7474,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface DaemonResponse<T = unknown> {
|
||||
|
||||
interface DaemonRequestOptions {
|
||||
timeoutMs?: number;
|
||||
/** Per-call header overrides (e.g., `Authorization: Bearer ...`). */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DaemonClientOptions {
|
||||
@@ -79,7 +81,20 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" }, options);
|
||||
return this.requestJson<T>(path, {
|
||||
method: "DELETE",
|
||||
headers: options?.headers,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async put<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
const baseHeaders: Record<string, string> = body !== undefined ? { "Content-Type": "application/json" } : {};
|
||||
const headers = { ...baseHeaders, ...(options?.headers ?? {}) };
|
||||
return this.requestJson<T>(path, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}, options);
|
||||
}
|
||||
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
|
||||
|
||||
@@ -1,10 +1,46 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* Read the CLI's own package.json version at call time (Item 1 / slice-05).
|
||||
* Function-level read on purpose: module-level constants would mask test
|
||||
* isolation per the audit-every-layer discipline.
|
||||
*/
|
||||
function getCliVersion(): string {
|
||||
try {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgPath = nodePath.join(nodePath.dirname(here), "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: unknown };
|
||||
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provenance block the CLI sends to /api/bundles/create. Reads
|
||||
* hostname, session name (from canonical OPENRIG_SESSION_NAME env), and
|
||||
* CLI version at call time. Operator notes come from the --notes flag.
|
||||
* Daemon adds daemonVersion server-side.
|
||||
*/
|
||||
function buildClientProvenance(notes: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
sourceHost: os.hostname(),
|
||||
cliVersion: getCliVersion(),
|
||||
};
|
||||
const session = process.env.OPENRIG_SESSION_NAME;
|
||||
if (typeof session === "string" && session.length > 0) out.authorSession = session;
|
||||
if (typeof notes === "string" && notes.length > 0) out.notes = notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("bundle").description("Manage rig bundles");
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -26,17 +62,38 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--bundle-version <ver>", "Bundle version", "0.1.0")
|
||||
.option("--include-packages <refs...>", "Package refs to include (default: all from spec)")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--notes <text>", "Operator notes captured in bundle provenance metadata")
|
||||
.option("--min-daemon-version <ver>", "Minimum daemon version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--min-cli-version <ver>", "Minimum CLI version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; minDaemonVersion?: string; minCliVersion?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// Item 2 / slice-05: build compatibility from operator flags. Only included
|
||||
// in the request body when at least one of the two flags is set.
|
||||
const compatibility: Record<string, string> = {};
|
||||
if (opts.minDaemonVersion) compatibility.minDaemonVersion = opts.minDaemonVersion;
|
||||
if (opts.minCliVersion) compatibility.minCliVersion = opts.minCliVersion;
|
||||
const hasCompatibility = Object.keys(compatibility).length > 0;
|
||||
|
||||
// QA-20260601 A1 repair (banked banked-create-install-long-running):
|
||||
// /create can take seconds-to-minutes (pod-aware assembler walk +
|
||||
// Item-6 cross-primitive vendoring + integrity-over-all-content +
|
||||
// tar pack). Default 5000ms CLI timeout (client.ts:31) is too short
|
||||
// for real-world bundles; daemon completes work but CLI reports
|
||||
// failure. Per-call 120s upper bound covers reasonable real-world
|
||||
// bundle sizes; if a bundle author hits this, the daemon-side
|
||||
// operation is async-completable in a future /bundles/jobs/<id>
|
||||
// surface (not in slice-05 scope).
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
|
||||
specPath: spec, bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: opts.output,
|
||||
includePackages: opts.includePackages,
|
||||
rigRoot: opts.rigRoot ? nodePath.resolve(opts.rigRoot) : undefined,
|
||||
});
|
||||
provenance: buildClientProvenance(opts.notes),
|
||||
...(hasCompatibility ? { compatibility } : {}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -92,15 +149,31 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--plan", "Plan mode")
|
||||
.option("--yes", "Auto-approve")
|
||||
.option("--target <root>", "Target root directory")
|
||||
.option("--skip-version-check", "Operator-explicit override of the Item-2 install-time compatibility check (NOT recommended for routine use)")
|
||||
.option("--force", "Operator-explicit override of the Item-3 install-time conflict check (NOT recommended; conflicts may produce partial install state)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; json?: boolean }) => {
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; skipVersionCheck?: boolean; force?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// QA-20260601 A1 repair: /install completes a full bootstrap run
|
||||
// (resolve → vendor sibling primitives → boot rig sessions) which
|
||||
// can take many seconds for real bundles. Default 5000ms CLI
|
||||
// timeout (client.ts:31) caused CLI to report failure while
|
||||
// daemon completed the mutating install — operator-unsafe retry
|
||||
// path (rig_name_collision on second attempt). Bumped to 120s.
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/install", {
|
||||
bundlePath, plan: opts.plan ?? false, autoApprove: opts.yes ?? false, targetRoot: opts.target,
|
||||
});
|
||||
// Item 2 / slice-05 Checkpoint 3.3: send CLI version + skip flag for the
|
||||
// daemon-side install-time compatibility check. CLI version read at call
|
||||
// time (no module-level constant) via the existing getCliVersion helper.
|
||||
cliVersion: getCliVersion(),
|
||||
skipVersionCheck: opts.skipVersionCheck ?? false,
|
||||
// Item 3 / slice-05 Checkpoint 4.2: send force flag for the daemon-side
|
||||
// install-time conflict check. Operator-explicit override only.
|
||||
force: opts.force ?? false,
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -118,5 +191,48 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
if (res.data["rigId"]) console.log(`Rig: ${res.data["rigId"]}`);
|
||||
});
|
||||
|
||||
// rig bundle history — Item 4 / slice-05 Checkpoint 5.2
|
||||
cmd.command("history")
|
||||
.description("List bundle install audit records from ~/.openrig/bundle-audit.jsonl")
|
||||
.option("--rig <name>", "Filter to records whose targetRigName matches")
|
||||
.option("--since <iso>", "Filter to records installedAt >= this ISO timestamp")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { rig?: string; since?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.since) qs.set("since", opts.since);
|
||||
const query = qs.toString();
|
||||
const path = query.length > 0 ? `/api/bundles/history?${query}` : "/api/bundles/history";
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(path);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(res.data["error"] ?? "History fetch failed");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const records = Array.isArray(res.data["records"]) ? res.data["records"] as Array<Record<string, unknown>> : [];
|
||||
if (records.length === 0) {
|
||||
console.log("No bundle install audit records found.");
|
||||
return;
|
||||
}
|
||||
console.log(`Bundle install history (${records.length} record${records.length === 1 ? "" : "s"}):`);
|
||||
for (const r of records) {
|
||||
const at = r["installedAt"] ?? "?";
|
||||
const rig = r["targetRigName"] ?? "?";
|
||||
const outcome = r["outcome"] ?? "?";
|
||||
const bundle = r["bundlePath"] ?? "?";
|
||||
console.log(` ${at} ${outcome.toString().padEnd(8)} rig=${rig} ${bundle}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { renderMissionNotesTemplate } from "../lib/scope/templates.js";
|
||||
|
||||
export interface InitWorkspaceOpts {
|
||||
root?: string;
|
||||
@@ -39,6 +40,11 @@ export interface InitWorkspaceResult {
|
||||
|
||||
type DefaultSlice = {
|
||||
id: string;
|
||||
/** §1 dot-ID per conventions/scope-and-versioning (release-0.3.2
|
||||
* slice 01 GA-coherence pass). Mirror of the daemon constant in
|
||||
* packages/daemon/src/domain/workspace/default-workspace-scaffold.ts
|
||||
* — keep in lockstep for the slice-21 parity test. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
@@ -46,27 +52,36 @@ type DefaultSlice = {
|
||||
|
||||
type DefaultMission = {
|
||||
id: string;
|
||||
/** §1 mission dot-ID — escape band for non-release missions. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
slices: DefaultSlice[];
|
||||
};
|
||||
|
||||
// Release-0.3.2 slice 01 (OPR.0.3.2.1) — seeded missions/slices now
|
||||
// carry §1 dot-IDs in frontmatter so the scaffold coheres with
|
||||
// `conventions/scope-and-versioning`. Mirror of the daemon constant;
|
||||
// the slice-21 parity test asserts both sides emit identical bodies.
|
||||
const DEFAULT_MISSIONS: DefaultMission[] = [
|
||||
{
|
||||
id: "getting-started",
|
||||
dotId: "OPR.99.0.1",
|
||||
title: "Getting Started",
|
||||
status: "active",
|
||||
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
|
||||
slices: [
|
||||
{
|
||||
id: "first-conveyor-run",
|
||||
dotId: "OPR.99.0.1.1",
|
||||
title: "First Conveyor Run",
|
||||
status: "active",
|
||||
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
|
||||
},
|
||||
{
|
||||
id: "inspect-project-evidence",
|
||||
dotId: "OPR.99.0.1.2",
|
||||
title: "Inspect Project Evidence",
|
||||
status: "draft",
|
||||
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
|
||||
@@ -93,7 +108,7 @@ const WORKSPACE_DIRS = [
|
||||
function subdirReadmeContent(subdir: string): string {
|
||||
switch (subdir) {
|
||||
case "missions":
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-name>/README.md\nmissions/<mission-name>/PROGRESS.md\nmissions/<mission-name>/slices/<slice-name>/README.md\n```\n\nEvery mission and slice README carries a stable dot-ID in its frontmatter (`id: OPR.<ver>[.<n>]`) per the scope-and-versioning convention. Use `rig scope mission create <name>` and `rig scope slice create <mission> <slug>` to mint conformant artifacts; the CLI handles the dot-ID + auto-numbering for you. The folder name is the operator-facing slug; the `id:` is the stable handle that survives renames.\n";
|
||||
case "artifacts":
|
||||
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
|
||||
case "evidence":
|
||||
@@ -122,7 +137,7 @@ This workspace is file-backed. The Project UI mirrors this structure:
|
||||
- \`evidence/\` is for proof notes and verification summaries.
|
||||
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
|
||||
|
||||
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
|
||||
Use stable kebab-case names for mission and slice folders. Every mission/slice README also carries a stable dot-ID in its frontmatter (\`id: OPR.<ver>[.<n>]\`) per the \`scope-and-versioning\` convention; mint conformant artifacts via \`rig scope mission create <name>\` and \`rig scope slice create <mission> <slug>\`. Folder names are operator-facing slugs; the \`id:\` is the rename-proof handle.
|
||||
`;
|
||||
|
||||
const STEERING_PLACEHOLDER = `---
|
||||
@@ -153,6 +168,7 @@ alongside your Project workspace.
|
||||
|
||||
function missionReadme(mission: DefaultMission): string {
|
||||
return `---
|
||||
id: ${mission.dotId}
|
||||
title: ${mission.title}
|
||||
status: ${mission.status}
|
||||
mission: ${mission.id}
|
||||
@@ -423,6 +439,7 @@ function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
|
||||
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
|
||||
if (narrative) {
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -433,6 +450,7 @@ slice: ${slice.id}
|
||||
${narrative.readme}`;
|
||||
}
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -522,6 +540,20 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri
|
||||
files.push(
|
||||
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
|
||||
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
|
||||
// FR-5e A1 — reuse the FR-3 MISSION_NOTES scaffold helper so a
|
||||
// fresh `rig config init-workspace` produces a getting-started
|
||||
// mission that doctor check #7 (mission_notes_presence) marks
|
||||
// ok. Same env-var-pivot (OPENRIG_MISSION_NOTES_TEMPLATE_PATH >
|
||||
// built-in bundled template) FR-3's `rig scope mission create`
|
||||
// honors.
|
||||
{
|
||||
relPath: `missions/${mission.id}/MISSION_NOTES.md`,
|
||||
content: renderMissionNotesTemplate({
|
||||
mission_id: mission.dotId,
|
||||
mission_name: mission.title,
|
||||
created_date: new Date().toISOString().slice(0, 10),
|
||||
}).rendered,
|
||||
},
|
||||
);
|
||||
for (const slice of mission.slices) {
|
||||
files.push(
|
||||
@@ -580,6 +612,17 @@ export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string
|
||||
const dryRun = !!opts.dryRun;
|
||||
const force = !!opts.force;
|
||||
|
||||
// FR-5e BLOCKER-1 — precompute the full scaffold file list BEFORE
|
||||
// any filesystem mutation. workspaceScaffoldFiles() now invokes
|
||||
// renderMissionNotesTemplate(), which throws on invalid
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH. If we mkdir first then
|
||||
// throw on render, the operator is left with a half-created
|
||||
// workspace that the next `rig workspace doctor` will misread as
|
||||
// partially-initialized. Verify-first-then-write (banked from
|
||||
// FR-3 self-lesson) — fail before any side effect.
|
||||
const scaffoldFiles = workspaceScaffoldFiles();
|
||||
const scaffoldDirs = workspaceScaffoldDirs();
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
const result: InitWorkspaceResult = {
|
||||
root,
|
||||
@@ -591,14 +634,14 @@ export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string
|
||||
|
||||
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
|
||||
|
||||
for (const sub of workspaceScaffoldDirs()) {
|
||||
for (const sub of scaffoldDirs) {
|
||||
const subPath = join(root, sub);
|
||||
const subExists = existsSync(subPath);
|
||||
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
|
||||
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
|
||||
}
|
||||
|
||||
for (const file of workspaceScaffoldFiles()) {
|
||||
for (const file of scaffoldFiles) {
|
||||
const absPath = join(root, file.relPath);
|
||||
const exists = existsSync(absPath);
|
||||
if (exists && !force) {
|
||||
|
||||
@@ -14,6 +14,14 @@ interface PsEntry {
|
||||
rigName?: string;
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
/** Slice 15 — subset of nodes producing tmux output within the silence
|
||||
* window. Sourced from the daemon's SeatActivityService; NEVER derived
|
||||
* from queue/assignment state. */
|
||||
activeCount?: number;
|
||||
/** Slice 15 — subset of nodes with at least one pending qitem assigned
|
||||
* to their canonical session name. Sourced from queue_items; NEVER
|
||||
* derived from tmux output. */
|
||||
hasWorkCount?: number;
|
||||
status: "running" | "partial" | "stopped";
|
||||
lifecycleState?: "running" | "recoverable" | "stopped" | "degraded" | "attention_required";
|
||||
uptime: string | null;
|
||||
@@ -36,6 +44,15 @@ interface NodeEntry {
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
latestError: string | null;
|
||||
/** Slice 15 — `terminal-active` primitive. true=producing output,
|
||||
* false=silent past threshold, null=no signal. NEVER derived from
|
||||
* hasAssignedWork (non-inference contract). */
|
||||
terminalActive?: boolean | null;
|
||||
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
|
||||
* NEVER derived from terminalActive. */
|
||||
hasAssignedWork?: boolean;
|
||||
/** Slice 15 — pending qitem count for this seat (cheap aggregate). */
|
||||
pendingWorkCount?: number;
|
||||
agentActivity?: {
|
||||
state: "running" | "needs_input" | "idle" | "unknown";
|
||||
reason: string;
|
||||
@@ -110,6 +127,8 @@ const ALLOWED_RIG_FIELDS = new Set([
|
||||
"rigName",
|
||||
"nodeCount",
|
||||
"runningCount",
|
||||
"activeCount",
|
||||
"hasWorkCount",
|
||||
"status",
|
||||
"lifecycleState",
|
||||
"uptime",
|
||||
@@ -131,6 +150,9 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"tmuxAttachCommand",
|
||||
"resumeCommand",
|
||||
"latestError",
|
||||
"terminalActive",
|
||||
"hasAssignedWork",
|
||||
"pendingWorkCount",
|
||||
"agentActivity",
|
||||
"contextUsage",
|
||||
]);
|
||||
@@ -434,11 +456,12 @@ Other keys are rejected. agentActivity.state is node-level (use with --nodes);
|
||||
allowed values: running, needs_input, idle, unknown.
|
||||
|
||||
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
|
||||
status, lifecycleState, uptime, latestSnapshot.
|
||||
activeCount, hasWorkCount, status, lifecycleState, uptime, latestSnapshot.
|
||||
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
|
||||
podNamespace, canonicalSessionName, nodeKind, runtime, sessionStatus,
|
||||
startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
|
||||
resumeCommand, latestError, agentActivity. Other keys are rejected.
|
||||
resumeCommand, latestError, terminalActive, hasAssignedWork,
|
||||
pendingWorkCount, agentActivity. Other keys are rejected.
|
||||
\`name\` is rig-level only; for node entries use \`rigName\`. Nested-field
|
||||
projection (e.g. \`agentActivity.state\`) is not supported in this slice; pass
|
||||
\`agentActivity\` to project the whole object and read the nested value
|
||||
@@ -608,13 +631,16 @@ Exit codes:
|
||||
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET;
|
||||
|
||||
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
|
||||
const header = padRigRow("RIG", "NODES", "RUNNING", "ACTIVE", "WORK", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
|
||||
console.log(header);
|
||||
for (const e of humanList as PsEntry[]) {
|
||||
console.log(padRigRow(
|
||||
e.rigName ?? e.name,
|
||||
String(e.nodeCount),
|
||||
String(e.runningCount),
|
||||
// Slice 15 — "—" when daemon predates the field; honest absence.
|
||||
e.activeCount !== undefined ? String(e.activeCount) : "—",
|
||||
e.hasWorkCount !== undefined ? String(e.hasWorkCount) : "—",
|
||||
e.status,
|
||||
abbrevRigLifecycle(e.lifecycleState),
|
||||
e.uptime ?? "—",
|
||||
@@ -704,7 +730,7 @@ async function handleNodes(
|
||||
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
|
||||
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "CTX", "RESTORE", "ERROR");
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "TERMINAL", "WORK", "ACTIVITY", "CTX", "RESTORE", "ERROR");
|
||||
console.log(header);
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const parts = n.logicalId.split(".");
|
||||
@@ -720,6 +746,10 @@ async function handleNodes(
|
||||
n.sessionStatus ?? "—",
|
||||
n.startupStatus ?? "—",
|
||||
abbrevNodeLifecycle(n.lifecycleState),
|
||||
// Slice 15 — terminal-active + has-work as DISTINCT columns
|
||||
// (non-inference contract: never collapsed into one signal).
|
||||
formatTerminalActive(n.terminalActive),
|
||||
formatHasWork(n.hasAssignedWork, n.pendingWorkCount),
|
||||
formatActivity(n.agentActivity),
|
||||
formatContextUsage(n.contextUsage),
|
||||
n.restoreOutcome,
|
||||
@@ -752,11 +782,17 @@ function fitCell(value: string, width: number): string {
|
||||
return truncate(value, width).padEnd(width);
|
||||
}
|
||||
|
||||
function padRigRow(rig: string, nodes: string, running: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
|
||||
function padRigRow(rig: string, nodes: string, running: string, active: string, work: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
|
||||
return [
|
||||
fitCell(rig, 24),
|
||||
fitCell(nodes, 7),
|
||||
fitCell(running, 9),
|
||||
// Slice 15 — distinct columns for the three orthogonal primitives.
|
||||
// RUNNING = process-alive (legacy); ACTIVE = terminal-active (tmux);
|
||||
// WORK = has-assigned-work (queue). UI/CLI render them separately so
|
||||
// operators see which dimension differs at a glance.
|
||||
fitCell(active, 8),
|
||||
fitCell(work, 6),
|
||||
fitCell(status, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
fitCell(uptime, 11),
|
||||
@@ -764,7 +800,7 @@ function padRigRow(rig: string, nodes: string, running: string, status: string,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, ctx: string, restore: string, error: string): string {
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, terminal: string, work: string, activity: string, ctx: string, restore: string, error: string): string {
|
||||
return [
|
||||
fitCell(rig, 30),
|
||||
fitCell(pod, 10),
|
||||
@@ -774,6 +810,9 @@ function padNodeRow(rig: string, pod: string, member: string, session: string, r
|
||||
fitCell(status, 10),
|
||||
fitCell(startup, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
// Slice 15 — distinct TERMINAL + WORK columns.
|
||||
fitCell(terminal, 9),
|
||||
fitCell(work, 6),
|
||||
fitCell(activity, 12),
|
||||
fitCell(ctx, 6),
|
||||
fitCell(restore, 10),
|
||||
@@ -781,6 +820,26 @@ function padNodeRow(rig: string, pod: string, member: string, session: string, r
|
||||
].join("");
|
||||
}
|
||||
|
||||
// Slice 15 — render the terminal-active primitive honestly.
|
||||
// `null` (no signal) is rendered distinctly from `false` (silent past
|
||||
// threshold) so an operator can see whether the daemon hasn't observed
|
||||
// the seat yet vs has observed and seen no output.
|
||||
function formatTerminalActive(t: boolean | null | undefined): string {
|
||||
if (t === true) return "active";
|
||||
if (t === false) return "idle";
|
||||
return "—"; // null / undefined → no signal
|
||||
}
|
||||
|
||||
// Slice 15 — render has-work with a hint of how much.
|
||||
// Pending count appended in parens when known and > 1 keeps the column
|
||||
// compact while remaining honest.
|
||||
function formatHasWork(has: boolean | undefined, count: number | undefined): string {
|
||||
if (has === undefined) return "—";
|
||||
if (!has) return "no";
|
||||
if (typeof count === "number" && count > 1) return `${count}`;
|
||||
return "yes";
|
||||
}
|
||||
|
||||
// PL-012: render context-usage as a 5-char cell — "<percent>%" when
|
||||
// known + fresh, "<percent>%*" when known but stale, "??" when unknown.
|
||||
// 4-char width keeps the table compact without truncating two-digit
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
@@ -41,6 +42,95 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// OPR.0.3.2.21.FR-4(a) — body input resolution. Three accepted shapes:
|
||||
// --body "<text>" inline (legacy; backtick-prone for raw
|
||||
// multiline content)
|
||||
// --body-file <path> read body content from a file path
|
||||
// (kills the backtick-corruption class)
|
||||
// --body-file - or --body - read body from stdin (pipeline-friendly)
|
||||
//
|
||||
// Exactly one of --body / --body-file must be provided; the resolver throws
|
||||
// a 3-part fact/consequence/action error otherwise.
|
||||
//
|
||||
// stdinReader is dependency-injected so tests can swap it without touching
|
||||
// process.stdin. Default reads UTF-8 from process.stdin until EOF.
|
||||
export interface ResolveBodyOpts {
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
}
|
||||
|
||||
export async function defaultStdinReader(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => { data += chunk; });
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
if (process.stdin.isTTY) {
|
||||
// No pipe is connected to stdin; resolve immediately to empty
|
||||
// rather than blocking forever waiting for data on a TTY. The
|
||||
// empty body then flows through to the daemon's content
|
||||
// validation (queue-repository owns the body contract); the CLI
|
||||
// does not error locally on empty stdin.
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveQueueBody(
|
||||
opts: ResolveBodyOpts,
|
||||
stdinReader: () => Promise<string> = defaultStdinReader,
|
||||
): Promise<string> {
|
||||
const hasInline = opts.body !== undefined && opts.body !== "";
|
||||
const hasFile = opts.bodyFile !== undefined && opts.bodyFile !== "";
|
||||
if (hasInline && hasFile) {
|
||||
const err = new Error("--body and --body-file are mutually exclusive.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Both --body and --body-file were passed; the body source is ambiguous.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass exactly one of --body or --body-file.";
|
||||
throw err;
|
||||
}
|
||||
if (!hasInline && !hasFile) {
|
||||
const err = new Error("Missing required body input.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Neither --body nor --body-file was provided.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass the qitem body via --body \"<text>\" or --body-file <path> (use - for stdin).";
|
||||
throw err;
|
||||
}
|
||||
if (hasInline) {
|
||||
if (opts.body === "-") return stdinReader();
|
||||
return opts.body!;
|
||||
}
|
||||
// hasFile path
|
||||
if (opts.bodyFile === "-") return stdinReader();
|
||||
const absPath = opts.bodyFile!;
|
||||
if (!fs.existsSync(absPath)) {
|
||||
const err = new Error(`--body-file path does not exist: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = `--body-file path does not exist: ${absPath}`;
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Check the path; pass an absolute path; or use --body-file - to read from stdin.";
|
||||
throw err;
|
||||
}
|
||||
const stat = fs.statSync(absPath);
|
||||
if (!stat.isFile()) {
|
||||
const err = new Error(`--body-file path is not a regular file: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = `--body-file path is not a regular file: ${absPath}`;
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass a path to a readable file (not a directory, symlink-to-directory, or block device). Use --body-file - to read from stdin.";
|
||||
throw err;
|
||||
}
|
||||
return fs.readFileSync(absPath, "utf8");
|
||||
}
|
||||
|
||||
function emitBodyResolveError(err: Error & { fact?: string; consequence?: string; action?: string }, json: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact: err.fact ?? err.message, consequence: err.consequence ?? "", action: err.action ?? "" } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.fact ?? err.message}\n${err.consequence ?? ""}\n${err.action ?? ""}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
|
||||
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
@@ -62,10 +152,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.description("Create a new qitem")
|
||||
.requiredOption("--source <session>", "Source session")
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.requiredOption("--body <text>", "Qitem body")
|
||||
.option("--body <text>", "Qitem body inline (use - to read from stdin; mutually exclusive with --body-file)")
|
||||
.option("--body-file <path>", "Read qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class for multiline bodies.")
|
||||
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
|
||||
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
|
||||
@@ -74,7 +167,10 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
mission?: string;
|
||||
slice?: string;
|
||||
priority: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
@@ -84,14 +180,35 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// OPR.0.3.2.21.FR-4(a) — resolve body BEFORE contacting the daemon
|
||||
// so a missing/ambiguous body fails fast and locally.
|
||||
let resolvedBody: string;
|
||||
try {
|
||||
resolvedBody = await resolveQueueBody({ body: opts.body, bodyFile: opts.bodyFile });
|
||||
} catch (err) {
|
||||
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
|
||||
// translate to canonical mission:<id> / slice:<id> tags. Composes
|
||||
// with --tags (any flag-derived tags prepend; explicit --tags
|
||||
// append). De-duplicates so passing both --mission X and
|
||||
// --tags mission:X yields one mission:X tag.
|
||||
const fromTagsArg = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const fromFlags: string[] = [];
|
||||
if (opts.mission) fromFlags.push(`mission:${opts.mission}`);
|
||||
if (opts.slice) fromFlags.push(`slice:${opts.slice}`);
|
||||
const merged = [...fromFlags, ...fromTagsArg];
|
||||
const seen = new Set<string>();
|
||||
const dedupedTags = merged.filter((t) => { if (seen.has(t)) return false; seen.add(t); return true; });
|
||||
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
|
||||
qitemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
body: resolvedBody,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
@@ -141,7 +258,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("update <qitemId>")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation)")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation). Closure ≠ acceptance: handed_off_to records delivery to the next stage; acceptance is the next stage's verdict on its own qitem, not this closure.")
|
||||
.option("--actor <session>", "Actor session performing the transition (defaults to OPENRIG_SESSION_NAME)")
|
||||
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
|
||||
.option("--closure-reason <reason>", "Required for state=done")
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
// Slice 09 (OPR.0.3.2.9) — `rig policy` CLI: operator-context-mode
|
||||
// surface. Pairs with the daemon's typed-primitive store.
|
||||
//
|
||||
// Subcommands:
|
||||
// rig policy set <mode> [--scope ...] [--qualifier ...] [--<field> ...]
|
||||
// [--evidence ...] [--confirm]
|
||||
// → restate-and-confirm; PUT only when --confirm
|
||||
// rig policy show → list all bindings (defaults to JSON-when-piped)
|
||||
// rig policy effective → resolve effective for a read context
|
||||
// rig policy cite → emit the citation line per convention §Component 5
|
||||
// rig policy unset <scope> [qualifier]
|
||||
// → DELETE one binding (operator-only)
|
||||
// rig policy defaults → recommended 6×7 + per-mode scope + stale rule
|
||||
//
|
||||
// HG-4 / HG-7 anchored here:
|
||||
// - `set` never silently applies. Without `--confirm` it echoes the
|
||||
// proposed binding (mode + scope + key settings) and exits with
|
||||
// `exit 2` so scripts cannot accidentally apply. `--confirm` is the
|
||||
// explicit operator action.
|
||||
// - Mode invocation: bare word OR `mode:<mode>` prefix; both normalize
|
||||
// via disambiguateModeInvocation().
|
||||
// - Citation format: short-prose per convention §Component 5 +
|
||||
// §Citation Rules.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export interface RigPolicyDeps extends StatusDeps {}
|
||||
|
||||
// Mirror of daemon enums + structure. We keep these inline so the CLI
|
||||
// doesn't grow a build-time dep on the daemon package; the validator
|
||||
// at the daemon edge is the source of truth, and the CLI sends the
|
||||
// record through unchanged.
|
||||
const MODES = ["sleep", "desk", "mobile", "away", "focus", "debug"] as const;
|
||||
type Mode = (typeof MODES)[number];
|
||||
|
||||
const SCOPES = ["global_host", "rig", "workstream", "qitem"] as const;
|
||||
type Scope = (typeof SCOPES)[number];
|
||||
|
||||
// Convention §Component 4 — bare-word disambiguation. A bare reserved
|
||||
// mode word, or `mode:<word>`, becomes an invocation; embedded-in-
|
||||
// sentence does not (the latter is a CLI input error here since the
|
||||
// CLI takes a single-positional <mode> argument).
|
||||
function disambiguateModeInvocation(raw: string): Mode | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const stripped = trimmed.startsWith("mode:") ? trimmed.slice("mode:".length).trim() : trimmed;
|
||||
if (stripped.split(/\s+/).length !== 1) return null;
|
||||
return (MODES as readonly string[]).includes(stripped) ? (stripped as Mode) : null;
|
||||
}
|
||||
|
||||
interface RecommendedDefaultsResponse {
|
||||
recommendedModeDefaults: Record<Mode, {
|
||||
autonomy_scope: string;
|
||||
heartbeat_cadence: string;
|
||||
inspection_depth: string;
|
||||
update_detail: string;
|
||||
escalation_threshold: string;
|
||||
concurrency_limit: string;
|
||||
permission_prompt_posture: string;
|
||||
}>;
|
||||
recommendedDefaultScope: Record<Mode, Scope>;
|
||||
defaultStaleRule: string;
|
||||
}
|
||||
|
||||
interface BindingResponse {
|
||||
binding: {
|
||||
id: string;
|
||||
mode: Mode;
|
||||
record: Record<string, string>;
|
||||
qualifier: string | null;
|
||||
setAt: string;
|
||||
setBy: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
bindings: Array<BindingResponse["binding"]>;
|
||||
}
|
||||
|
||||
interface EffectiveResponse {
|
||||
effective: { binding: BindingResponse["binding"]; resolvedScope: Scope } | null;
|
||||
posture: "known" | "unknown_posture";
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: RigPolicyDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function emitRecord(label: string, record: Record<string, string>): void {
|
||||
console.log(label);
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the daemon route's parseScopeAndQualifier semantics on the
|
||||
* CLI side. The CLI is the operator's authoring surface; the convention's
|
||||
* scope rule applies at invocation, NOT only at raw HTTP URL parsing.
|
||||
*
|
||||
* Reject:
|
||||
* - explicit qualifier with global_host (BLOCKING re-verify finding from
|
||||
* guard qitem-20260518044650): operators who type
|
||||
* `--scope global_host --qualifier <id>` get an error and the daemon
|
||||
* is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
* - missing qualifier for any non-global scope.
|
||||
*/
|
||||
type NormalizedScope = { ok: true; qualifier: string | null } | { ok: false; message: string };
|
||||
function normalizeScopeQualifier(scope: Scope, explicitQualifier: string | undefined): NormalizedScope {
|
||||
if (scope === "global_host") {
|
||||
if (explicitQualifier !== undefined && explicitQualifier !== "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Global-host bindings cannot carry a qualifier (got "${explicitQualifier}"). Either drop --qualifier OR change --scope to rig / workstream / qitem.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: null };
|
||||
}
|
||||
if (explicitQualifier === undefined || explicitQualifier === "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Scope ${scope} requires a qualifier (rigId / workstreamId / qitemId). Pass --qualifier <id>.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: explicitQualifier };
|
||||
}
|
||||
|
||||
function formatCitation(b: BindingResponse["binding"]): string {
|
||||
const qualifierPart = b.qualifier ? `:${b.qualifier}` : "";
|
||||
const scope = b.record.scope as Scope;
|
||||
return `Operating in \`${b.mode}\` mode at \`${scope}${qualifierPart}\` per operator (set_at ${b.setAt})`;
|
||||
}
|
||||
|
||||
export function rigPolicyCommand(depsOverride?: RigPolicyDeps): Command {
|
||||
const cmd = new Command("policy").description(
|
||||
"Slice 09 — operator-context-mode bindings (sleep/desk/mobile/away/focus/debug × global_host/rig/workstream/qitem).",
|
||||
);
|
||||
|
||||
const getDeps = (): RigPolicyDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
// -- set ---------------------------------------------------------------
|
||||
cmd
|
||||
.command("set <mode>")
|
||||
.description(
|
||||
"Propose a mode binding. Without --confirm, restates the proposed binding and exits 2 (no daemon write). With --confirm, sets it.",
|
||||
)
|
||||
.option("--scope <scope>", `Scope: ${SCOPES.join(" | ")} (default: per-mode recommendation)`)
|
||||
.option("--qualifier <id>", "Required for rig / workstream / qitem scopes; omit for global_host")
|
||||
.option("--autonomy-scope <v>")
|
||||
.option("--heartbeat-cadence <v>")
|
||||
.option("--inspection-depth <v>")
|
||||
.option("--update-detail <v>")
|
||||
.option("--escalation-threshold <v>")
|
||||
.option("--concurrency-limit <v>")
|
||||
.option("--permission-prompt-posture <v>", "One of: normal | batch_for_human | do_not_prompt_unless_blocked (auto_accept is FORBIDDEN by convention).")
|
||||
.option("--expiry-or-stale-rule <v>")
|
||||
.option("--evidence <citation>", "Free-text citation (operator message id, file pointer, chatroom topic, etc.).")
|
||||
.option("--confirm", "Confirm the proposed binding and apply it. Without this flag, set is restate-only.")
|
||||
.option("--bearer <token>", "Operator bearer token (or set OPENRIG_AUTH_BEARER_TOKEN env).")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
modeArg: string,
|
||||
opts: {
|
||||
scope?: string;
|
||||
qualifier?: string;
|
||||
autonomyScope?: string;
|
||||
heartbeatCadence?: string;
|
||||
inspectionDepth?: string;
|
||||
updateDetail?: string;
|
||||
escalationThreshold?: string;
|
||||
concurrencyLimit?: string;
|
||||
permissionPromptPosture?: string;
|
||||
expiryOrStaleRule?: string;
|
||||
evidence?: string;
|
||||
confirm?: boolean;
|
||||
bearer?: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
const mode = disambiguateModeInvocation(modeArg);
|
||||
if (!mode) {
|
||||
console.error(`Unknown mode '${modeArg}'. Allowed: ${MODES.join(", ")} (or 'mode:<name>' prefix).`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// BLOCKING re-verify-2 (qitem-20260518045300): when --scope is
|
||||
// explicit, validate scope + qualifier LOCALLY before touching
|
||||
// the daemon. Otherwise an invalid operator invocation
|
||||
// (e.g. `--scope global_host --qualifier X`) surfaces as
|
||||
// "Daemon not running" when the daemon is down, instead of the
|
||||
// proper local input error. Convention's scope rule applies at
|
||||
// operator invocation, not at daemon HTTP parsing.
|
||||
let explicitScope: Scope | null = null;
|
||||
if (opts.scope !== undefined) {
|
||||
if (!(SCOPES as readonly string[]).includes(opts.scope)) {
|
||||
console.error(`Unknown scope '${opts.scope}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
explicitScope = opts.scope as Scope;
|
||||
const preflight = normalizeScopeQualifier(explicitScope, opts.qualifier);
|
||||
if (!preflight.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: preflight.message }, null, 2));
|
||||
} else {
|
||||
console.error(preflight.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const defaultsRes = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
|
||||
if (defaultsRes.status >= 400) {
|
||||
console.error(JSON.stringify(defaultsRes.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const defaults = defaultsRes.data;
|
||||
const scope = explicitScope ?? defaults.recommendedDefaultScope[mode];
|
||||
// Defensive — the defaults map is from the daemon, so its scope
|
||||
// values are trusted; keep the assertion shape lean.
|
||||
if (!(SCOPES as readonly string[]).includes(scope)) {
|
||||
console.error(`Unknown scope '${scope}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Re-run normalization for the implicit-default-scope case.
|
||||
// (Explicit scope was already preflighted above; this branch is
|
||||
// a no-op for explicit-scope inputs but is needed when the
|
||||
// default scope is e.g. `qitem`/`workstream` and the operator
|
||||
// forgot --qualifier.)
|
||||
const normalized = normalizeScopeQualifier(scope, opts.qualifier);
|
||||
if (!normalized.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const perMode = defaults.recommendedModeDefaults[mode];
|
||||
// Component 3 — exactly the 10 settings fields. `mode` is the
|
||||
// binding's identity (Component 2) and lives at the top level
|
||||
// of the PUT body, NOT inside the record.
|
||||
const record = {
|
||||
autonomy_scope: opts.autonomyScope ?? perMode.autonomy_scope,
|
||||
heartbeat_cadence: opts.heartbeatCadence ?? perMode.heartbeat_cadence,
|
||||
inspection_depth: opts.inspectionDepth ?? perMode.inspection_depth,
|
||||
update_detail: opts.updateDetail ?? perMode.update_detail,
|
||||
escalation_threshold: opts.escalationThreshold ?? perMode.escalation_threshold,
|
||||
concurrency_limit: opts.concurrencyLimit ?? perMode.concurrency_limit,
|
||||
permission_prompt_posture: opts.permissionPromptPosture ?? perMode.permission_prompt_posture,
|
||||
scope,
|
||||
expiry_or_stale_rule: opts.expiryOrStaleRule ?? defaults.defaultStaleRule,
|
||||
evidence_citation: opts.evidence ?? `operator confirmed ${mode}`,
|
||||
};
|
||||
|
||||
if (!opts.confirm) {
|
||||
// Restate-and-confirm (HG-7). No daemon write.
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, proposed: { mode, scope, qualifier, record }, confirm_required: true }, null, 2));
|
||||
} else {
|
||||
console.log(`Proposed binding (restate-and-confirm — NOT applied):`);
|
||||
console.log(` mode: ${mode}`);
|
||||
console.log(` scope: ${scope}${qualifier ? ` (${qualifier})` : ""}`);
|
||||
emitRecord(` record:`, record);
|
||||
console.log(`\nRe-run with --confirm to apply.`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const res = await client.put<BindingResponse | { error: string; errors?: string[] }>(
|
||||
`/api/rig-policy/bindings/${scope}${qualifierPath}`,
|
||||
{ mode, record },
|
||||
{ headers },
|
||||
);
|
||||
if (res.status === 401) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "unauthorized", hint: "Daemon requires operator bearer. Pass --bearer <token> or set OPENRIG_AUTH_BEARER_TOKEN." }, null, 2));
|
||||
} else {
|
||||
console.error("Unauthorized. Daemon requires an operator bearer token.");
|
||||
console.error("Pass --bearer <token> or export OPENRIG_AUTH_BEARER_TOKEN before re-running.");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(`Error setting binding (HTTP ${res.status}):`);
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const body = res.data as BindingResponse;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, binding: body.binding }, null, 2));
|
||||
} else {
|
||||
console.log(`Set: ${body.binding.id}`);
|
||||
console.log(` mode: ${body.binding.mode}`);
|
||||
emitRecord(` record:`, body.binding.record);
|
||||
console.log(` set_by: ${body.binding.setBy}`);
|
||||
console.log(` set_at: ${body.binding.setAt}`);
|
||||
console.log(`\n${formatCitation(body.binding)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- show --------------------------------------------------------------
|
||||
cmd
|
||||
.command("show")
|
||||
.description("List all operator-context-mode bindings.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<ListResponse>("/api/rig-policy/bindings");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.bindings.length === 0) {
|
||||
console.log("No operator-context-mode bindings set.");
|
||||
return;
|
||||
}
|
||||
for (const b of res.data.bindings) {
|
||||
console.log(`${b.id} [${b.mode}] set_at=${b.setAt}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- effective ---------------------------------------------------------
|
||||
cmd
|
||||
.command("effective")
|
||||
.description("Resolve the effective mode for a (rig, workstream, qitem) read context. Q6 unknown_posture surfaced when no binding matches.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.posture === "unknown_posture" || !res.data.effective) {
|
||||
console.log("unknown_posture: no binding matches this read context.");
|
||||
if (res.data.hint) console.log(res.data.hint);
|
||||
return;
|
||||
}
|
||||
const b = res.data.effective.binding;
|
||||
console.log(`Effective: ${b.mode} (resolved scope: ${res.data.effective.resolvedScope})`);
|
||||
emitRecord(` record:`, b.record);
|
||||
console.log(` set_by: ${b.setBy}`);
|
||||
console.log(` set_at: ${b.setAt}`);
|
||||
});
|
||||
});
|
||||
|
||||
// -- cite --------------------------------------------------------------
|
||||
cmd
|
||||
.command("cite")
|
||||
.description("Emit a citation line for the effective mode at a read context. Per convention §Citation Rules.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400 || !res.data.effective) {
|
||||
console.log("Operating without an explicit operator-context-mode binding (unknown_posture).");
|
||||
return;
|
||||
}
|
||||
console.log(formatCitation(res.data.effective.binding));
|
||||
});
|
||||
});
|
||||
|
||||
// -- unset -------------------------------------------------------------
|
||||
cmd
|
||||
.command("unset <scope> [qualifier]")
|
||||
.description("Delete one binding (operator-only). Scope: global_host | rig | workstream | qitem.")
|
||||
.option("--bearer <token>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
scopeArg: string,
|
||||
qualifierArg: string | undefined,
|
||||
opts: { bearer?: string; json?: boolean },
|
||||
) => {
|
||||
if (!(SCOPES as readonly string[]).includes(scopeArg)) {
|
||||
console.error(`Unknown scope '${scopeArg}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const scope = scopeArg as Scope;
|
||||
// BLOCKING re-verify (qitem-20260518044650): never silently drop
|
||||
// an explicit operator-supplied qualifier on global_host on unset.
|
||||
// Same hazard class as set; same shared helper.
|
||||
const normalized = normalizeScopeQualifier(scope, qualifierArg);
|
||||
if (!normalized.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const res = await client.delete<{ removed: boolean }>(`/api/rig-policy/bindings/${scope}${qualifierPath}`, { headers });
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, removed: res.data.removed }, null, 2));
|
||||
} else {
|
||||
console.log(res.data.removed ? `Removed ${scope}${qualifier ? `:${qualifier}` : ""}` : "Nothing to remove.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- defaults ----------------------------------------------------------
|
||||
cmd
|
||||
.command("defaults")
|
||||
.description("Print the recommended per-mode 6×7 + default-scope + stale rule.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
for (const mode of MODES) {
|
||||
console.log(`${mode} (default scope: ${res.data.recommendedDefaultScope[mode]})`);
|
||||
emitRecord(" ", res.data.recommendedModeDefaults[mode] as unknown as Record<string, string>);
|
||||
}
|
||||
console.log(`\ndefault stale rule: ${res.data.defaultStaleRule}`);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Re-export for unit-testability of pure helpers.
|
||||
export const __test__ = { disambiguateModeInvocation, formatCitation, normalizeScopeQualifier };
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
updateFrontmatter,
|
||||
} from "../lib/scope/scope-fs.js";
|
||||
import {
|
||||
renderMissionNotesTemplate,
|
||||
renderMissionTemplate,
|
||||
renderSliceTemplate,
|
||||
titleFromSlug,
|
||||
@@ -558,6 +559,7 @@ function buildMissionCreateCommand(): Command {
|
||||
.option("--template <kind>", `Template: ${MISSION_TEMPLATE_KINDS.join(" | ")} (auto when name matches release-X.Y.Z)`, "")
|
||||
.option("--id <dot-id>", "Explicit dot-ID. Overrides name-pattern inference.")
|
||||
.option("--title <text>", "Display title (defaults to titlecased name)")
|
||||
.option("--no-mission-notes", "Skip the auto-scaffold of MISSION_NOTES.md (rare; default is to scaffold from conventions/mission-notes/TEMPLATE.md)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (rawName: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
@@ -614,12 +616,17 @@ function buildMissionCreateCommand(): Command {
|
||||
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
|
||||
id = inferMissionDotId(name, ordinal);
|
||||
}
|
||||
// Scaffold the mission directory + README.
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
|
||||
// Resolve titles + render templates BEFORE any filesystem side
|
||||
// effects (banked verify-first-then-write — guard catch on
|
||||
// OPR.0.3.2.21.FR-3 qitem-20260601121058: a stale
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH used to throw AFTER
|
||||
// mkdirSync + README write, leaking a half-created mission dir
|
||||
// that future `mission create <same-name>` would reject as
|
||||
// already-existing). Rendering MISSION_NOTES first means a
|
||||
// stale env-var fails before any disk mutation.
|
||||
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
|
||||
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
|
||||
const body = renderMissionTemplate(templateKind, {
|
||||
const readmeBody = renderMissionTemplate(templateKind, {
|
||||
id,
|
||||
slug: name,
|
||||
mission: name,
|
||||
@@ -627,8 +634,36 @@ function buildMissionCreateCommand(): Command {
|
||||
created_date: todayDateISO(),
|
||||
release_version: releaseVersion,
|
||||
});
|
||||
let missionNotesRendered: { rendered: string; resolvedFrom: "env" | "built-in" } | null = null;
|
||||
if (opts.missionNotes !== false) {
|
||||
const r = renderMissionNotesTemplate({
|
||||
mission_id: id,
|
||||
mission_name: title,
|
||||
created_date: todayDateISO(),
|
||||
});
|
||||
missionNotesRendered = { rendered: r.rendered, resolvedFrom: r.resolvedFrom };
|
||||
}
|
||||
// All renders succeeded — safe to touch the filesystem.
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
fs.writeFileSync(readmePath, body, "utf8");
|
||||
fs.writeFileSync(readmePath, readmeBody, "utf8");
|
||||
let missionNotesPath: string | null = null;
|
||||
let missionNotesResolvedFrom: "env" | "built-in" | null = null;
|
||||
if (missionNotesRendered) {
|
||||
missionNotesPath = path.join(absPath, "MISSION_NOTES.md");
|
||||
fs.writeFileSync(missionNotesPath, missionNotesRendered.rendered, "utf8");
|
||||
missionNotesResolvedFrom = missionNotesRendered.resolvedFrom;
|
||||
}
|
||||
const humanLines = [
|
||||
`Created mission ${name}`,
|
||||
` id: ${id}`,
|
||||
` template: ${templateKind}`,
|
||||
` path: ${absPath}`,
|
||||
];
|
||||
if (missionNotesPath) {
|
||||
humanLines.push(` mission-notes: ${missionNotesPath} (template: ${missionNotesResolvedFrom})`);
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
mission: {
|
||||
@@ -637,13 +672,10 @@ function buildMissionCreateCommand(): Command {
|
||||
template: templateKind,
|
||||
path: absPath,
|
||||
readmePath,
|
||||
missionNotesPath,
|
||||
missionNotesResolvedFrom,
|
||||
},
|
||||
}, json, [
|
||||
`Created mission ${name}`,
|
||||
` id: ${id}`,
|
||||
` template: ${templateKind}`,
|
||||
` path: ${absPath}`,
|
||||
]);
|
||||
}, json, humanLines);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@ import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 helper — mirrors cwd-resolution.isPathInsideRoot in
|
||||
// the daemon so the CLI can decide whether a path-form sourceRef lives
|
||||
// inside the daemon install root without importing the daemon package.
|
||||
function isPathInsideRoot(candidate: string, root: string): boolean {
|
||||
const relative = nodePath.relative(nodePath.resolve(root), nodePath.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
|
||||
}
|
||||
|
||||
export function upCommand(
|
||||
depsOverride?: StatusDeps & {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
@@ -189,6 +197,46 @@ Examples:
|
||||
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
|
||||
const targetRoot = opts.target ?? (isRigBundle ? process.cwd() : undefined);
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 — extend the bare `rig up <builtin>` default-cwd
|
||||
// treatment to path-form. Builtin starter specs declare member-level
|
||||
// cwd: "." which resolves to the spec directory (inside the daemon's
|
||||
// install root). Without --cwd that trips getOpenRigInstallCwdError
|
||||
// at preflight. Bare-name form is already rescued at the
|
||||
// resolveLibrarySpec branch above (entry.sourceType === "builtin");
|
||||
// path-form `rig up <install-internal-spec>` is the remaining gap.
|
||||
//
|
||||
// Detection: source is path-form (not isRigName, not isRigBundle),
|
||||
// no --cwd was given, no defaultLibraryCwdOverride was set by the
|
||||
// bare-name branch above, AND the resolved path lives inside the
|
||||
// daemon's install root (fetched via /api/info). When all hold,
|
||||
// default cwdOverride to process.cwd() so the operator's project
|
||||
// dir is used as launch cwd, and print a one-line notice. If
|
||||
// /api/info is unavailable, fall through silently — the daemon
|
||||
// preflight will still surface the install-cwd error.
|
||||
//
|
||||
// Structural redesign of how builtin starter specs declare cwd is
|
||||
// out of scope (deferred to 0.3.3 per the slice triage).
|
||||
if (!opts.cwd && !isRigName && !isRigBundle && defaultLibraryCwdOverride === undefined) {
|
||||
try {
|
||||
// Short timeout: a healthy daemon answers /api/info in <100ms; if
|
||||
// it doesn't, fall through to the daemon's own preflight error
|
||||
// rather than adding a multi-second stall to every rig up.
|
||||
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { timeoutMs: 2000 });
|
||||
const installRoot = infoRes.data?.installRoot;
|
||||
if (installRoot && isPathInsideRoot(sourceRef, installRoot)) {
|
||||
defaultLibraryCwdOverride = process.cwd();
|
||||
if (!opts.json) {
|
||||
console.log("Defaulting cwd to current directory because the spec lives inside the OpenRig install.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /api/info unavailable — fall through. The daemon preflight
|
||||
// returns getOpenRigInstallCwdError if this is in fact the
|
||||
// install-internal case; the operator gets the same hint they
|
||||
// would have gotten pre-Bug-3.
|
||||
}
|
||||
}
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
|
||||
@@ -41,6 +41,50 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 GA polish — every mutating workflow command
|
||||
// ends with a 3-line what/state/next summary in human mode so the
|
||||
// operator doesn't have to grep the raw JSON for the outcome. JSON
|
||||
// mode keeps the daemon response verbatim — agent consumers parse
|
||||
// the structured body, not the human summary.
|
||||
export interface OutcomeSummary {
|
||||
what: string;
|
||||
state: string;
|
||||
next: string;
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — runtime validator for the
|
||||
// project --exit enum. TypeScript types don't enforce at runtime;
|
||||
// without this guard `--exit banana` would forward to the daemon
|
||||
// transactional-scribe surface. Match the same 3-part error shape
|
||||
// used elsewhere in the slice.
|
||||
export const PROJECT_EXIT_KINDS = ["handoff", "waiting", "done", "failed"] as const;
|
||||
export type ProjectExitKind = (typeof PROJECT_EXIT_KINDS)[number];
|
||||
|
||||
export function isProjectExitKind(value: unknown): value is ProjectExitKind {
|
||||
return typeof value === "string" && (PROJECT_EXIT_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function printOutcomeSummary(json: boolean, status: number, summary: OutcomeSummary | null): void {
|
||||
if (json || !summary || status >= 400) return;
|
||||
console.log("");
|
||||
console.log(` what: ${summary.what}`);
|
||||
console.log(` state: ${summary.state}`);
|
||||
console.log(` next: ${summary.next}`);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
const cmd = new Command("workflow").description(
|
||||
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
|
||||
@@ -55,6 +99,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow validate workflows/conveyor-starter.workflow.md
|
||||
$ rig workflow validate ./my-spec.workflow.md --json | jq .ok
|
||||
`)
|
||||
.action(async (specPath: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -70,6 +119,17 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
|
||||
.option("--entry-owner <session>", "Override default entry-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow instantiate workflows/conveyor.workflow.md \\
|
||||
--root-objective "Ship release-0.3.2" \\
|
||||
--created-by orch-lead@openrig-velocity
|
||||
|
||||
$ rig workflow instantiate ./my-spec.workflow.md \\
|
||||
--root-objective "Run dogfood" \\
|
||||
--created-by velocity-driver@openrig-velocity \\
|
||||
--entry-owner velocity-qa@openrig-velocity --json
|
||||
`)
|
||||
.action(async (specPath: string, opts: {
|
||||
rootObjective: string;
|
||||
createdBy: string;
|
||||
@@ -78,13 +138,29 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/instantiate", {
|
||||
const res = await client.post<{
|
||||
instanceId?: string;
|
||||
entryStepId?: string;
|
||||
entryOwnerSession?: string;
|
||||
status?: string;
|
||||
}>("/api/workflow/instantiate", {
|
||||
specPath,
|
||||
rootObjective: opts.rootObjective,
|
||||
createdBySession: opts.createdBy,
|
||||
entryOwnerSession: opts.entryOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const instanceId = asString(body.instanceId) ?? "(no instance id)";
|
||||
const entryStepId = asString(body.entryStepId);
|
||||
const owner = asString(body.entryOwnerSession) ?? opts.entryOwner ?? "(default from spec)";
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Instantiated workflow from ${specPath} (instance ${instanceId})`,
|
||||
state: `${body.status ?? "active"}; entry packet ${entryStepId ?? "pending"} owned by ${owner}`,
|
||||
next: entryStepId
|
||||
? `Inspect: rig workflow show ${instanceId} | Open packet: rig queue show ${entryStepId}`
|
||||
: `Inspect: rig workflow show ${instanceId}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,28 +175,82 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
|
||||
.option("--next-owner <session>", "Override default next-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
# handoff to the spec's default next step
|
||||
$ rig workflow project \\
|
||||
--instance WF01ABC \\
|
||||
--current-packet QITEM-123 \\
|
||||
--exit handoff \\
|
||||
--actor-session velocity-driver@openrig-velocity \\
|
||||
--result-note "implementation green; ready for review"
|
||||
|
||||
# close the run cleanly
|
||||
$ rig workflow project --instance WF01ABC --current-packet QITEM-9 \\
|
||||
--exit done --actor-session orch-lead@openrig-velocity
|
||||
|
||||
# block on an external gate
|
||||
$ rig workflow project --instance WF01ABC --current-packet QITEM-4 \\
|
||||
--exit waiting --actor-session velocity-qa@openrig-velocity \\
|
||||
--blocked-on "founder-gate-2"
|
||||
`)
|
||||
.action(async (opts: {
|
||||
instance: string;
|
||||
currentPacket: string;
|
||||
exit: "handoff" | "waiting" | "done" | "failed";
|
||||
exit: string;
|
||||
actorSession: string;
|
||||
resultNote?: string;
|
||||
blockedOn?: string;
|
||||
nextOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// HG-6 — runtime-validate the --exit enum BEFORE the daemon
|
||||
// call. TypeScript types are erased at runtime; without this
|
||||
// guard `--exit banana` would forward to the transactional
|
||||
// workflow surface.
|
||||
if (!isProjectExitKind(opts.exit)) {
|
||||
emit3PartError(
|
||||
Boolean(opts.json),
|
||||
`--exit must be one of ${PROJECT_EXIT_KINDS.join(" | ")} (got "${opts.exit}").`,
|
||||
"rig workflow project did not run; daemon was not contacted.",
|
||||
`Pass a valid exit kind. Example: --exit handoff.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const exitKind: ProjectExitKind = opts.exit;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/project", {
|
||||
const res = await client.post<{
|
||||
closedPacketId?: string;
|
||||
nextPacketId?: string;
|
||||
nextOwnerSession?: string;
|
||||
instanceStatus?: string;
|
||||
}>("/api/workflow/project", {
|
||||
instanceId: opts.instance,
|
||||
currentPacketId: opts.currentPacket,
|
||||
exit: opts.exit,
|
||||
exit: exitKind,
|
||||
actorSession: opts.actorSession,
|
||||
resultNote: opts.resultNote,
|
||||
blockedOn: opts.blockedOn,
|
||||
nextOwnerSession: opts.nextOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const closedId = asString(body.closedPacketId) ?? opts.currentPacket;
|
||||
const nextId = asString(body.nextPacketId);
|
||||
const nextOwner = asString(body.nextOwnerSession) ?? opts.nextOwner ?? "(default from spec)";
|
||||
const status = body.instanceStatus ?? (opts.exit === "done" ? "completed" : opts.exit === "failed" ? "failed" : "active");
|
||||
const whatTail = nextId ? ` and projected ${nextId} to ${nextOwner}` : (opts.exit === "done" ? " (instance done)" : "");
|
||||
const nextAction = nextId
|
||||
? `Inspect: rig queue show ${nextId}`
|
||||
: (opts.exit === "done" || opts.exit === "failed")
|
||||
? `Inspect: rig workflow show ${opts.instance}`
|
||||
: `Wait for upstream; rig workflow show ${opts.instance} to monitor`;
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Closed ${closedId} (${opts.exit})${whatTail}`,
|
||||
state: `instance ${opts.instance} = ${status}`,
|
||||
next: nextAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +259,12 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.description("List workflow instances; optionally filter by status")
|
||||
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow list
|
||||
$ rig workflow list --status active --json
|
||||
$ rig workflow list --status waiting | jq -r '.instances[].instanceId'
|
||||
`)
|
||||
.action(async (opts: { status?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
@@ -150,6 +286,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("specs")
|
||||
.description("List registered workflow specs; built-in starters tagged with (built-in)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow specs
|
||||
$ rig workflow specs --json | jq '.specs[] | select(.isBuiltIn==false)'
|
||||
`)
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -185,6 +326,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("show <instanceId>")
|
||||
.description("Show one workflow instance")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow show WF01ABC
|
||||
$ rig workflow show WF01ABC --json | jq '.instance.status'
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -197,6 +343,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("trace <instanceId>")
|
||||
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow trace WF01ABC
|
||||
$ rig workflow trace WF01ABC --json | jq '.trail[] | {step, actor, exit}'
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -209,11 +360,33 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("continue <instanceId>")
|
||||
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow continue WF01ABC
|
||||
$ rig workflow continue WF01ABC --json
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
const res = await client.post<{
|
||||
advanced?: boolean;
|
||||
nextPacketId?: string;
|
||||
nextOwnerSession?: string;
|
||||
instanceStatus?: string;
|
||||
}>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const advanced = body.advanced !== false;
|
||||
const nextId = asString(body.nextPacketId);
|
||||
const nextOwner = asString(body.nextOwnerSession) ?? "(unknown)";
|
||||
const status = body.instanceStatus ?? "(unknown)";
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: advanced
|
||||
? `Advanced instance ${instanceId}${nextId ? ` to packet ${nextId}` : ""}`
|
||||
: `No advance for instance ${instanceId} (already at frontier or terminal)`,
|
||||
state: `instance status = ${status}${nextId ? `; next owner = ${nextOwner}` : ""}`,
|
||||
next: nextId ? `Inspect: rig queue show ${nextId}` : `Inspect: rig workflow trace ${instanceId}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
|
||||
// PL-007 Workspace Primitive — `rig workspace` CLI surface.
|
||||
//
|
||||
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, and emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes the
|
||||
// report.
|
||||
// Verbs:
|
||||
// - `rig workspace validate` (slice-01) — walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes.
|
||||
// - `rig workspace doctor` (slice-21 FR-5) — runs the 7-check
|
||||
// workspace-readiness diagnostic against the daemon's resolved
|
||||
// workspace (or a --workspace override). Reports state + fix-hints.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
@@ -46,9 +49,35 @@ async function withClient<T>(
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — strict-int validator for
|
||||
// --max-files. Rejects `12abc`, `abc`, `0`, `-1`, etc. with a 3-part
|
||||
// fact/consequence/action error; does NOT call the daemon on invalid
|
||||
// input. Keeps positive cases (`10000`, `12`, etc.) flowing through.
|
||||
export function parseMaxFilesStrict(raw: string): number {
|
||||
if (!/^[1-9][0-9]*$/.test(raw)) {
|
||||
const err = new Error(
|
||||
`--max-files must be a positive integer (got "${raw}").`,
|
||||
) as Error & { fact: string; consequence: string; action: string };
|
||||
err.fact = `--max-files must be a positive integer (got "${raw}").`;
|
||||
err.consequence = "rig workspace validate did not run; daemon was not contacted.";
|
||||
err.action = "Pass a positive integer like --max-files 10000.";
|
||||
throw err;
|
||||
}
|
||||
return Number.parseInt(raw, 10);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
const cmd = new Command("workspace").description(
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 7-check workspace-readiness diagnostic.",
|
||||
);
|
||||
|
||||
const getDeps = (): WorkspaceDeps =>
|
||||
@@ -78,6 +107,17 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
// HG-6 — CLI-side validation BEFORE the daemon call. Reject
|
||||
// malformed --max-files with a 3-part error; never silently
|
||||
// coerce `12abc` → 12.
|
||||
let maxFiles: number;
|
||||
try {
|
||||
maxFiles = parseMaxFilesStrict(opts.maxFiles);
|
||||
} catch (err) {
|
||||
const e = err as Error & { fact?: string; consequence?: string; action?: string };
|
||||
emit3PartError(Boolean(opts.json), e.fact ?? e.message, e.consequence ?? "", e.action ?? "");
|
||||
return;
|
||||
}
|
||||
const root = path.resolve(rootArg ?? process.cwd());
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -86,7 +126,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
workspaceKind: opts.kind,
|
||||
recursive: opts.recursive !== false,
|
||||
requireFrontmatter: opts.requireFrontmatter ?? false,
|
||||
maxFiles: Number.parseInt(opts.maxFiles, 10),
|
||||
maxFiles,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
@@ -105,6 +145,59 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
},
|
||||
);
|
||||
|
||||
// Slice-21 FR-5 — `rig workspace doctor`.
|
||||
cmd
|
||||
.command("doctor")
|
||||
.description(
|
||||
"Run the 7-check workspace-readiness diagnostic against the daemon's resolved workspace. Reports state of workspace root, missions folder, file allowlist, daemon alignment, daemon reload, slice docs, and MISSION_NOTES. Read-only.",
|
||||
)
|
||||
.option(
|
||||
"--workspace <path>",
|
||||
"Override the workspace under check (default: daemon-resolved configured root)",
|
||||
)
|
||||
.option("--json", "JSON output for agents")
|
||||
.option(
|
||||
"--strict",
|
||||
"Exit non-zero on warn-or-fail (default: non-zero only on fail)",
|
||||
)
|
||||
.action(async (opts: { workspace?: string; json?: boolean; strict?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const requestBody: { workspaceRoot?: string; filesAllowlistOverride?: string } = {};
|
||||
if (opts.workspace) requestBody.workspaceRoot = path.resolve(opts.workspace);
|
||||
// FR-5e A2 — CLI-side env overlay for files.allowlist. The
|
||||
// daemon runs in its own process with its own env; an
|
||||
// operator who sets OPENRIG_FILES_ALLOWLIST in their CLI
|
||||
// shell expects the doctor result to reflect that override
|
||||
// even though the daemon's env is unchanged. We mirror the
|
||||
// --workspace override pattern: read the env at request
|
||||
// time and forward as a per-request overlay; the daemon
|
||||
// route applies it to check #3 with source="env".
|
||||
const cliAllowlistEnv = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
if (typeof cliAllowlistEnv === "string" && cliAllowlistEnv.length > 0) {
|
||||
requestBody.filesAllowlistOverride = cliAllowlistEnv;
|
||||
}
|
||||
const res = await client.post<DoctorReport>("/api/workspace/doctor", requestBody);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const report = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
renderHumanDoctorReport(report);
|
||||
}
|
||||
// Exit-code semantics per FR-5 IMPL-PRD §74:
|
||||
// default: non-zero only on fail
|
||||
// --strict: non-zero on warn-or-fail
|
||||
const hasFail = report.summary.fail > 0;
|
||||
const hasWarn = report.summary.warn > 0;
|
||||
if (hasFail || (opts.strict && hasWarn)) process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -125,3 +218,74 @@ function renderHumanReport(r: ValidationReport): void {
|
||||
console.log(` ${g.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- slice-21 FR-5 — `rig workspace doctor` types + human formatter ---
|
||||
//
|
||||
// DoctorReport shape mirrors the daemon's runWorkspaceDoctor output
|
||||
// at packages/daemon/src/domain/workspace/workspace-doctor.ts. Type
|
||||
// is duplicated here (not imported from the daemon package) because
|
||||
// the CLI does not have a direct daemon-package dependency edge; the
|
||||
// HTTP boundary already enforces the JSON shape.
|
||||
|
||||
interface DoctorCheckResult {
|
||||
check: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
workspaceRoot: string;
|
||||
checks: DoctorCheckResult[];
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
const DOCTOR_CHECK_GROUPS: ReadonlyArray<{ category: string; checks: ReadonlyArray<string> }> = [
|
||||
{ category: "workspace", checks: ["workspace_root_reachable", "file_allowlist_sane"] },
|
||||
{ category: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence"] },
|
||||
{ category: "daemon", checks: ["daemon_points_at_this_workspace", "daemon_reload_needed"] },
|
||||
];
|
||||
|
||||
function statusIcon(status: DoctorCheckResult["status"]): string {
|
||||
switch (status) {
|
||||
case "ok": return "OK";
|
||||
case "warn": return "WARN";
|
||||
case "fail": return "FAIL";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHumanDoctorReport(report: DoctorReport): void {
|
||||
console.log(`workspace doctor — ${report.workspaceRoot}`);
|
||||
console.log(` summary: ${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.fail} fail`);
|
||||
console.log("");
|
||||
|
||||
const byName = new Map(report.checks.map((c) => [c.check, c]));
|
||||
const remaining = new Set(report.checks.map((c) => c.check));
|
||||
|
||||
for (const group of DOCTOR_CHECK_GROUPS) {
|
||||
console.log(`${group.category}:`);
|
||||
for (const checkName of group.checks) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
remaining.delete(checkName);
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Any checks not in the documented groups still get rendered so a
|
||||
// future check addition doesn't silently drop from the human view
|
||||
// before the group-map is updated.
|
||||
if (remaining.size > 0) {
|
||||
console.log("other:");
|
||||
for (const checkName of remaining) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,12 +155,42 @@ export function resolveCliBaseDir(baseDir: string): string {
|
||||
return path.basename(baseDir) === "commands" ? path.resolve(baseDir, "..") : baseDir;
|
||||
}
|
||||
|
||||
/** Pure resolver: prefers bundled daemon (npm install layout), falls back to monorepo. */
|
||||
/**
|
||||
* Pure resolver: prefers monorepo source when present (dev checkout
|
||||
* where `packages/daemon/dist` is the source of truth), falls back to
|
||||
* the bundled/vendored copy at `packages/cli/daemon` (the npm-install
|
||||
* layout).
|
||||
*
|
||||
* Why this order: in a monorepo dev checkout BOTH paths exist:
|
||||
* - packages/daemon/dist ← refreshed by
|
||||
* `npm run build --workspace
|
||||
* @openrig/daemon` (frequent;
|
||||
* source of truth)
|
||||
* - packages/cli/daemon/dist ← refreshed only by
|
||||
* `scripts/build-package.sh`
|
||||
* (rare; for npm publish)
|
||||
*
|
||||
* Picking the vendored copy first (the prior order) means devs run a
|
||||
* stale daemon through `rig daemon start` whenever they've rebuilt
|
||||
* `@openrig/daemon` but haven't rerun `build-package.sh`. That hid the
|
||||
* fact that QA on local main 55e01f2e was getting a daemon WITHOUT the
|
||||
* shipped slice-09 `/api/rig-policy/*` routes (qitem-20260518054224).
|
||||
*
|
||||
* In an npm-install layout `packages/daemon/dist` does not exist
|
||||
* (only the bundled `packages/cli/daemon` lives next to the cli);
|
||||
* the fallback handles that case unchanged.
|
||||
*/
|
||||
export function resolveDaemonPath(baseDir: string, exists: (p: string) => boolean): string {
|
||||
const cliBaseDir = resolveCliBaseDir(baseDir);
|
||||
const monorepo = path.resolve(cliBaseDir, "../../daemon");
|
||||
if (exists(path.join(monorepo, "dist/index.js"))) return monorepo;
|
||||
const bundled = path.resolve(cliBaseDir, "../daemon");
|
||||
if (exists(path.join(bundled, "dist/index.js"))) return bundled;
|
||||
return path.resolve(cliBaseDir, "../../daemon");
|
||||
// Last-resort: return the monorepo path so callers get a meaningful
|
||||
// error rather than crashing on undefined. The caller validates
|
||||
// existence elsewhere (doctor / start) and surfaces "daemon dist not
|
||||
// found at <path>".
|
||||
return monorepo;
|
||||
}
|
||||
|
||||
export function getDaemonPath(): string {
|
||||
|
||||
@@ -60,6 +60,7 @@ import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore
|
||||
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import { rigPolicyCommand, type RigPolicyDeps } from "./commands/rig-policy.js";
|
||||
import type { LifecycleDeps } from "./daemon-lifecycle.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
@@ -115,6 +116,7 @@ export interface ProgramDeps {
|
||||
compactPlanDeps?: CompactPlanDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
rigPolicyDeps?: RigPolicyDeps;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
@@ -164,6 +166,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
|
||||
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
|
||||
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
|
||||
program.addCommand(rigPolicyCommand(depsOverride?.rigPolicyDeps));
|
||||
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
|
||||
program.addCommand(configCommand(depsOverride?.configPath));
|
||||
program.addCommand(preflightCommand());
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
mission: {{mission_id}}
|
||||
name: {{mission_name}}
|
||||
status: active
|
||||
authored: {{created_date}}
|
||||
last-updated: {{created_date}}
|
||||
---
|
||||
|
||||
# Mission Notes — {{mission_name}}
|
||||
|
||||
> Read the frontmatter `last-updated` first. If stale relative to runtime,
|
||||
> treat this file as `provisional` and re-verify before relying on it.
|
||||
> Pointer-first by design — index durable canonical artifacts; don't duplicate
|
||||
> them (duplication is the stale-breadcrumb failure mode this convention
|
||||
> exists to kill).
|
||||
|
||||
## §1. Top-of-mind context
|
||||
|
||||
(Current most-load-bearing state — gates, open scope-decisions, in-flight
|
||||
surprises. Updated by anyone when material changes. Keep to 5-15 lines.)
|
||||
|
||||
## §2. Active workstreams + drivers + tips
|
||||
|
||||
(Per-workstream rows. THIS SECTION IS THE MISSION CODEMAP. Each row:
|
||||
state / driver / branch+tip-SHA / latest blocker or ACK.)
|
||||
|
||||
- (workstream-id) — state — driver — tip-SHA — blocker/ACK
|
||||
|
||||
## §3. Open scope-decisions
|
||||
|
||||
(Labeled gates the operator owns. Per gate: where the gate is, what's needed,
|
||||
who routed.)
|
||||
|
||||
- OQ-1: (decision needed) — gate at <artifact-path> — surfaced by <seat>
|
||||
|
||||
## §4. Slice inventory
|
||||
|
||||
(Full table of slices on this mission.)
|
||||
|
||||
| # | slice | state | driver | blocker |
|
||||
|---|---|---|---|---|
|
||||
|
||||
## §5. Pending dispatches
|
||||
|
||||
(Next-go items waiting on driver availability or convergence. Immediately-ready
|
||||
set, not full backlog.)
|
||||
|
||||
## §6. Ledgers
|
||||
|
||||
(Mission-specific shared ledgers: release version + tag, SC counts, cumulative
|
||||
deltas, etc.)
|
||||
|
||||
## §7. Banked memories that apply here
|
||||
|
||||
(Bulleted: banked-feedback / banked-project memories load-bearing on restore
|
||||
for this mission. Ones learned during this mission go into the same list.)
|
||||
|
||||
## §8. Convention pointers
|
||||
|
||||
(Which banked conventions this mission inherits. Cross-reference each by path.
|
||||
This section is the principles/conventions inheritance surface for the mission.)
|
||||
|
||||
- `conventions/mission-notes/README.md` — this convention (apply on every restore)
|
||||
|
||||
## §9. Reconstruction protocol
|
||||
|
||||
(Commands to run after compaction or for onboarding. Refresh as the mission's
|
||||
restore-needs evolve.)
|
||||
|
||||
1. `rig whoami --json` — confirm seat identity
|
||||
2. Read this file's §1 + your §A-§X section + this §9
|
||||
3. Read `missions/{{mission_id}}/README.md` for mission scope
|
||||
4. Read `missions/{{mission_id}}/PROGRESS.md` for delivery state
|
||||
5. `rig queue list --destination <your-session>` — your durable inbox
|
||||
6. State "restored from {{mission_id}}; resumed at <action>" before acting
|
||||
|
||||
## §10. What NOT to reconstruct
|
||||
|
||||
(Explicit pruning — completed slices / merged commits / closed ACK packets are
|
||||
recoverable on demand and don't need to be in working memory. Name them to
|
||||
free attention.)
|
||||
|
||||
## §A. <first-seat>@<rig> notes
|
||||
|
||||
(First seat's notes. Pattern: append cont.N entries with date + state of the
|
||||
world from this seat's perspective. Latest = truth. Other seats READ but
|
||||
don't modify.)
|
||||
|
||||
DONE {{created_date}} (cont.0 — mission scaffolded):
|
||||
- Mission scaffolded via `rig scope mission create`. Template applied; this
|
||||
file is the seed state. First substantive cont.1 entry follows when work
|
||||
begins.
|
||||
|
||||
## §B / §C / ... — per-seat sections
|
||||
|
||||
(Each additional seat adds an h2 ## §X section under their own header as they
|
||||
join. Section pattern matches §A.)
|
||||
@@ -128,7 +128,7 @@ export function resolveMissionsRoot(opts: {
|
||||
throw new ScopeCliError({
|
||||
fact: `Could not locate a missions/ root from cwd ${cwd}.`,
|
||||
consequence: "No mission tree to operate on.",
|
||||
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/substrate/shared-docs/openrig-work.",
|
||||
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/your/workspace.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,70 @@ export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpt
|
||||
return applyPlaceholders(raw, opts);
|
||||
}
|
||||
|
||||
// OPR.0.3.2.21.FR-3 — MISSION_NOTES.md scaffold support.
|
||||
//
|
||||
// `rig scope mission create` auto-copies a MISSION_NOTES.md alongside
|
||||
// the README. The template lives at scope-templates/mission-notes.md
|
||||
// by default; the operator can override via the
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH env var (banked env-var-pivot
|
||||
// pattern: env > built-in fallback).
|
||||
|
||||
export interface MissionNotesRenderOpts {
|
||||
mission_id: string;
|
||||
mission_name: string;
|
||||
created_date: string;
|
||||
}
|
||||
|
||||
function applyMissionNotesPlaceholders(content: string, opts: MissionNotesRenderOpts): string {
|
||||
return content
|
||||
.replace(/\{\{mission_id\}\}/g, opts.mission_id)
|
||||
.replace(/\{\{mission_name\}\}/g, opts.mission_name)
|
||||
.replace(/\{\{created_date\}\}/g, opts.created_date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the MISSION_NOTES.md template absolute path.
|
||||
* Env-var override > built-in bundled fallback (scope-templates/mission-notes.md).
|
||||
* Returns the absolute path; the caller reads the file content.
|
||||
*/
|
||||
export function resolveMissionNotesTemplatePath(envValue?: string): string {
|
||||
const fromEnv = envValue ?? process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
if (fromEnv && fromEnv.trim().length > 0) {
|
||||
const absPath = path.resolve(fromEnv.trim());
|
||||
if (!fs.existsSync(absPath)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `OPENRIG_MISSION_NOTES_TEMPLATE_PATH points at "${fromEnv}", which does not exist.`,
|
||||
consequence: "MISSION_NOTES.md not scaffolded.",
|
||||
action: "Set OPENRIG_MISSION_NOTES_TEMPLATE_PATH to an absolute path to a readable template file, or unset it to use the built-in bundled fallback.",
|
||||
});
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
return resolveTemplate("mission-notes.md");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a MISSION_NOTES.md by reading the resolved template + substituting
|
||||
* the three placeholders (mission_id / mission_name / created_date).
|
||||
*
|
||||
* Surfaces the resolution decision (env-override vs built-in) for callers
|
||||
* that want to log or test the path.
|
||||
*/
|
||||
export function renderMissionNotesTemplate(
|
||||
opts: MissionNotesRenderOpts,
|
||||
envValue?: string,
|
||||
): { rendered: string; resolvedFrom: "env" | "built-in"; absPath: string } {
|
||||
const envVal = envValue ?? process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
const usedEnv = Boolean(envVal && envVal.trim().length > 0);
|
||||
const absPath = resolveMissionNotesTemplatePath(envValue);
|
||||
const raw = fs.readFileSync(absPath, "utf8");
|
||||
return {
|
||||
rendered: applyMissionNotesPlaceholders(raw, opts),
|
||||
resolvedFrom: usedEnv ? "env" : "built-in",
|
||||
absPath,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a folder-slug to a title-cased display name. */
|
||||
export function titleFromSlug(slug: string): string {
|
||||
return slug
|
||||
|
||||
@@ -54,6 +54,8 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
// Captured create bodies for assertion
|
||||
let capturedCreateBodies: Record<string, unknown>[] = [];
|
||||
// Captured install bodies for assertion (Item 2 Checkpoint 3.3)
|
||||
let capturedInstallBodies: Record<string, unknown>[] = [];
|
||||
|
||||
describe("Bundle CLI", () => {
|
||||
let server: http.Server;
|
||||
@@ -90,6 +92,7 @@ describe("Bundle CLI", () => {
|
||||
res.end(JSON.stringify({ manifest: { name: "test", version: "0.1.0" }, digestValid: true, integrityResult: { passed: true } }));
|
||||
} else if (req.url === "/api/bundles/install" && req.method === "POST") {
|
||||
const parsed = JSON.parse(body);
|
||||
capturedInstallBodies.push(parsed);
|
||||
if (String(parsed.bundlePath ?? "").includes("blocked")) {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "blocked" }));
|
||||
@@ -102,6 +105,17 @@ describe("Bundle CLI", () => {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "run-2", rigId: "rig-1" }));
|
||||
}
|
||||
} else if (req.url?.startsWith("/api/bundles/history") && req.method === "GET") {
|
||||
// Capture query for assertion if needed; for now return a static fixture
|
||||
const url = new URL(req.url, "http://x");
|
||||
const rigFilter = url.searchParams.get("rig");
|
||||
const allRecs = [
|
||||
{ installedAt: "2026-05-18T10:00:00Z", bundlePath: "/tmp/a.rigbundle", targetRigName: "alpha", outcome: "success" },
|
||||
{ installedAt: "2026-05-18T11:00:00Z", bundlePath: "/tmp/b.rigbundle", targetRigName: "beta", outcome: "failed" },
|
||||
];
|
||||
const recs = rigFilter ? allRecs.filter((r) => r.targetRigName === rigFilter) : allRecs;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ records: recs, total: recs.length }));
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
@@ -211,4 +225,207 @@ describe("Bundle CLI", () => {
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["rigRoot"]).toMatch(/\/my\/project/);
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: --notes flag is captured into provenance in the request body
|
||||
it("bundle create --notes wires the operator note into provenance.notes in the request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--notes", "checkpoint-2-part-3 fixture",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["notes"]).toBe("checkpoint-2-part-3 fixture");
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: provenance auto-includes hostname + cliVersion at invoke time
|
||||
it("bundle create automatically includes hostname + cliVersion in provenance (no flag needed)", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
// hostname and cliVersion auto-populate (real os.hostname() + CLI package.json read)
|
||||
expect(typeof provenance!["sourceHost"]).toBe("string");
|
||||
expect((provenance!["sourceHost"] as string).length).toBeGreaterThan(0);
|
||||
expect(typeof provenance!["cliVersion"]).toBe("string");
|
||||
expect((provenance!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
// notes is undefined when --notes not passed (no empty string sent)
|
||||
expect(provenance!["notes"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version + --min-cli-version flags wire into request body compatibility
|
||||
it("bundle create --min-daemon-version and --min-cli-version wire into request body compatibility", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
"--min-cli-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBe("0.3.2");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version alone (partial) still wires
|
||||
it("bundle create --min-daemon-version alone wires partial compatibility into request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05 Checkpoint 3.3: bundle install --skip-version-check wires through
|
||||
it("bundle install --skip-version-check sets skipVersionCheck=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--skip-version-check",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(true);
|
||||
// cliVersion auto-included (read at call time via getCliVersion)
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
expect((installBody!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bundle install without --skip-version-check sets skipVersionCheck=false and still sends cliVersion", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(false);
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
});
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.2: --force flag wires through to request body
|
||||
it("bundle install --force sets force=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--force",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(true);
|
||||
});
|
||||
|
||||
it("bundle install without --force sets force=false in request body (operator-explicit opt-in only)", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["force"]).toBe(false);
|
||||
});
|
||||
|
||||
// Item 4 / slice-05 Checkpoint 5.2: rig bundle history subcommand
|
||||
it("bundle history renders records as text by default", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history"]);
|
||||
});
|
||||
expect(logs.some((l) => l.includes("Bundle install history"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("alpha"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("beta"))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("success"))).toBe(true);
|
||||
});
|
||||
|
||||
it("bundle history --json outputs parseable JSON with records array", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(Array.isArray(parsed.records)).toBe(true);
|
||||
expect(parsed.records.length).toBe(2);
|
||||
expect(parsed.total).toBe(2);
|
||||
});
|
||||
|
||||
it("bundle history --rig filter passes through to the query string", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "bundle", "history", "--rig", "alpha", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(parsed.records).toHaveLength(1);
|
||||
expect(parsed.records[0].targetRigName).toBe("alpha");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: no flags → compatibility omitted (no empty object sent)
|
||||
it("bundle create with neither min-version flag omits compatibility from request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["compatibility"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: authorSession populates when OPENRIG_SESSION_NAME env is set
|
||||
it("bundle create includes authorSession when OPENRIG_SESSION_NAME env is set", async () => {
|
||||
capturedCreateBodies = [];
|
||||
const origEnv = process.env.OPENRIG_SESSION_NAME;
|
||||
process.env.OPENRIG_SESSION_NAME = "velocity-driver@openrig-velocity";
|
||||
try {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
if (origEnv === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||||
else process.env.OPENRIG_SESSION_NAME = origEnv;
|
||||
}
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["authorSession"]).toBe("velocity-driver@openrig-velocity");
|
||||
});
|
||||
});
|
||||
@@ -787,13 +787,38 @@ describe("Daemon Lifecycle", () => {
|
||||
});
|
||||
|
||||
describe("resolveDaemonPath", () => {
|
||||
it("returns bundled path when ../daemon/dist/index.js exists", () => {
|
||||
// QA BLOCKING qitem-20260518054224 — the resolver previously preferred
|
||||
// the bundled (vendored) copy, so monorepo dev workflows ran a stale
|
||||
// packaged daemon when packages/cli/daemon hadn't been re-assembled
|
||||
// via scripts/build-package.sh. After the fix the resolver prefers
|
||||
// the monorepo source (packages/daemon) when present, ensuring
|
||||
// `node packages/cli/dist/bin-wrapper.js daemon start` always
|
||||
// launches the daemon source-of-truth in development.
|
||||
|
||||
it("returns monorepo source path when packages/daemon/dist/index.js exists (DEV — both paths present)", () => {
|
||||
// Simulates a monorepo checkout: BOTH paths have dist/index.js
|
||||
// (vendored bundle assembled via scripts/build-package.sh + source
|
||||
// daemon built via @openrig/daemon). The resolver picks source.
|
||||
const exists = (p: string) => p.endsWith("daemon/dist/index.js");
|
||||
const result = resolveDaemonPath("/repo/packages/cli/dist", exists);
|
||||
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
|
||||
});
|
||||
|
||||
it("falls back to bundled path when only ../daemon/dist exists (NPM-INSTALL — single path)", () => {
|
||||
// Simulates `npm install -g @openrig/cli` layout:
|
||||
// node_modules/@openrig/cli/{dist,daemon,ui}
|
||||
// Only ../daemon (sibling to cli/dist) exists; ../../daemon doesn't.
|
||||
const exists = (p: string) => {
|
||||
// monorepo path absent; bundled present
|
||||
if (p === path.resolve("/install/cli/dist", "../../daemon", "dist/index.js")) return false;
|
||||
if (p === path.resolve("/install/cli/dist", "../daemon", "dist/index.js")) return true;
|
||||
return false;
|
||||
};
|
||||
const result = resolveDaemonPath("/install/cli/dist", exists);
|
||||
expect(result).toBe(path.resolve("/install/cli/dist", "../daemon"));
|
||||
});
|
||||
|
||||
it("falls back to monorepo path when bundled does not exist", () => {
|
||||
it("returns the monorepo path even when nothing exists, so callers can surface a clear error", () => {
|
||||
const exists = () => false;
|
||||
const result = resolveDaemonPath("/repo/packages/cli/dist", exists);
|
||||
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Slice-21 FR-5e A1 — `rig config init-workspace` scaffolds
|
||||
// MISSION_NOTES.md for each seeded mission so a fresh baseline passes
|
||||
// doctor check #7 (mission_notes_presence) without manual scope
|
||||
// scaffolding.
|
||||
//
|
||||
// QA finding: fresh `rig config init-workspace` followed by `rig
|
||||
// workspace doctor` produced summary {ok:6, warn:1, fail:0} because
|
||||
// the FR-3 auto-scaffold only fired from `rig scope mission create`,
|
||||
// not the init-workspace path.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
runInitWorkspace,
|
||||
workspaceScaffoldFiles,
|
||||
} from "../src/commands/config-init-workspace.js";
|
||||
|
||||
let dir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "fr5e-init-"));
|
||||
configPath = path.join(dir, "test-config.json");
|
||||
// Seed config with workspace.root pointing into our temp dir so
|
||||
// ConfigStore's resolveWithSource returns a known root.
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ workspace: { root: path.join(dir, "workspace") } }) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
describe("FR-5e A1 — init-workspace scaffolds MISSION_NOTES.md", () => {
|
||||
it("writes missions/getting-started/MISSION_NOTES.md alongside README + PROGRESS", () => {
|
||||
const result = runInitWorkspace({ configPath });
|
||||
const notesFile = result.files.find((f) =>
|
||||
f.relPath === "missions/getting-started/MISSION_NOTES.md");
|
||||
expect(notesFile).toBeDefined();
|
||||
expect(notesFile?.created).toBe(true);
|
||||
expect(fs.existsSync(notesFile!.absPath)).toBe(true);
|
||||
const content = fs.readFileSync(notesFile!.absPath, "utf-8");
|
||||
// Discriminator: the rendered template must have placeholder
|
||||
// substitutions applied. Without applyMissionNotesPlaceholders
|
||||
// the file would still contain `{{mission_id}}` etc.
|
||||
expect(content).not.toContain("{{mission_id}}");
|
||||
expect(content).not.toContain("{{mission_name}}");
|
||||
expect(content).not.toContain("{{created_date}}");
|
||||
// Discriminator: at minimum the rendered file should mention the
|
||||
// mission's display name from DEFAULT_MISSIONS.
|
||||
expect(content).toContain("Getting Started");
|
||||
});
|
||||
|
||||
// Discriminator-flip: workspaceScaffoldFiles is the canonical
|
||||
// file-list used by both CLI init and daemon's
|
||||
// /api/config/init-workspace. Both surfaces need MISSION_NOTES so
|
||||
// the daemon-side init (UI Settings → init workspace) lands the
|
||||
// same scaffold.
|
||||
it("workspaceScaffoldFiles() emits a MISSION_NOTES.md entry per default mission", () => {
|
||||
const files = workspaceScaffoldFiles();
|
||||
const notesFiles = files.filter((f) => f.relPath.endsWith("/MISSION_NOTES.md"));
|
||||
// DEFAULT_MISSIONS has one mission (getting-started). When the
|
||||
// seed list grows, this discriminator catches the count drift.
|
||||
expect(notesFiles.length).toBeGreaterThanOrEqual(1);
|
||||
for (const f of notesFiles) {
|
||||
expect(f.content).not.toContain("{{mission_id}}");
|
||||
expect(f.content).not.toContain("{{mission_name}}");
|
||||
expect(f.content).not.toContain("{{created_date}}");
|
||||
}
|
||||
});
|
||||
|
||||
// Discriminator-flip: dry-run should NOT write the MISSION_NOTES
|
||||
// file to disk but should report it as "would create".
|
||||
it("dry-run reports MISSION_NOTES.md without writing", () => {
|
||||
const result = runInitWorkspace({ configPath, dryRun: true });
|
||||
const notesFile = result.files.find((f) =>
|
||||
f.relPath === "missions/getting-started/MISSION_NOTES.md");
|
||||
expect(notesFile).toBeDefined();
|
||||
expect(notesFile?.created).toBe(true);
|
||||
expect(fs.existsSync(notesFile!.absPath)).toBe(false);
|
||||
expect(result.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
// Discriminator-flip: idempotent re-run without --force does NOT
|
||||
// overwrite the existing MISSION_NOTES. Operators who hand-edit the
|
||||
// notes shouldn't lose work on a second init.
|
||||
it("re-run without --force skips existing MISSION_NOTES.md", () => {
|
||||
runInitWorkspace({ configPath });
|
||||
const notesPath = path.join(dir, "workspace", "missions", "getting-started", "MISSION_NOTES.md");
|
||||
fs.writeFileSync(notesPath, "operator hand-edited content", "utf-8");
|
||||
const result = runInitWorkspace({ configPath });
|
||||
const notesFile = result.files.find((f) =>
|
||||
f.relPath === "missions/getting-started/MISSION_NOTES.md");
|
||||
expect(notesFile?.created).toBe(false);
|
||||
expect(notesFile?.skipped).toBe("exists");
|
||||
expect(fs.readFileSync(notesPath, "utf-8")).toBe("operator hand-edited content");
|
||||
});
|
||||
|
||||
// GUARD/FR-5e BLOCKER-1 discriminator
|
||||
// (qitem-20260602045638-1a6e964c): invalid
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH must fail BEFORE any
|
||||
// filesystem mutation. The previous (pre-fix) flow created
|
||||
// workspace root + subdirs first, then threw on render, leaving a
|
||||
// half-initialized workspace. With the verify-first-then-write
|
||||
// reorder, the throw fires before any mkdir.
|
||||
it("invalid OPENRIG_MISSION_NOTES_TEMPLATE_PATH fails BEFORE any filesystem mutation", () => {
|
||||
const wsRoot = path.join(dir, "leak-target-workspace");
|
||||
expect(fs.existsSync(wsRoot)).toBe(false);
|
||||
const original = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = path.join(dir, "definitely-not-a-real-template.md");
|
||||
try {
|
||||
expect(() => runInitWorkspace({ root: wsRoot, configPath })).toThrow(
|
||||
/OPENRIG_MISSION_NOTES_TEMPLATE_PATH/,
|
||||
);
|
||||
// Mutation-target: if scaffoldFiles were called AFTER mkdir,
|
||||
// the workspace root would exist on disk. The fix asserts it
|
||||
// does NOT exist.
|
||||
expect(fs.existsSync(wsRoot)).toBe(false);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { QueueDeps } from "../src/commands/queue.js";
|
||||
import { resolveQueueBody } from "../src/commands/queue.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
/**
|
||||
@@ -134,6 +138,274 @@ describe("rig queue CLI", () => {
|
||||
expect((create!.body as { nudge: boolean }).nudge).toBe(false);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-4(a) — body input resolution kills the
|
||||
// backtick-corruption class. Three accepted shapes: --body inline,
|
||||
// --body-file <path>, --body / --body-file - for stdin. Exactly one
|
||||
// of --body / --body-file is required; mutual exclusion validates.
|
||||
describe("FR-4(a) — --body-file + stdin support", () => {
|
||||
it("resolveQueueBody returns inline body when --body is passed", async () => {
|
||||
const out = await resolveQueueBody({ body: "inline value" });
|
||||
expect(out).toBe("inline value");
|
||||
});
|
||||
|
||||
it("resolveQueueBody reads from a file path when --body-file is passed", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-"));
|
||||
const bodyPath = path.join(tmp, "body.txt");
|
||||
const content = "Multi-line body with `raw backticks` and\nliteral newlines\n— this is the corruption class --body-file kills.";
|
||||
fs.writeFileSync(bodyPath, content, "utf8");
|
||||
try {
|
||||
const out = await resolveQueueBody({ bodyFile: bodyPath });
|
||||
expect(out).toBe(content);
|
||||
// Discriminator: the backtick-corruption shell class is bypassed
|
||||
// entirely because no shell substitution happens on file content.
|
||||
expect(out).toMatch(/`raw backticks`/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveQueueBody throws 3-part error when both --body and --body-file are passed", async () => {
|
||||
await expect(resolveQueueBody({ body: "inline", bodyFile: "/tmp/x" })).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/mutually exclusive|ambiguous/i),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/exactly one/),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveQueueBody throws 3-part error when neither --body nor --body-file is passed", async () => {
|
||||
await expect(resolveQueueBody({})).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/Neither --body nor --body-file/),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/--body|--body-file/),
|
||||
});
|
||||
});
|
||||
|
||||
it("resolveQueueBody throws 3-part error when --body-file path does not exist", async () => {
|
||||
await expect(resolveQueueBody({ bodyFile: "/tmp/this-path-does-not-exist-fr4a-test.md" })).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/does not exist/),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/Check the path/),
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-4 cleanup (guard non-blocking note on FR-4a CLEAR): a
|
||||
// directory passed to --body-file used to fall through to fs.readFileSync
|
||||
// and surface a bare Error("EISDIR: illegal operation on a directory") with
|
||||
// blank consequence/action. The cleanup commit emits the 3-part shape
|
||||
// explicitly so the error reads consistently with the other body-resolve
|
||||
// failure modes.
|
||||
it("resolveQueueBody throws 3-part error when --body-file path is a directory (not a regular file)", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-isdir-"));
|
||||
try {
|
||||
await expect(resolveQueueBody({ bodyFile: tmp })).rejects.toMatchObject({
|
||||
fact: expect.stringMatching(/not a regular file/),
|
||||
consequence: expect.stringMatching(/did not run/),
|
||||
action: expect.stringMatching(/Pass a path to a readable file/),
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveQueueBody calls the injected stdin reader when --body is -", async () => {
|
||||
const stdinReader = vi.fn(async () => "from stdin\n");
|
||||
const out = await resolveQueueBody({ body: "-" }, stdinReader);
|
||||
expect(out).toBe("from stdin\n");
|
||||
expect(stdinReader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolveQueueBody calls the injected stdin reader when --body-file is -", async () => {
|
||||
const stdinReader = vi.fn(async () => "from stdin file dash\n");
|
||||
const out = await resolveQueueBody({ bodyFile: "-" }, stdinReader);
|
||||
expect(out).toBe("from stdin file dash\n");
|
||||
expect(stdinReader).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("create --body-file <file-with-backticks> POSTs the file content as body (operator-copy-paste-safe)", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-body-file-"));
|
||||
const bodyPath = path.join(tmp, "body.txt");
|
||||
const content = "Per-commit handoff for OPR.X.Y.Z\n\n```bash\nrig queue handoff qitem-1 --to next@rig\n```\n\nDone.";
|
||||
fs.writeFileSync(bodyPath, content, "utf8");
|
||||
try {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "qitem-fr4a-1", state: "pending" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body-file", bodyPath,
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect(create, "expected POST /api/queue/create to fire").toBeDefined();
|
||||
const body = create!.body as Record<string, unknown>;
|
||||
expect(body.body).toBe(content);
|
||||
// Discriminator: the backtick fence survived intact, proving the
|
||||
// shell-substitution class never touched the content.
|
||||
expect((body.body as string)).toMatch(/```bash[\s\S]*?```/);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("create with both --body and --body-file errors with exit 1 + 3-part error + does NOT contact the daemon", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-conflict-"));
|
||||
const bodyPath = path.join(tmp, "body.txt");
|
||||
fs.writeFileSync(bodyPath, "x", "utf8");
|
||||
try {
|
||||
const { deps, calls } = makeDeps();
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
const prevExit = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "inline",
|
||||
"--body-file", bodyPath,
|
||||
"--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||||
} finally {
|
||||
process.exitCode = prevExit;
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("create with neither --body nor --body-file errors with exit 1 + does NOT contact the daemon", async () => {
|
||||
const { deps, calls } = makeDeps();
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
const prevExit = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
try {
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
|
||||
} finally {
|
||||
process.exitCode = prevExit;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-4(b) — --mission / --slice first-class flags
|
||||
// translate to mission:<id> / slice:<id> tags and compose with --tags.
|
||||
// This is tag-formalization only — no schema change; the qitem still
|
||||
// stores tags as a flat list.
|
||||
describe("FR-4(b) — --mission / --slice first-class flag-formalization", () => {
|
||||
it("--mission translates to a mission:<id> tag", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-1" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--mission", "release-0.3.2",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags: string[] }).tags).toEqual(["mission:release-0.3.2"]);
|
||||
});
|
||||
|
||||
it("--slice translates to a slice:<id> tag", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-2" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--slice", "21-fr-4-queue-ergonomics",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags: string[] }).tags).toEqual(["slice:21-fr-4-queue-ergonomics"]);
|
||||
});
|
||||
|
||||
it("--mission + --slice + --tags merges all three sets (mission/slice first, --tags appended) and de-duplicates", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-3" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--mission", "release-0.3.2",
|
||||
"--slice", "21-fr-4-queue-ergonomics",
|
||||
"--tags", "gate:guard,handoff:per-commit",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags: string[] }).tags).toEqual([
|
||||
"mission:release-0.3.2",
|
||||
"slice:21-fr-4-queue-ergonomics",
|
||||
"gate:guard",
|
||||
"handoff:per-commit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("--mission release-0.3.2 + --tags mission:release-0.3.2 de-duplicates the redundant tag (one mission:X kept)", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-4" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--mission", "release-0.3.2",
|
||||
"--tags", "mission:release-0.3.2,gate:guard",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
const tags = (create!.body as { tags: string[] }).tags;
|
||||
expect(tags.filter((t) => t === "mission:release-0.3.2")).toHaveLength(1);
|
||||
expect(tags).toContain("gate:guard");
|
||||
});
|
||||
|
||||
it("no --mission/--slice/--tags → tags is undefined on the wire (legacy behavior preserved)", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-5" } } },
|
||||
});
|
||||
const program = createProgram({ queueDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "queue", "create",
|
||||
"--source", "alice@rig",
|
||||
"--destination", "bob@rig",
|
||||
"--body", "x",
|
||||
"--json",
|
||||
]);
|
||||
const create = calls.find((c) => c.path === "/api/queue/create");
|
||||
expect((create!.body as { tags?: string[] }).tags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("update --state done WITHOUT --closure-reason renders structured hot-potato error and exits non-zero", async () => {
|
||||
const { deps } = makeDeps({
|
||||
routes: {
|
||||
|
||||
@@ -1468,7 +1468,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
|
||||
|
||||
describe("M2c-Daemon final regression — Velocity 4-pack round-trip via CLI", () => {
|
||||
let tmpRoot: string;
|
||||
const VELOCITY_ROOT = "/Users/example/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
|
||||
const VELOCITY_ROOT = "/Users/example/.openrig/shared-docs/internal-docs/field-notes/2026-04-27-velocity-claude-from-codex-restore";
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// Slice 09 (OPR.0.3.2.9) — `rig policy` CLI tests.
|
||||
//
|
||||
// HG-4 / HG-7 anchored at the CLI layer:
|
||||
// - HG-7: `rig policy set <mode>` WITHOUT --confirm restates the
|
||||
// proposed binding and exits 2; daemon is NOT called.
|
||||
// - HG-7: bare-word + `mode:` prefix BOTH normalize to a valid mode.
|
||||
// - HG-4: with --confirm + --bearer, an Authorization header is sent
|
||||
// on the PUT.
|
||||
// - HG-3: `rig policy effective` surfaces Q6 unknown_posture cleanly
|
||||
// when no binding matches.
|
||||
// - Citation helper format matches convention §Citation Rules
|
||||
// (short-prose with mode + scope(:qualifier) + operator source).
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { rigPolicyCommand, __test__, type RigPolicyDeps } from "../src/commands/rig-policy.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeResponse<T = unknown> { status: number; data: T }
|
||||
type RecordedCall = { method: string; path: string; body?: unknown; options?: { headers?: Record<string, string> } };
|
||||
|
||||
function fakeClient(opts: {
|
||||
defaultsResponse?: FakeResponse;
|
||||
putResponse?: FakeResponse;
|
||||
deleteResponse?: FakeResponse;
|
||||
getResponse?: FakeResponse;
|
||||
effectiveResponse?: FakeResponse;
|
||||
}): { client: unknown; calls: RecordedCall[] } {
|
||||
const calls: RecordedCall[] = [];
|
||||
const client = {
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
if (path.startsWith("/api/rig-policy/defaults")) {
|
||||
return opts.defaultsResponse ?? {
|
||||
status: 200,
|
||||
data: {
|
||||
recommendedModeDefaults: {
|
||||
sleep: { autonomy_scope: "pre_approved_only", heartbeat_cadence: "sparse", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "serial", permission_prompt_posture: "batch_for_human" },
|
||||
desk: { autonomy_scope: "full_autonomy_within_workstream", heartbeat_cadence: "normal", inspection_depth: "normal", update_detail: "normal", escalation_threshold: "normal", concurrency_limit: "unlimited", permission_prompt_posture: "normal" },
|
||||
mobile: { autonomy_scope: "bounded_continuation", heartbeat_cadence: "normal", inspection_depth: "surface", update_detail: "compact", escalation_threshold: "low", concurrency_limit: "unlimited", permission_prompt_posture: "batch_for_human" },
|
||||
away: { autonomy_scope: "pre_approved_only", heartbeat_cadence: "sparse", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "serial", permission_prompt_posture: "batch_for_human" },
|
||||
focus: { autonomy_scope: "full_autonomy_within_workstream", heartbeat_cadence: "normal", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "unlimited", permission_prompt_posture: "batch_for_human" },
|
||||
debug: { autonomy_scope: "bounded_continuation", heartbeat_cadence: "fast", inspection_depth: "forensic", update_detail: "verbose", escalation_threshold: "low", concurrency_limit: "serial", permission_prompt_posture: "normal" },
|
||||
},
|
||||
recommendedDefaultScope: { sleep: "global_host", desk: "global_host", mobile: "global_host", away: "global_host", focus: "workstream", debug: "qitem" },
|
||||
defaultStaleRule: "re_confirm_on_long_gap",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path.startsWith("/api/rig-policy/effective")) {
|
||||
return opts.effectiveResponse ?? { status: 200, data: { effective: null, posture: "unknown_posture", hint: "..." } };
|
||||
}
|
||||
return opts.getResponse ?? { status: 200, data: { bindings: [] } };
|
||||
}),
|
||||
put: vi.fn(async (path: string, body: unknown, options?: { headers?: Record<string, string> }) => {
|
||||
calls.push({ method: "PUT", path, body, options });
|
||||
const reqBody = body as { mode: string; record: Record<string, string> };
|
||||
return opts.putResponse ?? { status: 200, data: { binding: { id: "qitem:q-1", mode: reqBody.mode, record: reqBody.record, qualifier: "q-1", setAt: "2026-05-17T00:00:00.000Z", setBy: "operator" } } };
|
||||
}),
|
||||
delete: vi.fn(async (path: string, options?: { headers?: Record<string, string> }) => {
|
||||
calls.push({ method: "DELETE", path, options });
|
||||
return opts.deleteResponse ?? { status: 200, data: { removed: true } };
|
||||
}),
|
||||
};
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
function deps(client: unknown): RigPolicyDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => client as ReturnType<RigPolicyDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
let logs: string[];
|
||||
let errs: string[];
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errs = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => errs.push(args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
describe("disambiguateModeInvocation (Component 4 bare-word rule)", () => {
|
||||
const { disambiguateModeInvocation } = __test__;
|
||||
it("accepts bare reserved modes", () => {
|
||||
for (const m of ["sleep", "desk", "mobile", "away", "focus", "debug"]) {
|
||||
expect(disambiguateModeInvocation(m)).toBe(m);
|
||||
}
|
||||
});
|
||||
it("accepts the `mode:` prefix form", () => {
|
||||
expect(disambiguateModeInvocation("mode:debug")).toBe("debug");
|
||||
expect(disambiguateModeInvocation("mode: focus")).toBe("focus");
|
||||
});
|
||||
it("rejects unknown words", () => {
|
||||
expect(disambiguateModeInvocation("banana")).toBeNull();
|
||||
expect(disambiguateModeInvocation("operator:L1")).toBeNull();
|
||||
expect(disambiguateModeInvocation("OFF")).toBeNull();
|
||||
});
|
||||
it("rejects multi-word strings (embedded-in-sentence)", () => {
|
||||
expect(disambiguateModeInvocation("set debug")).toBeNull();
|
||||
expect(disambiguateModeInvocation("we should mobile this")).toBeNull();
|
||||
});
|
||||
it("rejects empty input", () => {
|
||||
expect(disambiguateModeInvocation("")).toBeNull();
|
||||
expect(disambiguateModeInvocation(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCitation (Component 5)", () => {
|
||||
const { formatCitation } = __test__;
|
||||
it("emits mode + scope(:qualifier) + operator source + set_at — mode is binding-level", () => {
|
||||
const line = formatCitation({
|
||||
id: "qitem:q-1",
|
||||
mode: "debug",
|
||||
record: { scope: "qitem" } as Record<string, string>,
|
||||
qualifier: "q-1",
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
});
|
||||
expect(line).toContain("`debug`");
|
||||
expect(line).toContain("`qitem:q-1`");
|
||||
expect(line).toContain("operator");
|
||||
expect(line).toContain("2026-05-17T00:00:00.000Z");
|
||||
});
|
||||
it("omits qualifier when null (global_host)", () => {
|
||||
const line = formatCitation({
|
||||
id: "global_host:host",
|
||||
mode: "sleep",
|
||||
record: { scope: "global_host" } as Record<string, string>,
|
||||
qualifier: null,
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
});
|
||||
expect(line).toContain("`global_host`");
|
||||
expect(line).not.toContain("`global_host:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy set — restate-and-confirm gate (HG-7)", () => {
|
||||
it("WITHOUT --confirm: restates + exits 2 + does NOT call PUT", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
const puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(puts).toHaveLength(0);
|
||||
expect(logs.join("\n")).toContain("Proposed binding (restate-and-confirm — NOT applied)");
|
||||
expect(logs.join("\n")).toContain("debug");
|
||||
});
|
||||
|
||||
it("WITHOUT --confirm + --json: ok:false + confirm_required:true", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--json"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
const joined = logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.confirm_required).toBe(true);
|
||||
expect(parsed.proposed.mode).toBe("debug");
|
||||
expect(parsed.proposed.record.mode).toBeUndefined();
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("WITH --confirm: PUTs { mode, record } to the daemon and prints citation", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0]!.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
const putBody = puts[0]!.body as { mode: string; record: Record<string, string> };
|
||||
expect(putBody.mode).toBe("debug");
|
||||
// BLOCKING-1: record must NOT carry `mode` inside.
|
||||
expect(putBody.record.mode).toBeUndefined();
|
||||
expect(putBody.record.scope).toBe("qitem");
|
||||
expect(logs.join("\n")).toContain("Operating in `debug` mode at `qitem:q-1`");
|
||||
});
|
||||
|
||||
it("HG-4: --bearer forwards Authorization: Bearer <token>", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm", "--bearer", "operator-token"]);
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
expect(put.options?.headers?.Authorization).toBe("Bearer operator-token");
|
||||
});
|
||||
|
||||
it("HG-7 mode:<name> prefix: works the same as bare word", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "mode:focus", "--qualifier", "ws-1", "--confirm"]);
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
const body = put.body as { mode: string; record: Record<string, string> };
|
||||
expect(body.mode).toBe("focus");
|
||||
expect(body.record.scope).toBe("workstream");
|
||||
expect(put.path).toBe("/api/rig-policy/bindings/workstream/ws-1");
|
||||
});
|
||||
|
||||
it("rejects unknown mode with exit=1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "banana", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Unknown mode/);
|
||||
});
|
||||
|
||||
it("forwards 401 from daemon with operator hint", async () => {
|
||||
const { client } = fakeClient({ putResponse: { status: 401, data: { error: "unauthorized" } } });
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errs.join("\n")).toMatch(/Unauthorized/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy effective — Q6 unknown_posture surfaced", () => {
|
||||
it("prints 'unknown_posture' when daemon returns null effective", async () => {
|
||||
const { client } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "effective", "--qitem", "q-1"]);
|
||||
expect(logs.join("\n")).toContain("unknown_posture");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy cite — convention citation rules", () => {
|
||||
it("prints unknown_posture fallback when no binding resolves", async () => {
|
||||
const { client } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "cite", "--qitem", "q-1"]);
|
||||
expect(logs.join("\n")).toContain("without an explicit");
|
||||
});
|
||||
|
||||
it("emits citation line when a binding resolves", async () => {
|
||||
const { client } = fakeClient({
|
||||
effectiveResponse: {
|
||||
status: 200,
|
||||
data: {
|
||||
posture: "known",
|
||||
effective: {
|
||||
resolvedScope: "qitem",
|
||||
binding: {
|
||||
id: "qitem:q-1",
|
||||
mode: "debug",
|
||||
record: { scope: "qitem" },
|
||||
qualifier: "q-1",
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "cite", "--qitem", "q-1"]);
|
||||
const line = logs.join("\n");
|
||||
expect(line).toMatch(/Operating in `debug` mode at `qitem:q-1` per operator/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeScopeQualifier (CLI mirror of daemon-route parseScopeAndQualifier)", () => {
|
||||
const { normalizeScopeQualifier } = __test__;
|
||||
it("global_host + no explicit qualifier → ok with null", () => {
|
||||
const r = normalizeScopeQualifier("global_host", undefined);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.qualifier).toBeNull();
|
||||
});
|
||||
it("global_host + explicit qualifier → REJECTED (no silent drop)", () => {
|
||||
const r = normalizeScopeQualifier("global_host", "unexpected");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.message).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
it("rig + qualifier → ok with the qualifier", () => {
|
||||
const r = normalizeScopeQualifier("rig", "rig-a");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.qualifier).toBe("rig-a");
|
||||
});
|
||||
it("rig + no qualifier → rejected (qualifier_required)", () => {
|
||||
const r = normalizeScopeQualifier("rig", undefined);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.message).toMatch(/requires a qualifier/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BLOCKING re-verify-2 (qitem-20260518045300): explicit --scope global_host --qualifier <X> rejected BEFORE any daemon contact", () => {
|
||||
it("WITHOUT --confirm: exits 1; daemon NOT contacted at all (no GET /defaults, no PUT, no restate)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
// BLOCKING re-verify-2 evidence: zero daemon calls of any kind.
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
expect(logs.join("\n")).not.toContain("Proposed binding");
|
||||
});
|
||||
|
||||
it("WITH --confirm: exits 1; daemon NOT contacted (no GET /defaults, no PUT)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected", "--confirm"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
|
||||
it("WITH --json: emits ok:false + qualifier_invalid; zero daemon calls", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected", "--json"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toBe("qualifier_invalid");
|
||||
});
|
||||
|
||||
it("Explicit non-global scope missing qualifier ALSO preflighted: zero daemon calls", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--scope", "rig"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/requires a qualifier/);
|
||||
});
|
||||
|
||||
it("Explicit unknown scope rejected locally: zero daemon calls", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--scope", "banana", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Unknown scope 'banana'/);
|
||||
});
|
||||
|
||||
it("Positive: set sleep --scope global_host --confirm → PUT /bindings/global_host (one GET /defaults + one PUT)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--confirm"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
expect(put.path).toBe("/api/rig-policy/bindings/global_host");
|
||||
});
|
||||
|
||||
it("Implicit-default-scope path STILL fetches defaults (no preflight rejection): set debug without --scope reaches GET /defaults + PUT", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const gets = calls.filter((c) => c.method === "GET" && c.path.startsWith("/api/rig-policy/defaults"));
|
||||
const puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(gets.length).toBe(1);
|
||||
expect(puts.length).toBe(1);
|
||||
expect(puts[0]!.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
});
|
||||
|
||||
it("Implicit-default-scope path still rejects missing qualifier when default scope needs one (no PUT)", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
// `debug` default scope is `qitem`; without --qualifier the post-defaults normalization rejects.
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/requires a qualifier/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy unset — operator-only DELETE", () => {
|
||||
it("calls DELETE with Authorization when --bearer is provided", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "qitem", "q-1", "--bearer", "operator-token"]);
|
||||
const del = calls.find((c) => c.method === "DELETE")!;
|
||||
expect(del.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
expect(del.options?.headers?.Authorization).toBe("Bearer operator-token");
|
||||
});
|
||||
|
||||
it("rejects unknown scope with exit=1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "banana", "x"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// BLOCKING re-verify (qitem-20260518044650): `unset global_host
|
||||
// <qualifier>` must NOT silently drop the qualifier and delete the
|
||||
// host row. Same shape as set; same shared helper.
|
||||
it("BLOCKING re-verify: unset global_host unexpected → exits 1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "global_host", "unexpected"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
|
||||
it("Positive: unset global_host (no qualifier) targets /bindings/global_host", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "global_host"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const del = calls.find((c) => c.method === "DELETE")!;
|
||||
expect(del.path).toBe("/api/rig-policy/bindings/global_host");
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ function writeFile(p: string, content: string): void {
|
||||
|
||||
function seedSubstrate(): { root: string; missionsRoot: string } {
|
||||
const root = mktemp();
|
||||
const missionsRoot = path.join(root, "openrig-work", "missions");
|
||||
const missionsRoot = path.join(root, "internal-docs", "missions");
|
||||
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.name", "T"], { stdio: "ignore" });
|
||||
@@ -391,6 +391,97 @@ describe("rig scope mission create (HG-14 + HG-15)", () => {
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(JSON.parse(r.stdout).ok).toBe(false);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.21.FR-3 — auto-scaffold MISSION_NOTES.md alongside README.
|
||||
it("FR-3: mission create auto-scaffolds MISSION_NOTES.md with placeholders substituted (default ON; built-in template)", async () => {
|
||||
const r = await run(["mission", "create", "release-0.6.0", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.missionNotesPath).toBeTruthy();
|
||||
expect(parsed.mission.missionNotesResolvedFrom).toBe("built-in");
|
||||
const mnPath = parsed.mission.missionNotesPath as string;
|
||||
expect(fs.existsSync(mnPath)).toBe(true);
|
||||
const content = fs.readFileSync(mnPath, "utf8");
|
||||
// Placeholders must be substituted.
|
||||
expect(content).not.toMatch(/\{\{mission_id\}\}/);
|
||||
expect(content).not.toMatch(/\{\{mission_name\}\}/);
|
||||
expect(content).not.toMatch(/\{\{created_date\}\}/);
|
||||
// Substituted values present.
|
||||
expect(content).toMatch(/mission: OPR\.0\.6\.0/);
|
||||
// titleFromSlug("0.6.0") → "0.6.0" (no separators to titlecase); the
|
||||
// bare version string is what lands in mission_name.
|
||||
expect(content).toMatch(/name: 0\.6\.0/);
|
||||
expect(content).toMatch(/cont\.0 — mission scaffolded/);
|
||||
// Canonical structure markers.
|
||||
expect(content).toMatch(/## §1\. Top-of-mind context/);
|
||||
expect(content).toMatch(/## §10\. What NOT to reconstruct/);
|
||||
expect(content).toMatch(/## §A\. <first-seat>@<rig> notes/);
|
||||
});
|
||||
|
||||
it("FR-3: --no-mission-notes opts out — README created, MISSION_NOTES.md is NOT", async () => {
|
||||
const r = await run([
|
||||
"mission", "create", "release-0.7.0",
|
||||
"--no-mission-notes", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
// README still produced.
|
||||
expect(fs.existsSync(parsed.mission.readmePath)).toBe(true);
|
||||
// MISSION_NOTES suppressed.
|
||||
expect(parsed.mission.missionNotesPath).toBeNull();
|
||||
expect(parsed.mission.missionNotesResolvedFrom).toBeNull();
|
||||
expect(fs.existsSync(path.join(parsed.mission.path, "MISSION_NOTES.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("FR-3: OPENRIG_MISSION_NOTES_TEMPLATE_PATH env var overrides the built-in template", async () => {
|
||||
// Write a custom template at a temp path containing a distinctive marker.
|
||||
const customTemplatePath = path.join(env.root, "custom-mission-notes-template.md");
|
||||
fs.writeFileSync(
|
||||
customTemplatePath,
|
||||
"---\nmission: {{mission_id}}\n---\n\n# CUSTOM TEMPLATE — {{mission_name}}\n\nCREATED ON {{created_date}}\n",
|
||||
"utf8",
|
||||
);
|
||||
const prior = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = customTemplatePath;
|
||||
try {
|
||||
const r = await run(["mission", "create", "release-0.8.0", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.missionNotesResolvedFrom).toBe("env");
|
||||
const content = fs.readFileSync(parsed.mission.missionNotesPath as string, "utf8");
|
||||
expect(content).toMatch(/CUSTOM TEMPLATE — 0\.8\.0/);
|
||||
expect(content).toMatch(/mission: OPR\.0\.8\.0/);
|
||||
// Built-in canonical sections must NOT appear (proves we used the custom one).
|
||||
expect(content).not.toMatch(/## §1\. Top-of-mind context/);
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = prior;
|
||||
}
|
||||
});
|
||||
|
||||
it("FR-3: OPENRIG_MISSION_NOTES_TEMPLATE_PATH pointing at a missing file fails with a 3-part error and no mission dir leaked", async () => {
|
||||
const prior = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = path.join(env.root, "does-not-exist.md");
|
||||
try {
|
||||
const r = await run(["mission", "create", "release-0.9.0", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/OPENRIG_MISSION_NOTES_TEMPLATE_PATH/);
|
||||
// Discriminator (guard catch qitem-20260601121058): the failed scaffold
|
||||
// MUST NOT leave a half-created mission dir behind. Without the
|
||||
// verify-first-then-write reorder, the mission dir + README would
|
||||
// exist after this command, and a retry would hit
|
||||
// "Mission folder already exists." which the operator would
|
||||
// reasonably read as a CLI bug.
|
||||
const leakedPath = path.join(env.missionsRoot, "release-0.9.0");
|
||||
expect(fs.existsSync(leakedPath), `expected no half-created mission at ${leakedPath} after stale env-var failure`).toBe(false);
|
||||
} finally {
|
||||
if (prior === undefined) delete process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
else process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH = prior;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -137,7 +137,13 @@ describe("Up CLI", () => {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.2.22 Bug 3 — path-form sourceRef triggers a /api/info
|
||||
// call for install-root awareness; respond with a 404 so the CLI
|
||||
// falls through (this test does not exercise the install-internal
|
||||
// path-form gate).
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
await captureLogs(async () => {
|
||||
@@ -220,7 +226,10 @@ describe("Up CLI", () => {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.2.22 Bug 3 /api/info fallback (404 → CLI falls through).
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
await captureLogs(async () => {
|
||||
@@ -364,6 +373,104 @@ describe("Up CLI", () => {
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
});
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 — path-form `rig up <spec-inside-install>` without
|
||||
// --cwd should default cwdOverride to process.cwd() so the spec's
|
||||
// member-level cwd: "." does not resolve into the OpenRig install root
|
||||
// and trip getOpenRigInstallCwdError at preflight. The bare-name form
|
||||
// is already rescued at the resolveLibrarySpec branch (covered by the
|
||||
// earlier library-name test); this pins the path-form gap.
|
||||
it("up path-form spec inside install root defaults cwdOverride to caller cwd and prints notice", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||||
const specPath = path.join(installRoot, "rigs", "demo", "rig.yaml");
|
||||
fs.mkdirSync(path.dirname(specPath), { recursive: true });
|
||||
fs.writeFileSync(specPath, "version: '0.2'\nname: demo\npods: []\nedges: []\n");
|
||||
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
let infoCalls = 0;
|
||||
const origListeners = server.listeners("request");
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/info" && req.method === "GET") {
|
||||
infoCalls += 1;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ installRoot }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||||
});
|
||||
expect(infoCalls, "expected /api/info to be queried for install-root awareness").toBe(1);
|
||||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(true);
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("up path-form spec OUTSIDE install root leaves cwdOverride undefined", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const os = await import("node:os");
|
||||
|
||||
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-project-"));
|
||||
const specPath = path.join(projectRoot, "rig.yaml");
|
||||
fs.writeFileSync(specPath, "version: '0.2'\nname: outside-rig\npods: []\nedges: []\n");
|
||||
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
const origListeners = server.listeners("request");
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/info" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ installRoot }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
|
||||
});
|
||||
expect(lastBody.cwdOverride).toBeUndefined();
|
||||
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(false);
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
fs.rmSync(installRoot, { recursive: true, force: true });
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("up apply uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// release-0.3.2 slice 01 BC repair — HG-6 discriminators for the
|
||||
// workflow project --exit enum. Without runtime validation, the
|
||||
// TypeScript-only enum allowed `--exit banana` to flow through to
|
||||
// the daemon transactional-scribe surface.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
workflowCommand,
|
||||
isProjectExitKind,
|
||||
PROJECT_EXIT_KINDS,
|
||||
type WorkflowDeps,
|
||||
} from "../src/commands/workflow.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
function captureStderr(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
return { logs, restore: () => { process.stderr.write = original; } };
|
||||
}
|
||||
|
||||
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkflowDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isProjectExitKind — pure helper", () => {
|
||||
it("accepts every documented exit kind", () => {
|
||||
for (const kind of PROJECT_EXIT_KINDS) {
|
||||
expect(isProjectExitKind(kind)).toBe(true);
|
||||
}
|
||||
});
|
||||
it("rejects garbage strings (BC-2 root cause)", () => {
|
||||
expect(isProjectExitKind("banana")).toBe(false);
|
||||
expect(isProjectExitKind("")).toBe(false);
|
||||
expect(isProjectExitKind("HANDOFF")).toBe(false); // case-sensitive
|
||||
});
|
||||
it("rejects non-strings", () => {
|
||||
expect(isProjectExitKind(undefined)).toBe(false);
|
||||
expect(isProjectExitKind(null)).toBe(false);
|
||||
expect(isProjectExitKind(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workflow project --exit — CLI-side discriminator (BLOCK 2)", () => {
|
||||
it("rejects '--exit banana' with --json: exit=1, body.error 3-part, client.post NOT called", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", "banana",
|
||||
"--actor-session", "x@y",
|
||||
"--json",
|
||||
]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = out.logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/--exit must be one of handoff \| waiting \| done \| failed.*banana/);
|
||||
expect(parsed.error.consequence).toMatch(/did not run/);
|
||||
expect(parsed.error.action).toMatch(/--exit handoff/);
|
||||
});
|
||||
|
||||
it("emits 3-part error to stderr in human mode", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const err = captureStderr();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", "banana",
|
||||
"--actor-session", "x@y",
|
||||
]);
|
||||
err.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = err.logs.join("");
|
||||
expect(joined).toMatch(/Error:.*--exit must be one of/);
|
||||
expect(joined).toMatch(/banana/);
|
||||
expect(joined).toMatch(/did not run/);
|
||||
});
|
||||
|
||||
it("rejects each garbage variant ('', 'HANDOFF', 'shipped') without calling the daemon", async () => {
|
||||
for (const bad of ["", "HANDOFF", "shipped"]) {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workflowCommand(deps);
|
||||
await root.parseAsync([
|
||||
"node", "rig", "project",
|
||||
"--instance", "WF1",
|
||||
"--current-packet", "q1",
|
||||
"--exit", bad,
|
||||
"--actor-session", "x@y",
|
||||
"--json",
|
||||
]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode, `--exit "${bad}" should fail`).toBe(1);
|
||||
expect(post, `--exit "${bad}" must not call the daemon`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
// Positive-path validation (e.g. --exit handoff forwards correctly)
|
||||
// is covered by isProjectExitKind pure-helper above; the live action
|
||||
// path is gated by getDaemonStatus and tested at the integration
|
||||
// tier, mirroring the slice-01 workflow-ux test pattern.
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// release-0.3.2 slice 01 (OPR.0.3.2.1) — GA-polish coverage for the
|
||||
// rig workflow command surface.
|
||||
//
|
||||
// Scope: static --help + description coverage; the what/state/next
|
||||
// summary behavior is verified at the building-block level via
|
||||
// printOutcomeSummary unit tests (exported for this purpose), since
|
||||
// the live action path is gated by getDaemonStatus and tested at the
|
||||
// integration tier elsewhere.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import {
|
||||
workflowCommand,
|
||||
printOutcomeSummary,
|
||||
type OutcomeSummary,
|
||||
} from "../src/commands/workflow.js";
|
||||
|
||||
describe("rig workflow --help carries examples on every command (HG-4)", () => {
|
||||
const expectedVerbs = [
|
||||
"validate",
|
||||
"instantiate",
|
||||
"project",
|
||||
"list",
|
||||
"specs",
|
||||
"show",
|
||||
"trace",
|
||||
"continue",
|
||||
];
|
||||
|
||||
// Commander v13 attaches `addHelpText('after', ...)` as a listener
|
||||
// that fires when --help is rendered; `helpInformation()` returns
|
||||
// Usage+Options+Subcommands but NOT the after-text. Source-scan the
|
||||
// command file to prove every verb carries an Examples block —
|
||||
// catches drift if a future refactor drops `addHelpText` from one
|
||||
// verb but leaves the others.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const workflowSrc = readFileSync(
|
||||
path.resolve(here, "../src/commands/workflow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
for (const verb of expectedVerbs) {
|
||||
it(`${verb} has an addHelpText('after', ...) block with an Examples section`, () => {
|
||||
const root = workflowCommand();
|
||||
const sub = root.commands.find((c) => c.name() === verb);
|
||||
expect(sub, `verb ${verb} should be registered`).toBeTruthy();
|
||||
// Source-scan: locate the command(...) declaration and look
|
||||
// for an Examples block in the addHelpText that follows.
|
||||
const verbBlockRe = new RegExp(
|
||||
`\\.command\\("${verb}[ "(<]([\\s\\S]*?)\\.action\\(`,
|
||||
"m",
|
||||
);
|
||||
const match = verbBlockRe.exec(workflowSrc);
|
||||
expect(match, `couldn't locate builder for "${verb}"`).toBeTruthy();
|
||||
const block = match![0];
|
||||
expect(block).toMatch(/\.addHelpText\("after",/);
|
||||
expect(block).toMatch(/Examples?:/i);
|
||||
expect(block).toMatch(/\$ rig workflow /); // at least one concrete example invocation
|
||||
});
|
||||
}
|
||||
|
||||
it("every verb has a non-empty description", () => {
|
||||
const root = workflowCommand();
|
||||
for (const sub of root.commands) {
|
||||
expect(
|
||||
sub.description().length,
|
||||
`verb ${sub.name()} needs a description`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("the top-level command itself is documented", () => {
|
||||
const root = workflowCommand();
|
||||
expect(root.description().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("printOutcomeSummary — what/state/next ending (HG-4)", () => {
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
const baseSummary: OutcomeSummary = {
|
||||
what: "Closed QITEM-123 (handoff) and projected QITEM-456 to velocity-qa@openrig-velocity",
|
||||
state: "instance WF01ABC = active",
|
||||
next: "Inspect: rig queue show QITEM-456",
|
||||
};
|
||||
|
||||
it("emits 4 lines (blank + what + state + next) in human mode on success", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(false, 200, baseSummary);
|
||||
out.restore();
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toMatch(/what:\s+Closed QITEM-123/);
|
||||
expect(joined).toMatch(/state:\s+instance WF01ABC = active/);
|
||||
expect(joined).toMatch(/next:\s+Inspect: rig queue show QITEM-456/);
|
||||
// Blank separator line precedes the summary.
|
||||
expect(out.logs[0]).toBe("");
|
||||
});
|
||||
|
||||
it("suppresses output in --json mode (machine consumers stay clean)", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(true, 200, baseSummary);
|
||||
out.restore();
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses output when status >= 400 (error path uses its own surface)", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(false, 500, baseSummary);
|
||||
out.restore();
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("suppresses output when summary is null", () => {
|
||||
const out = captureStdout();
|
||||
printOutcomeSummary(false, 200, null);
|
||||
out.restore();
|
||||
expect(out.logs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
// Slice-21 FR-5 — `rig workspace doctor` CLI subcommand tests.
|
||||
//
|
||||
// Covers:
|
||||
// - default behavior posts to /api/workspace/doctor with empty body
|
||||
// - --workspace passes body.workspaceRoot
|
||||
// - --json passes through the structured report
|
||||
// - human output groups by category (workspace / missions / daemon)
|
||||
// - exit-code semantics for fail / warn-with-strict / warn-without-strict / all-ok
|
||||
//
|
||||
// Mirrors the queue.test.ts pattern: vi.mock daemon-lifecycle to skip
|
||||
// the live status probe; clientFactory delivers stub HTTP responses.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
workspaceCommand,
|
||||
renderHumanDoctorReport,
|
||||
type WorkspaceDeps,
|
||||
} from "../src/commands/workspace.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>(
|
||||
"../src/daemon-lifecycle.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({
|
||||
state: "running",
|
||||
healthy: true,
|
||||
pid: 1234,
|
||||
port: 7433,
|
||||
})),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface DoctorReportFixture {
|
||||
workspaceRoot: string;
|
||||
checks: Array<{
|
||||
check: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
}>;
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
function healthyReport(workspaceRoot = "/ws"): DoctorReportFixture {
|
||||
return {
|
||||
workspaceRoot,
|
||||
checks: [
|
||||
{ check: "workspace_root_reachable", status: "ok", message: `workspace root '${workspaceRoot}' is a reachable directory` },
|
||||
{ check: "missions_folder_present", status: "ok", message: `missions folder '${workspaceRoot}/missions' is present` },
|
||||
{ check: "file_allowlist_sane", status: "ok", message: "files.allowlist has 1 usable entry covering workspace root" },
|
||||
{ check: "daemon_points_at_this_workspace", status: "ok", message: "daemon and caller agree on workspace root" },
|
||||
{ check: "daemon_reload_needed", status: "ok", message: "config file mtime is older than daemon start" },
|
||||
{ check: "optional_slice_docs", status: "ok", message: "every slice has a README" },
|
||||
{ check: "mission_notes_presence", status: "ok", message: "every mission has MISSION_NOTES.md" },
|
||||
],
|
||||
summary: { ok: 7, warn: 0, fail: 0 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(response: { status: number; data: unknown }): {
|
||||
deps: WorkspaceDeps;
|
||||
post: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const post = vi.fn(async (_path: string, _body: unknown) => response);
|
||||
return {
|
||||
post,
|
||||
deps: {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
getText: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
let originalExitCode: number | string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode;
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — request shape", () => {
|
||||
it("POSTs to /api/workspace/doctor with empty body when no --workspace", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", {});
|
||||
});
|
||||
|
||||
// Discriminator-flip: when --workspace is passed, body.workspaceRoot
|
||||
// must carry the resolved absolute path. Without path.resolve(), a
|
||||
// relative input like "./ws" would land as "./ws" on the wire and
|
||||
// the daemon check #4 would compare against an un-normalized value.
|
||||
it("POSTs body.workspaceRoot (absolute) when --workspace is supplied", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport("/ws-override") });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync([
|
||||
"node", "rig", "doctor", "--workspace", "/ws-override",
|
||||
]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", { workspaceRoot: "/ws-override" });
|
||||
});
|
||||
|
||||
it("resolves relative --workspace to absolute path before posting", async () => {
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync([
|
||||
"node", "rig", "doctor", "--workspace", "./local-ws",
|
||||
]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { workspaceRoot: string };
|
||||
expect(sent.workspaceRoot.startsWith("/")).toBe(true);
|
||||
expect(sent.workspaceRoot.endsWith("/local-ws")).toBe(true);
|
||||
});
|
||||
|
||||
// GUARD/QA BLOCKING-A2 discriminator: CLI-side
|
||||
// OPENRIG_FILES_ALLOWLIST must propagate to the daemon as
|
||||
// body.filesAllowlistOverride. Without this, an operator setting
|
||||
// the env in their CLI shell sees no doctor behavior change because
|
||||
// the daemon's env-resolution is decoupled from the CLI process.
|
||||
it("forwards OPENRIG_FILES_ALLOWLIST from process.env as body.filesAllowlistOverride", async () => {
|
||||
const original = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
process.env.OPENRIG_FILES_ALLOWLIST = "workspace:.";
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
else process.env.OPENRIG_FILES_ALLOWLIST = original;
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { workspaceRoot?: string; filesAllowlistOverride?: string };
|
||||
expect(sent.filesAllowlistOverride).toBe("workspace:.");
|
||||
});
|
||||
|
||||
// Discriminator-flip: an unset OPENRIG_FILES_ALLOWLIST must NOT
|
||||
// attach an override (the daemon should use SettingsStore as the
|
||||
// source of truth, not silently apply an empty-string).
|
||||
it("omits filesAllowlistOverride from body when OPENRIG_FILES_ALLOWLIST is unset", async () => {
|
||||
const original = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
else process.env.OPENRIG_FILES_ALLOWLIST = original;
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { filesAllowlistOverride?: string };
|
||||
expect(sent.filesAllowlistOverride).toBeUndefined();
|
||||
});
|
||||
|
||||
// Discriminator-flip: empty-string env value is treated as unset
|
||||
// (no override sent). Without the length > 0 check, an empty value
|
||||
// would land as filesAllowlistOverride="" and the daemon route
|
||||
// would treat that as an explicit override.
|
||||
it("treats empty-string OPENRIG_FILES_ALLOWLIST as unset (no override sent)", async () => {
|
||||
const original = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
process.env.OPENRIG_FILES_ALLOWLIST = "";
|
||||
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
if (original === undefined) delete process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
else process.env.OPENRIG_FILES_ALLOWLIST = original;
|
||||
}
|
||||
const [, body] = post.mock.calls[0]!;
|
||||
const sent = body as { filesAllowlistOverride?: string };
|
||||
expect(sent.filesAllowlistOverride).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — output formatters", () => {
|
||||
it("--json prints the report as a single-line JSON document", async () => {
|
||||
const report = healthyReport();
|
||||
const { deps } = makeDeps({ status: 200, data: report });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--json"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(out.logs).toHaveLength(1);
|
||||
const parsed = JSON.parse(out.logs[0]!) as DoctorReportFixture;
|
||||
expect(parsed.summary).toEqual({ ok: 7, warn: 0, fail: 0 });
|
||||
expect(parsed.checks).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("default human output groups checks under workspace / missions / daemon categories", async () => {
|
||||
const { deps } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("workspace doctor — /ws");
|
||||
expect(joined).toContain("summary: 7 ok, 0 warn, 0 fail");
|
||||
expect(joined).toContain("workspace:");
|
||||
expect(joined).toContain("missions:");
|
||||
expect(joined).toContain("daemon:");
|
||||
// Discriminator: each check name must appear under the correct
|
||||
// group section. We assert the category labels appear BEFORE the
|
||||
// check names that belong to them.
|
||||
const wsIdx = joined.indexOf("workspace:");
|
||||
const wsCheckIdx = joined.indexOf("workspace_root_reachable");
|
||||
const missionsIdx = joined.indexOf("missions:");
|
||||
const missionsCheckIdx = joined.indexOf("missions_folder_present");
|
||||
const daemonIdx = joined.indexOf("daemon:");
|
||||
const daemonCheckIdx = joined.indexOf("daemon_points_at_this_workspace");
|
||||
expect(wsIdx).toBeLessThan(wsCheckIdx);
|
||||
expect(missionsIdx).toBeLessThan(missionsCheckIdx);
|
||||
expect(daemonIdx).toBeLessThan(daemonCheckIdx);
|
||||
});
|
||||
|
||||
// Discriminator-flip: fixHint MUST render below the failing check.
|
||||
// Without `if (c.fixHint) console.log(...)` operators don't see how
|
||||
// to fix the failure.
|
||||
it("human output renders fixHint under failing/warning checks", () => {
|
||||
const report: DoctorReportFixture = {
|
||||
workspaceRoot: "/ws",
|
||||
checks: [
|
||||
{ check: "workspace_root_reachable", status: "fail", message: "workspace root '/ws' does not exist", fixHint: "run `rig config init-workspace`" },
|
||||
{ check: "missions_folder_present", status: "ok", message: "ok" },
|
||||
{ check: "file_allowlist_sane", status: "ok", message: "ok" },
|
||||
{ check: "daemon_points_at_this_workspace", status: "ok", message: "ok" },
|
||||
{ check: "daemon_reload_needed", status: "ok", message: "ok" },
|
||||
{ check: "optional_slice_docs", status: "ok", message: "ok" },
|
||||
{ check: "mission_notes_presence", status: "ok", message: "ok" },
|
||||
],
|
||||
summary: { ok: 6, warn: 0, fail: 1 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
const out = captureStdout();
|
||||
try {
|
||||
renderHumanDoctorReport(report);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("[FAIL] workspace_root_reachable");
|
||||
expect(joined).toContain("Fix: run `rig config init-workspace`");
|
||||
});
|
||||
|
||||
// Discriminator-flip: a check absent from DOCTOR_CHECK_GROUPS falls
|
||||
// into "other:" rather than vanishing. Protects against silent
|
||||
// future-check drop-off.
|
||||
it("human output renders ungrouped checks under 'other:'", () => {
|
||||
const report: DoctorReportFixture = {
|
||||
workspaceRoot: "/ws",
|
||||
checks: [
|
||||
{ check: "future_unknown_check", status: "warn", message: "new check the formatter didn't know about" },
|
||||
],
|
||||
summary: { ok: 0, warn: 1, fail: 0 },
|
||||
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
|
||||
};
|
||||
const out = captureStdout();
|
||||
try {
|
||||
renderHumanDoctorReport(report);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
const joined = out.logs.join("\n");
|
||||
expect(joined).toContain("other:");
|
||||
expect(joined).toContain("future_unknown_check");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace doctor — exit-code semantics", () => {
|
||||
it("exits 0 when summary is all-ok (default mode)", async () => {
|
||||
const { deps } = makeDeps({ status: 200, data: healthyReport() });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// Discriminator-flip: any fail must trip exit code 1 even without
|
||||
// --strict.
|
||||
it("exits 1 when summary has at least one fail (default mode)", async () => {
|
||||
const failing = healthyReport();
|
||||
failing.checks[0]!.status = "fail";
|
||||
failing.summary = { ok: 6, warn: 0, fail: 1 };
|
||||
const { deps } = makeDeps({ status: 200, data: failing });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// Discriminator-flip: warns are NOT failures in default mode.
|
||||
it("exits 0 when summary has warns but no fails (default mode)", async () => {
|
||||
const warning = healthyReport();
|
||||
warning.checks[6]!.status = "warn";
|
||||
warning.summary = { ok: 6, warn: 1, fail: 0 };
|
||||
const { deps } = makeDeps({ status: 200, data: warning });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// Discriminator-flip: --strict turns warns into exit-1.
|
||||
it("exits 1 when summary has warns and --strict is set", async () => {
|
||||
const warning = healthyReport();
|
||||
warning.checks[6]!.status = "warn";
|
||||
warning.summary = { ok: 6, warn: 1, fail: 0 };
|
||||
const { deps } = makeDeps({ status: 200, data: warning });
|
||||
const out = captureStdout();
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--strict"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// Discriminator-flip: HTTP 5xx → exit 1.
|
||||
it("exits 1 when daemon returns 5xx", async () => {
|
||||
const { deps } = makeDeps({ status: 503, data: { error: "settings_unavailable" } });
|
||||
const out = captureStdout();
|
||||
const stderrOrig = process.stderr.write;
|
||||
process.stderr.write = ((_chunk: unknown) => true) as typeof process.stderr.write;
|
||||
try {
|
||||
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
|
||||
} finally {
|
||||
out.restore();
|
||||
process.stderr.write = stderrOrig;
|
||||
}
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// release-0.3.2 slice 01 BC repair — HG-6 discriminators.
|
||||
// Verifies that `rig workspace validate --max-files <garbage>` rejects
|
||||
// CLI-side with a 3-part error AND does NOT call the daemon. Matches
|
||||
// the guard BC-1 fix recipe.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { workspaceCommand, parseMaxFilesStrict, type WorkspaceDeps } from "../src/commands/workspace.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function captureStdout(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
|
||||
};
|
||||
return { logs, restore: () => { console.log = original; } };
|
||||
}
|
||||
|
||||
function captureStderr(): { logs: string[]; restore: () => void } {
|
||||
const logs: string[] = [];
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
return { logs, restore: () => { process.stderr.write = original; } };
|
||||
}
|
||||
|
||||
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkspaceDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => ({
|
||||
post,
|
||||
get: vi.fn(),
|
||||
postText: vi.fn(),
|
||||
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseMaxFilesStrict — pure helper", () => {
|
||||
it("accepts positive integers", () => {
|
||||
expect(parseMaxFilesStrict("1")).toBe(1);
|
||||
expect(parseMaxFilesStrict("12")).toBe(12);
|
||||
expect(parseMaxFilesStrict("10000")).toBe(10000);
|
||||
});
|
||||
it("rejects garbage tails (BC-1 root cause)", () => {
|
||||
expect(() => parseMaxFilesStrict("12abc")).toThrow(/positive integer/);
|
||||
});
|
||||
it("rejects non-numeric input", () => {
|
||||
expect(() => parseMaxFilesStrict("abc")).toThrow(/positive integer/);
|
||||
expect(() => parseMaxFilesStrict("")).toThrow(/positive integer/);
|
||||
});
|
||||
it("rejects zero and negatives", () => {
|
||||
expect(() => parseMaxFilesStrict("0")).toThrow(/positive integer/);
|
||||
expect(() => parseMaxFilesStrict("-1")).toThrow(/positive integer/);
|
||||
});
|
||||
it("3-part error carries fact + consequence + action fields", () => {
|
||||
try {
|
||||
parseMaxFilesStrict("12abc");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
const e = err as Error & { fact?: string; consequence?: string; action?: string };
|
||||
expect(e.fact).toMatch(/positive integer/);
|
||||
expect(e.consequence).toMatch(/did not run/);
|
||||
expect(e.action).toMatch(/Pass a positive integer/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig workspace validate --max-files — CLI-side discriminator (BLOCK 1)", () => {
|
||||
it("rejects '12abc' with --json: exit=1, body.error has fact/consequence/action, client.post NOT called", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workspaceCommand(deps);
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc", "--json"]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = out.logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/positive integer/);
|
||||
expect(parsed.error.consequence).toMatch(/did not run/);
|
||||
expect(parsed.error.action).toMatch(/--max-files/);
|
||||
});
|
||||
|
||||
it("rejects 'abc', '0', '-1' equivalently", async () => {
|
||||
for (const bad of ["abc", "0", "-1"]) {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const out = captureStdout();
|
||||
const root = workspaceCommand(deps);
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", bad, "--json"]);
|
||||
out.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode, `--max-files ${bad} should fail`).toBe(1);
|
||||
expect(post, `--max-files ${bad} must not call the daemon`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits 3-part error to stderr in human mode (no --json)", async () => {
|
||||
const post = vi.fn();
|
||||
const deps = fakeRunningDeps(post);
|
||||
const err = captureStderr();
|
||||
const originalExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const root = workspaceCommand(deps);
|
||||
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc"]);
|
||||
err.restore();
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = originalExitCode;
|
||||
expect(exitCode).toBe(1);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
const joined = err.logs.join("");
|
||||
expect(joined).toMatch(/Error:.*positive integer/);
|
||||
expect(joined).toMatch(/did not run/);
|
||||
expect(joined).toMatch(/Pass a positive integer/);
|
||||
});
|
||||
|
||||
// Positive case is covered by parseMaxFilesStrict pure-helper test
|
||||
// above ("accepts positive integers"); driving end-to-end here
|
||||
// requires stubbing the daemon-status check too, which is covered
|
||||
// by the daemon-lifecycle adopt/expand test pattern (out of scope
|
||||
// for this BC discriminator).
|
||||
});
|
||||
+1
-1
@@ -245,7 +245,7 @@ For real depth, these are the load-bearing canonical docs:
|
||||
|
||||
| Reference | What it covers |
|
||||
|---|---|
|
||||
| `openrig/docs/as-built/architecture.md` | Daemon architecture; system overview; package boundaries |
|
||||
| `openrig/docs/as-built/README.md` | As-built map of territory — daemon architecture, system overview, package boundaries; routes to the 13 `architecture/` + 4 `ui/` modules via `codemap.md` |
|
||||
| `openrig/docs/as-built/cli-reference.md` | The full `rig` CLI surface with all subcommands and flags |
|
||||
| `openrig/docs/reference/rig-spec.md` | The RigSpec YAML format — pods, members, edges, all fields |
|
||||
| `openrig/docs/reference/agent-spec.md` | The AgentSpec YAML format — resources, profiles, imports |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -38,10 +38,34 @@ Then run:
|
||||
|
||||
## Communication
|
||||
|
||||
- Send messages to agents via tmux or `rig send`
|
||||
- Read agent output via `rig capture`
|
||||
- Use the chatroom for broadcast announcements
|
||||
- Keep the human informed of progress at natural milestones
|
||||
You ship work through three coordination surfaces. See the
|
||||
"Coordination primitives — when to use which" section at the top of
|
||||
the `openrig-user` skill for full definitions, runnable examples,
|
||||
the §1b doctrine, and the anti-patterns list. Brief mental model:
|
||||
|
||||
- `rig send <seat> "<text>"` — intra-pod nudges and quick context.
|
||||
**NOT for durable work.** No queue record; the message lands and
|
||||
is gone.
|
||||
- `rig queue create --source X --destination Y --tags ... --body ...`
|
||||
— durable work item. Survives restarts. Tracked. Tag with
|
||||
mission / slice / gate / checkpoint. Use this for any substantive
|
||||
dispatch, verdict, or handoff.
|
||||
- `rig queue handoff <qitem-id> --to <next> ...` — hot-potato
|
||||
forward momentum. Chain-of-record preserved. **This is how a turn
|
||||
ends — by passing the ball, never by going idle.**
|
||||
|
||||
Other operator surfaces:
|
||||
|
||||
- Read agent output via `rig capture`.
|
||||
- Use the chatroom for broadcast announcements where the
|
||||
audience is the whole rig.
|
||||
- Keep the human informed of progress at natural milestones.
|
||||
|
||||
§1b reminder for orchestrators: a turn ends by passing the ball,
|
||||
never by going idle holding the slice waiting on a confirmation
|
||||
the process does not include. If work is authorized, dispatch it
|
||||
via `rig queue create` and stand by for the verdict back; do not
|
||||
phantom-gate the rig on an imagined operator confirmation.
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
@@ -12,22 +12,121 @@ This is not the config-layer or builder guide. Use the substrate control-plane g
|
||||
and experimental overlays. Use the OpenRig builder guidance when changing OpenRig behavior,
|
||||
doctrine, or release posture.
|
||||
|
||||
## Coordination primitives — when to use which
|
||||
|
||||
Three coordination surfaces, used together for forward-momentum work. Internalize this
|
||||
on first read — it shapes every turn you take in a rig.
|
||||
|
||||
### `rig send <seat> "<text>"` — intra-pod direct messaging / nudges
|
||||
|
||||
Use when you need to ask a quick question or give a teammate context that does not
|
||||
carry handoff semantics. **NOT for durable work.** NOT for state that must survive
|
||||
across turns. The message lands in the target's pane; there is no durable queue
|
||||
record. The CLI prints `Sent to ...` (and `Verified: yes/no` with `--verify`); read
|
||||
the receipt and move on.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
rig send velocity-guard@openrig-velocity "Heads up — filing per-commit handoff on slice-22 Bug 1 BONUS at tip 6b8673b6." --verify
|
||||
```
|
||||
|
||||
### `rig queue create --source <X> --destination <Y> --tags <...> --body "<...>"` — durable work item
|
||||
|
||||
Use for any substantive work that must not fall through chat — slice handoffs,
|
||||
guard verdicts, QA results, full-tip reviews, multi-item batches. Survives agent
|
||||
restarts. Tracked in the daemon SQLite schema. Surfaces in Project / queue views
|
||||
+ in the destination seat's inbox. Tag with mission / slice / gate / checkpoint
|
||||
so future-you (and any peer) can find it.
|
||||
|
||||
Body discipline: substantive bodies go through a temp-file pattern, not inline
|
||||
`--body` with raw backticks — `rig queue create` body parsing breaks on
|
||||
unescaped backticks and rejects flag-like tokens.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
rig queue create \
|
||||
--source redo-driver-2@openrig-velocity \
|
||||
--destination redo-guard-2@openrig-velocity \
|
||||
--tags "mission:release-0.3.2,slice:22-rig-up-paper-cut-fix-round,gate:guard,handoff:per-commit,checkpoint:bug-1-bonus" \
|
||||
--body "$(cat /tmp/per-commit-body.txt)"
|
||||
```
|
||||
|
||||
### `rig queue handoff <qitem-id> --to <next> ...` — hot-potato handoff
|
||||
|
||||
Use when you have completed your turn on a qitem and the work moves to the next
|
||||
owner. **This is forward momentum.** The ball passes to the destination seat;
|
||||
chain-of-record (the prior qitem id) is preserved so the verdict trail is intact;
|
||||
tags carry the phase boundary forward (e.g. `gate:guard` → `gate:qa`).
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
rig queue handoff qitem-20260601012431-d78aa805 \
|
||||
--to velocity-qa@openrig-velocity \
|
||||
--tags "mission:release-0.3.2,slice:22-rig-up-paper-cut-fix-round,gate:qa,handoff:adversarial-dogfood"
|
||||
```
|
||||
|
||||
### §1b doctrine — turn ends by passing the ball
|
||||
|
||||
**A turn ends by passing the ball, never by going idle holding the slice waiting
|
||||
on a confirmation the process does not include.** If the work was authorized, the
|
||||
per-commit guard + adversarial QA + orch heavy-verify are the guardrails — not an
|
||||
operator pre-commit gate. Do the authorized work and pass the ball.
|
||||
|
||||
Valid pauses are only:
|
||||
|
||||
- A genuine blocker — file a blocked-state qitem against the blocking peer or
|
||||
surface explicitly to orch.
|
||||
- A scope-or-architecture question that requires owner input and changes the
|
||||
plan — surface to orch with the specific decision needed.
|
||||
|
||||
Implementing already-authorized work is neither of these. Proceed without
|
||||
phantom-gating on an imagined "next prompt" or "operator confirmation" that the
|
||||
process does not require.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- Using `rig send` for durable work → use `rig queue create` instead. Sends do
|
||||
not survive restarts and do not show up in queue/project views.
|
||||
- Idle-holding a slice for an imagined "next prompt" or "operator confirmation"
|
||||
that the process does not require → pass the ball via `rig queue handoff` and
|
||||
proceed to the next slice or stand by for the inbound verdict. See the §1b
|
||||
doctrine above.
|
||||
- Hand-coding `rigx queue` for new work → `rig queue` is the daemon-backed
|
||||
canonical surface since the 2026-05-11 host-CLI fix. `rigx queue` is a
|
||||
recovery-only fallback; qitems written via `rigx queue` are invisible to
|
||||
daemon-backed reads and break fleet-wide routing discipline.
|
||||
- Inlining a multi-line / backtick-heavy body into `rig queue create --body`
|
||||
→ write the body to `/tmp/<descriptive-name>.txt` first, then
|
||||
`--body "$(cat /tmp/<file>.txt)"`. The body parser does not tolerate raw
|
||||
backticks or flag-like tokens inline.
|
||||
|
||||
## Runtime-Gated Coordination Primitives
|
||||
|
||||
OpenRig v0.2.0 includes the bundled PL-004 Coordination Primitive System: Phase A `rig stream` /
|
||||
`rig queue`, Phase B `rig project` / `rig view`, Phase C `rig watchdog`, and Phase D `rig workflow`
|
||||
/ `workflow-keepalive`.
|
||||
OpenRig v0.3.1 is published publicly as `@openrig/[email protected]` and GitHub Release
|
||||
`v0.3.1`. It includes the bundled PL-004 Coordination Primitive System: Phase A
|
||||
`rig stream` / `rig queue`, Phase B `rig project` / `rig view`, Phase C
|
||||
`rig watchdog`, and Phase D `rig workflow` / `workflow-keepalive`.
|
||||
|
||||
Do not confuse package/source truth with active daemon truth. These commands are shipped product
|
||||
surfaces in v0.2.0+, but they need a v0.2.0+ daemon and matching SQLite schema at runtime.
|
||||
These are shipped product surfaces in v0.3.x, but they require a compatible
|
||||
v0.3.x daemon and matching SQLite schema at runtime — the installed package
|
||||
version is not automatically the version of the daemon serving you. If a
|
||||
coordination command behaves unexpectedly, confirm the running daemon with
|
||||
`rig whoami --json` and daemon status before assuming a product bug.
|
||||
|
||||
Default posture:
|
||||
|
||||
- Treat daemon `rig queue`, `rig stream`, `rig project`, `rig view`, `rig watchdog`, and
|
||||
`rig workflow` as the product coordination surfaces when the active daemon is v0.2.0 or newer.
|
||||
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) is the
|
||||
canonical surface for queue routing since the 2026-05-11 host-CLI fix landed. Use
|
||||
`rig queue create / handoff / update / show / list` for all substantive work.
|
||||
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) became
|
||||
the canonical queue-routing surface when the 2026-05-11 host-CLI fix landed. The
|
||||
coordination model is now load-bearing at the top of this skill — see
|
||||
"Coordination primitives — when to use which" above for the send / queue /
|
||||
queue-handoff usage model and the §1b doctrine. Auxiliary queue verbs:
|
||||
`rig queue update / show / list` complement `rig queue create / handoff`
|
||||
for in-flight inspection and state mutation.
|
||||
- Use temporary substrate overlays such as `rigx queue`, `rigx stream`, `rigx project`, and
|
||||
`rigx view-proto` only where the current OpenRig workstream explicitly says that legacy/control
|
||||
layer is still in use. For queue specifically, `rigx queue` is recovery-only fallback;
|
||||
@@ -38,6 +137,184 @@ Default posture:
|
||||
- Do not perform daemon stop/start, production DB copy/mutation, release, publish, or other
|
||||
consequence-boundary actions unless the operator/workstream has granted that specific gate.
|
||||
|
||||
## First-user workspace setup
|
||||
|
||||
When booting into a rig on a host where the workspace is unset, gap-ridden, or
|
||||
points at a stale layout, address that before substantive project work. The
|
||||
shipped surface is small + bounded — reach for the canonical commands rather
|
||||
than improvising.
|
||||
|
||||
### Detect workspace state at boot
|
||||
|
||||
Agent-actionable when the daemon is reachable.
|
||||
|
||||
```bash
|
||||
rig workspace validate --json
|
||||
rig workspace validate <path> --kind <user|project|knowledge|lab|delivery> --json
|
||||
```
|
||||
|
||||
`rig workspace validate` walks the workspace root and emits a structured
|
||||
frontmatter-gap report against the v0 contract. Exit code is non-zero when
|
||||
gaps exist (operators chain into hygiene fix loops). Default root is the
|
||||
current directory; pass a positional path to validate elsewhere. `--kind`
|
||||
scopes the contract to a specific workspace kind; omit for a kind-agnostic
|
||||
structural check.
|
||||
|
||||
If `rig workspace validate` reports a non-zero `gapCount` OR the workspace
|
||||
root is unset / unwritable, the workspace needs instantiation — see the next
|
||||
section.
|
||||
|
||||
### Instantiate the canonical workspace scaffold
|
||||
|
||||
Agent-actionable. Idempotent on existing dirs without `--force`.
|
||||
|
||||
```bash
|
||||
rig config init-workspace
|
||||
rig config init-workspace --root <path>
|
||||
rig config init-workspace --dry-run --json
|
||||
```
|
||||
|
||||
`rig config init-workspace` scaffolds the canonical workspace layout at the
|
||||
configured `workspace.root` (default `~/.openrig/workspace`):
|
||||
|
||||
- `missions/` — release missions + slices
|
||||
- `artifacts/` — work artifacts produced inside the workspace
|
||||
- `evidence/` — non-dogfood evidence (release evidence, proof packets, etc.)
|
||||
- `progress/` — progress index + per-mission rails
|
||||
- `field-notes/` — operator + agent observations
|
||||
- `specs/` — spec library (rig + agent + workflow YAML lives here)
|
||||
- `dogfood-evidence/` — dogfood proof packets + run artifacts
|
||||
|
||||
The scaffold seeds one example mission (`getting-started`) with multiple
|
||||
slices, and drops a workspace README.md + STEERING.md so a fresh install has
|
||||
browsable Project content. `--root <path>` targets a non-default root for
|
||||
this call; `--dry-run` reports what would be created without writing.
|
||||
`--force` overwrites existing FILES but never deletes
|
||||
directories — operator content is safe.
|
||||
|
||||
### Redirect the workspace root
|
||||
|
||||
Operator-gated when persistent. Agent-actionable when one-shot via env-var.
|
||||
|
||||
For a single command:
|
||||
|
||||
```bash
|
||||
OPENRIG_WORKSPACE_ROOT=<path> rig <command> ...
|
||||
```
|
||||
|
||||
For a persistent host-level redirect, the operator changes the config file or
|
||||
runs the setter:
|
||||
|
||||
```bash
|
||||
rig config set workspace.root <path>
|
||||
```
|
||||
|
||||
ConfigStore precedence: `OPENRIG_WORKSPACE_ROOT` env > config-file
|
||||
`workspace.root` > built-in default `~/.openrig/workspace`. The same
|
||||
precedence governs `OPENRIG_WORKSPACE_SPECS_ROOT` → `workspace.specs_root`
|
||||
(default `<workspace_root>/specs`).
|
||||
|
||||
Prefer the env-var form for one-shot redirects (transparent to operators);
|
||||
reserve `rig config set` for changes the operator owns.
|
||||
|
||||
### Build a workspace from scratch
|
||||
|
||||
Agent-actionable. Same surface as the canonical scaffold above; the
|
||||
`workspace.root` cascade handles non-existent host paths.
|
||||
|
||||
```bash
|
||||
rig config init-workspace --root /path/to/new/workspace
|
||||
```
|
||||
|
||||
The command creates the root dir if missing (idempotent: existing root +
|
||||
populated subdirs is a no-op). Run
|
||||
`rig workspace validate /path/to/new/workspace --json` after to confirm the
|
||||
contract holds.
|
||||
|
||||
### Create a workflow inside an existing workspace
|
||||
|
||||
Authoring is operator-or-agent; validation + instantiation are
|
||||
agent-actionable.
|
||||
|
||||
Workflow spec files live at:
|
||||
|
||||
```
|
||||
<workspace_root>/specs/workflows/<name>.yaml
|
||||
```
|
||||
|
||||
`<workspace_root>` resolves via the ConfigStore precedence named above.
|
||||
There is no `rig workflow create` verb in v0.3.x — the spec YAML is authored
|
||||
directly. Template by hand from the documented schema, or copy a built-in
|
||||
starter from `<openrig install>/dist/builtins/workflow-specs/` and adapt.
|
||||
Once written:
|
||||
|
||||
```bash
|
||||
rig workflow validate <workspace_root>/specs/workflows/<name>.yaml --json
|
||||
|
||||
rig workflow instantiate <workspace_root>/specs/workflows/<name>.yaml \
|
||||
--root-objective "<one-line objective for the run>" \
|
||||
--created-by <your-session>@<your-rig> \
|
||||
--json
|
||||
```
|
||||
|
||||
Both `--root-objective <text>` and `--created-by <session>` are REQUIRED
|
||||
on `instantiate` — omitting either yields a Commander required-option
|
||||
error before the daemon is contacted. `--entry-owner <session>` is an
|
||||
optional override for the entry-step owner; default routing is per the
|
||||
workflow spec.
|
||||
|
||||
`validate` returns a structured ok/error report; `instantiate` creates a
|
||||
workflow instance + entry-step qitem. Inspect existing surface state with:
|
||||
|
||||
```bash
|
||||
rig workflow specs --json # list registered specs (built-in + operator-authored)
|
||||
rig workflow list --json # list active workflow instances
|
||||
rig workflow show <instanceId> --json
|
||||
```
|
||||
|
||||
## v0.3.x Starter, Workspace, And Plugin Surfaces
|
||||
|
||||
OpenRig v0.3.0 adds `rig agent-image`, `rig context-pack`, `rig workspace`, and
|
||||
`rig config init-workspace`. It also shifts fresh-user starter guidance toward
|
||||
`product-team` for human-directed work and `conveyor` for workflow-oriented
|
||||
work. Treat `demo` as legacy/test content unless a task specifically asks for
|
||||
the old demo spec.
|
||||
|
||||
OpenRig v0.3.1 adds public package/source surfaces for Plugin Primitive v0,
|
||||
Claude Auto-Compaction Policy, migration `040_workflow_specs_diagnostic`,
|
||||
Library Explorer finishing, Settings Destination Explorer, Dashboard/For You
|
||||
vellum refresh, storytelling adapter, and action outcome + inline error UX.
|
||||
|
||||
`rig plugin` is read-only at v0:
|
||||
|
||||
```bash
|
||||
rig plugin list
|
||||
rig plugin show <id>
|
||||
rig plugin used-by <id>
|
||||
rig plugin validate <path>
|
||||
```
|
||||
|
||||
There is no `rig plugin install` verb in v0.3.1. Plugin installation remains
|
||||
explicit operator copy/symlink to `$OPENRIG_HOME/plugins/<plugin-id>/`.
|
||||
|
||||
Claude auto-compaction policy is opt-in default-off. The v0.3.1 package and
|
||||
this host's active daemon ship `policies.claude_compaction.*` ConfigStore keys,
|
||||
but no behavior changes unless the operator enables the policy.
|
||||
|
||||
Known v0.3.0/v0.3.1 caveats:
|
||||
- `rig down <name> --delete` still returns 404 in the known D1 path; use
|
||||
`rig ps` to find the rig ID and then `rig down <rigId> --delete`.
|
||||
- `rig queue` / `rig view` JSON and limit compatibility drift is an open
|
||||
follow-up from host-adoption proof; treat it as a compatibility caveat, not a
|
||||
daemon-health failure.
|
||||
- Queue/view JSON/limit drift is now refined as a wrapper-layer routing issue,
|
||||
not a daemon-layer issue; use human-readable output for affected wrapper
|
||||
commands until v0.3.2.
|
||||
- First v0.3.1 daemon start hit a plugin-vendor fallback health-probe timeout;
|
||||
controlled retry succeeded. Manual retry is the current workaround.
|
||||
- Topology mobile drawer restoration and plugin source-label taxonomy are
|
||||
v0.3.2 carry-forwards.
|
||||
|
||||
## Core Loop
|
||||
|
||||
Most work in OpenRig reduces to this loop:
|
||||
|
||||
@@ -53,7 +53,7 @@ states.
|
||||
|
||||
**Failed resume is FAILED loudly.** No automatic fresh fallback. Fresh
|
||||
launch is **explicit follow-up only.** This is enforced architecturally
|
||||
at the daemon level (per `architecture.md` §7 rule 15).
|
||||
at the daemon level (per `docs/as-built/architecture/architecture-rules-and-event-system.md` rule 15).
|
||||
|
||||
The locked restore-outcome vocabulary:
|
||||
|
||||
@@ -113,4 +113,4 @@ different claims; don't conflate.
|
||||
- `openrig-user` skill — CLI surface for `rig up / down / restore / etc.`
|
||||
- `openrig-operator` skill — operator-level discipline for OpenRig itself
|
||||
- `seat-continuity-and-handover` skill — occupant-creation modes for restore (`resume` / `rebuild` / `fresh` / `failed`)
|
||||
- `openrig/docs/as-built/architecture.md` (product reference doc) — daemon enforcement of restore-honesty rule
|
||||
- `openrig/docs/as-built/architecture/lifecycle-snapshot-restore.md` (product reference doc) — daemon enforcement of restore-honesty rule
|
||||
@@ -58,7 +58,7 @@ edges:
|
||||
- kind: delegates_to
|
||||
from: build.builder
|
||||
to: review.reviewer
|
||||
- kind: delegates_to
|
||||
- kind: can_observe
|
||||
from: review.reviewer
|
||||
to: build.builder
|
||||
- kind: can_observe
|
||||
|
||||
@@ -258,7 +258,8 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
|
||||
if (!identity) return "";
|
||||
|
||||
const sharedDocsRoot = process.env.OPENRIG_SHARED_DOCS_ROOT?.trim()
|
||||
|| nodePath.join(this.fs.homedir ?? os.homedir(), "code", "substrate", "shared-docs");
|
||||
// OPR.0.3.2.14 — subpath scrubbed (internal-team layout → generic placeholder).
|
||||
|| nodePath.join(this.fs.homedir ?? os.homedir(), ".openrig", "shared-docs");
|
||||
const queueStateRoot = nodePath.join(sharedDocsRoot, "rigs", identity.rig, "state", identity.pod);
|
||||
return ` --add-dir ${shellQuote(queueStateRoot)}`;
|
||||
}
|
||||
|
||||
@@ -313,4 +313,70 @@ export class TmuxAdapter {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — configure tmux's per-window `monitor-silence` option for
|
||||
* the target's containing window. The runtime flips
|
||||
* `pane_silence_flag` to "1" when the pane has been silent past the
|
||||
* threshold; "0" while producing output. This is the v0 source of
|
||||
* the `terminal-active` primitive (README §v0). Window-scoped is the
|
||||
* correct level: tmux's monitor-silence is a window option (see
|
||||
* `man tmux` §OPTIONS); `-w` targets the window containing the pane.
|
||||
*
|
||||
* Validates `seconds` at the adapter boundary so a bad caller can't
|
||||
* inject negative / zero / fractional / NaN values into the shell
|
||||
* command — those would either silently coerce or fail with an
|
||||
* opaque tmux error.
|
||||
*/
|
||||
async setMonitorSilence(target: string, seconds: number): Promise<TmuxResult> {
|
||||
if (!Number.isFinite(seconds) || !Number.isInteger(seconds) || seconds < 1) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "validation_error",
|
||||
message: `setMonitorSilence: seconds must be a positive integer, got ${seconds}`,
|
||||
};
|
||||
}
|
||||
const cmd = `tmux set-option -w -t ${shellQuote(target)} monitor-silence ${shellQuote(String(seconds))}`;
|
||||
try {
|
||||
await this.exec(cmd);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return classifyWriteError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — read the timestamp (Unix epoch seconds) of the last
|
||||
* activity on the pane's window. The daemon's SeatActivityService
|
||||
* compares this against the configured silence window: if the
|
||||
* timestamp is within the window the seat is `terminal-active`,
|
||||
* otherwise it's silent past the threshold.
|
||||
*
|
||||
* Why not `pane_silence_flag`: tmux 3.6a was observed to return a
|
||||
* blank value for `#{pane_silence_flag}` during slice 15 dogfood
|
||||
* (sticky-alert behavior + version-dependent emit semantics), so
|
||||
* we cannot rely on it as the primary signal. `#{window_activity}`
|
||||
* is reliably populated (the runtime updates it whenever the
|
||||
* window receives output) and is the timestamp the tmux status-line
|
||||
* activity indicators use themselves.
|
||||
*
|
||||
* Returns:
|
||||
* - a Unix-epoch-seconds integer when the runtime exposed the value
|
||||
* - `null` when the target is missing OR the value is unparseable
|
||||
* (consumers treat null as "no signal", distinct from "idle").
|
||||
*/
|
||||
async readPaneLastActivity(paneId: string): Promise<number | null> {
|
||||
try {
|
||||
const output = await this.exec(
|
||||
`tmux display-message -p -t ${shellQuote(paneId)} '#{window_activity}'`,
|
||||
);
|
||||
const trimmed = output.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const n = Number(trimmed);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
return n;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,11 @@ import type { Migration } from "../migrate.js";
|
||||
* - reason (TEXT) — required for hold + drop
|
||||
* - annotation (TEXT) — required for annotate
|
||||
* - notify_attempted (INTEGER 0|1) — for handoff
|
||||
* - notify_result (TEXT) — verified | sent-unverified | failed:<reason>
|
||||
* - notify_result (TEXT) — verified | delivered-ack-pending | failed:<reason>
|
||||
* (post-OPR.0.3.2.21.FR-4(c) wording: "sent-unverified" was misleading
|
||||
* because it read as a partial-failure even when delivery was fine;
|
||||
* "delivered-ack-pending" reads as the normal-and-expected case for
|
||||
* codex seats whose synchronous ack window expired while busy)
|
||||
* - audit_notes_json (TEXT) — operator-supplied evidence map
|
||||
*
|
||||
* Indexes:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Slice 09 Rig Policy Primitive (OPR.0.3.2.9) — operator-context-mode
|
||||
* binding table.
|
||||
*
|
||||
* One row per scoped binding:
|
||||
* (scope, qualifier) is a unique key; multiple modes at different
|
||||
* scopes coexist (e.g., sleep@global_host + debug@qitem). The
|
||||
* effective-mode resolver picks the most-specific applicable row
|
||||
* for a (rig, workstream, qitem) read context.
|
||||
*
|
||||
* Schema follows the workspace-primitive migration pattern: small
|
||||
* table, JSON column for the 10-field record (the validator owns
|
||||
* its integrity at write time). This matches the typed-primitive
|
||||
* precedent the slice IMPL-PRD names (HG-5: no parallel store).
|
||||
*
|
||||
* `qualifier` is NULL for `scope = 'global_host'` and carries the
|
||||
* appropriate id for the other three scopes.
|
||||
*
|
||||
* `set_at` is a UTC ISO string written by the daemon. The drift
|
||||
* mechanism reads this to compute long-gap re-confirmation prompts
|
||||
* (v0 ships the field; the numeric threshold is convention Q3
|
||||
* follow-on).
|
||||
*
|
||||
* `set_by` is always `'operator'` in v0 — there is no agent-set path.
|
||||
* The column exists so future operator-attribution work (e.g.,
|
||||
* multi-operator hosts) can shape per-operator views without a
|
||||
* schema migration; v0's validator + route enforce the operator-only
|
||||
* contract at the boundary.
|
||||
*/
|
||||
export const rigPolicySchema: Migration = {
|
||||
name: "041_rig_policy.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS operator_context_mode_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global_host', 'rig', 'workstream', 'qitem')),
|
||||
qualifier TEXT,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('sleep', 'desk', 'mobile', 'away', 'focus', 'debug')),
|
||||
record_json TEXT NOT NULL,
|
||||
set_at TEXT NOT NULL,
|
||||
set_by TEXT NOT NULL CHECK (set_by = 'operator')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_operator_context_mode_bindings_scope_qualifier
|
||||
ON operator_context_mode_bindings(scope, qualifier);
|
||||
`,
|
||||
};
|
||||
@@ -7,7 +7,7 @@
|
||||
// fork without snapshot side effect).
|
||||
//
|
||||
// Honest failure mode: returns null when no token is available rather
|
||||
// than fabricating one (architecture.md § Resume honesty).
|
||||
// than fabricating one (docs/as-built/architecture/adapters-and-runtimes.md § Resume honesty).
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// Resume-token discovery routes through resume-token-discovery.ts so
|
||||
// the same logic is shared with the /api/agent-images/fork route.
|
||||
//
|
||||
// Failure modes surface honest errors per architecture.md § Resume
|
||||
// Failure modes surface honest errors per
|
||||
// docs/as-built/architecture/adapters-and-runtimes.md § Resume
|
||||
// honesty (no fabricated tokens; no auto-fallback to fresh).
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
@@ -433,5 +433,28 @@ function normalizeProfile(raw: Record<string, unknown>): ProfileSpec {
|
||||
plugins: Array.isArray(uses?.["plugins"]) ? uses["plugins"] as string[] : [],
|
||||
runtimeResources: Array.isArray(uses?.["runtime_resources"]) ? uses["runtime_resources"] as string[] : [],
|
||||
},
|
||||
activity: normalizeActivityBlock(raw["activity"]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — parse the `profile.activity` block. Returns undefined when
|
||||
* the block is absent or the inner `silence_window_seconds` is invalid
|
||||
* (non-integer / out of [1, 3600]); the daemon then uses its default
|
||||
* (3 seconds). Invalid values are dropped silently here; an explicit
|
||||
* validateAgentSpec error path can be added if operators report it as
|
||||
* confusing — for v0 dropping is the safer/simpler choice.
|
||||
*
|
||||
* Accepts both YAML snake_case (`silence_window_seconds`) and the
|
||||
* camelCase form the typed surface uses (`silenceWindowSeconds`).
|
||||
*/
|
||||
function normalizeActivityBlock(raw: unknown): { silenceWindowSeconds?: number } | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const candidate = obj["silence_window_seconds"] ?? obj["silenceWindowSeconds"];
|
||||
if (typeof candidate !== "number") return undefined;
|
||||
if (!Number.isFinite(candidate)) return undefined;
|
||||
if (!Number.isInteger(candidate)) return undefined;
|
||||
if (candidate < 1 || candidate > 3600) return undefined;
|
||||
return { silenceWindowSeconds: candidate };
|
||||
}
|
||||
@@ -624,6 +624,34 @@ export class BootstrapOrchestrator {
|
||||
const outcome = await podInstantiator.instantiate(rigSpecYaml, rigRoot, { cwdOverride: opts.cwdOverride, prelaunchHook });
|
||||
|
||||
if (!outcome.ok) {
|
||||
// OPR.0.3.2.CT — attention_required is a recoverable outcome
|
||||
// (rig + sessions preserved). Surface it distinctly from terminal
|
||||
// failure so the route can return a 3-part error pointing the
|
||||
// operator at the approve→resume path. The bootstrap run status
|
||||
// is `partial` (not `failed`) because the rig exists and is
|
||||
// operator-actionable.
|
||||
if (outcome.code === "attention_required") {
|
||||
const attentionMsg = (outcome as { message: string }).message;
|
||||
const attentionNodes = (outcome as { attentionNodes: import("./types.js").AttentionNode[] }).attentionNodes;
|
||||
stages.push({
|
||||
stage: "import_rig",
|
||||
status: "blocked",
|
||||
detail: {
|
||||
code: "attention_required",
|
||||
message: attentionMsg,
|
||||
attentionNodes,
|
||||
},
|
||||
});
|
||||
this.deps.bootstrapRepo.updateRunStatus(run.id, "partial", { rigId: (outcome as { rigId: string }).rigId });
|
||||
return {
|
||||
runId: run.id,
|
||||
status: "partial",
|
||||
stages,
|
||||
rigId: (outcome as { rigId: string }).rigId,
|
||||
errors: [attentionMsg],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
const outErrors = outcome.code === "validation_failed" || outcome.code === "preflight_failed"
|
||||
? (outcome as { errors: string[] }).errors
|
||||
: [(outcome as { message: string }).message];
|
||||
@@ -635,13 +663,49 @@ export class BootstrapOrchestrator {
|
||||
|
||||
const result = outcome.result;
|
||||
const anyFailed = result.nodes.some((n) => n.status === "failed");
|
||||
const finalStatus: BootstrapStatus = anyFailed ? "partial" : "completed";
|
||||
const anyAttention = result.nodes.some((n) => n.status === "attention_required");
|
||||
|
||||
stages.push({
|
||||
stage: "import_rig",
|
||||
status: anyFailed ? "failed" : "ok",
|
||||
detail: { rigId: result.rigId, specName: result.specName, nodes: result.nodes },
|
||||
});
|
||||
// OPR.0.3.2.CT (guard verdict qitem-20260518082933 BLOCKER 1):
|
||||
// attention_required nodes are recoverable but NOT done — the
|
||||
// launch is partial and the operator needs the same approve→resume
|
||||
// surface the all-attention path already gets. Treating mixed
|
||||
// launched+attention as "completed" would hide the parked seat
|
||||
// behind a 201 success response. finalStatus is partial whenever
|
||||
// any node is failed OR attention_required; the import_rig stage
|
||||
// is "blocked" (not "ok") with attentionNodes detail so the route
|
||||
// can build the 3-part error from the same path the all-attention
|
||||
// case uses.
|
||||
const finalStatus: BootstrapStatus = anyFailed || anyAttention ? "partial" : "completed";
|
||||
|
||||
if (anyAttention) {
|
||||
const attentionNodes: import("./types.js").AttentionNode[] = result.nodes
|
||||
.filter((n) => n.status === "attention_required")
|
||||
.map((n) => ({
|
||||
logicalId: n.logicalId,
|
||||
sessionName: n.sessionName ?? "",
|
||||
evidence: n.evidence,
|
||||
reason: n.error ?? "node awaiting attention",
|
||||
}));
|
||||
const message = `${attentionNodes.length} node${attentionNodes.length === 1 ? "" : "s"} require attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed).`;
|
||||
stages.push({
|
||||
stage: "import_rig",
|
||||
status: "blocked",
|
||||
detail: {
|
||||
code: "attention_required",
|
||||
message,
|
||||
rigId: result.rigId,
|
||||
specName: result.specName,
|
||||
nodes: result.nodes,
|
||||
attentionNodes,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
stages.push({
|
||||
stage: "import_rig",
|
||||
status: anyFailed ? "failed" : "ok",
|
||||
detail: { rigId: result.rigId, specName: result.specName, nodes: result.nodes },
|
||||
});
|
||||
}
|
||||
if (result.warnings?.length) {
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
@@ -713,6 +777,21 @@ export class BootstrapOrchestrator {
|
||||
status: "failed",
|
||||
detail: { code: bootResult.code, error: bootResult.error, receipt: bootResult.receipt },
|
||||
});
|
||||
// OPR.0.3.2.22 Bug 2 follow-up — serviceOrch.boot can already have
|
||||
// started compose resources before failing during status/wait. The
|
||||
// PodRigInstantiator will delete the rig record next, which cascades
|
||||
// away rig_services and the normal teardown handle — so any
|
||||
// already-started compose containers would orphan. Tear them down
|
||||
// here best-effort while the rig handle still exists. Teardown
|
||||
// errors are swallowed so they cannot mask the boot failure that
|
||||
// is the load-bearing return.
|
||||
try {
|
||||
await serviceOrch.teardown(rigId);
|
||||
} catch {
|
||||
// Best-effort. If teardown also fails, the boot-failure error
|
||||
// is what the operator needs; manual `docker compose down`
|
||||
// remains available with the compose file path from the spec.
|
||||
}
|
||||
return { ok: false, code: "service_boot_failed", message: `Service boot failed: ${bootResult.error}` };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Bundle agent_images router (Item 6 / slice-05 Checkpoint 7.3g step 2).
|
||||
*
|
||||
* Pure function. Copies agent-image directories declared in a bundle
|
||||
* manifest's agent_images[] block from the bundle's extracted tree to the
|
||||
* operator agent-images library. No daemon dependencies — fully
|
||||
* unit-testable via FsOps injection.
|
||||
*
|
||||
* Per PRD §Item 6 line 197: agent_images entries are paths to the
|
||||
* agent-image DIRECTORY (not manifest paths — distinct shape from
|
||||
* context_packs per the e7a0b253 PRD-coherent repair). The router copies
|
||||
* the declared directory to <targetAgentImagesDir>/<basename(sourceAbs)>/
|
||||
* — the operator-host canonical layout per agent-image-types.ts:9-10.
|
||||
*
|
||||
* Consumer contract (agent-image-library-service.ts:77-95): the consumer
|
||||
* walks roots whose immediate children are image DIRS, requires each
|
||||
* image dir to be a directory + contain a manifest.yaml FILE. The router
|
||||
* enforces these consumer-visibility invariants at copy time so
|
||||
* routedCount stays truthful at the consumer-visible boundary.
|
||||
*
|
||||
* All four banked router-level catches from the context_packs cycle
|
||||
* (d491eca9 + 3cd581e3 + 16ebb8af + a0e7e0e1) apply identically and are
|
||||
* built in from the first commit:
|
||||
* 1. source path containment (mirrors all sibling routers)
|
||||
* 2. sourceAbs exists + isDirectory (declared path IS the image dir)
|
||||
* 3. manifest.yaml inside exists AS A FILE (consumer requires
|
||||
* readable manifest.yaml; rejects manifest-missing AND manifest-
|
||||
* is-a-directory cases — file-vs-dir discrimination axis)
|
||||
* 4. basename(sourceAbs) collision detection (first wins, second
|
||||
* flagged status=conflict)
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3g step 3 with a
|
||||
* real consumer-scan() proof against the routed dir (mirror of the
|
||||
* cb0bf7b9 context_packs ContextPackLibraryService.scan reachability
|
||||
* proof).
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface AgentImagesRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
isDirectory: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
copyDir: (src: string, dest: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeAgentImages. */
|
||||
export interface RouteAgentImagesInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative paths to agent-image DIRECTORIES declared in the bundle
|
||||
* manifest's agent_images[] block (per PRD §Item 6 line 197). */
|
||||
declaredAgentImages: string[];
|
||||
/** Absolute path to the operator agent-images library (per
|
||||
* agent-image-types.ts:9-10: typically `<openrigHome>/agent-images/`
|
||||
* or workspace-local `.openrig/agent-images/`). Caller resolves. */
|
||||
targetAgentImagesDir: string;
|
||||
}
|
||||
|
||||
/** One routed agent_image (or one rejection). */
|
||||
export interface RoutedAgentImageRecord {
|
||||
/** Declared path from manifest.agent_images[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = image dir copied successfully and consumer-visible.
|
||||
* "missing" = declared image dir not in bundle (honest skip).
|
||||
* "unsafe" = source escapes bundleRoot OR target escapes
|
||||
* targetAgentImagesDir (structurally impossible given basename, kept
|
||||
* defensive).
|
||||
* "not_directory" = declared path exists but is not a directory.
|
||||
* "not_manifest" = image dir exists but manifest.yaml inside is
|
||||
* absent OR is itself a directory — consumer requires a readable
|
||||
* manifest.yaml file at the image dir root.
|
||||
* "conflict" = basename collides with an earlier routed image; first
|
||||
* wins, second flagged so routedCount stays truthful at the consumer-
|
||||
* visible boundary (banked 16ebb8af lesson). */
|
||||
status: "routed" | "missing" | "unsafe" | "not_directory" | "not_manifest" | "conflict";
|
||||
/** Where the image landed in the target library (absolute image dir
|
||||
* path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteAgentImagesResult {
|
||||
records: RoutedAgentImageRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared agent_image from the bundle tree to the operator
|
||||
* agent-images library. Per-entry safety: SOURCE containment under
|
||||
* bundleRoot + TARGET containment under targetAgentImagesDir. Consumer-
|
||||
* visibility invariants (declared dir IS a directory + manifest.yaml
|
||||
* inside exists AS A FILE) enforced BEFORE write so routedCount stays
|
||||
* truthful at the consumer-visible boundary. Caller writes the install
|
||||
* audit record using the records returned here.
|
||||
*/
|
||||
export function routeAgentImages(
|
||||
input: RouteAgentImagesInput,
|
||||
fs: AgentImagesRouterFsOps,
|
||||
): RouteAgentImagesResult {
|
||||
const records: RoutedAgentImageRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetAgentImagesDir);
|
||||
fs.mkdirp(input.targetAgentImagesDir);
|
||||
// Track basenames already routed so collisions surface as conflict
|
||||
// records, not silent overwrites (banked workflow_specs +
|
||||
// context_packs lessons).
|
||||
const routedDirNames = new Set<string>();
|
||||
|
||||
for (const declared of input.declaredAgentImages) {
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors sibling routers).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `agent_image path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `agent_image dir '${declared}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.isDirectory(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_directory",
|
||||
detail: `agent_image '${declared}' exists but is not a directory; consumer requires the declared path to be an image dir`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-file existence check (banked d491eca9 lesson on
|
||||
// context_packs): the consumer (agent-image-library-service.ts:93)
|
||||
// skips any image dir whose manifest.yaml is absent. Reject pre-write.
|
||||
const manifestPath = nodePath.join(sourceAbs, "manifest.yaml");
|
||||
if (!fs.exists(manifestPath)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `agent_image '${declared}' is missing manifest.yaml; consumer-invisible (agent-image-library scan requires it)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-type check (banked 3cd581e3 lesson on context_packs):
|
||||
// exists() returns true for both files and directories. If manifest.yaml
|
||||
// is a directory, the consumer's readFileSync would throw and scan()
|
||||
// records an error diagnostic instead of indexing the image. Reject.
|
||||
if (fs.isDirectory(manifestPath)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `agent_image '${declared}' has manifest.yaml as a directory, not a file; consumer requires a readable file`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dirName = nodePath.basename(sourceAbs);
|
||||
const targetAbs = nodePath.resolve(input.targetAgentImagesDir, dirName);
|
||||
// Sanity check (basename is structurally safe but resolve could
|
||||
// theoretically be a no-op for "" → keep the check, expected always-
|
||||
// true).
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `agent_image target path for '${declared}' escapes target agent-images library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (routedDirNames.has(dirName)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "conflict",
|
||||
detail: `agent_image basename '${dirName}' collides with an earlier declared path; only the first is routed (banked collision-detection lesson)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.copyDir(sourceAbs, targetAbs);
|
||||
routedDirNames.add(dirName);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import nodePath from "node:path";
|
||||
import { LegacyRigSpecCodec as RigSpecCodec } from "./rigspec-codec.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
import { LegacyRigSpecSchema as RigSpecSchema } from "./rigspec-schema.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
// TODO: AS-T12 — migrate to pod-aware bundle types
|
||||
import { serializeLegacyBundleManifest as serializeBundleManifest, type LegacyBundleManifest as BundleManifest } from "./bundle-types.js";
|
||||
import { serializeLegacyBundleManifest as serializeBundleManifest, type LegacyBundleManifest as BundleManifest, type BundleProvenance, type BundleCompatibility } from "./bundle-types.js";
|
||||
|
||||
export interface AssemblerFsOps {
|
||||
readFile: (path: string) => string;
|
||||
@@ -26,6 +26,23 @@ export interface AssembleOptions {
|
||||
outputDir: string;
|
||||
bundleName: string;
|
||||
bundleVersion: string;
|
||||
/**
|
||||
* Optional Item-1 provenance input. Caller fills the fields it knows
|
||||
* (sourceHost, authorSession, daemonVersion, cliVersion, sourceRigId,
|
||||
* sourceRigName, notes). The assembler mirrors root createdAt into
|
||||
* provenance.createdAt unless caller pre-set it (caller value wins
|
||||
* for test determinism). Missing block = no provenance recorded
|
||||
* (backward compat).
|
||||
*/
|
||||
provenance?: BundleProvenance;
|
||||
/**
|
||||
* Optional Item-2 compatibility input. Caller declares minimum
|
||||
* daemon + CLI versions and optional schema_version reaffirmation.
|
||||
* Missing block = no compatibility recorded (backward compat); the
|
||||
* install-time version check at /api/bundles/install (Checkpoint 3.3)
|
||||
* is a no-op for bundles without compatibility.
|
||||
*/
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,14 +121,24 @@ export class LegacyBundleAssembler {
|
||||
}
|
||||
|
||||
// Generate manifest (no integrity — P7-T02 adds it)
|
||||
const createdAt = new Date().toISOString();
|
||||
const manifest: BundleManifest = {
|
||||
schemaVersion: 1,
|
||||
name: opts.bundleName,
|
||||
version: opts.bundleVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt,
|
||||
rigSpec: "rig.yaml",
|
||||
packages: packageEntries,
|
||||
};
|
||||
if (opts.provenance) {
|
||||
manifest.provenance = {
|
||||
...opts.provenance,
|
||||
createdAt: opts.provenance.createdAt ?? createdAt,
|
||||
};
|
||||
}
|
||||
if (opts.compatibility) {
|
||||
manifest.compatibility = { ...opts.compatibility };
|
||||
}
|
||||
|
||||
// Write bundle.yaml
|
||||
this.fs.writeFile(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Bundle install audit trail (Item 4 / slice-05 Checkpoint 5.1).
|
||||
*
|
||||
* Append-only JSONL log of bundle install events. Matches the JSONL audit-
|
||||
* trail convention used by Transcript / Log Cleanup v0 and UI Enhancement
|
||||
* Pack v0 — no new SQLite schema or migration.
|
||||
*
|
||||
* Default location is ~/.openrig/bundle-audit.jsonl. The writer is a pure
|
||||
* data sink: callers (the /api/bundles/install route handler) compose the
|
||||
* record at the right moment in the install lifecycle and hand it to
|
||||
* BundleAuditWriter.append. The reader exposes a small filter API
|
||||
* (rig name + since timestamp) for the /api/bundles/history endpoint and
|
||||
* the `rig bundle history` CLI subcommand (both land at Checkpoint 5.2).
|
||||
*
|
||||
* FsOps injection mirrors the codebase pattern for testability: real
|
||||
* implementation reads/writes node:fs; tests substitute an in-memory mock.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** A single bundle install audit row. */
|
||||
export interface BundleAuditRecord {
|
||||
/** ISO timestamp of when the install event was recorded. */
|
||||
installedAt: string;
|
||||
/** Source bundle path the install operated on. */
|
||||
bundlePath: string;
|
||||
/** SHA-256 archive hash (when available from bundle's sibling digest). */
|
||||
archiveHash?: string;
|
||||
/** ULID of the rig the install targeted (when applicable). */
|
||||
targetRigId?: string;
|
||||
/** Name of the rig the install targeted (when applicable). */
|
||||
targetRigName?: string;
|
||||
/** Hostname recorded in the bundle's provenance (Item 1), when present. */
|
||||
sourceHost?: string;
|
||||
/** Daemon version at install time. */
|
||||
daemonVersion?: string;
|
||||
/** CLI version at install time (when CLI sent it; Item 1 + Item 2 path). */
|
||||
cliVersion?: string;
|
||||
/** Install outcome — keep honest (partial = some stages ok, some not). */
|
||||
outcome: "success" | "failed" | "partial";
|
||||
}
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface BundleAuditFsOps {
|
||||
appendFile: (path: string, content: string) => void;
|
||||
readFile: (path: string) => string;
|
||||
exists: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Configuration for the audit writer + reader. */
|
||||
export interface BundleAuditOpts {
|
||||
/** Absolute path to the JSONL file (default location: ~/.openrig/bundle-audit.jsonl). */
|
||||
auditPath: string;
|
||||
}
|
||||
|
||||
/** Append-only writer for bundle install audit records. */
|
||||
export class BundleAuditWriter {
|
||||
private opts: BundleAuditOpts;
|
||||
private fs: BundleAuditFsOps;
|
||||
|
||||
constructor(deps: { opts: BundleAuditOpts; fsOps: BundleAuditFsOps }) {
|
||||
this.opts = deps.opts;
|
||||
this.fs = deps.fsOps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one record to the JSONL file. Creates the parent directory if
|
||||
* missing. Each record is one JSON object on its own line (operator can
|
||||
* tail -f for live monitoring). Throws on filesystem errors via fsOps.
|
||||
*/
|
||||
append(record: BundleAuditRecord): void {
|
||||
this.fs.mkdirp(nodePath.dirname(this.opts.auditPath));
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
this.fs.appendFile(this.opts.auditPath, line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters supported by the audit reader (subset of /api/bundles/history). */
|
||||
export interface BundleAuditFilters {
|
||||
/** When set, only return records whose targetRigName equals this value. */
|
||||
rig?: string;
|
||||
/** When set, only return records whose installedAt is >= this ISO timestamp. */
|
||||
since?: string;
|
||||
}
|
||||
|
||||
/** Reader for the bundle install audit JSONL file. */
|
||||
export class BundleAuditReader {
|
||||
private opts: BundleAuditOpts;
|
||||
private fs: BundleAuditFsOps;
|
||||
|
||||
constructor(deps: { opts: BundleAuditOpts; fsOps: BundleAuditFsOps }) {
|
||||
this.opts = deps.opts;
|
||||
this.fs = deps.fsOps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all audit records (optionally filtered). Malformed lines are
|
||||
* skipped silently — forward-compat with future record-shape evolutions
|
||||
* that don't parse under the current shape; the readable lines still
|
||||
* surface. Records are returned in append order (oldest first).
|
||||
*/
|
||||
list(filters?: BundleAuditFilters): BundleAuditRecord[] {
|
||||
if (!this.fs.exists(this.opts.auditPath)) return [];
|
||||
const content = this.fs.readFile(this.opts.auditPath);
|
||||
const lines = content.split("\n").filter((l) => l.length > 0);
|
||||
const records: BundleAuditRecord[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
records.push(parsed as BundleAuditRecord);
|
||||
}
|
||||
} catch {
|
||||
// Malformed line — skip silently.
|
||||
}
|
||||
}
|
||||
return applyFilters(records, filters);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the supported filters to an in-memory record array. */
|
||||
function applyFilters(records: BundleAuditRecord[], filters?: BundleAuditFilters): BundleAuditRecord[] {
|
||||
if (!filters) return records;
|
||||
let out = records;
|
||||
if (filters.rig !== undefined && filters.rig.length > 0) {
|
||||
const rigName = filters.rig;
|
||||
out = out.filter((r) => r.targetRigName === rigName);
|
||||
}
|
||||
if (filters.since !== undefined && filters.since.length > 0) {
|
||||
const since = filters.since;
|
||||
out = out.filter((r) => r.installedAt >= since);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Bundle conflict detector (Item 3 / slice-05 Checkpoint 4.1).
|
||||
*
|
||||
* Pure function. Compares a bundle's declared identifiers against the daemon's
|
||||
* current state and returns a structured conflict report. Callers (route
|
||||
* handlers in Checkpoint 4.2) provide the daemon state (listRigs() etc.); the
|
||||
* detector itself has no daemon dependencies — fully unit-testable.
|
||||
*
|
||||
* Fails CLOSED: the conflict list is the source of truth. Missing or ambiguous
|
||||
* input is treated as no-conflict-detectable, NOT as bypass — Items 4.2+ will
|
||||
* surface ambiguity as a conflict where appropriate. This commit ships the
|
||||
* rig-name collision check only; agent / port / managed-app / sibling-primitive
|
||||
* checks land in Checkpoint 4.3 + 4.4.
|
||||
*
|
||||
* Extends, does NOT replace, the existing 409 "install already in progress"
|
||||
* guard at /api/bundles/install (concurrency lock) per PRD Item 3 §
|
||||
* "extends, does NOT replace".
|
||||
*/
|
||||
|
||||
/** A rig-name collision: bundle declares a rig name that a currently-running rig already uses. */
|
||||
export interface RigNameCollision {
|
||||
kind: "rig_name_collision";
|
||||
/** Name declared by the bundle's rig spec. */
|
||||
bundleRigName: string;
|
||||
/** Identity of the running rig that holds the same name. */
|
||||
collisionWith: { rigId: string; rigName: string };
|
||||
/** Human-readable summary suitable for the 3-part error description line. */
|
||||
description: string;
|
||||
/** Operator-actionable resolutions (3-part error what-to-do line). */
|
||||
resolutions: string[];
|
||||
}
|
||||
|
||||
/** Discriminated union over all conflict kinds. Extends in Checkpoint 4.3+. */
|
||||
export type BundleConflict = RigNameCollision;
|
||||
|
||||
/** Result of a conflict check. */
|
||||
export interface ConflictReport {
|
||||
conflicts: BundleConflict[];
|
||||
hasConflicts: boolean;
|
||||
}
|
||||
|
||||
/** Input to detectBundleConflicts. Pure data; no daemon handles. */
|
||||
export interface DetectConflictsInput {
|
||||
/** Rig name declared in the bundle's rig spec (rig.yaml `name:` field). */
|
||||
bundleRigName: string;
|
||||
/** Snapshot of currently-running rigs from rigRepo.listRigs(). */
|
||||
runningRigs: Array<{ rigId: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the conflict checks for an install candidate. Returns the full conflict
|
||||
* list (Checkpoint 4.2 returns it via /install --plan; --apply blocks on
|
||||
* non-empty unless --force).
|
||||
*/
|
||||
export function detectBundleConflicts(input: DetectConflictsInput): ConflictReport {
|
||||
const conflicts: BundleConflict[] = [];
|
||||
|
||||
// Rig-name collision: bundle declares a name a running rig already holds.
|
||||
// O(n) scan over runningRigs — acceptable for the seat-scale rig counts the
|
||||
// daemon manages (tens, not thousands).
|
||||
if (input.bundleRigName && input.bundleRigName.length > 0) {
|
||||
for (const rig of input.runningRigs) {
|
||||
if (rig.name === input.bundleRigName) {
|
||||
conflicts.push({
|
||||
kind: "rig_name_collision",
|
||||
bundleRigName: input.bundleRigName,
|
||||
collisionWith: { rigId: rig.rigId, rigName: rig.name },
|
||||
description: `bundle declares rig name '${input.bundleRigName}' but a running rig with this name already exists (rigId: ${rig.rigId})`,
|
||||
resolutions: [
|
||||
"use --target <newname> on install to rename the rig on install (lands at Checkpoint 4.2)",
|
||||
`stop the running rig first (e.g. rig down ${rig.name}) and re-attempt install`,
|
||||
"use --force on install for an operator-explicit override (lands at Checkpoint 4.2; NOT recommended for routine use)",
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { conflicts, hasConflicts: conflicts.length > 0 };
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Bundle context_packs router (Item 6 / slice-05 Checkpoint 7.3f step 2).
|
||||
*
|
||||
* Pure function. Copies context-pack directories declared in a bundle
|
||||
* manifest's context_packs[] block from the bundle's extracted tree to
|
||||
* the operator context-packs library. No daemon dependencies — fully
|
||||
* unit-testable via FsOps injection.
|
||||
*
|
||||
* Per PRD §Item 6 line 196: context_packs entries are paths to a
|
||||
* context-pack's manifest.yaml file inside the bundle. The PACK is the
|
||||
* PARENT DIRECTORY of that manifest.yaml (per context-pack-types.ts:9-10
|
||||
* and context-pack-library-service.ts:50-77: a pack is a directory whose
|
||||
* immediate children include manifest.yaml + the referenced content
|
||||
* files). The router copies that parent dir to
|
||||
* <targetContextPacksDir>/<basename(parentDir)>/ — the operator-host
|
||||
* canonical layout the live consumer scans.
|
||||
*
|
||||
* Banked degenerate-input dogfood discipline (extended from the
|
||||
* workflow_specs basename-collision + non-YAML cycles): every router
|
||||
* commit explicitly probes the failure-class edges through the live
|
||||
* consumer's contract before claiming routedCount truthful. For
|
||||
* context_packs the consumer-invisibility classes are:
|
||||
* - Declared path basename is NOT "manifest.yaml" → consumer never
|
||||
* scans (it looks for manifest.yaml at the pack dir root). status=
|
||||
* not_manifest.
|
||||
* - Two declared paths whose parent-dir basename collide → second
|
||||
* would silently overwrite the first; status=conflict; first wins.
|
||||
* - Source pack dir not present in bundle → status=missing (honest skip).
|
||||
* - Source path escapes bundleRoot → status=unsafe.
|
||||
*
|
||||
* Both-sides containment (banked
|
||||
* feedback_pre_existing_trust_boundary_reuse_canonical_helper addendum):
|
||||
* source-side under bundleRoot, target-side under targetContextPacksDir
|
||||
* (target via basename so structurally safe; defensive check retained).
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3f step 3 with a
|
||||
* real consumer-scan() proof against the routed dir (mirror of the
|
||||
* workflow_specs scanWorkflowSpecFolder reachability proof at d81456dc).
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface ContextPacksRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
isDirectory: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
copyDir: (src: string, dest: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeContextPacks. */
|
||||
export interface RouteContextPacksInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative paths to context-pack manifest.yaml files declared in the
|
||||
* bundle manifest's context_packs[] block. */
|
||||
declaredContextPacks: string[];
|
||||
/** Absolute path to the operator context-packs library (per
|
||||
* context-pack-types.ts:9-10: typically `<openrigHome>/context-packs`
|
||||
* or a workspace-local `.openrig/context-packs`). Caller resolves. */
|
||||
targetContextPacksDir: string;
|
||||
}
|
||||
|
||||
/** One routed context_pack (or one rejection). */
|
||||
export interface RoutedContextPackRecord {
|
||||
/** Declared path from manifest.context_packs[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = pack dir copied successfully and consumer-visible.
|
||||
* "missing" = pack manifest source not in bundle (honest skip).
|
||||
* "unsafe" = source escapes bundleRoot OR target escapes
|
||||
* targetContextPacksDir (structurally impossible given basename, kept
|
||||
* defensive).
|
||||
* "not_manifest" = declared path basename is not "manifest.yaml" —
|
||||
* consumer (context-pack-library-service.scan) scans pack dirs for a
|
||||
* top-level manifest.yaml file; any other basename is invisible-by-
|
||||
* construction.
|
||||
* "not_directory" = parent of the declared manifest path is not a
|
||||
* directory in the bundle tree.
|
||||
* "conflict" = parent-dir basename collides with an earlier routed
|
||||
* pack; first wins, second flagged so routedCount stays truthful at
|
||||
* the consumer-visible boundary (banked 16ebb8af lesson). */
|
||||
status: "routed" | "missing" | "unsafe" | "not_manifest" | "not_directory" | "conflict";
|
||||
/** Where the pack landed in the target library (absolute pack dir
|
||||
* path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteContextPacksResult {
|
||||
records: RoutedContextPackRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared context_pack from the bundle tree to the operator
|
||||
* context-packs library. Per-entry safety: SOURCE containment under
|
||||
* bundleRoot + TARGET containment under targetContextPacksDir.
|
||||
* Degenerate inputs the consumer ignores (non-manifest.yaml basename;
|
||||
* parent-dir basename collisions) are caught BEFORE write so the
|
||||
* routedCount stays truthful at the consumer-visible boundary. Caller
|
||||
* writes the install audit record using the records returned here.
|
||||
*/
|
||||
export function routeContextPacks(
|
||||
input: RouteContextPacksInput,
|
||||
fs: ContextPacksRouterFsOps,
|
||||
): RouteContextPacksResult {
|
||||
const records: RoutedContextPackRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetContextPacksDir);
|
||||
fs.mkdirp(input.targetContextPacksDir);
|
||||
// Track parent-dir basenames already routed so collisions surface as
|
||||
// conflict records, not silent overwrites (banked workflow_specs lesson
|
||||
// 16ebb8af).
|
||||
const routedDirNames = new Set<string>();
|
||||
|
||||
for (const declared of input.declaredContextPacks) {
|
||||
// Consumer-visibility prefilter: context-pack-library-service scans
|
||||
// pack dirs whose immediate child is manifest.yaml. Any declared path
|
||||
// whose basename isn't "manifest.yaml" routes to a dir the consumer
|
||||
// will never recognize as a pack. Reject pre-write so routedCount
|
||||
// stays truthful at the consumer-visible boundary.
|
||||
if (nodePath.basename(declared) !== "manifest.yaml") {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `context_pack '${declared}' basename is not 'manifest.yaml'; context-pack-library scans pack dirs for a top-level manifest.yaml`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors skills/
|
||||
// workflow_specs routers).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `context_pack path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-file existence check (banked B1 from a0e7e0e1 guard catch):
|
||||
// the consumer (context-pack-library-service.scan, line 76) skips any
|
||||
// pack dir whose manifest.yaml is absent. A parent-dir-exists-but-
|
||||
// manifest-missing pack would route as status=routed but be invisible
|
||||
// to the consumer — false-positive routedCount class. Check the file
|
||||
// ITSELF, not just the parent, to keep routedCount truthful at the
|
||||
// consumer-visible boundary.
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `context_pack manifest '${declared}' not present in bundle; skipped (consumer requires the file itself)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Manifest-type check (banked B2 from d491eca9 guard catch): exists()
|
||||
// returns true for both files AND directories. If sourceAbs exists as
|
||||
// a directory (named manifest.yaml, but a dir not a file), the live
|
||||
// consumer's readFileSync(manifestPath) throws and scan() records an
|
||||
// error diagnostic instead of indexing a visible pack — false-positive
|
||||
// routedCount class. Reject pre-write so routedCount stays truthful at
|
||||
// the consumer-visible boundary.
|
||||
if (fs.isDirectory(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_manifest",
|
||||
detail: `context_pack manifest '${declared}' exists but is a directory, not a file; consumer requires manifest.yaml to be a readable file`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sourcePackDir = nodePath.dirname(sourceAbs);
|
||||
if (!fs.isDirectory(sourcePackDir)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "not_directory",
|
||||
detail: `context_pack parent of '${declared}' is not a directory; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dirName = nodePath.basename(sourcePackDir);
|
||||
// Target = <targetContextPacksDir>/<basename(parentDir)>/ (the operator-
|
||||
// host canonical layout per context-pack-types.ts:9-10).
|
||||
const targetAbs = nodePath.resolve(input.targetContextPacksDir, dirName);
|
||||
// Sanity check (basename is structurally safe but the resolve could
|
||||
// theoretically be a no-op for "" → keep the check, expected always-
|
||||
// true).
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `context_pack target path for '${declared}' escapes target context-packs library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (routedDirNames.has(dirName)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "conflict",
|
||||
detail: `context_pack parent-dir basename '${dirName}' collides with an earlier declared path; only the first is routed (banked collision-detection lesson)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.copyDir(sourcePackDir, targetAbs);
|
||||
routedDirNames.add(dirName);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Bundle plugins router (Item 6 / slice-05 Checkpoint 7.3c).
|
||||
*
|
||||
* Pure function. Copies plugin trees declared in a bundle manifest's
|
||||
* plugins[] block from the bundle's extracted tree to the operator plugins
|
||||
* library (per-id subdir). Mirrors the bundle-skills-router pattern: FsOps
|
||||
* injection; both-sides path containment (banked
|
||||
* feedback_pre_existing_trust_boundary_reuse_canonical_helper addendum:
|
||||
* contain BOTH source AND target when handling untrusted path input);
|
||||
* missing source surfaces as status=missing; unsafe paths rejected with
|
||||
* status=unsafe.
|
||||
*
|
||||
* Per orch-ratified HYBRID decision: v0 source.kind is local only (path
|
||||
* under the bundle's extracted tree). Other kinds (external operator-host
|
||||
* paths, remote fetch) reserved for future.
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3d.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface PluginsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
isDirectory: (path: string) => boolean;
|
||||
mkdirp: (path: string) => void;
|
||||
copyDir: (src: string, dest: string) => void;
|
||||
}
|
||||
|
||||
/** A plugin reference in the bundle manifest. Matches the daemon's BundlePluginReference shape. */
|
||||
export interface PluginRoutingInput {
|
||||
id: string;
|
||||
source: { kind: "local"; path: string };
|
||||
}
|
||||
|
||||
/** Inputs to routePlugins. */
|
||||
export interface RoutePluginsInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Plugin references declared in the manifest. */
|
||||
declaredPlugins: PluginRoutingInput[];
|
||||
/** Absolute path to the operator plugins library (default ~/.openrig/plugins). */
|
||||
targetPluginsDir: string;
|
||||
}
|
||||
|
||||
/** One routed plugin (or one rejection). */
|
||||
export interface RoutedPluginRecord {
|
||||
/** Plugin id from manifest.plugins[].id. */
|
||||
id: string;
|
||||
/** "routed" = directory copied; "missing" = source not in bundle; "unsafe"
|
||||
* = source or target escapes its bounding directory; "not_directory" =
|
||||
* source path is not a directory. */
|
||||
status: "routed" | "missing" | "unsafe" | "not_directory";
|
||||
/** Where the plugin landed in the target library (absolute path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RoutePluginsResult {
|
||||
records: RoutedPluginRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared plugin from the bundle tree to the operator plugins
|
||||
* library as <targetPluginsDir>/<id>/. Per-plugin safety: BOTH source path
|
||||
* (under bundleRoot) AND target path (under targetPluginsDir) are
|
||||
* containment-checked. Banked both-sides-of-trust-boundary lesson applied.
|
||||
*/
|
||||
export function routePlugins(input: RoutePluginsInput, fs: PluginsRouterFsOps): RoutePluginsResult {
|
||||
const records: RoutedPluginRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetPluginsDir);
|
||||
fs.mkdirp(input.targetPluginsDir);
|
||||
|
||||
for (const plugin of input.declaredPlugins) {
|
||||
if (!plugin.id || plugin.source.kind !== "local") {
|
||||
records.push({
|
||||
id: plugin.id || "(unknown)",
|
||||
status: "unsafe",
|
||||
detail: `plugin reference invalid: id required and source.kind must be 'local' (other kinds reserved for future)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Source containment: source.path must resolve inside bundleRoot
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, plugin.source.path);
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "unsafe",
|
||||
detail: `plugin source path '${plugin.source.path}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "missing",
|
||||
detail: `plugin source '${plugin.source.path}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.isDirectory(sourceAbs)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "not_directory",
|
||||
detail: `plugin source '${plugin.source.path}' is not a directory; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Target containment: id must produce a path inside targetPluginsDir
|
||||
const targetAbs = nodePath.resolve(input.targetPluginsDir, plugin.id);
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "unsafe",
|
||||
detail: `plugin id '${plugin.id}' would resolve outside target plugins library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.copyDir(sourceAbs, targetAbs);
|
||||
records.push({
|
||||
id: plugin.id,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Bundle skills router (Item 6 / slice-05 Checkpoint 7.2).
|
||||
*
|
||||
* Pure function. Copies skill files declared in a bundle manifest's skills[]
|
||||
* block from the bundle's extracted tree to the operator skills library.
|
||||
* No daemon dependencies — fully unit-testable via FsOps injection.
|
||||
*
|
||||
* Safety: each declared skill path is treated as untrusted manifest content;
|
||||
* the helper rejects paths that escape the bundle tree (path containment
|
||||
* check mirrors the bundle-source-resolver pattern). The output target
|
||||
* directory is created via mkdirp.
|
||||
*
|
||||
* Honest-scoping: missing source files surface as warnings in the result
|
||||
* (NOT thrown errors) so the install lifecycle can continue with what's
|
||||
* available. Same for absent skills library: caller decides to skip-or-fail.
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface SkillsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeSkills. */
|
||||
export interface RouteSkillsInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative skill paths declared in the manifest's skills[] block. */
|
||||
declaredSkills: string[];
|
||||
/** Absolute path to the operator skills library (default ~/.openrig/skills). */
|
||||
targetSkillsDir: string;
|
||||
}
|
||||
|
||||
/** One routed skill (or one rejection). */
|
||||
export interface RoutedSkillRecord {
|
||||
/** Declared path from manifest.skills[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = copied successfully; "missing" = source not in bundle;
|
||||
* "unsafe" = escapes bundle workspace; "no_library" = target dir not
|
||||
* present and caller did not request creation (reserved for future
|
||||
* library-reachability mode). */
|
||||
status: "routed" | "missing" | "unsafe";
|
||||
/** Where the skill landed in the target library (absolute path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteSkillsResult {
|
||||
records: RoutedSkillRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared skill from the bundle tree to the operator skills
|
||||
* library. Per-skill safety: resolved source path must stay inside bundleRoot;
|
||||
* skipped if missing from bundle. Caller writes the install audit record
|
||||
* (Item 4 chain) using the records returned here.
|
||||
*/
|
||||
export function routeSkills(input: RouteSkillsInput, fs: SkillsRouterFsOps): RouteSkillsResult {
|
||||
const records: RoutedSkillRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetSkillsDir);
|
||||
fs.mkdirp(input.targetSkillsDir);
|
||||
|
||||
for (const declared of input.declaredSkills) {
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors bundle-source-
|
||||
// resolver pattern; the manifest validator already rejects unsafe paths
|
||||
// via isRelativeSafePath but we re-check here in case the input bypassed
|
||||
// validation upstream).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `skill path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `skill source '${declared}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Target path mirrors the declared path under the target skills directory.
|
||||
// Operator's skill library inherits the bundle's directory layout for the
|
||||
// declared skills (e.g., skills/foo/SKILL.md → <target>/foo/SKILL.md);
|
||||
// strip the leading "skills/" prefix if present so the target dir is the
|
||||
// root of the operator's skill tree.
|
||||
const declaredTrimmed = declared.startsWith("skills/") ? declared.slice("skills/".length) : declared;
|
||||
const targetAbs = nodePath.resolve(input.targetSkillsDir, declaredTrimmed);
|
||||
// Defense-in-depth path-containment on TARGET (B1 repair on
|
||||
// qitem-20260518215234-f84fff45). The leading "skills/" strip can promote
|
||||
// a relative segment that looks safe under bundleRoot (e.g.
|
||||
// "skills/../outside/SKILL.md" passes source-containment because the bundle
|
||||
// tree may contain "outside/SKILL.md", but stripping yields
|
||||
// "../outside/SKILL.md" which would escape targetSkillsDir). Reject if the
|
||||
// target resolves outside the target library. Banked lesson:
|
||||
// feedback_pre_existing_trust_boundary_reuse_canonical_helper — contain
|
||||
// BOTH source and target when handling untrusted path input.
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `skill target path for '${declared}' escapes target skills library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fs.mkdirp(nodePath.dirname(targetAbs));
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -1,5 +1,359 @@
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
|
||||
// -- Shared types --
|
||||
|
||||
/**
|
||||
* Provenance block — attribution metadata for a bundle artifact. All fields
|
||||
* optional for backward compat; bundles without provenance install unchanged.
|
||||
* Captured by the bundle-assembler at create time; surfaced in inspect output
|
||||
* and audit-trail records. Not cryptographically signed at this stage.
|
||||
*/
|
||||
export interface BundleProvenance {
|
||||
/** ISO timestamp; mirrors root createdAt at create time. */
|
||||
createdAt?: string;
|
||||
/** os.hostname() of the host that ran rig bundle create. */
|
||||
sourceHost?: string;
|
||||
/** Canonical session name of the creator (e.g. velocity-driver@openrig-velocity). */
|
||||
authorSession?: string;
|
||||
/** ULID of the source rig, if creating from a live rig. */
|
||||
sourceRigId?: string;
|
||||
/** Name of the source rig, if creating from a live rig. */
|
||||
sourceRigName?: string;
|
||||
/** Daemon version at create time (e.g. 0.3.2). */
|
||||
daemonVersion?: string;
|
||||
/** CLI version at create time (e.g. 0.3.2). */
|
||||
cliVersion?: string;
|
||||
/** Operator-authored notes from the --notes flag on rig bundle create. */
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const PROVENANCE_STRING_FIELDS = [
|
||||
"created_at",
|
||||
"source_host",
|
||||
"author_session",
|
||||
"source_rig_id",
|
||||
"source_rig_name",
|
||||
"daemon_version",
|
||||
"cli_version",
|
||||
"notes",
|
||||
] as const;
|
||||
|
||||
/** Validate optional provenance block. Appends to errors if present-but-malformed. */
|
||||
function validateProvenanceBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
errors.push("provenance must be an object");
|
||||
return;
|
||||
}
|
||||
const p = raw as Record<string, unknown>;
|
||||
for (const field of PROVENANCE_STRING_FIELDS) {
|
||||
if (field in p && typeof p[field] !== "string") {
|
||||
errors.push(`provenance.${field} must be a string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a typed BundleProvenance to the snake_case YAML record shape. */
|
||||
function provenanceToYamlRecord(p: BundleProvenance): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (p.createdAt !== undefined) out["created_at"] = p.createdAt;
|
||||
if (p.sourceHost !== undefined) out["source_host"] = p.sourceHost;
|
||||
if (p.authorSession !== undefined) out["author_session"] = p.authorSession;
|
||||
if (p.sourceRigId !== undefined) out["source_rig_id"] = p.sourceRigId;
|
||||
if (p.sourceRigName !== undefined) out["source_rig_name"] = p.sourceRigName;
|
||||
if (p.daemonVersion !== undefined) out["daemon_version"] = p.daemonVersion;
|
||||
if (p.cliVersion !== undefined) out["cli_version"] = p.cliVersion;
|
||||
if (p.notes !== undefined) out["notes"] = p.notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-primitive bundling — Item 6 / slice-05 Checkpoint 7.1.
|
||||
*
|
||||
* Manifest may optionally declare typed sibling primitives the bundle should
|
||||
* route to their respective libraries on install. v0 ships the `skills` kind
|
||||
* (Checkpoint 7.1). Plugins + workflow_specs + context_packs + agent_images
|
||||
* land additively in subsequent checkpoints as their libraries are reachable.
|
||||
*
|
||||
* PRD §Item 6: bundles list these alongside the existing rig + agents +
|
||||
* packages fields (no `contents:` re-grouping — that would be a schema
|
||||
* reorg violating the no-bump constraint). Each kind is an optional top-
|
||||
* level field; missing kinds keep backward compat.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Plugin reference — Item 6 / slice-05 Checkpoint 7.3b. Bundle manifest may
|
||||
* declare plugin references the bundle includes (in HYBRID mode the bundle
|
||||
* REFERENCES the existing 0.3.1 plugin rather than forking its content; per
|
||||
* orch-ratified decision doc).
|
||||
*/
|
||||
export interface BundlePluginReference {
|
||||
/** Plugin id (matches the plugin primitive's id surface). */
|
||||
id: string;
|
||||
/** Where to resolve the plugin from. v0 supports local-path source. */
|
||||
source: { kind: "local"; path: string };
|
||||
}
|
||||
|
||||
/** Validate optional plugins[] block. Appends to errors if present-but-malformed. */
|
||||
function validatePluginsBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("plugins must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`plugins[${i}] must be an object`);
|
||||
continue;
|
||||
}
|
||||
const p = entry as Record<string, unknown>;
|
||||
if (typeof p["id"] !== "string" || !p["id"]) {
|
||||
errors.push(`plugins[${i}].id is required`);
|
||||
}
|
||||
const source = p["source"];
|
||||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||
errors.push(`plugins[${i}].source must be an object`);
|
||||
continue;
|
||||
}
|
||||
const s = source as Record<string, unknown>;
|
||||
if (s["kind"] !== "local") {
|
||||
errors.push(`plugins[${i}].source.kind must be 'local' (other kinds reserved for future)`);
|
||||
}
|
||||
if (typeof s["path"] !== "string" || !s["path"]) {
|
||||
errors.push(`plugins[${i}].source.path is required`);
|
||||
} else if (!isRelativeSafePath(s["path"] as string)) {
|
||||
errors.push(`plugins[${i}].source.path is not safe: '${s["path"]}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw plugins[] block (defensive copy). */
|
||||
function normalizePluginsBlock(raw: unknown): BundlePluginReference[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: BundlePluginReference[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
||||
const p = entry as Record<string, unknown>;
|
||||
const s = p["source"];
|
||||
if (typeof p["id"] !== "string" || !p["id"]) continue;
|
||||
if (!s || typeof s !== "object" || Array.isArray(s)) continue;
|
||||
const src = s as Record<string, unknown>;
|
||||
if (src["kind"] !== "local" || typeof src["path"] !== "string" || !src["path"]) continue;
|
||||
result.push({ id: p["id"], source: { kind: "local", path: src["path"] } });
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional skills[] block. Appends to errors if present-but-malformed. */
|
||||
function validateSkillsBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("skills must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`skills[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`skills[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw skills[] block (defensive copy + string filter). */
|
||||
function normalizeSkillsBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional workflow_specs[] block. Appends to errors if present-but-malformed.
|
||||
* Item 6 / slice-05 Checkpoint 7.3e. Same shape as skills[]: array of relative-safe paths
|
||||
* to workflow spec YAML files inside the bundle. */
|
||||
function validateWorkflowSpecsBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("workflow_specs must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`workflow_specs[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`workflow_specs[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw workflow_specs[] block (defensive copy + string filter). */
|
||||
function normalizeWorkflowSpecsBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional context_packs[] block. Item 6 / slice-05 Checkpoint 7.3f.
|
||||
* Each entry is a relative-safe path to a context-pack's manifest.yaml inside
|
||||
* the bundle (per PRD §Item 6). The router copies the parent directory of
|
||||
* each manifest path to the operator context-packs library on install. */
|
||||
function validateContextPacksBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("context_packs must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`context_packs[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`context_packs[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw context_packs[] block (defensive copy + string filter). */
|
||||
function normalizeContextPacksBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/** Validate optional agent_images[] block. Item 6 / slice-05 Checkpoint 7.3g.
|
||||
* Each entry is a relative-safe path to an agent-image DIRECTORY inside the
|
||||
* bundle (per PRD §Item 6 line 197: agent_images: [path/to/agent-image-name/,
|
||||
* ...]). The router copies the declared directory itself to the operator
|
||||
* agent-images library on install. The consumer
|
||||
* (agent-image-library-service.ts:77-95) requires manifest.yaml to exist
|
||||
* inside each routed image dir; the router enforces that at copy time. */
|
||||
function validateAgentImagesBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (!Array.isArray(raw)) {
|
||||
errors.push("agent_images must be an array");
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const entry = raw[i];
|
||||
if (typeof entry !== "string") {
|
||||
errors.push(`agent_images[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!isRelativeSafePath(entry)) {
|
||||
errors.push(`agent_images[${i}] path is not safe: '${entry}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize raw agent_images[] block (defensive copy + string filter). */
|
||||
function normalizeAgentImagesBlock(raw: unknown): string[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const result: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string" && entry.length > 0) result.push(entry);
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility block — operator-declared install-time requirements for a
|
||||
* bundle artifact. All fields optional; missing block keeps backward compat
|
||||
* (bundles install unchanged). Install-time version check (Item 2 Checkpoint
|
||||
* 3.3) consults this block before bootstrap delegation; --skip-version-check
|
||||
* is the operator-explicit override.
|
||||
*/
|
||||
export interface BundleCompatibility {
|
||||
/** Minimum daemon version required to install this bundle (semver string). */
|
||||
minDaemonVersion?: string;
|
||||
/** Minimum CLI version required to install this bundle (semver string). */
|
||||
minCliVersion?: string;
|
||||
/** Schema version reaffirmed; mirrors root schemaVersion when set. */
|
||||
schemaVersion?: number;
|
||||
}
|
||||
|
||||
/** Validate optional compatibility block. Appends to errors if present-but-malformed. */
|
||||
function validateCompatibilityBlock(raw: unknown, errors: string[]): void {
|
||||
if (raw === undefined) return;
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
errors.push("compatibility must be an object");
|
||||
return;
|
||||
}
|
||||
const c = raw as Record<string, unknown>;
|
||||
if ("min_daemon_version" in c && typeof c["min_daemon_version"] !== "string") {
|
||||
errors.push("compatibility.min_daemon_version must be a string");
|
||||
}
|
||||
if ("min_cli_version" in c && typeof c["min_cli_version"] !== "string") {
|
||||
errors.push("compatibility.min_cli_version must be a string");
|
||||
}
|
||||
if ("schema_version" in c && typeof c["schema_version"] !== "number") {
|
||||
errors.push("compatibility.schema_version must be a number");
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a typed BundleCompatibility to the snake_case YAML record shape. */
|
||||
function compatibilityToYamlRecord(c: BundleCompatibility): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (c.minDaemonVersion !== undefined) out["min_daemon_version"] = c.minDaemonVersion;
|
||||
if (c.minCliVersion !== undefined) out["min_cli_version"] = c.minCliVersion;
|
||||
if (c.schemaVersion !== undefined) out["schema_version"] = c.schemaVersion;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw snake_case compatibility to typed camelCase BundleCompatibility.
|
||||
* Returns undefined when absent or empty. Exported alongside the provenance
|
||||
* normalizer so the v2 inspect-route projection can produce a single
|
||||
* camelCase shape for both manifest schemas.
|
||||
*/
|
||||
export function normalizeCompatibilityBlock(raw: unknown): BundleCompatibility | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const c = raw as Record<string, unknown>;
|
||||
const result: BundleCompatibility = {};
|
||||
if (typeof c["min_daemon_version"] === "string") result.minDaemonVersion = c["min_daemon_version"];
|
||||
if (typeof c["min_cli_version"] === "string") result.minCliVersion = c["min_cli_version"];
|
||||
if (typeof c["schema_version"] === "number") result.schemaVersion = c["schema_version"];
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw snake_case provenance (as parsed from YAML or received from
|
||||
* a request body) to typed camelCase BundleProvenance. Returns undefined when
|
||||
* absent or empty. Exported so both the v1 normalizer pipeline and the v2
|
||||
* inspect-route projection produce identical camelCase shapes — the
|
||||
* /api/bundles/inspect contract is one shape regardless of schema version.
|
||||
*/
|
||||
export function normalizeProvenanceBlock(raw: unknown): BundleProvenance | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const p = raw as Record<string, unknown>;
|
||||
const result: BundleProvenance = {};
|
||||
if (typeof p["created_at"] === "string") result.createdAt = p["created_at"];
|
||||
if (typeof p["source_host"] === "string") result.sourceHost = p["source_host"];
|
||||
if (typeof p["author_session"] === "string") result.authorSession = p["author_session"];
|
||||
if (typeof p["source_rig_id"] === "string") result.sourceRigId = p["source_rig_id"];
|
||||
if (typeof p["source_rig_name"] === "string") result.sourceRigName = p["source_rig_name"];
|
||||
if (typeof p["daemon_version"] === "string") result.daemonVersion = p["daemon_version"];
|
||||
if (typeof p["cli_version"] === "string") result.cliVersion = p["cli_version"];
|
||||
if (typeof p["notes"] === "string") result.notes = p["notes"];
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
// -- Pod-aware bundle types (AgentSpec reboot) --
|
||||
|
||||
export interface PodBundleAgentImportEntry {
|
||||
@@ -28,6 +382,18 @@ export interface PodBundleManifest {
|
||||
agents: PodBundleAgentEntry[];
|
||||
cultureFile?: string;
|
||||
integrity?: BundleIntegrity;
|
||||
provenance?: BundleProvenance;
|
||||
compatibility?: BundleCompatibility;
|
||||
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
|
||||
skills?: string[];
|
||||
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
|
||||
plugins?: BundlePluginReference[];
|
||||
/** Item 6 cross-primitive bundling: workflow spec YAML paths to route to the operator workflow-specs library on install (Checkpoint 7.3e). */
|
||||
workflowSpecs?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to context-pack manifest.yaml files; router copies the parent dir to the operator context-packs library on install (Checkpoint 7.3f). */
|
||||
contextPacks?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to agent-image DIRECTORIES (per PRD §Item 6 line 197); router copies the declared directory to the operator agent-images library on install. Consumer requires manifest.yaml inside each image dir. (Checkpoint 7.3g) */
|
||||
agentImages?: string[];
|
||||
}
|
||||
|
||||
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
|
||||
@@ -54,6 +420,14 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
|
||||
}
|
||||
}
|
||||
|
||||
validateProvenanceBlock(m["provenance"], errors);
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
validateSkillsBlock(m["skills"], errors);
|
||||
validatePluginsBlock(m["plugins"], errors);
|
||||
validateWorkflowSpecsBlock(m["workflow_specs"], errors);
|
||||
validateContextPacksBlock(m["context_packs"], errors);
|
||||
validateAgentImagesBlock(m["agent_images"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -81,6 +455,13 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
|
||||
};
|
||||
if (manifest.cultureFile) doc["culture_file"] = manifest.cultureFile;
|
||||
if (manifest.integrity) doc["integrity"] = { algorithm: manifest.integrity.algorithm, files: manifest.integrity.files };
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
|
||||
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
|
||||
if (manifest.workflowSpecs && manifest.workflowSpecs.length > 0) doc["workflow_specs"] = manifest.workflowSpecs;
|
||||
if (manifest.contextPacks && manifest.contextPacks.length > 0) doc["context_packs"] = manifest.contextPacks;
|
||||
if (manifest.agentImages && manifest.agentImages.length > 0) doc["agent_images"] = manifest.agentImages;
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
|
||||
@@ -116,6 +497,18 @@ export interface LegacyBundleManifest {
|
||||
rigSpec: string;
|
||||
packages: LegacyBundlePackageEntry[];
|
||||
integrity?: BundleIntegrity;
|
||||
provenance?: BundleProvenance;
|
||||
compatibility?: BundleCompatibility;
|
||||
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
|
||||
skills?: string[];
|
||||
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
|
||||
plugins?: BundlePluginReference[];
|
||||
/** Item 6 cross-primitive bundling: workflow spec YAML paths to route to the operator workflow-specs library on install (Checkpoint 7.3e). */
|
||||
workflowSpecs?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to context-pack manifest.yaml files; router copies the parent dir to the operator context-packs library on install (Checkpoint 7.3f). */
|
||||
contextPacks?: string[];
|
||||
/** Item 6 cross-primitive bundling: paths to agent-image DIRECTORIES (per PRD §Item 6 line 197); router copies the declared directory to the operator agent-images library on install. Consumer requires manifest.yaml inside each image dir. (Checkpoint 7.3g) */
|
||||
agentImages?: string[];
|
||||
}
|
||||
|
||||
/** Validation options */
|
||||
@@ -206,6 +599,14 @@ export function validateLegacyBundleManifest(
|
||||
}
|
||||
}
|
||||
|
||||
validateProvenanceBlock(m["provenance"], errors);
|
||||
validateCompatibilityBlock(m["compatibility"], errors);
|
||||
validateSkillsBlock(m["skills"], errors);
|
||||
validatePluginsBlock(m["plugins"], errors);
|
||||
validateWorkflowSpecsBlock(m["workflow_specs"], errors);
|
||||
validateContextPacksBlock(m["context_packs"], errors);
|
||||
validateAgentImagesBlock(m["agent_images"], errors);
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -247,6 +648,27 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
|
||||
};
|
||||
}
|
||||
|
||||
const provenance = normalizeProvenanceBlock(m["provenance"]);
|
||||
if (provenance) result.provenance = provenance;
|
||||
|
||||
const compatibility = normalizeCompatibilityBlock(m["compatibility"]);
|
||||
if (compatibility) result.compatibility = compatibility;
|
||||
|
||||
const skills = normalizeSkillsBlock(m["skills"]);
|
||||
if (skills) result.skills = skills;
|
||||
|
||||
const plugins = normalizePluginsBlock(m["plugins"]);
|
||||
if (plugins) result.plugins = plugins;
|
||||
|
||||
const workflowSpecs = normalizeWorkflowSpecsBlock(m["workflow_specs"]);
|
||||
if (workflowSpecs) result.workflowSpecs = workflowSpecs;
|
||||
|
||||
const contextPacks = normalizeContextPacksBlock(m["context_packs"]);
|
||||
if (contextPacks) result.contextPacks = contextPacks;
|
||||
|
||||
const agentImages = normalizeAgentImagesBlock(m["agent_images"]);
|
||||
if (agentImages) result.agentImages = agentImages;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -274,5 +696,15 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
|
||||
};
|
||||
}
|
||||
|
||||
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
|
||||
|
||||
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
|
||||
|
||||
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
|
||||
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
|
||||
if (manifest.workflowSpecs && manifest.workflowSpecs.length > 0) doc["workflow_specs"] = manifest.workflowSpecs;
|
||||
if (manifest.contextPacks && manifest.contextPacks.length > 0) doc["context_packs"] = manifest.contextPacks;
|
||||
if (manifest.agentImages && manifest.agentImages.length > 0) doc["agent_images"] = manifest.agentImages;
|
||||
|
||||
return stringifyYaml(doc);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Bundle workflow_specs router (Item 6 / slice-05 Checkpoint 7.3e step 2).
|
||||
*
|
||||
* Pure function. Copies workflow spec YAML files declared in a bundle
|
||||
* manifest's workflow_specs[] block from the bundle's extracted tree to the
|
||||
* operator workflow-specs library. No daemon dependencies — fully
|
||||
* unit-testable via FsOps injection.
|
||||
*
|
||||
* Mirrors the bundle-skills-router pattern (file-paths, single-file copy
|
||||
* preserving directory layout, leading-prefix strip). Per orch-ratified
|
||||
* Candidate A on Item-6 completeness: workflow_specs is the 3rd v0
|
||||
* cross-primitive kind (after skills + plugins). Source primitive
|
||||
* reachable on main (WorkflowSpecCache + workflow-runtime + scanner).
|
||||
*
|
||||
* Safety (banked feedback_pre_existing_trust_boundary_reuse_canonical_helper
|
||||
* addendum): both-sides containment — declared source path must stay
|
||||
* inside bundleRoot; resolved target path (after leading "workflows/"
|
||||
* strip) must stay inside targetWorkflowSpecsDir. The leading-prefix
|
||||
* strip can promote intermediate ../ segments that bypass source check
|
||||
* but escape target.
|
||||
*
|
||||
* Honest-scoping: missing source files surface as warnings in the
|
||||
* result (NOT thrown errors) so the install lifecycle can continue
|
||||
* with what's available. Same as bundle-skills-router.
|
||||
*
|
||||
* /install handler integration lands at Checkpoint 7.3e step 3.
|
||||
*/
|
||||
|
||||
import nodePath from "node:path";
|
||||
|
||||
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
|
||||
export interface WorkflowSpecsRouterFsOps {
|
||||
exists: (path: string) => boolean;
|
||||
readFile: (path: string) => string;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Inputs to routeWorkflowSpecs. */
|
||||
export interface RouteWorkflowSpecsInput {
|
||||
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
|
||||
bundleRoot: string;
|
||||
/** Relative workflow_spec paths declared in the manifest's workflow_specs[] block. */
|
||||
declaredWorkflowSpecs: string[];
|
||||
/**
|
||||
* Absolute path to the operator workflow-specs library that the
|
||||
* spec-library-workflow-scanner ACTUALLY reads from. **CALLER CONTRACT**:
|
||||
* this MUST be exactly
|
||||
* `nodePath.join(ContextPackSettingsStore.resolveConfig().workspaceSpecsRoot, "workflows")`
|
||||
* — the folder discovered by `scanWorkflowSpecFolder` in
|
||||
* `packages/daemon/src/domain/spec-library-workflow-scanner.ts:320-342`
|
||||
* and exposed via `deps.workflowsFolderDir` in
|
||||
* `packages/daemon/src/startup.ts:903-916`. SettingsStore is the sole
|
||||
* authority for the actual operator-host path; do NOT hardcode a default
|
||||
* here — settings layering (env > config > workspace-default) can shift
|
||||
* the resolved root, so any literal default repeated in this comment
|
||||
* would be a drift hazard. Writing anywhere else will succeed silently
|
||||
* but be invisible to the live workflow scanner — operator dead-end.
|
||||
* The /install integration helper (Checkpoint 7.3e step 3) MUST resolve
|
||||
* this path via the SettingsStore call shown above before invoking
|
||||
* routeWorkflowSpecs.
|
||||
*/
|
||||
targetWorkflowSpecsDir: string;
|
||||
}
|
||||
|
||||
/** One routed workflow_spec (or one rejection). */
|
||||
export interface RoutedWorkflowSpecRecord {
|
||||
/** Declared path from manifest.workflow_specs[]. */
|
||||
declaredPath: string;
|
||||
/** "routed" = copied successfully and scanner-visible.
|
||||
* "missing" = source not in bundle (honest skip).
|
||||
* "unsafe" = source escapes bundleRoot OR declared path has a non-YAML
|
||||
* suffix the scanner will ignore (operator-invisible-by-construction).
|
||||
* "conflict" = basename collision with an earlier routed entry — only
|
||||
* the first such declared path is written; later ones flagged so the
|
||||
* routed count stays truthful at the scanner-visible boundary (mirror
|
||||
* of the slice's broader 3-part collision-error posture). */
|
||||
status: "routed" | "missing" | "unsafe" | "conflict";
|
||||
/** Where the workflow_spec landed in the target library (absolute path), if routed. */
|
||||
installedAt?: string;
|
||||
/** Human-readable detail (3-part error shape input for caller). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Aggregate routing result. */
|
||||
export interface RouteWorkflowSpecsResult {
|
||||
records: RoutedWorkflowSpecRecord[];
|
||||
routedCount: number;
|
||||
rejectedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route each declared workflow_spec from the bundle tree to the operator
|
||||
* workflow-specs library. Per-entry safety: BOTH source path (under
|
||||
* bundleRoot) AND target path (under targetWorkflowSpecsDir, post leading-
|
||||
* prefix strip) are containment-checked. Banked both-sides-trust-boundary
|
||||
* lesson applied. Caller writes the install audit record using the records
|
||||
* returned here.
|
||||
*/
|
||||
export function routeWorkflowSpecs(
|
||||
input: RouteWorkflowSpecsInput,
|
||||
fs: WorkflowSpecsRouterFsOps,
|
||||
): RouteWorkflowSpecsResult {
|
||||
const records: RoutedWorkflowSpecRecord[] = [];
|
||||
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
|
||||
const targetRootResolved = nodePath.resolve(input.targetWorkflowSpecsDir);
|
||||
fs.mkdirp(input.targetWorkflowSpecsDir);
|
||||
// Track basenames already routed so duplicate-basename collisions (after
|
||||
// the basename-flatten) surface as conflict records rather than silent
|
||||
// overwrites with a false routedCount (banked B1 guard catch on d81456dc).
|
||||
const routedBasenames = new Set<string>();
|
||||
|
||||
for (const declared of input.declaredWorkflowSpecs) {
|
||||
// Scanner-visibility prefilter: spec-library-workflow-scanner reads only
|
||||
// .yaml/.yml top-level files (spec-library-workflow-scanner.ts:366 +
|
||||
// 390-397). Non-YAML declarations would route to disk but be invisible
|
||||
// to the operator via the Library UI — false-positive class. Reject
|
||||
// here so the routedCount stays truthful at the scanner-visible
|
||||
// boundary.
|
||||
if (!/\.ya?ml$/i.test(declared)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `workflow_spec '${declared}' is not a .yaml/.yml file; scanner ignores non-YAML suffixes`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
|
||||
// Defense-in-depth path-containment on SOURCE (mirrors skills router
|
||||
// pattern; the manifest validator already rejects unsafe paths via
|
||||
// isRelativeSafePath but we re-check here in case the input bypassed
|
||||
// validation upstream).
|
||||
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `workflow_spec path '${declared}' escapes bundle workspace; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!fs.exists(sourceAbs)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "missing",
|
||||
detail: `workflow_spec source '${declared}' not present in bundle; skipped`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Target = top-level basename under targetWorkflowSpecsDir.
|
||||
//
|
||||
// SCANNER-REACHABILITY CONTRACT: spec-library-workflow-scanner reads
|
||||
// ONLY top-level YAML files via readdirSync(folder) + isFile() (see
|
||||
// spec-library-workflow-scanner.ts:382-397) — no recursion. Nested
|
||||
// YAML inside subdirs is INVISIBLE to the scanner and Library UI.
|
||||
// Workflow specs MUST land at the top of <workspace.specs_root>/workflows
|
||||
// to become operator-visible. Basename-only target computation enforces
|
||||
// this; preserving the bundle's directory layout would silently route
|
||||
// specs to an unscanned location.
|
||||
//
|
||||
// Side benefit: basename can never escape targetWorkflowSpecsDir
|
||||
// (nodePath.basename(any-path) is a leaf name with no separators), so
|
||||
// the "target-side escape after prefix strip" hazard banked on the
|
||||
// skills router (595e9550 B1 repair) is structurally impossible here.
|
||||
//
|
||||
// Basename collisions caught BEFORE write: see the routedBasenames
|
||||
// dedup-set + the basename-collision branch below. Two declared paths
|
||||
// sharing the same filename → first wins (status=routed), later flagged
|
||||
// status=conflict so routedCount stays truthful at the scanner-visible
|
||||
// boundary (mirror of the slice's 3-part collision-error posture).
|
||||
// Non-YAML suffixes are pre-filtered (status=unsafe) since the scanner
|
||||
// ignores non-.yaml/.yml entries entirely.
|
||||
const basename = nodePath.basename(declared);
|
||||
const targetAbs = nodePath.resolve(input.targetWorkflowSpecsDir, basename);
|
||||
// Sanity check (defensive; basename is structurally safe but the resolve
|
||||
// could in theory be a no-op for "" → keep the check, expected always-true).
|
||||
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "unsafe",
|
||||
detail: `workflow_spec target path for '${declared}' escapes target workflow-specs library; rejected`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Basename-collision detection: two declared paths that share a
|
||||
// basename would both target the same file after basename-flatten. Only
|
||||
// the FIRST is written; later ones surface as conflict records so the
|
||||
// routedCount equals scanner-visible specs (banked B1 guard catch on
|
||||
// d81456dc). Operator-actionable: rename the declared path or curate
|
||||
// the bundle manifest.
|
||||
if (routedBasenames.has(basename)) {
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "conflict",
|
||||
detail: `workflow_spec basename '${basename}' collides with an earlier declared path; only the first is routed (basename-flatten conflict)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFile(sourceAbs);
|
||||
fs.writeFile(targetAbs, content);
|
||||
routedBasenames.add(basename);
|
||||
records.push({
|
||||
declaredPath: declared,
|
||||
status: "routed",
|
||||
installedAt: targetAbs,
|
||||
});
|
||||
}
|
||||
|
||||
const routedCount = records.filter((r) => r.status === "routed").length;
|
||||
const rejectedCount = records.length - routedCount;
|
||||
return { records, routedCount, rejectedCount };
|
||||
}
|
||||
@@ -26,6 +26,10 @@ interface ClaudeContextProvisioner {
|
||||
checkReady?(binding: NodeBinding): Promise<ReadinessResult>;
|
||||
}
|
||||
|
||||
interface RuntimeReadinessChecker {
|
||||
checkReady?(binding: NodeBinding): Promise<ReadinessResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls known managed runtime context sources and persists the latest
|
||||
* normalized telemetry. Scheduler-only: no queries, no response shaping,
|
||||
@@ -41,6 +45,7 @@ export class ContextMonitor {
|
||||
private store: ContextUsageStore;
|
||||
private claudeContextProvisioner: ClaudeContextProvisioner | null;
|
||||
private compactionEnforcer: ClaudeCompactionEnforcer | null;
|
||||
private readinessCheckers: Record<string, RuntimeReadinessChecker | undefined>;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -48,11 +53,16 @@ export class ContextMonitor {
|
||||
store: ContextUsageStore,
|
||||
claudeContextProvisioner?: ClaudeContextProvisioner,
|
||||
compactionEnforcer?: ClaudeCompactionEnforcer,
|
||||
readinessCheckers?: Record<string, RuntimeReadinessChecker | undefined>,
|
||||
) {
|
||||
this.db = db;
|
||||
this.store = store;
|
||||
this.claudeContextProvisioner = claudeContextProvisioner ?? null;
|
||||
this.compactionEnforcer = compactionEnforcer ?? null;
|
||||
this.readinessCheckers = readinessCheckers ?? {};
|
||||
if (claudeContextProvisioner && !this.readinessCheckers["claude-code"]) {
|
||||
this.readinessCheckers["claude-code"] = claudeContextProvisioner;
|
||||
}
|
||||
}
|
||||
|
||||
/** Discover active managed Claude sessions and poll their sidecar files. */
|
||||
@@ -60,15 +70,18 @@ export class ContextMonitor {
|
||||
const sessions = this.getEligibleSessions();
|
||||
for (const session of sessions) {
|
||||
let observed: ContextUsage | null = null;
|
||||
try {
|
||||
observed = this.readContextUsage(session);
|
||||
this.store.persist(session.node_id, observed);
|
||||
} catch {
|
||||
observed = null;
|
||||
// One bad session must not crash polling for others
|
||||
const canReadContextUsage = session.runtime !== "codex" || !!session.resume_token;
|
||||
if (canReadContextUsage) {
|
||||
try {
|
||||
this.store.persist(session.node_id, this.store.unknownUsage("parse_error"));
|
||||
} catch { /* give up on this session */ }
|
||||
observed = this.readContextUsage(session);
|
||||
this.store.persist(session.node_id, observed);
|
||||
} catch {
|
||||
observed = null;
|
||||
// One bad session must not crash polling for others
|
||||
try {
|
||||
this.store.persist(session.node_id, this.store.unknownUsage("parse_error"));
|
||||
} catch { /* give up on this session */ }
|
||||
}
|
||||
}
|
||||
|
||||
await this.normalizeStartupStatus(session);
|
||||
@@ -139,7 +152,13 @@ export class ContextMonitor {
|
||||
LEFT JOIN bindings b ON b.node_id = n.id
|
||||
WHERE (
|
||||
(n.runtime = 'claude-code' AND s.status = 'running')
|
||||
OR (n.runtime = 'codex' AND s.resume_token IS NOT NULL)
|
||||
OR (
|
||||
n.runtime = 'codex'
|
||||
AND (
|
||||
s.resume_token IS NOT NULL
|
||||
OR s.startup_status IN ('failed', 'attention_required')
|
||||
)
|
||||
)
|
||||
)
|
||||
AND COALESCE(b.attachment_type, 'tmux') = 'tmux'
|
||||
AND COALESCE(b.tmux_session, s.session_name) IS NOT NULL
|
||||
@@ -162,12 +181,12 @@ export class ContextMonitor {
|
||||
}
|
||||
|
||||
private async normalizeStartupStatus(session: EligibleSession): Promise<void> {
|
||||
if (session.runtime !== "claude-code") return;
|
||||
if (!this.claudeContextProvisioner?.checkReady) return;
|
||||
const checker = session.runtime ? this.readinessCheckers[session.runtime] : undefined;
|
||||
if (!checker?.checkReady) return;
|
||||
if (session.startup_status !== "failed" && session.startup_status !== "attention_required") return;
|
||||
|
||||
try {
|
||||
const readiness = await this.claudeContextProvisioner.checkReady({
|
||||
const readiness = await checker.checkReady({
|
||||
id: `context-monitor:${session.session_id}`,
|
||||
nodeId: session.node_id,
|
||||
attachmentType: "tmux",
|
||||
|
||||
@@ -37,6 +37,9 @@ interface RFNodeData {
|
||||
contextTotalOutputTokens: number | null;
|
||||
agentActivity?: AgentActivity | null;
|
||||
currentQitems?: CurrentQitemSummary[];
|
||||
terminalActive?: boolean | null;
|
||||
hasAssignedWork?: boolean;
|
||||
pendingWorkCount?: number;
|
||||
}
|
||||
|
||||
interface RFNode {
|
||||
@@ -73,6 +76,9 @@ export interface InventoryOverlay {
|
||||
contextTotalOutputTokens?: number | null;
|
||||
agentActivity?: AgentActivity | null;
|
||||
currentQitems?: CurrentQitemSummary[];
|
||||
terminalActive?: boolean | null;
|
||||
hasAssignedWork?: boolean;
|
||||
pendingWorkCount?: number;
|
||||
}
|
||||
|
||||
export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: InventoryOverlay[]): ReactFlowGraph {
|
||||
@@ -133,6 +139,9 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
|
||||
contextTotalOutputTokens: overlay?.contextTotalOutputTokens ?? null,
|
||||
agentActivity: overlay?.agentActivity ?? null,
|
||||
currentQitems: overlay?.currentQitems ?? [],
|
||||
terminalActive: overlay?.terminalActive,
|
||||
hasAssignedWork: overlay?.hasAssignedWork ?? false,
|
||||
pendingWorkCount: overlay?.pendingWorkCount ?? 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -170,6 +179,9 @@ export function projectRigToGraph(input: RigGraphInput, inventoryOverlay?: Inven
|
||||
contextTotalOutputTokens: null,
|
||||
agentActivity: null,
|
||||
currentQitems: [],
|
||||
terminalActive: null,
|
||||
hasAssignedWork: false,
|
||||
pendingWorkCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,13 +126,6 @@ export function assessNativeResumeProbe(
|
||||
detail: "Codex could not refresh the stored access token; an operator must sign in again before the session can resume.",
|
||||
};
|
||||
}
|
||||
if (looksLikeCodexTrustPrompt(paneContent)) {
|
||||
return {
|
||||
status: "inconclusive",
|
||||
code: "trust_gate",
|
||||
detail: "Codex is waiting for workspace trust approval before the session can become interactive.",
|
||||
};
|
||||
}
|
||||
if (looksLikeCodexModelSelectionPrompt(paneContent)) {
|
||||
return {
|
||||
status: "inconclusive",
|
||||
@@ -147,6 +140,13 @@ export function assessNativeResumeProbe(
|
||||
detail: "Codex is running with an active interactive TUI in the probe pane.",
|
||||
};
|
||||
}
|
||||
if (looksLikeCodexTrustPrompt(paneContent)) {
|
||||
return {
|
||||
status: "inconclusive",
|
||||
code: "trust_gate",
|
||||
detail: "Codex is waiting for workspace trust approval before the session can become interactive.",
|
||||
};
|
||||
}
|
||||
if (paneContent.includes("Update available!") || paneContent.includes("Updating Codex")) {
|
||||
return {
|
||||
status: "inconclusive",
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { NodeInventoryEntry, NodeDetailEntry, NodeDetailPeer, NodeDetailEdg
|
||||
import type { RuntimeAdapter } from "./runtime-adapter.js";
|
||||
import type { ContextUsageStore } from "./context-usage-store.js";
|
||||
import type { AgentActivityStore } from "./agent-activity-store.js";
|
||||
import type { SeatActivityService } from "./seat-activity-service.js";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { probeSessionActivity } from "./session-transport.js";
|
||||
import { findLatestUsableSnapshot } from "./rig-repository.js";
|
||||
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
|
||||
import { deriveCanonicalSessionName } from "./session-name.js";
|
||||
|
||||
// -- Row types for SQL results --
|
||||
|
||||
@@ -630,6 +632,108 @@ export function getNodeDetailWithContext(
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — populate `terminalActive` + `hasAssignedWork` per node.
|
||||
*
|
||||
* Two orthogonal enrichments computed independently (non-inference
|
||||
* contract per IMPL-PRD §2.3):
|
||||
* - `terminalActive`: read from SeatActivityService (tmux signal)
|
||||
* - `hasAssignedWork` + `pendingWorkCount`: derived from queue_items
|
||||
* where destination_session matches the seat's canonical session name
|
||||
*
|
||||
* Pure / synchronous — keeps the projection cheap for both `rig ps` and
|
||||
* the UI which both fetch this per-request. The two enrichments do not
|
||||
* read each other's source.
|
||||
*/
|
||||
export function attachTerminalActivityAndWork(
|
||||
entries: NodeInventoryEntry[],
|
||||
deps: { db: Database.Database; seatActivity?: SeatActivityService },
|
||||
): NodeInventoryEntry[] {
|
||||
const seatActivity = deps.seatActivity ?? null;
|
||||
const pendingByDest = readPendingWorkBySession(deps.db);
|
||||
return entries.map((entry) => {
|
||||
let terminalActive: boolean | null | undefined = undefined;
|
||||
if (seatActivity && entry.canonicalSessionName) {
|
||||
const obs = seatActivity.getSeatActivity(entry.canonicalSessionName);
|
||||
terminalActive = obs ? obs.isActiveWithinWindow : null;
|
||||
}
|
||||
const pendingCount = countPendingForEntry(entry, pendingByDest);
|
||||
return {
|
||||
...entry,
|
||||
terminalActive,
|
||||
hasAssignedWork: pendingCount > 0,
|
||||
pendingWorkCount: pendingCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* QA baseline-deep-dogfood BLOCKING-A2 (qitem-20260518063900-85745917):
|
||||
* adopted/live-session rigs do NOT surface assigned queue work because
|
||||
* the prior lookup matched destination_session ONLY against
|
||||
* canonicalSessionName.
|
||||
*
|
||||
* For MANAGED seats, canonicalSessionName equals the canonical form
|
||||
* `{pod}-{member}@{rig}` (set by deriveCanonicalSessionName at
|
||||
* materialize time) and queue operators address them by that form, so
|
||||
* the single-key lookup works.
|
||||
*
|
||||
* For ADOPTED seats, canonicalSessionName is the RAW tmux session
|
||||
* name (whatever the adopter chose, e.g., `my-existing-claude`).
|
||||
* Operators address adopted seats by the canonical form (logical
|
||||
* `{pod}-{member}@{rig}`) through `rig queue create --destination`,
|
||||
* so the single-key lookup misses.
|
||||
*
|
||||
* Resolve via BOTH forms:
|
||||
* 1. entry.canonicalSessionName (covers managed + adopted-by-raw)
|
||||
* 2. derived `{pod}-{member}@{rig}` from logicalId + rigName
|
||||
* (covers adopted-by-canonical)
|
||||
*
|
||||
* The logicalId is the pod-aware `pod.member` form (dot-separated).
|
||||
* The canonical session form replaces the dot with a dash to match the
|
||||
* convention from deriveCanonicalSessionName (so `redo.driver-2` in
|
||||
* rig `openrig-velocity` becomes `redo-driver-2@openrig-velocity`).
|
||||
*
|
||||
* Sums distinct destination_session keys to avoid double-counting
|
||||
* when both forms are identical (managed seats whose
|
||||
* canonicalSessionName already equals the derived canonical form).
|
||||
*/
|
||||
function countPendingForEntry(
|
||||
entry: NodeInventoryEntry,
|
||||
pendingByDest: Map<string, number>,
|
||||
): number {
|
||||
const keys = new Set<string>();
|
||||
if (entry.canonicalSessionName) keys.add(entry.canonicalSessionName);
|
||||
const derived = deriveCanonicalFromEntry(entry);
|
||||
if (derived) keys.add(derived);
|
||||
let count = 0;
|
||||
for (const key of keys) {
|
||||
count += pendingByDest.get(key) ?? 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function deriveCanonicalFromEntry(entry: NodeInventoryEntry): string | null {
|
||||
if (!entry.rigName || !entry.logicalId) return null;
|
||||
const dotIdx = entry.logicalId.indexOf(".");
|
||||
if (dotIdx <= 0 || dotIdx === entry.logicalId.length - 1) return null;
|
||||
const pod = entry.logicalId.slice(0, dotIdx);
|
||||
const member = entry.logicalId.slice(dotIdx + 1);
|
||||
return deriveCanonicalSessionName(pod, member, entry.rigName);
|
||||
}
|
||||
|
||||
function readPendingWorkBySession(db: Database.Database): Map<string, number> {
|
||||
const rows = db.prepare(`
|
||||
SELECT destination_session, COUNT(*) as c
|
||||
FROM queue_items
|
||||
WHERE state = 'pending'
|
||||
GROUP BY destination_session
|
||||
`).all() as Array<{ destination_session: string; c: number }>;
|
||||
const out = new Map<string, number>();
|
||||
for (const r of rows) out.set(r.destination_session, r.c);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function attachAgentActivity(
|
||||
entries: NodeInventoryEntry[],
|
||||
deps: { tmuxAdapter: TmuxAdapter; activityStore?: AgentActivityStore; now?: Date },
|
||||
|
||||
@@ -20,6 +20,14 @@ export type LaunchResult =
|
||||
interface LaunchOpts {
|
||||
sessionName?: string;
|
||||
cwd?: string;
|
||||
/**
|
||||
* Slice 15 — per-seat silence window override (seconds). The daemon
|
||||
* configures tmux's `monitor-silence` on the seat's pane so the
|
||||
* `terminal-active` primitive flips at this threshold. Falls back to
|
||||
* the launcher's default (typically 3) when not provided. Caller
|
||||
* (StartupOrchestrator) plumbs this from AgentSpec.profile.activity.
|
||||
*/
|
||||
silenceWindowSeconds?: number;
|
||||
}
|
||||
|
||||
interface NodeLauncherDeps {
|
||||
@@ -30,6 +38,9 @@ interface NodeLauncherDeps {
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
transcriptStore?: TranscriptStore;
|
||||
sessionEnv?: Record<string, string | undefined>;
|
||||
/** Slice 15 — default monitor-silence window (seconds). Defaults to 3
|
||||
* per slice 15 README §v0. Per-seat override via LaunchOpts. */
|
||||
defaultSilenceWindowSeconds?: number;
|
||||
}
|
||||
|
||||
export class NodeLauncher {
|
||||
@@ -40,6 +51,7 @@ export class NodeLauncher {
|
||||
private tmuxAdapter: TmuxAdapter;
|
||||
private transcriptStore: TranscriptStore | null;
|
||||
private sessionEnv: Record<string, string>;
|
||||
private defaultSilenceWindowSeconds: number;
|
||||
|
||||
constructor(deps: NodeLauncherDeps) {
|
||||
// Hard runtime invariant: all domain services must share the same db handle.
|
||||
@@ -61,6 +73,7 @@ export class NodeLauncher {
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.transcriptStore = deps.transcriptStore ?? null;
|
||||
this.sessionEnv = compactEnv(deps.sessionEnv ?? {});
|
||||
this.defaultSilenceWindowSeconds = deps.defaultSilenceWindowSeconds ?? 3;
|
||||
}
|
||||
|
||||
async launchNode(
|
||||
@@ -110,12 +123,38 @@ export class NodeLauncher {
|
||||
return { ok: false, code: tmuxResult.code, message: tmuxResult.message };
|
||||
}
|
||||
|
||||
// 3a. Slice 15 — configure tmux monitor-silence on the new session
|
||||
// so the runtime maintains a per-pane silence_flag that the daemon's
|
||||
// SeatActivityService reads on its poll tick. Best-effort: a tmux
|
||||
// misfire here does NOT abort seat launch — the seat still functions,
|
||||
// we just won't have a terminal-active signal for it until a
|
||||
// subsequent seat-up retry. Threshold: per-seat override (from
|
||||
// AgentSpec.profile.activity.silenceWindowSeconds via LaunchOpts)
|
||||
// falls back to the launcher default (3s per slice 15 README).
|
||||
//
|
||||
// Failures are surfaced as launch warnings (not silently swallowed)
|
||||
// so the operator and downstream consumers can see when activity
|
||||
// detection isn't active for a seat. The seat still launches.
|
||||
const launchWarnings: string[] = [];
|
||||
const silenceWindowSeconds = opts?.silenceWindowSeconds ?? this.defaultSilenceWindowSeconds;
|
||||
try {
|
||||
const monitorResult = await this.tmuxAdapter.setMonitorSilence(sessionName, silenceWindowSeconds);
|
||||
if (!monitorResult.ok) {
|
||||
launchWarnings.push(
|
||||
`monitor-silence setup failed for ${sessionName}: ${monitorResult.message}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
launchWarnings.push(
|
||||
`monitor-silence setup failed for ${sessionName}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3b. Start transcript rotation (V1 pre-release CLI/daemon Item 1:
|
||||
// bounded capture-pane overwrite replaces the unbounded pipe-pane
|
||||
// mechanism). Failures inside individual rotation ticks are silent
|
||||
// (best-effort); only the transcript directory not being writable
|
||||
// surfaces a launch warning.
|
||||
const launchWarnings: string[] = [];
|
||||
if (this.transcriptStore?.enabled) {
|
||||
const dirOk = this.transcriptStore.ensureTranscriptDir(rig.rig.name);
|
||||
if (dirOk) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { RigSpecCodec } from "./rigspec-codec.js";
|
||||
import { RigSpecSchema } from "./rigspec-schema.js";
|
||||
import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
|
||||
import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgentEntry, type PodBundleAgentImportEntry } from "./bundle-types.js";
|
||||
import { serializePodBundleManifest, type PodBundleManifest, type PodBundleAgentEntry, type PodBundleAgentImportEntry, type BundleProvenance, type BundleCompatibility } from "./bundle-types.js";
|
||||
import type { RigSpec, StartupBlock } from "./types.js";
|
||||
|
||||
export interface PodAssemblerFsOps extends AgentResolverFsOps {
|
||||
@@ -19,6 +19,21 @@ export interface PodAssembleOptions {
|
||||
outputDir: string;
|
||||
bundleName: string;
|
||||
bundleVersion: string;
|
||||
/**
|
||||
* Optional Item-1 provenance input. Caller fills the fields it knows
|
||||
* (sourceHost, authorSession, daemonVersion, cliVersion, sourceRigId,
|
||||
* sourceRigName, notes). The assembler mirrors root createdAt into
|
||||
* provenance.createdAt unless caller pre-set it (caller value wins
|
||||
* for test determinism). Missing block = no provenance recorded
|
||||
* (backward compat).
|
||||
*/
|
||||
provenance?: BundleProvenance;
|
||||
/**
|
||||
* Optional Item-2 compatibility input. Caller declares minimum
|
||||
* daemon + CLI versions and optional schema_version reaffirmation.
|
||||
* Missing block = no compatibility recorded (backward compat).
|
||||
*/
|
||||
compatibility?: BundleCompatibility;
|
||||
}
|
||||
|
||||
export interface PodAssembleResult {
|
||||
@@ -163,15 +178,25 @@ export class PodBundleAssembler {
|
||||
this.fs.writeFile(nodePath.join(opts.outputDir, "rig.yaml"), rewrittenRigYaml);
|
||||
|
||||
// 4. Build manifest
|
||||
const createdAt = new Date().toISOString();
|
||||
const manifest: PodBundleManifest = {
|
||||
schemaVersion: 2,
|
||||
name: opts.bundleName,
|
||||
version: opts.bundleVersion,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt,
|
||||
rigSpec: "rig.yaml",
|
||||
agents: agentEntries,
|
||||
cultureFile: rigSpec.cultureFile,
|
||||
};
|
||||
if (opts.provenance) {
|
||||
manifest.provenance = {
|
||||
...opts.provenance,
|
||||
createdAt: opts.provenance.createdAt ?? createdAt,
|
||||
};
|
||||
}
|
||||
if (opts.compatibility) {
|
||||
manifest.compatibility = { ...opts.compatibility };
|
||||
}
|
||||
|
||||
// Write manifest
|
||||
this.fs.writeFile(
|
||||
|
||||
@@ -37,6 +37,14 @@ export interface ResolvedNodeConfig {
|
||||
resolvedSpecName: string;
|
||||
resolvedSpecVersion: string;
|
||||
resolvedSpecHash: string;
|
||||
/**
|
||||
* Slice 15 — per-seat activity-detection tuning. Forwarded verbatim
|
||||
* from `profile.activity` after agent-manifest normalization (which
|
||||
* dropped invalid values). The instantiator passes
|
||||
* `silenceWindowSeconds` to NodeLauncher.launchNode where it overrides
|
||||
* the launcher's default (3s) for the tmux monitor-silence call.
|
||||
*/
|
||||
activity?: { silenceWindowSeconds?: number };
|
||||
}
|
||||
|
||||
export interface ResolutionContext {
|
||||
@@ -208,6 +216,10 @@ export function resolveNodeConfig(ctx: ResolutionContext): ResolutionResult {
|
||||
resolvedSpecName: spec.name,
|
||||
resolvedSpecVersion: spec.version,
|
||||
resolvedSpecHash: baseSpec.hash,
|
||||
// Slice 15 — pass through the parsed activity block (or undefined
|
||||
// when the profile didn't declare one). NodeLauncher applies the
|
||||
// default 3s when this is missing.
|
||||
activity: profile.activity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { NodeLifecycleState, RigLifecycleState } from "./types.js";
|
||||
import { getNodeInventory } from "./node-inventory.js";
|
||||
import type { SeatActivityService } from "./seat-activity-service.js";
|
||||
|
||||
export interface PsEntry {
|
||||
rigId: string;
|
||||
@@ -17,6 +18,20 @@ export interface PsEntry {
|
||||
rigName: string;
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
/**
|
||||
* Slice 15 — count of nodes whose `terminal-active` primitive
|
||||
* reports `isActiveWithinWindow === true`. PARALLEL to `runningCount`
|
||||
* (which stays `process-alive` semantics). Sourced exclusively from
|
||||
* the SeatActivityService; never from queue/assignment state.
|
||||
*/
|
||||
activeCount: number;
|
||||
/**
|
||||
* Slice 15 — count of nodes whose `has-work-to-do` primitive reports
|
||||
* `hasAssignedWork === true`. Derived from the pending queue items
|
||||
* with `destination_session` matching the node's canonical session
|
||||
* name. Independent from `activeCount` (non-inference contract).
|
||||
*/
|
||||
hasWorkCount: number;
|
||||
status: "running" | "partial" | "stopped";
|
||||
/** Always populated. Folded from per-node `lifecycleState` (post-L2);
|
||||
* empty rigs derive `stopped`. Never undefined or null. */
|
||||
@@ -62,9 +77,11 @@ export function deriveRigLifecycleState(nodeStates: NodeLifecycleState[]): RigLi
|
||||
*/
|
||||
export class PsProjectionService {
|
||||
readonly db: Database.Database;
|
||||
private readonly seatActivity: SeatActivityService | null;
|
||||
|
||||
constructor(deps: { db: Database.Database }) {
|
||||
constructor(deps: { db: Database.Database; seatActivity?: SeatActivityService }) {
|
||||
this.db = deps.db;
|
||||
this.seatActivity = deps.seatActivity ?? null;
|
||||
}
|
||||
|
||||
getEntries(): PsEntry[] {
|
||||
@@ -107,12 +124,34 @@ export class PsProjectionService {
|
||||
const inventory = getNodeInventory(this.db, r.rig_id);
|
||||
const lifecycleState = deriveRigLifecycleState(inventory.map((e) => e.lifecycleState));
|
||||
|
||||
// Slice 15 — `terminal-active` count. Subset of running tmux-bound
|
||||
// seats whose latest SeatActivity observation says
|
||||
// `isActiveWithinWindow === true`. Sourced ONLY from the activity
|
||||
// service; never derived from queue state.
|
||||
let activeCount = 0;
|
||||
if (this.seatActivity) {
|
||||
for (const node of inventory) {
|
||||
if (node.sessionStatus !== "running") continue;
|
||||
if (!node.canonicalSessionName) continue;
|
||||
const obs = this.seatActivity.getSeatActivity(node.canonicalSessionName);
|
||||
if (obs?.isActiveWithinWindow === true) activeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Slice 15 — `has-work-to-do` count. Subset of nodes with at
|
||||
// least one pending qitem whose `destination_session` matches the
|
||||
// node's `canonicalSessionName`. Sourced ONLY from the queue
|
||||
// projection; never from tmux output.
|
||||
const hasWorkCount = countNodesWithPendingWork(this.db, r.rig_id);
|
||||
|
||||
return {
|
||||
rigId: r.rig_id,
|
||||
name: r.name,
|
||||
rigName: r.name,
|
||||
nodeCount: r.node_count,
|
||||
runningCount: r.running_count,
|
||||
activeCount,
|
||||
hasWorkCount,
|
||||
status,
|
||||
lifecycleState,
|
||||
uptime: r.earliest_running_at ? formatDuration(now - new Date(r.earliest_running_at + "Z").getTime()) : null,
|
||||
@@ -122,6 +161,40 @@ export class PsProjectionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — count nodes in this rig that have at least one pending
|
||||
* qitem assigned to them (queue.destination_session matches a node's
|
||||
* latest session_name). Pure SQL query — does NOT consult tmux output
|
||||
* or SeatActivity (non-inference contract).
|
||||
*/
|
||||
function countNodesWithPendingWork(db: Database.Database, rigId: string): number {
|
||||
const row = db.prepare(`
|
||||
SELECT COUNT(DISTINCT n.id) as c
|
||||
FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
WHERE n.rig_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM queue_items q
|
||||
WHERE q.destination_session = s.session_name
|
||||
AND q.state = 'pending'
|
||||
)
|
||||
`).get(rigId) as { c: number } | undefined;
|
||||
return row?.c ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — count of pending qitems assigned to a specific canonical
|
||||
* session name. Exposed for per-node enrichment in node-inventory.
|
||||
*/
|
||||
export function countPendingWorkForSession(db: Database.Database, canonicalSessionName: string): number {
|
||||
const row = db.prepare(`
|
||||
SELECT COUNT(*) as c FROM queue_items
|
||||
WHERE destination_session = ? AND state = 'pending'
|
||||
`).get(canonicalSessionName) as { c: number } | undefined;
|
||||
return row?.c ?? 0;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
|
||||
@@ -256,6 +256,19 @@ export class QueueRepository {
|
||||
this.validateRig = opts?.validateRig ?? (() => true);
|
||||
this.transport = opts?.transport;
|
||||
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
|
||||
|
||||
// OPR.0.3.2.20 — register the EXACT human-seat regex predicate as
|
||||
// a SQLite function so the attention query can apply the strict
|
||||
// check BEFORE LIMIT. LIKE / GLOB patterns are supersets that
|
||||
// would let malformed rows (e.g., 'human-@kernel' — empty name
|
||||
// segment) occupy the LIMIT window and hide valid attention items
|
||||
// behind them (guard re-verify-3 qitem-20260518193005 BLOCKER 1).
|
||||
// better-sqlite3 db.function is idempotent; safe to call once at
|
||||
// construction.
|
||||
db.function("is_human_seat_session", { deterministic: true }, (value: unknown) => {
|
||||
if (typeof value !== "string") return 0;
|
||||
return /^human(?:-[A-Za-z0-9._-]+)?@(kernel|host)$/.test(value) ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +292,7 @@ export class QueueRepository {
|
||||
* can invoke after its outer transaction commits, completing the
|
||||
* createWithinTransaction()'s deferred post-commit side effects.
|
||||
*
|
||||
* V0.3.1 slice 23 founder-walk-queue-handoff-envelope: the nudge body
|
||||
* V0.3.1 slice 23 queue-handoff-envelope: the nudge body
|
||||
* is now wrapped with the same From/To/---/body/---/↩ Reply envelope
|
||||
* that `rig send` uses. `sourceSession` is the seat that triggered
|
||||
* the create/handoff so the recipient pane shows where the nudge
|
||||
@@ -298,8 +311,15 @@ export class QueueRepository {
|
||||
const text = wrapPaneEnvelope(sourceSession, destinationSession, bareBody);
|
||||
try {
|
||||
const res = await this.transport.send(destinationSession, text, { verify: true });
|
||||
// OPR.0.3.2.21.FR-4(c) — wording rename: the prior literal
|
||||
// "sent-unverified" read as a failure even in the common case
|
||||
// (delivery confirmed but the synchronous ack window expired,
|
||||
// which is normal for codex seats mid-task). The new literal
|
||||
// "delivered-ack-pending" reads as healthy. The old "verified"
|
||||
// case is unchanged for backward-compat with any tooling that
|
||||
// already consumed the positive literal.
|
||||
const result = res.ok
|
||||
? (res.verified ? "verified" : "sent-unverified")
|
||||
? (res.verified ? "verified" : "delivered-ack-pending")
|
||||
: `failed:${res.error ?? res.reason ?? "unknown"}`;
|
||||
this.recordNudgeAttempt(qitemId, result);
|
||||
} catch (err) {
|
||||
@@ -979,6 +999,92 @@ export class QueueRepository {
|
||||
return rows.map((r) => this.rowToItem(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.2.20 — durable attention-class query.
|
||||
*
|
||||
* Returns OPEN attention-class qitems (the source of truth for the
|
||||
* For You Action-required + Approval lenses) by pushing the
|
||||
* attention predicate INTO the SQL WHERE clause so the LIMIT
|
||||
* applies AFTER attention filtering. This makes the result
|
||||
* window-INDEPENDENT by construction: an old human-gate item
|
||||
* cannot be evicted past LIMIT by routine open qitems even when
|
||||
* there are >>LIMIT of them. (Guard verdict qitem-20260518190827
|
||||
* BLOCKER 1 — the prior fetch-then-filter approach in the route
|
||||
* could still hide attention items behind ATTENTION_FETCH_BOUND
|
||||
* newer routine open qitems.)
|
||||
*
|
||||
* Attention predicate in SQL (mirror of mission-control read
|
||||
* layer + the route-level `isAttentionItem`):
|
||||
* tier = 'human-gate' (approval)
|
||||
* OR destination_session matches human-seat regex (action-required)
|
||||
*
|
||||
* SQLite has no native regex; LIKE patterns are used as a
|
||||
* SUPER-SET (every regex match also matches one of the LIKE
|
||||
* patterns). Callers can refine in JS with isAttentionItem if
|
||||
* they need strict regex semantics — but for the LIMIT-pushdown
|
||||
* guarantee, the SQL superset is what matters: NO attention item
|
||||
* is filtered out by the SQL stage.
|
||||
*
|
||||
* Default open state set: pending|in-progress|blocked. Caller may
|
||||
* override via `state`.
|
||||
*/
|
||||
listAttention(opts?: {
|
||||
limit?: number;
|
||||
state?: QueueState | QueueState[];
|
||||
destinationSession?: string;
|
||||
sourceSession?: string;
|
||||
targetRepo?: string;
|
||||
}): QueueItem[] {
|
||||
const limit = opts?.limit ?? 100;
|
||||
const states = opts?.state
|
||||
? Array.isArray(opts.state) ? opts.state : [opts.state]
|
||||
: ["pending" as QueueState, "in-progress" as QueueState, "blocked" as QueueState];
|
||||
|
||||
// Compose the WHERE clause: state-set + attention predicate +
|
||||
// optional scope filters (mirrors list() composition so
|
||||
// `attention=1` query params remain composable with
|
||||
// destinationSession/sourceSession/targetRepo — guard re-verify
|
||||
// qitem-20260518192210 BLOCKER 1).
|
||||
const statePlaceholders = states.map(() => "?").join(", ");
|
||||
// The attention predicate is EXACT in SQL (guard re-verify-3
|
||||
// qitem-20260518193005 BLOCKER 1): is_human_seat_session evaluates
|
||||
// the strict regex registered in the QueueRepository constructor.
|
||||
// Malformed rows that would have slipped through a LIKE superset
|
||||
// (e.g., 'human-@kernel' — empty name segment) are rejected at
|
||||
// the SQL stage, BEFORE LIMIT, so they cannot saturate the LIMIT
|
||||
// window and hide valid attention items.
|
||||
const conditions: string[] = [
|
||||
`state IN (${statePlaceholders})`,
|
||||
`(
|
||||
tier = 'human-gate'
|
||||
OR is_human_seat_session(destination_session) = 1
|
||||
)`,
|
||||
];
|
||||
const params: unknown[] = [...states];
|
||||
if (opts?.destinationSession) {
|
||||
conditions.push("destination_session = ?");
|
||||
params.push(opts.destinationSession);
|
||||
}
|
||||
if (opts?.sourceSession) {
|
||||
conditions.push("source_session = ?");
|
||||
params.push(opts.sourceSession);
|
||||
}
|
||||
if (opts?.targetRepo && this.hasTargetRepoColumn) {
|
||||
conditions.push("target_repo = ?");
|
||||
params.push(opts.targetRepo);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const sql = `
|
||||
SELECT * FROM queue_items
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY ts_created DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
const rows = this.db.prepare(sql).all(...params) as QueueItemRow[];
|
||||
return rows.map((r) => this.rowToItem(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find qitems whose `closure_required_at` is past now. Used by watchdog;
|
||||
* does NOT itself emit events — callers decide whether to nudge or escalate.
|
||||
|
||||
@@ -199,25 +199,32 @@ interface HostInfraCheckResult {
|
||||
// --- Service ---
|
||||
|
||||
const DAEMON_HEALTHY_PATTERN = /^Daemon running\b/m;
|
||||
// OPR.0.3.2.14 — fallback subpath uses a generic .openrig placeholder
|
||||
// rather than an internal-team layout, to close the source-side
|
||||
// privacy leak. Fallback preserved per slice README §"Architecture
|
||||
// note" (option-A removal cascaded 36 test yellows in 0.3.1 cleanup).
|
||||
const SUBSTRATE_SHARED_DOCS_ROOT = process.env["OPENRIG_SUBSTRATE_SHARED_DOCS"]
|
||||
?? join(homedir(), "code", "substrate", "shared-docs");
|
||||
const CLAUDE_HOOKS_ROOT = join(
|
||||
?? join(homedir(), ".openrig", "shared-docs");
|
||||
// OPR.0.3.2.14 — these four constants used to be copy-pasted in 7+
|
||||
// test files. Exporting from source + importing in tests eliminates
|
||||
// the divergence class that broke 17 tests during 0.3.1 cleanup.
|
||||
export const CLAUDE_HOOKS_ROOT = join(
|
||||
SUBSTRATE_SHARED_DOCS_ROOT,
|
||||
"control-plane",
|
||||
"services",
|
||||
"claude-hooks",
|
||||
);
|
||||
const CLAUDE_SESSION_START_COMPACT_COMMAND = join(
|
||||
export const CLAUDE_SESSION_START_COMPACT_COMMAND = join(
|
||||
CLAUDE_HOOKS_ROOT,
|
||||
"bin",
|
||||
"session-start-compact-context.sh",
|
||||
);
|
||||
const CLAUDE_USER_PROMPT_SUBMIT_COMMAND = join(
|
||||
export const CLAUDE_USER_PROMPT_SUBMIT_COMMAND = join(
|
||||
CLAUDE_HOOKS_ROOT,
|
||||
"bin",
|
||||
"userpromptsubmit-queue-attention.sh",
|
||||
);
|
||||
const CLAUDE_HOOK_FRAGMENT_PATH = join(
|
||||
export const CLAUDE_HOOK_FRAGMENT_PATH = join(
|
||||
CLAUDE_HOOKS_ROOT,
|
||||
"config",
|
||||
"settings.fragment.json",
|
||||
@@ -879,7 +886,8 @@ export class RestoreCheckService {
|
||||
return { check, status: "yellow", evidence: "Cannot derive queue path (no pod namespace)", remediation: "" };
|
||||
}
|
||||
|
||||
const substrateRoot = this.deps.substrateRoot ?? join(process.env["HOME"] ?? "~", "code", "substrate", "shared-docs");
|
||||
// OPR.0.3.2.14 — subpath scrubbed (internal-team layout → generic placeholder).
|
||||
const substrateRoot = this.deps.substrateRoot ?? join(process.env["HOME"] ?? "~", ".openrig", "shared-docs");
|
||||
const queuePath = join(substrateRoot, "rigs", rigName, "state", podName, `${memberName}.queue.md`);
|
||||
|
||||
if (this.deps.exists(queuePath)) {
|
||||
@@ -1021,7 +1029,8 @@ export class RestoreCheckService {
|
||||
}
|
||||
|
||||
private checkSpecPresent(rig: { rigId: string; name: string }): CheckEntry {
|
||||
const substrateRoot = this.deps.substrateRoot ?? join(process.env["HOME"] ?? "~", "code", "substrate", "shared-docs");
|
||||
// OPR.0.3.2.14 — subpath scrubbed (internal-team layout → generic placeholder).
|
||||
const substrateRoot = this.deps.substrateRoot ?? join(process.env["HOME"] ?? "~", ".openrig", "shared-docs");
|
||||
const rigRoot = join(substrateRoot, "rigs", rig.name);
|
||||
const rigYaml = join(rigRoot, "rig.yaml");
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Slice 09 — recommended per-mode defaults + per-mode default scope.
|
||||
//
|
||||
// Component 3 — Recommended Per-Mode Defaults (the 6×7 matrix).
|
||||
// Component 4 — Recommended Default Scopes Per Mode.
|
||||
//
|
||||
// "RECOMMENDED, operator-overridable per field" — these are the
|
||||
// fall-back values the CLI / HTTP set route applies when the operator
|
||||
// invokes a bare mode (e.g., `rig policy set debug`) without
|
||||
// specifying individual fields. Operator-supplied per-field overrides
|
||||
// merge over the defaults.
|
||||
|
||||
import type {
|
||||
AutonomyScope,
|
||||
ConcurrencyLimit,
|
||||
EscalationThreshold,
|
||||
HeartbeatCadence,
|
||||
InspectionDepth,
|
||||
OperatorContextMode,
|
||||
OperatorContextScope,
|
||||
PermissionPromptPosture,
|
||||
UpdateDetail,
|
||||
} from "./rig-policy-types.js";
|
||||
|
||||
/**
|
||||
* The seven per-mode setting defaults (Component 3 §Recommended
|
||||
* Per-Mode Defaults). Mirrors the convention table verbatim.
|
||||
*
|
||||
* `scope` is NOT included here — per-mode default scope lives in
|
||||
* RECOMMENDED_DEFAULT_SCOPE below, since the convention treats it
|
||||
* separately (Component 4 §Recommended Default Scopes Per Mode).
|
||||
*
|
||||
* `expiry_or_stale_rule` and `evidence_citation` are not part of the
|
||||
* 6×7 matrix; the daemon applies a conservative default for the
|
||||
* former (`re_confirm_on_long_gap` — convention Q3 leaves the numeric
|
||||
* for Mode 2; the rule kind is the v0 conservative choice) and the
|
||||
* latter is operator-provided per invocation.
|
||||
*/
|
||||
export interface RecommendedModeDefaults {
|
||||
autonomy_scope: AutonomyScope;
|
||||
heartbeat_cadence: HeartbeatCadence;
|
||||
inspection_depth: InspectionDepth;
|
||||
update_detail: UpdateDetail;
|
||||
escalation_threshold: EscalationThreshold;
|
||||
concurrency_limit: ConcurrencyLimit;
|
||||
permission_prompt_posture: PermissionPromptPosture;
|
||||
}
|
||||
|
||||
export const RECOMMENDED_MODE_DEFAULTS: Record<OperatorContextMode, RecommendedModeDefaults> = {
|
||||
sleep: {
|
||||
autonomy_scope: "pre_approved_only",
|
||||
heartbeat_cadence: "sparse",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "blocker_only",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
desk: {
|
||||
autonomy_scope: "full_autonomy_within_workstream",
|
||||
heartbeat_cadence: "normal",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "normal",
|
||||
escalation_threshold: "normal",
|
||||
concurrency_limit: "unlimited", // "normal" in the convention table; v0 maps "normal" concurrency to unlimited (the existing OpenRig default — agents fan out per workstream-mode discipline)
|
||||
permission_prompt_posture: "normal",
|
||||
},
|
||||
mobile: {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "normal",
|
||||
inspection_depth: "surface",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "unlimited",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
away: {
|
||||
autonomy_scope: "pre_approved_only",
|
||||
heartbeat_cadence: "sparse",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "blocker_only",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
focus: {
|
||||
autonomy_scope: "full_autonomy_within_workstream",
|
||||
heartbeat_cadence: "normal",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "blocker_only",
|
||||
concurrency_limit: "unlimited",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
debug: {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "fast",
|
||||
inspection_depth: "forensic",
|
||||
update_detail: "verbose",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Component 4 — Recommended Default Scopes Per Mode.
|
||||
*
|
||||
* Used during restate-and-confirm: when the operator's invocation
|
||||
* omits an explicit scope, the agent restates with this recommendation
|
||||
* and awaits confirmation/correction. The operator's confirmation is
|
||||
* authoritative.
|
||||
*/
|
||||
export const RECOMMENDED_DEFAULT_SCOPE: Record<OperatorContextMode, OperatorContextScope> = {
|
||||
sleep: "global_host",
|
||||
away: "global_host",
|
||||
desk: "global_host",
|
||||
mobile: "global_host",
|
||||
focus: "workstream",
|
||||
debug: "qitem",
|
||||
};
|
||||
|
||||
/**
|
||||
* Conservative default for `expiry_or_stale_rule`. Per convention Q3,
|
||||
* the numeric threshold is deferred to a Mode 2 helper slice;
|
||||
* `re_confirm_on_long_gap` is the rule kind selected here as the
|
||||
* safest default (it prompts re-confirmation rather than silent
|
||||
* continuation when the operator has been away for an extended
|
||||
* period).
|
||||
*
|
||||
* Operators may override per-binding via the `expiry_or_stale_rule`
|
||||
* field of the OperatorContextModeRecord; v0 does not auto-tune the
|
||||
* threshold.
|
||||
*/
|
||||
export const DEFAULT_STALE_RULE: "re_confirm_on_long_gap" = "re_confirm_on_long_gap";
|
||||
@@ -0,0 +1,226 @@
|
||||
// Slice 09 — daemon-persisted typed primitive (SQLite-backed).
|
||||
//
|
||||
// Follows the workspace-primitive precedent: structured table,
|
||||
// JSON column for the 10-field record (validator owns integrity at
|
||||
// write time). NO parallel/forked store (HG-5).
|
||||
//
|
||||
// The store exposes:
|
||||
// - setBinding(scope, qualifier, mode, record) → upserts a row
|
||||
// (mode is binding-level
|
||||
// Component-2 identity;
|
||||
// record is the frozen
|
||||
// 10-field Component-3
|
||||
// settings)
|
||||
// - getBinding(scope, qualifier) → reads one row
|
||||
// - listBindings() → all rows (for show)
|
||||
// - resolveEffective(readContext) → most-specific binding
|
||||
// - deleteBinding(scope, qualifier) → unset
|
||||
//
|
||||
// Update authority is operator-only: setBinding hard-codes
|
||||
// `set_by = 'operator'`. The HTTP route + CLI are the only authoring
|
||||
// surfaces; agent code paths read but never write. (HG-4)
|
||||
//
|
||||
// HG-SAFE preserved here: this store NEVER writes to permission
|
||||
// allowlists / runtime configs / auth / tmux / lifecycle. It writes
|
||||
// to ONE table (operator_context_mode_bindings) and reads JSON. A
|
||||
// future contributor cannot widen permission authority via this
|
||||
// service because the surface area is the bindings table only.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
type EffectiveOperatorContextMode,
|
||||
type OperatorContextMode,
|
||||
type OperatorContextModeBinding,
|
||||
type OperatorContextModeRecord,
|
||||
type OperatorContextReadContext,
|
||||
type OperatorContextScope,
|
||||
SCOPE_SPECIFICITY,
|
||||
} from "./rig-policy-types.js";
|
||||
import { validateModeName, validateRecord } from "./rig-policy-validator.js";
|
||||
|
||||
interface BindingRow {
|
||||
id: string;
|
||||
scope: OperatorContextScope;
|
||||
qualifier: string | null;
|
||||
mode: OperatorContextMode;
|
||||
record_json: string;
|
||||
set_at: string;
|
||||
set_by: string;
|
||||
}
|
||||
|
||||
function rowToBinding(row: BindingRow): OperatorContextModeBinding {
|
||||
return {
|
||||
id: row.id,
|
||||
mode: row.mode,
|
||||
record: JSON.parse(row.record_json) as OperatorContextModeRecord,
|
||||
qualifier: row.qualifier,
|
||||
setAt: row.set_at,
|
||||
setBy: "operator",
|
||||
};
|
||||
}
|
||||
|
||||
function bindingId(scope: OperatorContextScope, qualifier: string | null): string {
|
||||
return `${scope}:${qualifier ?? "host"}`;
|
||||
}
|
||||
|
||||
export interface SetBindingResult {
|
||||
ok: true;
|
||||
binding: OperatorContextModeBinding;
|
||||
}
|
||||
|
||||
export interface SetBindingError {
|
||||
ok: false;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface RigPolicyStoreOpts {
|
||||
/** Override the clock for tests; defaults to new Date(). */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export class RigPolicyStore {
|
||||
private readonly db: Database.Database;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(db: Database.Database, opts?: RigPolicyStoreOpts) {
|
||||
this.db = db;
|
||||
this.now = opts?.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-only set. Validates the candidate record + the
|
||||
* scope/qualifier shape, then upserts. Returns the typed binding
|
||||
* on success.
|
||||
*
|
||||
* scope == 'global_host' REQUIRES qualifier === null;
|
||||
* scope != 'global_host' REQUIRES a non-empty qualifier string.
|
||||
* Both are operator/host invariants — the route validates and
|
||||
* the store rejects on disagreement.
|
||||
*/
|
||||
setBinding(
|
||||
scope: OperatorContextScope,
|
||||
qualifier: string | null,
|
||||
mode: unknown,
|
||||
candidateRecord: unknown,
|
||||
): SetBindingResult | SetBindingError {
|
||||
const errors: string[] = [];
|
||||
if (scope === "global_host" && qualifier !== null) {
|
||||
errors.push(
|
||||
`Global-host bindings cannot carry a qualifier (got ${JSON.stringify(qualifier)}). Pass null for the global_host scope.`,
|
||||
);
|
||||
}
|
||||
if (scope !== "global_host" && (typeof qualifier !== "string" || qualifier.length === 0)) {
|
||||
errors.push(
|
||||
`${scope} bindings require a qualifier (rigId / workstreamId / qitemId). Got ${JSON.stringify(qualifier)}.`,
|
||||
);
|
||||
}
|
||||
const modeCheck = validateModeName(mode);
|
||||
if (!modeCheck.ok) errors.push(modeCheck.error);
|
||||
const validation = validateRecord(candidateRecord);
|
||||
if (!validation.ok) errors.push(...validation.errors);
|
||||
if (errors.length > 0 || !modeCheck.ok || !validation.ok) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
const record = validation.record;
|
||||
if (record.scope !== scope) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
`Record scope mismatch: binding scope is "${scope}" but record.scope is "${record.scope}". The record's scope field MUST agree with the binding scope.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const id = bindingId(scope, qualifier);
|
||||
const setAt = this.now().toISOString();
|
||||
this.db.prepare(`
|
||||
INSERT INTO operator_context_mode_bindings (id, scope, qualifier, mode, record_json, set_at, set_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'operator')
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mode = excluded.mode,
|
||||
record_json = excluded.record_json,
|
||||
set_at = excluded.set_at
|
||||
`).run(id, scope, qualifier, modeCheck.mode, JSON.stringify(record), setAt);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
binding: {
|
||||
id,
|
||||
mode: modeCheck.mode,
|
||||
record,
|
||||
qualifier,
|
||||
setAt,
|
||||
setBy: "operator",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getBinding(
|
||||
scope: OperatorContextScope,
|
||||
qualifier: string | null,
|
||||
): OperatorContextModeBinding | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT id, scope, qualifier, mode, record_json, set_at, set_by
|
||||
FROM operator_context_mode_bindings
|
||||
WHERE id = ?
|
||||
`).get(bindingId(scope, qualifier)) as BindingRow | undefined;
|
||||
return row ? rowToBinding(row) : null;
|
||||
}
|
||||
|
||||
listBindings(): OperatorContextModeBinding[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT id, scope, qualifier, mode, record_json, set_at, set_by
|
||||
FROM operator_context_mode_bindings
|
||||
ORDER BY scope, qualifier NULLS FIRST, id
|
||||
`).all() as BindingRow[];
|
||||
return rows.map(rowToBinding);
|
||||
}
|
||||
|
||||
deleteBinding(scope: OperatorContextScope, qualifier: string | null): boolean {
|
||||
const info = this.db.prepare(`
|
||||
DELETE FROM operator_context_mode_bindings WHERE id = ?
|
||||
`).run(bindingId(scope, qualifier));
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most-specific applicable binding for a read context.
|
||||
* Returns null when no binding matches — callers MUST treat null as
|
||||
* `unknown_posture` per convention §Q6 (do NOT default to `desk`).
|
||||
*
|
||||
* Specificity ranks (qitem > workstream > rig > global_host) are
|
||||
* defined in rig-policy-types.SCOPE_SPECIFICITY. For each scope the
|
||||
* resolver picks the binding whose qualifier matches the read
|
||||
* context. Among multiple matching bindings, the most-specific
|
||||
* scope wins.
|
||||
*/
|
||||
resolveEffective(ctx: OperatorContextReadContext): EffectiveOperatorContextMode | null {
|
||||
const candidates: OperatorContextModeBinding[] = [];
|
||||
if (ctx.qitemId) {
|
||||
const b = this.getBinding("qitem", ctx.qitemId);
|
||||
if (b) candidates.push(b);
|
||||
}
|
||||
if (ctx.workstreamId) {
|
||||
const b = this.getBinding("workstream", ctx.workstreamId);
|
||||
if (b) candidates.push(b);
|
||||
}
|
||||
if (ctx.rigId) {
|
||||
const b = this.getBinding("rig", ctx.rigId);
|
||||
if (b) candidates.push(b);
|
||||
}
|
||||
const host = this.getBinding("global_host", null);
|
||||
if (host) candidates.push(host);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
candidates.sort(
|
||||
(a, b) => SCOPE_SPECIFICITY[b.record.scope] - SCOPE_SPECIFICITY[a.record.scope],
|
||||
);
|
||||
const winner = candidates[0]!;
|
||||
return {
|
||||
binding: winner,
|
||||
resolvedScope: winner.record.scope,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Slice 09 — Rig Policy Primitive (OPR.0.3.2.9).
|
||||
//
|
||||
// Typed graduation of the operator-context-mode-system v0 doctrine. The
|
||||
// convention at conventions/operator-context-mode-system/README.md is
|
||||
// the FROZEN spec; types below mirror it as closed enums + a 10-field
|
||||
// schema. None of these fields may be silently merged or dropped — the
|
||||
// validator enforces field-set integrity.
|
||||
//
|
||||
// GATE-ZERO (HG-SAFE): this primitive is permission-ADJACENT but NEVER
|
||||
// permission-modifying. `permission_prompt_posture` is a descriptive
|
||||
// operator-set ergonomic hint. The enum below is STRUCTURALLY
|
||||
// incapable of expressing auto-accept: there is no auto-accept member
|
||||
// in the union, and the validator further rejects unknown values. Any
|
||||
// future contributor who tries to add an auto-accept value here MUST
|
||||
// also amend the permission-posture convention + safety policy in the
|
||||
// FROZEN contract — review will reject otherwise.
|
||||
|
||||
/**
|
||||
* Component 2 — six reserved mode names. Lowercase single English
|
||||
* words; synonyms and numeric aliases (`L0`–`L3`, `operator:L<n>`)
|
||||
* are explicitly forbidden by the convention. The L0–L3 collision
|
||||
* warning at `conventions/operator-context-mode-system/README.md`
|
||||
* §"L0–L3 Collision Warning" is load-bearing.
|
||||
*/
|
||||
export type OperatorContextMode = "sleep" | "desk" | "mobile" | "away" | "focus" | "debug";
|
||||
|
||||
export const OPERATOR_CONTEXT_MODES = [
|
||||
"sleep",
|
||||
"desk",
|
||||
"mobile",
|
||||
"away",
|
||||
"focus",
|
||||
"debug",
|
||||
] as const satisfies readonly OperatorContextMode[];
|
||||
|
||||
/**
|
||||
* Component 4 — four scopes. More-specific overrides less-specific
|
||||
* when multiple modes coexist (Scope hierarchy: qitem > workstream >
|
||||
* rig > global_host).
|
||||
*/
|
||||
export type OperatorContextScope = "global_host" | "rig" | "workstream" | "qitem";
|
||||
|
||||
export const OPERATOR_CONTEXT_SCOPES = [
|
||||
"global_host",
|
||||
"rig",
|
||||
"workstream",
|
||||
"qitem",
|
||||
] as const satisfies readonly OperatorContextScope[];
|
||||
|
||||
/**
|
||||
* Scope specificity ranks; higher number = more specific. Used by the
|
||||
* store's effective-mode resolver. NOT operator-facing.
|
||||
*/
|
||||
export const SCOPE_SPECIFICITY: Record<OperatorContextScope, number> = {
|
||||
global_host: 0,
|
||||
rig: 1,
|
||||
workstream: 2,
|
||||
qitem: 3,
|
||||
};
|
||||
|
||||
// --- 10-field schema enums ---
|
||||
|
||||
export type AutonomyScope =
|
||||
| "pre_approved_only"
|
||||
| "bounded_continuation"
|
||||
| "full_autonomy_within_workstream"
|
||||
| "full_autonomy";
|
||||
|
||||
export type HeartbeatCadence = "sparse" | "normal" | "fast";
|
||||
|
||||
export type InspectionDepth = "surface" | "normal" | "forensic";
|
||||
|
||||
export type UpdateDetail = "compact" | "normal" | "verbose";
|
||||
|
||||
export type EscalationThreshold = "low" | "normal" | "high" | "blocker_only";
|
||||
|
||||
/**
|
||||
* Component 3 — concurrency_limit. Suggested values include the enum
|
||||
* forms below + structured integer values (1..N). v0 ships the enum
|
||||
* subset for simplicity; an integer specialization can graduate via
|
||||
* Mode 1.5 amendment when fixture-backed evidence appears.
|
||||
*/
|
||||
export type ConcurrencyLimit = "serial" | "2" | "4" | "unlimited";
|
||||
|
||||
/**
|
||||
* Component 6 — Safety Policy load-bearing rule:
|
||||
* `permission_prompt_posture` MUST NOT include auto-accept in v0 or
|
||||
* any descendant. The safe values are exactly these three.
|
||||
*
|
||||
* The enum is STRUCTURALLY closed: no auto-accept literal exists in
|
||||
* the union. A contributor cannot "set permissionPromptPosture =
|
||||
* 'auto_accept'" because the literal isn't a member; TypeScript
|
||||
* rejects at compile time. The validator additionally rejects any
|
||||
* non-member string at runtime (defense-in-depth for inputs that
|
||||
* bypass typing — JSON file, env var, etc).
|
||||
*
|
||||
* If a future amendment proposes auto-accept, it MUST first amend
|
||||
* the permission-posture canon at
|
||||
* `conventions/permission-posture/README.md` AND the FROZEN
|
||||
* operator-context-mode safety policy. This slice and its
|
||||
* descendants reject auto-accept independently of any caller intent.
|
||||
*/
|
||||
export type PermissionPromptPosture =
|
||||
| "normal"
|
||||
| "batch_for_human"
|
||||
| "do_not_prompt_unless_blocked";
|
||||
|
||||
export const SAFE_PERMISSION_PROMPT_POSTURES = [
|
||||
"normal",
|
||||
"batch_for_human",
|
||||
"do_not_prompt_unless_blocked",
|
||||
] as const satisfies readonly PermissionPromptPosture[];
|
||||
|
||||
/**
|
||||
* Component 4 — citation source. Free-text in v0 (operator may cite
|
||||
* `current-mode.md`, a qitem id, a chatroom topic, or a convention-only
|
||||
* declaration). Structured citations are a Mode 1.5 amendment per
|
||||
* the convention.
|
||||
*/
|
||||
export type EvidenceCitation = string;
|
||||
|
||||
/**
|
||||
* Component 4 — expiry_or_stale_rule. v0 declares the field with a
|
||||
* conservative default ("re_confirm_on_long_gap"); the numeric
|
||||
* threshold is deferred per convention Q3 to a Mode 2 helper slice.
|
||||
* The rule values below enumerate the supported re-confirmation
|
||||
* triggers. NO silent-switch value exists — drift is always a
|
||||
* question, never an auto-mode-change.
|
||||
*/
|
||||
export type ExpiryOrStaleRule =
|
||||
| "none"
|
||||
| "re_confirm_on_long_gap"
|
||||
| "re_confirm_on_day_boundary"
|
||||
| "re_confirm_on_observed_conflict";
|
||||
|
||||
export const STALE_RULES = [
|
||||
"none",
|
||||
"re_confirm_on_long_gap",
|
||||
"re_confirm_on_day_boundary",
|
||||
"re_confirm_on_observed_conflict",
|
||||
] as const satisfies readonly ExpiryOrStaleRule[];
|
||||
|
||||
/**
|
||||
* Component 3 — the 10-field SETTINGS schema. ALL fields are required
|
||||
* by the validator; none may be merged or dropped. Reviewer field-set
|
||||
* integrity check (HG-2).
|
||||
*
|
||||
* **`mode` is NOT in this record.** Mode is the binding's identity
|
||||
* (Component 2 — the name of the named bundle); the record parameterizes
|
||||
* how that named mode shapes ergonomics. The binding wrapper
|
||||
* (OperatorContextModeBinding) carries `mode` at the top level so the
|
||||
* frozen 10-field settings record stays exactly 10 fields, as declared
|
||||
* by the convention's §Component 3 table.
|
||||
*/
|
||||
export interface OperatorContextModeRecord {
|
||||
autonomy_scope: AutonomyScope;
|
||||
heartbeat_cadence: HeartbeatCadence;
|
||||
inspection_depth: InspectionDepth;
|
||||
update_detail: UpdateDetail;
|
||||
escalation_threshold: EscalationThreshold;
|
||||
concurrency_limit: ConcurrencyLimit;
|
||||
permission_prompt_posture: PermissionPromptPosture;
|
||||
scope: OperatorContextScope;
|
||||
expiry_or_stale_rule: ExpiryOrStaleRule;
|
||||
evidence_citation: EvidenceCitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scoped binding of a mode to a target context. The store keys rows
|
||||
* by (scope, qualifier) so the same scope can hold many bindings
|
||||
* (e.g., multiple rig-scoped modes), and the effective-mode resolver
|
||||
* picks the right one for the (rig, workstream, qitem) read context.
|
||||
*
|
||||
* - `global_host` bindings have a null qualifier.
|
||||
* - `rig` bindings carry the rig id as qualifier.
|
||||
* - `workstream` bindings carry the workstream id as qualifier.
|
||||
* - `qitem` bindings carry the qitem id as qualifier.
|
||||
*/
|
||||
export interface OperatorContextModeBinding {
|
||||
/** Stable identifier — `${scope}:${qualifier ?? "host"}` v0. */
|
||||
id: string;
|
||||
/** Component 2 — the named mode this binding selects. Lives at the
|
||||
* binding (not in the 10-field settings record) so the frozen
|
||||
* Component-3 record stays exactly 10 fields. */
|
||||
mode: OperatorContextMode;
|
||||
record: OperatorContextModeRecord;
|
||||
/** Qualifier value (rigId, workstreamId, qitemId) — null when scope is global_host. */
|
||||
qualifier: string | null;
|
||||
/** ISO timestamp of last set. Used by drift-rule consumers. */
|
||||
setAt: string;
|
||||
/** Who set it. Operator-only by contract — see update authority. */
|
||||
setBy: "operator";
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective-mode read result. The resolver returns the most
|
||||
* specific binding for a (rig?, workstream?, qitem?) context, or
|
||||
* `null` when no binding exists at any matching scope.
|
||||
*
|
||||
* Per convention §"Q6 — Absent mode is unknown_posture, NOT desk":
|
||||
* callers MUST treat a null effective mode as `unknown_posture` and
|
||||
* re-confirm to a real mode explicitly. The resolver does NOT default
|
||||
* to `desk` and never invents a binding.
|
||||
*/
|
||||
export interface EffectiveOperatorContextMode {
|
||||
binding: OperatorContextModeBinding;
|
||||
/** Why this binding won (which scope; for debug + UI surfacing). */
|
||||
resolvedScope: OperatorContextScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read context for the resolver. All fields optional; the resolver
|
||||
* picks the most-specific applicable binding.
|
||||
*/
|
||||
export interface OperatorContextReadContext {
|
||||
rigId?: string;
|
||||
workstreamId?: string;
|
||||
qitemId?: string;
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Slice 09 — runtime validator for OperatorContextModeRecord input.
|
||||
//
|
||||
// The type system (rig-policy-types.ts) blocks invalid values at
|
||||
// compile time when callers go through the typed surface. This
|
||||
// validator is the defense for inputs that bypass types: JSON file
|
||||
// reads, HTTP request bodies, env-var-derived configuration, CLI
|
||||
// argument parsing, and migrations from older record shapes.
|
||||
//
|
||||
// HG-2: every record must carry all 10 fields. Reject missing/extra.
|
||||
// HG-1 / HG-SAFE / HG-8: closed enums; auto-accept rejected; no
|
||||
// silent-switch value.
|
||||
//
|
||||
// Error format follows the 3-part convention from the velocity team:
|
||||
// what failed / what's allowed / what to do
|
||||
// so an operator gets a useful CLI message.
|
||||
|
||||
import {
|
||||
type AutonomyScope,
|
||||
type ConcurrencyLimit,
|
||||
type EscalationThreshold,
|
||||
type ExpiryOrStaleRule,
|
||||
type HeartbeatCadence,
|
||||
type InspectionDepth,
|
||||
type OperatorContextMode,
|
||||
type OperatorContextModeRecord,
|
||||
type OperatorContextScope,
|
||||
type PermissionPromptPosture,
|
||||
type UpdateDetail,
|
||||
OPERATOR_CONTEXT_MODES,
|
||||
OPERATOR_CONTEXT_SCOPES,
|
||||
SAFE_PERMISSION_PROMPT_POSTURES,
|
||||
STALE_RULES,
|
||||
} from "./rig-policy-types.js";
|
||||
|
||||
const ALLOWED_AUTONOMY_SCOPES: readonly AutonomyScope[] = [
|
||||
"pre_approved_only",
|
||||
"bounded_continuation",
|
||||
"full_autonomy_within_workstream",
|
||||
"full_autonomy",
|
||||
];
|
||||
|
||||
const ALLOWED_HEARTBEAT_CADENCES: readonly HeartbeatCadence[] = ["sparse", "normal", "fast"];
|
||||
|
||||
const ALLOWED_INSPECTION_DEPTHS: readonly InspectionDepth[] = ["surface", "normal", "forensic"];
|
||||
|
||||
const ALLOWED_UPDATE_DETAILS: readonly UpdateDetail[] = ["compact", "normal", "verbose"];
|
||||
|
||||
const ALLOWED_ESCALATION_THRESHOLDS: readonly EscalationThreshold[] = [
|
||||
"low",
|
||||
"normal",
|
||||
"high",
|
||||
"blocker_only",
|
||||
];
|
||||
|
||||
const ALLOWED_CONCURRENCY_LIMITS: readonly ConcurrencyLimit[] = ["serial", "2", "4", "unlimited"];
|
||||
|
||||
/**
|
||||
* The 10 required fields of an OperatorContextModeRecord. Used by
|
||||
* validateRecord to enumerate field-set integrity (HG-2) — both
|
||||
* presence and exhaustiveness.
|
||||
*
|
||||
* NOTE: `mode` is NOT in this list. Mode is the binding selector
|
||||
* (Component 2 vocabulary), not part of the Component-3 settings
|
||||
* record. The store/route validate mode separately at the binding
|
||||
* boundary; this validator enforces only the 10 Component-3 fields.
|
||||
*/
|
||||
export const REQUIRED_RECORD_FIELDS: readonly (keyof OperatorContextModeRecord)[] = [
|
||||
"autonomy_scope",
|
||||
"heartbeat_cadence",
|
||||
"inspection_depth",
|
||||
"update_detail",
|
||||
"escalation_threshold",
|
||||
"concurrency_limit",
|
||||
"permission_prompt_posture",
|
||||
"scope",
|
||||
"expiry_or_stale_rule",
|
||||
"evidence_citation",
|
||||
];
|
||||
|
||||
export interface ValidationOk {
|
||||
ok: true;
|
||||
record: OperatorContextModeRecord;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
ok: false;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export type ValidationResult = ValidationOk | ValidationError;
|
||||
|
||||
function threePart(failed: string, allowed: string, recovery: string): string {
|
||||
return `${failed}. Allowed: ${allowed}. ${recovery}`;
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function checkEnum<T extends string>(
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
errors: string[],
|
||||
): T | null {
|
||||
if (typeof value !== "string") {
|
||||
errors.push(
|
||||
threePart(
|
||||
`${fieldName} is not a string (got ${typeof value})`,
|
||||
allowed.join(", "),
|
||||
`Set ${fieldName} to one of the allowed values.`,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!(allowed as readonly string[]).includes(value)) {
|
||||
errors.push(
|
||||
threePart(
|
||||
`${fieldName}="${value}" is not a recognized value`,
|
||||
allowed.join(", "),
|
||||
`Set ${fieldName} to one of the allowed values.`,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a candidate record against the FROZEN contract. Returns
|
||||
* the typed record on success OR a list of 3-part error messages on
|
||||
* failure (one per offending field; report all at once so an
|
||||
* operator doesn't have to retry per error).
|
||||
*
|
||||
* Strict: any field outside REQUIRED_RECORD_FIELDS is rejected
|
||||
* (HG-2 — field-set integrity; "no merged/dropped fields").
|
||||
*/
|
||||
export function validateRecord(raw: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isPlainObject(raw)) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
threePart(
|
||||
"Record is not an object",
|
||||
"an object with the 10 required fields",
|
||||
"See conventions/operator-context-mode-system/README.md §Component 3 for the schema.",
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// HG-2 — field-set integrity. Reject missing AND extra fields. The
|
||||
// convention says "All ten fields MUST be present in any descendant
|
||||
// that publishes a current-mode artifact. None may be silently
|
||||
// merged or dropped."
|
||||
for (const field of REQUIRED_RECORD_FIELDS) {
|
||||
if (!(field in raw)) {
|
||||
errors.push(
|
||||
threePart(
|
||||
`Missing required field "${field}"`,
|
||||
REQUIRED_RECORD_FIELDS.join(", "),
|
||||
`Add the field to the record. See convention §Component 3.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const requiredSet = new Set<string>(REQUIRED_RECORD_FIELDS);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!requiredSet.has(key)) {
|
||||
errors.push(
|
||||
threePart(
|
||||
`Unknown field "${key}"`,
|
||||
REQUIRED_RECORD_FIELDS.join(", "),
|
||||
`Drop the unknown field. The schema is closed at v0; an extension requires a Mode 1.5 amendment.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we already failed field-set integrity, return early so the
|
||||
// operator sees only the structural error and not cascade per-field
|
||||
// noise.
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
|
||||
const autonomy_scope = checkEnum<AutonomyScope>(
|
||||
"autonomy_scope",
|
||||
raw["autonomy_scope"],
|
||||
ALLOWED_AUTONOMY_SCOPES,
|
||||
errors,
|
||||
);
|
||||
const heartbeat_cadence = checkEnum<HeartbeatCadence>(
|
||||
"heartbeat_cadence",
|
||||
raw["heartbeat_cadence"],
|
||||
ALLOWED_HEARTBEAT_CADENCES,
|
||||
errors,
|
||||
);
|
||||
const inspection_depth = checkEnum<InspectionDepth>(
|
||||
"inspection_depth",
|
||||
raw["inspection_depth"],
|
||||
ALLOWED_INSPECTION_DEPTHS,
|
||||
errors,
|
||||
);
|
||||
const update_detail = checkEnum<UpdateDetail>(
|
||||
"update_detail",
|
||||
raw["update_detail"],
|
||||
ALLOWED_UPDATE_DETAILS,
|
||||
errors,
|
||||
);
|
||||
const escalation_threshold = checkEnum<EscalationThreshold>(
|
||||
"escalation_threshold",
|
||||
raw["escalation_threshold"],
|
||||
ALLOWED_ESCALATION_THRESHOLDS,
|
||||
errors,
|
||||
);
|
||||
const concurrency_limit = checkEnum<ConcurrencyLimit>(
|
||||
"concurrency_limit",
|
||||
raw["concurrency_limit"],
|
||||
ALLOWED_CONCURRENCY_LIMITS,
|
||||
errors,
|
||||
);
|
||||
|
||||
// HG-SAFE (runtime) — the type system blocks auto-accept at
|
||||
// compile time; this branch rejects it at runtime for inputs that
|
||||
// bypass typing (JSON / env / HTTP body). The validator does NOT
|
||||
// tolerate any "auto*" / "yes_to_all" / etc. value; it accepts
|
||||
// EXACTLY the three SAFE values.
|
||||
const permission_prompt_posture = checkEnum<PermissionPromptPosture>(
|
||||
"permission_prompt_posture",
|
||||
raw["permission_prompt_posture"],
|
||||
SAFE_PERMISSION_PROMPT_POSTURES,
|
||||
errors,
|
||||
);
|
||||
|
||||
const scope = checkEnum<OperatorContextScope>(
|
||||
"scope",
|
||||
raw["scope"],
|
||||
OPERATOR_CONTEXT_SCOPES,
|
||||
errors,
|
||||
);
|
||||
|
||||
const expiry_or_stale_rule = checkEnum<ExpiryOrStaleRule>(
|
||||
"expiry_or_stale_rule",
|
||||
raw["expiry_or_stale_rule"],
|
||||
STALE_RULES,
|
||||
errors,
|
||||
);
|
||||
|
||||
const evidence_citation_raw = raw["evidence_citation"];
|
||||
if (typeof evidence_citation_raw !== "string") {
|
||||
errors.push(
|
||||
threePart(
|
||||
`evidence_citation is not a string (got ${typeof evidence_citation_raw})`,
|
||||
"a non-empty source-citation string (e.g., qitem id, file path, chatroom topic)",
|
||||
"Provide a brief citation per convention §Citation Rules.",
|
||||
),
|
||||
);
|
||||
} else if (evidence_citation_raw.trim().length === 0) {
|
||||
errors.push(
|
||||
threePart(
|
||||
"evidence_citation is empty",
|
||||
"a non-empty source-citation string",
|
||||
"Provide a brief citation per convention §Citation Rules.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
errors.length > 0
|
||||
|| autonomy_scope === null
|
||||
|| heartbeat_cadence === null
|
||||
|| inspection_depth === null
|
||||
|| update_detail === null
|
||||
|| escalation_threshold === null
|
||||
|| concurrency_limit === null
|
||||
|| permission_prompt_posture === null
|
||||
|| scope === null
|
||||
|| expiry_or_stale_rule === null
|
||||
) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
record: {
|
||||
autonomy_scope,
|
||||
heartbeat_cadence,
|
||||
inspection_depth,
|
||||
update_detail,
|
||||
escalation_threshold,
|
||||
concurrency_limit,
|
||||
permission_prompt_posture,
|
||||
scope,
|
||||
expiry_or_stale_rule,
|
||||
evidence_citation: (evidence_citation_raw as string).trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an operator-supplied mode name. Mode lives outside the
|
||||
* 10-field record (Component 2 vocabulary, not Component 3 settings).
|
||||
* The route + store call this at the binding boundary; the
|
||||
* disambiguator below (for invocation parsing) is separate.
|
||||
*/
|
||||
export function validateModeName(raw: unknown): { ok: true; mode: OperatorContextMode } | { ok: false; error: string } {
|
||||
if (typeof raw !== "string") {
|
||||
return { ok: false, error: threePart(`mode is not a string (got ${typeof raw})`, OPERATOR_CONTEXT_MODES.join(", "), "Provide one of the six reserved mode names.") };
|
||||
}
|
||||
if (!(OPERATOR_CONTEXT_MODES as readonly string[]).includes(raw)) {
|
||||
return { ok: false, error: threePart(`mode="${raw}" is not a recognized mode name`, OPERATOR_CONTEXT_MODES.join(", "), "Provide one of the six reserved mode names. See convention §Component 2.") };
|
||||
}
|
||||
return { ok: true, mode: raw as OperatorContextMode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disambiguate operator-supplied mode input. Per convention §Component 4
|
||||
* "Bare-Word Disambiguation":
|
||||
*
|
||||
* - A bare word that is one of the six reserved modes → invocation
|
||||
* - An explicit `mode:` prefix → invocation
|
||||
* - A word embedded in a sentence → NOT invocation (caller treats as topic)
|
||||
*
|
||||
* Returns the canonical mode name when the input is unambiguously an
|
||||
* invocation; null otherwise. Caller emits a clarification question
|
||||
* for null+bare-multi-word inputs (per convention "ask once").
|
||||
*/
|
||||
export function disambiguateModeInvocation(rawInput: string): OperatorContextMode | null {
|
||||
const trimmed = rawInput.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
// Explicit prefix wins.
|
||||
const prefixMatch = trimmed.match(/^mode\s*:\s*(\S+)/i);
|
||||
if (prefixMatch) {
|
||||
const candidate = prefixMatch[1]!.toLowerCase();
|
||||
if ((OPERATOR_CONTEXT_MODES as readonly string[]).includes(candidate)) {
|
||||
return candidate as OperatorContextMode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bare-word: exactly one word AND it's a reserved mode.
|
||||
if (/^\S+$/.test(trimmed)) {
|
||||
const lower = trimmed.toLowerCase();
|
||||
if ((OPERATOR_CONTEXT_MODES as readonly string[]).includes(lower)) {
|
||||
return lower as OperatorContextMode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Embedded — let the caller ask.
|
||||
return null;
|
||||
}
|
||||
@@ -335,7 +335,7 @@ export type MaterializeOutcome =
|
||||
export interface LaunchMaterializedNodeResult {
|
||||
logicalId: string;
|
||||
nodeId: string;
|
||||
status: "launched" | "failed";
|
||||
status: "launched" | "failed" | "attention_required";
|
||||
error?: string;
|
||||
sessionName?: string;
|
||||
}
|
||||
@@ -645,7 +645,21 @@ export class PodRigInstantiator {
|
||||
return { ok: false, code: "preflight_failed", errors: preflight.errors, warnings: preflight.warnings };
|
||||
}
|
||||
|
||||
// 3. Create rig
|
||||
// 3. Compute launch order from edges (rejects cycles)
|
||||
//
|
||||
// OPR.0.3.2.22 Bug 2: this MUST run BEFORE createRig. Pre-fix, a
|
||||
// cycle in the spec left an orphan stopped-state rig record because
|
||||
// createRig had already committed before the cycle check ran. The
|
||||
// operator-facing consequence was the "ambiguous library-spec vs
|
||||
// restore-target" UX trap on the very next `rig up <builtin>` retry.
|
||||
let launchOrder: string[];
|
||||
try {
|
||||
launchOrder = this.computePodLaunchOrder(rigSpec);
|
||||
} catch (err) {
|
||||
return { ok: false, code: "cycle_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
// 4. Create rig (after cycle check passes)
|
||||
let rigId: string;
|
||||
try {
|
||||
const rig = this.deps.rigRepo.createRig(rigSpec.name);
|
||||
@@ -659,16 +673,8 @@ export class PodRigInstantiator {
|
||||
return { ok: false, code: "instantiate_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
// 4. Compute launch order from edges (rejects cycles)
|
||||
let launchOrder: string[];
|
||||
try {
|
||||
launchOrder = this.computePodLaunchOrder(rigSpec);
|
||||
} catch (err) {
|
||||
return { ok: false, code: "cycle_error", message: (err as Error).message };
|
||||
}
|
||||
|
||||
// 5. Create pods + nodes + edges, then launch in topological order
|
||||
const nodeResults: { logicalId: string; status: "launched" | "failed"; error?: string }[] = [];
|
||||
const nodeResults: { logicalId: string; status: "launched" | "failed" | "attention_required"; error?: string; evidence?: string; sessionName?: string }[] = [];
|
||||
const nodeIdMap: Record<string, string> = {}; // "pod.member" -> node DB id
|
||||
const launchedSessionNames: string[] = []; // Track for orphan cleanup on total failure
|
||||
const podInstantiateWarnings: string[] = [];
|
||||
@@ -701,10 +707,15 @@ export class PodRigInstantiator {
|
||||
const orderMap = new Map(launchOrder.map((id, i) => [id, i]));
|
||||
memberEntries.sort((a, b) => (orderMap.get(a.qualifiedId) ?? 999) - (orderMap.get(b.qualifiedId) ?? 999));
|
||||
|
||||
// Prelaunch hook: service gate runs after topology setup, before any node launch
|
||||
// Prelaunch hook: service gate runs after topology setup, before any node launch.
|
||||
//
|
||||
// OPR.0.3.2.22 Bug 2: if the hook fails, roll back the rig record so
|
||||
// the spec name is left free for a clean retry. Pods rely on rigs
|
||||
// via ON DELETE CASCADE so deleting the rig is sufficient.
|
||||
if (opts?.prelaunchHook) {
|
||||
const hookResult = await opts.prelaunchHook(rigId);
|
||||
if (!hookResult.ok) {
|
||||
this.deps.rigRepo.deleteRig(rigId);
|
||||
return { ok: false, code: "service_boot_failed", message: hookResult.message };
|
||||
}
|
||||
}
|
||||
@@ -794,6 +805,8 @@ export class PodRigInstantiator {
|
||||
logicalId: qualifiedId,
|
||||
status: launched.status,
|
||||
error: launched.error,
|
||||
evidence: launched.evidence,
|
||||
sessionName: launched.sessionName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -821,9 +834,24 @@ export class PodRigInstantiator {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for total failure — kill orphan tmux sessions and clean up rig
|
||||
const allFailed = nodeResults.length > 0 && nodeResults.every((n) => n.status === "failed");
|
||||
if (allFailed) {
|
||||
// OPR.0.3.2.CT (conveyor-trust-minimal-fix):
|
||||
// Distinguish recoverable attention_required (e.g., workspace trust
|
||||
// gate) from terminal failure. ANY attention_required node means
|
||||
// the rig is operator-recoverable — do NOT tear down. Only when
|
||||
// every node is TERMINALLY failed (status === "failed") does the
|
||||
// tear-down run, preserving the existing failure-cleanup behavior.
|
||||
//
|
||||
// For all-attention_required (no successful launch, no terminal
|
||||
// failure), surface an attention_required outcome that carries
|
||||
// the actionable per-node detail. The route returns a 3-part
|
||||
// error pointing the operator at the approve→resume path. Sessions
|
||||
// remain in startup_status='attention_required' so `rig ps` lists
|
||||
// them and tmux panes are NOT killed.
|
||||
const hasAttention = nodeResults.some((n) => n.status === "attention_required");
|
||||
const hasLaunched = nodeResults.some((n) => n.status === "launched");
|
||||
const allTerminal = nodeResults.length > 0 && nodeResults.every((n) => n.status === "failed");
|
||||
|
||||
if (allTerminal) {
|
||||
// Kill orphan tmux sessions
|
||||
if (this.deps.tmuxAdapter) {
|
||||
for (const sessionName of launchedSessionNames) {
|
||||
@@ -835,14 +863,56 @@ export class PodRigInstantiator {
|
||||
return { ok: false, code: "instantiate_error", message: `all node launches/startups failed — ${details}` };
|
||||
}
|
||||
|
||||
if (hasAttention && !hasLaunched) {
|
||||
// All-attention_required path — rig + sessions PRESERVED; no
|
||||
// tear-down. The operator's path: attach to a session via
|
||||
// `tmux attach -t <session>` and answer the runtime prompt. NO
|
||||
// new trust primitive
|
||||
// introduced (HG-5); reuses the runtime's shipped in-pane
|
||||
// trust-grant prompt.
|
||||
const attentionNodes = nodeResults
|
||||
.filter((n) => n.status === "attention_required")
|
||||
.map((n) => ({
|
||||
logicalId: n.logicalId,
|
||||
sessionName: n.sessionName ?? "",
|
||||
evidence: n.evidence,
|
||||
reason: n.error ?? "node awaiting attention",
|
||||
}));
|
||||
// Emit rig.imported so callers see the rig exists; nodes are
|
||||
// attention_required, not "launched", so `rig ps` shows the
|
||||
// correct lifecycle state.
|
||||
try {
|
||||
this.deps.eventBus.emit({ type: "rig.imported", rigId, specName: rigSpec.name, specVersion: rigSpec.version });
|
||||
} catch { /* best-effort */ }
|
||||
return {
|
||||
ok: false,
|
||||
code: "attention_required",
|
||||
message: `${attentionNodes.length} node${attentionNodes.length === 1 ? "" : "s"} require attention before becoming interactive (rig parked, NOT failed; approve and resume to proceed).`,
|
||||
rigId,
|
||||
attentionNodes,
|
||||
};
|
||||
}
|
||||
|
||||
// Emit rig.imported
|
||||
try {
|
||||
this.deps.eventBus.emit({ type: "rig.imported", rigId, specName: rigSpec.name, specVersion: rigSpec.version });
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
// Carry sessionName + evidence through to InstantiateResult.nodes
|
||||
// so BootstrapOrchestrator can build AttentionNode[] in the mixed
|
||||
// launched+attention_required path (guard verdict
|
||||
// qitem-20260518082933 BLOCKER 1).
|
||||
const resultNodes = nodeResults.map((n) => ({
|
||||
logicalId: n.logicalId,
|
||||
status: n.status,
|
||||
error: n.error,
|
||||
sessionName: n.sessionName,
|
||||
evidence: n.evidence,
|
||||
}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: { rigId, specName: rigSpec.name, specVersion: rigSpec.version, nodes: nodeResults, warnings: podInstantiateWarnings.length > 0 ? podInstantiateWarnings : undefined },
|
||||
result: { rigId, specName: rigSpec.name, specVersion: rigSpec.version, nodes: resultNodes, warnings: podInstantiateWarnings.length > 0 ? podInstantiateWarnings : undefined },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -986,7 +1056,7 @@ export class PodRigInstantiator {
|
||||
cwdOverride?: string;
|
||||
resolveResult?: ReturnType<typeof resolveAgentRef> extends infer T ? T : never;
|
||||
configResult?: ReturnType<typeof resolveNodeConfig> extends infer T ? T : never;
|
||||
}): Promise<{ status: "launched" | "failed"; error?: string; sessionName?: string; warnings?: string[] }> {
|
||||
}): Promise<{ status: "launched" | "failed" | "attention_required"; error?: string; evidence?: string; sessionName?: string; warnings?: string[] }> {
|
||||
const resolveResult = input.resolveResult ?? resolveAgentRef(input.member.agentRef, input.rigRoot, this.deps.fsOps);
|
||||
if (!resolveResult.ok) {
|
||||
const msg = resolveResult.code === "validation_failed"
|
||||
@@ -1018,7 +1088,16 @@ export class PodRigInstantiator {
|
||||
}
|
||||
|
||||
const canonicalSessionName = deriveCanonicalSessionName(input.pod.id, input.member.id, input.rigSpec.name);
|
||||
const launchResult = await this.deps.nodeLauncher.launchNode(input.rigId, input.qualifiedId, { sessionName: canonicalSessionName });
|
||||
// Slice 15 — forward per-seat silenceWindowSeconds from the resolved
|
||||
// profile.activity block. NodeLauncher applies the launcher default
|
||||
// (3s) when this is undefined. The seat's tmux monitor-silence is
|
||||
// configured immediately after createSession; SeatActivityService
|
||||
// then reads window_activity to compute terminalActive against this
|
||||
// threshold per poll.
|
||||
const launchResult = await this.deps.nodeLauncher.launchNode(input.rigId, input.qualifiedId, {
|
||||
sessionName: canonicalSessionName,
|
||||
silenceWindowSeconds: configResult.config.activity?.silenceWindowSeconds,
|
||||
});
|
||||
if (!launchResult.ok) {
|
||||
return { status: "failed", error: launchResult.message };
|
||||
}
|
||||
@@ -1095,7 +1174,8 @@ export class PodRigInstantiator {
|
||||
} else if (input.member.sessionSource?.mode === "agent_image") {
|
||||
// PL-016 Item 4: agent_image → resolve via library + dispatch
|
||||
// through the native-fork code path so nativeResumeProbe
|
||||
// semantics are preserved (architecture.md § Resume honesty).
|
||||
// semantics are preserved
|
||||
// (docs/as-built/architecture/adapters-and-runtimes.md § Resume honesty).
|
||||
const library = this.deps.agentImageLibrary;
|
||||
if (!library) {
|
||||
return {
|
||||
@@ -1205,9 +1285,22 @@ export class PodRigInstantiator {
|
||||
}
|
||||
}
|
||||
|
||||
// OPR.0.3.2.CT — preserve the attention_required distinction so
|
||||
// the instantiator can leave a recoverable rig + sessions instead
|
||||
// of zero-collapsing. The session row's startup_status was already
|
||||
// persisted by startupOrchestrator.fail() (see startup-orchestrator.ts
|
||||
// lines 223-225, 241-243).
|
||||
if (startupResult.ok) {
|
||||
return {
|
||||
status: "launched",
|
||||
sessionName: canonicalSessionName,
|
||||
warnings: launchResult.warnings,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: startupResult.ok ? "launched" : "failed",
|
||||
error: startupResult.ok ? undefined : startupResult.errors.join("; "),
|
||||
status: startupResult.startupStatus === "attention_required" ? "attention_required" : "failed",
|
||||
error: startupResult.errors.join("; "),
|
||||
evidence: startupResult.evidence,
|
||||
sessionName: canonicalSessionName,
|
||||
warnings: launchResult.warnings,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { SeatActivity } from "./types.js";
|
||||
|
||||
/** Default polling cadence: 1Hz. The default silence window is 3s, so
|
||||
* 1Hz polling gives at-most ~1s freshness lag on the cached observation. */
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/**
|
||||
* Slice 15 — daemon owner of the `terminal-active` primitive.
|
||||
*
|
||||
* Polls tmux's `#{window_activity}` last-activity timestamp per seat
|
||||
* (via TmuxAdapter.readPaneLastActivity) and keeps the latest
|
||||
* observation keyed by canonical session name. Active/idle is derived
|
||||
* by comparing the observed timestamp's age against the silence-window
|
||||
* threshold. Downstream consumers (ps-projection, node-inventory, UI
|
||||
* hooks) read via `getSeatActivity(canonicalSessionName)`.
|
||||
*
|
||||
* Non-inference contract (slice 15 IMPL-PRD §2.3, HG-4): this service
|
||||
* NEVER reads queue/assignment state. Its constructor surface
|
||||
* intentionally rejects any queue/assignment-shaped dependency so a
|
||||
* future contributor cannot wire one in without first amending the
|
||||
* design. The companion `hasAssignedWork` primitive lives in the
|
||||
* ps/queue projection and never imports this service either.
|
||||
*/
|
||||
export interface SeatActivityServiceDeps {
|
||||
tmux: Pick<TmuxAdapter, "readPaneLastActivity">;
|
||||
defaultWindowSeconds: number;
|
||||
eventBus?: EventBus;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface PollSeatOptions {
|
||||
silenceWindowSeconds?: number;
|
||||
}
|
||||
|
||||
export class SeatActivityService {
|
||||
private readonly tmux: Pick<TmuxAdapter, "readPaneLastActivity">;
|
||||
private readonly defaultWindowSeconds: number;
|
||||
private readonly eventBus: EventBus | null;
|
||||
private readonly now: () => Date;
|
||||
private readonly latestByPaneId = new Map<string, SeatActivity>();
|
||||
|
||||
constructor(deps: SeatActivityServiceDeps) {
|
||||
this.tmux = deps.tmux;
|
||||
this.defaultWindowSeconds = deps.defaultWindowSeconds;
|
||||
this.eventBus = deps.eventBus ?? null;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the window's last-activity timestamp for `paneId` once and
|
||||
* record an `isActiveWithinWindow` observation by comparing it to
|
||||
* the configured silence window.
|
||||
*
|
||||
* Returns the new SeatActivity record, or null when no signal is
|
||||
* available (transient tmux error, blank/unparseable timestamp).
|
||||
*
|
||||
* Slice 15 BLOCKING-fix: pivoted from reading the runtime's
|
||||
* `pane_silence_flag` (observed blank on tmux 3.6a, sticky-alert
|
||||
* behavior on others) to computing active/idle ourselves from
|
||||
* `window_activity` — the same timestamp tmux's own status-line
|
||||
* activity indicators consult.
|
||||
*/
|
||||
async pollSeat(paneId: string, opts?: PollSeatOptions): Promise<SeatActivity | null> {
|
||||
const silenceWindowSeconds = opts?.silenceWindowSeconds ?? this.defaultWindowSeconds;
|
||||
let lastActivityEpochSeconds: number | null = null;
|
||||
try {
|
||||
lastActivityEpochSeconds = await this.tmux.readPaneLastActivity(paneId);
|
||||
} catch {
|
||||
lastActivityEpochSeconds = null;
|
||||
}
|
||||
if (lastActivityEpochSeconds === null) return null;
|
||||
|
||||
const observedAt = this.now();
|
||||
const ageSeconds = observedAt.getTime() / 1000 - lastActivityEpochSeconds;
|
||||
// Active when the most recent activity is within the silence window.
|
||||
// Negative ageSeconds (clock skew) defensively reads as active too —
|
||||
// it means tmux reports activity in the (very near) future, which
|
||||
// happens when the daemon's monotonic clock lags briefly.
|
||||
const isActiveWithinWindow = ageSeconds < silenceWindowSeconds;
|
||||
|
||||
const record: SeatActivity = {
|
||||
paneId,
|
||||
isActiveWithinWindow,
|
||||
silenceWindowSeconds,
|
||||
lastObservedAt: observedAt.toISOString(),
|
||||
};
|
||||
this.latestByPaneId.set(paneId, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the latest stored observation for a seat, or null when no
|
||||
* observation has been recorded yet (e.g. service hasn't polled this
|
||||
* seat). Distinct from `isActiveWithinWindow: false`.
|
||||
*/
|
||||
getSeatActivity(paneId: string): SeatActivity | null {
|
||||
return this.latestByPaneId.get(paneId) ?? null;
|
||||
}
|
||||
|
||||
/** Drop the latest stored observation for a seat (used on seat teardown). */
|
||||
forgetSeat(paneId: string): void {
|
||||
this.latestByPaneId.delete(paneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — refresh observations for every running tmux-bound seat.
|
||||
* Drives the per-tick cadence from `start(intervalMs, db)`; callers
|
||||
* can also invoke directly for tests or one-shot refresh.
|
||||
*/
|
||||
async pollAllRunningTmuxSeats(db: Database.Database): Promise<void> {
|
||||
const rows = db.prepare(`
|
||||
SELECT s.session_name as session_name
|
||||
FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
LEFT JOIN bindings b ON b.node_id = n.id
|
||||
WHERE s.status = 'running'
|
||||
AND s.session_name IS NOT NULL
|
||||
AND COALESCE(b.attachment_type, 'tmux') = 'tmux'
|
||||
`).all() as Array<{ session_name: string }>;
|
||||
|
||||
// Drop observations for seats that are no longer running (release
|
||||
// memory + avoid stale reads from `getSeatActivity`).
|
||||
const live = new Set(rows.map((r) => r.session_name));
|
||||
for (const pane of Array.from(this.latestByPaneId.keys())) {
|
||||
if (!live.has(pane)) this.latestByPaneId.delete(pane);
|
||||
}
|
||||
|
||||
// Best-effort: a single seat's failure does not crash the loop.
|
||||
await Promise.all(rows.map(async (r) => {
|
||||
try { await this.pollSeat(r.session_name); } catch { /* swallow */ }
|
||||
}));
|
||||
}
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Start the scheduler. Polls every running tmux-bound seat once per
|
||||
* `intervalMs`. Idempotent — calling twice is a no-op.
|
||||
*/
|
||||
start(db: Database.Database, intervalMs: number = DEFAULT_POLL_INTERVAL_MS): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => {
|
||||
void this.pollAllRunningTmuxSeats(db);
|
||||
}, intervalMs);
|
||||
if (this.timer && typeof this.timer === "object" && "unref" in this.timer) {
|
||||
(this.timer as NodeJS.Timeout).unref();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the scheduler. Safe to call before start or multiple times. */
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,11 @@ export interface SliceListEntry {
|
||||
workflowSpec: WorkflowSpecRef | null;
|
||||
status: SliceStatus;
|
||||
rawStatus: string | null;
|
||||
/** OPR.0.3.2.17 — frontmatter `description` / `summary` from the
|
||||
* slice's primary doc (README/IMPLEMENTATION-PRD/PROGRESS). Used by
|
||||
* the storytelling adapter as the ConceptCard.oneLiner for
|
||||
* `rawStatus === "candidate"` slices. null when absent. */
|
||||
description: string | null;
|
||||
qitemCount: number;
|
||||
hasProofPacket: boolean;
|
||||
lastActivityAt: string | null;
|
||||
@@ -299,6 +304,12 @@ export class SliceIndexer {
|
||||
workflowSpec: parseWorkflowSpecRef(frontmatter["workflow_spec"]),
|
||||
status,
|
||||
rawStatus: (frontmatter["status"] as string | undefined) ?? null,
|
||||
// OPR.0.3.2.17 — surface frontmatter `description` (or `summary`
|
||||
// fallback) so the storytelling adapter can use it as the
|
||||
// ConceptCard.oneLiner without a separate detail fetch. The
|
||||
// detail endpoint stays the source of truth for full body
|
||||
// content; this is the short-form summary mirror.
|
||||
description: extractDescription(frontmatter),
|
||||
qitemCount: qitemIds.length,
|
||||
hasProofPacket: proofPacket !== null,
|
||||
lastActivityAt,
|
||||
@@ -670,6 +681,23 @@ export function parseFrontmatter(content: string): Record<string, unknown> {
|
||||
* missing, non-string, or doesn't match the `<name>@<version>` shape.
|
||||
* Exported so the missions route can reuse the same parser on the
|
||||
* mission README's frontmatter. */
|
||||
/**
|
||||
* OPR.0.3.2.17 — extract a short description from slice frontmatter.
|
||||
* Tries `description` first, then `summary`. Returns null when both
|
||||
* are absent or non-string. Whitespace trimmed; empty strings become
|
||||
* null so the adapter's graceful-empty fallback fires.
|
||||
*/
|
||||
export function extractDescription(frontmatter: Record<string, unknown>): string | null {
|
||||
for (const key of ["description", "summary"]) {
|
||||
const v = frontmatter[key];
|
||||
if (typeof v === "string") {
|
||||
const trimmed = v.trim();
|
||||
if (trimmed.length > 0) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseWorkflowSpecRef(raw: unknown): WorkflowSpecRef | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
|
||||
@@ -49,6 +49,24 @@ function isYamlFile(filename: string): boolean {
|
||||
return filename.endsWith(".yaml") || filename.endsWith(".yml");
|
||||
}
|
||||
|
||||
// OPR.0.3.2.22 Bug 4 — noise directories that should never enter the
|
||||
// spec library walk. The set matches progress-indexer.ts:81 exactly.
|
||||
// frontmatter-validator.ts:103 is a related scanner that uses a
|
||||
// narrower subset (node_modules / .git / .worktrees / dist / build);
|
||||
// if this list and the progress-indexer list ever diverge, reconcile
|
||||
// there first. The `.worktrees` entry closes the stale-row class
|
||||
// where conveyor.yaml inside a worktree was sliding into the library
|
||||
// cache and producing the `rig specs show conveyor` ambiguous-match UX.
|
||||
const SKIP_DIRS = new Set([
|
||||
".worktrees",
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".turbo",
|
||||
".next",
|
||||
]);
|
||||
|
||||
function walkYamlFiles(rootPath: string): string[] {
|
||||
const files: string[] = [];
|
||||
const stack = [rootPath];
|
||||
@@ -65,6 +83,7 @@ function walkYamlFiles(rootPath: string): string[] {
|
||||
for (const entry of entries) {
|
||||
const absPath = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
stack.push(absPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -422,6 +422,34 @@ export interface NodeInventoryEntry {
|
||||
startupCompletedAt: string | null;
|
||||
agentActivity?: AgentActivity;
|
||||
contextUsage?: ContextUsage;
|
||||
/**
|
||||
* Slice 15 — `terminal-active` primitive (tmux byte-stream).
|
||||
*
|
||||
* true → seat is currently producing tmux output (pane silence
|
||||
* flag = 0, within configured silence window)
|
||||
* false → seat is silent past the threshold
|
||||
* null → no signal (not tmux-bound, monitor-silence not configured,
|
||||
* or transient read error). Distinct from `false`: consumers
|
||||
* treat `null` as "no observation right now", not "definitely
|
||||
* idle".
|
||||
*
|
||||
* MUST NOT be derived from `hasAssignedWork` or queue/assignment state.
|
||||
* The non-inference contract (slice 15 README + IMPL-PRD §2.3) is the
|
||||
* core correctness item: this field is computed independently from
|
||||
* `hasAssignedWork`.
|
||||
*/
|
||||
terminalActive?: boolean | null;
|
||||
/**
|
||||
* Slice 15 — `has-work-to-do` primitive. Derived from queue/assignment
|
||||
* projection (pending qitems whose `destination_session` matches this
|
||||
* seat's `canonicalSessionName`).
|
||||
*
|
||||
* MUST NOT be derived from `terminalActive` or tmux output. Two
|
||||
* orthogonal primitives, never one inferred from the other.
|
||||
*/
|
||||
hasAssignedWork?: boolean;
|
||||
/** Optional count of pending qitems assigned to this seat (cheap aggregate). */
|
||||
pendingWorkCount?: number;
|
||||
/** PL-007: per-node workspace block when the rig declares a workspace.
|
||||
* workspaceRoot mirrors RigSpec.workspace.workspaceRoot. activeRepo is
|
||||
* the repo whose path contains the node's cwd, or RigSpec.workspace.
|
||||
@@ -431,6 +459,29 @@ export interface NodeInventoryEntry {
|
||||
workspace?: NodeWorkspaceInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice 15 — `terminal-active` observation for a single seat's pane.
|
||||
*
|
||||
* Sourced from tmux's `#{window_activity}` format (Unix-epoch-seconds
|
||||
* timestamp of the last output on the window). The producing service
|
||||
* polls the timestamp at a configurable cadence and compares it
|
||||
* against the silence window threshold; `isActiveWithinWindow` is true
|
||||
* when the most recent activity is within that window. The
|
||||
* `monitor-silence` option is configured on the seat's window at
|
||||
* launch time for tmux's own status-line + bell-alert behavior, but is
|
||||
* not the read source.
|
||||
*/
|
||||
export interface SeatActivity {
|
||||
/** tmux pane id OR canonical session name — whatever the daemon binds. */
|
||||
paneId: string;
|
||||
/** True ⟺ pane has produced output within `silenceWindowSeconds`. */
|
||||
isActiveWithinWindow: boolean;
|
||||
/** Configured threshold at observation time. */
|
||||
silenceWindowSeconds: number;
|
||||
/** ISO timestamp of the most recent observation. */
|
||||
lastObservedAt: string;
|
||||
}
|
||||
|
||||
export interface NodeWorkspaceInfo {
|
||||
workspaceRoot: string;
|
||||
activeRepo: string | null;
|
||||
@@ -554,6 +605,18 @@ export interface ProfileSpec {
|
||||
plugins: string[];
|
||||
runtimeResources: string[];
|
||||
};
|
||||
/**
|
||||
* Slice 15 — per-seat activity-detection tuning. The daemon configures
|
||||
* tmux's `monitor-silence` option for the seat's pane at seat-up.
|
||||
* `silenceWindowSeconds` is the integer threshold (in seconds) below
|
||||
* which an output-producing pane reads as "terminal-active". When
|
||||
* omitted, the daemon's default (3 seconds per slice 15 README) is
|
||||
* applied. Invalid values (non-integer, outside [1, 3600]) are
|
||||
* dropped at normalize time and the default is used.
|
||||
*/
|
||||
activity?: {
|
||||
silenceWindowSeconds?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentSpec {
|
||||
@@ -763,7 +826,7 @@ export interface WorkspaceSpec {
|
||||
workspaceRoot: string;
|
||||
repos: WorkspaceRepoSpec[];
|
||||
defaultRepo?: string;
|
||||
/** Optional knowledge-canon root (e.g., substrate/shared-docs/openrig-work).
|
||||
/** Optional knowledge-canon root (e.g., a shared docs repo path).
|
||||
* Treated as kind=knowledge when surfaced through whoami / UI. */
|
||||
knowledgeRoot?: string;
|
||||
}
|
||||
@@ -874,13 +937,51 @@ export type InstantiateOutcome =
|
||||
| { ok: false; code: "preflight_failed"; errors: string[]; warnings: string[] }
|
||||
| { ok: false; code: "instantiate_error"; message: string }
|
||||
| { ok: false; code: "cycle_error"; message: string }
|
||||
| { ok: false; code: "service_boot_failed"; message: string };
|
||||
| { ok: false; code: "service_boot_failed"; message: string }
|
||||
// OPR.0.3.2.CT (conveyor-trust-minimal-fix):
|
||||
// When every launched node reaches a recoverable attention_required
|
||||
// state (e.g., workspace-trust prompt), do NOT tear the rig down.
|
||||
// The rig + sessions are preserved, listable, and approvable; the
|
||||
// operator's path is "approve trust → resume". `attentionNodes`
|
||||
// carries the actionable per-node detail for the route's 3-part
|
||||
// error response.
|
||||
| {
|
||||
ok: false;
|
||||
code: "attention_required";
|
||||
message: string;
|
||||
rigId: string;
|
||||
attentionNodes: AttentionNode[];
|
||||
};
|
||||
|
||||
export interface AttentionNode {
|
||||
logicalId: string;
|
||||
sessionName: string;
|
||||
evidence?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface InstantiateResult {
|
||||
rigId: string;
|
||||
specName: string;
|
||||
specVersion: string;
|
||||
nodes: { logicalId: string; status: "launched" | "failed"; error?: string }[];
|
||||
// Per-node startup status — `launched` and `failed` are the legacy
|
||||
// terminal states; `attention_required` (OPR.0.3.2.CT) marks a
|
||||
// recoverable parked node awaiting operator action (e.g., trust
|
||||
// approval). The session row's startup_status carries the same
|
||||
// signal so `rig ps` surfaces it.
|
||||
//
|
||||
// `sessionName` + `evidence` carry runtime detail needed by
|
||||
// BootstrapOrchestrator to construct AttentionNode[] for the mixed
|
||||
// launched+attention_required path (the route's 3-part error
|
||||
// response needs sessionName for the tmux-attach hint). Optional
|
||||
// because legacy callers don't supply them on terminal-only paths.
|
||||
nodes: {
|
||||
logicalId: string;
|
||||
status: "launched" | "failed" | "attention_required";
|
||||
error?: string;
|
||||
sessionName?: string;
|
||||
evidence?: string;
|
||||
}[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
@@ -922,7 +1023,11 @@ export interface ExpansionRequest {
|
||||
export interface ExpansionNodeOutcome {
|
||||
logicalId: string;
|
||||
nodeId: string;
|
||||
status: "launched" | "failed";
|
||||
// OPR.0.3.2.CT — `attention_required` is a recoverable parked state
|
||||
// (e.g., workspace-trust prompt awaiting operator action). The
|
||||
// session row's startup_status carries the same signal so `rig ps`
|
||||
// surfaces it. Distinct from `failed` (terminal).
|
||||
status: "launched" | "failed" | "attention_required";
|
||||
error?: string;
|
||||
sessionName?: string;
|
||||
}
|
||||
|
||||
@@ -326,6 +326,46 @@ export class WorkflowSpecCache {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.2.22 Bug 4 — prune cached rows whose source_path lives in
|
||||
* noise directories that the post-Bug-4 walkYamlFiles SKIP_DIRS
|
||||
* guard now refuses to scan. Without this prune, rows that were
|
||||
* inserted before the SKIP_DIRS guard landed would survive forever
|
||||
* (the scanner cleanup at spec-library-workflow-scanner.ts only
|
||||
* fires for paths starting with the workspace `workflows/` folder
|
||||
* prefix). Called once at startup. Returns the number of rows
|
||||
* removed.
|
||||
*
|
||||
* installRoot guard (Bug 4 follow-up): when supplied, rows whose
|
||||
* source_path starts with the install root are PRESERVED even if
|
||||
* they match a noise pattern. This is the load-bearing safety for
|
||||
* shipped built-in workflow specs that live at
|
||||
* `<pkg>/dist/builtins/workflow-specs/` in production npm-published
|
||||
* daemons — without this guard the unscoped DELETE would nuke
|
||||
* every shipped built-in on every boot. Pass
|
||||
* `getOpenRigInstallRoot()` from cwd-resolution at the call site.
|
||||
* When omitted, no install-root preservation is applied (test-only
|
||||
* convenience for tests that operate fully outside any install).
|
||||
*/
|
||||
pruneNoiseDirRows(installRoot?: string): number {
|
||||
const guardClause = installRoot ? ` AND source_path NOT LIKE ? || '%'` : "";
|
||||
const params: string[] = installRoot ? [installRoot] : [];
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`DELETE FROM workflow_specs WHERE (
|
||||
source_path LIKE '%/.worktrees/%'
|
||||
OR source_path LIKE '%/node_modules/%'
|
||||
OR source_path LIKE '%/.git/%'
|
||||
OR source_path LIKE '%/dist/%'
|
||||
OR source_path LIKE '%/build/%'
|
||||
OR source_path LIKE '%/.turbo/%'
|
||||
OR source_path LIKE '%/.next/%'
|
||||
)${guardClause}`,
|
||||
)
|
||||
.run(...params);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
getByIdOrThrow(specId: string): WorkflowSpecRow {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM workflow_specs WHERE spec_id = ?`)
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// V0.3.1 slice 21 onboarding-conveyor: rich narrative content for the
|
||||
// getting-started mission's two slices (the click-through-to-learn
|
||||
// teaching surface).
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { GETTING_STARTED_NARRATIVE } from "./getting-started-narrative.js";
|
||||
|
||||
type DefaultSlice = {
|
||||
id: string;
|
||||
/** §1 dot-ID per conventions/scope-and-versioning. Minted into the
|
||||
* slice README frontmatter so the seeded scaffold satisfies the
|
||||
* GA-convention coherence target without needing to round-trip
|
||||
* through `rig scope`. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
@@ -12,27 +19,37 @@ type DefaultSlice = {
|
||||
|
||||
type DefaultMission = {
|
||||
id: string;
|
||||
/** §1 mission dot-ID — escape band for non-release missions. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
slices: DefaultSlice[];
|
||||
};
|
||||
|
||||
// Release-0.3.2 slice 01 (OPR.0.3.2.1) — seeded missions/slices now
|
||||
// carry §1 dot-IDs in frontmatter so the scaffold coheres with
|
||||
// `conventions/scope-and-versioning`. `getting-started` is a
|
||||
// non-release mission → escape band `OPR.99.0.1`; its slices fan out
|
||||
// as `OPR.99.0.1.<n>` (numbers monotonic, never reused).
|
||||
const DEFAULT_MISSIONS: DefaultMission[] = [
|
||||
{
|
||||
id: "getting-started",
|
||||
dotId: "OPR.99.0.1",
|
||||
title: "Getting Started",
|
||||
status: "active",
|
||||
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
|
||||
slices: [
|
||||
{
|
||||
id: "first-conveyor-run",
|
||||
dotId: "OPR.99.0.1.1",
|
||||
title: "First Conveyor Run",
|
||||
status: "active",
|
||||
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
|
||||
},
|
||||
{
|
||||
id: "inspect-project-evidence",
|
||||
dotId: "OPR.99.0.1.2",
|
||||
title: "Inspect Project Evidence",
|
||||
status: "draft",
|
||||
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
|
||||
@@ -61,7 +78,7 @@ export function workspaceScaffoldDirs(): string[] {
|
||||
function subdirReadmeContent(subdir: string): string {
|
||||
switch (subdir) {
|
||||
case "missions":
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-name>/README.md\nmissions/<mission-name>/PROGRESS.md\nmissions/<mission-name>/slices/<slice-name>/README.md\n```\n\nEvery mission and slice README carries a stable dot-ID in its frontmatter (`id: OPR.<ver>[.<n>]`) per the scope-and-versioning convention. Use `rig scope mission create <name>` and `rig scope slice create <mission> <slug>` to mint conformant artifacts; the CLI handles the dot-ID + auto-numbering for you. The folder name is the operator-facing slug; the `id:` is the stable handle that survives renames.\n";
|
||||
case "artifacts":
|
||||
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
|
||||
case "evidence":
|
||||
@@ -90,7 +107,7 @@ This workspace is file-backed. The Project UI mirrors this structure:
|
||||
- \`evidence/\` is for proof notes and verification summaries.
|
||||
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
|
||||
|
||||
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
|
||||
Use stable kebab-case names for mission and slice folders. Every mission/slice README also carries a stable dot-ID in its frontmatter (\`id: OPR.<ver>[.<n>]\`) per the \`scope-and-versioning\` convention; mint conformant artifacts via \`rig scope mission create <name>\` and \`rig scope slice create <mission> <slug>\`. Folder names are operator-facing slugs; the \`id:\` is the rename-proof handle.
|
||||
`;
|
||||
|
||||
const STEERING_PLACEHOLDER = `---
|
||||
@@ -121,6 +138,7 @@ alongside your Project workspace.
|
||||
|
||||
function missionReadme(mission: DefaultMission): string {
|
||||
return `---
|
||||
id: ${mission.dotId}
|
||||
title: ${mission.title}
|
||||
status: ${mission.status}
|
||||
mission: ${mission.id}
|
||||
@@ -157,6 +175,7 @@ function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
|
||||
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
|
||||
if (narrative) {
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -167,6 +186,7 @@ slice: ${slice.id}
|
||||
${narrative.readme}`;
|
||||
}
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -244,6 +264,145 @@ ${slice.objective}
|
||||
`;
|
||||
}
|
||||
|
||||
// FR-5e A1 (daemon-side symmetry) — MISSION_NOTES template +
|
||||
// rendering for the daemon's `/api/config/init-workspace` route so
|
||||
// UI-driven init lands the same scaffold as CLI-driven init. The
|
||||
// template content is duplicated here from
|
||||
// packages/cli/src/lib/scope-templates/mission-notes.md to keep the
|
||||
// daemon side dependency-free of the cli package; a parity test in
|
||||
// getting-started-narrative-parity.test.ts asserts CLI + daemon emit
|
||||
// byte-identical MISSION_NOTES content given the same opts. Same
|
||||
// env-var-pivot pattern the CLI surface honors:
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH > built-in.
|
||||
const MISSION_NOTES_BUILT_IN = `---
|
||||
mission: {{mission_id}}
|
||||
name: {{mission_name}}
|
||||
status: active
|
||||
authored: {{created_date}}
|
||||
last-updated: {{created_date}}
|
||||
---
|
||||
|
||||
# Mission Notes — {{mission_name}}
|
||||
|
||||
> Read the frontmatter \`last-updated\` first. If stale relative to runtime,
|
||||
> treat this file as \`provisional\` and re-verify before relying on it.
|
||||
> Pointer-first by design — index durable canonical artifacts; don't duplicate
|
||||
> them (duplication is the stale-breadcrumb failure mode this convention
|
||||
> exists to kill).
|
||||
|
||||
## §1. Top-of-mind context
|
||||
|
||||
(Current most-load-bearing state — gates, open scope-decisions, in-flight
|
||||
surprises. Updated by anyone when material changes. Keep to 5-15 lines.)
|
||||
|
||||
## §2. Active workstreams + drivers + tips
|
||||
|
||||
(Per-workstream rows. THIS SECTION IS THE MISSION CODEMAP. Each row:
|
||||
state / driver / branch+tip-SHA / latest blocker or ACK.)
|
||||
|
||||
- (workstream-id) — state — driver — tip-SHA — blocker/ACK
|
||||
|
||||
## §3. Open scope-decisions
|
||||
|
||||
(Labeled gates the operator owns. Per gate: where the gate is, what's needed,
|
||||
who routed.)
|
||||
|
||||
- OQ-1: (decision needed) — gate at <artifact-path> — surfaced by <seat>
|
||||
|
||||
## §4. Slice inventory
|
||||
|
||||
(Full table of slices on this mission.)
|
||||
|
||||
| # | slice | state | driver | blocker |
|
||||
|---|---|---|---|---|
|
||||
|
||||
## §5. Pending dispatches
|
||||
|
||||
(Next-go items waiting on driver availability or convergence. Immediately-ready
|
||||
set, not full backlog.)
|
||||
|
||||
## §6. Ledgers
|
||||
|
||||
(Mission-specific shared ledgers: release version + tag, SC counts, cumulative
|
||||
deltas, etc.)
|
||||
|
||||
## §7. Banked memories that apply here
|
||||
|
||||
(Bulleted: banked-feedback / banked-project memories load-bearing on restore
|
||||
for this mission. Ones learned during this mission go into the same list.)
|
||||
|
||||
## §8. Convention pointers
|
||||
|
||||
(Which banked conventions this mission inherits. Cross-reference each by path.
|
||||
This section is the principles/conventions inheritance surface for the mission.)
|
||||
|
||||
- \`conventions/mission-notes/README.md\` — this convention (apply on every restore)
|
||||
|
||||
## §9. Reconstruction protocol
|
||||
|
||||
(Commands to run after compaction or for onboarding. Refresh as the mission's
|
||||
restore-needs evolve.)
|
||||
|
||||
1. \`rig whoami --json\` — confirm seat identity
|
||||
2. Read this file's §1 + your §A-§X section + this §9
|
||||
3. Read \`missions/{{mission_id}}/README.md\` for mission scope
|
||||
4. Read \`missions/{{mission_id}}/PROGRESS.md\` for delivery state
|
||||
5. \`rig queue list --destination <your-session>\` — your durable inbox
|
||||
6. State "restored from {{mission_id}}; resumed at <action>" before acting
|
||||
|
||||
## §10. What NOT to reconstruct
|
||||
|
||||
(Explicit pruning — completed slices / merged commits / closed ACK packets are
|
||||
recoverable on demand and don't need to be in working memory. Name them to
|
||||
free attention.)
|
||||
|
||||
## §A. <first-seat>@<rig> notes
|
||||
|
||||
(First seat's notes. Pattern: append cont.N entries with date + state of the
|
||||
world from this seat's perspective. Latest = truth. Other seats READ but
|
||||
don't modify.)
|
||||
|
||||
DONE {{created_date}} (cont.0 — mission scaffolded):
|
||||
- Mission scaffolded via \`rig scope mission create\`. Template applied; this
|
||||
file is the seed state. First substantive cont.1 entry follows when work
|
||||
begins.
|
||||
|
||||
## §B / §C / ... — per-seat sections
|
||||
|
||||
(Each additional seat adds an h2 ## §X section under their own header as they
|
||||
join. Section pattern matches §A.)
|
||||
`;
|
||||
|
||||
interface MissionNotesOpts {
|
||||
mission_id: string;
|
||||
mission_name: string;
|
||||
created_date: string;
|
||||
}
|
||||
|
||||
function applyMissionNotesPlaceholders(content: string, opts: MissionNotesOpts): string {
|
||||
return content
|
||||
.replace(/\{\{mission_id\}\}/g, opts.mission_id)
|
||||
.replace(/\{\{mission_name\}\}/g, opts.mission_name)
|
||||
.replace(/\{\{created_date\}\}/g, opts.created_date);
|
||||
}
|
||||
|
||||
export function renderDaemonMissionNotes(opts: MissionNotesOpts): string {
|
||||
const envValue = process.env.OPENRIG_MISSION_NOTES_TEMPLATE_PATH;
|
||||
if (envValue && envValue.trim().length > 0) {
|
||||
const absPath = path.resolve(envValue.trim());
|
||||
if (!fs.existsSync(absPath)) {
|
||||
throw new Error(
|
||||
`OPENRIG_MISSION_NOTES_TEMPLATE_PATH points at "${envValue}", which does not exist. ` +
|
||||
"MISSION_NOTES.md not scaffolded. Set it to an absolute path to a readable template file, " +
|
||||
"or unset it to use the built-in bundled fallback.",
|
||||
);
|
||||
}
|
||||
const raw = fs.readFileSync(absPath, "utf8");
|
||||
return applyMissionNotesPlaceholders(raw, opts);
|
||||
}
|
||||
return applyMissionNotesPlaceholders(MISSION_NOTES_BUILT_IN, opts);
|
||||
}
|
||||
|
||||
export function workspaceScaffoldFiles(): Array<{ relPath: string; content: string }> {
|
||||
const files: Array<{ relPath: string; content: string }> = [
|
||||
{ relPath: "README.md", content: WORKSPACE_README },
|
||||
@@ -260,6 +419,18 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri
|
||||
files.push(
|
||||
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
|
||||
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
|
||||
// FR-5e A1 (daemon-side symmetry) — match CLI scaffold so UI-init
|
||||
// produces a workspace that passes doctor check #7. The template
|
||||
// resolution + env-var-pivot mirrors the CLI surface;
|
||||
// getting-started-narrative-parity.test.ts asserts byte-identity.
|
||||
{
|
||||
relPath: `missions/${mission.id}/MISSION_NOTES.md`,
|
||||
content: renderDaemonMissionNotes({
|
||||
mission_id: mission.dotId,
|
||||
mission_name: mission.title,
|
||||
created_date: new Date().toISOString().slice(0, 10),
|
||||
}),
|
||||
},
|
||||
);
|
||||
for (const slice of mission.slices) {
|
||||
files.push(
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
// Slice-21 FR-5 — workspace doctor readiness checks for `rig workspace doctor`.
|
||||
//
|
||||
// 7 checks (this file ships pure check helpers; daemon route + CLI
|
||||
// subcommand wiring land in follow-on commits):
|
||||
// 1. Workspace root reachable (env > file > default precedence)
|
||||
// 2. Missions folder present
|
||||
// 3. File allowlist sane (named-pair ConfigStore key, NOT a file)
|
||||
// 4. Daemon points at this workspace
|
||||
// 5. Daemon reload needed
|
||||
// 6. Optional slice docs (warn-only)
|
||||
// 7. MISSION_NOTES presence
|
||||
//
|
||||
// Per-check return shape is `{check, status: "ok"|"warn"|"fail",
|
||||
// message, fixHint?, evidence?}` per FR-5 IMPL-PRD §76-78. The shape
|
||||
// diverges intentionally from the install-health `rig doctor`'s
|
||||
// DoctorCheck shape (pass|warn|fail|skipped, reason/fix) — different
|
||||
// concerns warrant different schemas (workspace-readiness vs install-
|
||||
// health); the divergence is orch-marshal-accepted per cont.43-followup.
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { decodeAllowlist } from "../files/path-safety.js";
|
||||
|
||||
export type CheckStatus = "ok" | "warn" | "fail";
|
||||
|
||||
export interface DoctorCheck {
|
||||
check: string;
|
||||
status: CheckStatus;
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type WorkspaceRootSource = "env" | "file" | "default";
|
||||
|
||||
export interface CheckWorkspaceRootInput {
|
||||
workspaceRoot: string;
|
||||
source: WorkspaceRootSource;
|
||||
}
|
||||
|
||||
const ENV_FIX_HINT =
|
||||
"unset OPENRIG_WORKSPACE_ROOT or set it to an existing directory; run `rig config init-workspace` to scaffold a fresh one";
|
||||
const FILE_FIX_HINT =
|
||||
"update workspace.root in config.json to an existing directory; run `rig config init-workspace` to scaffold a fresh one";
|
||||
const DEFAULT_FIX_HINT =
|
||||
"run `rig config init-workspace` to scaffold the default workspace at the configured root";
|
||||
|
||||
function fixHintForSource(source: WorkspaceRootSource): string {
|
||||
switch (source) {
|
||||
case "env":
|
||||
return ENV_FIX_HINT;
|
||||
case "file":
|
||||
return FILE_FIX_HINT;
|
||||
case "default":
|
||||
return DEFAULT_FIX_HINT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #1 — workspace root reachable.
|
||||
*
|
||||
* Verifies the resolved workspace root exists as a directory. The
|
||||
* `source` (env / file / default) is propagated into the fix-hint so
|
||||
* the operator gets the right remediation channel.
|
||||
*/
|
||||
export function checkWorkspaceRootReachable(opts: CheckWorkspaceRootInput): DoctorCheck {
|
||||
const { workspaceRoot, source } = opts;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(workspaceRoot);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
check: "workspace_root_reachable",
|
||||
status: "fail",
|
||||
message: code === "ENOENT"
|
||||
? `workspace root '${workspaceRoot}' does not exist (resolved from ${source})`
|
||||
: `workspace root '${workspaceRoot}' is not reachable: ${(err as Error).message}`,
|
||||
fixHint: fixHintForSource(source),
|
||||
evidence: { workspaceRoot, source, errorCode: code ?? "unknown" },
|
||||
};
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
check: "workspace_root_reachable",
|
||||
status: "fail",
|
||||
message: `workspace root '${workspaceRoot}' exists but is not a directory (resolved from ${source})`,
|
||||
fixHint: fixHintForSource(source),
|
||||
evidence: { workspaceRoot, source, kind: "not_a_directory" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "workspace_root_reachable",
|
||||
status: "ok",
|
||||
message: `workspace root '${workspaceRoot}' is a reachable directory`,
|
||||
evidence: { workspaceRoot, source },
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckMissionsFolderInput {
|
||||
workspaceRoot: string;
|
||||
/** Resolved `workspace.slicesRoot` per ConfigStore (defaults to
|
||||
* `<workspaceRoot>/missions`). Operators who customized
|
||||
* `workspace.slices_root` (env or config) point this elsewhere; the
|
||||
* check honors the override so a deliberate custom layout doesn't
|
||||
* spuriously fail. */
|
||||
slicesRoot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #2 — missions folder present.
|
||||
*
|
||||
* Verifies the resolved missions folder (per ConfigStore `workspace.
|
||||
* slicesRoot` — defaults to `<workspaceRoot>/missions`) exists as a
|
||||
* directory. Fail on absence or wrong-shape; this is the load-bearing
|
||||
* folder for Project UI projection.
|
||||
*/
|
||||
export function checkMissionsFolder(opts: CheckMissionsFolderInput): DoctorCheck {
|
||||
const { workspaceRoot, slicesRoot } = opts;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(slicesRoot);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
check: "missions_folder_present",
|
||||
status: "fail",
|
||||
message: code === "ENOENT"
|
||||
? `missions folder '${slicesRoot}' does not exist`
|
||||
: `missions folder '${slicesRoot}' is not reachable: ${(err as Error).message}`,
|
||||
fixHint:
|
||||
slicesRoot === path.join(workspaceRoot, "missions")
|
||||
? "run `rig config init-workspace` to scaffold the default missions/ folder"
|
||||
: "create the configured missions folder or unset workspace.slices_root to use the default `<workspaceRoot>/missions/`",
|
||||
evidence: { slicesRoot, workspaceRoot, errorCode: code ?? "unknown" },
|
||||
};
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
check: "missions_folder_present",
|
||||
status: "fail",
|
||||
message: `missions folder '${slicesRoot}' exists but is not a directory`,
|
||||
fixHint:
|
||||
"remove or rename the conflicting file and run `rig config init-workspace` to scaffold the missions folder",
|
||||
evidence: { slicesRoot, workspaceRoot, kind: "not_a_directory" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "missions_folder_present",
|
||||
status: "ok",
|
||||
message: `missions folder '${slicesRoot}' is present`,
|
||||
evidence: { slicesRoot, workspaceRoot },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical-decoded allowlist entry as the shipped file API sees it
|
||||
* (post `decodeAllowlist`): absolute path, realpath-canonicalized
|
||||
* when reachable. Relative paths are silently dropped by the canonical
|
||||
* decoder before they reach this shape — see check #3 for the
|
||||
* usable-vs-raw entry-count semantics.
|
||||
*/
|
||||
export interface AllowlistEntry {
|
||||
name: string;
|
||||
/** Canonical absolute path (post-decodeAllowlist + realpathSync
|
||||
* fallback). Equivalent to the file API's `AllowlistRoot.canonicalPath`. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface CheckFileAllowlistInput {
|
||||
workspaceRoot: string;
|
||||
/** Raw `files.allowlist` value resolved via ConfigStore
|
||||
* (comma-separated `name:/abs/path` pairs, or empty string). */
|
||||
allowlistValue: string;
|
||||
allowlistSource: WorkspaceRootSource;
|
||||
/** Pre-decoded entries; if omitted the check decodes allowlistValue
|
||||
* via the canonical `decodeAllowlist` from
|
||||
* domain/files/path-safety.ts so the doctor reports exactly what
|
||||
* the shipped file API at /api/files/* accepts. Pre-decoded
|
||||
* entries MUST be in canonical form (absolute path); raw
|
||||
* named-pair strings from SettingsStore should go through
|
||||
* allowlistValue + the in-function decode, not parsedEntries. */
|
||||
parsedEntries?: AllowlistEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #3 — file allowlist sane.
|
||||
*
|
||||
* IMPL-PRD §43 references `<workspace-root>/.openrig/file-allowlist`
|
||||
* as a file — the shipped surface (ConfigStore `files.allowlist` key,
|
||||
* env OPENRIG_FILES_ALLOWLIST, default `workspace:${workspaceRoot}`)
|
||||
* is a CONFIGSTORE STRING KEY holding comma-separated `name:/abs/path`
|
||||
* pairs. There is no per-workspace file. Check #3 verifies the
|
||||
* resolved value decodes to at least one USABLE entry (canonical,
|
||||
* absolute) covering the workspaceRoot — the same usable-root
|
||||
* semantics the shipped file API at /api/files/* enforces via
|
||||
* `decodeAllowlist` in domain/files/path-safety.ts:60-80 (silently
|
||||
* drops non-absolute paths at line 71).
|
||||
*
|
||||
* Decoding via the canonical decoder (not a local re-implementation)
|
||||
* guarantees the doctor's verdict matches the actual file-API
|
||||
* behavior — `workspace:.` or `workspace:relative-root` evaluates
|
||||
* to zero usable entries here, mirroring the file API's silent skip.
|
||||
*/
|
||||
export function checkFileAllowlist(opts: CheckFileAllowlistInput): DoctorCheck {
|
||||
const { workspaceRoot, allowlistValue, allowlistSource } = opts;
|
||||
const entries: AllowlistEntry[] = opts.parsedEntries
|
||||
?? decodeAllowlist(allowlistValue).map((r) => ({ name: r.name, path: r.canonicalPath }));
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
check: "file_allowlist_sane",
|
||||
status: "fail",
|
||||
message: `files.allowlist resolved to no usable entries (raw='${allowlistValue}', source=${allowlistSource}); non-absolute or malformed pairs are silently dropped to match the shipped file API`,
|
||||
fixHint:
|
||||
"set OPENRIG_FILES_ALLOWLIST or run `rig config set files.allowlist workspace:<absoluteWorkspaceRoot>` so the file surface has a readable root (absolute paths only)",
|
||||
evidence: { allowlistValue, allowlistSource, entryCount: 0 },
|
||||
};
|
||||
}
|
||||
const covers = entries.some((e) => allowlistPathCoversRoot(e.path, workspaceRoot));
|
||||
if (!covers) {
|
||||
return {
|
||||
check: "file_allowlist_sane",
|
||||
status: "warn",
|
||||
message: `files.allowlist has ${entries.length} usable entr${entries.length === 1 ? "y" : "ies"} but none cover workspace root '${workspaceRoot}'`,
|
||||
fixHint:
|
||||
"add a `workspace:<absoluteWorkspaceRoot>` entry to files.allowlist (or set OPENRIG_FILES_ALLOWLIST) so workspace files are read-allowed",
|
||||
evidence: {
|
||||
allowlistValue,
|
||||
allowlistSource,
|
||||
entryCount: entries.length,
|
||||
workspaceRoot,
|
||||
entries,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "file_allowlist_sane",
|
||||
status: "ok",
|
||||
message: `files.allowlist has ${entries.length} usable entr${entries.length === 1 ? "y" : "ies"} covering workspace root`,
|
||||
evidence: { allowlistValue, allowlistSource, entryCount: entries.length, entries },
|
||||
};
|
||||
}
|
||||
|
||||
function allowlistPathCoversRoot(allowlistPath: string, workspaceRoot: string): boolean {
|
||||
// Canonical entry paths from decodeAllowlist are already absolute
|
||||
// + realpath-resolved. workspaceRoot we resolve + try-to-realpath
|
||||
// here so callers can pass an un-canonical workspace path and
|
||||
// coverage still matches when the entry was realpath'd through a
|
||||
// symlink (e.g. macOS `/var/folders/...` → `/private/var/...`).
|
||||
const normEntry = allowlistPath;
|
||||
let normRoot: string;
|
||||
try {
|
||||
normRoot = fs.realpathSync(workspaceRoot);
|
||||
} catch {
|
||||
normRoot = path.resolve(workspaceRoot);
|
||||
}
|
||||
if (normEntry === normRoot) return true;
|
||||
const rel = path.relative(normEntry, normRoot);
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
export interface CheckDaemonWorkspaceInput {
|
||||
/** workspace.root the daemon process resolves (server-side). */
|
||||
daemonResolvedRoot: string;
|
||||
/** workspace.root the doctor caller expects (CLI-side resolved,
|
||||
* passed in the request body or matched against the --workspace
|
||||
* flag). */
|
||||
expectedRoot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #4 — daemon points at this workspace.
|
||||
*
|
||||
* Reports divergence between the workspace.root the daemon resolved at
|
||||
* start-time vs the workspace.root the doctor caller expects. The
|
||||
* common cause is the daemon being started in a shell with a different
|
||||
* OPENRIG_WORKSPACE_ROOT than the operator's current shell.
|
||||
*/
|
||||
export function checkDaemonWorkspace(opts: CheckDaemonWorkspaceInput): DoctorCheck {
|
||||
const { daemonResolvedRoot, expectedRoot } = opts;
|
||||
const normDaemon = path.resolve(daemonResolvedRoot);
|
||||
const normExpected = path.resolve(expectedRoot);
|
||||
if (normDaemon === normExpected) {
|
||||
return {
|
||||
check: "daemon_points_at_this_workspace",
|
||||
status: "ok",
|
||||
message: `daemon and caller agree on workspace root '${normDaemon}'`,
|
||||
evidence: { daemonResolvedRoot: normDaemon, expectedRoot: normExpected },
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "daemon_points_at_this_workspace",
|
||||
status: "fail",
|
||||
message: `daemon resolved workspace root '${normDaemon}' but caller expected '${normExpected}'`,
|
||||
fixHint:
|
||||
"restart the daemon (`rig daemon restart`) in a shell where OPENRIG_WORKSPACE_ROOT matches the expected workspace, or unset OPENRIG_WORKSPACE_ROOT to fall through to config + default",
|
||||
evidence: { daemonResolvedRoot: normDaemon, expectedRoot: normExpected },
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckDaemonReloadInput {
|
||||
/** Path to the ConfigStore config file on disk. */
|
||||
configFilePath: string;
|
||||
/** Daemon process start time as a Date. Callers typically capture
|
||||
* this once at daemon-startup (e.g.
|
||||
* `new Date(Date.now() - process.uptime() * 1000)` at the doctor
|
||||
* route handler) and pass it through. Compared to config-file
|
||||
* mtime; mtime > startTime → stale daemon. */
|
||||
daemonStartTime: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #5 — daemon reload needed.
|
||||
*
|
||||
* Compares config-file mtime to the daemon's start-time. A newer
|
||||
* mtime means the operator edited config (via CLI / UI) after the
|
||||
* daemon started, and the daemon hasn't picked the change up yet.
|
||||
* Missing config file is not a fail — fresh installs without an
|
||||
* operator-written config use defaults entirely and never need a
|
||||
* reload.
|
||||
*/
|
||||
export function checkDaemonReload(opts: CheckDaemonReloadInput): DoctorCheck {
|
||||
const { configFilePath, daemonStartTime } = opts;
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(configFilePath);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return {
|
||||
check: "daemon_reload_needed",
|
||||
status: "ok",
|
||||
message: `no config file at '${configFilePath}'; daemon is running on defaults only`,
|
||||
evidence: { configFilePath, configFileExists: false },
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "daemon_reload_needed",
|
||||
status: "warn",
|
||||
message: `cannot stat config file '${configFilePath}': ${(err as Error).message}`,
|
||||
fixHint: "verify file permissions on the config file path so the daemon can detect freshness",
|
||||
evidence: { configFilePath, errorCode: code ?? "unknown" },
|
||||
};
|
||||
}
|
||||
const mtimeMs = stat.mtime.getTime();
|
||||
const startMs = daemonStartTime.getTime();
|
||||
if (mtimeMs > startMs) {
|
||||
return {
|
||||
check: "daemon_reload_needed",
|
||||
status: "warn",
|
||||
message: `config file mtime ${stat.mtime.toISOString()} is newer than daemon start ${daemonStartTime.toISOString()}`,
|
||||
fixHint: "run `rig daemon restart` to pick up the latest config",
|
||||
evidence: {
|
||||
configFilePath,
|
||||
configMtime: stat.mtime.toISOString(),
|
||||
daemonStartTime: daemonStartTime.toISOString(),
|
||||
staleMs: mtimeMs - startMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "daemon_reload_needed",
|
||||
status: "ok",
|
||||
message: `config file mtime ${stat.mtime.toISOString()} is older than daemon start ${daemonStartTime.toISOString()}`,
|
||||
evidence: {
|
||||
configFilePath,
|
||||
configMtime: stat.mtime.toISOString(),
|
||||
daemonStartTime: daemonStartTime.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckSliceDocsInput {
|
||||
/** Resolved missions folder root. The check walks each
|
||||
* `<missionsRoot>/<mission>/slices/<slice>/` and verifies it has
|
||||
* README.md OR IMPLEMENTATION-PRD.md OR IMPL-PRD.md. */
|
||||
missionsRoot: string;
|
||||
}
|
||||
|
||||
const SLICE_DOC_FILES = ["README.md", "IMPLEMENTATION-PRD.md", "IMPL-PRD.md"] as const;
|
||||
|
||||
interface BareSlice {
|
||||
mission: string;
|
||||
slice: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #6 — optional slice docs.
|
||||
*
|
||||
* Walks each mission's slices subdirectory and reports slices that
|
||||
* have NEITHER a README.md, IMPLEMENTATION-PRD.md, nor IMPL-PRD.md.
|
||||
* Warn-only (empty slices are sometimes intentional staging per
|
||||
* IMPL-PRD §57-59). The walk is bounded to one mission + one slice
|
||||
* level; we don't recurse into slice subdirs.
|
||||
*/
|
||||
export function checkOptionalSliceDocs(opts: CheckSliceDocsInput): DoctorCheck {
|
||||
const { missionsRoot } = opts;
|
||||
let missions: string[];
|
||||
try {
|
||||
missions = fs.readdirSync(missionsRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
check: "optional_slice_docs",
|
||||
status: "warn",
|
||||
message: code === "ENOENT"
|
||||
? `missions root '${missionsRoot}' does not exist; no slice docs to check`
|
||||
: `cannot read missions root '${missionsRoot}': ${(err as Error).message}`,
|
||||
evidence: { missionsRoot, errorCode: code ?? "unknown" },
|
||||
};
|
||||
}
|
||||
|
||||
const bareSlices: BareSlice[] = [];
|
||||
for (const mission of missions) {
|
||||
const slicesDir = path.join(missionsRoot, mission, "slices");
|
||||
let slices: string[];
|
||||
try {
|
||||
slices = fs.readdirSync(slicesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
} catch {
|
||||
continue; // no slices subdir is fine — mission may not be slice-organized
|
||||
}
|
||||
for (const slice of slices) {
|
||||
const slicePath = path.join(slicesDir, slice);
|
||||
const hasDoc = SLICE_DOC_FILES.some((f) => fs.existsSync(path.join(slicePath, f)));
|
||||
if (!hasDoc) {
|
||||
bareSlices.push({ mission, slice, path: slicePath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bareSlices.length === 0) {
|
||||
return {
|
||||
check: "optional_slice_docs",
|
||||
status: "ok",
|
||||
message: `every slice under '${missionsRoot}' has a README, IMPLEMENTATION-PRD, or IMPL-PRD`,
|
||||
evidence: { missionsRoot, bareSlices: [] },
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "optional_slice_docs",
|
||||
status: "warn",
|
||||
message: `${bareSlices.length} slice${bareSlices.length === 1 ? " has" : "s have"} no README / IMPLEMENTATION-PRD / IMPL-PRD`,
|
||||
fixHint:
|
||||
"author a README.md, IMPLEMENTATION-PRD.md, or IMPL-PRD.md in each bare slice directory; warn-only because empty slices are sometimes intentional staging",
|
||||
evidence: { missionsRoot, bareSlices },
|
||||
};
|
||||
}
|
||||
|
||||
export interface CheckMissionNotesInput {
|
||||
missionsRoot: string;
|
||||
}
|
||||
|
||||
interface MissionWithoutNotes {
|
||||
mission: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check #7 — MISSION_NOTES presence.
|
||||
*
|
||||
* Verifies each mission directory has a `MISSION_NOTES.md` (per the
|
||||
* FR-3 convention that just merged at LOCAL main e154a5ce). Warn-
|
||||
* only because legacy missions predate the convention; the
|
||||
* operator can run `rig scope mission create` (which now auto-
|
||||
* scaffolds the file per FR-3) for new missions.
|
||||
*/
|
||||
export function checkMissionNotesPresence(opts: CheckMissionNotesInput): DoctorCheck {
|
||||
const { missionsRoot } = opts;
|
||||
let missions: string[];
|
||||
try {
|
||||
missions = fs.readdirSync(missionsRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
check: "mission_notes_presence",
|
||||
status: "warn",
|
||||
message: code === "ENOENT"
|
||||
? `missions root '${missionsRoot}' does not exist; no MISSION_NOTES to check`
|
||||
: `cannot read missions root '${missionsRoot}': ${(err as Error).message}`,
|
||||
evidence: { missionsRoot, errorCode: code ?? "unknown" },
|
||||
};
|
||||
}
|
||||
|
||||
const missing: MissionWithoutNotes[] = [];
|
||||
for (const mission of missions) {
|
||||
const missionDir = path.join(missionsRoot, mission);
|
||||
const notesPath = path.join(missionDir, "MISSION_NOTES.md");
|
||||
if (!fs.existsSync(notesPath)) {
|
||||
missing.push({ mission, path: missionDir });
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length === 0) {
|
||||
return {
|
||||
check: "mission_notes_presence",
|
||||
status: "ok",
|
||||
message: `every mission under '${missionsRoot}' has MISSION_NOTES.md`,
|
||||
evidence: { missionsRoot, missing: [] },
|
||||
};
|
||||
}
|
||||
return {
|
||||
check: "mission_notes_presence",
|
||||
status: "warn",
|
||||
message: `${missing.length} mission${missing.length === 1 ? " has" : "s have"} no MISSION_NOTES.md`,
|
||||
fixHint:
|
||||
"scaffold via `rig scope mission create <id>` (FR-3 auto-scaffold) for new missions, or copy from `<substrate>/openrig-work/conventions/mission-notes/TEMPLATE.md` for existing ones",
|
||||
evidence: { missionsRoot, missing },
|
||||
};
|
||||
}
|
||||
|
||||
export interface RunDoctorInput {
|
||||
/** Workspace root under check (caller's --workspace override or
|
||||
* daemon-resolved default). */
|
||||
workspaceRoot: string;
|
||||
workspaceRootSource: WorkspaceRootSource;
|
||||
/** Resolved missions folder per ConfigStore workspace.slices_root,
|
||||
* or `<workspaceRoot>/missions` when caller overrode --workspace. */
|
||||
slicesRoot: string;
|
||||
/** Raw files.allowlist value from SettingsStore (will be decoded
|
||||
* via canonical decodeAllowlist). */
|
||||
allowlistValue: string;
|
||||
allowlistSource: WorkspaceRootSource;
|
||||
/** Daemon-resolved workspace.root (for check #4 comparison against
|
||||
* the workspace under check). */
|
||||
daemonResolvedWorkspaceRoot: string;
|
||||
/** Daemon's ConfigStore configPath on disk (for check #5 staleness
|
||||
* comparison). */
|
||||
configFilePath: string;
|
||||
/** Daemon process start time (for check #5). */
|
||||
daemonStartTime: Date;
|
||||
}
|
||||
|
||||
export interface DoctorReport {
|
||||
/** Workspace under check (echoes input.workspaceRoot for clarity). */
|
||||
workspaceRoot: string;
|
||||
checks: DoctorCheck[];
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
/** ISO timestamp of when the daemon ran the report. */
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrator — runs all 7 checks in fixed order and returns a
|
||||
* structured DoctorReport with aggregate counts. Pure function;
|
||||
* filesystem effects are bounded to the individual check helpers
|
||||
* (statSync / readdirSync / existsSync). Used by the daemon route
|
||||
* POST /api/workspace/doctor (FR-5c) and by the CLI's --json
|
||||
* formatter (FR-5d).
|
||||
*/
|
||||
export function runWorkspaceDoctor(input: RunDoctorInput): DoctorReport {
|
||||
const checks: DoctorCheck[] = [
|
||||
checkWorkspaceRootReachable({
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
source: input.workspaceRootSource,
|
||||
}),
|
||||
checkMissionsFolder({
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
slicesRoot: input.slicesRoot,
|
||||
}),
|
||||
checkFileAllowlist({
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
allowlistValue: input.allowlistValue,
|
||||
allowlistSource: input.allowlistSource,
|
||||
}),
|
||||
checkDaemonWorkspace({
|
||||
daemonResolvedRoot: input.daemonResolvedWorkspaceRoot,
|
||||
expectedRoot: input.workspaceRoot,
|
||||
}),
|
||||
checkDaemonReload({
|
||||
configFilePath: input.configFilePath,
|
||||
daemonStartTime: input.daemonStartTime,
|
||||
}),
|
||||
checkOptionalSliceDocs({
|
||||
missionsRoot: input.slicesRoot,
|
||||
}),
|
||||
checkMissionNotesPresence({
|
||||
missionsRoot: input.slicesRoot,
|
||||
}),
|
||||
];
|
||||
const summary = { ok: 0, warn: 0, fail: 0 };
|
||||
for (const c of checks) summary[c.status]++;
|
||||
return {
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
checks,
|
||||
summary,
|
||||
daemonResolvedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user