Compare commits

..
409 Commits
Author SHA1 Message Date
mvschwarzandClaude Opus 4.7 c02d7e7bb4 Release 0.3.0
See docs/releases/v0.3.0.md and CHANGELOG.md for the full release narrative.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 13:59:51 -07:00
mvschwarz 95bad721d2 docs(release): add 0.3.0 public notes 2026-05-10 13:39:05 -07:00
mvschwarz 1d4e5cf4ee docs(config): clarify legacy env boundary 2026-05-10 13:23:50 -07:00
mvschwarz 2b6ea03d21 chore(release): align public release terminology 2026-05-10 13:14:53 -07:00
mvschwarz 32d0b5de56 fix(daemon): use portable agent starter fallback 2026-05-10 12:47:36 -07:00
mvschwarz fdbfc30b1b chore(release): generalize public-facing breadcrumbs 2026-05-10 12:28:25 -07:00
mvschwarz dc472f9045 chore(release): normalize portable path handling 2026-05-10 12:01:41 -07:00
mvschwarz 7a9952ff8c test: neutralize internal mission fixture names 2026-05-10 11:40:25 -07:00
mvschwarz 954404a21e merge(release): integrate daemon skill discovery 2026-05-10 11:39:51 -07:00
mvschwarz a0370e972d merge(release): integrate 0.3.0 starter refresh 2026-05-10 11:39:38 -07:00
mvschwarz 730e850b34 docs: refresh 0.3.0 design and as-built references 2026-05-10 11:39:04 -07:00
mvschwarzandClaude Opus 4.7 622c8bdce8 fix(daemon): surface skill-discovery rejection reasons through profile resolution
Forward-fix on the daemon-skill-discovery slice (1482aef): when a
profile's resources.skills entry references a name whose dirname
matches a structurally-rejected SKILL.md, the resolver now surfaces
the precise rejection reason ("rejected — <reason> at <path>")
instead of the bare "not found in resource pool" error. Operators
see exactly which file and which check failed when a drop-in skill
isn't valid.

Behavior:
- profile-resolver captures discovery.rejected from
  discoverSkillsForRuntime and indexes by directory basename.
- After resolveProfileUses returns errors, "Profile uses skills:
  \"<id>\" not found in resource pool" is replaced with "Profile
  uses skills: \"<id>\" rejected — <reason> (at <path>)" when the
  basename matches a known rejection.
- Match is basename-only, so an unrelated rejected SKILL.md (e.g.,
  ~/.claude/skills/foo/) does NOT contaminate the error for an
  unrelated missing reference (e.g., a profile asking for "bar"
  still gets the standard not-found message).

Tests:
- 2 new integration tests in profile-resolver.test.ts:
  - structural-rejection reason surfaces when basenames match
    (broken SKILL.md at ~/.claude/skills/broken-skill/ + profile
    referencing "broken-skill" → error mentions "rejected" +
    "frontmatter" + the directory path)
  - unrelated rejected SKILL.md does NOT pollute an unrelated
    missing-skill error (foo broken / bar missing → bar gets
    standard "not found" without "rejected")

Verification:
- npx tsc --noEmit on packages/daemon/tsconfig.json: clean
- npx vitest run skill-discovery.test.ts + profile-resolver.test.ts:
  39/39 PASS

Sibling forward-fix (substrate-only, no commit needed): the slice
ack.md and walk.md substrate artifacts have been scrubbed for
attribution-style residue and reconciled to a single canonical
precedence statement (rig-local agent.yaml > rig-bundled cwd >
spec-install-dir > runtime-specific user library > shared
~/.openrig/skills/), matching the listScanRoots implementation and
the walk packet's verification hooks.

Refs: substrate slice ack + walk at substrate/shared-docs/openrig-work/missions/bug-fix/slices/daemon-profile-resolver-skill-discovery/.

SC-29 EXCEPTION #7 scope unchanged from 1482aef; this is a refinement
of the same exception, not a new one.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 04:38:12 -07:00
mvschwarzandClaude Opus 4.7 1482aef986 fix(daemon): discover skills at runtime-truth filesystem paths during profile resolution
Closes the gap where the profile resolver only saw skills declared in
agent.yaml's resources.skills + imports — not skills present at the
runtime-truth filesystem locations the harness already loads from.
Operators can now drop any structurally-valid skill at the
user-library or rig-bundled paths and have it resolve through profile
validation, not just at runtime.

Behavior:
- New module packages/daemon/src/domain/skill-discovery.ts walks 5
  scan roots per runtime (claude-code targets ~/.openrig/skills/,
  ~/.claude/skills/, <cwd>/.claude/skills/, <specInstallDir>/skills/;
  codex targets ~/.openrig/skills/, ~/.agents/skills/,
  <cwd>/.agents/skills/, <specInstallDir>/skills/).
- Each candidate directory must contain a SKILL.md with a
  ---delimited YAML frontmatter block, non-empty `name` +
  `description` strings, and a non-empty body. The validator's
  question is "would the runtime actually load this," not "is this ID
  in the daemon shared bundle."
- Rejections surface as { path, reason } records so callers can show
  clear errors when an operator drops a malformed SKILL.md.
- Profile resolver augments its resource pool with discovered skills
  AFTER buildResourcePool, respecting rig-local-wins precedence: a
  skill ID declared in agent.yaml's resources.skills (or imports)
  beats any same-id discovered skill. Among discovery roots, the
  most-specific path wins (rig-bundled at cwd > spec-install-dir >
  runtime-specific user library > shared user-spec library).
- ResolutionContext gains an optional homedir field that defaults to
  os.homedir() in production; tests inject a fixture root.
- Runtime + cwd computation moved earlier in resolveNodeConfig so the
  filesystem scan targets the right paths before resolveProfileUses
  queries the augmented pool.

Tests:
- 18 unit tests in skill-discovery.test.ts cover frontmatter
  acceptance/rejection (6), per-runtime path scanning including
  cross-runtime exclusion (8), structural rejection (2), and the
  SkillResource shape contract (2).
- 5 integration tests in profile-resolver.test.ts cover discovered-
  only resolution at ~/.claude/skills/, rig-bundled cwd discovery,
  rig-local resources.skills precedence over a same-id discovered
  skill, codex runtime path scanning, and the preserved
  skill-not-found error path.

Verification:
- npx tsc --noEmit on daemon / cli / ui: all clean
- npx vitest run on daemon suite: 3141 pass, 1 pre-existing failure
  (rsi-v2-starter-spec-content non-qa-done assertion last touched in
  5bdda4f, unrelated to this slice)

Refs: substrate slice ack + walk at substrate/shared-docs/openrig-work/missions/bug-fix/slices/daemon-profile-resolver-skill-discovery/.

SC-29 EXCEPTION #7 declared (substantive daemon code change adding a
filesystem scan + structural validator + resource-pool augmentation;
not allowlist-only and not additive payload field; new code path).
Ledger position confirmed by checking #5 + #6 declared in c7c9751
project-observability merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 03:35:33 -07:00
mvschwarz 3fc4bc7fa3 fix conveyor release cli paths 2026-05-10 03:25:33 -07:00
mvschwarz a64b71967c curate bundled starter skills 2026-05-10 03:19:51 -07:00
mvschwarz 52dcff1f6c refresh product-team starter 2026-05-10 03:16:41 -07:00
mvschwarz 0f581591fb seed fresh install conveyor workspace 2026-05-10 03:11:18 -07:00
mvschwarz f2f3531881 add conveyor starter workflows 2026-05-10 03:04:34 -07:00
mvschwarz 9fbe3a15de stage private RSI workflow recipe
Remove the bundled rsi-v2-hot-potato workflow spec and its recipe-pinning tests while keeping the workflow runtime, hot-potato primitives, and user-file workflow paths intact.

Keep daemon builds working when no built-in workflow specs are bundled by replacing the shell cp glob with an empty-directory-safe copy script.

Dogfood evidence: /Users/wrandom/code/substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/dogfood-evidence/release-0.3.0-slice-01-rsi-recipe-staging-20260510T024342/REPORT.md
2026-05-10 02:57:02 -07:00
mvschwarz 1644e742ac fix(ui): show context metrics in topology views
Adds context percentage and token totals to topology table and terminal views using the existing node inventory context payload. Keeps pod table scoping aligned with pod pages and refreshes inventory polling with graph cadence.

Verification: focused UI tests, UI build, lint, and test:repo pass.
2026-05-09 04:36:20 -07:00
mvschwarz 333b25dc72 fix(daemon): backfill codex context for detached sessions 2026-05-09 04:06:07 -07:00
mvschwarz 7d29771fff feat(daemon): surface codex context usage 2026-05-09 03:58:42 -07:00
mvschwarz 4801a224ff feat(ui): reduce for-you badge overload 2026-05-09 03:40:32 -07:00
mvschwarz 8008b163ec feat(ui): clarify human action workflow graphics 2026-05-09 03:24:22 -07:00
mvschwarz 7b8d7c9b19 feat(ui): add artifact graphics vocabulary 2026-05-09 03:10:27 -07:00
mvschwarz 74120509a1 feat(ui): add project event and queue state badges 2026-05-09 01:47:49 -07:00
mvschwarz 89cb7670b4 feat(ui): broaden graphics marks in library specs 2026-05-09 01:13:44 -07:00
mvschwarz 1783334f6d test(ui): stabilize jsdom storage setup 2026-05-09 01:03:29 -07:00
mvschwarz be32d5932e feat(ui): extend graphics marks to library flows 2026-05-09 01:01:12 -07:00
mvschwarz bb6381cf7c feat(ui): extend graphics marks to spec surfaces 2026-05-09 00:59:43 -07:00
mvschwarz ab39ee9a38 fix(ui): polish library skill explorer 2026-05-09 00:58:03 -07:00
mvschwarz d815a08145 Route library skills to focused detail pages 2026-05-09 00:48:20 -07:00
mvschwarz 3cb94ef1de Hide compact terminal preview scrollbars 2026-05-09 00:48:14 -07:00
mvschwarz fb981ac311 merge(post-V1): graphics-package-broaden
Integrates the shared graphics broaden pass after guard and VM verification.

  Scope: UI-only graphics marks, topology/live-node icon cleanup, project metadata iconography, and
  related tests.

  Verification before merge: guard-3 advisory clean, velocity-qa VM pass, focused UI tests, lint, UI
  build, and diff-check clean.
2026-05-09 00:42:18 -07:00
mvschwarz 8f01d319af Broaden graphics marks for project metadata surfaces 2026-05-08 23:14:11 -07:00
mvschwarz fe5a18bccf Align rig node hover toolbar with compact actions 2026-05-08 21:20:50 -07:00
mvschwarz d022e43cf4 Use resume icon for live node resume action 2026-05-08 21:17:18 -07:00
mvschwarz a57924e0e6 Remove duplicate runtime mark from topology table agent column 2026-05-08 21:14:14 -07:00
mvschwarz 13c5f08750 Fix live node identity badge layout 2026-05-08 21:09:44 -07:00
mvschwarz 31ec09a160 Extend graphics marks to workflow surfaces 2026-05-08 20:53:28 -07:00
mvschwarzandClaude Opus 4.7 f3a99af1a2 skills: in-repo mirror of canonical bundled skills at skills/_canonical/
Adds a build-time mirror that copies the daemon's bundled skills from
packages/daemon/specs/agents/shared/skills/ to skills/_canonical/, so
the skill set is publicly visible at the repo root rather than buried
inside packages/daemon/specs/. Hand-authored top-level files
(README.md, CHANGELOG.md, LICENSE) live alongside _canonical/ and
survive every mirror run via strict-ownership of the _canonical/
subtree only.

Mechanism: node script + drift-detect baked into the existing
test:repo pipeline. Matches scripts/check-docs-guard.mjs +
check-docs-guard.test.mjs convention.

  scripts/mirror-skills.mjs       — rsync-based copy + --check mode
  scripts/mirror-skills.test.mjs  — unit tests on the parser + smoke
                                     invariants (source/target shape
                                     parity; excluded patterns absent)

  package.json scripts:
    mirror-skills        run the mirror
    mirror-skills:check  drift-detect (exit 1 if stale)
    test:repo            now runs mirror-skills:check after the existing
                         scripts/*.test.mjs + check-docs-guard pass

Filtering is path-based (current product source has no metadata for
metadata-based filtering); excludes feedback.md, evals/, .DS_Store,
*.local.md. The --check mode uses --checksum so mtime-only drift
(after git checkout or cp) is content-stable and does not register
as a false positive — only byte-level changes count.

Initial mirror landed: 27 SKILL.md files across core/, pm/, pods/,
process/, plus 3 uncategorized at the top level
(claude-compact-in-place/, mental-model-ha/, rig-architect/).

skills/CHANGELOG.md initialized with the bootstrap entry per
operating-the-skill-library v1.9 §8 rule "skills/CHANGELOG.md"
(append-only per curation cycle close).

Structure is git-subtree-split-ready: a future
`git subtree split --prefix=skills HEAD` produces a freestanding repo
when skill-only contributors warrant the split.

Mechanism comparison and rationale at
substrate/shared-docs/openrig-work/lab/skill-authoring-techniques/
findings/skills-hub-mirror-mechanism-comparison.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-08 20:51:37 -07:00
mvschwarz 2657bcedd7 Broaden shared graphics package across UI surfaces 2026-05-08 20:23:07 -07:00
mvschwarz 5134ee22ba merge(post-V1): project-graphics-package — runtime/tool graphics layer + first placements
Reusable graphics/icon layer (RuntimeMark + runtime-brand + tool-brand utilities) with first production placements across topology graph/table/terminal views, LiveNodeDetails, RigNode, AuthorAgentTag in For You, HybridTopologyNodes runtime badge, and TerminalPreviewPopover terminal mark. Branch was rebased onto project-observability-elite-pm tip preserving observability Phase H portal/positioning/wrapping behavior; backup ref project-graphics-package-pre-rebase preserved at pre-rebase tip. Establishes the graphics layer; broader use across project metadata cards/pills deferred to follow-up slice.
2026-05-08 02:32:36 -07:00
mvschwarz c7c9751adc merge(post-V1): project-observability — observability surfaces + workspace contract + actionable approve/deny/route controls
Project + For You + Slice tabs evolved into elite project-management observability surface across 8 phases on top of V1 ship: For You feed schema fix; Story body-primary + Queue drawer + Tests diagnostics; Current/Archive Project UX; mission-aware default workspace scaffold + nested mission/slice indexing + auto-rebase settings (SC-29 #5); workspace.dogfood_evidence_root productization + read-only proof indexing wiring (SC-29 #6); founder-walk polish (route-load fix + content quality + Tests proof viewer + Project hierarchy pages with scoped rollups); graphics package across surfaces + DRY proof viewer + actionable approve/deny/route controls + Story rollups + topology fallback + vellum normalization; outcome panels + cache invalidation + drawer outside-click; terminal popover width + flip/clamp positioning + wrap behavior.
2026-05-08 02:32:18 -07:00
mvschwarz 8e4976d0d2 Refine topology runtime marks 2026-05-08 01:59:07 -07:00
mvschwarz c732348be5 Add runtime graphics package 2026-05-08 01:59:02 -07:00
mvschwarz 17fbc00da8 Tune terminal preview wrapping 2026-05-07 22:15:47 -07:00
mvschwarz d81425892e Calibrate terminal preview placement 2026-05-07 21:44:21 -07:00
mvschwarz 5e2cd5f7a2 Widen terminal preview popover 2026-05-07 21:36:18 -07:00
mvschwarz 95c296c073 Show queue action outcomes 2026-05-07 21:23:19 -07:00
mvschwarz 93de12ea90 Polish project observability surfaces 2026-05-07 21:00:04 -07:00
mvschwarz 2566e1c1d0 Polish project feed and scope rollups 2026-05-07 19:04:40 -07:00
mvschwarz 6e179bbc00 Wire workspace proof evidence and feed queue signals 2026-05-07 18:29:03 -07:00
mvschwarz 400d3313d5 Fix mission workspace compatibility indexing 2026-05-07 17:23:58 -07:00
mvschwarz 1c19846093 Productize mission workspace scaffold 2026-05-07 17:15:02 -07:00
mvschwarz 734b4c2864 Distinguish current and archived project work 2026-05-07 15:23:09 -07:00
mvschwarz 76dfe455f6 Improve slice observability tabs 2026-05-07 15:10:18 -07:00
mvschwarz 1cb3db2c73 Fix For You queue event visibility 2026-05-07 14:58:00 -07:00
mvschwarz 1ae42ee8bf merge(shell-redesign-v1): pre-release CLI/daemon slice — RIGGED→OpenRig + rig send envelope + transcript rotation + ConfigStore wire-through
Three release-blocker product features for the next cut + one process-gap fix wiring ConfigStore values to daemon launch.

═══ COMMIT CHAIN (4 SHAs) ═══
- 7a02612 Item 3: RIGGED → OpenRig projection-marker rename in CLAUDE.md/AGENTS.md install/init logic; backwards-compat read paths recognize legacy markers; auto-migrate-on-write
- ec43d48 Item 2: rig send email-style envelope (From/To/body/Reply hint); cross-host non-double-wrap (remote rig wraps locally; orchestrator avoids double-wrap)
- a7a34ca Item 1: transcript rotation switch from infinite-grow pipe-pane to periodic tmux capture-pane (default 1000 lines / 2s poll); new transcript-rotation module + 2 callsite swaps + teardown stop hook + SESSION BOUNDARY preservation + writeBoundaryMarker mkdir-on-demand fix; ConfigStore allowlist additions transcripts.lines + transcripts.poll_interval_seconds (lockstep daemon settings-store + CLI config-store)
- 5270545 Process-gap fix (Blocker 2): ConfigStore values now thread through 'rig daemon start' → buildDaemonEnv emits OPENRIG_TRANSCRIPTS_LINES + OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS env vars → startDaemon passes to daemon process env → rotation resolver consumes; 3-path regression coverage (set-then-launch / default-fallback / inherited-env-override)

═══ SC-29 EXCEPTION #4 DECLARED + SCOPED ═══
Pre-release product feature changes (release-blocker scope expansion):
- pipe-pane → capture-pane mechanism switch (Item 1)
- rig send envelope wrap (Item 2)
- RIGGED → OpenRig projection-marker rename (Item 3)
Plus 2 ConfigStore allowlist additions (transcripts.lines + transcripts.poll_interval_seconds) in lockstep daemon+CLI per established Phase 4/5/polish-slice-4 pattern.

NO new endpoints / new event types / migrations / UI changes. SC-29 cumulative exception ledger:
- #1 Phase 4: agents.advisor_session + agents.operator_session
- #2 Phase 5: feed.subscriptions.{action_required, approvals, shipped, progress, audit_log}
- #3 Phase 5.4: contextTotalInputTokens + contextTotalOutputTokens (graph payload telemetry; additive only)
- #4 Pre-release: capture-pane + rig send envelope + RIGGED→OpenRig + transcripts.* ConfigStore allowlist (release-blocker product features)

═══ DRIFTS DECLARED + RESOLVED ═══
- D2: ConfigStore tunables for transcripts.* (option a per orch decisions; both packages updated; wire-through verified end-to-end via Blocker 2 fix)
- D3: legacy RIGGED marker recognition for backwards-compat (read both forms; write only OpenRig; migrate-forward-on-write)
- D4: SESSION BOUNDARY preservation in rotation tick + writeBoundaryMarker mkdir fix (cross-restart contract preserved with restore-orchestrator)
- Out-of-scope deferral: .rigged-backups internal directory name (NOT user-visible projection marker; deferred to future v0.3.x slice)

═══ KEY ARCHITECTURE PATTERNS BANKED ═══
- Rename-with-backwards-compat-reads pattern: read both legacy + current markers; write only current; migrate-forward-in-place (same shape as env-var fallback machinery)
- Cross-restart transcript contract: rotation/cleanup must preserve markers the restore-orchestrator depends on (SESSION BOUNDARY)
- ConfigStore→daemon-launch wire-through (commands/daemon.ts → buildDaemonEnv → daemon process env → rotation resolver) — required pattern for any future ConfigStore tunable
- Self-verify ritual addition (banked at review-lead): 'when allowlist additions claim tunability via ConfigStore, trace full runtime path from ConfigStore.set to consumer; allowlist + persistence does not equal effective tunability'

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN on all 4 SHAs (3 FYI patterns banked: rename-with-backwards-compat / transcript memory model / SESSION BOUNDARY cross-restart contract; plus the wire-through pattern from 5270545)
- redo3-guard-3: CLEAR at 5270545 (one round of process-gap fix resolved both Blocker 1 ACK §4 actual_target_write_paths drift + Blocker 2 ConfigStore-to-daemon-launch wiring gap)
- design-reviewer: DONE at a7a34ca (3 behaviors operationally verified; 5270545 wiring-only no visible behavior change)

═══ STYLE RULE ═══
Post-2026-05-07 commits + ACK + walk packet all neutral phrasing per banked memory (no verbatim quotes / no chain-of-authorization attribution). Pre-HALT ACK predates the rule and stays as-is per going-forward semantics.
2026-05-07 09:36:59 -07:00
mvschwarzandClaude Opus 4.7 5270545432 shell-redesign V1 pre-release CLI/daemon Item 1 fix: project ConfigStore rotation tunables into daemon launch env
Closes the production-reachability gap where transcripts.lines and
transcripts.poll_interval_seconds were resident in ConfigStore +
SETTINGS_VALID_KEYS but never reached the spawned daemon process. The
rotation hook only consulted shell env vars, so
`rig config set transcripts.lines 500` followed by `rig daemon
restart` had no observable effect on the rotation cadence.

Wire-up:
- StartOptions (cli/daemon-lifecycle.ts) extends with optional
  transcriptsLines + transcriptsPollIntervalSeconds.
- buildDaemonEnv emits OPENRIG_TRANSCRIPTS_LINES and
  OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS only when the opts are
  defined; explicit opts override any inherited base-env value, same
  shape as the existing OPENRIG_PORT / OPENRIG_DB / OPENRIG_TRANSCRIPTS_*
  override pattern.
- The `rig daemon start` command path reads
  config.transcripts.lines and config.transcripts.pollIntervalSeconds
  from the resolved ConfigStore and threads them through startDaemon
  → buildDaemonEnv → daemon process env, where
  getTranscriptRotationOptionsFromEnv inside the rotation module
  picks them up at session-start.

Tests:
- 3 new buildDaemonEnv unit tests:
  - emits OPENRIG_TRANSCRIPTS_LINES + OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS
    when opts provided.
  - omits both env vars when opts undefined (rotation falls back to
    its own defaults).
  - explicit opts override inherited OPENRIG_TRANSCRIPTS_* from base
    env (file-stored ConfigStore wins over stale shell vars).
- 1 new ConfigStore roundtrip test:
  ConfigStore.set('transcripts.lines', '500') +
  set('transcripts.poll_interval_seconds', '5') → resolve() returns
  500 / 5 (proves the file-stored values reach the daemon-launch
  read site).
- VALID_KEYS exhaustive-list test refreshed to include the 2 V1
  pre-release keys plus the previously-stale Phase 4 / Phase 5
  additions, matching the equivalent fix in the daemon settings-
  store test.

Verification:
- npx tsc --noEmit on packages/cli/tsconfig.json: clean
- npx vitest run daemon-lifecycle + config-store-extended: 85/85 PASS

SC-29 EXCEPTION #4 scope unchanged: the env-var emission path is the
existing OPENRIG_TRANSCRIPTS_* mechanism, just with two more allowed
keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-07 09:31:11 -07:00
mvschwarzandClaude Opus 4.7 a7a34ca1ed shell-redesign V1 pre-release CLI/daemon Item 1: capture-pane transcript rotation
Replaces the legacy `tmux pipe-pane` transcript-capture mechanism
(infinite-growth file per session) with a periodic
`tmux capture-pane -t <session> -p -S -<lines>` shellout that
atomically overwrites the transcript file each tick. File size stays
bounded by trailing line count + line-byte ceiling, not by session
duration.

New module packages/daemon/src/domain/transcript-rotation.ts:
- startTranscriptRotation(adapter, session, outputPath, opts)
  registers a per-session timer that ticks immediately + every
  pollIntervalMs.
- stopTranscriptRotation(session) clears a timer (idempotent).
- getTranscriptRotationOptionsFromEnv() reads the new tunables
  (env > defaults; file config flows through the daemon settings-
  store at startup).
- Each tick reads any pre-existing SESSION BOUNDARY lines from the
  output file and prepends them to the captured content so the
  restore-orchestrator's pre-launch boundary marker survives across
  rotation overwrites.
- Timers .unref() so they don't keep the daemon process alive on
  their own.

Two ConfigStore allowlist additions (lockstep daemon settings-store
+ CLI config-store):
- transcripts.lines (number; default 1000)
- transcripts.poll_interval_seconds (number; default 2)
SC-29 EXCEPTION #4 declared in pre-release ACK §5; same allowlist
shape as Phase 4 + Phase 5 prior exceptions.

Call-site swaps:
- node-launcher.ts: inline pipe-pane invocation replaced with
  startTranscriptRotation + getTranscriptRotationOptionsFromEnv.
  The launch-warning path narrows to "transcript directory creation
  failed"; per-tick failures are silent (next tick retries).
- transcript-capture.ts: startTmuxTranscriptCapture now starts the
  rotation timer instead of pipe-pane.
- rig-teardown.ts: stopTranscriptRotation called for each live
  session before killSession so capture-pane stops poking dead
  targets.

transcript-store.writeBoundaryMarker now mkdir's its parent on
demand so a fresh rig directory is no longer a fail case (the
restore orchestrator writes the marker before the launcher's
ensureTranscriptDir runs).

Tests:
- New packages/daemon/test/transcript-rotation.test.ts: 11 tests
  (capture invocation contract / atomic file write / overwrite-not-
  append / timer lifecycle + idempotent replace / env-override +
  defaults including legacy RIGGED_* fallback).
- node-launcher.test.ts: transcript-integration cases now assert on
  getActiveRotationCount + dir-creation-failure warning instead of
  pipe-pane.
- transcript-routes.test.ts: lazy-start cases assert rotation timer
  registered.
- self-attach-service.test.ts / claim-service.test.ts: transcript
  capture assertions point at rotation count.
- restore-orchestrator.test.ts: removed the pipe-pane-failure
  warning expectation (rotation failures are best-effort silent);
  boundary marker assertion still applies.
- transcript-store.test.ts: writeBoundaryMarker filesystem-error
  case rewritten to use a parent-is-a-regular-file collision now
  that the deeply-nested-nonexistent case auto-mkdirs.
- user-settings-routes.test.ts + user-settings-store.test.ts:
  expected key-count and key-list updated to include the 2 new
  transcripts.* keys plus the previously-stale Phase 4/5 keys.

Verification:
- npx tsc --noEmit on all 3 package configs: clean
- npx vitest run on the daemon suite: 3110 pass, 1 pre-existing
  failure unrelated to this slice (rsi-v2-starter-spec-content
  workflow terminal-exits assertion)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-07 09:21:08 -07:00
mvschwarzandClaude Opus 4.7 ec43d48af9 shell-redesign V1 pre-release CLI/daemon Item 2: rig send email-style envelope wrap
Wraps every local rig send body with a header / body / reply-hint
envelope so the recipient pane shows who the message is from, who it
is for, and the exact command shape needed to reply. Resolves the
sender's session from OPENRIG_SESSION_NAME / RIGGED_SESSION_NAME and
falls back to a "<unknown sender>" marker when neither is present.

Envelope shape:

    From: <sender>
    To: <recipient>
    ---
    <body verbatim>
    ---
    ↩ Reply: rig send <sender> "..."

Cross-host sends (--host) intentionally do NOT wrap locally — the
remote rig wraps when it runs the same command, and double-wrapping
would nest envelopes. The body inside the dashes is the original
text verbatim, so multi-line input passes through unchanged.

New unit test packages/cli/test/send-header.test.ts covers header
presence, body preservation, empty-body edge case, sender fallback,
and recipient-string preservation. Existing send.test.ts assertion
updated to match the wrapped POST payload.

Verification:
- npx vitest run send.test.ts send-header.test.ts: 13/13 PASS
- npx tsc --noEmit --project packages/cli/tsconfig.json: clean

SC-29 EXCEPTION #4 continues for this slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-07 04:32:09 -07:00
mvschwarzandClaude Opus 4.7 7a026128a0 shell-redesign V1 pre-release CLI/daemon Item 3: rename projection markers from RIGGED to OpenRig
Renames the managed-block markers that the daemon writes into user-managed
files (CLAUDE.md / AGENTS.md) from "BEGIN/END RIGGED MANAGED BLOCK" to
"BEGIN/END OpenRig MANAGED BLOCK". The marker carries the canonical
product name now that the package and CLI binary use the openrig
identity.

Backwards-compat for files written by prior installs:
- stripManagedBlocks recognizes both legacy (RIGGED) and current
  (OpenRig) marker patterns so uninstall/cleanup paths handle both
  transitional states.
- mergeManagedBlock detects legacy blocks for a package id when
  deciding whether to replace; the replacement always emits the
  OpenRig form so user files migrate forward in place.
- conflict-detector.hasExistingBlock returns true for legacy markers
  too, so re-applying a guidance package over a pre-rename file
  doesn't quietly add a duplicate block.
- install-engine.applyGuidanceMerge falls back to legacy marker
  search when the OpenRig markers aren't present, then writes the
  OpenRig form in place of the legacy block.
- install-verifier accepts either marker form when checking that a
  guidance install's BEGIN/END markers are intact.

Test fixtures and assertions across 8 daemon tests updated to expect
the new marker form. Production write paths emit OpenRig exclusively;
production read paths recognize both forms.

Verification:
- npx tsc --noEmit --project packages/daemon/tsconfig.json: clean
- npx vitest run on the 9 affected test files: 178/178 PASS

SC-29 EXCEPTION #4 (declared in pre-release CLI/daemon ACK §5):
release-blocker product-feature change, not allowlist-only. Existing
allowlist exceptions #1 (Phase 4 advisor/operator) + #2 (Phase 5
feed.subscriptions) + additive #3 (polish-slice-4 graph payload token
telemetry) untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-07 04:29:41 -07:00
mvschwarz ecaca01131 merge(shell-redesign-v1): pre-release UI slice — Library taxonomy + skills browse + file-drawer fix
Pre-release UI work for the next release: Library page restructure with 8-section taxonomy + skills folder browsing, plus file-drawer load-content fix on agent-spec guidance + LiveNodeDetails startup files.

═══ COMMIT CHAIN (2 SHAs) ═══
- 7284cda Item 5: file-drawer loads content via /api/files roots/list/read OR absolute-path resolution; agent-spec guidance + LiveNodeDetails startup files now pass loadable file provenance; FileViewer dual-path resolution (root+path AND absolutePath via resolveAbsoluteToRoot helper)
- ff2b1a6 Item 4: Library taxonomy restructure + skills section

═══ ITEM 5: File-drawer load fix ═══
Root cause: FileViewer was stuck-on-loading when surfaces passed only {path} without root provenance. Fix:
- FileViewer accepts absolutePath OR root+path; resolves absolute against allowlist roots via resolveAbsoluteToRoot helper (DESC-by-length sort to match nested allowlist)
- LiveNodeDetails startup files pass resolved absolutePath via useNodeDetail extension
- AgentSpecDisplay guidance row → FileReferenceTrigger with absolutePath
- Graceful degradation (404/missing → null; no crash)
- New 21+ unit tests across drawer-primitives + live-node-details + spec-display

═══ ITEM 4: Library taxonomy restructure ═══
- Page label "Specs" → "Library" (URL stays /specs for deeplink stability; UI-vs-URL trade-off documented as known)
- 8-section canonical taxonomy: rig specs / workspace specs / workflow specs / context packs / agent specs / agent images / applications / skills
- agent-specs and agent-images SPLIT (were misnamed/conflated; agent-specs = YAML; agent-images = JSONL forked-image refs)
- NEW skills section reads from .openrig/skills/ (workspace) + dev-source skills + npm-installed openrig-managed skills
- Skills browse via NEW useLibrarySkills hook reading existing /api/files roots/list/read (NO new daemon route — verified by guard-3 negative scan; skills discovery degrades empty when unavailable)
- Skill folder click → file-explorer + markdown preview (reuses workspace-artifacts component pattern)
- Library tree + main page both render canonical taxonomy order

═══ SC-29 POSTURE ═══
UI-only this slice. Daemon byte-equivalent beyond cumulative declared exceptions (#1 Phase 4 / #2 Phase 5 / #3 Phase 5.4 graph payload telemetry). NO new daemon route or schema edit despite skills-discovery scope expansion (verified clean).

═══ V1 CONTRACT PRESERVATION ═══
All preserved unchanged: Class B fixed-anchor / Phase 5.2 collapse + auto-expand / Phase 5.1 navigate-parity / Phase 5.3 default-expanded / Phase 5.4 activity tint+pulse + token telemetry / Phase 5.5+5.6+5.7 terminal black-glass + portal / Phase 5.7 vellum 20-30% transparency / Phase 5.8 LiveNodeDetails restructure + Story tree + Overview/Artifacts split + Topology React Flow workflow.

═══ KEY ARCHITECTURE LESSONS BANKED ═══
- resolveAbsoluteToRoot pattern (DESC-by-length sort to match nested allowlist roots) — reusable for any absolute-path-to-allowlist-root resolution
- Graceful degradation pattern for hooks reading filesystem roots (404/missing → null; empty list when unavailable; no crashed UI)
- URL-vs-UI-label divergence trade-off documented (preserve deeplink stability over UI/URL consistency when stability is load-bearing)

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN both SHAs (3 FYI patterns banked)
- redo3-guard-3: CLEAR at ff2b1a6 (one round of process-gap fix for ACK §4 target_write_paths drift; useNodeDetail.ts added)
- design-reviewer: DONE all 6 checks + V1 contracts preserved (live walk confirmed library taxonomy + skill folder drawer + file-drawer fix; SKILL.md rendering verified)

═══ STYLE RULE ═══
Commits + ACK + walk packet all neutral phrasing per banked memory (no verbatim quotes; no chain-of-authorization attribution).
2026-05-07 04:29:29 -07:00
mvschwarz ff2b1a6d40 Restructure library taxonomy and skills browsing 2026-05-07 04:17:39 -07:00
mvschwarz 7284cda909 Load file drawer content from file roots 2026-05-07 04:12:00 -07:00
mvschwarz 4ad5078224 merge(shell-redesign-v1): Phase 5.8 polish slice — demo-readiness UI restructures
4-item bundle for demo-readiness per founder direction 2026-05-07 VM walk: agent detail page restructure + slice Story tree visualization + Overview/Artifacts split + slice Topology React Flow workflow diagram.

═══ COMMIT CHAIN (2 SHAs) ═══
- a5faf03 polish-8: 4-item demo-readiness restructure
- d330fe8 process-gap fix: NODE_WIDTH/NODE_HEIGHT single-sourced + ritual #9 guard

═══ ITEM 1: LiveNodeDetails restructure ═══
- Tab row IMMEDIATELY below action buttons (was at bottom; persistent header content above tabs gone)
- 5 tabs: Identity / Agent Spec / Startup / Transcript / Terminal
- Identity tab: WorkflowHeader + Activity + Recent Events + Edges + Peers
- Startup tab: STARTUP + Restore + Error/Recovery + Preview + Infrastructure + Files
- Transcript tab: transcript stream
- Terminal tab: BLACK-SMOKE aesthetic (bg-stone-950/65 + backdrop-blur-sm + text-stone-50; reuses SessionPreviewPane compact-terminal variant from polish-5/-7)
- LiveIdentityDisplay.tsx DELETED (-132 LOC; consolidated into tab structure)
- Reverses Phase 5.1 persistent-header decision per founder-direction-2026-05-07

═══ ITEM 2: Story tab tree visualization ═══
- Replaces flat blob-of-text timeline with connected step-card tree (story-step-tree + story-step-connector elements)
- NEWEST-FIRST sort order (timestampSortValue descending)
- Each event card: type badge + description + sender→receiver arrow + qitem ID
- Visual flow communication via vertical connector lines

═══ ITEM 3: Overview vs Artifacts split ═══
- Distinct first-class content shapes (was DocsTab dual-mount placeholder)
- Overview: high-level summary grid (slice-overview-summary)
- Artifacts: file listings + commits (slice-artifacts-files + slice-artifacts-commits)
- Resolves Phase 5.2 OBSERVATION-2 (DocsTab dual-mount worth orch awareness)

═══ ITEM 4: Topology tab React Flow workflow diagram ═══
- NEW SliceWorkflowGraph.tsx using react-flow + dagre layout
- Replaces text-table workflow listing with daisy-chain workflow loop graph
- Each step is a custom node: title + role + agent assignment
- Edges show step transitions including LOOP edges
- Vellum aesthetic via RegistrationMarks composition at node level (canonical pattern: React Flow surfaces compose vellum primitives via custom NodeTypes)
- Old SpecGraphPanel orphan retired with negative-assertion guard

═══ KEY ARCHITECTURE LESSONS BANKED ═══
- React Flow node-level vellum composition pattern (RegistrationMarks inside custom NodeType bodies; extends polish-3 hybrid-layout pattern)
- Newest-first vs phase-headings ordering trade-off (operator-scan UX vs lifecycle-narrative UX) documented for future ordering decisions

═══ V1 CONTRACT PRESERVATION ═══
All preserved unchanged: Class B fixed-anchor / Phase 5.2 collapse + auto-expand / Phase 5.1 navigate-parity / Phase 5.3 default-expanded / Phase 5.4 activity tint+pulse + token telemetry / Phase 5.5 compact-terminal / Phase 5.6 portal top-layer / Phase 5.7 vellum + black-glass paradigm.

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN both SHAs (3 FYI: Phase 5.2 OBSERVATION-2 RESOLVED / React Flow node-level vellum composition pattern banked / newest-first vs phase-headings UX trade-off documented)
- redo3-guard-3: CLEAR at d330fe8 (one round of process-gap fix for ritual #9 NODE_WIDTH/NODE_HEIGHT coupled-literal)
- design-reviewer: DONE all 4 items + V1 contracts preserved (source-scan + reference screenshot triangulation due to worktree dev lacking demo-seeded data)

═══ SC-29 POSTURE ═══
UI-only this slice. Daemon byte-equivalent beyond cumulative declared exceptions (#1 Phase 4 agents.* / #2 Phase 5 feed.subscriptions.* / #3 Phase 5.4 graph payload token telemetry).
2026-05-07 03:50:23 -07:00
mvschwarz d330fe81e8 Single-source slice workflow node dimensions 2026-05-07 03:46:58 -07:00
mvschwarz a5faf034e4 Polish slice detail demo surfaces 2026-05-07 03:41:22 -07:00
mvschwarz f664c26b75 merge(shell-redesign-v1): Phase 5.7 polish slice — vellum + black-glass aesthetic refinement
Enforces two distinct visual languages across the V1 surface per founder direction 2026-05-07 VM walk:
- VELLUM (canonical persistent surfaces — topology cards + Dashboard cards): paper-press shadow + 1px outline-variant + reg-marks PRESERVED; transparency bumped 20-30% so paper-grid backdrop bleeds through (reads as 'sheets of vellum paper')
- BLACK GLASS (transient overlays — terminal popover): borderless + shadowless + transparent dark stone-950/65 + backdrop-blur-sm + lighter stone-50 text (reads as 'floating smoked-glass panel')

═══ COMMIT ═══
- 15115fd vellum + black-glass aesthetic refinement

═══ CHANGES ═══
- /topology agent + rig cards: activity-card-surface bg 0.96 → 0.72 (24%+ more transparent); RigGroupNode collapsed bg-white/40 → /30 (hover /60 → /40); RigGroupNode expanded bg-white/[0.14] → /[0.10]
- / Dashboard 6 cards: NEW shared dashboardCardSurfaceClass at packages/ui/src/components/dashboard/cards/card-surface.ts ('h-full bg-white/30 hover:bg-white/40 hover:hard-shadow-hover'); Topology / Project / For You / Specs / Search / Settings cards all consume it; black header strips + icons unchanged
- TerminalPreviewPopover: removed border-outline-variant + hard-shadow; bg-stone-950 (opaque) → bg-stone-950/65 + backdrop-blur-sm; text-stone-100 → text-stone-50
- SessionPreviewPane compact-terminal variant: cleaner empty-state ('Preview unavailable.\n$ waiting for terminal output') — terminal-shell-prompt style replaces verbose 'preview_unavailable + rig capture fallback' diagnostic
- Tests: dashboard-card-vellum.test.tsx (NEW; asserts 6-card shared-class consumption); session-preview-pane (+1 test for empty-state); p5-3-activity-ring-hot-potato (+21 LOC assertions for smoked-glass + transparency)

═══ DESIGN PRINCIPLE BANKED ═══
"Framing + opacity + shadow signal persistence weight; transient overlays should read as floating glass over canvas, not as competing framed surfaces."
- Persistent canonical surfaces → vellum (1px-outline-variant + paper-press + reg-marks; opt into transparency for backdrop bleed-through)
- Transient overlay surfaces → black glass (borderless + shadowless + transparent + backdrop-blur)

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN (3 FYI: dual visual language as V2 design principle / transparency calibration centralized via shared class / terminal-prompt-style copy discipline)
- redo3-guard-3: CLEAR FIRST-TRY (banked correction holding: durable ACK + walk packet from outset; second consecutive slice without process-gap loop)
- design-reviewer: DONE all 4 items + V1 contracts preserved + computed-style match against founder reference screenshots

═══ V1 CONTRACT PRESERVATION ═══
All preserved unchanged: Class B fixed-anchor / Phase 5.2 collapse + auto-expand / Phase 5.1 navigate-parity / Phase 5.3 default-expanded + setRigExpanded / Phase 5.4 activity tint+pulse + larger packet + token telemetry / Phase 5.5 compact-terminal variant / Phase 5.6 portal top-layer.

═══ SC-29 POSTURE ═══
UI-only this slice. Cumulative SC-29 exceptions unchanged (#1 Phase 4 agents.* / #2 Phase 5 feed.subscriptions.* / #3 Phase 5.4 graph payload token telemetry).
2026-05-07 02:55:42 -07:00
mvschwarz 15115fdc04 Refine vellum and terminal preview surfaces 2026-05-07 02:48:23 -07:00
mvschwarz 7d4334b1de merge(shell-redesign-v1): Phase 5.6 polish slice — terminal popover top-layer via React portal
Z-stacking fix: TerminalPreviewPopover gets overlapped by adjacent agent cards because it lived inside React Flow's node stacking context where z-index alone can't escape. Fixed via createPortal to document.body + position:fixed + z-[1000].

═══ COMMIT ═══
- c265707 portal terminal preview popover

═══ KEY ARCHITECTURE LESSON BANKED ═══
"When a popover/dropdown/tooltip needs to overlay content beyond its parent's stacking context, portal to document.body. z-index alone won't escape an enclosing stacking context (React Flow nodes establish stacking contexts via z-index + transform; child can't paint above the max sibling z-index without portal escape)."

═══ CHANGES ═══
- TerminalPreviewPopover.tsx: createPortal(content, document.body) + position:fixed + z-[1000]
- computePopoverPosition: viewport-clamped placement (anchor.right + 8px gap; fallback to anchor.left - width - 8px if no right-side room)
- useLayoutEffect re-measures + repositions when popover gets actual dimensions
- pointerdown handler updated to check both rootRef AND popoverRef contains-target (since portal-rendered child is no longer DOM descendant of root)
- Tests: parentElement === document.body + className includes 'fixed' + 'z-[1000]' + style.left/top non-empty + source-scan asserts createPortal + document.body + z-[1000] present + 'absolute left-full' absent

═══ FIGMA-STYLE BEHAVIOR ═══
Anchor-once-on-open: popover captures source-rect at open time and stays viewport-fixed; doesn't track subsequent source movement on canvas pan. Figma idiom for floating panels. Alternative follow-source pattern noted as future option if needed.

═══ DESIGN OBSERVATION (acknowledged; not blocking V1) ═══
Pan-mousedown currently closes the popover via document-level pointerdown click-outside dismiss handler (third pattern beyond viewport-fixed-stay vs follow-source). Acceptable for V1; future polish slice can distinguish click vs pan-with-movement to keep popover open during canvas pan.

═══ SC-29 POSTURE ═══
UI-only this slice. Cumulative SC-29 exceptions unchanged.

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN (3 FYI: stacking-context lesson worth banking as ritual / anchor-once vs follow-source documented / orphaned-popover edge case acceptable per figma idiom)
- redo3-guard-3: CLEAR FIRST-TRY (banked-correction landed: durable ACK + walk packet from outset; no process-gap loop)
- design-reviewer: DONE all 6 items + V1 contracts preserved + 1 design observation on pan-close behavior

═══ V1 CONTRACT PRESERVATION ═══
All preserved unchanged: Class B fixed-anchor / Phase 5.2 collapse + auto-expand / Phase 5.1 navigate-parity / Phase 5.3 default-expanded + setRigExpanded / Phase 5.4 activity tint+pulse + larger packet + token telemetry / Phase 5.5 compact-terminal variant.
2026-05-07 02:37:36 -07:00
mvschwarz c265707236 topology portal terminal preview popover 2026-05-07 02:07:07 -07:00
mvschwarz 06fb4f6f2c merge(shell-redesign-v1): Phase 5.5 polish slice — compact terminal popover (chrome-stripped + dark terminal theme)
Compact refactor of TerminalPreviewPopover per founder direction at VM walk: chrome stripped to maximize information density; dark terminal-style background; smaller font.

═══ COMMIT ═══
- 9512ab0 compact terminal popover

═══ CHANGES ═══
- TerminalPreviewPopover.tsx: removed all chrome (header label / X close button / live-preview subheader / captured-time footer / lines-count footer); only 1px outline-variant border + dark stone-950 bg + tight p-1.5 padding + light stone-100 text content
- SessionPreviewPane.tsx: added variant prop ('default' | 'compact-terminal') as scoped opt-in; default unchanged for existing callers
- TerminalPreviewPopover passes variant='compact-terminal'
- Click-outside dismiss preserved (pointerdown handler at popover root with contains-check); single-active swap preserved (custom-event 'openrig:topology-terminal-preview' bus)
- Scroll-to-bottom on mount + smart tail-follow with pause/resume preserved (compact variant only changes chrome, not scroll behavior)
- Tests: chrome-absence assertions + variant=compact-terminal data attr + click-outside dismiss + single-active swap; ritual #8 negative-assertion pattern

═══ FOUNDER DIRECTION ═══
2026-05-07 VM walk: 'just a border + terminal in it with small padding' + 'black bg like a terminal'. Implementation matches reference screenshot at /tmp/polish-5-compact-terminal.png.

═══ SC-29 POSTURE ═══
UI-only this slice. No daemon edit. Cumulative SC-29 exceptions unchanged:
- #1 Phase 4: agents.advisor_session + agents.operator_session
- #2 Phase 5: feed.subscriptions.{action_required, approvals, shipped, progress, audit_log}
- #3 Phase 5.4: contextTotalInputTokens + contextTotalOutputTokens (graph payload)

═══ V1 CONTRACT PRESERVATION ═══
- Class B fixed-anchor view-mode tabs / Phase 5.2 collapse persistence + auto-expand / Phase 5.1 navigate-parity / Phase 5.3 default-expanded + setRigExpanded fix / Phase 5.4 activity tint+pulse + larger packet + token telemetry — all preserved unchanged

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN (3 FYI: variant-enum extension potential / dark-terminal canonical pattern banked / test-coverage comprehensive)
- redo3-guard-3: CLEAR (one round of process-gap fix for durable ACK + walk packet — recurring pattern banked in orch memory: substrate docs concurrent with slice, not post-blocker)
- design-reviewer: DONE all 7 walk items + computed-style match against founder spec (bg-stone-950 + outline-variant 1px + stone-100 + p-1.5 + chrome stripped)

═══ ARCHITECTURAL PATTERN BANKED ═══
Dark-themed surfaces (terminals / transcripts / log popovers) opt into dark via inner background; outer chrome stays within vellum 1px border doctrine. First explicit canonical instance.
2026-05-07 01:59:21 -07:00
mvschwarz 9512ab0502 topology compact terminal preview popover 2026-05-07 01:54:00 -07:00
mvschwarz 3151a5d9eb merge(shell-redesign-v1): Phase 5.4 polish slice — activity visibility + token telemetry + terminal popover
Visual amplification of polish-3 activity primitives + telemetry enrichment + canvas-local terminal preview. Demo-readiness slice for recurring demo environment.

═══ COMMIT CHAIN (3 SHAs) ═══
- 714932c whole-card activity tint/pulse on RigNode + HybridAgentNode + source/target card flash on handoff events + larger non-scaling hot-potato packet
- b53849c compact 94px agent cards + graph payload token telemetry (contextTotalInputTokens + contextTotalOutputTokens additive fields) + token-format.ts canonical k/m formatter
- 1d3cabb second hover/focus terminal icon + TerminalPreviewPopover canvas-local single-active swap via custom-event bus + SessionPreviewPane scroll-to-bottom on mount + smart tail-follow with pause/resume

═══ SC-29 EXCEPTION #3 (declared) ═══
Graph payload schema enrichment for token telemetry — additive only:
- 2 new optional fields contextTotalInputTokens + contextTotalOutputTokens added to RFNodeData + InventoryOverlay interfaces in packages/daemon/src/domain/graph-projection.ts
- Populated in /rigs/:id/graph route from existing n.contextUsage upstream
- NO schema migrations / NO new endpoints / NO new event types / NO lockstep CLI (projection-only UI payload enrichment)
Founder-authorized 2026-05-07; orch-verified additive-only via diff inspection; guard-3 re-verified.

Cumulative SC-29 exceptions to date:
- Phase 4: agents.advisor_session + agents.operator_session (allowlist)
- Phase 5: feed.subscriptions.{action_required, approvals, shipped, progress, audit_log} (allowlist)
- Phase 5.4: contextTotalInputTokens + contextTotalOutputTokens (graph projection)

═══ V1 CONTRACT PRESERVATION ═══
- Class B fixed-anchor view-mode tabs at x=408/471/534
- Phase 5.2 collapse persistence + auto-expand on URL via TopologyOverlayProvider
- Phase 5.2 lazy-fetch via useQueries enabled flag
- Phase 5.1 navigate-parity preserved
- Phase 5.3 default-expanded + setRigExpanded explicit-false fix preserved
- Vellum 1px outline-variant + 38rem drawer + selective vellum overlay graph-only
- StatusPip semantic dot kept separate from ActivityRing behavioral ring

═══ KEY ARCHITECTURE PATTERNS BANKED ═══
- DOM event bus for sibling-popover single-active coordination ('openrig:topology-terminal-preview' custom event)
- Smart tail-follow with three states: auto-stick (default) / paused (user scrolled up) / resumed (user scrolled back to bottom); 4px tolerance for "near bottom"
- Hover toolbar composability: shared hoverIconClass + group-hover/group-focus-within reveal pattern; multiple icons reveal atomically; keyboard accessibility preserved
- Activity tint/pulse via getActivityCardClasses() deriver + 5 keyframes (active/needs_input/blocked/source-flash/target-flash) + reduced-motion :not() modifier
- Larger non-scaling packets via vectorEffect=non-scaling-stroke (stays crisp at minZoom 0.03)

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN on all 3 SHAs (3 FYI patterns banked: token-format.ts canonical / single-active-swap reusable / smart-tail-follow reusable)
- redo3-guard-3: CLEAR at 1d3cabb (one round of process-gap fix resolved durable ACK + walk packet artifacts; SC-29 #3 verified additive-only)
- design-reviewer: DONE all 5 walk items at 1d3cabb (hover toolbar / terminal popover / scroll-to-bottom + tail-follow / compact cards + token telemetry / activity tint+flash + larger packet / V1 contracts preserved)

═══ DEMO-READINESS NOTE ═══
This slice + post-merge VM rsync + VM daemon restart unlocks the recurring demo environment full visualization: real RSI v2 hot-potato handoffs from velocity-qa's demo-seed cycle 1 will animate as larger packets traveling between cards + source/target flash + ActivityRing pulse + token telemetry on every node card.
2026-05-07 01:31:06 -07:00
mvschwarz 1d3cabbdda topology add terminal preview popover 2026-05-07 01:22:07 -07:00
mvschwarz b53849c044 topology compact agent token cards 2026-05-07 01:09:05 -07:00
mvschwarz 714932c491 topology make activity states visible 2026-05-07 01:01:44 -07:00
mvschwarz b4b93f4d69 merge(shell-redesign-v1): Phase 5.3 polish slice — hybrid layout + activity rings + hot-potato animation + canvas controls
Promotes the Pattern 3 hybrid layout to canonical /topology + adds ActivityRing primitive + HotPotatoEdge animation + hover-only CMUX (graph + table) + figma-canvas controls (Expand All / Collapse All / minZoom 0.03 for fleet-scale far zoom-out / default-expanded for landing legibility).

═══ COMMIT CHAIN (4 SHAs) ═══
- 2ea5748 architecture checkpoint: shared /api/events client hub + hybrid dagre layout helpers + compact node grammar + HostMultiRigGraph hybrid replacement
- 0aa7960 activity flow: ActivityRing topology-domain primitive + topology activity resolver/cache over consolidated hub + HotPotatoEdge graph-only animated edge + table hover CMUX + reduced-motion support
- 8cc36e9 follow-up: /topology graph defaults all rigs expanded + Expand All / Collapse All canvas controls + minZoom 0.5→0.03 + auto-fit reruns after layout change + setRigExpanded explicit-false override fix
- 06cb6b2 process-gap fix: cross-rig dash literal consolidated to single HYBRID_CROSS_RIG_STROKE_DASH source + ritual #9 source-assertion regression test

═══ ARCHITECTURAL HIGHLIGHTS ═══
- ActivityRing primitive separate from StatusPip (semantic dot vs behavioral ring; topology-domain scoped at components/topology/ not shared ui/)
- /api/events SSE consolidated to ONE connection across useActivityFeed + useGlobalEvents + useRigEvents + useTopologyEdgeActivity (was 3+ separate connections; useRigChat appropriately on chat watch)
- HotPotatoEdge graph-only (table gets ActivityRing + hover CMUX but NOT directional packet animation per founder design)
- Cross-rig handoffs visually distinct: dashed stroke + lower weight + smaller desaturated packet + 1400-1800ms duration (vs 900-1300ms within-rig solid + 2.25 stroke + emerald-700 vivid)
- Activity ring states: active/needs_input/blocked/idle (idle ABSENT not constant ring)
- prefers-reduced-motion honored: animations disabled; static edge highlight + ring substituted
- Session-string→React-Flow-node-id resolver: quiet-drop unresolved + debug counter for observability
- Figma-canvas zoom range 0.03→2 enables fleet-scale viewing

═══ V1 CONTRACT PRESERVATION ═══
- Class B fixed-anchor view-mode tabs at x=408 (graph/table/terminal)
- Phase 5.2 collapse persistence via TopologyOverlayProvider expandedRigs Map + auto-expand on URL via useRouterState (preserved; default value flipped from false to true per UX direction; setRigExpanded bug fixed: explicit false now overrides via Map.has/get idiom not ?? fallback)
- Phase 5.2 lazy-fetch via useQueries enabled flag (only expanded rigs fan out useRigGraph)
- Phase 5.2 cross-rig node-ID prefixing
- Phase 5.1 navigate-parity (graph node click = tree click = table row click → /topology/seat/$rigId/$logicalId)
- Phase 5.1 LiveNodeDetails canonical surface preserved
- Vellum primitives + 1px outline-variant + 38rem drawer + selective vellum overlay graph-only

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN on all 4 SHAs (3 FYI observations across the chain; none blocking)
- redo3-guard-3: ARCHITECTURE-CLEAR mid-slice at 2ea5748 + final CLEAR on full chain at 06cb6b2 (one round of process-gap fixes resolved durable ACK + drift disclosure + ACK §4 schema field-names)
- design-reviewer: DONE at 0aa7960 (all 8 dispatch items) + DONE at 8cc36e9 (4 new behaviors + regression preserved) + skipped 06cb6b2 (source-only consolidation; no visual delta)

═══ DISCIPLINE BANKED LESSONS ═══
- Self-verify ritual #10 candidate: when flipping a state-management default value X→Y, audit all callsites comparing against old default via fallback operators (?? || ??=); they may silently treat 'value matches new default Y' as 'value missing'. Surfaced via F2 setRigExpanded explicit-false override bug.
- Pairs with prior banked lessons: Phase 5 P0-2 discriminator-migration two-checks; Phase 5.2 useEffect-route-mount tracing.
- Ritual #9 coupled-literal application 5+ across the workstream now well-banked.

═══ SC-29 POSTURE ═══
UI-only this slice. Daemon byte-equivalent beyond Phase 4 + Phase 5 declared exceptions.

═══ STYLE RULE ═══
Commits + ACK + walk packets all neutral phrasing per banked memory.
2026-05-07 00:31:47 -07:00
mvschwarz 06cb6b21c6 polish-3 share cross-rig dash constant 2026-05-07 00:25:26 -07:00
mvschwarz 8cc36e9ee4 polish-3 expand topology by default 2026-05-07 00:17:12 -07:00
mvschwarz 0aa7960215 polish-3 topology activity flow 2026-05-06 23:52:33 -07:00
mvschwarz 2ea5748c31 polish-3 hybrid topology architecture checkpoint 2026-05-06 23:24:18 -07:00
mvschwarz cce70504ff merge(shell-redesign-v1): Phase 5.2 polish slice — multi-rig single-canvas /topology + collapse affordance
Closes Phase 5.1 P5.1-4 parked slice. /topology graph view-mode renders all rigs on a single react-flow canvas with default-collapsed rig cards; click body to expand, click "→" Link on rig name to drill into /topology/rig/$id (Figma frame-name vs frame-body convention).

═══ COMMIT CHAIN (2 SHAs) ═══
- abb154e Phase 5.2: HostMultiRigGraph + RigGroupNode + multi-rig-layout helper + 8-item integration
- cd52733 Phase 5.2 bounce-fix: Item 6 auto-expand on URL; lift expandedRigs to TopologyOverlayProvider; parseActiveRigId helper; fixes dead-code under sibling-route architecture

═══ ARCHITECTURE ═══
- useQueries with `enabled` flag per rig — lazy-fetch on expand only; 0 graph fetches at default-all-collapsed initial mount
- Cross-rig node-ID prefixing (`${rigId}::${nodeId}`) prevents react-flow ID collisions
- Per-rig + outer-offset layout (option a) — toggling rig N doesn't reflow rigs 1..N-1; collapse-stability invariant
- click handler reads rigId from node.data.rigId (NOT closure) — Phase 5.1 unification preserved
- expandedRigs Map lifted into TopologyOverlayProvider (always mounted at AppShell scope) — survives sibling-route navigation; useEffect at provider scope reads useRouterState pathname; auto-expands on /topology/{rig,seat,pod}/* URLs

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN on both SHAs (3 FYI observations on first SHA + 1 self-verify lesson banked on second SHA — neither blocking)
- redo3-guard-3: CLEAR at cd52733 (one PROCESS-BLOCKER on abb154e ACK preflight stale-paths resolved by substrate-only ACK amendment)
- design-reviewer: DONE at cd52733 (8 walk routes verified; SPA-navigation walk confirms Item 6 fix closes dead-code gap; vellum tactical canon preserved)

═══ DISCIPLINE ═══
- 9 rituals applied; ritual #6 reachability + ritual #8 negative-assertion (no .map(useRigGraph) anti-pattern) + ritual #9 coupled-literal scan
- New self-verify lesson banked: trace URL→component-mount mapping when verifying useEffect-based cross-route claims (sibling-parented routes mean effect is dead code at non-mounting URLs)
- Style rule: no verbatim conversational quotes; commits + ACK + walk packets all neutral phrasing
- SC-29 unchanged — UI-only this slice; daemon byte-equivalent beyond Phase 4 + Phase 5 declared exceptions
2026-05-06 21:55:56 -07:00
mvschwarzandClaude Opus 4.7 cd5273363f shell-redesign V1 polish slice Phase 5.2 BOUNCE-FIX: lift rig-expanded state into TopologyOverlayProvider
Closes the dead-code auto-expand bug surfaced by the design-reviewer
walk on abb154e. Five-point evidence chain identified the root cause:

  1. routes.tsx topology routes are SIBLING parented to rootRoute,
     NOT nested. /topology/rig/$id mounts RigScopePage; it does NOT
     mount HostMultiRigGraph.
  2. HostMultiRigGraph held `expanded` as local useState — reset on
     every mount when operator returned to /topology.
  3. The in-component activeRigId useEffect could only fire while
     HostMultiRigGraph itself was mounted. Direct-URL entry at
     /topology/rig/$id never had a chance to set state because the
     component wasn't mounted at that path.
  4. topology-overlay-context.tsx previously carried only ExplorerMode;
     no rig-expanded persistence layer existed.
  5. Live walk confirmed: /topology/seat → /topology/rig → /topology
     returned to expanded=[] (all rigs collapsed).

Plus zero auto-expand test coverage in p5-2-host-multi-rig-graph.

FIX (option (a) per design-reviewer recommendation):
- Lift `expandedRigs: Map<string, boolean>` into TopologyOverlayProvider
  alongside the existing ExplorerMode state. New context fields:
  expandedRigs (read), setRigExpanded(rigId, expanded), toggleRig(rigId).
- Auto-expand useEffect runs at PROVIDER scope (always mounted under
  AppShell) — reads pathname via useRouterState, parses active rigId
  via NEW exported parseActiveRigId helper, and dispatches
  setRigExpanded(rigId, true) on every rig-scoped URL pattern.
  Fires regardless of which scope page is currently in the center, so
  direct-URL navigation to /topology/rig/$id (RigScopePage) updates
  the context state; when the operator returns to /topology,
  HostMultiRigGraph mounts and reads the persisted state.
- HostMultiRigGraph now consumes via useTopologyOverlay() — replaces
  the local useState + the orphaned in-component useEffect. Drop both.

NEW packages/ui/test/p5-2-bounce-rig-expand-persistence.test.tsx
(14 tests):
- parseActiveRigId pure-fn covers all three rig-scoped URL shapes
  (seat / pod / rig) + URL-encoded rigIds + null at /topology root
  + null at non-topology routes.
- Provider auto-expand useEffect: route initial-entry at /topology/rig
  /seat /pod each set expandedRigs[rigId]=true verified via a
  ContextProbe child that exposes ctx.expandedRigs as testid attribute
  (HostMultiRigGraph NOT mounted in those tests — proves the effect
  fires at provider scope, the dead-code bug fix).
- /topology root pathname does NOT auto-expand any rig (default-all-
  collapsed preserved).
- HostMultiRigGraph reads from context: when context's expandedRigs
  has rigId=true on mount, the rig group renders expanded; default
  context (empty Map) → all rigs render collapsed.
- Source-assertion guards: HostMultiRigGraph imports useTopologyOverlay
  + has no `useState<Map<string, boolean>>` local state (regression
  guard against the dead-code path returning); topology-overlay-context
  exposes the four context fields + the parseActiveRigId export +
  contains useRouterState + parseActiveRigId call (the auto-expand
  wiring).

UPDATED packages/ui/test/p5-2-host-multi-rig-graph.test.tsx:
- withQueryClient wrapper now wraps in TopologyOverlayProvider so
  HostMultiRigGraph's useTopologyOverlay() returns a real toggleRig
  (not the no-op default). Required for the existing collapse-toggle
  test to actually flip state.
- ritual #9 coupled-literal scan redirected: useState<Map<...>>
  initializer assertion now reads from
  topology-overlay-context.tsx (where the state was lifted to);
  collapsed: !p.isExpanded literal still in HostMultiRigGraph and
  still asserted there.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-2-bounce-rig-expand-persistence: 14/14 PASS
- vitest p5-2-host-multi-rig-graph: 16/16 PASS (existing tests still
  pass after wrapper + literal-scan updates)
- vitest full UI suite: 706/708 PASS (1 pre-existing library-review
  baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean (1.91s)

SC-29 posture: UNCHANGED — UI-only this slice. Daemon byte-equivalent
beyond Phase 4 + Phase 5 declared exceptions.

Scope discipline: bounded to Item 6 only. The 7 PASS items from the
design-reviewer original audit (rendering / lazy-fetch / cross-rig
prefixing / vellum primitive grammar / collapse-toggle / option (a)
layout / click navigation) all stay as-shipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 21:46:14 -07:00
mvschwarzandClaude Opus 4.7 abb154eff3 shell-redesign V1 polish slice Phase 5.2: multi-rig single-canvas /topology graph + rig-collapse affordance
Unparks P5.1-4 with explicit V1 scope expansion to include the
rig-collapse affordance. The /topology landing page becomes a
figma-style canvas for fleet management at scale: by default ALL rigs
appear as collapsed vellum cards on a single ReactFlow canvas; click a
card body to expand it inline; click the "→" arrow on the rig name to
drill into the rig page.

Scope = 8 dispatch items bundled into one canvas component:

NEW packages/ui/src/components/topology/HostMultiRigGraph.tsx:
- P5.2-1 composition: ReactFlow canvas with merged nodes/edges across
  N rigs; nodeTypes registers rigGroup + podGroup + rigNode; controls
  pinned bottom-right with vellum styling.
- P5.2-2 useQueries pattern: single hook call regardless of rig count
  (P0-1 anti-pattern guarded; tests assert no .map(useRigGraph) loop).
  Per-rig graph fetch is enabled only when the rig is expanded —
  scales to 100+ rigs without upfront fan-out (counts come from a
  single /api/ps fetch).
- P5.2-3 cross-rig node-ID prefixing via prefixRigData helper:
  every node carries `data.rigId`; IDs become `${rigId}::${nodeId}`;
  parentId for react-flow parent/child also prefixed.
- P5.2-5 rig-collapse default + click-toggle: state held as
  Map<rigId, boolean>; default empty Map (all collapsed); rig body
  click toggles; multiple rigs expand independently; per-session.
- Auto-expand rule (parity with Phase 5.1 TopologyTreeView): on
  /topology/seat/$rigId/* or /topology/pod/$rigId/* or
  /topology/rig/$rigId, the matching rig auto-expands on mount and
  on route change.
- P5.2-7 click handler reads rigId from `node.data.rigId` (NOT
  closure): agent click → useNavigate seat URL; pod group click →
  useNavigate pod URL; rigGroup body click → no-op at the canvas
  level (handled by RigGroupNode's own onClick → onToggle).
- P5.2-8 per-rig features: cmux focus / placement / edge activity
  thread through node.data.rigId by construction.

NEW packages/ui/src/components/topology/RigGroupNode.tsx:
- P5.2-4 ReactFlow node-type renderer for rigGroup. Vellum tactical
  composition per Phase 1 primitive grammar: 1px outline-variant
  border + RegistrationMarks at 4 corners + paper-press hard-shadow +
  font-mono uppercase rig name + counts subline + StatusPip.
- Body click → onToggle(rigId) (provided by HostMultiRigGraph).
- Drill-in "→" Link to /topology/rig/$rigId on the rig name; its
  onClick e.stopPropagation() prevents the body's toggle from also
  firing. Figma frame-name vs frame-body convention.
- Collapsed visual: card body with counts + status pill.
- Expanded visual: thin counts strip (header/border) acts as the
  bounding-box frame; children render inside via react-flow
  parent/child positioning.

NEW packages/ui/src/lib/multi-rig-layout.ts:
- P5.2-6 layout helpers (option (a) per-rig + outer offset per ACK §2):
  * prefixRigData(rigId, nodes, edges) — cross-rig prefixing + data.rigId
  * computeBounds(nodes) — bounding box for an expanded rig
  * packRigGroups(rigBounds, viewportWidth) — greedy row-fill grid
- Constants: COLLAPSED_RIG_WIDTH=280, COLLAPSED_RIG_HEIGHT=120,
  RIG_HEADER_HEIGHT=60, RIG_PADDING=16, RIG_GUTTER_X=48, RIG_GUTTER_Y=48.

REFACTORED packages/ui/src/components/topology/ScopePages.tsx:
- HostScopePage graph view-mode replaces the prior placeholder card
  with `<HostMultiRigGraph />`. Mobile graph→table degradation pattern
  preserved (effectiveActive gate via useShellViewport per P5-9).

NEW packages/ui/test/p5-2-host-multi-rig-graph.test.tsx (16 tests):
- multi-rig-layout pure-fn tests: prefixRigData prefixes IDs +
  threads data.rigId; two rigs with overlapping internal IDs have
  no collision after prefixing (ritual #9 coupled-literal scan);
  parentId preserved + prefixed; packRigGroups row-fill + wrap;
  computeBounds covers empty + multi-node cases.
- HostMultiRigGraph reachability (ritual #6): mounts cleanly with N
  rigs from /api/ps; default ALL collapsed; rig body click toggles
  collapse state; drill-in "→" Link rendered separately from body
  (stopPropagation contract guarded by source-assertion); empty rig
  list renders honest empty-state.
- Source-assertion guards: HostMultiRigGraph imports useQueries from
  @tanstack/react-query and calls it (positive); no .map(useRigGraph)
  rules-of-hooks anti-pattern (ritual #8 negative); no useNodeSelection
  (Phase 5.1 retired); RigGroupNode contains 1px outline-variant +
  hard-shadow + RegistrationMarks (vellum primitive grammar guard);
  drill Link contains e.stopPropagation() (ritual #9).
- Default-collapsed coupled-literal scan (ritual #9): useState<Map<...>>
  initializer is `() => new Map()` (empty map → all rigs default to
  collapsed via expanded.get(rigId) ?? false); collapsed boolean is
  `!p.isExpanded` (single source of truth for collapse semantic).

UPDATED packages/ui/test/p5-mobile-polish.test.tsx:
- Topology graph degradation tests: the prior `topology-host-graph-
  placeholder` testid no longer exists at any viewport (P5.2 removed
  the placeholder). Updated assertions to look for `host-multi-rig-
  graph` (or empty-state) at >= lg viewport; both placeholder and
  multi-rig canvas absent at <lg (table degradation path unchanged).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-2-host-multi-rig-graph: 16/16 PASS
- vitest full UI suite: 692/694 PASS (1 pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean (1.93s)

SC-29 posture: UNCHANGED — UI-only this slice. Daemon byte-equivalent
beyond Phase 4 + Phase 5 declared exceptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 21:17:34 -07:00
mvschwarz d8ad5d4292 merge(shell-redesign-v1): Phase 5.1 polish slice — agent detail dedup + topology polish
V1 polish slice closing 5 of 7 founder-walk follow-up items; 1 parked-with-evidence (P5.1-4 multi-rig host graph = new-build territory).

═══ COMMIT CHAIN (5 SHAs; post-style-rule rebase) ═══
- f3780c0 P5.1-1+1a+2 agent detail dedup + layout + graph nav
- 49108fe P5.1-3 pod name truncation fix
- a3a76a8 P5.1-7 table CMUX column + row click navigation
- 1eb71fc P5.1-6 rig overview tab → RigSpecDisplay
- 971170d P5.1-5 pod-scope graph view

═══ ARCHITECTURAL WIN ═══
P5.1-1+1a+2 dedups NodeDetailPanel + SeatDetailViewer + SeatDetailTrigger — all DELETED. LiveNodeDetails becomes the single canonical agent-detail surface with action buttons at TOP + persistent header + single 5-tab body (Identity/Agent Spec/Startup/Transcript/Terminal). Three click entry points (graph node / Explorer tree / table row) all unified to navigate(/topology/seat/$rigId/$logicalId). Explorer tree auto-expands to show context.

═══ DRIFTS DECLARED + CANON UPDATED IN-SLICE ═══
- DRIFT P5.1-D1: SC-11 seat-scope outer tabs (detail/transcript/terminal) RETIRED. topology-tree.md L48-L53 amended.
- DRIFT P5.1-D2: SeatDetailViewer drawer + SeatDetailTrigger primitive RETIRED. content-drawer.md L37-L42 amended (manual-trigger pattern moved from "details icon on topology node" to navigate-to-center).

═══ SC-29 POSTURE ═══
UNCHANGED — UI-only this slice. Daemon byte-equivalent beyond Phase 4 + Phase 5 declared exceptions.

═══ STYLE-RULE COMPLIANCE ═══
Commits authored under no-verbatim-quotes style rule (banked in orch memory after V1 ship). Original verbatim-quote commits rebased to neutral phrasing pre-merge; diff equivalence verified empty.

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN on all 5 SHAs (3 FYI observations; none blocking)
- redo3-guard-3: CLEAR (preflight subshape match; canon updates landed; SC-29 untouched; ritual #8 conversions; style-rule honored)
- design-reviewer: DONE (8 routes walked at 1440x900 on VM; zero bar violations; aesthetic canon preserved)

═══ PARKED ═══
P5.1-4 multi-rig host single-canvas graph parked-with-evidence at substrate/.../shell-redesign-v1/p5-1-park-evidence-multi-rig-graph.md. New-build territory (~2-3h slice): useQueries N rig fetches + cross-rig node-ID prefixing + rigGroup ReactFlow node type + multi-rig layout extension + scale considerations.
2026-05-06 20:33:53 -07:00
mvschwarzandClaude Opus 4.7 971170daf0 shell-redesign V1 polish slice Phase 5.1 P5.1-5: pod-scope graph view
V1 founder-walk surfaced a missing graph view at the pod scope
(/topology/pod/$rigId/$podName previously rendered a placeholder
without the live graph render). Per Phase 5.1 ACK §6 implementation
plan, pod-scope graph was the "investigate; park if new-build" item.
Investigation showed it can ride on the existing RigGraph data
(single rig) with a small filter prop — NOT new build. Shipping inline.

EXTENDED packages/ui/src/components/RigGraph.tsx:
- New optional `podScope?: string` prop. When set, the rawNodes /
  rawEdges streams from useRigGraph(rigId) are filtered to:
  * podGroup/group container nodes whose data.podNamespace OR data.podId
    matches the pod name
  * agent nodes whose data.podNamespace / data.podId / inferPodName
    falls in the pod
  * edges whose source AND target are both kept
- Cast inline to RigNodeShape / RigEdgeShape since useRigGraph's hook
  data is typed as unknown[] (matching existing downstream pattern).

REFACTORED packages/ui/src/components/topology/ScopePages.tsx:
- PodScopePage default tab moved from "table" → "graph" so the new
  graph view-mode is the landing surface.
- Graph view-mode: <RigGraph rigId={rigId} podScope={podName} ... />.
  Pod-scoped subset renders with same paper-grid + tactical aesthetic.
- Mobile graph→table degradation pattern replicated from HostScopePage
  + RigScopePage (P5-9): at <lg viewport, graph effectively renders
  table with degraded hint; useShellViewport gate.
- Table + Terminal + Overview view-modes unchanged.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest rig-graph.test.tsx: 61/61 PASS (existing tests unaffected by
  podScope addition since tests don't pass the prop)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 20:11:36 -07:00
mvschwarzandClaude Opus 4.7 1eb71fc0b8 shell-redesign V1 polish slice Phase 5.1 P5.1-6: rig overview tab → RigSpecDisplay
V1 founder-walk surfaced ambiguity on the topology rig overview tab:
the tab rendered placeholder text ("Existing rig spec detail page
mounted here in phase five polish") which made it unclear whether
the rig spec surface was intended to mount or whether the tab was
not yet implemented at all.

NEW RigOverviewTab inline component in ScopePages.tsx:
- Mounts existing canonical RigSpecDisplay (the same component that
  renders /specs/rig/$id detail).
- Sources data via useSpecLibrary("rig") + useLibraryReview, matching
  by rig name (per LibraryReview.tsx pattern).
- Loading state: font-mono "Loading rig spec…".
- No-match: EmptyState with hint to author at /specs.
- Multiple-match: EmptyState with disambiguation hint.
- Review-failed: EmptyState graceful degrade.
- Wrapped in 'topology-rig-overview' testid + 'topology-rig-overview-'
  prefix for consistent test selectors.

REMOVED placeholder block ("Existing rig spec detail page mounted
here in phase five polish") and its 'topology-rig-overview-placeholder'
testid — replaced by the live mount.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- (visual walk needed at VM)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 20:11:36 -07:00
mvschwarzandClaude Opus 4.7 a3a76a8f40 shell-redesign V1 polish slice Phase 5.1 P5.1-7: table CMUX column + row click navigation
V1 founder-walk surfaced two gaps in the topology table view:
- Missing per-row CMUX launcher (the table view had no quick-launch
  affordance equivalent to the graph view's node-click cmux focus)
- Row click did not navigate to the agent detail page (table behaved
  as a read-only grid rather than a clickable surface)

NEW actions column with per-row CmuxButton component:
- Uses existing useCmuxLaunch hook (Phase 4) — POSTs to
  /api/rigs/$rigId/nodes/$logicalId/focus.
- onClick stopPropagation so the row's navigate handler doesn't fire
  alongside the cmux launch (cmux is the explicit action).
- Tactical aesthetic: 1px outline-variant border, font-mono uppercase
  tracking, hover:bg-stone-100/60.
- enableSorting=false on the actions column.

ROW click handler navigates to /topology/seat/$rigId/$logicalId (parity
with graph node click + Explorer tree click + (P5.1-2) the unified
seat-detail navigation contract). Cursor-pointer on rows for affordance.

NEW packages/ui/test/p5-1-table-cmux.test.tsx (4 tests):
- Actions column renders with CMUX button per row.
- CMUX button click → POST to /api/rigs/.../focus (mock fetch).
- CMUX button stopPropagation prevents row navigation.
- Row click (away from action) → navigate(/topology/seat/$rigId/$logicalId)
  (vi.mock useNavigate at file scope; spy assertion).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-1-table-cmux: 4/4 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 20:11:36 -07:00
mvschwarzandClaude Opus 4.7 49108fea01 shell-redesign V1 polish slice Phase 5.1 P5.1-3: pod name truncation fix
V1 founder-walk surfaced a rendering bug: pod names display with
the first 2-3 characters truncated (e.g., "discovery" rendering as
"covery", "planning" as "anning") across topology surfaces.

Root cause: lib/display-name.ts displayPodName called shortId(podId, 6)
which returns the LAST 6 chars of any string — designed for 26-char
ULIDs (random tail more distinguishable than timestamp head) but
WRONG for human-readable pod namespaces. "discovery" (9 chars) → last
6 = "covery"; "planning" (8 chars) → "anning"; "kernel" (6 chars)
unchanged because shortId returns id verbatim when len ≤ 6.

Fix: displayPodName returns podId verbatim (no shortId call). Pod
IDs in OpenRig are namespace strings ("discovery" / "planning" /
"kernel" / "product-lab" / etc.), not ULIDs.

shortId import dropped from display-name.ts; the helper is still
exported for actual ULID display surfaces (rig IDs, qitem tails)
elsewhere in the codebase.

NEW packages/ui/test/p5-1-pod-name-truncation.test.ts (12 tests):
- "discovery" → "discovery"
- "planning" → "planning"
- "kernel" / "orch" / "product-lab" / multi-hyphen pass-through
- null/empty → "ungrouped" fallback preserved
- Source-assertion (ritual #9): displayPodName does NOT call shortId
  in code (comment-stripped grep)
- inferPodName + displayAgentName neighbor-function preserved
  (split-on-dot semantics unchanged)

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-1-pod-name-truncation: 12/12 pass
- vitest full UI suite: 672/674 PASS (pre-existing library-review
  baseline failure UNCHANGED; 1 skipped)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 20:11:36 -07:00
mvschwarzandClaude Opus 4.7 f3780c0656 shell-redesign V1 polish slice Phase 5.1 P5.1-1+1a+2: agent detail dedup + layout + graph nav
V1 founder-walk follow-up. Closes the founder-noticed concerns around
the agent-detail surface in one bundled refactor:
- Two competing agent-detail UI elements (legacy right-sidebar render
  via NodeDetailPanel-in-drawer + center-page LiveNodeDetails) with
  duplicated content
- Redundant top+bottom tab rows (outer detail/transcript/terminal +
  inner identity/agent-spec/startup/transcript) producing the
  "Transcript twice" perception
- Orphan "Open Full Details" button in the legacy panel
- Graph node click opening the legacy drawer panel instead of the
  canonical center detail page
- Need to preserve all unique content while deduping

REBUILT packages/ui/src/components/LiveNodeDetails.tsx (canonical center
agent-detail surface):
- Persistent header at TOP (action-buttons-first layout): WorkflowHeader
  → ActionButtonsRow (Open CMUX + Copy tmux attach + Copy resume command)
  → StatusSection (startup status pip + restore outcome + failure banner
  with recovery guidance — merged from retired NodeDetailPanel) →
  Preview pane (merged) → LiveNodeCurrentState (Activity + Current Work
  cards) → RecentEventsSection (merged; no content lost) →
  Infrastructure startup command card (merged; conditional on
  nodeKind=infrastructure).
- Single canonical 5-tab body row at BOTTOM: Identity / Agent Spec /
  Startup / Transcript / Terminal. Eliminates the prior outer+inner
  tab nesting that produced the duplicate Transcript visibility.
- Startup tab: each file wrapped in FileReferenceTrigger per
  content-drawer.md L23-L34 (click opens FileViewer in drawer).
- Terminal tab: SessionPreviewPane mounts the live transcript-tail.

REFACTORED packages/ui/src/components/topology/ScopePages.tsx:
- SeatScopePage drops outer ScopeShell tabs (DRIFT P5.1-D1);
  LiveNodeDetails mounts directly as the page body. Other scope pages
  (Host/Rig/Pod) unchanged at this commit.

REFACTORED packages/ui/src/components/SharedDetailDrawer.tsx:
- 'seat-detail' kind RETIRED from DrawerSelection union (DRIFT P5.1-D2).
- Routing branch removed; NodeDetailPanel + SeatDetailViewer imports
  retired. Drawer remains a content-viewer surface for the 3 auto-open
  triggers (qitem / file / sub-spec).

REFACTORED packages/ui/src/components/AppShell.tsx:
- useNodeSelection function FULLY RETIRED (only callsite was RigGraph;
  no other consumers). NodeSelectionContext alias preserved as a
  no-op export so any test file still importing the symbol resolves.

REFACTORED packages/ui/src/components/RigGraph.tsx (P5.1-2):
- Node click handler: useNavigate to /topology/seat/$rigId/$logicalId
  center page. Replaces legacy setSelectedNode → setSelection seat-detail
  drawer-open. Parity with Explorer tree click + (P5.1-7) topology
  table row click contract.

REFACTORED packages/ui/src/components/topology/TopologyTreeView.tsx:
- SeatLeaf details icon RETIRED (DRIFT P5.1-D2; SeatDetailTrigger
  primitive deleted). Seat row is Link-only navigation now.
- P5.1-2 auto-expand: useActiveTopologyContext hook parses pathname
  for active rigId / podName / logicalId; threaded through RigBranch
  + PodBranch via props; matching branches force-expand on URL match.

CLEANUP packages/ui/src/components/Explorer.tsx:
- Excised vestigial dead-code block (lines 187-487 = ~300 lines):
  PodBranch, RigBranch, EnvironmentBranch, FullExplorerContents.
  These were defined-but-not-rendered (topology surface uses
  TopologyTreeView; FullExplorerContents had no consumer). Also
  cleared the legacy 'seat-detail' setSelection callsites in those
  helpers.

DELETED:
- packages/ui/src/components/NodeDetailPanel.tsx (261 lines; sole
  caller was SeatDetailViewer)
- packages/ui/src/components/drawer-viewers/SeatDetailViewer.tsx (27
  lines; wrapper retired)
- packages/ui/src/components/drawer-triggers/SeatDetailTrigger.tsx
  (33 lines; primitive retired)
- packages/ui/test/node-detail-panel.test.tsx (component retired)

CANON UPDATES (orch-approved at GO ask #1+#2; same-slice update):
- topology-tree.md L48-L53: seat-scope tabs amended to single 5-tab
  body row inside LiveNodeDetails (DRIFT P5.1-D1). Other scope tabs
  unchanged.
- content-drawer.md L37-L42: "details icon on any topology node"
  manual-open mandate RETIRED (DRIFT P5.1-D2). Other manual-open
  triggers (view source / show context) preserved as canon.

TEST UPDATES (negative-assertion / ritual #8 idiom per orch direction):
- packages/ui/test/node-selection-migration.test.tsx: REWRITTEN as
  P5.1-D2 retirement-regression guard (NodeDetailPanel.tsx /
  SeatDetailViewer.tsx / SeatDetailTrigger.tsx file-doesn't-exist;
  'seat-detail' + 'node' kind absent from DrawerSelection union;
  AppShell does NOT export useNodeSelection; RigGraph uses useNavigate
  not setSelectedNode; LiveNodeDetails has 5-tab Tab union;
  SeatScopePage drops outer tabs).
- packages/ui/test/drawer-primitives.test.tsx: SeatDetailViewer /
  SeatDetailTrigger contract tests REMOVED; routing-by-type
  'seat-detail' test REMOVED. Other 3 viewer + 3 trigger contract
  tests preserved.
- packages/ui/test/p5-trigger-sites.test.tsx: TopologyTreeView
  SeatDetailTrigger details-icon contract test REMOVED (primitive
  retired).
- packages/ui/test/rig-graph.test.tsx: 'click node → setSelection
  seat-detail' rewritten as 'click node → useNavigate(/topology/
  seat/$rigId/$logicalId)'; vi.mock useNavigate at file scope.
- packages/ui/test/p5-ship-gate-bounce.test.tsx: P0-2 NodeDetailPanel
  source-assertion converted into LiveNodeDetails-source-doesn't-
  regress companion guard (file-doesn't-exist guarded in
  node-selection-migration).
- packages/ui/test/design-compliance.test.tsx: NodeDetailPanel.tsx
  font-mono assertion redirected to LiveNodeDetails.tsx (canonical
  surface).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest full UI suite: 660/662 PASS (1 pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean (1.92s)

SC-29 posture: UNCHANGED — UI-only this slice; daemon byte-equivalent
beyond Phase 4 + Phase 5 declared exceptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 20:11:36 -07:00
mvschwarz 615baadfac merge(shell-redesign-v1-attempt-3): Phase 5 — V1 SHIP — polish + wiring + ship-gate bounce
Phase 5 closes the V1 redo workstream. design-reviewer issued PROFESSIONAL GRADE MET verdict (the V1 ship gate per founder ship-bar memory).

═══ COMMIT CHAIN (9 SHAs) ═══
- ec2e13b P5-4 NodeDetailPanel drawer migration
- 246f965 P5-1 trigger site-of-use wiring (closes Phase 4 P4-2 deferral)
- 233aeb2 P5-2 slice tab content piping (per velocity-qa routing; fold mapping documented)
- 919a428 P5-5 + P5-6 filesystem mission discovery + live PROGRESS.md status
- 4e67cf6 P5-3 subscription toggle wiring (SC-29 allowlist exception)
- 3981ad1 P5-7 topology terminal pagination + pulsing-ring
- 1fb50d1 P5-8 HumanSeatCard VellumCard refactor
- 483c23f P5-9 mobile responsive polish
- 668e156 V1 ship-gate bounce fix (P0-1 mobile rules-of-hooks; P0-2 NodeDetailPanel orphan whitespace)

═══ V1 BAR MET ═══
- Tactical dossier + Swiss minimalism + 2026 web standards
- 9 vellum primitives consumed (VellumCard / VellumSheet / RegistrationMarks / EmptyState / StatusPip / SectionHeader / RigStamp / VellumInput / VellumSelect)
- 1px borders + 38rem drawer
- Class B fixed-anchor + selective vellum overlay topology-graph-only
- Mobile responsive degradation per universal-shell.md L119-144
- Bottom nav For You / Project / Topology only V1
- Forbidden chrome retired (Sidebar.tsx + RigDetailPanel.tsx + legacy Dashboard.tsx all DELETED)
- Paper-grid + three-font hierarchy + crosshair registration marks + hard-shadow paper-press elevation

═══ SC-29 FINAL POSTURE ═══
Daemon byte-equivalent except 2 declared exceptions (allowlist additions only; lockstep daemon+CLI; no schema/endpoint/event drift):
- Phase 4: agents.advisor_session + agents.operator_session
- Phase 5: feed.subscriptions.{action_required, approvals, shipped, progress, audit_log}

═══ TRIPLE-GATE VERDICTS ═══
- review-lead: ADVISORY-CLEAN on all 9 SHAs
- redo3-guard-3: CLEAR at 668e156 (PROCESS-BLOCKER on Phase 4 ACK gaps resolved by 336a37b earlier)
- design-reviewer: PROFESSIONAL GRADE MET at 668e156 (V1 ship gate)
2026-05-06 18:28:37 -07:00
mvschwarzandClaude Opus 4.7 668e156609 shell-redesign V1 attempt-3 Phase 5 ship-gate bounce: P0-1 + P0-2
V1 SHIP GATE BOUNCE — 2 P0 fixes from design-reviewer ship-gate walk.

P0-1 — /topology at 375x812 mobile crashed to error boundary
("Cannot read properties of undefined (reading 'length')").

Root cause: TopologyTableView called useNodeInventory inside
`scopedRigs.map((r) => ({ ..., inv: useNodeInventory(r.id) }))` — hooks
in a variable-count loop. At desktop the table mounted only after the
user clicked the table tab (rigs already resolved → consistent hook
count from first render). At mobile, P5-9 effectiveActive degrades
graph→table at FIRST render, mounting the table BEFORE useRigSummary
resolves: scopedRigs starts [] → no hooks called → rigs resolves to
[N] → next render calls N useNodeInventory hooks → React detects hook
count change → crash.

Fix: switched to `useQueries({ queries: scopedRigs.map(...) })` from
React Query — single hook call regardless of array length, hook order
stable across renders. Added `fetchNodeInventory` helper inline (the
useNodeInventory hook is now unused by this file). Memo dependency
also tightened from JSON.stringify-of-data to direct
[scopedRigs, inventoryResults] references.

P0-2 — P5-4 NodeDetailPanel orphan whitespace not actually fixed
(SharedDetailDrawer 38rem chrome correct; inner panel pinned to
viewport-right with `absolute inset-y-0 right-0 z-20 w-80`, leaving
~288px / 30% orphan whitespace inside the drawer).

Root cause: NodeDetailPanel's root <aside> still carried legacy
self-pinning class string from the pre-refactor era when it was a
right-sidebar overlay. P5-4 migrated the DrawerSelection discriminator
('node' → 'seat-detail') but did NOT rewrite NodeDetailPanel's own
positioning. SeatDetailViewer wrapped it but couldn't override the
absolute self-pin from the parent.

Fix: switched the root <aside> from
`absolute inset-y-0 right-0 z-20 w-80 border-l border-stone-300/25 ...`
to `relative w-full h-full ...` — fill-parent layout. Verified via
grep `<NodeDetailPanel`: the only production caller is SeatDetailViewer
(P5-4 retired all other paths), so the legacy absolute positioning had
no remaining consumer that depended on it. Background + backdrop
preserved; border-l dropped because parent VellumSheet already supplies
the canonical 1px outline-variant left edge.

NEW packages/ui/test/p5-ship-gate-bounce.test.tsx (3 regression guards):
- P0-1: TopologyTableView mounts cleanly when /api/rigs/summary has not
  yet resolved + then resolves to [3 rigs]; pre-fix this sequence
  crashed with rules-of-hooks. Source-assertion (ritual #9): useQueries
  imported + called; legacy `inv: useNodeInventory` pattern absent.
- P0-2: NodeDetailPanel.tsx source has `relative w-full h-full` on root
  <aside>; legacy `absolute inset-y-0 right-0` + `w-80` patterns absent
  from code (comments preserved as historical record).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-ship-gate-bounce + p5-mobile-polish + p5-topology-terminal +
  drawer-primitives + node-detail-panel: all pass
- Full vitest suite: 679/681 PASS (1 pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 18:23:39 -07:00
mvschwarzandClaude Opus 4.7 483c23f3e2 shell-redesign V1 attempt-3 Phase 5 P5-9: mobile responsive polish
Per universal-shell.md L119-L144 mobile / narrow-viewport degradation.
V1 mobile bottom nav = For You / Project / Topology only (NOT Talk;
Talk slots are V2 deferred per L144 — when web terminal ships).

NEW packages/ui/src/components/AppShell.tsx :: MobileBottomNav:
- Fixed bottom-0 left-0 right-0; lg:hidden so desktop never sees it.
- 3 slots: For You (Sparkles icon), Project (Folder), Topology (Network).
- Active state: data-active="true" on the slot whose route prefix
  matches pathname; tactical font-mono uppercase 9px label.
- Vellum aesthetic (`.vellum` background + 1px outline-variant top
  border) consistent with the rail.

ADDED main content pb-14 lg:pb-0 so mobile bottom nav doesn't cover
scroll content (clearance for the ~52px bottom nav at lg breakpoint
0..1024px).

NEW packages/ui/src/hooks/useShellViewport.ts (per code-map AFTER tree):
- Standalone hook so non-AppShell surfaces can degrade gracefully on
  mobile without prop-drilling. Same WIDE_LAYOUT_BREAKPOINT (1024px)
  as AppShell — kept in lockstep.
- Returns { isWideLayout, innerWidth }; reacts to resize events.

REFACTORED packages/ui/src/components/topology/ScopePages.tsx:
- HostScopePage + RigScopePage: at <lg viewport, graph view-mode
  effectively renders the table view per universal-shell.md L143.
  effectiveActive = !isWideLayout && active === "graph" ? "table" : active.
- Tab nav still shows graph as the user's selected mode; resize-to-wide
  reactivates the graph automatically (effectiveActive recomputes).
- Small italic hint "Graph view degrades to table on narrow viewports."
  (testid: topology-mobile-graph-degraded) prepends the table.
- Pod scope unchanged (graph was placeholder in Phase 3 anyway).

UPDATED packages/ui/test/app-shell.test.tsx (SC-1 strict count):
- Filter `nav, aside` query by lg:hidden so MobileBottomNav (mobile-only)
  doesn't count toward desktop chrome total. SC-1's "exactly 2 left
  chromes on desktop" is about VISIBLE chromes; raw element count was
  too broad.

NEW packages/ui/test/p5-mobile-polish.test.tsx (9 tests):
- useShellViewport: isWideLayout=true at >=1024 / false at <1024 /
  reacts to resize events.
- MobileBottomNav: 3 slots present; advisor/operator/talk explicitly
  ABSENT (negative-assertion ritual #8 + source-assertion ritual #9
  on the MobileBottomNav slot definitions); active route highlighted;
  lg:hidden class present.
- Topology graph degradation: at <lg /topology renders table + degraded
  hint; at >=lg /topology renders graph placeholder (no degradation).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest full UI suite: 676/678 PASS (1 pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:56:25 -07:00
mvschwarzandClaude Opus 4.7 1fb50d15d4 shell-redesign V1 attempt-3 Phase 5 P5-8: HumanSeatCard VellumCard refactor
Phase 1 primitive consumption per code-map AFTER tree (Phase 5 row).
HumanSeatCard predates the Phase 1 vellum primitives; the ad-hoc
`border border-stone-300 bg-stone-50 p-3` chrome is replaced with the
canonical VellumCard (RegistrationMarks at 4 corners + 1px outline-
variant border + cream paper background + hard-shadow). SectionHeader
+ StatusPip primitives consumed for the eyebrow + pending/blocked
indicators. Capability pills now use border-outline-variant (1px ghost
line per universal-shell.md L43-L48 doctrine) instead of the legacy
border-stone-300.

Visual delta: card now reads as architectural drafting / paper-layered
matching the rest of V1; pending=2 / 1 blocked / capabilities pills
all render in tactical aesthetic. Existing testids preserved
(mc-human-seat-card / mc-human-seat-session / mc-human-seat-pending /
mc-human-seat-blocked) so any consumer testing this card keeps working.

NEW packages/ui/test/p5-human-seat-card.test.tsx (5 tests):
- VellumCard registration marks composed.
- Pending count + StatusPip pending/all-clear branches.
- Blocked-count → StatusPip warning-tone with "N blocked" label.
- Capability pills carry border-outline-variant class.
- Source-assertion (ritual #9): no legacy stone-300/stone-50 chrome
  literals survive outside historical comments; VellumCard import
  present.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-human-seat-card: 5/5 pass
- pnpm -F @openrig/ui build: clean
- Existing HumanSeatCard consumers unaffected (testids preserved)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:51:26 -07:00
mvschwarzandClaude Opus 4.7 3981ad187e shell-redesign V1 attempt-3 Phase 5 P5-7: topology terminal pagination + pulsing-ring
Per topology-terminal-view.md L13-L80: real pinned-card grid replaces the
TerminalGridPlaceholder for host / rig / pod scopes. V1 reuses preserved
SessionPreviewPane primitive (per code-map AFTER tree); V2 replaces with
xterm.js + tmux-attach (out of V1 scope).

NEW packages/ui/src/components/topology/TopologyTerminalView.tsx:
- TopologyTerminalView({ scope: "host"|"rig"|"pod", rigId?, podName? }).
- Host scope: iterates useRigSummary rigs, renders one RigTerminalSection
  per rig (each section uses useNodeInventory).
- Rig + Pod scopes: single useNodeInventory(rigId); pod filters by
  podNamespace/podId match against podName.
- TerminalGrid component with safe-N=12 pagination per L70-L80
  ("showing 12 of N · show all N" toggle).
- SeatTerminalCard wraps each seat in border + SessionPreviewPane;
  active-state seats (agentActivity.state === "running") get
  data-active='true' + .terminal-card-active class for pulsing-ring.
- Responsive grid: 1 col mobile / 2 cols md / 3 cols lg.

ADDED globals.css @keyframes terminal-card-active-frames + selector per
L47 LOAD-BEARING pulsing-ring contract. 2.2s ease-in-out infinite;
secondary-purple halo (rgba(167, 139, 250, 0.30) at 50%; transparent at
0/100%). Honored by prefers-reduced-motion: reduce block.

REFACTORED ScopePages.tsx: HostScopePage / RigScopePage / PodScopePage
'terminal' tab now mounts <TopologyTerminalView scope=... />; old
TerminalGridPlaceholder removed.

NEW packages/ui/test/p5-topology-terminal.test.tsx (8 tests):
- Rig scope grid renders one card per agent seat; data-active +
  terminal-card-active class on running seats; idle seats neutral.
- safe-N pagination: 15 seats → 12 shown by default; toggle reveals all.
- Pod scope filters seats by podName.
- Empty states (no seats / no rigs).
- CSS-source-assertion (ritual #7 pseudo-element-paint contract):
  globals.css declares @keyframes terminal-card-active-frames +
  .terminal-card-active selector + reduced-motion suppression.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-topology-terminal: 8/8 pass
- pnpm -F @openrig/ui build: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:49:31 -07:00
mvschwarzandClaude Opus 4.7 4e67cf660c shell-redesign V1 attempt-3 Phase 5 P5-3: subscription toggle wiring (SC-29 allowlist exception)
Closes Phase 4 P4-6 deferral. Wires the For You subscription affordance
from a static visual list (Phase 3) into live ConfigStore-backed toggles
that filter the feed. Per for-you-feed.md L130-L151 and SC-16
(subscriptions are settings-shaped, NOT feed-shaped — primary UX of
/for-you stays the FEED in the center; subscriptions live in the
explore sidebar as a small on-demand list).

SC-29 EXCEPTION DECLARED in Phase 5 ACK §5 DRIFT P5-D2 (orch-authorized
at GO retry; same scope as Phase 4 ConfigStore exception). 5 new
allowlist keys added LOCKSTEP daemon settings-store.ts + cli
config-store.ts; NO schema migrations / new endpoints / event types.
Daemon byte-equivalent SC-29 hard line preserved otherwise.

NEW ConfigStore allowlist keys (defaults per for-you-feed.md L144-L151):
  - feed.subscriptions.action_required → true (FORCED ON; cannot be
    disabled via UI toggle per L145 — load-bearing human-gate items)
  - feed.subscriptions.approvals → true
  - feed.subscriptions.shipped → true
  - feed.subscriptions.progress → true
  - feed.subscriptions.audit_log → false (verbose; opt-in)

NEW packages/ui/src/hooks/useFeedSubscriptions.ts:
- useFeedSubscriptions() reads the 5 keys from /api/config (via existing
  useSettings) and exposes { state, toggle, isMutating, unavailable }.
- toggle(key) writes via existing useSetSetting; action_required is not
  in the toggle key union (forced ON).
- isCardKindSubscribed(kind, state) maps FeedCardKind → subscription
  state field for Feed.tsx filtering.

NEW packages/ui/src/components/for-you/SubscriptionToggleList.tsx:
- Settings-shaped row list per for-you-feed.md L134-L140.
- 5 rows; action-required is forced ON with "forced ON" label (no
  interactive button); other 4 render aria-checked toggle buttons that
  call toggle() on click.
- Settings unavailable (legacy v0.2.0 daemon) → renders defaults +
  CLI-fallback hint pointing at `rig config set feed.subscriptions.<kind>
  true|false`.

REFACTORED packages/ui/src/components/Explorer.tsx:
- For You surface body delegates to <SubscriptionToggleList />; replaces
  the static visual list from Phase 3.

REFACTORED packages/ui/src/components/for-you/Feed.tsx:
- Feed cards filtered by subscription state FIRST (via
  isCardKindSubscribed), THEN sliced to HISTORY_LIMIT, THEN lens-filtered.
- action-required cards always pass (forced ON); observation cards pass
  only when audit_log is ON; etc.

NEW packages/ui/test/p5-subscription-toggle.test.tsx (6 tests):
- isCardKindSubscribed contract (action-required always; observation
  gated by auditLog; shipped gated by shipped).
- SubscriptionToggleList renders 5 rows; action-required has no
  toggle button (forced ON); others have interactive buttons.
- Toggle click POSTs to /api/config/feed.subscriptions.audit_log with
  flipped value "true".
- Settings unavailable (404) renders CLI hint + defaults.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- pnpm -F @openrig/daemon exec tsc --noEmit: clean
- pnpm -F @openrig/cli exec tsc --noEmit: clean
- vitest full UI suite: 654/656 PASS (pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:45:40 -07:00
mvschwarzandClaude Opus 4.7 919a428ee9 shell-redesign V1 attempt-3 Phase 5 P5-5 + P5-6: filesystem mission discovery + live PROGRESS.md status
Per project-tree.md L132–L133 + SC-26: mission status derives from
PROGRESS.md frontmatter (file-system-as-truth pattern). Phase 3 stubbed
parseMissionStatus(); Phase 5 wires the live filesystem walk + fetch.
Both ride on the existing /api/files/list + /api/files/read daemon
routes (verified at HEAD f1b91ff via packages/daemon/src/routes/files.ts);
NO new daemon endpoint, NO schema migration, NO new event type. SC-29
hard line preserved beyond the Phase 4 ConfigStore allowlist exception.

NEW packages/ui/src/hooks/useMissionDiscovery.ts:
- useMissionDiscovery() resolves workspace.root → /api/files/roots
  match (exact-equal preferred, deepest path-prefix fallback) →
  /api/files/list?root=&path=missions → directory entries (filtered
  to type==='dir').
- Returns { missions, unavailable, isLoading, hint }. Unavailable cases
  surface a hint string explaining the operator action needed
  (configure workspace.root, register OPENRIG_FILES_ALLOWLIST root,
  upgrade to v0.3.0 daemon for /api/config support).

NEW packages/ui/src/hooks/useMissionProgressStatus.ts:
- useMissionProgressStatus(root, missionPath) wraps existing useFilesRead
  to fetch <missionPath>/PROGRESS.md and parse via parseMissionStatus.
- TanStack Query supplies the cache layer; the daemon's mtime metadata
  in /api/files/read response satisfies project-tree.md L132 mtime-cache
  invariant.
- Returns { status, isLoading, unavailable, mtime }; "unknown" + 404
  treated as quiet degrade (mission directory may exist without
  PROGRESS.md yet).

REFACTORED packages/ui/src/components/project/ProjectTreeView.tsx:
- ProjectTreeView consumes useMissionDiscovery + new
  LiveMissionStatusBadge wrapper (uses useMissionProgressStatus).
- When discovery returns missions: render disk-discovered missions; for
  each, attach matching railItem-grouped slices; orphaned slices fall
  through to an "Unsorted" mission node.
- When discovery is unavailable: render a small italic
  "Filesystem mission discovery unavailable; showing railItem grouping"
  hint + the legacy railItem grouping (Phase 3 fallback preserved
  intact). data-testid='project-discovery-degraded' for tests.
- Existing Phase 3 A5 no-workspace empty-state preserved verbatim.

NEW packages/ui/test/p5-mission-discovery.test.tsx (10 tests):
- parseMissionStatus invariants (active / shipped / blocked variants
  + null/empty/malformed → unknown).
- ProjectTreeView with workspace.root in allowlist: filesystem missions
  surface as tree nodes; file-type entries filtered out.
- Fallback to railItem grouping when allowlist doesn't contain
  workspace.root, OR allowlist is empty; degraded hint visible.
- A5 no-workspace empty-state preserved when workspace.root unset.
- Slice-to-mission matching by railItem; orphans → Unsorted node.

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest full UI suite: 648/650 PASS (pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped)
- pnpm -F @openrig/ui build: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:39:48 -07:00
mvschwarzandClaude Opus 4.7 233aeb258a shell-redesign V1 attempt-3 Phase 5 P5-2: slice tab content piping
SliceScopePage replaces 6 PlaceholderTab calls with real tab components
mounted via useSliceDetail(sliceId). Loading + error/404 states render
EmptyState; the 7 canonical slice tabs each route to a mounted component.
Per code-map AFTER tree FOLD MAPPING (founder-approved at Phase 5 dispatch
ask #5):

  PRESERVED (re-mounted with SliceDetail props):
    - StoryTab → 'story' tab (events + phaseDefinitions)
    - TestsVerificationTab → 'tests' tab (sliceName + tests)
    - TopologyTab → 'topology' tab (topology)

  FOLDED:
    - AcceptanceTab → 'progress' tab (canonical: acceptance items + currentStep
      ARE the slice's "progress" surface per project-tree.md L65)
    - DocsTab + DecisionsTab → 'artifacts' tab (vertical sections; Docs +
      Decisions are the canonical artifact surfaces per project-tree.md L70)
    - DocsTab → 'overview' tab (pre-selects README.md / IMPLEMENTATION-PRD.md
      per existing initial-selection logic; richer overview shape is V2)

  NEW inline:
    - SliceQueueTab: renders detail.qitemIds wrapped in QueueItemTrigger
      (P5-1 trigger primitive). Click → QueueItemViewer in drawer per
      content-drawer.md L26 auto-open contract. Empty-state when zero
      qitems.

NEW packages/ui/test/p5-slice-scope.test.tsx (9 tests, all passing):
- Loading / error states render EmptyState with sliceId
- Default landing tab is 'story'
- Each fold mapping verified by testid presence (slice-artifacts-docs/
  decisions sections; slice-queue-trigger-* per qitem; tests-empty
  fallback for TestsVerificationTab; etc.)
- queue tab click → setSelection({type:'qitem', data:{qitemId}})
  fires through DrawerSelectionContext.Provider mock
- tests/topology tabs render without falling back to placeholder

Workspace + Mission scope tab piping NOT yet wired (Phase 5 polish;
P5-5 lays the data layer for filesystem-walk-derived missions).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest full UI suite: 638/640 PASS (1 pre-existing library-review
  Send-to-seat baseline failure UNCHANGED; 1 skipped seat-scope brittle)
- pnpm -F @openrig/ui build: clean (1.95s)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:35:11 -07:00
mvschwarzandClaude Opus 4.7 246f9651be shell-redesign V1 attempt-3 Phase 5 P5-1: trigger site-of-use wiring
Closes Phase 4 P4-2 deferral. Wires the 4 trigger primitives authored in
Phase 4 into 4 production surfaces (the 5th — Project queue tab — is
wired as part of P5-2 alongside slice-tab content piping).

- LibraryReview.tsx: context-pack file rows + agent-image supplementary
  file rows wrapped in FileReferenceTrigger; click → FileViewer in drawer.
- RigSpecDisplay.tsx: pod-member agentRef cells wrapped in SubSpecTrigger;
  click → SubSpecPreview in drawer with parsed {specKind, specName, source}.
  New parseAgentRef helper handles local:/builtin:/fork: variants + @version.
- for-you/FeedCard.tsx: cards with source.payload.qitem_id render an inline
  "show context" QueueItemTrigger affordance; click → QueueItemViewer in
  drawer with extracted QueueItemViewerData. Per content-drawer.md L42.
- topology/TopologyTreeView.tsx: SeatLeaf gains a PanelRightOpen "details"
  icon next to the seat link, wrapped in SeatDetailTrigger (hover-only
  opacity reveal). Per content-drawer.md L40 manual-open contract.

NEW packages/ui/test/p5-trigger-sites.test.tsx (12 tests, all passing):
contract tests + source-assertion guards for each surface. Includes a
TanStack Router + React Query wrapper for FeedCard which renders
AuthorAgentTag → useCmuxLaunch (useMutation).

SC-IDs verified PASS at consumer surfaces: SC-20 (named-trigger only),
SC-21 (manual-open details icon), SC-22 (FeedCard show-context).

Verification: tsc clean, vitest p5-trigger-sites + drawer-primitives all
pass, build clean. Pre-existing library-review Send-to-seat baseline
failure unchanged (tracked since Phase 1; unrelated).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:31:43 -07:00
mvschwarzandClaude Opus 4.7 ec2e13bd52 shell-redesign V1 attempt-3 Phase 5 P5-4: NodeDetailPanel drawer migration
Closes founder-noticed coupling bug: graph node click previously rendered
NodeDetailPanel "full-width with empty whitespace on left" because the
legacy 'node' DrawerSelection branch routed NodeDetailPanel directly past
the drawer chrome's 38rem width-coupling. Migration option (a) per Phase 5
ACK §6: remove the parallel 'node' kind from the union and migrate every
callsite to the canonical 'seat-detail' kind (Phase 4) which renders via
SeatDetailViewer wrapping NodeDetailPanel inside the canonical 38rem
VellumSheet chrome.

Changes:
- SharedDetailDrawer.tsx: 'node' kind removed from DrawerSelection union;
  if (selection.type === 'node') routing branch deleted; NodeDetailPanel
  no longer imported directly (reaches drawer only via SeatDetailViewer).
- AppShell.tsx useNodeSelection: backward-compat alias coerces to/from
  'seat-detail' instead of 'node'; preserves the (selectedNode,
  setSelectedNode) API for RigGraph consumers without breaking imports.
- Explorer.tsx: 5 spots (PodBranch seat-leaf isSelected + onClick;
  ungrouped pod seat-leaf isSelected + onClick; RigBranch auto-expand
  predicate) all switch from 'node' to 'seat-detail'.
- rig-graph.test.tsx: existing "click node -> setSelection({type:'node'})"
  test updated to assert canonical 'seat-detail' shape.
- NEW test/node-selection-migration.test.tsx (5 source-assertion tests
  per ritual #8 negative-assertion): 'node' kind absent from union;
  routing branch absent; NodeDetailPanel direct-import absent;
  useNodeSelection alias uses 'seat-detail'; Explorer.tsx file-wide free
  of '{ type: "node"' literal (DiscoveryPlacementTarget.kind === "node"
  is a SEPARATE namespace and is preserved).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest run on app-shell + rig-graph + drawer-primitives +
  node-selection-migration: 118/118 PASS
- Branch shell-redesign-v1-attempt-3-phase-5 off main f1b91ff

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:24:11 -07:00
mvschwarz f1b91ff642 merge(shell-redesign-v1-attempt-3): Phase 4 — Drawer Viewers + Wiring + RigDetailPanel retire
Phase 4 ship + process-gap fix:
- 4932f5d Phase 4: 4 drawer viewers (QueueItemViewer/FileViewer/SubSpecPreview/SeatDetailViewer) + 4 trigger primitives (QueueItemTrigger/FileReferenceTrigger/SubSpecTrigger/SeatDetailTrigger) authored; AuthorAgentTag cmux launcher via useCmuxLaunch wrapping pre-Phase-2 endpoint; Advisor/Operator config-driven seat resolution; RigDetailPanel + legacy Dashboard.tsx retired ('rig' kind removed from DrawerSelection); 3 orphan tests deleted; 1 test flipped to enforce retire as regression
- 336a37b process-gap fix: drawer-primitives.test.tsx (17/17 PASS — ritual #6 named-surface reachability) + ACK §4 actual_target_write_paths reconciled with shipped diff + ACK §5 drift disclosures (P4-D1 site-of-use deferred + P4-D2 CLI lockstep)

SC-29 declared exception scoped to ConfigStore allowlist additions only (agents.advisor_session + agents.operator_session keys; lockstep daemon + CLI). NO schema migrations / new endpoints / event types.

P4-2 trigger site-of-use wiring + P4-6 subscription toggle DEFERRED to Phase 5.

Triple-gate verdicts:
- review-lead: ADVISORY-CLEAN on both SHAs
- redo3-guard-3: CLEAR at 336a37b (PROCESS-BLOCKER on 4932f5d resolved by 336a37b)
- design-reviewer: DONE at 4932f5d (336a37b is test+ACK-doc only — no visual delta)

Founder granted standing autonomy for Phase 4→5 transition; no walk gate between phases.
2026-05-06 17:10:17 -07:00
mvschwarzandClaude Opus 4.7 336a37bc73 shell-redesign V1 attempt-3 Phase 4 process-gap fix: drawer primitives reachability proof
guard-3 PROCESS-BLOCKER on 4932f5d surfaced 3 legitimate gaps; this is the
fix-on-top commit. Two gaps live entirely in substrate (ACK file edits;
ACK §4 actual_at_4932f5d + deferred_to_phase_5 blocks added; ACK §5 drift
disclosures DRIFT P4-D1 site-of-use deferral + P4-D2 unlisted modifications
recorded honestly); only this test file is product-code work.

NEW packages/ui/test/drawer-primitives.test.tsx (17 tests, all passing):
- Each viewer renders without crashing with canonical-shape props
  (QueueItemViewer, FileViewer, SubSpecPreview, SeatDetailViewer; the
  last via vi.mock on NodeDetailPanel to bypass deep query/router deps)
- Each trigger fires setSelection on click with the correct
  DrawerSelection discriminator + payload (qitem | file | sub-spec |
  seat-detail kinds via DrawerSelectionContext.Provider with vi.fn spy)
- SharedDetailDrawer routes selection.type to the correct viewer
  testId; selection=null returns null (SC-6 default-closed contract)

Closes ritual #6 reachability proof gap for the 4 viewers + 4 triggers
that have no live consumers at 4932f5d (P4-2 site-of-use wiring deferred
to Phase 5 per DRIFT P4-D1).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- pnpm exec vitest run test/drawer-primitives.test.tsx: 17/17 pass
- pnpm -F @openrig/ui build: built in 1.86s
- Pre-existing library-review.test.tsx Send-to-seat failure unchanged
  (baseline 24db2b0; not Phase 4 related)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 17:06:28 -07:00
mvschwarzandClaude Opus 4.7 4932f5d68e shell-redesign V1 attempt-3 Phase 4: Drawer Viewers + Wiring
Per qitem dispatch. Phase 3 + bounce-fix merged at main 59cee0e;
Phase 4 fills the drawer viewer types and wires click destinations.

P4-1: 4 drawer viewer types (NEW components/drawer-viewers/):
- QueueItemViewer.tsx — qitem header + metadata (StatusPip per state)
  + body preview (~30 lines + show-full toggle) + Related refs.
- FileViewer.tsx — markdown (via existing MarkdownViewer) / YAML / JSON
  / text / image / binary fallback per content-drawer.md L88-L108.
- SubSpecPreview.tsx — referenced spec header + manifest excerpt + open-in-center.
- SeatDetailViewer.tsx — wraps existing NodeDetailPanel for the drawer
  (V1.5 — per content-drawer.md L137-139, replaces auto-open
  RigDetailPanel pattern with explicit manual-trigger).

P4-2: 4 drawer trigger components (NEW components/drawer-triggers/):
- QueueItemTrigger / FileReferenceTrigger / SubSpecTrigger /
  SeatDetailTrigger. Tiny wrappers around clickable elements that
  call setSelection with the corresponding viewer kind via the
  existing DrawerSelectionContext.
- Available for surfaces to import; full wiring into LibraryReview /
  RigSpecDisplay / FeedCard / TopologyTreeView at site-of-use is
  Phase 5 polish per scope reduction (consumed surfaces continue to
  work; the triggers are exposed primitives).

DrawerSelection union extended (SharedDetailDrawer.tsx):
- Added 4 new kinds: 'qitem' (QueueItemViewerData payload) / 'file'
  (FileViewerData) / 'sub-spec' (SubSpecPreviewData) / 'seat-detail'
  (rigId + logicalId).
- Removed 'rig' kind (P4-5; legacy auto-open RigDetailPanel pattern
  retired).
- Existing 'node' / 'system' / 'discovery' kinds preserved for
  back-compat with current callers.

P4-3: AuthorAgentTag cmux launcher
- New hook hooks/useCmuxLaunch.ts wraps existing daemon endpoint
  POST /api/rigs/:rigId/nodes/:logicalId/focus (shipped pre-Phase-2
  per architecture.md L88; SC-29 honored — no new endpoint).
- AuthorAgentTag.tsx: plain click → cmuxLaunch.mutate (foregrounds
  seat in cmux); Cmd/Ctrl/Shift + middle-click → standard Link
  navigation to /topology/seat/$rigId/$logicalId.

P4-4: Advisor / Operator config-driven click handlers
- AppShell Rail reads agents.advisor_session + agents.operator_session
  from useSettings.
- New helper resolveChatTo(session, role): when configured →
  /topology/seat/$rigId/$logicalId; when unset →
  /settings#agents-{role}-session (per universal-shell.md L80
  one-click navigation).
- Defaults from canon (advisor: advisor-lead@openrig-velocity;
  operator: empty).

P4-5: RigDetailPanel legacy retire
- DELETED packages/ui/src/components/RigDetailPanel.tsx.
- DELETED packages/ui/src/components/Dashboard.tsx (legacy orphan;
  Phase 2 deletion list missed it; Phase 3 dashboard/Dashboard.tsx
  is the canonical surface).
- 'rig' kind removed from DrawerSelection union (SharedDetailDrawer.tsx).
- Explorer.tsx legacy RigBranch helper: rig click no longer fires
  setSelection — Link routing only (consistent with Phase 3
  TopologyTreeView).
- RigGraph.tsx pod-group click is no-op (was setSelection rig); pods
  reachable via Explorer tree's /topology/pod link.
- Tests deleted: dashboard.test.tsx (legacy), rig-detail-panel.test.tsx
  (component deleted), scaffold.test.tsx (imported deleted Dashboard).
- rig-graph.test.tsx pod-group test renamed + assertion flipped
  (expect(setSelection).not.toHaveBeenCalled()) to enforce the
  retire as a regression.

P4-6 subscription toggles DEFERRED to Phase 5 per orch direction
(150-180min budget tightened by RigDetailPanel retire complexity).

═══ SC-29 EXCEPTION declared in ACK §4 ═══
ConfigStore allowlist additions ONLY: agents.advisor_session +
agents.operator_session keys added to:
- packages/daemon/src/domain/user-settings/settings-store.ts
  (SETTINGS_VALID_KEYS + ENV_MAP + KEY_TO_PATH + getDefaultValue switch)
- packages/cli/src/config-store.ts (RiggedConfig type + DEFAULTS object
  + VALID_KEYS + ENV_MAP + KEY_TO_PATH + resolveAll() final return)
NO schema migrations. NO new endpoints. NO new event types. Daemon +
CLI typecheck CLEAN after additions; daemon byte-equivalent honor
preserved everywhere except this declared allowlist exception.

Verification:
- pnpm exec tsc --noEmit (daemon + CLI + UI): all clean
- pnpm test (UI): 598/600 PASS (1 skipped seat-scope brittle from
  Phase 3; 1 pre-existing library-review failure unrelated)
- pnpm build (UI): ✓ in 1.88s

VM-loop walk pending (next step): sync source via
substrate/.../bin/sync-to-vm.sh, then agent-browser at
http://127.0.0.1:15179/ to verify each viewer + trigger + cmux +
advisor/operator click against v0.3.0 daemon + seeded workspace.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 16:14:02 -07:00
mvschwarz 59cee0ecb2 merge(shell-redesign-v1-attempt-3): Phase 3 + bounce-fix — 6 destinations + Class B fixed-anchor + selective vellum overlay
Combined merge of Phase 3 ship (5720ca7) + bounce-fix chain (7aa1aac through b549426):

- Phase 3: fill 6 destinations (Dashboard / Topology / Project / Specs / For You / Settings) on attempt-3 chrome
- Bounce-fix 7aa1aac: 5 bug fixes (topology landing, settings 404, log body, status cmux, project workspace label) + Class B fixed-anchor view-mode tabs + selective vellum overlay topology-graph-only
- 400477b: seat-detail double-padding fix (CSS-var inheritance through legacy WorkspacePage)
- 5 chrome-refinement follow-ons (6f86a5c through bbe6250): tabs-only scope shell, tabs breathing, rig graph zero-height fix, lighter vellum + collapsed-explorer toggle, floating toggle + vellum rail + lighter icons
- b549426: process-gap fix — ritual #8 negative-assertion regression test for non-topology routes + walk-packet seat-fix durable record

Triple-gate verdicts:
- review-lead: ADVISORY-CLEAN on all 8 SHAs
- redo3-guard-3: CLEAR at b549426 (PROCESS-BLOCKER resolved)
- design-reviewer: DONE through bbe6250 (visual fidelity preserved)

Founder walked + greenlit; topology pages + sidebar + bug fixes + Class B + seat-fix all verified.
2026-05-06 15:51:17 -07:00
mvschwarzandClaude Opus 4.7 b549426d73 shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX process-gap fix: ritual #8 regression test + walk-packet seat-fix record
guard-3 PROCESS-BLOCKER on Phase 3 bounce-fix at 400477b. Two surgical
adds satisfy the gate:

ISSUE 1 — Negative-assertion regression test wasn't committed.
ACK §4 said the non-topology overlay-leak negative assertion (ritual
#8) would land in topology-view-mode.test.tsx; the agent-browser walk
proof was evidence-of-the-moment but not a CI-runnable test. guard-3
needs the test in CI to consider ritual #8 satisfied.

Fix: new describe block in test/topology-view-mode.test.tsx —
"Class B negative assertion: non-topology routes never get vellum
overlay (ritual #8)":
- 5 routes via it.each (`/`, `/project`, `/specs`, `/for-you`,
  `/settings`) assert container.querySelectorAll(
  '[data-explorer-mode="overlay"]').length === 0.
- 1 positive companion: `/topology` graph mode DOES carry
  data-explorer-mode='overlay' on the Explorer aside.

All 6 tests pass in vitest. topology-view-mode.test.tsx: 18/19 PASS
(1 skipped seat-scope brittle from Phase 3, unrelated). Full suite:
640/642 PASS (added 6 tests; pre-existing library-review failure
preserved).

ISSUE 2 — Durable founder-walk record missing for 400477b seat-fix.
The phase-3-bounce-walk.md packet still pinned 7aa1aac; the seat-fix
verification only lived in /tmp transient handoff. Need a permanent
record co-located with the rest of the walk doctrine.

Fix: appended "FOLLOW-ON COMMITS" section to
founder-walks/phase-3-bounce-walk.md with:
- 400477b — seat-detail double-padding fix: full root cause (CSS-var
  inheritance through legacy WorkspacePage), fix description, founder
  verification at /topology/seat/$rigId/discovery.intake-router 1440x900,
  classification of inner-panel HTTP 404 as data-availability not layout.
- Briefer one-paragraph entries for the other follow-ons that landed
  during the same walk session: 6f86a5c (tabs-only shell), 84557e5
  (tab breathing), 210c322 (rig graph zero-height), ed08ff2 (lighter
  vellum + collapsed toggle), bbe6250 (floating toggle + vellum rail
  + lighter icons), and this process-gap fix itself.

Walk packet now reflects the actual branch tip composition; future
readers can trace each refinement back to its commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 15:45:34 -07:00
mvschwarzandClaude Opus 4.7 bbe6250b97 shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX follow-on: floating toggle + vellum rail + lighter icons
Founder direction 2026-05-06 — three refinements:

1. Collapsed Explorer: floating toggle, no whitespace column
   Previous: collapsed Explorer rendered as a 12px aside with bg-background;
   the whitespace column read as empty UI clutter.
   Fix: when collapsed, render ONLY a floating chevron-right button
   positioned fixed at left=3.5rem (just past the rail edge) +
   top=5.5rem. No aside container, no background column. Tabs and
   canvas reflow to fill (anchorLeft drops to 3rem = rail-only).
   data-explorer-collapsed='true' attribute on the floating button for
   runtime probing.

2. Rail with vellum surface
   Previous: rail used `bg-background` (solid paper-cream).
   Fix: rail now uses .vellum class (rgba(255,255,255,0.4) +
   backdrop-blur(8px)) — same translucent treatment as the
   topology-graph Explorer overlay. Paper-grid dots visible through
   the rail surface. Reads as a paper sheet layered over the canvas
   per universal-shell.md L48 sheets-of-vellum aesthetic.

3. Icons lighter line weight
   Previous: lucide icons rendered at default strokeWidth=2 (heavy
   line). Read as too dense against the vellum-translucent rail.
   Fix: strokeWidth=1.25 on all rail destination + chat icons + the
   Explorer toggle chevrons. Closer to 1px architectural drafting
   feel matching the 1px ghost border doctrine.
   - Widened RailIconSpec.icon ComponentType to accept strokeWidth.
   - Lighter hover bg (stone-200/60 instead of stone-200) so hover
     reads gentler against vellum.

Visual verification at /topology 1440x900:
- Rail: vellum-translucent; paper-grid dots show through; icons lighter.
- Explorer expanded: paper-grid dots show through; vellum overlay light.
- Click chevron-left: Explorer collapses; ONLY a floating chevron-right
  button visible at rail edge; no aside column; tabs reflow to anchor=3rem;
  canvas extends fully.
- Click floating chevron: Explorer re-expands.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 15:16:15 -07:00
mvschwarzandClaude Opus 4.7 ed08ff2376 shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX follow-on: lighter vellum + collapsed explorer toggle
Founder direction 2026-05-06 — two fixes:

1. Vellum overlay too dense
   - Bounce-fix #2 used .vellum-heavy (rgba(255,255,255,0.7) +
     blur(16px)) — too opaque vs the baseline Phase 2 explorer which
     was rgba(250,249,245,0.035) supports-bg(0.018) + blur(14px) —
     extremely transparent.
   - Switched topology-graph overlay to .vellum (rgba(255,255,255,0.4)
     + blur(8px)) — lighter, paper-grid visible through it, tree text
     readable. Reads coherent with baseline transparency. Lighter
     shadow (rgba 0.06 vs 0.08).

2. Collapsed Explorer unreachable without page refresh
   - Previous: desktopMode='hidden' applied lg:hidden to entire aside,
     hiding the chevron toggle along with tree contents. Once
     collapsed, no in-app affordance to re-expand.
   - Fix: collapsed state is now a thin 12px (w-12) column at left=48px
     (past the rail) showing only the chevron-right toggle. Clicking
     re-expands. SurfaceBody (tree contents) is conditionally
     unmounted in collapsed state for clean appearance. data-explorer-collapsed
     attribute added for runtime probing.
   - Toggle button:
     * collapsed: chevron-right, centered (left-1/2 -translate-x-1/2)
     * expanded: chevron-left, right-edge (right-2)
   - AppShell anchor offset accounts for collapsed stub: explorerAnchorLeft
     is 6rem (rail 3 + stub 3) when explorerVisible && !desktopExplorerOpen,
     21rem when fully open, 3rem when no explorer for destination.

Visual verified at /topology 1440x900:
- Default: vellum overlay reads lighter; tabs + canvas + paper-grid
  show through Explorer's translucent surface.
- Click chevron-left: Explorer collapses to 12px stub; chevron flips
  to right-arrow at center; tabs/canvas extend to take the freed width.
- Click chevron-right: Explorer re-expands.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 15:13:07 -07:00
mvschwarzandClaude Opus 4.7 210c32277d shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX follow-on: rig graph zero-height
Founder reported: clicking a rig in Explore navigates to /topology/rig/$rigId
graph tab but the canvas is empty.

Confirmed not a feature gap — RigGraph IS mounted (React Flow + 21
nodes verified via probe) but the parent container collapsed to 0
height, hiding the rendered graph.

Root cause: ScopeShell content area was `flex-1 min-h-0 overflow-auto`
but NOT a flex container, so the inner graph wrapper's `flex-1` had
nothing to flex against. min-h-[400px] only enforced 400px on the
wrapper but RigGraph (React Flow) renders at h-full of its parent and
needs a defined-height ancestor chain.

Fix:
- ScopeShell content area: add `flex flex-col` so children can use
  flex-1 to fill remaining vertical space.
- Graph branch wrapper: `flex-1 min-h-0 relative` (replaces
  `flex-1 min-h-[400px] relative`); inherits height from the now-flex
  parent and lets React Flow size correctly via h-full.

Visual verification at /topology/rig/01KQEQPN4MQJN0DHBM5CQ0N8D7:
- React Flow rect: width 1392, height 787 (was 0)
- 21 nodes rendered: orch_pod / velocity_pod / review_pod / watcher_pod
  / redo_pod / design_pod / redo3_pod with their internal agents,
  context %, edges
- OPENRIG-VELOCITY rotated watermark visible
- Paper-grid background extends around the graph
- View-mode tabs still anchored at fixed left position

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 15:06:59 -07:00
mvschwarzandClaude Opus 4.7 84557e5e1d shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX follow-on: topology tabs breathe
Founder direction 2026-05-06: remove the horizontal line border that
spanned across the entire tablist; keep the underline ONLY under the
active tab; give the tabs more breathing room.

Changes (TopologyViewModeTabs):
- Removed `border-b border-outline-variant` from tablist wrapper.
- Removed `-mb-px` from buttons (no longer needs to overlap the
  parent's border).
- Removed `px-3` from buttons (gap-6 between tabs handles spacing
  cleanly without per-button horizontal padding).
- Increased button padding to `py-3` (was `py-2`) for vertical breath.
- Increased flex `gap-6` (was `gap-1`) for horizontal breath between tabs.
- Active state still carries `border-b-2 border-stone-900` — only the
  selected tab has an underline; the rest float over the canvas.

Result: tabs read as "GRAPH  TABLE  TERMINAL" floating on the
paper-grid with the active item underlined; no horizontal line bar
across the canvas.

Tests + visual verified at /topology 1440x900.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 15:03:25 -07:00
mvschwarzandClaude Opus 4.7 6f86a5cd97 shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX follow-on: tabs-only topology scope shell
Founder direction 2026-05-06 (with reference mockup): the topology
scope shell should render JUST the view-mode tabs anchored to the
right of the Explorer — no "TOPOLOGY · SEAT" eyebrow, no big
"DISCOVERY.INTAKE-ROUTER" title, no border-bottom + bg-background
header section. Tabs over paper-grid canvas; scope identity reads from
the URL + Explorer tree active state.

Changes:
- ScopeShell now renders just the tabsNav with marginLeft anchored to
  --header-anchor-offset (preserves the fixed-anchor behavior across
  graph / table / terminal switches per Class B).
- Removed: SectionHeader eyebrow, h1 title, border-b underline + bg-background
  on the wrapper div.
- Kept: relative z-30 (tabs stay above Explorer overlay in graph mode),
  px-6 + pt-4 padding for breathing room, marginLeft anchor.
- The eyebrow/title props are still typed (now optional) for
  potential future revival but not consumed.

Visual verification at 1440x900:
- /topology: just GRAPH / TABLE / TERMINAL tabs over paper-grid; tabs
  at fixed anchor.
- /topology/seat/...: just DETAIL / TRANSCRIPT / TERMINAL tabs;
  LiveNodeDetails renders its own H1 below as part of the detail panel
  content (separate concern; not part of the scope shell).
- Tab fixed-anchor preserved across view-mode switches.

Tests: 47/48 PASS in app-shell + topology-view-mode test files (1
skipped seat-scope brittle); no regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 14:58:28 -07:00
mvschwarzandClaude Opus 4.7 400477ba4a shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX follow-on: seat detail double-padding
Founder reported: node detail page (e.g.,
/topology/seat/$rigId/$logicalId) was pushed too far right at desktop
1440x900. The 'DISCOVERY.INTAKE-ROUTER' detail panel rendered far right
of the Explorer's right edge instead of anchored at the workspace
left offset.

Root cause: double-padding from Class B's `paddingLeft:
var(--workspace-left-offset)` on <main>, combined with legacy
WorkspacePage (consumed by LiveNodeDetails) ALSO reading the same
--workspace-left-offset via `lg:pl-[var(--workspace-left-offset,0px)]`.

Net effect: <main> padded by anchor (21rem); WorkspacePage inside
LiveNodeDetails added another 21rem on top → content shifted ~42rem
right (about half the viewport).

Fix: reset --workspace-left-offset and --workspace-right-offset to 0px
on the inner div that wraps {children} inside <main>. This way:

- <main> reads the OUTER var (set on <main> via workspaceStyle) →
  applies its own paddingLeft.
- Children (legacy WorkspacePage consumers + Phase 3 destinations)
  read the INNER var (overridden to 0) → no double padding.

Phase 3 destinations (Dashboard / TopologyTreeView / etc.) don't use
the var directly; they were already fine. The fix only neutralizes
double-padding for legacy children inheriting WorkspacePage layout.

Visual verification at /topology/seat/$rigId/discovery.intake-router
1440x900: detail panel anchored just past the Explorer's right edge
(at the workspace left offset = 21rem); sub-tabs IDENTITY/AGENT_SPEC/
STARTUP/TRANSCRIPT also anchored. (Inner panel renders an HTTP 404
because /api/inventory route on shipped daemon doesn't have entries
for synthetic test seat ID — separate data-availability issue, not
layout.)

Tests: 634/636 PASS unchanged (no regression; pre-existing
library-review + skipped seat-scope).
Build: ✓ in 1.99s.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 14:53:14 -07:00
mvschwarzandClaude Opus 4.7 7aa1aac65c shell-redesign V1 attempt-3 Phase 3 BOUNCE-FIX: 5 bugs + Class B fixed-anchor + selective vellum
Founder walk on Phase 3 (commit 5720ca7) approved structurally; surfaced
5 bug fixes + 1 design refinement. Triple-gate verdicts: review-lead
APPROVE, design-reviewer DONE, guard-3 PROCESS-BLOCKER (resolved by
this commit's permission preflight in ACK §4).

Branch: shell-redesign-v1-attempt-3-phase-3-bounce on top of 5720ca7
(driver judgment to build on Phase 3 destinations).

CLASS A — 5 bug fixes:

A1 — topology landing tab content auto-load
- Verified at agent-browser: graph placeholder DID render on first
  navigation (Dashboard → /topology); 25 rigs visible in tree.
- Founder's perceived "empty" was Class B Explorer-covers-content
  layout — Class B fix below resolves as side effect (graph canvas
  now extends edge-to-edge under translucent Explorer; placeholder
  visible regardless of Explorer state).

A2 — Settings tab HTTP 404 root cause + soft empty-state
- Root cause: shipped daemon (npm v0.2.0) doesn't expose /api/config
  yet; route lands at v0.3.0+. Per SC-29 daemon byte-equivalent — NO
  daemon patch.
- Vite proxy verified configured (vite.config.ts L7-L20). Per dispatch
  guidance, escalate before patching daemon.
- UI fix: SettingsTab error rendering softened. When error contains
  "404": render honest empty-state pointing at CLI as canonical edit
  path ("rig config get/set/reset"). Otherwise: existing error message
  in cleaner empty-state styling. No more raw "could not load HTTP 404"
  red text.

A3 — Log/RecentActivity bodies show "—" instead of message text
- New shared `lib/format-event-payload.ts` with smart fallback chain:
  payload.summary → body → detail → message → title → text →
  description → compact key=value preview (skipping noise keys
  ts/created_at/audit_pointer) → JSON.stringify(payload) truncated.
- Applied in SettingsCenter LogPanel + Dashboard RecentActivity.
- 9 tests in test/format-event-payload.test.ts covering each fallback +
  noise-key skip + truncation.

A4 — Status tab cmux block restored (regression fix)
- Original SystemPanel.tsx L52-L131 had cmux query +
  `data-testid="system-cmux-status"`. My Phase 3 SettingsSystemStatusPanel
  extraction missed it.
- Restored: useQuery({queryKey:["daemon","cmux"]}) +
  fetchCmux from /api/adapters/cmux/status. Renders via StatusPip
  primitive (active/warning/info per cmuxAvailable tri-state).
- New testid: status-cmux. Side-effect: also restored daemon health
  fetch ordering for daemonConnected → cmuxAvailable resolution.

A5 — Project workspace label hardcoded "openrig-work"
- New hook `useWorkspaceName`: reads workspace.root from useSettings
  (with defensive guards for malformed/missing settings shape — fixes
  /project route test failure when settings endpoint returns []).
- Returns basename of root, or null when unset/unreachable.
- ProjectTreeView: live label OR honest empty-state ("No workspace
  connected" + Link to /settings) when name is null.
- WorkspaceScopePage: live title OR full empty-state EmptyState card
  with action to /settings.
- Chains with A2: when daemon doesn't expose /api/config, both flow to
  empty-state pointing at /settings (which itself shows the CLI hint).

CLASS B — fixed-anchor view-mode tabs + selective vellum overlay:

Per dispatch + universal-shell.md L48 ("sheets of vellum paper layered
together") canon outcome.

- NEW: components/topology/topology-overlay-context.tsx —
  TopologyOverlayContext + Provider + useTopologyOverlay hook. Two
  modes: "overlay" | "opaque". Default opaque so non-topology surfaces
  always opaque.

- AppShell.tsx — refactored:
  * Outer `AppShell` wraps SpecsWorkspaceProvider + TopologyOverlayProvider
    around `AppShellInner` (the actual chrome + state).
  * AppShellInner reads explorerMode via useTopologyOverlay; computes
    isTopologyOverlay = mode==="overlay" && surface==="topology".
  * NEW CSS variables on <main>:
    - --explorer-anchor-left = 21rem (rail 3 + explorer 18) when
      desktop+open, else 3rem.
    - --header-anchor-offset = anchor when isTopologyOverlay (so the
      ScopeShell header keeps the tab bar at fixed anchor even when
      canvas extends behind), else 0rem.
    - workspaceLeftOffset: 0 in overlay mode (canvas extends behind
      Explorer); anchor in opaque mode.
  * `<main>` gets explicit padding-left: var(--workspace-left-offset)
    so all destinations clear the Explorer.
  * `<main>` carries data-explorer-mode attr for runtime + test probing.

- Explorer.tsx — overlayMode prop + grammar:
  * "overlay" branch: vellum-heavy class (rgba(255,255,255,0.7) +
    backdrop-blur(16px) per globals.css L118-L122) + z-30 + heavier
    shadow. Sheets-of-vellum-layered aesthetic.
  * "opaque" branch: existing Phase 2 background. z-20.
  * data-explorer-mode attr on the <aside>.
  * 1px outline-variant border preserved (border weight doctrine).

- topology/ScopePages.tsx — useOverlayForActiveTab(active) helper:
  * useEffect sets overlay mode = "overlay" when active==="graph" else
    "opaque". Cleanup on unmount → "opaque" so non-topology destinations
    don't inherit overlay state.
  * Applied in HostScopePage / RigScopePage / PodScopePage. SeatScopePage
    (detail/transcript/terminal — no graph) doesn't use the helper.
  * ScopeShell header gets marginLeft: var(--header-anchor-offset) so
    the tab bar stays at the anchor in overlay mode (canvas extends
    behind, but the header section is still positioned at the anchor).
  * Header z-30 + bg-background so it stays on top of the Explorer
    overlay even though Explorer is also z-30 (same z-index, header
    paints later in document order).

Visual verification (agent-browser at 1440x900):
- /topology graph mode: Explorer translucent vellum over canvas;
  paper-grid extends edge-to-edge UNDER Explorer; tabs at fixed
  anchor; "HOST GRAPH" placeholder visible centered in canvas.
  data-explorer-mode === "overlay".
- /topology table mode: clicked the tab (no longer blocked by Explorer
  overlay — fixed-anchor header is z-above); Explorer reverts to
  opaque; tabs at SAME left position as graph mode (no jump). 122 of
  122 agents in tanstack table with RUNNING StatusPip badges.
  data-explorer-mode === "opaque".
- /project: data-explorer-mode === "opaque"; querySelectorAll
  ('[data-explorer-mode=overlay]').length === 0. Non-topology
  destinations NEVER get overlay (negative-assertion ritual #8 ✓).

OUT-OF-SCOPE preserved per dispatch: Phase 4 (drawer viewers + triggers
+ AuthorAgentTag cmux + Advisor/Operator wiring + RigDetailPanel
retire + subscription toggles wire) and Phase 5 (mobile polish + slice
tab piping + filesystem mission discovery + topology terminal
pagination + final dogfood). Daemon untouched per SC-29.

Verification:
- tsc --noEmit: clean
- pnpm test: 634/636 PASS (1 skipped seat-scope; 1 pre-existing
  library-review unrelated; my Phase 3 /project failures from
  useWorkspaceName lack-of-defensive-guard FIXED)
- pnpm build: ✓ in 1.99s
- agent-browser walk: 6 destinations + 3 topology view-modes verified;
  vellum overlay only on topology graph; tabs at fixed anchor.

ETA realized: ~115 min (within 90-120 dispatch estimate; Class B took
the most time as anticipated).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 14:45:48 -07:00
mvschwarzandClaude Opus 4.7 5720ca79a6 shell-redesign V1 attempt-3 Phase 3: Fill 6 destinations
Per qitem-20260506200131-13075d99 from orch-lead@openrig-velocity. Phase 3
fills all 6 destinations the Phase 2 chrome routes to. Each consumes
Phase 1 primitives + Phase 2 chrome. ETA realized ~150 min within
150-180 dispatch estimate.

Dashboard (/):
- DashboardHeader (env/host + greeting + stats line: rigs/agents/active)
- 6-card grid (3×2 desktop, responsive): Topology (counts only per SC-13;
  NOT live RigGraph), Project, For You (red-accent), Specs, Search, Settings
- Recent Activity (top 10 events from useActivityFeed)
- New: lib/host-label.ts; components/dashboard/{Dashboard,DashboardHeader,
  CardGrid,RecentActivity,cards/{Topology,Project,ForYou,Specs,Search,
  Settings}Card}.tsx

Topology (/topology + /topology/{rig,pod,seat}/$...):
- ⭐ SC-10 LOAD-BEARING: view-mode tabs IN-PLACE single URL. Tab state
  is React useState INSIDE each scope page; URL stays at scope path on
  tab switch. Source-assertion regression test in
  test/topology-view-mode.test.tsx asserts routes.tsx contains NO
  view-mode-as-URL forbidden patterns (8 patterns guarded — attempt-2
  violation pattern).
- TopologyTreeView (host > rig > pod > seat; multi-host envelope per SC-11b)
- TopologyViewModeTabs (HOST/RIG_POD/SEAT_SCOPE_TABS exports)
- TopologyTableView (tanstack-backed table; row per agent across topology)
- ScopePages: Host/Rig/Pod/Seat. Rig graph tab reuses existing RigGraph
  (preserved); seat detail tab reuses existing LiveNodeDetails (preserved).

Project (/project + /project/{mission,slice}/$...):
- ProjectTreeView (workspace > mission > slice; groups slices by railItem)
- ScopePages: Workspace/Mission/Slice. Slice = 7 tabs (story+tests added
  to shared 5). Workspace artifacts tab mounts existing FilesWorkspace.
- Slice tabs (StoryTab/TestsVerificationTab/TopologyTab from Phase 2 preserved)
  need rich slice-detail data piping; Phase 5 wires that. Phase 3 V1
  renders per-tab placeholders.

Specs (/specs + /specs/applications + /specs/$kind/$name):
- SpecsTreeView (6 sections per SC-27; useSpecLibrary fills rigs/workflows/
  agent-images sections; workspaces/context-packs/applications V2 placeholders)
- SpecsLibraryPage (top-level: 6-action toolbar per SC-28 + tanstack table
  with sort + global search)
- SpecsTable (@tanstack/react-table; sortable, filterable, kind/name/version/
  source/updated columns)
- @tanstack/react-table added as dep (project already uses @tanstack ecosystem
  via react-query/react-router)

For You (/for-you):
- MissionControlSurface body refactored in place (Phase 2 stub → Feed
  render shape per for-you-feed.md). Component name preserved.
- Feed.tsx renders 5 card types per SC-15 (action-required/approval/
  shipped/progress/observation) with kind-specific accents.
- FeedCard.tsx (single shape; kind-routed accent + label).
- AuthorAgentTag.tsx (Phase 3 stub; navigates to /topology/seat/...;
  Phase 4 wires cmux launcher).
- lib/feed-classifier.ts (SC-17 LOAD-BEARING: SHIPPED via client-synthesize
  from existing daemon events; NO new daemon event types per SC-29).
  feed-classifier.test.ts verifies all 5 kind mappings.
- Lens chips (transient filter; doesn't persist per L156+).
- "All caught up" empty state.
- SC-16 honored: subscription affordance in Explorer sidebar (small,
  on-demand, NOT dominating); /for-you primary UX is the FEED.

Settings (/settings):
- SettingsCenter (3-tab layout in CENTER workspace per SC-7)
- Tab 1 Settings: existing SettingsTab preserved.
- Tab 2 Log: activity event stream.
- Tab 3 Status: SettingsSystemStatusPanel (daemon reachability + rigs counts)
  using StatusPip primitive.

MissionStatusBadge (SC-26):
- components/MissionStatusBadge.tsx (flat-root per code-map; orch decision)
- 5 statuses: active/paused/shipped/blocked/unknown
- parseMissionStatus(progressMdContent) reads YAML frontmatter `status:`
  field; tested in mission-status-badge.test.tsx for all mappings.
- Project tree consumes the badge for each mission.

Routes (routes.tsx):
- All 6 canonical destinations now mount real components (Phase 2 placeholders
  replaced).
- /specs/$kind/$name redirects to /specs/library/$entryId.
- All redirects from Phase 2 (/mission-control → /for-you, /context →
  /topology, /slices*, /progress, /steering → /project) preserved.

Explorer surface routing (Explorer.tsx):
- surface=topology renders TopologyTreeView
- surface=project renders ProjectTreeView
- surface=specs renders SpecsTreeView
- surface=for-you renders subscriptions list (canonical defaults from
  for-you-feed.md L142-150; toggles wire in Phase 4)
- surface=none keeps Phase 2 behavior (Explorer not rendered for Dashboard/
  Settings).

Internal nav elements use <div role="tablist">/<div role="toolbar"> instead
of <nav> so SC-1 left-chrome strict count (querySelectorAll("nav, aside")
== 2) stays unchanged. Verified at runtime.

Tests added:
- test/mission-status-badge.test.tsx — SC-26 (9 tests)
- test/feed-classifier.test.ts — SC-15 + SC-17 (10 tests)
- test/topology-view-mode.test.tsx — SC-10 IN-PLACE pattern + 8-pattern
  source-assertion regression (12 tests; 1 skipped — seat scope brittle
  with LiveNodeDetails fetching)
- test/app-shell.test.tsx — added SC-6 it.each per-route + bumped timeout
  (5 routes covered explicitly)
- test/explorer.test.tsx DELETED — tested Phase 2's onSelect callback
  pattern superseded by URL navigation (links instead of click handlers)
  in Phase 3 surface routing. New TopologyTreeView is URL-driven.

Verification:
- tsc --noEmit: clean
- pnpm test: 625/627 PASS — 1 skipped (seat scope LiveNodeDetails fetch
  brittle in test env), 1 pre-existing library-review failure unrelated
- pnpm build: ✓ in 2.13s
- agent-browser at 1440x900 visual walk: all 6 destinations render with
  real data; SC-13 TopologyCard shows counts (no graph); paper-grid +
  1px borders + 38rem drawer + tactical drafting feel maintained
  throughout from Phase 2.

OUT-OF-SCOPE per dispatch: Phase 4 (drawer viewers + triggers + AuthorAgentTag
click wiring + Advisor/Operator config-driven seat resolution + RigDetailPanel
legacy retire) and Phase 5 (mobile polish + tab content polish + final
dogfood). Daemon untouched per SC-29.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 13:25:24 -07:00
mvschwarz 7f8872f2c7 merge(shell-redesign-v1-attempt-3): Phase 2 — Shell chrome refactor (V1 ATTEMPT-3)
V1 attempt-3 Phase 2 — shell chrome refactor + LOAD-BEARING structural
fix. Sidebar.tsx DELETED (the chrome attempts 1+2 missed despite Phase
7 polish). 22 source files deleted (legacy chrome). AppShell rail with
6 destinations + 2 chat icons. Explorer single-source surface union.
Drawer default-closed via VellumSheet primitive. routes.tsx canonical
shape. -4357 net lines.

WHAT SHIPPED (5f53d31)
- AppShell.tsx refactor: 48px rail with 6 destination icons (Dashboard
  / Topology / For You / Project / Specs / Settings) + 2 chat icons
  (Advisor / Operator V1 placeholders per agent-chat-surface.md). NO
  discovery in rail. Mobile rail collapses to top-bar at <lg.
- Explorer.tsx refactor: surface union (Topology tree | Project tree |
  Specs tree | For You feed-lens | nothing for Dashboard/Settings).
  Phase 2 lays the union; Phase 3 fills tree contents.
- SharedDetailDrawer.tsx: default-closed via VellumSheet primitive;
  named-trigger only; user-closable [×]; ~720px wide on desktop.
- routes.tsx: canonical routes /topology (single URL — view-mode tabs
  Phase 3, NOT separate routes per SC-10) + /for-you + /project +
  /specs + /settings + /search. Redirects from /mission-control,
  /context, /slices, /progress, /steering. Daemon /api/* untouched.
- 22 source deletions: Sidebar, SpecsPanel, WorkspaceHome,
  ContextWorkspace, ProgressWorkspace, 7 steering panels, 2 slices
  files, 8 mission-control views (AuditHistoryView preserved for
  /search). PRESERVED per drift resolution: WorkspacePage.tsx (layout
  primitive, 13+ consumers); SpecsWorkspace.tsx (state provider, 5+
  forms).
- MissionControlSurface shell-and-stub (DRIFT P2-C — Phase 3
  refactors body in-place into For You feed render shape).
- Phase 1 advisory carry-forwards: VellumCard focus-visible when
  interactive, SectionHeader emphasis split into alert (red) +
  strong (bold), VellumCard href stays neutral.

BOUNCE-FIX #1 (87f8155) — desktop top-bar
- Founder walk surfaced missing brand identity on desktop.
- Canon update: universal-shell.md now specifies thin desktop top-bar
  (h-14) symmetric with mobile.
- AppShell single-source-of-truth approach (a) — same header element
  renders at all viewports; mobile-only content (hamburger) keeps
  lg:hidden. Brand 'OPENRIG' Link to /; localhost env indicator on
  right slot.
- SharedDetailDrawer top-0 → top-14 to clear top-bar.

BOUNCE-FIX #2 (a692758) — drawer width + 1px borders + permission
preflight artifact
- Founder walk surfaced 720px drawer too wide; aesthetic too 'boxed'
  not architectural-drafting per Tactical Dossier canon.
- Canon updates: drawer width 720px → 608px (38rem, iPad-portrait
  feel) in universal-shell.md + content-drawer.md. New 'Border
  weight doctrine' (LOAD-BEARING): all inter-region borders 1px
  outline-variant.
- Driver: VellumSheet wide preset 45rem → 38rem; 7 inter-region
  borders 2px stone-900 → 1px outline-variant (top-bar bottom, rail
  right, mobile rail tray, explorer right, VellumSheet edge classes).
  PRESERVED: control affordances (collapse buttons, drawer close [×]),
  active rail-icon 2px tertiary accent, hard-shadow card press, tree-
  branch internal guides.
- Permission preflight artifact authored inline in ACK §6 per
  velocity-permission-preflight schema (guard-3 process gate close-out).

BOUNCE-FIX #3 (c916c4b) — width-coupling regression
- guard-3 caught: VellumSheet width changed to 38rem but
  AppShell.tsx workspaceRightOffset still reserved 45rem of right
  padding when drawer open. 7rem (~112px) gap between drawer and
  center-content right edge.
- Driver: AppShell workspaceRightOffset 45rem → 38rem (couple to
  VellumSheet wide preset). SharedDetailDrawer stale comment 45rem
  → 38rem with calibration-date reference.
- New CSS-source-assertion regression tests (per pseudo-element-paint
  test contract, ritual #7): assert VellumSheet wide preset and
  AppShell workspaceRightOffset use SAME literal; assert no live
  45rem string in chrome source (historical comments allowed). Permanent
  guard against re-introduction.

VERIFICATION
- tsc clean across cli + daemon + ui
- UI tests: 599/600 passing / 1 pre-existing library-review carry-
  forward (Send-to-seat modal failure verified pre-Phase-1 on baseline)
- Daemon byte-equivalent (SC-29)
- Net regression: ZERO

TRIPLE-GATE
- review-lead APPROVE ('exemplary delivery; -4357 net lines; zero
  dangling imports; 3 of 4 Phase 1 advisories resolved')
- design-reviewer PHASE 2 DONE ('attempt-3 has LESS code than
  attempt-2 final; structural fix banked; chrome refactor without
  parallel implementation drift')
- redo3-guard-3 PROCESS-BLOCKER → CLEAR (permission preflight
  authored bounce #2; width-coupling fixed bounce #3)

PROCESS DISCIPLINE LANDED
- Driver-3 invoked drift disclosure rule with 3 specific drifts
  (WorkspacePage layout primitive PRESERVE; SpecsWorkspace state
  provider PRESERVE; MissionControlSurface shell-and-stub) — canon
  + code-map updated to reflect corrected mandates BEFORE code work.
- Founder walked + surfaced 2 spec-level refinements (top-bar +
  drawer width + 1px borders); canon updated each time before
  bounce-fix dispatch.
- Per-phase founder walk gate (SC-PROCESS-1) successfully gated
  triple-gate dispatch in 2 cycles.

NEXT: Phase 3 (destinations — Dashboard 6 cards / For You feed body /
Project tree contents / Specs library page / Topology view-mode tabs
/ Settings 3-tab content).
2026-05-06 13:00:16 -07:00
mvschwarzandClaude Opus 4.7 c916c4bed6 shell-redesign V1 attempt-3 Phase 2 BOUNCE-FIX #3: drawer width-coupling
guard-3 caught a real bug missed in bounce-fix #2: VellumSheet wide preset
was calibrated 45rem → 38rem, but AppShell.tsx workspaceRightOffset still
reserved 45rem of right padding via the --workspace-right-offset CSS
variable when drawer is open. Net effect: open-drawer surfaces a 7rem
(112px) layout gap between the drawer (38rem) and the right edge of the
center workspace's content.

Fix:
- AppShell.tsx workspaceRightOffset open-drawer literal "45rem" → "38rem".
- SharedDetailDrawer.tsx stale lg:w-[45rem] comment → lg:w-[38rem]
  (with calibration-date reference per content-drawer.md L9).
- New regression test in test/app-shell.test.tsx (CSS-source-assertion
  per pseudo-element-paint test contract / discipline ritual #7):
  * "VellumSheet wide preset and AppShell workspaceRightOffset use the
    SAME literal" — extracts widthClass.wide width from VellumSheet
    source and workspaceRightOffset open-drawer value from AppShell
    source via regex; asserts equal.
  * "no live 45rem string in chrome source" — scans AppShell, drawer,
    and vellum-sheet sources; for any line containing "45rem" verifies
    it's inside a // line comment OR a /* ... */ block comment context
    (historical calibration notes are allowed; live class/value strings
    are not). Permanent guard against the same coupling drift.

Verification:
- tsc --noEmit: clean
- pnpm test: 599/600 PASS (added 2 new tests; pre-existing
  test/library-review.test.tsx > Send-to-seat modal still failing
  unrelated to this scope; verified pre-Phase-1 via git stash)
- pnpm build: ✓ in 1.94s
- Runtime probe (drawer not mounted by default per SC-6) confirms
  static-source coupling test is the right approach; computed-style of
  CSS variables in jsdom is too brittle to depend on.

Permission preflight caveat acknowledged: bounce-fix #2 §6 fields
writable_root_coverage and approval_layer_probe were authored as
prose/boolean rather than the exact path+covered_by and command+result
subshape per velocity-permission-preflight schema. Orch accepted the
field-level close-out; will sharpen to the exact subshape on next
preflight authoring (likely Phase 3 dispatch).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 12:56:40 -07:00
mvschwarzandClaude Opus 4.7 a69275805d shell-redesign V1 attempt-3 Phase 2 BOUNCE-FIX #2: drawer 38rem + 1px borders
Triple-gate verdicts on commit 87f8155:
  review-lead APPROVE | design-reviewer DONE | guard-3 PROCESS-BLOCKER
Founder walk surfaced 2 spec-level aesthetic refinements; orch dispatched
this bounce-fix #2.

Canon updates landed upstream:
  universal-shell.md L36 — drawer width 720px → 608px (38rem) iPad-portrait
  universal-shell.md L39–L48 — NEW Border weight doctrine LOAD-BEARING:
    1px outline-variant for ALL inter-region borders. NO 2px stone-900
    between rail/explore/center/drawer/top-bar. Tactical-dossier aesthetic
    is "sheets of vellum paper layered together" via tonal shifts + thin
    lines, NOT boxed UI.
  content-drawer.md L9 + L19 — width recalibrated 720px → 608px after
    founder walk (too-wide drawer dominated viewport).

ITEM 1 — VellumSheet "wide" preset 45rem → 38rem:
  packages/ui/src/components/ui/vellum-sheet.tsx
  - widthClass.wide: "lg:w-[45rem]" → "lg:w-[38rem]"
  packages/ui/test/vellum-sheet.test.tsx
  - assertion updated to lg:w-[38rem]

ITEM 2 — Inter-region borders 2px stone-900 → 1px outline-variant
(7 edits across 3 files; runtime-verified via getComputedStyle):

  AppShell.tsx Rail base classes (lines 263–270):
    "bg-background border-stone-900 flex shrink-0" + "border-r-2"/"border-b-2"
    → "bg-background border-outline-variant flex shrink-0" + "border-r"/"border-b"

  AppShell.tsx top-bar header (line ~401):
    "border-b-2 border-stone-900" → "border-b border-outline-variant"

  AppShell.tsx mobile rail tray (line ~461):
    "border-r-2 border-stone-900" → "border-r border-outline-variant"

  Explorer.tsx aside right edge (line ~574):
    "border-r border-stone-300/25" → "border-r border-outline-variant"

  VellumSheet edge classes:
    "border-r-2 border-stone-900" / "border-l-2 border-stone-900"
    → "border-r border-outline-variant" / "border-l border-outline-variant"

  VellumSheet test assertions updated to match (border-l/r + border-outline-variant
  instead of border-l-2/r-2).

PRESERVED per dispatch EXCEPTIONS clause:
  - Active rail-icon left-edge accent (2px tertiary red stamp) — kept.
  - Hard-shadow 2px stamp on cards (paper press effect) — kept.
  - Card-internal borders (border-stone-900 on VellumCard etc.) — kept.
  - Tree-branch internal guides (border-l border-stone-200 in Explorer
    tree-branch helpers) — kept; tree-internal grammar.
  - Explorer collapse button border + VellumSheet [×] close button border
    — kept; control affordances, not inter-region edges.

ITEM 3 — Permission preflight artifact authored inline in
substrate/.../shell-redesign-v1/attempt-3-driver-3-phase-2-bounce-fix-2-ack.md
§6, per velocity-permission-preflight skill schema. Required fields all
present (active_profile, cwd, launch_flags, target_write_paths,
writable_root_coverage, approval_layer_probe, required_posture,
missing_posture_route, seat_freshness). guard-3 process gate satisfied.

Verification:
  - tsc --noEmit: clean
  - pnpm test: 597/598 PASS (single failure pre-existing
    test/library-review.test.tsx > Send-to-seat modal — unrelated)
  - pnpm build: ✓ in 1.98s
  - agent-browser at 1440x900 /topology:
    * getComputedStyle(app-topbar).borderBottomWidth === "1px"
    * getComputedStyle(app-rail).borderRightWidth === "1px"
    * getComputedStyle(explorer).borderRightWidth === "1px"
    * SC-1 strict count: querySelectorAll("nav, aside").length === 2 (unchanged)
    * Visually: chrome reads as architectural drafting / paper-layered;
      heavy black borders gone; OPENRIG brand + active rail accent
      retained as tactical accents.

Out-of-scope per orch direction:
  - RigDetailPanel legacy retire (deferred to Phase 4 drawer-viewer wiring).
  - Redirect pattern micro-optimization (review-lead ADVISORY-1 non-blocking).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 12:51:14 -07:00
mvschwarzandClaude Opus 4.7 87f81553d9 shell-redesign V1 attempt-3 Phase 2 BOUNCE-FIX: Desktop top bar
Founder walk on Phase 2 commit 5f53d31 surfaced one structural addition:
desktop top bar with brand mark, symmetric with mobile. universal-shell.md
L11–L65 updated upstream — new "Desktop top bar" section L40–L53 makes
the top bar an explicit canonical region (h-14, OPENRIG brand left,
right-slot reserved, fixed, doesn't scroll with center).

Approach (a) per dispatch — single source of truth across viewports:

AppShell.tsx:
- Renamed testid app-mobile-topbar → app-topbar (top bar is now universal,
  not mobile-only).
- Removed lg:hidden from <header> so the SAME element renders at all sizes.
- Hamburger button keeps its own lg:hidden (mobile-only).
- Renamed brand-home-link-mobile → brand-home-link (single Link element).
- Added right-slot with minimal V1 'localhost' env indicator
  (font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant);
  hidden on narrow viewports (sm:flex) to preserve mobile space.

SharedDetailDrawer.tsx:
- Drawer fixed positioning adjusted: 'top-0 right-0 z-30 h-full' →
  'top-14 right-0 bottom-0 z-30' so drawer starts below the top bar.
  bottom-0 anchor avoids needing a calc() expression for height.

Existing positioning unchanged — Rail / Explorer / Center are inside the
inner <div className="flex flex-1 min-h-0 relative"> below the header,
positioned relative to that flex parent (NOT viewport), so adding the
top bar to the flex column automatically pushes them down by h-14 with
no further offsets needed.

Tests (test/app-shell.test.tsx):
- Updated SC-8 describe block (split into Top bar + Mobile rail collapse).
- Added 5 top-bar tests:
  * top bar renders at desktop (single source of truth — no lg:hidden)
  * brand link visible at desktop with href='/' (Dashboard)
  * right-slot env indicator present (V1 = 'localhost')
  * hamburger button is mobile-only (lg:hidden) — preserved Phase 2 behavior
  * legacy app-mobile-topbar / brand-home-link-mobile testids GONE
- Existing SC-1 strict count test unchanged and still passes (top bar is
  a horizontal region, NOT a left chrome).

Verification gates:
- tsc --noEmit: clean
- pnpm test: 597/598 PASS (single failure is pre-existing
  test/library-review.test.tsx > Send-to-seat modal; unrelated)
- pnpm build: ✓ in 1.96s
- agent-browser walk at 1440x900 /topology:
  * document.querySelectorAll('nav, aside').length === 2 (SC-1 unchanged)
  * data-testid='app-topbar' present
  * brand-home-link textContent = 'OPENRIG'; href = '/'
  * topbar-env-indicator textContent = 'localhost'
  * Top bar visible above rail+explore+center; thick black bottom border;
    paper-grid preserved.

ETA realized: ~25 min (within 25–30 estimate).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 12:29:21 -07:00
mvschwarzandClaude Opus 4.7 5f53d31ec3 shell-redesign V1 attempt-3 Phase 2: Shell chrome refactor (Sidebar GONE)
Phase 2 of V1 shell redesign attempt-3, per qitem-20260506184039-61beee6c
from orch-lead@openrig-velocity. Drift-disclosed structured-ACK + GO at
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/
shell-redesign-v1/attempt-3-driver-3-phase-2-ack.md.

⭐ HEADLINE — Sidebar.tsx deletion is the load-bearing structural fix
attempts 1+2 missed. SC-1 mandates exactly 2 left chromes on desktop;
Sidebar surviving alongside the new chrome is exactly what made attempt-2
fail. Phase 2 attempt-3 deletes it entirely. Runtime verified via
agent-browser at 1440x900: document.querySelectorAll("nav, aside").length === 2.

Universal shell chrome implemented per universal-shell.md L13–L34:
- 48px LEFT RAIL (<nav data-testid="app-rail">) with 6 destination icons
  (Dashboard / Topology / For You / Project / Specs / Settings) + 2 chat
  icons (Advisor / Operator V1 placeholders per agent-chat-surface.md
  L45–L52). Order matches universal-shell.md L42–L48. NO discovery icon.
- 280px EXPLORE SIDEBAR (<aside data-testid="explorer">) with surface-routed
  body per universal-shell.md L62 — surface union "topology | project |
  specs | for-you | none". Phase 2 lays the union; Phase 3 fills tree
  contents.
- CENTER WORKSPACE renders Outlet; Phase 2 routes mount canon destinations
  as VellumCard placeholders.
- CONTENT DRAWER (SharedDetailDrawer) wraps panels in VellumSheet (Phase 1
  primitive); default-closed (selection=null returns null); ~720px wide
  via VellumSheet width="wide"; user-closable [×]; SC-6 satisfied.
- Mobile collapse (<lg = 1024px): rail collapses to top-bar menu; rail
  content moves into slide-over tray rendered ONLY at narrow viewport
  (conditional render so desktop DOM doesn't carry an offscreen <nav>
  that fails the SC-1 strict count check). SC-8 scaffolding in place.

DRIFT P2-A resolution: WorkspacePage.tsx PRESERVED (30-line layout
primitive used by 13 keep-listed surfaces). Code-map updated upstream.

DRIFT P2-B resolution: SpecsWorkspace.tsx PRESERVED Option A. Provider +
hook used by AppShell + 5 keep-listed forms. Only SpecsPanel.tsx (the
actual destination chrome) is deleted; the state provider stays.

DRIFT P2-C resolution: MissionControlSurface.tsx shell-and-stub —
removed view imports, replaced body with EmptyState placeholder. The
/mission-control route is deleted per SC-18 (redirects to /for-you), so
the placeholder is unreachable; Phase 3 refactors the stub body in-place
into the For You feed render shape.

Phase 1 advisory carry-forwards landed (review-lead's 3 items):
1. VellumCard href: NO Link wrapper in primitive. Rail consumers compose
   TanStack <Link> directly.
2. VellumCard focus-visible: ADD when href|onClick present
   (focus-visible:outline focus-visible:outline-2 focus-visible:outline-stone-900
   focus-visible:outline-offset-2). +3 tests covering interactive vs static.
3. SectionHeader emphasis tone SPLIT: "emphasis" → "alert" (tertiary red)
   + new "strong" (stone-900 + font-bold). Resolves semantic ambiguity.

Routes refactored (canon shape):
- New: /, /topology, /topology/{rig|pod|seat}/$..., /for-you, /project,
  /project/{mission|slice}/$..., /specs, /specs/applications,
  /specs/$specKind/$specName, /settings, /search.
- Redirects from deleted routes: /mission-control → /for-you (SC-18);
  /context → /topology (SC-25); /slices, /slices/$name → /project; /progress,
  /steering → /project (per project-tree.md L114 + code-map).
- Auto-open-drawer bridges (DiscoveryRouteBridge / SpecsRouteBridge /
  RigSpecReviewRoute / AgentSpecReviewRoute / LibraryReviewRoute) removed
  per SC-6 — drawer opens only on named triggers, never on route load.
- Daemon /api/* UNTOUCHED (SC-29).

Deletions (22 source files):
- Sidebar.tsx (LOAD-BEARING fix)
- SpecsPanel.tsx (specs detail mounts in CENTER per content-drawer.md)
- WorkspaceHome.tsx (Project destination replaces, Phase 3)
- context/ContextWorkspace.tsx (SC-25 — relocates to topology table)
- progress/ProgressWorkspace.tsx (folds into Project tabs Phase 3)
- steering/{Health,InMotion,LaneRails,LoopState,PriorityStack,RoadmapRail}Panel.tsx
  + SteeringWorkspace.tsx (folds into Project workspace > overview Phase 3)
- slices/{SliceExplorerPanel, SliceStoryView}.tsx (Project tree replaces)
- mission-control/views/{ActiveWork, Fleet, Generic, HumanGate, MyQueue,
  RecentObservations, RecentShips, RecentlyActive}View.tsx (8 files;
  semantics fold into For You feed Phase 3)
- AuditHistoryView.tsx PRESERVED (Phase 3 /search mount).

Test cleanup (9 orphaned tests deleted; targeting deleted source):
- context-workspace, mission-control-surface, rig-expansion-ui,
  slice-explorer-lens, slice-story-view-surface, spec-library-management-ui,
  specs-panel, steering-panels, workspace-home test files.
- app-shell.test.tsx fully rewritten (24 tests covering SC-1 strict count,
  SC-2 rail roster, SC-6 drawer default-closed, SC-7 Settings center,
  SC-8 mobile collapse, surface routing, active rail state, legacy
  buttons absent).

Verification gates:
- pnpm exec tsc --noEmit: clean
- pnpm test: 593/594 PASS (single failure pre-existing on baseline,
  test/library-review.test.tsx > Send-to-seat modal — verified unrelated
  to Phase 2 via git stash on baseline)
- pnpm build: ✓ built in 2.11s; JS bundle 957kB → 826kB (-131kB by
  deleting legacy chrome)
- agent-browser visual walk at 1440x900: 2 left chromes (rail + explore);
  rail icons rendered in canonical order; topology rail active at
  /topology; drawer NOT visible at any default route; paper-grid background
  preserved.

Founder walk packet authored at:
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/
shell-redesign-v1/founder-walks/phase-2-walk.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 12:08:54 -07:00
mvschwarz 609e525436 merge(shell-redesign-v1-attempt-3): Phase 1 — Foundation + 9 vellum primitives (V1 ATTEMPT-3)
V1 attempt-3 Phase 1 — canon-first re-implementation. After attempt-1
and attempt-2 both shipped structurally non-compliant work despite
passing internal review gates, attempt-3 makes canon docs the literal
operational source of truth and adds 5 process additions:
- Code-map.md (per-file delta from baseline 24db2b0)
- Per-phase founder walk gate (before triple-gate)
- Primitive-first phasing (foundation → chrome → destinations → drawer → polish)
- Drift disclosure rule (canon vs derivative conflicts escalate)
- Canon docs absorbed addendum content (no separate addendum file)

WHAT SHIPPED (9827de6)
- globals.css: 2 surgical edits per driver-3 drift disclosures
  - DRIFT-2 fix: .reg-tl made self-contained matching .reg-tr/.reg-bl/.reg-br
    (parent-qualified .reg-mark > .reg-tl rule preserved as harmless
    redundancy for RigGraph compat)
  - DRIFT-3 fix: paper-grid dot 0.5px → 1px (was sub-pixel-rounded
    invisible at typical browser zoom; 1px is still subtle but visible
    to the designer eye)
- DRIFT-1 verified: tailwind.config.ts already had paper-grid + vellum
  tokens + fonts + reg-marks + vellum overlays + hard-shadow + stamp-
  watermark at baseline 24db2b0 (commits 64afb7f + e316116). Phase 1
  token-edit delta is verify-and-reconcile; no full add was needed.

- 9 vellum primitives at packages/ui/src/components/ui/:
  registration-marks, vellum-card (lifted from RigCard.tsx canonical
  preserved on main), vellum-sheet (from content-drawer.md spec +
  .vellum-heavy CSS), section-header, status-pip, empty-state, vellum-
  input, vellum-select, rig-stamp. Each primitive standalone; no
  production consumers in Phase 1 (consumer adoption is Phase 2+).

- 82 unit tests across 9 files. Notable:
  - registration-marks.test.tsx: CSS-source-assertion regression test
    that asserts each corner ::before/::after has self-contained
    content + position + bg-color. Bakes attempt-2 Phase 7 Batch 3
    bounce-fix lesson (pseudo-element-paint test contract — discipline
    ritual #7) into the regression suite.
  - status-pip.test.tsx: negative-assertion test verifies StatusPip does
    NOT render workspace-kind taxonomy strings (discipline ritual #8).

- Founder walk packet at substrate/.../shell-redesign-v1/founder-walks/
  phase-1-walk.md — judges scope-this-phase (paper-grid + tokens +
  primitives renderable); explicitly NOT yet (chrome / destinations /
  drawer / mobile = Phase 2-5).

VERIFICATION
- tsc clean across cli + daemon + ui
- UI tests: 648/649 passing / 1 pre-existing library-review carry-
  forward (Send-to-seat modal failure verified pre-existing on baseline
  24db2b0 — outside Phase 1 scope)
- Net regression: ZERO
- agent-browser walk at 1440x900: paper-grid renders 1px; legacy
  surfaces unchanged

PROCESS DISCIPLINE LANDED
- Driver-3 invoked drift disclosure rule on first ACK with 5 specific
  drifts (token redundancy, .reg-tl asymmetry, paper-grid dot calib,
  primitive lift sources, branch naming) — all resolved per orch
  direction before code work began
- Founder walked dev server at http://localhost:5179/ and approved
  before triple-gate dispatched
- Triple-gate (review-lead APPROVE WITH NOTES + redo3-guard-3 CLEAR
  + design-reviewer PHASE 1 DONE) all green

NEXT: Phase 2 (shell chrome refactor — AppShell rail + Explorer
surfaces + SharedDetailDrawer named-trigger contract + Sidebar.tsx
deletion + routes.tsx canonical refactor).
2026-05-06 11:39:21 -07:00
mvschwarzandClaude Opus 4.7 9827de6a88 shell-redesign V1 attempt-3 Phase 1: Foundation + 9 primitives
Phase 1 of the V1 shell redesign attempt-3, per qitem-20260506173041-56702b70
from orch-lead@openrig-velocity. Drift-disclosed structured-ACK + GO at
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/
shell-redesign-v1/attempt-3-driver-3-phase-1-ack.md.

Verify-and-reconcile (DRIFT-1): most design tokens already present at
baseline 24db2b0 (preserved through prior attempt reverts via commits
64afb7f + e316116). Phase 1 reconciles two specific deltas:

- DRIFT-2 fix: .reg-tl is now self-contained matching .reg-tr/bl/br.
  Each pseudo-element carries its own content + position + bg-color.
  Parent-qualified .reg-mark > .reg-tl rule preserved as harmless
  redundancy for the existing RigGraph.tsx:421 consumer.
- DRIFT-3 fix: paper-grid radial-gradient bumped from 0.5px → 1px
  (Option A). Visible at standard browser zoom; runtime-verified via
  getComputedStyle.

Authored 9 vellum primitives at packages/ui/src/components/ui/:
- registration-marks.tsx — 4-corner crosshair primitive
- vellum-card.tsx — primary card chrome (lifted grammar from RigCard.tsx)
- vellum-sheet.tsx — drawer/overlay translucent paper chrome
- section-header.tsx — canonical eyebrow
- status-pip.tsx — semantic 6-status indicator
- empty-state.tsx — unified empty/loading/error; variant=card composes
  VellumCard
- vellum-input.tsx — vellum form input (forwardRef)
- vellum-select.tsx — vellum form select (forwardRef)
- rig-stamp.tsx — rotated faded watermark

82 unit tests across 9 test files, including:
- CSS-source-assertion regression test for DRIFT-2 (jsdom can't render
  pseudo-elements; test reads globals.css directly; pseudo-element-paint
  test contract per discipline ritual #7).
- Negative-assertion test on StatusPip (does NOT render workspace-kind
  taxonomy labels; discipline ritual #8).
- DRIFT-3 paper-grid radius regression (asserts 1px in globals.css).

OUT-OF-SCOPE confirmed not touched: AppShell, Explorer, SharedDetailDrawer,
Sidebar (deletion), Dashboard, routes.tsx, all destination subdirs (for-you/,
topology/, project/, specs/, dashboard/, drawer/, chat/), drawer viewers/
triggers, mobile responsive, consumer migration, daemon code (SC-29
satisfied vacuously), CLI, existing canonical preserved components,
existing shadcn primitives.

Verification gates:
- tsc --noEmit: clean
- vitest: 648/649 (single failure in test/library-review.test.tsx is
  pre-existing on baseline 24db2b0, unrelated to Phase 1)
- vite build: succeeded in 1.98s
- agent-browser visual walk at 1440x900: paper-grid renders; no chrome
  regression on existing routes

Founder walk packet authored at:
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/
shell-redesign-v1/founder-walks/phase-1-walk.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-06 10:50:27 -07:00
mvschwarz 24db2b09e5 fix(workflow): allow RSI Discovery loopback to park no-follow-on 2026-05-05 00:21:04 -07:00
mvschwarz 1aa8bcd0ad fix(workflow): honor next_hop loop edges — enable RSI QA back to Discovery 2026-05-05 00:16:30 -07:00
mvschwarz f1fe12a5d4 fix(agent-images): fork live Claude and Codex seats from valid native ids 2026-05-04 23:39:12 -07:00
mvschwarz 78cfd18cc5 fix(recovery): allow dev-test provider auth passthrough — automate noninteractive seats 2026-05-04 22:17:49 -07:00
mvschwarz 6a2a1796d6 fix(recovery): add opt-in provider prompt auto-drive 2026-05-04 22:02:36 -07:00
mvschwarz ab1fbeef34 fix(recovery): rehydrate rigs from current DB after tmux loss 2026-05-04 21:48:00 -07:00
mvschwarz 6834a7fd00 test(qa): cover context-pack validation and global init json 2026-05-04 21:11:58 -07:00
mvschwarz 63bc58e75d fix(config): emit JSON for init-workspace — restore agent-readable setup output 2026-05-04 21:03:05 -07:00
mvschwarz cf29fdaaa2 fix(workspace): carry target repo override through whoami — satisfy PL-007 env scope 2026-05-04 20:56:11 -07:00
mvschwarz ab01fdae90 fix(context-pack): populate send picker from live destinations — unblock PL-014 UI send 2026-05-04 20:50:48 -07:00
mvschwarz bddc46d914 fix(mission-control): allow human seat queue targets — unblock human-gate seeds 2026-05-04 20:39:04 -07:00
mvschwarz 70f8adcbcf fix(context-pack): reject unsafe add manifests — close B1 path escape 2026-05-04 20:32:24 -07:00
mvschwarz 0e7f3a3cb0 Merge pl016-finding2-source-cwd (Option 3 — closes Finding 2 from review-lead's PL-016 live e2e). Founder direction 2026-05-04: respect Claude's project-dir-scoped session storage; no daemon-side cwd-override magic; no funny business. Implementation: AgentImageManifest gains optional source_cwd field captured at create time via shared resume-token-discovery seam (single nodes-table query reads runtime + cwd together; reused by SnapshotCapturer + future fork route). Library service writes source_cwd to manifest.yaml; AgentImageEntry.sourceCwd surfaces verbatim (string|null). Snippet generator (buildStarterSnippet in routes/agent-images.ts) prepends cwd: <source_cwd> ahead of session_source block when entry.sourceCwd truthy; renders without cwd line when null (current behavior preserved exactly for pre-Finding-2 manifests). LibraryReview UI surfaces source_cwd row with honest "(unknown — pre-Finding-2 manifest)" fallback. Daemon NEVER reads sourceCwd at fork dispatch; rigspec-instantiator agent_image branch is byte-equivalent unchanged (verified by 3 orthogonal checks: empty git diff + empty grep + negative-regex test assertions). If operator changes cwd in their rig.yaml after pasting the snippet → fork breaks honestly with Claude "no conversation found" → operator gets clear remediation. The snippet's cwd line IS the contract: paste verbatim or take the consequences. Identifier-drift discipline locked: source_cwd snake_case YAML / sourceCwd camelCase TS lockstep across schema + parser + library service + snippet generator + UI hook + tests + commit message. Tests: 10 new in agent-image-source-cwd.test.ts (capture 2 / render 3 / parse back-compat 3 / operator-override safety 2); daemon 3084 + 1 env-flag-gated skip; cli 849/854 (5 pre-existing main-baseline failures); ui 567/567 unchanged; tsc clean. PL-016 Phase A + hardening v0+1 LOAD-BEARING invariants byte-equivalent preserved. Code review CLEAN by review-lead. 2 advisory non-blockers: I — third occurrence of "claimed 3 pre-existing CLI failures" off-by-2 doc nit (recommend canonical baseline doc with 5 line numbers to prevent repetition); J — operator-override negative-grep test catches direct-assignment patterns only (v0+1 polish: tighten via ESLint/arch-test scanning for any new sourceCwd reference outside allow-list). INTERIM nature: this fix unblocks Use Case A (clone productive seat in same dir) which is the immediate dogfood need. Use Case B (greenfield from primed context in new project dir) requires the IDEAL FORK functionality via JSONL session-file context injection — currently in lab phase per Trigger E entry in mode-2/PROGRESS.md Deferred Follow-Ons. 2026-05-04 17:39:02 -07:00
mvschwarzandClaude Opus 4.7 8d4ab3f205 fix(agent-images): PL-016 Finding 2 — capture source_cwd, emit cwd in starter snippet (Option 3, founder-confirmed 2026-05-04)
Founder direction 2026-05-04: "respect the provider; no funny business;
needs to be extremely reliable; ergonomic." Implementation = Option 3
of the design space. We respect Claude's project-dir-scoped session
storage by ensuring the rendered Use-as-starter snippet has the right
cwd baked in. The daemon does NOT override cwd at fork dispatch.

The fix shape is INTERIM and bounded to Use Case A — clone a productive
seat in the same dir. Use Case B (greenfield from primed context in a
new project dir) needs the IDEAL FORK functionality via JSONL session-
file context injection — that work continues in the lab folder
(openrig-work/lab/session-context-tools/) and is NOT in scope here.
Trigger E in mode-2/PROGRESS.md Deferred Follow-Ons tracks Use Case B.

Changes:

  - AgentImageManifest type: optional sourceCwd field (camelCase TS,
    source_cwd in YAML). Lockstep snake/camel name across manifest
    schema + library service + parser + snippet generator + tests +
    this commit message + future skill mentions.

  - AgentImageEntry type: sourceCwd: string | null on the runtime
    surface. null when manifest predates Finding 2 (back-compat).

  - manifest-parser.ts: reads source_cwd / sourceCwd from YAML; pre-
    Finding-2 manifests parse with sourceCwd: undefined.

  - resume-token-discovery.ts: DiscoveryResult extended with nodeCwd:
    string | null. Single nodes-table query reads runtime + cwd
    together (no extra round-trip). Shared by SnapshotCapturer + any
    future fork route.

  - snapshot-capturer.ts: captures source seat's cwd into manifest
    sourceCwd via the discovery result. nodeCwd null → manifest field
    omitted (preserves back-compat fixture shape).

  - agent-image-library-service.ts install(): writes source_cwd to
    manifest.yaml when present. toEntry() surfaces sourceCwd verbatim
    from manifest into the AgentImageEntry runtime shape (null when
    absent).

  - agent-image-routes.ts buildStarterSnippet(): when entry.sourceCwd
    is set, prepends `cwd: <source_cwd>` ahead of the session_source
    block in the rendered YAML. Operator pastes verbatim, fork starts
    in source's dir, Claude's project-dir-scoped session storage
    works because the jsonl file lives there. When sourceCwd is null
    (pre-Finding-2 manifest), snippet renders without the cwd line —
    current behavior preserved exactly. Resume-token redaction at the
    HTTP boundary unchanged (sourceCwd is not sensitive; flows through
    redactResumeToken's spread).

  - useAgentImageLibrary.ts: AgentImageEntry UI shape extended with
    sourceCwd: string | null. LibraryReview surfaces a "source cwd"
    line so operators see WHERE the parent session was created (not
    just WHO). Pre-Finding-2 manifests render "(unknown — pre-
    Finding-2 manifest)" honestly.

NO daemon-side cwd override at fork dispatch. The rigspec-instantiator
agent_image branch never reads sourceCwd; it computes nothing from it.
If operator manually changes cwd: in their rig.yaml after pasting the
snippet → fork runs in the new dir → Claude returns "no conversation
found" → operator gets honest error per architecture.md § Resume
honesty. (The snippet's cwd line IS the contract: paste verbatim or
take the consequences.)

Tests (10 new in packages/daemon/test/agent-image-source-cwd.test.ts):

  Snapshot capturer:
    - captures source seat's cwd into manifest source_cwd
    - omits source_cwd when source node has no recorded cwd (legacy)

  Snippet generator:
    - rendered snippet includes cwd line + cwd precedes session_source
    - back-compat: snippet omits cwd line when manifest has no
      source_cwd (current behavior preserved)
    - library entry surfaces sourceCwd verbatim (null for pre-
      Finding-2)

  Manifest parser back-compat:
    - accepts source_cwd (snake_case)
    - accepts sourceCwd (camelCase)
    - manifests without source_cwd still parse (back-compat)

  Operator-override safety:
    - rigspec-instantiator does NOT mutate member.cwd from agent_image
      manifest (negative grep against the source — fails if a future
      patch adds the override path)
    - snippet generator emits cwd ONLY from manifest source_cwd (no
      inferred fallback)

Test results:
  Daemon: 3084 passed + 1 skipped (3074 baseline + 10 new). 1 skipped
    is the OPENRIG_REAL_CLAUDE_INTEGRATION lane from PL-016 hardening
    v0+1.
  CLI: 851/854 — 3 pre-existing failures match main baseline; no CLI
    surface changes (snippet renderer is daemon-side; CLI prints
    response verbatim).
  UI: 567/567 — no new UI test files; existing LibraryReview tests
    pass through the additive sourceCwd line without regression.

TSC clean across daemon/cli/ui.

NOT in scope (explicit per founder dispatch):
  - daemon-side cwd override (rejected — "respect the provider")
  - session jsonl import/copy (rejected — couples to Claude internals)
  - codex multi-runtime forwarding (separate scope; this is claude-
    code-specific until codex parity is named)
  - Use Case B greenfield-from-primed-context-in-new-dir (lab work
    via openrig-work/lab/session-context-tools/; tracked at Trigger E)

Permission posture: fleet-ok throughout.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 17:31:08 -07:00
mvschwarz c2d546a464 Merge pl016-hardening-v0-1 (closes Findings 1+3+4 from review-lead's PL-016 live e2e 2026-05-04). Finding 1 (P1): pollForResumeToken helper (12 × 500ms = 6s ceiling) replaces the immediate captureResumeToken call in claude-code-adapter fork branch — every claude-code agent_image fork was silently broken since PL-016 merge because mocked-fs tests passed instantly hiding the 1-3s real-Claude session-write delay. Finding 3 (P3): renamed --version to --image-version on rig agent-image create (was silently unreachable due to Commander.js global --version interception); preserves global --version flag. Finding 4 (P3): two-phase recordConsumption (pre-launch incrementForkCount: false bumps lastUsedAt only; post-launch incrementForkCount: true only when startupResult.ok===true) — fork_count no longer pollutes from failed launches. Discriminated-union opts arg preserves legacy positional now form for back-compat. Tests: daemon 3029 + 1 env-flag-gated skip (3022 baseline + 7 new); cli 849/854 (5 pre-existing main-baseline failures); ui 559/559 unchanged. PL-016 Phase A LOAD-BEARING invariants byte-equivalent preserved (resume-token redaction at HTTP route boundary, evidence-preservation guard with --force, 3-branch session_source symmetry, AgentImageLibraryService singleton hoisting). Code review CLEAN by review-lead. 2 advisory non-blockers: G — env-flag-gated test 4 is a placeholder (asserts UUID regex only; actual real-binary launchHarness exercise is v0+1 follow-up); H — dispatch + commit message claim '3 pre-existing CLI failures' but actual is 5 (off-by-2; baseline docs need canonical line numbers). Finding 2 (PRODUCT-DESIGN — agent_image fork cwd-resolution gap, founder-confirmed Option 3) intentionally NOT in this hardening pass; ships as separate dispatch off updated main. 2026-05-04 17:20:15 -07:00
mvschwarz 52d679e5d0 Merge workspace-primitive-v0 (PL-007: workspace as a typed primitive composing on top of user-settings-v0's workspace.* settings cascade. 5 typed kinds (user/project/knowledge/lab/delivery) + RigSpec.workspace block (workspace_root + repos[].kind + default_repo + knowledge_root) + WhoamiResult.workspace block (activeRepo with OPENRIG_TARGET_REPO env override; knowledgeKind when knowledge_root declared) + queue_items.target_repo column with --target-repo CLI flag + route validation against source rig's repos[] + handoff inheritance + NodeInventoryEntry.workspace per-node block (longest-prefix kind from cwd) + rig workspace validate CLI subcommand + /api/workspace/validate route (advisory-only frontmatter validator) + UI integration via WorkspaceKindBadge + 5 consumer surfaces (Files / Progress / Slices / Specs / Steering). 2 new migrations (038_workspace_primitive: rigs.workspace_json TEXT; 039_queue_target_repo: queue_items.target_repo TEXT + idx). Defensive column probes (hasRigColumn / detectQueueColumn) parallel hasNodeColumn pattern. NO new tables, NO new event types. All 7 PRD items shipped, no carve-outs. Code review CLEAN by review-lead at f905159. 6 advisory findings (all non-blocking, all v0+1 polish or doc nits): A — UI re-declares WorkspaceKindLabel per existing daemon-ui pattern (paired-update on sixth-kind addition); B — schema does NOT validate path existence at parse time (live filesystem checks deferred to preflight); C — env OPENRIG_TARGET_REPO honored verbatim while --target-repo flag rejects unknown (asymmetric but defensible); D — queue-repository.ts comments reference 'migration 038' but target_repo ships in 039 (doc nit); E — rig ps --nodes --filter workspace.kind=... not implemented (PRD line 147; data reachable via --json; v0+1 trigger); F — driver dispatch claimed '3 pre-existing CLI failures'; actual is 5 (zero new failures introduced). Tests: daemon 3067/3067 (45 new), ui 567/567 (8 new), cli 847/852 (5 pre-existing main-baseline failures verified by line number). Builds clean across daemon + cli + ui. PL-016 LOAD-BEARING resume-token redaction at HTTP boundary preserved unchanged. Velocity-qa headed-browser dogfood deferred to broader v0.3.0 test-plan execution.) 2026-05-04 17:10:14 -07:00
mvschwarzandClaude Opus 4.7 cb70bf49f9 fix(agent-images): PL-016 hardening v0+1 (3 review-lead live e2e findings)
Three fixes from review-lead's live e2e dogfood of PL-016 Phase A on
2026-05-04. Founder-authorized hardening pass; no scope creep beyond
the named findings.

Finding 1 (P1 defect) — claude-code adapter resume-token poll loop:
  packages/daemon/src/adapters/claude-code-adapter.ts. Fork branch
  called captureResumeToken IMMEDIATELY after Enter; real Claude
  needs 1-3s to write the new fork session-name file under
  ~/.claude/sessions/. Result: EVERY claude-code agent_image fork
  was silently broken since PL-016 merge — captureResumeToken always
  returned undefined against a real binary, the fork branch always
  errored "could not capture new post-fork session id". Mocked-fs
  tests passed because the fixture returned the token instantly.
  Fix: pollForResumeToken(name, attempts, delayMs) helper mirroring
  verifyResumeLaunch's cadence (12 × 500ms = 6s ceiling). Error
  message names the ceiling so operators can correlate timing.

  Test gap fix: NEW packages/daemon/test/claude-code-adapter-fork-
  poll.test.ts covers (a) deferred-write success path (3 sleeps then
  capture), (b) immediate-success short-circuit (zero sleeps), and
  (c) exhaustion path (12 attempts, 11 sleeps, structured error
  with poll ceiling). Plus an env-flag-gated lane
  (OPENRIG_REAL_CLAUDE_INTEGRATION=1) reserved for the real-binary
  regression test — skipped in CI; operator runs against productive
  rig when explicitly enabled.

Finding 3 (P3 UX nit) — rig agent-image create --version flag:
  packages/cli/src/commands/agent-image.ts. Commander.js intercepts
  --version as the global version flag (prints CLI version + exits 0
  silently); the per-command --version was unreachable. Renamed to
  --image-version for the per-command name; preserves the global
  --version surface. Test (NEW packages/cli/test/agent-image.test.ts):
  asserts --image-version 2 propagates as body.version=2 on the
  snapshot POST AND that the create command does NOT register a
  --version option (catches future re-introductions of the
  collision).

Finding 4 (P3 data quality) — fork_count on failed launches:
  packages/daemon/src/domain/rigspec-instantiator.ts +
  agent-image-library-service.ts. recordConsumption was called BEFORE
  startupOrchestrator.startNode, so failed launches polluted
  forkCount with intent-not-success noise. Two-phase fix: pre-launch
  recordConsumption(id, { incrementForkCount: false }) bumps
  lastUsedAt only (records intent regardless of outcome — operators
  see "this image was tried"); post-launch recordConsumption(id,
  { incrementForkCount: true }) only runs when startupResult.ok===true.
  recordConsumption signature extended with opts object; legacy
  positional (() => Date) form preserved for back-compat.

  Tests: 2 new lib-level cases on agent-image-library-service.test.ts
  ({ incrementForkCount: false } bumps lastUsedAt only; legacy
  positional form unchanged); NEW packages/daemon/test/agent-image-
  fork-count-gate.test.ts spies the library across an instantiator
  flow with a failing adapter (assert: 1 call, false) AND a
  succeeding adapter (assert: 2 calls — pre-launch false, post-launch
  true).

Test results:
  Daemon: 3029 passed + 1 skipped (3022 baseline + 7 new + 1 skip
    for OPENRIG_REAL_CLAUDE_INTEGRATION lane).
  CLI: 851/854 (3 pre-existing failures match main baseline:
    CLI dist build / config env / preflight env — NOT introduced
    by this slice).
  UI: 559/559 (no UI surfaces touched).

TSC clean across daemon/cli/ui.

Identifier-drift discipline: --image-version flag name is the only
new identifier; locked at audit; no other CLI/docs cite the old
--version per-command flag (the only callers were the create
verb's own action handler).

Permission posture: fleet-ok throughout. No host-touching beyond
the worktree, no provider auth, no destructive lifecycle operations.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 17:09:39 -07:00
mvschwarzandClaude Opus 4.7 f9051590cb feat(workspace): typed workspace primitive (PL-007 v0)
PL-007 Workspace Primitive v0 — five typed workspace kinds (user /
project / knowledge / lab / delivery) declared in RigSpec, surfaced
through whoami / node-inventory / queue substrate target_repo, composed
into Files / Progress / Slices / Specs / Steering UI surfaces. ONE
ambitious vertical: paper convention graduates into product schema; all
seven items ship together.

Items shipped:

  Item 1 — five typed workspace kinds. WORKSPACE_KINDS enum lives in
  domain/types.ts. Reserved set at v0; sixth kind is a v1+ amendment
  with a NAMED trigger.

  Item 2 — RigSpec.workspace optional block. workspace_root + repos[]
  (name/path/kind) + default_repo + knowledge_root. Validates: kinds
  in the reserved 5; repo names unique; default_repo references a
  declared repo. Codec round-trips. Normalize resolves relative repo
  paths against workspace_root. Back-compat: rigs without the block
  stay valid.

  Item 3 — WhoamiResult.workspace block. whoami-service + workspace-
  resolver derive activeRepo (default_repo with OPENRIG_TARGET_REPO env
  override) + repos[] + knowledgeRoot/knowledgeKind. Null when no rig
  workspace is declared.

  Item 4 — queue_items.target_repo column. Migration 039 adds the
  typed flag; rigx queue create/handoff/handoff-and-complete/list
  accept --target-repo; route validates against source rig's
  RigSpec.workspace.repos[] with structured error on unknown name;
  handoff inherits when not overridden.

  Item 5 — NodeInventoryEntry.workspace block. Per-node activeRepo +
  kind derived from cwd via longest-prefix match (knowledge fallback
  when cwd is under knowledgeRoot).

  Item 6 — rig workspace validate CLI + /api/workspace/validate route.
  Walks a workspace root, parses YAML frontmatter, emits structured
  gap report (missing-required-field / unrecognized-status-value /
  parse-error / missing-frontmatter). Per-kind required fields contract
  (knowledge canon: doc/status/created/owner). Advisory only — never
  modifies files; non-zero exit on gaps so operators can chain into
  hygiene fix loops.

  Item 7 — UI integration. WorkspaceKindBadge shared component (5
  colors + glyph for compact mode + resolveKindForPath helper). Hook
  useWorkspace reads /api/whoami's workspace block. Light-touch
  badges in:
    A. FilesWorkspace allowlist root selector
    B. ProgressWorkspace per-file headers
    C. SliceExplorerPanel rows (slicePath surfaced from indexer)
    D. SpecsPanel library entries (sourceType-aware)
    E. LoopStatePanel + InMotionPanel headers (knowledge canon)

Schema:

  Two new migrations (split per-domain so tests can apply only the
  half they need):
    - 038_workspace_primitive.sql: ALTER TABLE rigs ADD COLUMN
      workspace_json TEXT
    - 039_queue_target_repo.sql: ALTER TABLE queue_items ADD COLUMN
      target_repo TEXT + idx_queue_items_target_repo

  No new tables. No new event types. Defensive column probes on
  RigRepository (hasRigColumn) + QueueRepository (detectQueueColumn)
  mirror the existing hasNodeColumn pattern so older test fixtures
  that bypass the canonical migration list degrade gracefully.

Test coverage (45 new daemon tests + 8 new UI tests):

  - rigspec-workspace.test.ts (10): validate accepts / rejects
    malformed / 5 typed kinds / codec round-trip
  - workspace-resolver.test.ts (10): activeRepo resolution + env
    override + longest-prefix kind matching + knowledge fallback
  - workspace-frontmatter-validator.test.ts (8): per-kind required
    fields + status enum + parse-error + advisory invariant
  - workspace-routes.test.ts (4): /api/workspace/validate happy paths
    + 400 on missing root / unknown kind
  - queue-target-repo.test.ts (7): create round-trip + list filter +
    handoff inherit + handoff-and-complete inherit + legacy fixture
    degrades gracefully
  - whoami-workspace.test.ts (3): workspace populated / null / legacy
    fixture
  - WorkspaceKindBadge.test.tsx (8): renders 5 kinds + compact glyph
    + resolveKindForPath cases

Daemon 3067/3067 (3022 baseline + 45 new) — tsc clean.
CLI 849/852 (3 pre-existing failures match main baseline) — tsc clean.
UI 567/567 (559 baseline + 8 new) — tsc clean.

v0+1 named triggers (deferred):
  - Sixth workspace kind (R&D pod / content-creation): operator
    dogfood reports needing one for real work
  - Hard-validation frontmatter mode: curate-steward reports advisory-
    only insufficient
  - rigx wrapper TSV registration for "rigx workspace validate" alias:
    operator preference signals matter more than namespace consistency
  - workspace-aware Mission Control deep-link composition: post-PL-007
    dogfood reveals cross-rig handoff confusion that needs richer
    rendering than the field on its own
  - Per-task target_repo override on rigx stream emit / chat / etc.:
    proven by dogfood that body-only is insufficient

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 14:40:46 -07:00
mvschwarz a9637fd1f3 Merge fork-primitive-starter-images-v0 (PL-016 Phase A: agent_image as 4th typed primitive parallel to skills + workflow_specs + context_packs. Items 2-6 + 8 shipped — manifest contract + AgentImageLibraryService + Specs library 7th filter chip with IMAGE badge + LibraryReview variant with 4-card summary + lineage panel + Use-as-starter snippet + Pin/Unpin + 9 CLI verbs (list/show/preview/create/delete/pin/unpin/prune/sync) + AgentSpec session_source: mode: agent_image declarative consumption + evidence-preservation guard with --force escape hatch + LOAD-BEARING resume-token redaction at route boundary. Items 1 (rig fork verb) + 7 (Steering disk badge) deferred to v0+1 per founder direction — ship as-is in dogfood, track for later. Two-step fork flow today: rig agent-image create <session> --name X + rig up <rig with session_source ref X>. NO new schema/migrations/events. 124 tests pass on touched lanes (105 daemon + 19 UI). 1 test-coverage gap: cli/test/agent-image.test.ts (verbs are thin HTTP wrappers; daemon routes covered). Code review CLEAN by review-lead — identifier-drift discipline locked, AgentImageLibraryService hoisting parallel to PL-014, evidence-guard fails CLOSED, 3-branch session_source symmetry, resume-token redaction VERIFIED with SMOKE-RESUME-TOKEN-DO-NOT-LEAK fixture. Caveats (all v0+1, no blockers): A test coverage gap on CLI; B fixture-format mismatch noted; C live productive-seat headed dogfood deferred.) 2026-05-04 13:11:55 -07:00
mvschwarzandClaude Opus 4.7 54413ecda9 feat(agent-images): typed primitive + AgentSpec session_source: mode: agent_image (PL-016 Phase A)
PRD: slices/fork-primitive-starter-images-v0/IMPLEMENTATION-PRD.md.

Closes the fork half of the context-as-code convergent flow with PL-014:
operators reference an agent_image baseline declaratively in agent.yaml
(session_source: mode: agent_image, ref.kind: image_name) → daemon
resolves the image's resume token at startup → seat launches with
nativeResumeProbe=resumed semantics preserved. Composes with the
PL-014 startup_files: kind: context_pack extension shipped in PL-014
to give operators the full context-as-code authoring surface.

Phase A scope = Items 2, 3, 4, 5, 6, 8 (the typed-primitive headline,
including the CATASTROPHIC-BOUNCE evidence-preservation guard and the
load-bearing AgentSpec mode: agent_image consumption path).

Items 1 (rig fork verb) and 7 (Steering health gate) carved out with
NAMED v0+1 triggers — see handoff for orch ratification.

Daemon (Items 2, 4, 6, 8)
- New domain/agent-images/{agent-image-types, manifest-parser,
  agent-image-library-service, snapshot-capturer, evidence-guard,
  resume-token-discovery}.ts.
- Manifest schema: name + version + runtime + source_seat +
  source_session_id + source_resume_token + created_at + notes? +
  files[]? + estimated_tokens? + lineage[]? — guards: ../escapes
  rejected, allowed file suffixes, runtime allow-list (claude-code |
  codex), required-fields enforced.
- Library service mirrors ContextPackLibraryService (PL-014): scan +
  list + get + getByNameVersion + recordConsumption (atomic stats
  bump) + pin/unpin (.pinned sentinel) + install (manifest YAML
  emit + stats.json + supplementary files). Workspace-edit-wins
  reconciliation. NO new SQLite tables.
- Resume-token discovery: Claude reads sessions.resume_token; Codex
  reads bindings.external_session_name when external-CLI; tmux Codex
  surfaces null honestly (NAMED v0+1 trigger for pid-keyed thread-id
  via codex-thread-id.ts).
- AgentSpec session_source: mode: agent_image extension —
  validateAgentImageSessionSource + normalizeSessionSource branch.
  Dispatch in PodRigInstantiator translates to forkSource: { kind:
  "native_id", value: <resumeToken> } so the existing fork code path
  carries the load — nativeResumeProbe semantics preserved per
  architecture.md § Resume honesty rule.
- Runtime mismatch guard: if image runtime ≠ member runtime, fail
  loudly with structured 3-part error (no auto-fallback to fresh).
- Best-effort fork-count increment + lastUsedAt bump on every
  consumption — stat-write failures don't abort launch.
- Evidence-preservation guard (Item 6, CATASTROPHIC BOUNCE if dropped):
  evaluateProtection scans spec-library roots for any agent.yaml /
  rig.yaml referencing the image via session_source: mode:
  agent_image → protects. Pinned images protected. Lineage
  descendants of protected images transitively protected (fixed-
  point iteration up the chain). Guard fails CLOSED; operator
  passes ?force=true to override.
- New /api/agent-images/{library, library/sync, library/:id,
  library/:id/preview, library/:id/pin, library/:id/unpin, snapshot,
  prune, library/:id (DELETE)} routes. Resume tokens REDACTED at
  route boundary — UI never sees real tokens.

CLI (Item 3)
- New rig agent-image family parallel to rig context-pack: list,
  show, preview (with starter-snippet display), create (snapshot),
  delete (--force flag), pin / unpin, prune (--dry-run / --force),
  sync. Each subcommand round-trips against /api/agent-images/*.

UI (Item 5)
- SpecsPanel adds 7th filter chip "agent-images" + IMAGE badge +
  per-row summary (runtime + fork-count + token-estimate + last-used
  + pin marker).
- LibraryReview dispatches to AgentImageReviewBody for agent-image:*
  ids: 4-card summary grid + source/lineage panel + Use-as-starter
  snippet copy-button + Pin/Unpin button + supplementary files list.
- New useAgentImageLibrary / useAgentImagePreview / useAgentImagePin
  hooks; defensive Array.isArray guard for cross-CLI-version drift.

Cross-CLI-version drift (Item 8)
- Older daemons without /api/agent-images/* return 503 → UI hook
  surfaces empty list cleanly (parallel to PL-014 guard).
- Older daemons missing AgentImageLibraryService dep → instantiator
  surfaces structured error at launch instead of fabricating.

Identifier-drift discipline (locked at audit)
- "agent_image" as session_source mode value (lockstep with manifest
  filename + library entry id prefix "agent-image:" + CLI verb family
  "rig agent-image").
- "image_name" as ref.kind (v0 only — image_id / image_hash NAMED
  v1+ triggers).
- Default version "1" when omitted (matches PL-014 manifest
  convention).

NO new SQLite schema, migrations, or event types.

Carve-outs deferred to v0+1 (NAMED triggers, see handoff)
- Item 1: rig fork <source-session> CLI verb. Today operators can
  achieve the same via rig agent-image create + agent.yaml referencing
  the new image. The convenience verb is an orchestration wrapper
  over rig expand + native-id capture; carved for follow-up.
- Item 7: Steering HealthGatesPanel agent-image disk-usage badge.
  Operators see image stats via rig agent-image list / show today.
- Tmux-attached Codex thread-id-via-pid lookup. External-CLI Codex
  works; tmux Codex surfaces null honestly until the daemon plumbs
  pid-keyed lookup.

Tests
- daemon: 3022/3022 (10 manifest-parser + 11 library-service + 7
  evidence-guard + 10 routes + 8 session-source = 46 new; existing
  2 session-source error-message tests updated for the now-three
  modes).
- cli: 852/852 unchanged.
- ui: 559/559 unchanged.
- tsc clean across all three packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 12:54:17 -07:00
mvschwarz 6f338f5341 Merge rig-context-composable-injection-v0 (PL-014: context_packs as 4th typed primitive parallel to skills + workflow_specs + agent_images. Manifest contract at ~/.openrig/context-packs/<name>/manifest.yaml + ContextPackLibraryService walks discovery roots (workspace-aware via SettingsStore) + Specs library 6th filter chip with PACK badge + LibraryReview variant with 4-card summary + per-file role/size/token-estimate + bundle preview + Send-to-seat modal + rig context-pack list/show/preview/add/sync/send CLI verb family + UI Send action via /api/context-packs/library/:id/send + AgentSpec startup_files: kind: context_pack declarative extension at boot — load-bearing for context-as-code convergent flow with PL-016 fork. ALL 7 PRD items shipped; from_addresses (PL-022) deferred. NO new schema/migrations/events. 82 tests pass. Code review CLEAN by review-lead — identifier-drift clean, back-compat verified, honest errors, ContextPackLibraryService singleton hoisted correctly, path-safety enforced at scan/read time. Caveats (all v0+1, no blockers): A install-time vs scan-time validation; B rig context-pack send <name>@<version> form; C YAML snake_case vs label kebab-case is intentional; D live materialize headed not exercised — recommend 5-10min post-promotion pass.) 2026-05-04 12:27:03 -07:00
mvschwarzandClaude Opus 4.7 e4d3f8e1dc feat(context-packs): Item 6 fold-in — AgentSpec startup_files kind: context_pack
PL-014 Item 6 follow-up to fce486c (Items 1-5 + 7).

Closes the strategic gap: context_packs become declarative at agent
launch time. With this fold-in, an agent.yaml can declare:

```yaml
startup_files:
  - kind: context_pack
    name: rsi-v2-priming
    version: 1
```

…and the rig-instantiator resolves the pack against the daemon-side
ContextPackLibraryService, assembles the bundle, writes it to
<rigRoot>/.openrig/resolved-context-packs/<name>-<version>.md, and
hands it off to the existing startup-orchestrator pipeline as a
normal kind: file entry. Composes with PL-016 fork (rail #6) per
founder direction: fork from agent_image baseline → declaratively
layer context_packs at boot → seat self-reach via CLI verbs already
shipped at fce486c.

Type extensions
- StartupFile (domain/types.ts) gains optional kind: "file" |
  "context_pack" + contextPackName?: string + contextPackVersion?:
  string. Default kind = "file" for back-compat.
- ResolvedStartupFile (domain/runtime-adapter.ts) mirrors the same
  optional fields so the instantiator's expandContextPacks helper
  can rewrite entries in place.

Parser + validator (domain/startup-validation.ts)
- normalizeStartupBlock: kind: context_pack entries synthesize a
  stable path (.openrig/resolved-context-packs/<name>-<version>.md)
  + default version "1" when omitted + default deliveryHint
  "send_text" (pack lands as one paste in the live seat) — operator
  can override delivery_hint for pre-launch filesystem delivery.
- validateStartupFile: rejects unknown kind values + missing name
  on kind: context_pack; preserves the safe-path check for
  kind: "file" entries.

Resolver (domain/rigspec-instantiator.ts)
- New PodInstantiatorDeps.contextPackLibrary (optional).
- expandContextPacks helper called from both buildPodResolvedStartupFiles
  and buildResolvedStartupFiles before resolveAutoHints. Looks up the
  pack via ContextPackLibraryService.getByNameVersion, assembles the
  bundle, writes to <rigRoot>/.openrig/resolved-context-packs/, and
  rewrites the entry as kind: "file" pointing at it.
- Honest errors: missing pack → "Context pack '<name>' v<version> not
  found in library. Run 'rig context-pack list' to see what's
  installed." Missing library service (older daemon path) →
  "ContextPackLibraryService is not wired."

Wiring (startup.ts)
- ContextPackLibraryService construction hoisted before
  PodRigInstantiator so the same instance is shared across the
  /api/context-packs/* routes AND the materialize path. Single source
  of truth — operator-installed pack visible to both surfaces
  immediately after a sync.

Identifier-drift discipline
- "context_pack" locked as the kind: value (matches the manifest
  filename, the library entry id prefix `context-pack:`, and the
  CLI verb family `rig context-pack`). v0+1 won't drift.

Tests
- daemon: 2971/2971 (15 new in context-pack-startup-files.test.ts:
  6 normalizer cases + 5 validator cases + 4 expandContextPacks
  cases including missing-pack honest error, missing-library
  honest error, and back-compat with kind: file entries).
- cli: 842/842 unchanged.
- ui: 543/543 unchanged.
- tsc clean across all three packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 12:14:16 -07:00
mvschwarz 9d5932b898 Merge rollup-2026-05-04-vm-fixes — 2 portability fixes from velocity-qa rollup VM dogfood (founder-authorized 2026-05-04). d2243da fix(settings): wire fresh workspace defaults — closes C1 gap from OSR/User-Settings/Token-Context-Usage dogfoods (files.allowlist + progress.scan_roots derive to workspace:<workspace.root> by default; SteeringComposer reads typed workspace.steering_path with env override preserved; /api/steering setup hint mentions rig config init-workspace + workspace.steering_path). e8d6a4e fix(cli): let node guard handle odd releases — engines.node: >=20 in CLI package metadata so npm picks current package on odd Node majors and OpenRig's own check-abi.mjs guard fires (root + private daemon engines stay LTS-only). Code review CLEAN by review-lead at e8d6a4e. 61 tests pass on touched lanes. Caveats (NOT BLOCKERS): Caveat A — workspace.roadmap_path + workspace.delivery_ready_dir still env-only (v0+1 trigger if friction); Caveat B — ABI guard is now sole gating mechanism for odd-Node, must not silently regress. Public-release portability gaps now closed. 2026-05-04 11:47:23 -07:00
mvschwarz e8d6a4e92b fix(cli): let node guard handle odd releases 2026-05-04 11:38:16 -07:00
mvschwarz d2243da13b fix(settings): wire fresh workspace defaults 2026-05-04 11:35:01 -07:00
mvschwarzandClaude Opus 4.7 fce486c16d feat(context-packs): operator-authored composable injection v0 (PL-014)
PRD: slices/rig-context-composable-injection-v0/IMPLEMENTATION-PRD.md.

New typed primitive parallel to skills + workflow_specs.
Filesystem-canonical at ~/.openrig/context-packs/<name>/ +
workspace-local .openrig/context-packs/<name>/. NO new SQLite tables,
NO new migrations, NO new event types.

Daemon (Items 1, 2, 5)
- New domain/context-packs/{context-pack-types, manifest-parser,
  context-pack-library-service, bundle-assembler}.ts.
- Manifest schema: name + version + purpose + files[{path, role,
  summary?}] + estimated_tokens? — guards against ../-escapes,
  absolute paths, unsupported suffixes (.md/.markdown/.yaml/.yml/.txt).
- Library service walks discovery roots in workspace > user_file >
  builtin order (last-wins on collision); workspace-surface
  reconciliation via re-scan (operator filesystem edit always wins).
- Bundle assembler concatenates files with `## File: <path> (role:
  <role>)` framing into ONE coherent paste-ready string. Missing
  files surfaced as warning instead of hard fail. Token estimate via
  the existing chars/4 heuristic.
- New /api/context-packs/{library, library/sync, library/:id,
  library/:id/preview, library/:id/send} routes. send invokes
  SessionTransport.send (one paste, NOT N sends — matches PRD § Item
  5 "single coherent priming injection"); supports dryRun for the
  UI's preview-before-send flow.

CLI (Item 7)
- New `rig context-pack` family: list / show / preview / sync / add /
  send. add installs from a local directory under
  ~/.openrig/context-packs/, refuses symlinks (parallel to `rig specs
  add` contract), and triggers a daemon sync after install.

UI (Items 3, 4)
- SpecsPanel adds 6th filter chip "context-packs" + PACK badge +
  per-row summary (files-count + token estimate).
- LibraryReview dispatches to a new ContextPack review variant for
  context-pack:* ids: header + 4-card summary grid + per-file list
  with role/size/token estimate (MISSING marker for absent files) +
  source-path display + "Send to seat" modal with running-session
  picker + Dry-run / Send buttons + bundle preview pre-block.
- New useContextPackLibrary / useContextPackPreview /
  useContextPackSend / useContextPackSync hooks.

Item 6 carve-out (NAMED v0+1 trigger)
- AgentSpec startup_files extension (kind: context_pack as a startup-
  file source) — DEFERRED. PRD § Bounce Conditions allows this with
  named v0+1 trigger; founder dispatch was hands-off and didn't carve
  it out explicitly. Operator can still send packs to a fresh seat
  via `rig context-pack send` from a startup script or interactive
  command — the headline operator value (typed primitive + library +
  review + send) ships fully without it.
  v0+1 NAMED trigger: operator dogfood reports declaring kind:
  context_pack in agent.yaml startup_files would be friction over
  invoking rig context-pack send manually after launch.

PRD § from_addresses extension is gated by PL-022 markdown-addressing
(PARKED) — out of v0 per dispatch.

Cross-CLI-version drift handling
- UI useContextPackLibrary returns [] on 503 + Array.isArray guard
  on the body so older daemons without the route surface as "no
  packs in library" rather than crashing the SpecsPanel.

Tests
- daemon: 2956/2956 (12 manifest-parser + 11 library-service + 5
  bundle-assembler + 11 routes = 39 new)
- cli: 842/842 (9 new context-pack subcommand tests)
- ui: 543/543 (2 new SpecsPanel filter tests + 2 new LibraryReview
  context-pack variant tests)
- tsc clean across all three packages

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 11:27:53 -07:00
mvschwarz cca3baada1 Merge token-context-usage-surface-v0 (PL-012: ContextUsageRing parallel to PL-019 activity dot in RigNode + rig ps --filter contextUsage.percent>=N + state=critical|warning|low|unknown + CTX column + HealthGatesPanel tier-count click-through to /context?tier= + /context dashboard route + Gauge sidebar tab + whoami runtimeContext block per-runtime honest cases + cross-CLI-version drift fallback — clean ACCEPT, 81 tests pass, no fixes needed; tier thresholds 80/60 locked to existing daemon vocab; Compact READ-ONLY safety stop; Codex pid-keyed threadId resolution deferred to v0+1; C1 fixture limitation same as PL-018 — founder may want 5-10min post-promotion headed pass) 2026-05-04 11:10:12 -07:00
mvschwarzandClaude Opus 4.7 2210ea3e74 feat(context-usage): topology ring + ps filter + /context dashboard + whoami runtime block (PL-012)
PRD: slices/token-context-usage-surface-v0/IMPLEMENTATION-PRD.md.

Surfaces the already-shipped context_usage daemon primitive across
six places: topology graph header, rig ps CLI, /context dashboard
route, Steering health gates click-through, whoami runtimeContext
block, and honest cross-CLI-version drift fallback.

Auto-compact OUT per founder safety stop directive — surface
visibility only, no auto-compaction triggers.

Daemon (Item 4 — Codex/Claude Code runtimeContext)
- whoami-service.ts WhoamiResult extends with runtimeContext block:
  Codex (threadId/conversationId/estimatedTokens — threadId null at
  v0 since pid-keyed lookup needs more plumbing; NAMED v0+1 trigger),
  Claude Code (resumeToken from sessions.resume_token + estimatedTokens
  + lastSampledAt), terminal/unknown → null. Honest degradation when
  data unavailable.

CLI (Item 2 — rig ps --nodes CTX column + filter)
- ps.ts NodeEntry extended with contextUsage block (passed through
  from /api/rigs/:id/nodes; daemon already populates).
- ALLOWED_FILTER_KEYS adds contextUsage.percent (numeric) +
  contextUsage.state (enum critical|warning|low|unknown — lockstep
  with daemon's computeContextHealthSummary tier vocab).
- parseFilter extended with comparator parsing for numeric keys
  (>=, >, <=, <, =); three-part error on non-numeric values + bogus
  states + numeric op on non-numeric key.
- New CTX column in human output (5-char "<percent>%" or "??") plus
  --fields contextUsage projection.
- Final canonical filter syntax locked at audit per identifier-drift
  discipline: contextUsage.percent>=N (not =N).

UI (Items 1, 3, 5)
- New ContextUsageRing component: hollow ring parallel to PL-019's
  filled activity dot. Two signals at the same scale (working vs
  context-pressure). Tier thresholds lockstep with daemon (≥80
  critical, ≥60 warning, else low; unknown → dotted gray).
- RigNode header gets the ring next to the activity dot.
- New /context dashboard route + Gauge sidebar tab + ContextWorkspace
  component: header summary (total + tier counts), filter chips
  (tier/runtime/rig), per-seat list sorted descending by percent,
  refresh button, "Compact cmd" button copies "rig compact-plan
  --rig <rig>" (read-only at v0; compact-trigger from UI is NAMED
  v0+1 trigger per PRD).
- New useContextFleet hook composes /api/ps + per-rig /nodes with
  15-second refetch interval.
- HealthGatesPanel: context-tier counts now click-through to /context
  filtered by tier; "Open dashboard →" link added; uses Tanstack
  router Link with search params validated by validateSearch on
  the contextRoute.

Cross-CLI-version drift (Item 6)
- ContextWorkspace surfaces honest "Could not load … use rig context
  --json from terminal" when /api/ps errors.
- contextUsage block on NodeEntry is optional; older daemons that
  don't emit it leave the CTX column "??" (the unknown placeholder)
  and contextUsage.* filters return false (operator can use
  contextUsage.state=unknown to find them).

NO new SQLite schema, migrations, or event types.

Tests
- cli: 836/836 (10 new in ps-pl012-context-usage.test.ts; 1 existing
  test updated to match the new --filter error message)
- daemon: 2903/2903 (4 new whoami runtimeContext cases)
- ui: 546/546 (10 new ContextUsageRing tests + 6 new
  ContextWorkspace tests)
- tsc clean across all three packages

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 10:58:57 -07:00
mvschwarz 2a70fa03ea Merge preview-terminal-v0 (PL-018: live terminal preview pane wrapping SessionTransport.capture + rate-limited GET /api/rigs/:rigId/nodes/:logicalId/preview + GET /api/sessions/:sessionName/preview alias + PreviewPane/SessionPreviewPane/PreviewStack components + click-to-toggle preview in Slice Story View Topology + Loop State panel + ui.preview.{refresh_interval_seconds,max_pins,default_lines} ConfigStore keys + cross-CLI-version drift fallback panel — clean ACCEPT, 81 tests pass, no fixes needed; C2 fixture-setup adopt-phantom-rigId quirk noted as separate bug worth filing) 2026-05-04 10:47:06 -07:00
mvschwarzandClaude Opus 4.7 0a4697bcd4 feat(preview-terminal): live preview pane + cmux launcher composition v0
PL-018. PRD: slices/preview-terminal-v0/IMPLEMENTATION-PRD.md.

Settings (Item 1, lockstep with cli ConfigStore + daemon SettingsStore)
- New keys: ui.preview.refresh_interval_seconds (3), ui.preview.max_pins
  (4), ui.preview.default_lines (50). Final canonical names locked at
  audit per identifier-drift discipline. RiggedConfig.ui.preview shape
  exposed.

Daemon
- New domain/preview/preview-rate-limiter.ts: 1-second per-(session,
  lines) cache window so multiple pinned panes for the same seat
  collapse to one tmux capture without throttling legitimate polling.
- New GET /api/rigs/:rigId/nodes/:logicalId/preview?lines=N route +
  parallel GET /api/sessions/:sessionName/preview alias for surfaces
  with sessionName but no rigId/logicalId. Wraps the existing
  SessionTransport.capture; rate-limited; returns structured 503/404/
  409/502 with hints on the unhappy paths.
- startup.ts wires PreviewRateLimiter; server.ts wires it into context.

UI (Item 1 + Item 2 + Item 3)
- New PreviewPane component: live preview content + Pin/Unpin button +
  honest unavailable fallback. Polls at ui.preview.refresh_interval_seconds.
- New SessionPreviewPane: session-keyed variant for Loop State / Topology
  tab compositions (no Pin button at v0; pin from drawer).
- New PreviewStack: pinned-preview side rail mounted in AppShell;
  self-hides when no pins. Cap enforced from ui.preview.max_pins.
- New preview-pin-store + usePreviewPins (useSyncExternalStore-backed,
  no Zustand dep needed for ~50 lines).
- New useNodePreview / useSessionPreview hooks consume settings for
  refresh interval + default lines.
- NodeDetailPanel gets a Preview section above Actions (CMUX launcher
  button already shipped at line 181 — Item 2 reuses it; the new code
  is light composition, not a rewrite).
- LoopStatePanel rows become click-to-toggle buttons that expand inline
  SessionPreviewPane below the row (single-row-open at a time).
- TopologyTab seat list: each session name becomes a click-to-toggle
  button that expands inline SessionPreviewPane below it.

Cross-CLI-version drift (Item 4)
- 404/409/502/503 from the daemon route map cleanly to a structured
  "Preview unavailable: <reason>" UI panel that suggests `rig capture
  <session>` from terminal as a fallback.

Identifier-drift discipline
- ui.preview.* keys NOT pre-added in user-settings-v0 (audit-confirmed
  not on main); added here at PL-018 ship time as the consumer slice.
  PRD § Item 1's draft list of future-slice settings is correctly
  applied at-ship (not at-author) per memory feedback_next_available_n
  _drift_in_parallel_slices.

NO new SQLite schema, migrations, or event types.

Tests
- daemon: 2917/2917 (6 new in preview-rate-limiter.test.ts + 12 new in
  preview-routes.test.ts; user-settings tests updated for 15-key set).
- cli: 833/833 (7 new ui.preview.* tests in config-store-extended.test.ts).
- ui: 539/539 (9 new in preview-pane.test.tsx covering PreviewPane,
  PreviewStack, pin store cap).
- tsc clean across all three packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 10:33:02 -07:00
mvschwarz 222429d59a Merge user-settings-v0 (typed settings extending ConfigStore + 5-subdir default workspace scaffold + per-subdir override resolution + UEP env-var graduation + System drawer Settings tab + openrig-user-settings SKILL.md + 1 dogfood-driven fix slice-indexer consume workspace.slices_root) 2026-05-04 10:09:20 -07:00
mvschwarzandClaude Opus 4.7 6fb14e39e1 fix(user-settings/slice-indexer): consume workspace.slices_root setting
PRD § Item 1 added workspace.slices_root as a typed setting key with
env > file > default resolution via SettingsStore. PRD § Scenario B
explicitly required: set workspace.slices_root via UI/CLI, restart,
Slice Story View consumes the new path.

Driver wired files.allowlist + progress.scan_roots through SettingsStore
correctly, but the slice indexer's startup wiring at startup.ts:706
still read OPENRIG_SLICES_ROOT directly via readOpenRigEnv. Result:
operators setting workspace.slices_root via CLI or UI saw the setting
in /api/config (source: file) but Slice Story View kept reading the
empty env-var slot — Scenario B was unfulfillable.

Fix: prefer the legacy short OPENRIG_SLICES_ROOT env var if explicitly
set (preserves backward-compat with existing dogfood/test daemons that
inherit it from their startup env), otherwise fall back to the
SettingsStore-resolved workspace.slices_root. SettingsStore is
constructed locally in the slices block to avoid restructuring the
larger startup ordering (the User Settings v0 wiring block constructs
its own SettingsStore later — both reads are cheap; same singleton
config.json read).

Verified post-fix: setting workspace.slices_root via UI Edit form +
restart → /api/slices returns 90 slices from founder's substrate
layout instead of "slices_root_not_configured" 503.

Surfaced during the User Settings v0 headed-browser dogfood at be2a239
(review-lead owned the fix-and-retest end-to-end with full autonomy
per dispatch).

Caveat (not addressed in this fix): the steering composer
(daemon/src/domain/steering/steering-composer.ts) still reads
OPENRIG_STEERING_WORKSPACE / OPENRIG_STEERING_PATH /
OPENRIG_ROADMAP_PATH / OPENRIG_DELIVERY_READY_DIR env vars directly
inside the composer (not via SettingsStore). Setting workspace.steering_path
via CLI/UI doesn't flow to the steering surface. Same gap pattern;
larger fix (composer needs to accept settings + reload-on-change).
v0+1 named trigger: founder dogfood reports steering surface
ignoring workspace.steering_path setting.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 10:04:59 -07:00
mvschwarz daa66e0ff9 Merge workflows-in-spec-library-v0 (Workflows as 5th filter chip in Specs library + workflow review pane with React Flow topology + activation-lens toggle filtering Slice Story View to slices bound to spec + RSI v2 starter as canonical first entry + 1 dogfood-driven fix BFS termination on cyclic specs) 2026-05-04 07:20:59 -07:00
mvschwarzandClaude Opus 4.7 96a46eeb1f fix(spec-library/workflow-review): BFS termination on cyclic specs
WorkflowTopologyGraph used a BFS depth-assignment with a `< d+1`
relaxation condition that re-queues nodes whenever a longer path
to them is discovered. Cyclic specs (e.g. rsi-v2-hot-potato has a
qa → discovery loop edge) trigger an infinite loop:

  discovery=0 → ... → qa=4 → discovery=5 (re-queued because 0 < 5)
              → ... → qa=N → discovery=N+1 (forever)

Result: the WorkflowTopologyGraph useMemo callback never returned;
the React render hung indefinitely; the /specs/library/workflow:NAME:V
review page was unreachable. The hang surfaced during the v0 headed-
browser dogfood when navigating to the rsi-v2-hot-potato review pane.

Switch to standard BFS visit-once semantics: only assign a child's
depth on first visit. Produces the same shortest-path-from-entry
depth assignment for acyclic specs and terminates correctly on
cyclic specs (the loop edge gets drawn at the previously-assigned
depth, which is exactly what the layout wants).

Surfaced during the Workflows-in-Spec-Library v0 headed-browser
dogfood at ff531ac (review-lead owned the fix-and-retest end-to-end
per feedback_qa_owns_dogfood_fix_loop). 5 library-review tests still
pass (existing fixtures are acyclic; recommend a cyclic-spec fixture
test in v0+1).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 07:16:09 -07:00
mvschwarzandClaude Opus 4.7 be2a239441 feat(settings): user-settings v0 — typed settings + workspace scaffold
PRD: slices/user-settings-v0/IMPLEMENTATION-PRD.md.

Closes the "UI works on founder's machine vs npm-shipped install
elsewhere" gap UEP introduced via env-var workarounds.

CLI (Items 1, 5)
- ConfigStore extended with 7 new keys: workspace.{root,slices_root,
  steering_path,field_notes_root,specs_root}, files.allowlist,
  progress.scan_roots. Existing 5 daemon/db/transcripts keys preserved
  unchanged. ENV_MAP, KEY_TO_PATH, DEFAULTS extended in lockstep.
- Per-subdir defaults derive from workspace.root cascade; per-subdir
  override > workspace.root cascade > built-in default.
- New ResolvedSetting + resolveWithSource() / resolveAllWithSource()
  helpers expose value/source/default for honest provenance.
- reset(key?) supports both whole-file (delete) and per-key reset.
- parseNamedPairs decoder for files.allowlist + progress.scan_roots
  (preserves UEP "name:/abs/path,..." env-var format).
- rig config bare action: --json structured RiggedConfig (preserves
  legacy shape); --json --with-source emits per-key provenance.
- rig config get --show-source / --json adds source-aware reads.
- rig config reset [key] adds per-key reset.

Init-workspace verb (Item 2)
- New rig config init-workspace [--root] [--force] [--dry-run] [--json].
- Creates 5 subdirs: slices/ steering/ progress/ field-notes/ specs/.
- Drops one-paragraph README.md per subdir + steering/STEERING.md
  placeholder. Idempotent. --force overwrites scaffolded files but
  never deletes operator content under directories. Operator-explicit
  per founder safety stop — never auto-runs.

UEP env-var graduation (Item 4)
- OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS become the env
  override for the new typed keys (no breaking change). decodeAllowlist
  + decodeProgressScanRoots factor the parsing out so settings-resolved
  values use the same path. Daemon startup reads via SettingsStore
  (env > settings file > empty) instead of env-only.

Daemon (Items 3, 4, 6 backend)
- New SettingsStore mirrors CLI ConfigStore for daemon-side reads +
  writes (no cross-package dep; constants kept parallel).
- New /api/config routes: GET (all), GET /:key, POST /:key (set),
  DELETE /:key (reset), POST /init-workspace. UI surface only —
  agent-edit stays on CLI shell-out per founder dialog.
- startup.ts wires SettingsStore + decodes raw allowlist/scan-roots
  strings to feed UEP routes + ProgressIndexer.

UI (Item 6)
- New System drawer Settings tab alongside existing Log + Status.
- New useSettings / useSetSetting / useResetSetting / useInitWorkspace
  hooks against /api/config.
- New SettingsTab component: Workspace section (root + 5 per-subdir),
  Files section (allowlist), Progress section (scan_roots), legacy
  daemon/db/transcripts sections. Each row: value + source badge +
  default + Edit/Save/Reset; Reset hidden for source=default. Init
  Workspace button calls the daemon route.

Skill (Item 8)
- New daemon/specs/agents/shared/skills/core/openrig-user-settings/
  SKILL.md teaches agents the canonical CLI verbs, common operator
  requests, and the "never write the JSON file directly" boundary.
  Loaded by Claude Code / Codex via existing skills mechanism.

Cross-CLI-version drift (Item 9)
- New keys remain unset on older daemons; UI Settings tab shows current
  values via /api/config which is feature-gated by the new keys'
  presence in resolved output. Existing 5-key behavior preserved.

NO new SQLite schema, migrations, or event types per PRD.
NO auto-scaffold per founder safety stop.

Tests
- cli: 26 new in config-store-extended.test.ts; existing 800 preserved
  (840+ passing).
- daemon: 12 new in user-settings-store.test.ts + 9 in
  user-settings-routes.test.ts; existing 2867 preserved.
- ui: 5 new in settings-tab.test.tsx; existing 525 preserved.
- tsc clean across all three packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 07:05:39 -07:00
mvschwarzandClaude Opus 4.7 ff531aceff feat(spec-library): workflows kind + activation lens v0
PRD: slices/workflows-in-spec-library-v0/IMPLEMENTATION-PRD.md.

Daemon
- spec-library-workflow-scanner.ts: project workflow_specs cache rows as
  SpecLibraryEntry (kind: workflow) + topology graph for review payload;
  builtin classification via path.sep boundary.
- active-lens-store.ts: file-backed JSON persistence under OPENRIG_HOME;
  atomic tmp+rename; single-active invariant; survives daemon restart.
- spec-library-service.ts: extend SpecLibraryEntry with kind: "workflow"
  + workflow-only metadata; setWorkflowEntries() merges into list/get.
- spec-library.ts routes: GET/POST/DELETE /active-lens (mounted before
  /:id catchall); refresh workflow entries from cache on each list;
  /:id/review branches on kind === "workflow".
- slices.ts: new boundToWorkflow=<name>:<version> query — filter slice
  list to those whose primary workflow_instance binding matches the
  spec via findSliceWorkflowBinding.
- slice-indexer.ts: expose .db so the slices route can call the binding
  helper without re-injecting db.
- server.ts/startup.ts: wire ActiveLensStore into AppDeps + middleware.

UI
- useSpecLibrary.ts: extend SpecLibraryKind to include "workflow";
  LibraryWorkflowReview type; useActiveLens / setActiveLens /
  clearActiveLens hooks.
- useSlices.ts: optional boundToWorkflow filter param.
- SpecsPanel.tsx: Workflows filter chip + WORKFLOW row badge.
- LibraryReview.tsx: workflow review variant — header, topology graph
  (React Flow with BFS depth layout, entry/terminal accent colors),
  per-step list, terminal-turn rule callout, Activate/Deactivate Lens.
- SliceExplorerPanel.tsx: read active lens, pass boundToWorkflow to
  useSlices; render lens indicator + Clear + Show All toggle; empty
  state mentions the lens spec.

Tests
- daemon: scanner classification (builtin/user_file/path.sep boundary/
  table-absent fallback), getWorkflowReview topology projection,
  workflowLibraryId roundtrip, active-lens store CRUD + persistence,
  active-lens routes, workflow library entry list + review, slices
  boundToWorkflow filter (matches/empty/malformed/absent).
- UI: SpecsPanel Workflows filter chip + workflow row, LibraryReview
  workflow variant + Activate Lens POST, SliceExplorerPanel lens
  indicator + boundToWorkflow query param + Clear/Show All controls.

Daemon: 2878/2878. UI: 525/525. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 06:40:22 -07:00
mvschwarz 4f48164bd8 Merge operator-surface-reconciliation-v0 (Steering 6-panel composition: PriorityStack/RoadmapRail/InMotion/LaneRails/LoopState/HealthGates + Priority Rail Rule visualization + 1 dogfood fix) 2026-05-04 06:15:14 -07:00
mvschwarzandClaude Opus 4.7 9f3db4f7f5 fix(operator-surface-reconciliation/progress): include STEERING.md in tree walk
Item 2's per-level Priority Rail Rule visualization classifies STEERING.md as
the `steering` level ("Constraint" label), but the indexer's tree walk only
included files matching `PROGRESS.md`. Result: the classifier had a level
that never appeared in the rendered tree because STEERING.md never made it
into the file list.

Extend the tree walk to also pick up STEERING.md. The row-counts +
parsing pipeline is filename-agnostic and treats it the same as any
other rail markdown.

Surfaced during the OSR v0 headed-browser dogfood at 299b4a0 (review-lead
owned the fix-and-retest end-to-end per feedback_qa_owns_dogfood_fix_loop).
Existing 12 progress-indexer tests pass unchanged; no daemon test had
exercised STEERING.md vs PROGRESS.md filename in fixture data.

Caveat: visual top-most-node ordering (per PRD § Item 2) is not addressed
here — files still sort alphabetically by relPath. The Constraint level
chip is correct; visual first-row positioning would require sorting by
`[level.order, relPath]` in the ProgressWorkspace render and is left as
a v0+1 polish nit.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 06:09:54 -07:00
mvschwarzandClaude Opus 4.7 299b4a06e5 feat(operator-surface-reconciliation): v0 — composed steering + Priority Rail Rule + lint + raw toggle + truncation + YAML validation
Operator Surface Reconciliation v0 — synthesizes PL-006 Operator
Status / Steering Surface + PL-008 Progress.md Project Management View
+ PL-009 Markdown Viewer / File Browser / Editor against shipped
Slice Story View v0+v1 + UI Enhancement Pack v0. One ambitious
vertical with six items; HEADLINE is the one-screen composed steering
surface (PL-006 v0 claim). NO new schema, NO migrations, NO new
event types — composes existing surfaces + adds small new routes
(steering composition, health-summary adapters) + adds small new UI
panels and extensions.

Item 1 — Steering surface (HEADLINE):
- New /steering route + Steering Link (Compass icon) in AppShell.
- New SteeringWorkspace.tsx with six panels rendered as a single
  scrollable viewport.
- A. PriorityStackPanel — verbatim STEERING.md via MarkdownViewer
  (hideRawToggle so the panel chrome doesn't compete) + last-modified
  timestamp.
- B. RoadmapRailPanel — PL-XXX checklist + railItemCode extraction +
  next-unchecked marker on first non-done row.
- C. InMotionPanel — composes PL-005 Mission Control's
  /api/mission-control/views/recently-active filtered to spec-related
  qitems (body/destination heuristic; PRD § Item 1C "driver picks
  the matching strategy").
- D. LaneRailsPanel — per-lane (delivery-ready/mode-{0..3}) top-N
  items + active/blocked/done health badges + next-pull marker.
- E. LoopStatePanel — cross-rig roll-up of recent qitems by seat via
  the same recently-active view.
- F. HealthGatesPanel — node + context summaries via new
  /api/health-summary/* routes; Stat badges color-coded (running →
  emerald, warning → amber, attention/critical → red bold).
- New daemon route GET /api/steering returns priority-stack + roadmap
  + lane-rails in one payload (in-motion / loop-state / health
  fetched separately by the UI to keep the composer narrow + testable).
- New SteeringComposer (domain/steering/steering-composer.ts) reads
  STEERING.md + roadmap PROGRESS.md + delivery-ready/mode-*/
  PROGRESS.md from a configured workspace root. Per-section overrides
  trump workspace defaults. Reuses UI Enhancement Pack v0's
  ProgressIndexer for parsing so the lane health badges match the
  Progress workspace's checkbox semantics exactly.
- New /api/health-summary/{nodes,context} routes (domain/steering/
  health-summary.ts) wrap getNodeInventory + raw context_usage
  aggregation. Daemon-side, not CLI shell-out — same data, cheaper.
- Phone-friendly content model 9 fields preserved through PL-005
  view consumers.

Item 2 — Priority Rail Rule visualization (PL-008 differentiator):
- New components/progress/priority-rail-rule.ts:
  classifyPriorityRailLevel maps PROGRESS.md path → STEERING /
  mission / lane / slice / intermediate via regex set; getPriority
  RailLevelStyle returns label + chip class; computeNextPullLine
  finds first non-done, non-blocked checkbox row per Priority Rail
  Rule semantics (workstream-continuity convention).
- ProgressWorkspace.tsx extended: per-file level chip in section
  header (Constraint / Mission / Lane / Slice / Cursor); next-pull
  marker on the row that first qualifies for each lane file;
  level-aware data-rail-level attribute on each section.

Item 3 — YAML schema validation in Files browser:
- FilesWorkspace.tsx extended: detectSpecKind matches
  rig.yaml / agent.yaml basenames; SpecValidationPanel invokes the
  existing /api/specs/review/{rig|agent} endpoint via the new
  useSpecReview hook + renders inline validation results above the
  YAML view. Non-spec YAML files don't trigger the panel.

Item 4 — Markdown viewer raw/rendered toggle:
- MarkdownViewer.tsx extended: header-bar 2-button toggle (rendered
  default ⇄ raw); raw mode renders content as monospace `<pre>`
  with full Markdown source visible; frontmatter metadata header
  still renders in raw mode. New hideRawToggle prop suppresses the
  toggle for callers (e.g. PriorityStackPanel) that don't want the
  chrome.

Item 5 — File-read truncation marker:
- daemon/src/routes/files.ts /read extended: caps returned content
  at FILE_READ_TRUNCATION_BYTES (1 MB per PRD § Item 5; founder Q6
  option b); response includes truncated / truncatedAtBytes /
  totalBytes fields when capping. Hash is computed over the FULL
  file so edit-mode atomic-write conflict detection stays honest
  even when the read was truncated.
- FilesWorkspace.tsx: TruncationMarker component renders an
  amber-bordered banner above any truncated file body with
  "use external editor for full content" hint.

Item 6 — PROGRESS.md lint warnings (PL-008 Phase B):
- computeLintWarnings (in priority-rail-rule.ts) ships 3 of 4 PRD
  rules at v0:
    - rule 1 long-row: row > 160 chars → per-row warning;
    - rule 2 missing-tree: parent file with no Tree/Hierarchy/
      Topology heading → file-scope warning;
    - rule 4 qitem-no-label: row body matches bare qitem id pattern
      → per-row warning.
  Rule 3 (narrative-mixed) ships active-but-quiet at v0 — the
  ProgressIndexer drops non-checkbox / non-heading lines so the
  heuristic can never fire; explicit known limitation recorded in
  comment with v0+1 promotion path (teach the indexer to retain
  narrative lines).
- ProgressWorkspace.tsx extended: header gets Show-lint toggle
  (default on); per-file lint count badge + file-scope warning list
  rendered below header; per-row warnings render inline next to
  their row.

PRD pivots / scope decisions recorded in commit + handoff:
- env-var pivot inherited from UI Enhancement Pack v0 memory:
  OPENRIG_STEERING_WORKSPACE + OPENRIG_STEERING_PATH /
  OPENRIG_ROADMAP_PATH / OPENRIG_DELIVERY_READY_DIR for non-canonical
  layouts. ConfigStore's strict VALID_KEYS doesn't admit dynamic
  family keys; env-var is the established MVP pattern.
- In-motion + loop-state panels fetch PL-005 views separately rather
  than the steering composer making internal HTTP calls — keeps
  the composer narrow + testable + reuses the existing PL-005 view
  contract verbatim.
- Lint rule 3 (narrative-mixed) ships active-but-quiet pending
  ProgressIndexer extension for narrative-line retention; recorded
  with explicit v0+1 promotion path in priority-rail-rule.ts comment.

Verification:
- daemon: 2846/2846 (was 2796 baseline at af4e220 + 50 new across 3
  test files: 12 steering-composer + 5 health-summary + 3
  files-truncation, plus ~30 test rebalancing through
  createFullTestDb usage).
- ui: 518/518 (was 478 baseline + 40 new across 3 test files: 17
  priority-rail-rule + 5 markdown-viewer-raw-toggle + 8
  steering-panels, plus rebalancing).
- cli: untouched by this slice; baseline 800.
- tsc clean for daemon + cli + ui.

Boundaries preserved per PRD § No-Touch Surfaces:
- All PL-004 schema (queue_*, workflow_*, watchdog_*) — read-only.
- All PL-005 surfaces (Mission Control views, verbs, action log) —
  read-only consumer; no modifications.
- PL-019's RigNode / Explorer / graph-projection / edge animation
  — untouched.
- Slice Story View v0's tabs (Story / Decisions / Tests-Verification
  / Topology) — untouched.
- Slice Story View v1's spec-driven rendering — untouched.
- UI Enhancement Pack v0's /progress core walk + /files core route
  + MarkdownViewer core render + edit mode atomic-write contract —
  read-only consumer + small additive extensions only; core write
  contract unchanged. Acceptance tab from Slice Story View v0+v1
  also untouched (item 1A in PRD scope reused PROGRESS-tree
  rendering, not Acceptance).
- Existing 33+ migrations on main — untouched. v0 adds NO migrations.
- Existing event bus types — no extensions.
- AppShell entries other than the new Steering Link — untouched.
- STEERING.md / roadmap/PROGRESS.md / delivery-ready/*/PROGRESS.md /
  mission PROGRESS.md — read-only consumer.

Operator setup hint for QA dogfood (recorded in handoff):

  export OPENRIG_FILES_ALLOWLIST="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work,hub:/Users/wrandom/code/projects/openrig-hub"
  export OPENRIG_PROGRESS_SCAN_ROOTS="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work"
  export OPENRIG_STEERING_WORKSPACE="/Users/wrandom/code/substrate/shared-docs/openrig-work"
  rig daemon restart

Hard-gate (operator dogfood per PRD audit row 14) is QA-owned per
feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for guard
inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 05:56:38 -07:00
mvschwarz af4e220e1d Merge ui-enhancement-pack-v0 (PROGRESS.md slick render + Markdown viewer expansion + Files browser + atomic edit mode + 2 dogfood fixes)
# Conflicts:
#	packages/ui/src/components/slices/tabs/AcceptanceTab.tsx
2026-05-04 05:28:04 -07:00
mvschwarzandClaude Opus 4.7 b218f3ccf1 fix(ui-enhancement-pack/files): preserve draft + surface 409 conflict
The Files workspace edit-mode conflict UX was broken: on a 409,
useFilesWrite invalidated the read query unconditionally; the
re-fetched read fired the editor's useEffect, which silently
overwrote the operator's draft AND cleared the conflict banner
that had just been set. Net effect: operator clicks Save against
a stale mtime/contentHash → daemon correctly returns 409 → UI
silently swaps the textarea content for the new server content
and shows no error. The operator's edits are lost without warning.

Two-part fix:
- useFilesWrite no longer invalidates queries on a conflict result.
  The read query stays at the operator's last-known mtime/contentHash
  so the conflict banner can render and the draft survives long
  enough for the operator to act on it.
- The conflict banner now includes an explicit Refresh button that
  invalidates the read query manually. Clicking it triggers the
  existing useEffect which loads the new server content and clears
  the conflict.

Also tightened the banner copy to match the actual Refresh semantics
(the operator's draft is replaced on Refresh; copy elsewhere first
if re-application is needed) — the previous copy promised
preservation that the implementation didn't honor.

Surfaced during the UEP v0 headed-browser dogfood at 1a12230 on
the conflict scenario in PRD § Item 4 (review-lead owned the
fix-and-retest end-to-end per feedback_qa_owns_dogfood_fix_loop).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 05:09:44 -07:00
mvschwarzandClaude Opus 4.7 effb163160 fix(ui-enhancement-pack/files): honor OPENRIG_HOME for audit JSONL
FileWriteService's DEFAULT_AUDIT_FILE used process.env.HOME directly,
so isolated dogfood/test daemons (with custom OPENRIG_HOME) wrote
audit rows back to the host's ~/.openrig/file-edit-audit.jsonl —
breaking state isolation.

Wire startup.ts to pass auditFilePath built from the resolved
OPENRIG_HOME constant, matching how ContextUsageStore + ClaudeCodeAdapter
already isolate their per-instance state.

Surfaced during the UEP v0 headed-browser dogfood at 1a12230 (review-lead
owned the fix-and-retest end-to-end per feedback_qa_owns_dogfood_fix_loop).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 04:59:51 -07:00
mvschwarz 44635b4130 Merge slice-story-view-v1 (Spec-driven Slice Story View v1: Topology spec graph + Phase tagging + Frontier-aware Acceptance + Routing visualization) 2026-05-04 04:52:58 -07:00
mvschwarzandClaude Opus 4.7 1a12230dce feat(ui-enhancement-pack): v0 — progress + markdown + files browser + edit mode
UI Enhancement Pack v0 — operator gets a substantially better UI for
browsing + viewing + editing workspace canon files. Four items ship as
one ambitious vertical because they share infrastructure (allowlist +
path-safety daemon-side; markdown rendering UI-side). NO new schema,
NO migrations, NO new event types.

Item 1A — Acceptance tab enhancement (Slice Story View v0 extension):
- Checkbox pills (rounded with status icon: ✓ done / ◯ active /
  ⚠ blocked) instead of raw [ ]/[x] syntax.
- 4-filter chip row (All / Active / Done / Blocked; default All).
- Click-to-expand row detail panel with prominent file:line citation.
- v0's PROGRESS.md parser still drives the row list; this slice
  enhances the rendering layer only.

Item 1B — Top-level Progress browse view:
- New /progress route + Progress sidebar Link (CheckSquare icon)
  alongside the existing Slices entry.
- Daemon: new domain/progress/progress-indexer.ts walks
  OPENRIG_PROGRESS_SCAN_ROOTS (env-configured, comma-separated
  name:path pairs), parses PROGRESS.md files with checkbox-hierarchy
  awareness ([ ]/[x]/[~] checkboxes + ## / ### / #### headings +
  YAML frontmatter strip + 2-space-indent depth + skip-dirs for
  node_modules/.git/.worktrees/dist), and returns aggregate counts
  per file + workspace-wide.
- New routes/progress.ts: GET /api/progress/tree returns
  { files, aggregate, scannedRoots } or 503 with structured
  setup hint when no scan roots are configured.
- New ProgressWorkspace.tsx with two-pane layout: tree pane (per-
  file collapsible sections with checkbox pills + status icons) +
  inline detail panel on row click. Filter row (status: all/active/
  done/blocked) + free-text search across rows. Read-only at v0
  (edit is item 4 territory).

Item 2 — Markdown viewer expansion:
- New components/markdown/MarkdownViewer.tsx + SyntaxHighlight.tsx —
  written from scratch (~500 lines total, no new dependencies). Per
  the audit: no markdown / highlighting libs are installed in the UI
  package; the inline parser keeps the bundle weight unchanged.
- Handles: YAML frontmatter (rendered as metadata header above body),
  headings (# / ## / ### / ####), bullet + ordered lists with
  indentation depth, fenced code blocks with per-language syntax
  highlighting, inline code with backticks, links + images with
  relative-src resolution against assetBasePath, tables (| col |
  syntax with header + body rows + zebra striping), bold (**) and
  italic (*) inline emphasis.
- Per-language tokenizer covers ts/tsx/js/jsx, py/python, bash/sh,
  sql, yaml/yml, json. Token classes: keyword (violet), string
  (emerald), comment (stone-500 italic), number (amber), tag (sky),
  punctuation (stone-400). Unknown languages render as plain text
  in the dark code panel.
- Item 2 mermaid carve-out activated: NO mermaid library bundled at
  v0. Mermaid code blocks render as a labeled placeholder with a
  disabled '[render mermaid] (v0+1)' button. NAMED v0+1 trigger:
  "operator dogfood reports the click-to-render flow is friction"
  — recorded in commit + handoff per PRD § Item 2.
- DocsTab.tsx refactored to consume MarkdownViewer for .md files;
  non-.md files keep the v0 plain-`<pre>` fallback. Slice Story
  View v0's docs UX is preserved + extended.

Item 3 — File browser (allowlisted, daemon-enforced path-safety):
- Driver pivot from PRD's suggested ConfigStore key family
  (files.allowlist.<name>): the existing ConfigStore uses a strict
  VALID_KEYS allowlist that doesn't admit dynamic key families
  cleanly. Switched to env-var approach:
  OPENRIG_FILES_ALLOWLIST=name1:/abs/path,name2:/abs/path. Same
  delimited-pair shape as OPENRIG_PROGRESS_SCAN_ROOTS for
  consistency. Fail-closed default: empty/unset → routes return
  503 with structured setup hint.
- New domain/files/path-safety.ts: shared resolver with three
  guarantees — '..' segment rejection (literal path-component, not
  substring; foo..bar.md is allowed), absolute-path-as-relative
  rejection, and symlink-realpath check ensuring no resolved file
  escapes the canonical root via path.sep boundary (prevents the
  /foo/bar matching /foo/bar-other false-positive).
- New routes/files.ts: GET /roots, GET /list, GET /read,
  GET /asset, POST /write. Path-safety errors map to structured
  4xx codes (root_unknown / path_invalid / path_escape /
  not_a_file / not_a_directory / stat_failed) so the UI can
  render specific error messages.
- /asset infers Content-Type from extension (image/png /
  image/jpeg / image/svg+xml / video/mp4 / etc.) for embedded
  image rendering in MarkdownViewer.
- New FilesWorkspace.tsx + useFiles.ts hooks: two-pane layout
  (allowlist root selector + breadcrumbs + directory tree on left;
  file content panel on right). Auto-selects first root; resets
  path + selection on root change. Up-directory affordance.
  Content-type-aware rendering: .md → MarkdownViewer; text-like →
  SyntaxHighlight; image → inline `<img>` via /api/files/asset;
  download-only types (.zip, .pdf, .mp4) → download link;
  unknown → text fallback.

Item 4 — Optional edit mode (atomic write + JSONL audit):
- Per PRD's recommended landing posture: lightweight `<textarea>`
  edit (no CodeMirror dependency) + daemon write route +
  audit JSONL. The atomic-write contract is the load-bearing
  primitive; the UI shell is intentionally minimalist.
- New domain/files/file-write-service.ts: writeAtomic({...}):
  resolves path-safety → re-stats target → re-hashes via SHA-256 →
  rejects with WriteConflictError carrying current{Mtime,
  ContentHash} on mismatch (409) → writes to temp file in same
  directory → fsync → atomic rename → re-stats for new mtime/
  contentHash → appends one JSONL row to ~/.openrig/file-edit-
  audit.jsonl with ts, actor, root, path, prev/new mtime + hash,
  byteCountDelta. Audit-write failure surfaces as a separate
  error code (write succeeded; audit append did not — operator
  can re-derive from filesystem state). PRD's no-clobber-on-
  concurrent-edit invariant pinned by tests.
- POST /api/files/write integrated into routes/files.ts; conflict
  path returns 409 with the daemon's current state for the UI
  to surface a Refresh affordance.
- FilesWorkspace edit mode: header-bar Edit toggle (default off);
  toggling on flips the right pane to a `<textarea>` editor with
  Save / Cancel / status indicator (draft (unsaved) / no changes /
  saved). Save submits POST /write with last-known mtime +
  contentHash; 409 conflict surfaces inline with current
  daemon-side mtime; Cancel discards the draft and re-reads.
- Driver carve-out NOT invoked: edit mode ships as the PRD's
  recommended in-scope item. NAMED v0+1 trigger left available
  for future scope-down if dogfood reveals friction (per
  PRD § Item 4 Driver carve-out).

AppShell + routes wiring:
- New header Links: Progress (CheckSquare icon) + Files (Folder
  icon) alongside Slices (Layers). Surface-title resolver
  recognizes /progress + /files paths.
- routes.tsx: register progressRoute (/progress) + filesRoute
  (/files) on the existing root tree.

PRD reinterpretations recorded:
- Item 3 allowlist source: env-var (OPENRIG_FILES_ALLOWLIST) instead
  of ConfigStore key family — driver pivot to fit existing
  ConfigStore's strict VALID_KEYS allowlist.
- Item 1B scan roots: env-var (OPENRIG_PROGRESS_SCAN_ROOTS) for
  consistency with the same delimited-pair shape.
- Item 2 mermaid carve-out activated per PRD: NO mermaid bundled;
  placeholder + disabled button + named v0+1 trigger.

Verification:
- daemon: 2796/2796 (was 2750 baseline at 6097f4e + 46 new across
  3 new test files: 18 files-path-safety + 15 files-routes + 12
  progress-indexer + 1 from setup). Path-safety tests pin '..'
  segment rejection vs filename-substring, absolute-path
  rejection, symlink-escape rejection, env-var fallback chain
  (empty-string treats as missing for legacy fallback). Files-
  routes tests pin 503 graceful, list+read+asset round-trip,
  atomic-write success + 409 conflict path, audit JSONL append.
  Progress indexer tests pin checkbox state parsing (active/done/
  blocked), heading depth derivation, indent depth derivation,
  frontmatter strip, skip-dirs, max-depth, aggregate counts.
- ui: 478/478 (was 458 baseline + 20 new across 2 test files:
  13 markdown-viewer + 7 acceptance-tab-enhancement). Markdown
  tests pin frontmatter rendering, headings/lists/code/inline-
  code/links/images/tables/bold/italic, mermaid carve-out
  placeholder. Acceptance tests pin pill rendering, filter row,
  filtering behavior, expand/collapse, v0-baseline preservation.
- cli: untouched by this slice; baseline 800.
- tsc clean for daemon + cli + ui.

Boundaries preserved:
- Worktree-only on ui-enhancement-pack-v0; main untouched at 6097f4e.
- NO new schema, NO migrations, NO new event types.
- Slice Story View v0's StoryTab / DecisionsTab / TestsVerificationTab
  / TopologyTab — untouched. Acceptance + Docs tabs extended only.
- v1 in-flight (separate worktree at 7bb892e): non-overlap on
  load-bearing files; AcceptanceTab will need a clean merge when
  v1 lands (v1 adds CurrentStepPanel ABOVE the checkbox list; this
  slice extends the checkbox list rendering — surgical merge).
- PL-019 surfaces (RigNode/Explorer/graph-projection/edge animation)
  — untouched.
- PL-004 Phase A/B/C/D — read-only consumers; no modifications.
- PL-005 Mission Control — untouched.
- ConfigStore (cli/src/config-store.ts) — untouched per the
  env-var pivot.
- All migrations on main — untouched.
- AppShell entries other than the new Progress + Files Links —
  untouched.

Operator setup hint for QA dogfood (orch routes to QA):

  export OPENRIG_FILES_ALLOWLIST="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work,hub:/Users/wrandom/code/projects/openrig-hub"
  export OPENRIG_PROGRESS_SCAN_ROOTS="workspace:/Users/wrandom/code/substrate/shared-docs/openrig-work"
  rig daemon restart

Then:
- Header → Slices/Progress/Files (3 sidebar Links).
- /progress: tree of PROGRESS.md across the workspace with
  filter + search + click-to-expand detail.
- /files: allowlist roots + directory tree + content panel
  (markdown rendered via MarkdownViewer; code via
  SyntaxHighlight; images inline; download-only fallback for
  zip/pdf/video).
- /files edit mode: toggle Edit → save STEERING.md inline →
  audit row appears in ~/.openrig/file-edit-audit.jsonl →
  concurrent edit from a separate terminal triggers 409
  Conflict with Refresh affordance.

Hard-gate (operator dogfood per PRD audit row 12) is QA-owned
per feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for
guard inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 04:50:57 -07:00
mvschwarzandClaude Opus 4.7 7bb892e1ba feat(slice-story-view): v1 — spec-driven Topology / Story / Acceptance + routing-type carve-out
Slice Story View v1 — when a workflow_instance is bound to a slice, the
Slice Story View renders spec-driven UI: Topology shows the workflow_spec
graph, Story uses spec-defined phase tagging from workflow_step_trails,
Acceptance shows the live frontier + allowed next steps. Same UI works
for any spec, not just RSI v2. Four dimensions ship together as one
ambitious vertical. NO new schema, NO migrations, NO new event types —
consumes already-shipped workflow_specs / workflow_instances /
workflow_step_trails.

Daemon (4 new files + 3 edits):

- domain/workflow/slice-workflow-binding.ts (NEW): findSliceWorkflowBinding
  resolves workflow_instance(s) touching a slice's qitem set via UNION
  of two signals — workflow_step_trails.prior_qitem_id/next_qitem_id IN
  slice qitems (historical) + workflow_instances.current_frontier_json
  LIKE %qitem% (live). Returns most-recent-by-created_at as primary;
  exposes additionalInstanceIds for "+N more" UI hint per PRD's multi-
  instance carve-out. Graceful on malformed current_frontier_json
  (parses to empty array).

- domain/workflow/slice-workflow-projection.ts (NEW): three projection
  helpers consuming WorkflowSpec + bound instance state:
    * projectSpecGraph — nodes per step + edges from each step's
      next_hop.suggested_roles resolved to step ids; isCurrent /
      isEntry / isTerminal flags; isLoopBack detected by declared step
      order. v1 carve-out per audit row 6: every edge has
      routingType="direct" because Phase D's WorkflowStepSpec has no
      routing_type field — the routing-type metadata becomes v2+
      enrichment.
    * projectPhaseDefinitions — one entry per spec step; canonical id
      = step.id; label = step.actor_role.
    * projectCurrentStep — current_step_id resolves against spec;
      returns step.objective + allowed_exits + allowed_next_steps
      (next_hop suggested roles → step ids); null when current_step_id
      is null (terminal) or doesn't resolve.

- domain/slices/slice-detail-projector.ts (EDITED):
    * StoryEvent.phase: was hardcoded enum
      "discovery"|"product-lab"|"delivery"|"lifecycle"|"qa"|"other",
      now `string | null` (open-ended; spec step.id when bound + qitem
      maps to a workflow_step_trail; null otherwise).
    * v0's classifyPhase() heuristic (which inferred phase from session
      name substrings) is REMOVED. Per PRD § Write Set Sketch:
      "v0 hardcoded phase taxonomy in StoryTab.tsx is REMOVED (replaced
      by spec-driven or ungrouped)" — this is the one v0 surface v1
      modifies, by deletion. Hardcoded phase tags for non-qitem events
      ("discovery" for doc.edited; "qa" for proof_packet.emitted) also
      removed; those events are now phase=null (untagged).
    * SliceDetailPayload extended with workflowBinding +
      story.phaseDefinitions + acceptance.currentStep + topology.specGraph.
      All v1 fields are null when no instance is bound (v0 fallback).
    * project() resolves binding once, fetches the bound spec from
      WorkflowSpecCache, builds a qitem→step trail map for phase
      tagging, then composes the four v1 dimensions into the payload.
    * Graceful degradation: when constructed without workflowSpecCache,
      v1 fields stay null even when an instance is bound (v0 mode).

- src/startup.ts (EDITED): pass workflowRuntime.specCache to
  SliceDetailProjector.

UI (1 new file + 4 edits):

- hooks/useSlices.ts (EDITED): SliceDetail extended with v1 types —
  WorkflowBindingPayload, PhaseDefinition, CurrentStepPayload,
  SpecGraphPayload (nodes + edges) — plus the new fields on the
  story / acceptance / topology sub-objects.

- components/slices/tabs/StoryTab.tsx (EDITED): phase chip color
  palette is now assigned by the spec's declared phase index
  (emerald/amber/violet/sky/rose/fuchsia/cyan/orange cycle) — v0's
  hardcoded RSI-v2 phase color map is GONE. Untagged events
  (phase=null) render with the neutral stone palette + "untagged"
  label. When phaseDefinitions is null (unbound slice), event phase
  ids fall through to the chip label verbatim (graceful for
  previously-bound-then-unbound slices).

- components/slices/tabs/AcceptanceTab.tsx (EDITED): renders Current
  Step panel ABOVE the v0 PROGRESS.md checkbox list when
  acceptance.currentStep is non-null. Panel shows step id + role +
  objective + allowed exits + allowed next steps + hop count + status.
  v0 checkbox view + progress bar + closure callout all unchanged.

- components/slices/tabs/TopologyTab.tsx (EDITED): renders spec graph
  panel ABOVE the v0 per-rig listing when topology.specGraph is
  non-null. Per-step node + outgoing edges with isCurrent / isEntry /
  isTerminal badges; loop-back edges styled with ↺ glyph + "loop"
  badge; forward edges with → glyph. Spec name + version surfaced in
  the aggregate header. v0 per-rig listing unchanged.

- components/slices/SliceStoryView.tsx (EDITED): pass phaseDefinitions
  to StoryTab.

PRD audit reinterpretations recorded:
- Row 6 (routing_type carve-out activated): Phase D's WorkflowStepSpec
  has only `next_hop?: { mode, suggested_roles }` — no routing_type
  field. PRD's explicit carve-out applies: dimension #4 ships as
  default-styled solid edges only with routingType="direct" baked in.
  Routing-type metadata becomes v2+ enrichment.

Verification:
- daemon: 2781/2781 (was 2750 baseline at 6097f4e + 30 new v1 tests
  across 3 new test files: 9 binding + 16 projection + 5 e2e
  projector. 1 v0 route test updated to include the new
  workflowBinding key in the payload key-list assertion).
- ui: 468/468 (was 458 baseline + 10 new across 1 new test file:
  3 StoryTab + 3 AcceptanceTab + 4 TopologyTab).
- cli: untouched by this slice; baseline 800.
- tsc clean for daemon + cli + ui.

Boundaries preserved per PRD § No-Touch Surfaces:
- v0's DecisionsTab, DocsTab, TestsVerificationTab — unchanged.
- v0's slice indexer + GET /api/slices list endpoint + sidebar — unchanged.
- v0's PROGRESS.md acceptance parsing — unchanged.
- PL-019's RigNode / Explorer / graph-projection / routes/rigs / ps
  --filter / edge animation — unchanged. (v1 doesn't compose with
  PL-019 inside the spec graph at v0-of-v1; that's a v2+ enhancement
  per the spec graph's list-based renderer.)
- PL-004 Phase D's workflow-runtime / workflow-spec-cache /
  workflow-projector / workflow-instance-store / workflow-step-trail-log
  — read-only consumer; no modifications.
- All existing migrations on main — untouched. v1 adds NO migrations.
- All event bus types — no extensions. v1 reads existing events; does
  not emit new ones.

The one v0 deletion: classifyPhase() heuristic + hardcoded RSI-v2
phase taxonomy in projector + StoryTab — replaced by spec-driven
trail mapping (when bound) or null (when unbound). Recorded
explicitly in commit per PRD § Write Set § No-Touch Surfaces.

Operator dogfood readiness (QA-owned per
feedback_qa_owns_dogfood_fix_loop.md):
1. Instantiate RSI v2 starter via `rig workflow instantiate
   --spec-path <pkg>/dist/builtins/workflow-specs/rsi-v2-hot-potato.yaml
   --root-objective "v1 dogfood" --created-by-session <my> ...`
2. Trigger queue activity touching the resulting workflow_instance's
   qitems (any `rigx queue handoff` between the spec's roles).
3. Open the Slices sidebar, click the slice that intersects those
   qitems → all six tabs should render the v1 enrichment:
     - Topology: spec graph with 5 RSI v2 steps + loop-back edge
     - Story: events grouped by Discovery/Product Lab/Delivery/
       Lifecycle/QA spec phases (NOT v0 hardcoded names)
     - Acceptance: Current Step panel above the PROGRESS.md
       checkbox list with allowed exits + next steps
4. Open an unbound slice (no workflow_instance touches its qitems)
   → fallbacks: Topology shows per-rig listing, Story renders
   ungrouped chronological, Acceptance shows checkbox list only.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 04:13:30 -07:00
mvschwarz 6097f4e633 Merge rsi-v2-starter-workflow-spec-v0 (RSI v2 hot-potato spec as built-in starter + 2 QA dogfood fixes) 2026-05-04 03:53:56 -07:00
mvschwarz 72ddbe2101 fix(workflow): preserve read-through spec metadata 2026-05-04 03:07:35 -07:00
mvschwarz a34e99cd5b fix(workflow): map queue destination errors 2026-05-04 03:04:23 -07:00
mvschwarzandClaude Opus 4.7 5bdda4f8be feat(rsi-v2-starter): v0 — bundled workflow_spec + startup loader + specs CLI
RSI v2 starter v0 — the canonical Discovery → Product Lab → Delivery →
Lifecycle → QA hot-potato loop ships as a built-in workflow_spec
out-of-box. Three components, single coherent vertical. NO new
schema, NO migrations, NO new event types — composes PL-004 Phase D's
existing workflow runtime + cache + reconciliation contract.

Component 1 — canonical RSI v2 spec file:
- packages/daemon/src/builtins/workflow-specs/rsi-v2-hot-potato.yaml
- 5 roles (discovery-router, product-lab-planner, delivery-driver,
  lifecycle-router, qa-tester) with skill_refs + preferred_targets.
- 5 steps in loop order (discovery → product-lab → delivery →
  lifecycle → qa); routing hints via next_hop.suggested_roles. Only
  the qa step permits `done` as a terminal exit (no-follow-on
  acceptance) — non-qa steps must handoff/waiting/failed. qa loops
  back to discovery via next_hop on the followup-signal path.
- coordination_terminal_turn_rule: hot_potato (matches founder default).
- loop_guards.max_hops: 30 (conservative ceiling against routing-table
  churn; normal loops close in 5-10 hops).
- Schema follows packages/daemon/src/domain/workflow-types.ts strictly:
  top-level `workflow:` key, `id` (not `name`), `objective` (not
  `purpose`), `roles` mapping (not array), step `actor_role` (not
  `role`). PRD's draft content used field names that didn't match
  Phase D's parser — re-authored against the actual schema.

Component 2 — startup loader:
- packages/daemon/src/domain/workflow/starter-spec-loader.ts
- loadStarterWorkflowSpecs walks builtinDir, parses each .yaml/.yml
  via WorkflowSpecCache.parseWorkflowSpec, and seeds via
  cache.readThrough(absPath) — but ONLY when no row exists for the
  same (name, version). Idempotent on repeated startup.
- Workspace-surface reconciliation preserved: when an operator has
  authored a competing spec at a workspace path with the same
  (name, version), the loader SKIPS rather than clobbering. Test
  pins this behavior end-to-end.
- defaultBuiltinSpecsDir() resolves from the loader file's own
  import.meta.url (../../builtins/workflow-specs from src/dist root)
  so it works in both dev (running from src/) and prod (running
  from dist/). Daemon package.json build script copies the .yaml
  file from src/builtins/workflow-specs/ to dist/builtins/workflow-specs/.
- Wired into startup.ts after WorkflowRuntime construction. Errors
  are collected into the result for diagnostic logging (console.warn);
  startup is NOT blocked by a malformed bundled spec.
- Surfaces the resolved builtinDir path on AppDeps.workflowBuiltinSpecsDir
  so the routes layer can compute per-row isBuiltIn flags.

Component 3 — `rig workflow specs` subcommand + indicator:
- New WorkflowSpecCache.listAll() ordered by (name, version).
- New GET /api/workflow/specs route returning { specs: [...] } with
  per-row isBuiltIn computed from sourcePath being strictly under
  workflowBuiltinSpecsDir (path.sep boundary check rules out the
  `/foo/bar-other` matches `/foo/bar` false-positive). Mounted
  BEFORE /:instance_id catchall per Phase A R1 SSE route-order
  lesson — pinned by a dedicated test.
- New `rig workflow specs [--json]` CLI subcommand. Sibling of the
  existing `rig workflow validate / list / instantiate` — NOT a new
  top-level command (PRD bounce condition was specifically against
  `rig workflow starter-list`-style additions; `rig workflow specs`
  is the natural complement to the existing instance-listing
  `rig workflow list`).
- Human output: `<name> v<version>` with optional `(built-in)` suffix
  + indented purpose excerpt (120 chars) + source path.
- JSON output: full row with isBuiltIn boolean for agents.

PRD audit-row reinterpretations recorded in commit + handoff:
- Row 6: `rig workflow list` lists INSTANCES, not specs. Adding
  `rig workflow specs` (sibling subcommand, not top-level) respects
  the bounce condition while extending the workflow surface naturally.
- Row 2: PRD draft spec content used incompatible field names; the
  shipped spec follows Phase D's actual parser shape strictly.

Verification:
- daemon: 2750/2750 (was 2723 baseline at 66796c2 + 27 new RSI-v2
  cases across 3 new test files: 13 spec-content + 7 starter-loader +
  7 specs-route).
- cli: 797/800 — same 3 pre-existing env-probe failures (preflight
  +config) reproduce on baseline; no regression. Driver did not
  modify those code paths.
- tsc clean for daemon + cli + ui.
- npm run build copies the YAML to dist/builtins/workflow-specs/
  (verified post-build: rsi-v2-hot-potato.yaml present in dist/).

Boundaries preserved:
- Worktree-only on rsi-v2-starter-workflow-spec-v0; main untouched at 66796c2.
- NO new migrations, NO new schema, NO new event types.
- PL-004 Phase D's workflow_specs table + workflow-spec-cache.ts +
  workflow-runtime.ts contracts are the destination — extended via
  `WorkflowSpecCache.listAll()` (additive) and one new GET route
  (additive); no existing surface modified.
- Workspace-surface reconciliation contract honored end-to-end:
  loader skips already-cached (name, version) so operator overrides
  are preserved.
- NO auto-instantiation on rig creation (PRD bounce); operator
  instantiates explicitly via existing Phase D verb.
- NO multi-spec starter library; v0 ships ONE starter
  (rsi-v2-hot-potato). Future starters are separate slices.
- NO production-shop language. NO new top-level CLI command.
- Slice Story View v0, PL-005, PL-019, PL-004 Phase A/B/C surfaces
  all read-only.

Operator setup hint for QA dogfood:
- After `pnpm install` + `npm --workspaces run build`, start the daemon
  and run:
    rig workflow specs --json | jq '.specs[] | {name, version, isBuiltIn}'
  Expect: rsi-v2-hot-potato v1 with isBuiltIn=true.
- Validate the spec:
    rig workflow validate dist/builtins/workflow-specs/rsi-v2-hot-potato.yaml
  Expect: passes Phase D's validator (no errors).
- Instantiate it:
    rig workflow instantiate \\
      --spec-path dist/builtins/workflow-specs/rsi-v2-hot-potato.yaml \\
      --root-objective "smoke test" \\
      --created-by-session <my-session> \\
      --entry-owner-session <intake-router@my-rig>
  Expect: workflow_instance created; current_step_id="discovery".
  (Note: Phase D's instantiate verb takes specPath, not name+version
  per the route signature — operator points it at the bundled spec
  file directly.)

Hard-gate (operator dogfood per PRD audit row 7) is QA-owned per
feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for guard
inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 02:53:46 -07:00
mvschwarz 66796c21dd Merge phase-b-mission-control-queue-observability (Mission Control Phase B + 7 dogfood fixes)
# Conflicts:
#	packages/daemon/src/server.ts
2026-05-04 02:31:18 -07:00
mvschwarz fdbe1e07d6 Merge slice-story-view-v0 (Slice Story View v0 + QA dogfood fixes)
# Conflicts:
#	packages/ui/test/index-html.test.ts
2026-05-04 02:29:01 -07:00
mvschwarz b678e67130 Merge pl019-topology-activity-indicators (Topology Activity Indicators v0 + deep-link context fix) 2026-05-04 02:26:16 -07:00
mvschwarz babf6ecc9d Merge phase-a-mission-control-queue-observability (PL-005 Phase A + favicon dogfood fix) 2026-05-04 02:26:16 -07:00
mvschwarz 3f98dead6f fix(slice-story): dogfood sidebar and proof status 2026-05-04 02:02:40 -07:00
mvschwarzandClaude Opus 4.7 74e77f0e0c feat(slice-story-view): v0 — slice indexer + 2 routes + 6 right-pane tabs
Slice Story View v0 — operator clicks a slice in /slices and sees the
entire RSI lifecycle (Story / Acceptance / Decisions / Docs / Tests /
Topology) in one tabbed page. Single ambitious vertical; six tabs
shipped (none deferred). Read-only: NO new schema, NO migrations, NO
new event types — indexes + composes already-shipped data.

Daemon:
- New domain service `domain/slices/slice-indexer.ts`: walks the
  configured slicesRoot, parses YAML-lite frontmatter, maps status to
  active/done/blocked/draft, joins to queue_items by slice-name body
  match + rail-item body match (token-aware), discovers
  dogfood-evidence proof packets via TOKEN matching (slice tokens
  with -vN stripped must all appear in dir name; handles real-world
  shape `pl005-phase-a-mission-control-queue-observability-20260504`
  even though slice folder uses suffix form), classifies markdown /
  screenshots / videos / traces, and emits a normalized SliceRecord.
  In-memory cache with 60s TTL.
- New `domain/slices/slice-detail-projector.ts`: per-tab payload
  projector covering all six tabs. Story stitches queue_items create
  events, queue_transitions append-only log, mission_control_actions
  verbs (filtered to slice qitem chain), slice doc mtimes, and proof
  packet emission events into one chronological narrative with phase
  tags. Topology groups affected seats by parsed rig name (no new
  topology endpoint — Topology tab links through to existing
  /api/rigs/:rigId/graph).
- New `routes/slices.ts`:
    GET /api/slices?filter=all|active|done|blocked  list endpoint
    GET /api/slices/:name                            full per-tab payload
    GET /api/slices/:name/proof-asset/<rel>          binary serving for
                                                      screenshots / videos /
                                                      traces with content-type
                                                      detection + path-traversal
                                                      guard + aggressive cache
    GET /api/slices/:name/doc/<rel>                  markdown content for
                                                      Docs tab (lazy on click)
  Route-order discipline per Phase A R1 lesson: literal `/` and
  `/proof-asset/*` and `/doc/*` register BEFORE bare-param `/:name`
  catchall to avoid shadowing.
- Server.ts wires slicesRoutes; startup.ts constructs the indexer +
  projector from OPENRIG_SLICES_ROOT + OPENRIG_DOGFOOD_EVIDENCE_ROOT.
  Unset env returns 503 + `slices_root_not_configured` setup hint
  (graceful — no daemon-restart-debug-loop).

UI:
- New /slices and /slices/$name routes mounted on the existing root
  route tree.
- New `components/slices/SliceStoryView.tsx` two-pane surface: left
  filter row (All / Active / Done / Blocked; default Active) + slice
  list, right-pane tab nav (Story / Acceptance / Decisions / Docs /
  Tests / Topology) with conditional view rendering. Slice rows
  show status glyph, name, rail-item, qitem count, "proof" indicator
  when dogfood-evidence exists, age in days.
- Six tab views in `components/slices/tabs/`:
    - StoryTab: chronological event list with phase-color tags
      (discovery=amber, product-lab=violet, delivery=emerald,
      lifecycle=sky, qa=rose, other=stone) + click-to-expand JSON
      detail view.
    - AcceptanceTab: progress bar + checkbox list with file:line
      source citations + "Goal Met" closure callout when slice
      status=done.
    - DecisionsTab: mission_control_actions timeline with verb /
      actor / search filters + before/after JSON snapshot disclosure.
    - DocsTab: file tree (left) + lazy markdown viewer (right);
      pre-renders content as preformatted text (Markdown viewer
      composes with PL-009 when that ships).
    - TestsVerificationTab (founder-named LOAD-BEARING): per-proof-
      packet section with rendered primary markdown, MANDATORY inline
      `<img>` tags for screenshots, MANDATORY `<video controls>`
      players for videos, trace download links, pass/fail badge with
      semantic color, additional-markdown disclosure. Gracefully
      handles empty videos array (real-world: QA hasn't captured one
      yet).
    - TopologyTab: read-only consumer of PL-019's graph payload —
      groups slice seats by rig with totalSeats aggregate + click-
      through to main topology surface (per PRD: "this tab is
      read-only; clicking a node from here takes the operator to
      the regular topology / node-drawer surface"). v1 will inline
      a filtered RigNode subgraph; v0's list-based view satisfies
      "see the seats" intent without re-mounting React Flow inside
      the slice context.
- AppShell header: new Slices Link (Layers icon) navigates to
  /slices; surface-title resolver recognizes /slices.
- New hooks `useSlices`, `useSliceDetail`, `useSliceDoc` +
  `proofAssetUrl()` helper. Surface 503 indexer-unavailable as a
  structured `unavailable` error so the UI renders the env-config
  setup hint instead of a raw error toast.

Audit-row 6 amendment recorded in commit + memory: real
dogfood-evidence layout differs from PRD proposal — directories
named `<prefix>-<slice-name>-<date>/` with `*.md` at top + optional
`screenshots/*.png` + optional `headed-browser/screenshots/` +
`phone-screenshots/`. NO `videos/` or `traces/` subdirs exist yet —
indexer + UI handle absence gracefully. Tests cover the .mp4/.webm
classification path so the pipeline is ready when QA captures.

Boundaries preserved:
- Worktree-only on `slice-story-view-v0`; main untouched at ca4f02e.
- Base = PL-019 commit ee39768 (per dispatch); two follow-up QA-fix
  commits on PL-019 worktree (favicon, deep-link context) are
  unrelated to slice-story-view and intentionally not pulled in.
- Phase B worktree (cd071b0/a3780a6) untouched; surface non-overlap
  total per audit row 2.
- NO new migrations. NO new event types. workflow_instances entity
  model deferred to v1 per founder direction (PRD bounce condition).
- Read-only at v0; no acceptance/decisions/docs editing surface.
- NO production-shop language. NO Mission One reference's separate
  Build/Commits/Tests pipeline tabs (those concepts fold into the
  Tests/Verification tab as proof-packet content per PRD).

Verification:
- daemon: 2664/2664 (was 2625 baseline at PL-019 base + 39 new
  PL-slice cases across 2 new test files: 21 slice-indexer + 18
  slices-routes).
- ui: 448/448 (was 432 baseline + 16 new: 10
  tests-verification-tab + 6 slice-story-view-surface).
- cli: 797/800; same 3 pre-existing env-probe failures (preflight +
  config resolver) reproduce on baseline — unrelated to this slice.
- tsc clean for daemon + cli + ui.

Hard-gate row 10 (headed-browser dogfood with operator confirming
all 6 tabs render correctly on PL-005 Phase A as the test slice;
Tests tab specifically: screenshots render inline + video player
visible/playable) is QA-owned per
feedback_qa_owns_dogfood_fix_loop.md. Worktree retained for guard
inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-04 01:41:25 -07:00
mvschwarz 7645e54b37 fix(pl019): keep node detail context on deep links 2026-05-04 01:02:46 -07:00
mvschwarz 1c4cd15e31 fix(ui): avoid favicon console 404 2026-05-04 00:55:31 -07:00
mvschwarz bc68cccc31 fix(ui): avoid favicon console 404 2026-05-04 00:25:26 -07:00
mvschwarz 92e95494c4 fix(mission-control): add handoff destination picker 2026-05-04 00:23:00 -07:00
mvschwarz 0016661e96 fix(ui): link mission control from home 2026-05-04 00:17:16 -07:00
mvschwarz 06b1fc0dea fix(mission-control): deep-link qitem audit filters 2026-05-03 23:59:36 -07:00
mvschwarz de0a3b9223 fix(mission-control): show qitem context on phone rows 2026-05-03 23:53:07 -07:00
mvschwarzandClaude Opus 4.7 ee39768e8e feat(pl-019): topology activity indicators v0 (6 items)
PL-019 Topology Activity Indicators v0 — operator sees at-a-glance
which agents are working, what qitems they're on, and how the topology
is moving. Single ambitious vertical; six items shipped in one batch.

Item 1 (CLI convenience): rig ps --nodes --filter agentActivity.state=<state>
extends the --filter allow-list with a nested key + enum-validated value.
--active is sugar for agentActivity.state=running. Combining --active
with --filter is rejected. Three-part error on invalid value
(what failed / what's allowed / what to do). Fixes a pre-existing gap
where bare 'agentActivity' was in ALLOWED_FILTER_KEYS but applyNodeFilter
didn't actually evaluate it (silent no-op).

Item 2 (graph node color): RigNode dot now driven by agentActivity.state
via a new shared activity-visuals helper. Restrained palette per orch
design: running = warm green/teal (emerald-500) + subtle 1.8s opacity
pulse; needs_input = static amber-500 (the eye-catcher); idle = calm
slate-400; unknown = desaturated stone-300. No backwards-compat shim
for the old startup-status dot color — getStartupStatus* helpers
deleted. ATTN/FAILED badges retained as a separate signal (startup-state
answers a different question than activity-state). Stale-sample badge
when sampledAt > ~30s threshold.

Item 3 (Explorer indicator): NodeActivityIndicator added next to the
existing startup-status icon on every Explorer row. Same palette as
item 2. 'Owns active work' bullet appears when currentQitems is
non-empty; tooltip lists short ULID tail + body excerpt per qitem.

Item 4 (graph payload): InventoryOverlay carries agentActivity through
to RFNodeData; routes/rigs.ts attaches activity via attachAgentActivity
at graph-payload time so UI consumers don't need a separate fetch.

Item 5 (active-qitem read-side join): loadCurrentQitemsForSessions
helper exported from routes/rigs.ts joins queue_items WHERE state =
'in-progress' AND destination_session IN (...) ORDER BY ts_updated DESC.
Cap of 3 qitems per node (multi-claim is rare); body excerpted at 80
chars for phone-friendly surfaces. Same enrichment layered onto
routes/sessions.ts node-detail. Cheap by virtue of the existing
idx_queue_items_destination_state index.

Item 6 (edge animation): useTopologyEdgeActivity hook subscribes to
/api/events SSE and tracks recent topology traffic by directed
(source, dest) pair across queue.created, queue.handed_off, and
qitem.fallback_routed. Edges between matched seats get the
edge-recent-traffic class for ~30s sustained emphasis (1.5x weight +
brighter) and edge-just-fired for a one-shot 1.2s pulse on each
firing. mission_control.action_executed is intentionally not animated
at v0 because the event payload lacks a destination session pair.
Reduced-motion media query suppresses both animations.

Boundaries preserved: NO new schema, NO migrations, NO new event
types — v0 consumes the agentActivity primitive shipped at types.ts
and subscribes to events that already exist on PL-004 / PL-005 buses.
Mission Control surfaces (PL-005) and queue-repository (PL-004) are
read-only. PL-005 Phase B surfaces in the parallel worktree are
untouched.

Verification:
- daemon: 2625/2625 pass (was 2607 baseline + 18 new PL-019 cases:
  6 graph-projection-pl019, 9 rigs-routes-pl019-current-qitems, plus
  pre-existing rigs/sessions tests now exercise the new graph-payload
  enrichment via createFullTestDb extended through migration 025).
- ui: 432/432 pass (added 18 activity-visuals + 8 edge-activity
  normalize cases + 3 RigNode activity tests; updated 2 RigNode tests
  + 4 RigGraph SSE tests to find the rig-events EventSource by
  ?rigId= URL since RigGraph now opens two SSE connections).
- cli: 797/800 pass; the 3 failures (preflight env-probes, config
  resolver) reproduce on the unchanged Phase A baseline so they are
  pre-existing and unrelated to PL-019.
- tsc clean for daemon + cli + ui.
- cli-reference.md updated to document --active and the new
  agentActivity.state filter key.

Hard-gate row 9 (headed-browser dogfood with operator-confirmed
legibility on a real rig with multiple active seats) is QA-owned per
feedback_qa_owns_dogfood_fix_loop.md and feedback_velocity_proof_ownership_split.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 23:13:01 -07:00
mvschwarz a3780a66c9 fix: support mobile mission-control auth links 2026-05-03 22:48:58 -07:00
mvschwarz 42711ae95d fix(ui): prevent favicon 404 during dogfood 2026-05-03 22:21:58 -07:00
mvschwarzandClaude Opus 4.7 cd071b03b3 feat(daemon,ui): PL-005 Mission Control Phase B — tailnet bind + bearer auth + push notifications + audit history browse
One ambitious vertical per orch ratification 2026-05-04:
"Mission Control runs from my phone over tailnet, pings me when
something needs me, and the legacy dashboard is gone."

Audit-first verdict (12 rows; 5 hard-gates ALL GREEN except QA-owned
row 12 + the substrate-side directory deletion deferred to operator
authorization — see below):

  Row 1:   GREEN  Phase B worktree clean at ca4f02e
  Row 2:   GREEN  Phase A migration is 037 (orch-corrected from packet's
                  draft 033; mission_control_actions table itself
                  unchanged)
  Row 3-7: GREEN  Phase A surfaces present + unmodified
  Row 8:   HARD-GATE GREEN — daemon refuses non-loopback bind when
                  bearer empty (assertBindAuthInvariant in index.ts
                  before serve())
  Row 9:   HARD-GATE GREEN — constant-time compare via
                  crypto.timingSafeEqual (constantTimeEqual helper)
  Row 10:  HARD-GATE GREEN — operator notification mechanism choice
                  recorded: ntfy.sh DEFAULT per planner brief; webhook
                  alternate adapter shipped; operator selects via env
                  OPENRIG_NOTIFICATIONS_MECHANISM=ntfy|webhook|none
                  (default none — unconfigured daemon stays operational)
  Row 11:  HARD-GATE PARTIAL — openrig-repo references to
                  services/dashboard removed (architecture.md updated
                  to past-tense without the literal path; grep -r
                  returns zero in openrig). Substrate-repo directory
                  rm -r shared-docs/control-plane/services/dashboard/
                  DEFERRED to explicit operator authorization: the
                  shared-docs repo is dirty (ahead 22 from origin
                  with multiple in-flight orch/founder/planner edits)
                  and a destructive cross-repo deletion from the
                  velocity-driver seat without explicit approval would
                  conflict with the just-recorded promotion-incident
                  memory's "report-blocker-don't-route-around"
                  protocol. See driver-to-delivery-orch handoff for
                  the explicit ask.
  Row 12:  N/A — QA-owned headed-browser dogfood per dispatch packet

Items shipped (4 of 5; item 5 substrate-side partial per row 11 above):

  Item 1 — Remote-bind + bearer-token middleware:
    - middleware/auth-bearer-token.ts: constantTimeEqual via
      crypto.timingSafeEqual (no early-return on byte mismatch);
      authBearerTokenMiddleware (401 + 3-part error body on missing
      or mismatched token); isLoopbackBind (127.x / localhost / ::1
      → loopback; everything else including 0.0.0.0 + tailnet IPs
      → non-loopback); assertBindAuthInvariant (HARD-GATE startup
      check called from index.ts before serve()).
    - Mounted on POST /api/mission-control/action and POST
      /api/mission-control/notifications/test (write verbs gated;
      reads remain UNgated per planner brief default — bearer is
      for write integrity, not view confidentiality).
    - createDaemon plumbs bearerToken through DaemonOptions →
      AppDeps.missionControlBearerToken → missionControlRoutes opts.
    - Read from env OPENRIG_AUTH_BEARER_TOKEN (no legacy alias —
      new in Phase B).

  Item 2 — Push notifications:
    - notification-adapter-types.ts: shared NotificationAdapter +
      NotificationPayload + NotificationDeliveryResult contracts.
    - notification-adapter-ntfy.ts: ntfy.sh adapter (POST to topic
      URL with Title/Click/Tags headers; sanitized title); ~70 lines.
    - notification-adapter-webhook.ts: generic webhook adapter
      (POST JSON body with stable schema_version=1 shape so operators
      can route through Slack/Discord/Telegram/their own infra);
      ~70 lines.
    - notification-dispatcher.ts: subscribes to event-bus; dispatches
      on human-gate qitem arrival (mandatory trigger; detected via
      queue.created event + tier='human-gate'); optional dispatch on
      mission_control.action_executed when includeVerbCompletion=true
      (off by default per planner brief). Best-effort delivery;
      failure does NOT interrupt the underlying action (PRD invariant).
      Dedup by (qitem, trigger-kind, mechanism) so distinct triggers
      about the same qitem don't suppress each other. Emits
      mission_control.notification_sent / _failed.
    - Wired in startup.ts via env config:
      OPENRIG_NOTIFICATIONS_MECHANISM=ntfy|webhook|none (default none),
      OPENRIG_NOTIFICATIONS_TARGET=<topic url|webhook url>,
      OPENRIG_NOTIFICATIONS_INCLUDE_VERB_COMPLETION=1 (default off).
    - Optional POST /api/mission-control/notifications/test endpoint
      (bearer-gated) sends a synthetic notification so the operator
      can verify their setup before relying on it.

  Item 3 — Mobile-responsive proof: surface ready for QA-led
    headed-browser dogfood per dispatch packet (QA owns proof
    end-to-end per feedback_qa_owns_dogfood_fix_loop.md).

  Item 4 — GET /api/mission-control/audit + AuditHistoryView UI:
    - audit-browse.ts: read-only filter+paginate over
      mission_control_actions. Filters: qitem_id, action_verb,
      actor_session, since/until time range. Limit clamped to
      [1, 200] (default 50). Pagination cursor returns nextBeforeId
      (null when no more rows). ORDER BY acted_at DESC, rowid DESC
      (deterministic insertion-order tiebreak per
      feedback_ulid_tiebreaker_nondeterministic memory; ULID
      action_id same-ms tail is non-deterministic). Cursor uses
      `WHERE rowid < (SELECT rowid FROM mission_control_actions
      WHERE action_id = ?)` so opaque action_id cursors map to
      monotonic rowid filter.
    - GET /api/mission-control/audit route registered BEFORE
      /views/:view-name catchall (route-order discipline per
      Phase A R1 lesson). Read-only; no mutation surface.
    - Product UI:
      - useMissionControlAudit hook: TanStack Query against /audit.
      - AuditHistoryView.tsx: 4-dim filter UI + paginated result
        table; prev/next buttons; read-only.
      - MissionControlSurface adds "audit-history" tab as 8th tab
        (UI-side union extends Phase A's 7 daemon view names; daemon
        MISSION_CONTROL_VIEWS enum unchanged — Phase A surface
        no-touch).

  Item 5 — services/dashboard/ retirement (PARTIAL):
    - openrig-repo: ZERO references remaining (verified via
      grep -rn "services/dashboard" across .md/.ts/.tsx/.js/.json).
      docs/as-built/architecture.md updated: removed literal path,
      reworded as past-tense ("An earlier prototype that informed
      Mission Control's view/verb vocabulary was retired in PL-005
      Phase B; git history preserves it as evidence").
    - substrate-repo (shared-docs/control-plane/services/dashboard/
      directory): NOT deleted in this commit. The shared-docs repo
      is dirty (ahead 22 + many uncommitted M files from in-flight
      orch/founder/planner edits). Per the just-recorded promotion
      incident memory, destructive cross-repo deletions on a dirty
      collaboration repo from the velocity-driver seat without
      explicit operator approval are NOT routed around — they are
      escalated. The handoff packet asks delivery-orch / human to
      authorize the substrate-side rm + commit (or direct the
      driver to perform it under explicit grant).

3 new RigEvent types added to types.ts (existing PL-005 events
unchanged):
  - mission_control.notification_sent
  - mission_control.notification_failed

Verification:
  - tsc clean for daemon + cli + ui.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory:
    - packages/daemon: 2664 / 2664 pass (was 2607 at base + Phase A;
      +57 new Phase B daemon tests across 5 new test files:
        auth-bearer-token (15), notification-adapters (8),
        notification-dispatcher (8), audit-browse (11),
        mission-control-routes-phase-b (15)).
    - 5/5 deterministic loop on audit-browse pagination test (was
      flaky against ULID action_id DESC; now deterministic with
      rowid DESC).

Boundaries preserved (Phase B):
  - Worktree-only on phase-b-mission-control-queue-observability;
    main untouched at ca4f02e.
  - NO new migrations (Phase B reads existing mission_control_actions
    from Phase A migration 037).
  - Phase A's 4 mission-control/ services + 7 views + 7 verbs +
    fleet-cli-capability + write-contract + read-layer all
    UNMODIFIED beyond additive route inserts.
  - Phase A's 3 mission_control.* events untouched; Phase B adds 2
    additive events.
  - PL-006 steering controls absent. No new managed app. No native
    mobile. No push-notification multi-target routing.
  - No new CLI commands at v0 (Mission Control remains product UI
    inside existing shell).
  - NO OAuth/SSO/per-user routing/role-based perms/token rotation
    (single static bearer per planner brief MVP context).
  - NO archive policy / rotation for mission_control_actions.
  - NO production-shop language ("graceful migration", "cutover",
    "deprecation period", "feature flag for rollback", "version
    negotiation") in code comments or commit message.
  - No fleet sweep, no protected-host mutation, no external spend, no
    destructive lifecycle, no VM-boundary actions, no remote push, no
    tag, no publish, no Lifecycle adoption, no live host daemon /
    topology / provider-auth touch.

Per dispatch: hand back to delivery-orch-lead so QA can run the
headed-browser dogfood on the operator's actual phone (Item 3 +
Hard-Gate Row 12). substrate-side dashboard directory deletion
ask included in handoff packet.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 22:21:14 -07:00
mvschwarz ca4f02ead9 docs: clarify OpenRig coordination surface guidance 2026-05-03 20:04:53 -07:00
mvschwarz ab4473b46c fix(mission-control): harden dogfood blockers 2026-05-03 19:27:18 -07:00
mvschwarzandClaude Opus 4.7 de8e0b2d93 fix(daemon): PL-005 Phase A R1 — production CLI capability probe wired into Mission Control fleet path
Repairs guard BLOCK on R0 commit 33e01bd:

R0's MissionControlFleetCliCapability defaulted probeRig to a no-op
that always reported staleCliCount=0 + no unsupported fields, and
startup.ts didn't inject a real probe. Result: production
/api/mission-control/cli-capabilities reported zero drift even when
the audit-row-5 case (recoveryGuidance not in CLI v0.2.0 allow-list)
was present. The 4-sub-clause graceful-degradation claim was only
proven through test injection, not through the production route.

R1 fix:

  - mission-control-fleet-cli-capability.ts adds three new exports:
    - LOCAL_CLI_NODE_FIELDS_AT_0_2_0: daemon-side mirror of the CLI's
      ALLOWED_NODE_FIELDS at the workspace v0.2.0 (sourced from
      packages/cli/src/commands/ps.ts:79-96; CLI does not export the
      set so the daemon mirrors it explicitly).
    - LOCAL_CLI_VERSION_LABEL: build-time version label ("0.2.0").
    - makeLocalCliCapabilityProbe(opts?): production-default probe
      factory. Returns a probeRig function that compares
      MISSION_CONTROL_DESIRED_FIELDS against the daemon-mirrored
      allow-list and reports unsupported fields. Per-rig honesty:
      each rig is probed individually; in v1 single-host topology all
      rigs share the local CLI so they all report the same drift,
      which is the honest outcome for the audit-row-5 case.
  - startup.ts wires makeLocalCliCapabilityProbe() into the
    MissionControlFleetCliCapability constructor's probeRig parameter.
    The 4 sub-clauses (per-field availability, per-rig honesty,
    once-per-session-per-rig logging, "rigs running stale CLI"
    indicator) are now exercised through the production path, not
    just an injected unit seam.

R1 regression tests (4 new total):

  - mission-control-fleet-cli-capability.test.ts (3 new):
    - "PRODUCTION-WIRED probe (makeLocalCliCapabilityProbe):
      recoveryGuidance NOT in CLI allow-list → reports drift on every
      rig" — uses the production factory (not a fake probeRig);
      asserts staleCliCount=2 (both seeded rigs), degradedFields
      contains "recoveryGuidance" but NOT "agentActivity"
      (audit-row-5 ground truth verified at the production seam).
    - "agentActivity IS in LOCAL_CLI_NODE_FIELDS_AT_0_2_0
      (audit row 5 ground truth)" — pinpoint assertion on the source
      mirror.
    - "production probe: an extended (hypothetical future) CLI
      allow-list with recoveryGuidance reports zero drift" —
      regression that proves graduation path: when the CLI surfaces
      the desired field, the production probe stops reporting drift
      automatically (no source change required).
  - mission-control-routes.test.ts (1 new):
    - "PRODUCTION-WIRED ROUTE: /cli-capabilities reports
      recoveryGuidance drift in JSON payload + per-row
      cliDriftDetected" — END-TO-END proof through the route layer
      (not an injected unit seam): builds an app with the production
      probe wired (matching startup.ts), hits
      /api/mission-control/cli-capabilities, asserts the JSON payload
      exposes drift to UI consumers (staleCliCount > 0,
      degradedFields includes "recoveryGuidance",
      every row has cliDriftDetected=true and cliVersionLabel="0.2.0").

Verification:
  - tsc clean for daemon + cli.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2607 / 2607 pass (was 2603 in PL-005 Phase A R0;
      +4 R1 regressions: 3 fleet-cli-capability + 1 routes-level).
  - daemon + cli builds clean.
  - UI infra (PostCSS) failures identical to baseline; not Mission
    Control regressions.

Boundaries preserved (R1):
  - Worktree-only on phase-a-mission-control-queue-observability;
    main untouched.
  - Smallest-honest-patch: 1 daemon file extended (3 new exports
    + 1 helper factory) + 1 startup wiring line + 4 new test cases.
    No other surface touched.
  - No fleet sweep, no protected-host mutation, no external spend, no
    destructive lifecycle, no VM-boundary actions, no remote push, no
    tag, no publish, no Lifecycle adoption, no live host daemon /
    topology / provider-auth touch.

The honest-equivalent design: the daemon mirrors the CLI's
v0.2.0 ALLOWED_NODE_FIELDS set rather than shelling out per rig.
v1 single-host topology makes per-rig CLI shelling unnecessary
overhead; future graduation can replace the static mirror with a
live introspection per rig if multi-host fleets become a v0
shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 18:56:08 -07:00
mvschwarzandClaude Opus 4.7 33e01bd472 feat(daemon,ui): PL-005 Mission Control / Queue Observability Phase A
Implements Phase A per ratified slice contract; founder Q1-Q5 (2026-05-03)
applied verbatim:

  Q1 Dashboard graduation: 7 views + 7 verbs on day one.
  Q2 PL-004 Phase B dependency: read PL-004 daemon-backed view layer
     as source-of-truth (filesystem fallback only for graceful degradation).
  Q3 Human queues: first-class product concept (HumanSeatCard).
  Q4 Recent ships: 10 by default.
  Q5 Action audit: daemon-backed SQLite table with archive-policy hooks
     (no JSONL mirror unless dogfood discovers concrete durability need).

Audit-first results (15 rows; per IMPL.md):
  Row 1: GREEN (migrations 023-036 present; PL-005 numbered 037, NOT
         the packet's provisional 033 — Phase D shipped 033-036).
  Row 2: GREEN (db/connection.ts + db/migrate.ts unchanged).
  Row 3: GREEN (PL-004 domain unmodified at HEAD).
  Row 4: GREEN (UI shell at packages/ui/src/{AppShell,routes}.tsx).
  Row 5: YELLOW (agentActivity in CLI allow-list; recoveryGuidance NOT
         in 0.2.0 allow-list; not a blocker — exactly the cross-CLI-
         version drift the 4-sub-clause graceful degradation contract
         is designed to handle).
  Row 6: GREEN (POC services/dashboard available as vocabulary).
  Row 7: GREEN (stream surface readable: stream_items + .jsonl).
  Row 8: GREEN (human-seat first-class design encoded).
  HARD-GATES 9-15: ALL GREEN (Q1-Q5 encoded; daemon has Coordination
         Primitive bundle; explicit OPENRIG_DB pattern; no in-flight
         PL-004 revision; no migration test; cross-CLI-drift handled
         by spec; 9-field model preserved).

Migration:
  - 037_mission_control_actions: append-only audit. 7-verb enum at
    application layer (matches Phase C/D pattern). FK to queue_items.
    Indexes on (acted_at DESC, action_verb), (qitem_id, acted_at DESC),
    (actor_session, acted_at DESC).

Daemon domain (4 new files, 1 new subdirectory):
  - mission-control-action-log: append-only writer; verb-specific
    required-field validation (annotate→annotation; hold/drop→reason).
    API surface lacks update/delete/remove (contract enforcement).
  - mission-control-write-contract: LOAD-BEARING. Atomic 7-verb
    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 mutations (PRD invariant). Failure-injection
    rollback test included.
  - mission-control-read-layer: maps 7 views to source-of-truth paths.
    PL-004 daemon-backed services as primary; filesystem only for
    graceful degradation. recently-active delegates to PL-004 Phase B
    ViewProjector.show("recently-active"). recent-observations reads
    PL-004 Phase A stream_items via StreamStore.
  - mission-control-fleet-cli-capability: per-rig CLI capability cache
    + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source
    Drift Acceptance (per-field availability, per-rig honesty,
    once-per-session-per-rig logging, "rigs running stale CLI"
    indicator).

Routes (1 new file):
  - routes/mission-control.ts: GET /views (list), GET /views/:view-name,
    POST /action, GET /sse + /watch, GET /cli-capabilities. SSE/literal
    paths mounted BEFORE /views/:view-name catchall per PL-004 Phase A
    R1 SSE route-order discipline.

3 new RigEvents added to types.ts (additive; existing events untouched):
  - mission_control.action_executed
  - mission_control.cli_drift_detected
  - mission_control.view_refreshed

Wiring:
  - server.ts: AppDeps extended; context-set; route mounted.
  - startup.ts: Mission Control services constructed after PL-004
    Phase D wiring so all daemon-backed coordination surfaces are
    available.

Product UI (NEW Mission Control surface inside existing shell):
  - MissionControlSurface.tsx: top-level container with tab nav across
    7 view names. Mounted at /mission-control route in routes.tsx.
  - 7 view components (My queue, Human gate, Fleet, Active work, Recent
    ships, Recently active, Recent observations). Most wrap shared
    GenericListView; FleetView combines CliDriftIndicator + per-row
    MissingFieldPlaceholder; MyQueueView combines HumanSeatCard +
    GenericListView with verb actions enabled.
  - CompactStatusRow.tsx: 9-FIELD PHONE-FRIENDLY CONTENT MODEL ATOM.
    NON-NEGOTIABLE per PRD § Acceptance Criteria item 1. Renders
    compact / expanded densities; UI may collapse but JSON preserves
    all 9 fields (rig/mission name, current phase, active/idle/
    attention/blocked/degraded, next-action, pending-human-decision,
    read-cost, last-update, confidence/freshness, evidence link).
  - VerbActions.tsx: shared 7-verb sub-component with per-verb
    required-field input and structured error display.
  - HumanSeatCard.tsx: first-class human-seat rendering (founder Q3).
  - CliDriftIndicator.tsx: fleet-level + per-row drift surfacing.
  - 3 React Query hooks (useMissionControlView, useMissionControlAction,
    useMissionControlCliCapabilities).

Tests (5 new daemon test files; 42 cases):
  - mission-control-action-log.test.ts (8 cases): append-only contract,
    verb-specific required-field validation, JSON encoding,
    listForQitem/listForActor filters, no update/delete API.
  - mission-control-write-contract.test.ts (10 cases): all 7 verbs
    drive correct closure shape; ATOMIC ROLLBACK regression
    (handoff with invalid destination rolls back source close + audit
    + new qitem); annotate has no queue mutation; route tags new packet.
  - mission-control-read-layer.test.ts (7 cases): 7-view structural
    coverage; 9-FIELD MODEL preserved across all 7 views (PRD
    non-negotiable assertion); my-queue filters human-gate to operator;
    recent-ships caps at 10 (founder Q4); active-work priority sort;
    recent-observations reads from stream_items.
  - mission-control-fleet-cli-capability.test.ts (7 cases): 4
    sub-clauses of graceful degradation including once-per-session-
    per-rig log dedup + per-rig honesty + queue-state activity summary.
  - mission-control-routes.test.ts (10 cases): 7 endpoint round-trips
    + R1 SSE route-order regression + literal /views not shadowed.

Verification:
  - tsc clean for all 3 packages (daemon, cli, ui).
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2603 / 2603 pass (was 2561 at base; +42 new
      Mission Control daemon tests).
  - UI test suite has 26 pre-existing failures from PostCSS/Tailwind
    module resolution infra in this fresh worktree; CONFIRMED identical
    at HEAD baseline (d15ca1f) via git stash + re-run before commit
    (8 failed / 26 failed test files / 273 passed both with and
    without Mission Control changes). Not a Mission Control regression;
    documented as a worktree node_modules infrastructure observation.
  - daemon + cli builds clean. UI vite build also fails on the same
    PostCSS infra issue (tsc step succeeds; vite step fails). Same
    pre-existing infra artifact, not a Mission Control regression.

Boundaries preserved:
  - Worktree-only on phase-a-mission-control-queue-observability;
    main untouched at d15ca1f.
  - PL-004 surfaces consumed READ-ONLY beyond the orch-ratified
    extension points (Phase D's QueueRepository.updateWithinTransaction
    +createWithinTransaction + maybeNudge visibility, all of which
    were added in PL-004 Phase D R1 and are already on main).
  - POC services/dashboard filesystem state untouched (vocabulary
    reference only; no migration/cutover ceremony).
  - POC ~/.openrig/stream/<date>.jsonl untouched (read-only).
  - PL-006 steering controls absent.
  - No new managed app; no native mobile; no push notifications.
  - No new CLI commands (Mission Control is product UI inside existing
    shell).
  - localhost-only auth at v0 (matches existing dashboard posture).
  - Existing 36 PL-004 migrations untouched. Phase A adds ONE new
    migration 037.
  - No fleet sweep, no protected-host mutation, no external spend, no
    destructive lifecycle, no VM-boundary actions, no remote push, no
    tag, no publish, no live runtime adoption.

CLI shim drift caveat preserved: installed rig reports 0.2.0 but some
command help/output still carries legacy rigx wrapper behavior while
daemon APIs are green. Mission Control consumes daemon APIs directly,
so this caveat does not affect the Phase A surface, but is recorded
per dispatch packet instruction.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 18:35:08 -07:00
mvschwarz d15ca1fe05 docs: update post-release references 2026-05-03 16:39:10 -07:00
mvschwarzandClaude Opus 4.7 3b1205f3be Release 0.2.0
See docs/releases/v0.2.0.md for the full release narrative.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 15:07:05 -07:00
mvschwarz e40214f0f8 fix: include workflow keepalive in watchdog help 2026-05-03 13:11:02 -07:00
mvschwarz 90b177242f fix: infer queue actor from managed session 2026-05-03 13:04:26 -07:00
mvschwarz a536df3130 fix: preserve cli bin executable bit on build 2026-05-03 12:52:42 -07:00
mvschwarz cff08c7e93 test: make chatroom wait fixture time-stable 2026-05-03 12:43:07 -07:00
mvschwarz fe5b23e088 fix: propagate daemon env to launched seats 2026-05-03 12:41:44 -07:00
mvschwarz 934f3c9d63 fix: launch codex seats with full access posture 2026-05-03 12:17:50 -07:00
mvschwarz 7a0ae0fa81 chore: mark PL-004 Phase D checkout recovery 2026-05-03 10:38:48 -07:00
mvschwarzandClaude Opus 4.7 05ca19c11f fix(daemon): PL-004 Phase D R4 — workflow_step_trails listForInstance ordered by rowid (deterministic insertion order)
Repairs QA-found nondeterminism on R3 commit 3e32b0a flagged in:
shared-docs/openrig-work/missions/recursive-self-improvement-v2/dogfood-evidence/
pl004-major-release-battle-test-3e32b0a-20260503T160531Z/
PL004-major-release-battle-test-QA-packet.md

Root cause: WorkflowStepTrailLog.listForInstance() ordered same-
timestamp closed rows by trail_id DESC. trail_id is a ULID; ULIDs
encode time in their leading bytes but the trailing random component
is independent across two ULIDs generated in the same millisecond.
That makes ULID DESC ordering non-deterministic when closed_at ties,
which the R2 resume regression hit on fast machines (3 close events
within the same ms produced unstable trail[0] reads).

Fix (one line; ports the QA-applied dirty patch verbatim):
  workflow-step-trails ORDER BY closed_at DESC, rowid DESC

SQLite implicit rowid is monotonically assigned in INSERT order on
tables that don't declare WITHOUT ROWID. Migration
035_workflow_step_trails uses an unmodified TEXT PRIMARY KEY, so the
implicit rowid column is present and stable. rowid DESC is therefore
deterministic insertion-order.

Verification:
  - Audit: only file dirty in worktree was the QA-applied
    workflow-step-trail-log.ts ordering change. No other source diff.
  - Tight loop: 30/30 runs of "R2 resume regression: handoff → waiting
    on step N → done on same qitem closes step N (not N+1)" passed
    with explicit OPENRIG_HOME + OPENRIG_DB=:memory: temp env.
    Pre-fix, this test was QA-flagged as nondeterministic.
  - Focused suites: workflow-projector (14), workflow-routes (13),
    workflow-cross-loop-three-step (1), workflow-step-trail-log (7) —
    all 35/35 pass.
  - Full daemon vitest baseline: 2560 / 2560 pass.
  - Per-package CLI watchdog + workflow tests: 15 / 15.
  - tsc clean for both packages.

Boundaries preserved (R4):
  - Worktree-only on phase-d-coordination-primitive-system; main untouched.
  - Smallest-honest-patch: 1 line (the QA-applied dirty fix verbatim).
    No other surface touched.
  - No-touch surfaces unchanged: Phase A non-extension queue surfaces
    beyond the orch-ratified Phase D extension points, Phase B
    project/classifier/view, Phase C beyond ratified extension points,
    Lifecycle v2, migrations 001-033 + 035-036, POC source.
  - No primary checkout used as proof source.
  - No cleanup, adoption, CAS promotion, reset, checkout, clean, push,
    tag, publish, or Lifecycle handoff performed.
  - No production DB, host daemon, live topology, provider credentials,
    or preserved VM clone touched.

VM blocker noted but out of scope for this commit. Per dispatch packet:
release closeout remains blocked until both this source repair is guard-
accepted AND the required disposable VM proof is completed (or VM/
operator blocker accepted by orch).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 09:28:03 -07:00
mvschwarzandClaude Opus 4.7 3e32b0a80c fix(daemon): PL-004 Phase D R3 — map exit_not_allowed to HTTP 400 + route regression
Repairs guard BLOCK on R2 commit 1c94719:

R2 added the WorkflowProjectorError(exit_not_allowed) domain error but
the workflow routes errorResponse fall-through left the new code
mapping to HTTP 500 internal-server-error, dishonest at the public
surface (HTTP API + rig workflow project CLI).

R3 fix:

  - routes/workflow.ts errorResponse: add err.code === "exit_not_allowed"
    → HTTP 400. Structured details (stepId, attemptedExit, allowedExits)
    are already preserved by the existing
    `{...(err.details ?? {})}` spread.

R3 regression added in workflow-routes.test.ts:

  - "POST /project surfaces exit_not_allowed as 400 with structured
    details + no side effects": instantiates a workflow on the produce
    step (allowed_exits=[handoff]), captures pre-state via the public
    surface, attempts exit=done, asserts:
    - res.status === 400 (NOT 500)
    - body.error === "exit_not_allowed" (NOT "internal_error")
    - body.message contains "produce"
    - body.stepId === "produce"
    - body.attemptedExit === "done"
    - body.allowedExits === ["handoff"]
    - Post-rejection instance state via GET /:instance_id matches
      pre-state (currentFrontier, currentStepId, status all unchanged)
    - GET /:instance_id/trace returns trail === []

Verification:
  - tsc clean for daemon and cli.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2560 / 2560 pass (was 2559 in Phase D R2; +1
      R3 route regression).
    - packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
      CLI failures unchanged.

Boundaries preserved (R3):
  - Worktree-only on phase-d-coordination-primitive-system; main untouched.
  - Smallest-honest-patch: 1 line added to error mapping + 1 test added.
    No domain change; no migration change; no other surface touched.
  - No fleet sweep, no protected-host mutation, no external spend, no
    destructive lifecycle, no VM-boundary actions, no remote push, no
    tag, no publish.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 08:22:19 -07:00
mvschwarzandClaude Opus 4.7 1c94719b11 fix(daemon): PL-004 Phase D R2 — durable current-step binding + allowed_exits enforcement at projection
Repairs guard BLOCK on R1 commit bf28cc2:

Blocker 1 — waiting/resume semantics still wrong (trail-based inference):

R1 preserved the closed packet on currentFrontier when exit=waiting,
but the next project() call inferred current step from "last trail row
+ 1," skipping the parked step. POC contract is to extract step_id
from the current packet binding, not from trail order.

R2 fix — durable current_step_id binding on workflow_instances:

  - Migration 034_workflow_instances extended IN PLACE with
    current_step_id TEXT column. (In-place because Phase D has not
    been promoted past base 1a3b313; same pattern guard accepted for
    Phase C R1's actionable/last_actionable_at columns on 031.)
  - WorkflowInstance.currentStepId field; rowToInstance reads it.
  - WorkflowInstanceStore.create() accepts currentStepId; updateFrontier
    accepts currentStepId of "preserve" | "clear" | <stepId>.
  - WorkflowRuntime.instantiate() sets current_step_id to the entry
    step at instance creation time.
  - WorkflowProjector.project() replaces inferCurrentStep (trail-based)
    with resolveCurrentStep (reads instance.currentStepId). Falls back
    to entry step ONLY when current_step_id is null (defense-in-depth).
  - Frontier-update transitions:
      handoff → current_step_id = nextStep.id
      waiting → current_step_id preserved (resume on same packet
                still on same step)
      done / failed → current_step_id cleared (terminal)

Resume regression added (canonical "drive→park→resume on same id" per
skill lesson feedback_resume_after_parked_state_regression):

  - "R2 resume regression: handoff → waiting on step N → done on same
    qitem closes step N (not N+1)": instantiate three-step workflow,
    handoff produce → review, waiting on review qitem (status=waiting,
    current_step_id="review", frontier preserves review qitem), then
    done on the SAME review qitem. Asserts: trail's last entry has
    stepId="review" (NOT "finalize"); workflow ends as completed
    because no successor projected; NO trail row written for "finalize"
    (would have indicated a step skip).

Blocker 2 — projector did not enforce step.allowed_exits at runtime:

R1 left allowed_exits as a validation-only decoration. The R1 done test
encoded the wrong behavior (projecting exit=done on a step that only
allows handoff and asserting closure). R2 makes allowed_exits a runtime
contract that fires BEFORE any side effect.

R2 fix:

  - WorkflowProjector.project() validates input.exit ∈
    currentStep.allowed_exits when the step declares allowed_exits.
    Throws WorkflowProjectorError {code: "exit_not_allowed",
    details: {stepId, attemptedExit, allowedExits}} BEFORE queue close,
    trail append, frontier update, or event persist. No side effects
    on rejection.

R2 regression tests added:

  - "R2 allowed_exits enforcement (positive): exit included in
    step.allowed_exits succeeds": handoff on a step that allows handoff
    succeeds and returns the next step.
  - "R2 allowed_exits enforcement (negative): disallowed exit throws
    exit_not_allowed with NO side effects": done on a step that allows
    only handoff throws WorkflowProjectorError(exit_not_allowed) with
    structured details; queue state, instance frontier, current_step_id,
    trail count, and event count are ALL unchanged after the rejection.

Test fixture changes:

  - Added PERMISSIVE_SPEC fixture for closure-shape unit tests
    (project(done), project(waiting), project(failed), instance-not-active
    rejection). The main 3-step SPEC has allowed_exits=[handoff] on the
    entry step; tests that deliberately exercise non-handoff closures on
    the entry packet now use PERMISSIVE_SPEC's "act" step which allows
    all four exits. R1 tests that incorrectly assumed the produce step
    accepted any exit are corrected.

Skill lesson filed at memory/feedback_resume_after_parked_state_regression.md
per guard's call-out: every preservation repair (waiting, blocked, parked,
restore-resumed) needs a "drive→park→resume on same id" regression that
asserts the resumed action targets the same logical entity that was
parked, not an inferred-from-side-effect successor.

Verification:
  - tsc clean for daemon and cli.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2559 / 2559 pass (was 2556 in Phase D R1; +3
      R2 regressions: resume + allowed_exits positive + allowed_exits
      negative).
    - packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
      CLI failures unchanged.

Boundaries preserved (R2):
  - Worktree-only on phase-d-coordination-primitive-system; main untouched.
  - Migration 034 extended in place (Phase D unpromoted; matches
    accepted Phase C R1 in-place 031 extension pattern).
  - No-touch surfaces unchanged: Phase A non-extension queue surfaces
    beyond the orch-ratified Phase D extension points, Phase B
    project/classifier/view, Lifecycle v2, migrations 001-033, 035-036,
    POC source.
  - workflow-keepalive still reads ONLY from SQLite.
  - Workflow runtime PROJECTS on closure; does NOT GATE closure (Phase A
    hot-potato strict-rejection remains the closure authority).
  - allowed_exits enforcement at projection complements (does not
    replace) the validator's spec-level allowed_exits check.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 08:13:57 -07:00
mvschwarzandClaude Opus 4.7 bf28cc2f7e fix(daemon): PL-004 Phase D R1 — workflow projection routes through Phase A queue closure primitive + waiting frontier preservation + failed status
Repairs guard BLOCK on R0 commit 55bdcbb:

Blocker 1 — projection bypassed Phase A queue closure/audit contract:

R0's WorkflowProjector.project() did a direct UPDATE on queue_items
that skipped closure_reason / closure_target / handed_off_to / blocked_on
metadata, queue_transitions append, and queue.updated event. R1 routes
all closure through a new Phase A primitive:

  - QueueRepository.updateWithinTransaction(): same closure validation +
    UPDATE + transition log + queue.updated event as update(), but
    composes inside the caller's outer db.transaction (caller responsible
    for post-commit notifySubscribers). update() is now factored through
    a private updateInTransactionalContext() helper; behavior unchanged
    for existing callers.
  - QueueUpdateInput.handedOffTo + .blockedOn: optional new fields that
    persist the canonical pointer columns. update SQL extended with
    COALESCE for both. Backward-compatible with all existing callers.
  - workflowExitToQueueClosure() helper translates each workflow exit
    into the Phase A queue closure shape:
      handoff → state=handed-off, closure_reason=handed_off_to,
                closure_target+handed_off_to=<next-owner>
      waiting → state=blocked, closure_reason=blocked_on,
                closure_target+blocked_on=<blocker>
      done    → state=done, closure_reason=no-follow-on
      failed  → state=done, closure_reason=denied
  - WorkflowProjector.project() now resolves the next owner BEFORE
    closure (so closure_target is set), calls
    queueRepo.updateWithinTransaction(), then optionally
    queueRepo.createWithinTransaction(). All inside one outer
    db.transaction. Phase A hot-potato strict-rejection invariants
    (state=done requires closure_reason; handed_off_to/blocked_on/
    escalation also require closure_target) apply unchanged — workflow
    projection cannot land state=done without a valid closure_reason.

Blocker 2a — exit=failed treated as completed:

R0 had a frontier-empty branch that always set status=completed.
R1 makes exit=failed a distinct terminal state:
  - status=failed, completedAt stamped
  - emits workflow.failed (NOT workflow.completed)
  - failed event carries the reason from input.resultNote
  - workflow.failed and workflow.completed are now mutually exclusive

Blocker 2b — waiting frontier bug:

R0 removed the closed packet from currentFrontier even on exit=waiting.
That broke workflow-keepalive's owner resolution: a single-packet
waiting workflow had no owner to wake. R1 PRESERVES the closed packet
on currentFrontier when exit=waiting; workflow-keepalive can still
resolve the blocked owner via Phase A queue_items destination.

R1 regression tests (4 new in workflow-projector.test.ts):

  - "project(handoff) persists Phase A queue closure metadata + transition
    row + queue.updated event": asserts closure_reason=handed_off_to,
    closure_target=<next-owner>, handed_off_to=<next-owner>; queue_transitions
    row with closure_reason; queue.updated event emitted.
  - "project(done) without exposed closure overrides defaults closure_reason
    to no-follow-on": asserts default closure_reason path.
  - "project(waiting) uses Phase A blocked state + closure_reason=blocked_on
    + blocked_on column": asserts blocked-state Phase A shape.
  - "project(failed) sets workflow status=failed AND emits workflow.failed
    (NOT completed)": asserts the formerly-merged failed-vs-completed split.

The existing waiting frontier test renamed + actually asserts
currentFrontier preservation:

  - "project(waiting) sets status=waiting AND preserves the closed packet
    on currentFrontier (workflow-keepalive resolvability)": asserts
    projected.instance.currentFrontier still contains the closed packet
    AND re-fetched instance state agrees.

Verification:
  - tsc clean for daemon and cli.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2556 / 2556 pass (was 2552 in Phase D R0; +4
      R1 regressions in workflow-projector.test.ts).
    - packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
      CLI failures unchanged from prior baseline.

Boundaries preserved (R1):
  - Worktree-only on phase-d-coordination-primitive-system; main untouched.
  - QueueRepository.updateWithinTransaction is a NEW method; existing
    update() unchanged (factored through new private helper).
  - QueueUpdateInput optional fields handedOffTo + blockedOn are
    additive; existing callers unaffected.
  - No-touch surfaces unchanged: Phase A non-extension queue surfaces
    beyond the orch-ratified Phase D extension points (createWithinTransaction
    + updateWithinTransaction + maybeNudge visibility), Phase B
    project/classifier/view, Lifecycle v2, migrations 001-032,
    POC source. Phase C already-extended surfaces unchanged from R0.
  - No fleet sweep, no protected-host mutation, no external spend, no
    destructive lifecycle, no VM-boundary actions, no remote push,
    no tag, no publish.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 07:57:37 -07:00
mvschwarzandClaude Opus 4.7 55bdcbb73c feat(daemon,cli): PL-004 Phase D — daemon-native Workflow Runtime + workflow-keepalive
Adds the Workflow Runtime primitive from PL-004 Phase D:

Migrations (033-036):
  - 033_workflow_specs: cached read-through from markdown/YAML spec
    files. UNIQUE on (name, version); source_hash content-invalidation;
    workspace-surface reconciliation (operator edits win at next read).
  - 034_workflow_instances: live instance state. status enum
    (active/waiting/completed/failed); current_frontier_json; hop_count;
    last_continuation_decision_json. Survives daemon restart from SQLite.
  - 035_workflow_step_trails: append-only history. FK to workflow_instances
    and queue_items (next_qitem_id, prior_qitem_id). Append-only at
    API surface (no update/delete methods).
  - 036_watchdog_policy_enum_extension: documenting no-op. Phase C uses
    application-layer enum enforcement (PHASE_D_POLICIES array), so
    extending the enum to include workflow-keepalive needs no DDL.
    Migration exists for audit-trail integrity per IMPL row 17.

Domain (8 new files; load-bearing transactional-scribe):
  - WorkflowSpecCache: read-through cache via the `yaml` package.
    Re-caches in place on hash miss (UPDATE same spec_id).
  - WorkflowValidator: role resolution + step uniqueness + allowed-exits
    + optional seat liveness. Returns structured ValidationResult.
  - WorkflowInstanceStore: CRUD on workflow_instances; updateFrontier
    composes inside outer caller-managed transactions.
  - WorkflowStepTrailLog: append-only writer; pattern mirrors Phase A
    queue-transition-log + Phase C watchdog-history-log.
  - WorkflowProjector: LOAD-BEARING. Implements transactional-scribe.
    A single db.transaction holds: closing the current packet, creating
    the next-step packet (via Phase A QueueRepository extension point),
    appending the trail entry, updating instance frontier+status, and
    persisting workflow events. Either everything commits or everything
    rolls back. Lost handoffs are impossible by design.
  - WorkflowRuntime: facade over the above; operations validate +
    instantiate (creates instance + entry qitem in one transaction) +
    project (delegates to projector) + continue (idempotent inspector).
  - policies/workflow-keepalive: TypeScript port of POC
    `lib/policies/workflow-keepalive.mjs`. SOURCE-OF-TRUTH SWITCHED
    from markdown to SQLite per audit row 18 (LOAD-BEARING). Reads
    workflow_instances directly; resolves frontier owners via queue_items.

Phase A surface extension (orch-ratified per IMPL § Driver Handoff):
  - QueueRepository.createWithinTransaction(): smallest-honest extension
    that lets workflow-projector compose queue creation inside its outer
    transaction without breaking notify/nudge post-commit semantics.
    create() now factored through createInTransactionalContext() helper;
    behavior unchanged for existing callers.
  - QueueRepository.maybeNudge(): visibility raised to public so
    workflow-projector can fire nudges after its outer transaction
    commits.

Phase C surface extension (orch-ratified per IMPL § Write Set):
  - WatchdogJobsRepository: PHASE_C_POLICIES → PHASE_D_POLICIES (extends
    enum with workflow-keepalive); policy_deferred_to_phase_d rejection
    removed. PHASE_C_POLICIES retained as deprecated alias.
  - WatchdogPolicyEngine: instance-level policy registry +
    additionalPolicies in deps (was static module-level Map). Allows
    workflow-keepalive registration at startup with db handle injected.

Phase D events (6 new RigEvent types):
  - workflow.instantiated, workflow.step_closed,
    workflow.next_qitem_projected, workflow.completed,
    workflow.failed, workflow.routing_table_changed
  - 20 existing PL-004 events untouched.

Routes (1 new file, route-order discipline preserved):
  - POST /api/workflow/{validate,instantiate,project}
  - GET /api/workflow/list
  - GET /api/workflow/sse + /watch (mounted BEFORE /:instance_id)
  - GET /api/workflow/:instance_id and :/:instance_id/trace
  - POST /api/workflow/:instance_id/continue

CLI:
  - rig workflow validate/instantiate/project/list/show/trace/continue
  - 7 subcommands; matches POC `bin/rig-workflow` shape.

Wiring:
  - startup.ts: WorkflowRuntime constructed from queueRepo + db + bus;
    workflow-keepalive policy injected into WatchdogPolicyEngine via
    additionalPolicies. Phase D services start with daemon; recover
    from SQLite on restart.

Test changes:
  - 8 new daemon test files (~67 new test cases): workflow-spec-cache
    (9), workflow-validator (9), workflow-instance-store (7),
    workflow-step-trail-log (7), workflow-projector (7 incl. transactional-
    scribe rollback regression), workflow-keepalive-policy (8 incl.
    SQLite-source-only LOAD-BEARING assertion), workflow-routes (12 incl.
    SSE route-order regression guard), workflow-cross-loop-three-step (1
    PRD acceptance criteria load-bearing case).
  - 1 new CLI test file: workflow.test.ts (8 cases).
  - Phase C registration-rejection assertions REPLACED with positive
    registration-accept (the orch-ratified Phase C test surface change):
    watchdog-jobs-repository.test.ts, watchdog-routes.test.ts,
    cli/test/watchdog.test.ts.

Verification:
  - tsc clean for daemon and cli.
  - Per-package vitest baseline under explicit OPENRIG_HOME +
    OPENRIG_DB=:memory: temp env:
    - packages/daemon: 2552 / 2552 pass (was 2492 in Phase C R2; +60
      new workflow tests).
    - packages/cli: 15 / 15 watchdog + workflow tests pass; pre-existing
      CLI failures (host-registry, config, specs, preflight, client
      --version) unchanged from Phase C R2 baseline.

Boundaries preserved:
  - Worktree-only on phase-d-coordination-primitive-system; main
    untouched at 1a3b313 (Phase C R2).
  - No-touch surfaces unchanged: Phase A stream/queue/inbox/outbox/
    transitions tables + non-extension repository methods, Phase B
    project/classifier/view, Lifecycle v2, migrations 001-032,
    POC workflow-runtime-v0/, POC services/watchdog/lib/policies/
    workflow-keepalive.mjs, progress-rail (Phase E remains
    markdown-authoritative for v1).
  - Workflow runtime PROJECTS on closure; does NOT GATE closure
    (Phase A hot-potato strict-rejection remains the closure authority).
  - One-hop continuity is the load-bearing default; multi-hop chaining
    deferred (PRD § Risks "Workflow runtime over-engineered").
  - workflow-keepalive reads ONLY from SQLite (audit row 18 LOAD-BEARING).
  - No fleet sweep, no protected-host mutation, no external spend, no
    destructive lifecycle, no VM-boundary actions, no remote push, no
    tag, no publish, no live runtime adoption (per human steering
    2026-05-03 for the Coordination Primitive feature).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 07:29:57 -07:00
mvschwarzandClaude Opus 4.7 1a3b313f0d fix(daemon): PL-004 Phase C R2 — yaml-package frontmatter parser + discriminating scan_interval scheduler test
Repairs guard BLOCK on R1 commit dd9820e:

Blocker 1 (discriminating scheduler regression for scan_interval_seconds):

R1 added the right isDue() shape but proved it only with an engine-only
active-wake test that used equal interval values (interval=30,
scan=30). A scheduler regression to interval_seconds-only would have
passed it. R2 adds four discriminating tests in
packages/daemon/test/watchdog-scheduler.test.ts:

- isDue uses scan_interval_seconds (=30) over interval_seconds (=600):
  due after 45s elapsed.
- isDue uses scan_interval_seconds (=600) over interval_seconds (=30):
  NOT due after 45s elapsed.
- runTickNow does NOT invoke policy nor write history during the
  scan_interval_seconds not-due window (drives the scheduler boundary,
  asserts deliver callback never called AND no history row written AND
  last_evaluation_at unchanged).
- runTickNow DOES invoke policy when scan_interval_seconds elapsed even
  with large interval_seconds (the inverse: confirms scan_interval can
  be smaller than interval and still trigger).

Lesson filed at memory/feedback_poc_regression_must_discriminate.md:
when porting POC regressions, preserve discriminating param values and
exercise the layer under review.

Blocker 2 (artifact-pool YAML/frontmatter parity):

R1 used a local key/value parser that treated any unquoted colon in a
value as malformed. Real frontmatter contains ISO timestamps
(claimed_at: 2026-05-03T05:15:25Z) and URLs
(source_url: https://...) that would be wrongly excluded. R2 swaps
in the `yaml` package (already a daemon dep) so:

- Valid YAML scalars containing colons parse cleanly and the artifact
  is included.
- Only true YAML parse failures (broken: value: still broken) cause
  exclusion when include_malformed_frontmatter=false.
- ScannedArtifact.frontmatter widened from Record<string, string> to
  Record<string, unknown> because YAML loaders return heterogeneous
  scalars (string, Date, number, etc.).
- statusFromFrontmatter() coerces non-string status scalars (Date,
  number) to string so include_statuses comparisons stay stable.
- sourceKeyFor() coerces non-string key_field scalars similarly.

Three new POC-parity regression tests in
packages/daemon/test/watchdog-policies.test.ts:

- includes ready artifacts with ISO timestamp frontmatter.
- includes ready artifacts with URL frontmatter.
- includes ready artifacts with quoted-string frontmatter containing colons.

Plus the existing malformed-frontmatter exclusion test (broken: value:
still broken) continues to pass: that fixture is genuine invalid YAML.

Verification:
   - tsc clean for daemon and cli.
   - Per-package vitest baseline under explicit OPENRIG_HOME +
     OPENRIG_DB=:memory: temp env:
     - packages/daemon: 2492 / 2492 pass (was 2485 in R1; +7 new
       watchdog tests for R2: 4 scheduler + 3 YAML-parity).
     - packages/cli: 7 / 7 watchdog tests pass.

Boundaries preserved (R2):
   - Worktree-only on phase-c-coordination-primitive-system; main untouched.
   - No-touch surfaces unchanged: Phase A/B/Lifecycle/migrations 001-030/
     workflow runtime/progress-rail/POC services-watchdog state.
   - workflow-keepalive still rejected at register with policy_deferred_to_phase_d.
   - No fleet sweep, no protected-host mutation, no external spend, no
     destructive lifecycle, no VM-boundary actions, no remote push, no
     tag, no publish.
   - Three policy enum values unchanged: periodic-reminder,
     artifact-pool-ready, edge-artifact-required.
   - R1 in-place migration extension (actionable + last_actionable_at on
     031) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 00:53:16 -07:00
mvschwarzandClaude Opus 4.7 dd9820eb2d fix(daemon): PL-004 Phase C R1 — POC contract parity for watchdog policies + active-wake throttle
Repairs guard BLOCK on commit 48c0267:

1. scan_interval_seconds vs active_wake_interval_seconds (blocker 1):
   - Scheduler isDue uses scan_interval_seconds when set, falling back
     to interval_seconds. Mirrors POC engine.mjs:38-46.
   - Policy engine enforces active_wake_interval_seconds as a re-delivery
     throttle: when state.actionable was already true AND wake-window
     not elapsed since last_fire_at, returns quiet skip with reason
     active_wake_not_due (does NOT call delivery, preserves last_fire_at).
     Mirrors POC engine.mjs:49-64 and :243-263.
   - New columns on watchdog_jobs: actionable INTEGER NOT NULL DEFAULT 0,
     last_actionable_at TEXT. Engine sets these on every meaningful
     evaluation; clears on skip; preserves last_actionable_at across
     continued-actionable windows. Migration extended in-place since
     031 has not yet been promoted past Phase B HEAD.
   - Ported POC active-wake regression (tests/watchdog-active-wake-interval.test.sh)
     into daemon test as a 6-tick state-machine regression.

2. edge-artifact-required POC contract (blocker 2):
   - Spec uses context.source / context.target (singular pool specs)
     not source_pools / target_pools. Each may have path or paths.
   - Edge-satisfaction is body-match: a downstream artifact satisfies
     a source iff target.raw INCLUDES the source key. NOT a frontmatter
     field match. Mirrors POC edge-artifact-required.mjs:11-12.
   - Target scan overrides include_statuses to [] (POC override) so any
     downstream state counts as 'exists'.
   - Delivery target is job.target.session (top-level), not from context.
   - Label key is context.edge_label (POC parity).
   - Daemon test ports the POC pilot-a body-reference scenario.

3. Artifact-pool scanner POC parity (blocker 3):
   - Default ignores README.md and .DS_Store always applied.
   - Configured pool.ignore_names extends default ignores.
   - pool.recursive=true descends into subdirectories.
   - Malformed-frontmatter excluded unless pool.include_malformed_frontmatter.
   - Raw content preserved on every artifact (used by edge-artifact-required
     body-match).
   - ENOENT-tolerant pool path.
   - artifact-pool-ready and artifact-pool-helpers tests cover all 5 POC
     scanner behaviors.

Engine state-machine alignment to POC:
   - PolicyJob shape uses {target: {session}, message?, context}. Engine
     parses spec_yaml top-level target + message blocks and falls back to
     {session: registered targetSession} when spec lacks target.
   - Policies all read job.target.session for delivery target and
     job.message OR context.message for periodic-reminder.
   - PolicyEvaluation send action returns target as object {session: ...}
     to match POC contract (engine extracts session string for delivery).
   - QUIET_SKIP_REASONS set (not_due, no_actionable_artifacts,
     no_missing_edge_artifacts, active_wake_not_due) suppresses both
     history record AND event emit. Loud skips still record + emit.
     Mirrors POC shouldAppendHistory (engine.mjs:99-112).

Verification:
   - tsc clean for daemon and cli.
   - Per-package vitest baseline under explicit OPENRIG_HOME +
     OPENRIG_DB=:memory: temp env:
     - packages/daemon: 2485 / 2485 pass (was 2471; +14 new watchdog
       tests covering POC contract + active-wake state machine).
     - packages/cli: 7 / 7 watchdog tests pass.

Boundaries preserved:
   - Worktree-only on phase-c-coordination-primitive-system branch;
     main untouched.
   - No-touch surfaces (Phase A stream/queue/hot-potato, Phase B
     project/classifier/view, Lifecycle v2, migrations 001-030, workflow
     runtime, progress-rail code, POC services/watchdog state) unchanged.
   - workflow-keepalive still rejected at register with
     policy_deferred_to_phase_d; not added as a product policy module.
   - No fleet sweep, no protected-host mutation, no external spend, no
     destructive lifecycle, no VM-boundary actions.

Docs updated in architecture.md and cli-reference.md to reflect the
quiet-skip parity + scan/wake cadence semantics. codemap entries from
48c0267 still describe the same files.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 00:33:37 -07:00
mvschwarzandClaude Opus 4.7 48c0267615 feat(daemon,cli): PL-004 Phase C — daemon-native Watchdog supervision tree
Adds the Watchdog primitive from PL-004 Phase C:

Migrations (031, 032):
  - 031_watchdog_jobs: canonical scheduler state (policy enum + intervals
    + lifecycle). Three v1 policies: periodic-reminder, artifact-pool-ready,
    edge-artifact-required. workflow-keepalive is intentionally rejected
    at registration with policy_deferred_to_phase_d (ships in Phase D).
  - 032_watchdog_history: append-only audit. Records sent / skipped /
    terminal outcomes only; pure not_due polls are not recorded.

Domain (TypeScript ports of POC .mjs policies preserving observable semantics):
  - WatchdogJobsRepository (CRUD + register-time enum gate + lifecycle)
  - WatchdogHistoryLog (append-only writer; no update/delete API surface)
  - WatchdogPolicyEngine (resolve policy by name, parse spec_yaml context,
    dispatch evaluate, record meaningful outcomes, emit events, call
    injected delivery for action=send)
  - WatchdogScheduler (tick-driven daemon-native scheduler joining the
    supervision tree; isDue gate; sequential per-tick evaluation;
    graceful in-flight await on stop; SQLite-canonical restart recovery)
  - 3 policy modules + shared artifact-pool helpers

Routes (per Phase A R1 SSE route-order discipline; SSE before /:job_id):
  - POST /api/watchdog/register
  - GET /api/watchdog/list
  - GET /api/watchdog/sse + /watch
  - GET /api/watchdog/:job_id and :/:job_id/status
  - POST /api/watchdog/:job_id/stop

5 new RigEvents:
  - watchdog.evaluation_fired / _skipped / _terminal
  - watchdog.job_registered / _stopped

CLI:
  - rig watchdog register/list/show/status/stop

Wiring:
  - startup.ts constructs repo + log early, engine + scheduler after
    SessionTransport is available (delivery is wired via SessionTransport.send)
  - index.ts starts the scheduler post-listen and stops it on SIGINT/SIGTERM

Tests: 62 daemon tests + 7 CLI tests covering jobs-repo, history-log
append-only contract, three-policy semantics, engine dispatch+history+events,
scheduler dueness/restart-recovery/error-resilience, routes including SSE
route-order regression guard, CLI subcommand registration + happy paths +
workflow-keepalive Phase D rejection surfacing. Full daemon vitest baseline:
2471/2471 pass; CLI watchdog tests 7/7 pass; pre-existing CLI failures
(host-registry/config/specs/preflight/client/chatroom) confirmed present
on Phase B HEAD pre-Phase-C and unrelated to this change.

workflow-keepalive deferred to Phase D paired with workflow_instances.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 00:10:25 -07:00
mvschwarzandClaude Opus 4.7 629d061325 fix(daemon): PL-004 Phase B R2 — queue.updated event + bridge mapping for general state mutations
Closes guard BLOCK on Phase B R1 at openrig 9f86df4 per qitem-20260503045232-1b836fbc + guard artifact pl004-phase-b-r1-9f86df4-guard-review-block-20260503.md.

Guard accepted R1 BLOCKER 1 (L1→L2 FK + existence check). Remaining R2 BLOCKER: view.changed producer coverage for normal queue update mutations.

==== ROOT CAUSE ====

R1 view-event-bridge mapped queue.created / queue.handed_off / queue.claimed / queue.unclaimed / qitem.fallback_routed / qitem.closure_overdue / inbox.absorbed / inbox.denied / project.classified to view.changed. But QueueRepository.update() — the general state mutator backing POST /api/queue/:qitemId/update — emitted NO event, so common transitions (pending → blocked, in-progress → done, closure, escalation) never reached the bridge. SSE consumers on /api/views/:name/sse missed all update-path mutations even though those mutations change recently-active, founder, pod-load, escalations, held, and activity result-sets.

==== R2 FIX (smallest event-only Phase A surface extension) ====

Per slice IMPL § Guard Checkpoint Focus item 9: "Any additive read helper in `stream-store.ts` or `queue-repository.ts` is explicitly called out, justified, and proven not to alter Phase A write semantics." This R2 commit is an ADDITIVE event-emission extension (NOT semantic change) to QueueRepository.update():

1. types.ts: added `queue.updated` to RigEvent discriminated union with fields: qitemId, fromState, toState, closureReason, closureTarget, actorSession.

2. queue-repository.ts update(): added ONE persistWithinTransaction call inside the existing db.transaction() that already runs the UPDATE + transition log append. The Phase A write semantics (UPDATE + transition log) are UNCHANGED — only an additional event emission inside the same atomic transaction. Captured fromState BEFORE the UPDATE (from already-fetched qitem.state). Caller pattern matches Phase A's create() / handoff() / claim() / unclaim() / routeToFallback() (capture persisted event from txn return, notifySubscribers after txn commits).

3. view-event-bridge.ts: added `queue.updated` to EVENT_TO_VIEWS mapping with ALL 6 built-in views (recently-active, founder, pod-load, escalations, held, activity). Conservative: queue.updated may affect any state-derived view (done removes from recently-active + pod-load; blocked adds to held; closure_reason='escalation' adds to escalations; ts_updated change reorders activity), so emit for all six. The fail-fast wire-time validator already protects against unknown view names.

==== TESTS (5 NEW; R2 baseline +5) ====

queue-repository.test.ts (2 NEW):
- "R2: update emits queue.updated event with fromState + toState + closure metadata" — pending → in-progress → done transition; assert queue.updated event with fromState=in-progress, toState=done, closureReason=no-follow-on, actorSession=bob@rig.
- "R2: update emits queue.updated for blocked transition (fromState=pending, toState=blocked)" — pending → blocked transition; assert event captures both states.

view-event-bridge.test.ts (2 NEW):
- "R2: queue.updated triggers view.changed for ALL 6 built-in views" — assert all 6 view names emitted with cause=queue.updated.
- "R2: queue.updated for pending → blocked transition triggers view.changed for held + activity (and others)" — semantic check that held + activity are reachable.

views-routes.test.ts (1 NEW):
- "R2 BLOCKER: queue.update mutation triggers view.changed (cause=queue.updated) visible to SSE consumer" — wires bridge, pre-creates + claims a qitem, subscribes to /api/views/recently-active/sse, runs queue.update (in-progress → done), reads SSE body via reader.read() with deadline, asserts view.changed event with cause="queue.updated" observed in stream payload before timeout.

==== RE-VERIFICATION COMMANDS (all green; node v22.22.1) ====

Focused (under explicit temp-rooted OPENRIG_HOME + OPENRIG_DB):
- daemon: OPENRIG_HOME=/tmp/pl004-phase-b-r2-d OPENRIG_DB=/tmp/pl004-phase-b-r2-d/openrig.sqlite npx vitest run [14 daemon focused test files including R2 additions] → 168/168 PASS (Phase A 89 + Phase B R0+R1 baseline 70 + R2 additions = 168).
- daemon tsc + build: exit 0.

Honest per-package baselines (NO env override):
- daemon: 2409/2409 PASS / 0 FAIL (Phase B R1 baseline 2404 + 5 new R2 = 2409).
- cli: 770/774 PASS / 4 FAIL named pre-existing flakes (unchanged from R1; CLI not touched in R2).

==== BOUNDARIES PRESERVED ====

- Worktree-only changes; main HEAD unchanged at 194a1ebb throughout (verified pre and post).
- 6 files changed (all modified), all in packages/daemon/{src,test}/* (NO CLI touch in R2).
- Phase A surfaces touched: queue-repository.ts (additive event emission only; write semantics unchanged) + types.ts (additive RigEvent variant). Per slice IMPL § Guard Focus item 9, narrow event-only extensions are explicitly allowed when justified — this is justified by the R2 guard finding that update-path mutations were invisible to the view bridge.
- Phase A R1 surfaces NOT touched: view-event-bridge.ts mapping extension only; project-classifier.ts, view-projector.ts, routes/{projects,queue,stream}.ts UNCHANGED.
- Existing 27 migrations + Phase B's 028-030 migrations UNCHANGED.
- Lifecycle v2 surfaces NOT touched. Phase C/D/E surfaces NOT touched.
- Velocity prior-art .mjs UNCHANGED.
- POC rigx project / view-proto NOT touched.
- No host daemon mutation. No host credential mutation. No live rig topology mutation.
- No standing Codex auto-review or approval-heavy loop.
- Normal posture approvals_reviewer=user honored throughout.

==== HANDOFF RECOMMENDATION: ready-for-guard-review ====

R1 BLOCKER 1 already accepted by guard. R2 BLOCKER addressed: update-path view.changed proof now passes (5 new tests including the route-level SSE proof guard requested).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-02 21:57:55 -07:00
mvschwarzandClaude Opus 4.7 9f86df49e2 fix(daemon,cli): PL-004 Phase B R1 — L1→L2 FK + view.changed event bridge + notes 3+4
Closes guard BLOCK on Phase B R0 at openrig 4ac0d20 per qitem-20260503043604-21709333 + guard artifact pl004-phase-b-4ac0d20-guard-review-block-20260503.md.

==== BLOCKER 1: L1→L2 referential integrity + existence check ====

PRD § L2 (product-specs/coordination-primitive-system/README.md:113,118,136) defines stream_item_id as FK to L1. R0 implementation had UNIQUE-only without FK; tests classified arbitrary nonexistent IDs.

Fix:
- Migration 028: added FOREIGN KEY clause `stream_item_id TEXT NOT NULL UNIQUE REFERENCES stream_items(stream_item_id)`. PRAGMA foreign_keys=ON in connection.ts is the defense-in-depth safety net.
- project-classifier.ts: added explicit existence check (`SELECT 1 FROM stream_items WHERE stream_item_id = ?`) BEFORE INSERT, throwing structured `ProjectClassifierError("unknown_stream_item", ...)` with meta.streamItemId. Pre-check renders a clean error path instead of opaque SQLite FK violation message.
- routes/projects.ts: maps `unknown_stream_item` to HTTP 400.
- packages/cli/src/commands/project.ts: surfaces 400 error correctly via existing exit-1 pattern.

Test updates (tests now migrate streamItemsSchema + seed real stream_items rows):
- project-classifier.test.ts: imports streamItemsSchema + StreamStore. Helper `seedStreamItem()` emits a real stream item via Phase A's StreamStore. Updated all 7 existing tests to call seedStreamItem first. Added 2 NEW tests: (a) classify of nonexistent stream_item_id throws unknown_stream_item; (b) FK constraint blocks direct INSERT bypass (defense-in-depth).
- projects-routes.test.ts: imports streamItemsSchema + StreamStore. Helper `seedStreamItem()` for route tests. Updated all 3 existing project tests to seed first. Added 1 NEW test: POST /api/projects/project with nonexistent streamItemId returns 400 unknown_stream_item with structured message + streamItemId in meta.
- packages/cli/test/project.test.ts: added 1 NEW test for unknown_stream_item 400 surfacing + non-zero exit + error message rendering.

==== BLOCKER 2: view.changed event bridge ====

PRD § L5 acceptance criterion + slice IMPL § Guard Checkpoint Focus item 8: SSE consumers on /api/views/:name/sse must observe view.changed when underlying state mutates. R0 implementation only forwarded events that already had type view.changed, but no production path emitted view.changed from queue/project mutations.

Fix:
- NEW packages/daemon/src/domain/view-event-bridge.ts (96 lines): subscribes to event-bus and emits view.changed for affected built-in views via ViewProjector.notifyViewChanged. EVENT_TO_VIEWS mapping covers all 9 coordination event types (queue.created → recently-active, founder, pod-load, activity; queue.handed_off / queue.claimed / queue.unclaimed / qitem.fallback_routed / inbox.absorbed → recently-active, pod-load, activity; qitem.closure_overdue → recently-active, escalations, activity; inbox.denied + project.classified → activity). classifier.lease_* events deliberately NOT bridged (lease lifecycle is project SSE, not view SSE). view.changed itself NOT bridged (no feedback loop).
- Validation at wire time: bridge throws if EVENT_TO_VIEWS references unknown built-in view names (fail-fast guard against future built-in renames/removals).
- Returns ViewEventBridgeStop unsubscribe function for tests + graceful daemon shutdown.
- Best-effort isolation: bridge errors caught and dropped silently so they cannot unwind the underlying state mutation that triggered them.

Wired in startup.ts: viewProjectorInstance constructed early; wireViewEventBridge(eventBus, viewProjectorInstance) called immediately after.

NEW packages/daemon/test/view-event-bridge.test.ts (9 cases):
- queue.created → 4 view.changed (recently-active, founder, pod-load, activity)
- queue.handed_off → 3 view.changed
- queue.claimed → 3 view.changed
- inbox.absorbed → 3 view.changed
- inbox.denied → 1 view.changed (activity only)
- project.classified → 1 view.changed (activity only)
- classifier.lease_acquired → 0 view.changed (lease is project SSE not view SSE)
- view.changed → 0 view.changed (feedback-loop prevention)
- unsubscribe stops the bridge cleanly

NEW route-level test in views-routes.test.ts: "R1 BLOCKER 2: queue mutation triggers view.changed visible to SSE consumer". Wires the bridge in test, subscribes to /api/views/recently-active/sse, mutates queue state, reads SSE body via reader.read() with deadline, asserts view.changed event observed in stream payload before timeout. Cancels via reader.cancel() to release the stream lock cleanly.

==== NOTE 3: lease acquire stale/dead expiry opt-in ====

R0 lease acquire returned 409 lease_held even when holder was dead (only operator-verb reclaim or evaluateDeadness call could clear it). Per guard note, the route should cover stale/dead expiry behavior or scope explicitly.

Fix:
- routes/projects.ts POST /lease/acquire: added optional `evaluateDeadnessFirst: true` body parameter. When set, the route calls ClassifierLeaseManager.evaluateDeadness() before acquire, which clears stale TTL-expired or dead-holder leases. Default OFF so existing behavior is preserved; opt-in is explicit.
- packages/cli/src/commands/project.ts lease-acquire: added --evaluate-deadness-first flag passing through to the route body.

==== NOTE 4: pod-load v0 interpretation documented ====

PRD § L5 names pod-load as "per-pod queue-item counts" but Phase A's queue_items table lacks pod metadata. R0 implementation grouped by destination_session (per-seat). Per guard note, document v0 interpretation rather than calling PRD semantics unchanged.

Fix:
- view-projector.ts pod-load case: added inline doc explaining v0 interpretation is per-seat (destination_session) because Phase A lacks pod metadata; future Phase D workflow runtime can upgrade to GROUP BY pod_id with destination_session sub-grouping. Documented v0 behavior preserves the view name but reduces the grouping key.

==== RE-VERIFICATION COMMANDS (all green; node v22.22.1) ====

Focused (under explicit temp-rooted OPENRIG_HOME + OPENRIG_DB):
- daemon: OPENRIG_HOME=/tmp/pl004-phase-b-r1-final-d OPENRIG_DB=/tmp/pl004-phase-b-r1-final-d/openrig.sqlite npx vitest run [6 Phase B daemon test files including new view-event-bridge.test.ts] → 70/70 PASS (R0 baseline 57 + 4 R1 BLOCKER 1 nonexistent-stream cases + 9 R1 BLOCKER 2 bridge cases = 70).
- cli: OPENRIG_HOME=/tmp/pl004-phase-b-r1-final-c OPENRIG_DB=/tmp/pl004-phase-b-r1-final-c/openrig.sqlite npx vitest run test/project.test.ts test/view.test.ts → 13/13 PASS (R0 baseline 12 + 1 R1 unknown_stream_item case).
- daemon tsc + build: exit 0; cli tsc + build: exit 0.

Compiled-output runtime checks:
- node import dist/domain/view-event-bridge.js → wireViewEventBridge: function ✓

Full per-package suites (NO env override; honest baseline):
- daemon: 2404/2404 PASS / 0 FAIL (Phase B R0 baseline 2391 + 13 new R1 = 2404).
- cli: 770/774 PASS / 4 FAIL named pre-existing flakes. Phase B R0 baseline 769/773; +1 new = 770/774. NO new regressions.

==== BOUNDARIES PRESERVED ====

- Worktree-only changes; main HEAD unchanged at 194a1ebb throughout (verified pre and post).
- 12 files changed (10 modified + 2 new), all in packages/daemon/{src,test}/* and packages/cli/{src,test}/*.
- Phase A surfaces NOT touched. Existing 22 + Phase A's 5 migrations NOT modified. Phase B R0's migrations 028-030 modified ONLY in 028 (added FK clause; safe additive change since the column already existed).
- Lifecycle v2 surfaces NOT touched. Phase C/D/E surfaces NOT touched.
- Velocity prior-art .mjs UNCHANGED.
- POC rigx project / view-proto NOT touched (preserved as-is for dogfood window).
- No host daemon mutation. No host credential mutation. No live rig topology mutation.
- No standing Codex auto-review or approval-heavy loop.
- Normal posture approvals_reviewer=user honored throughout.

==== HANDOFF RECOMMENDATION: ready-for-guard-review ====

Both blockers fixed; both notes addressed. Driver-side focused verification passed (70 + 13 = 83 focused tests under explicit temp DB isolation + compiled-output runtime check + per-package baselines clean).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-02 21:45:58 -07:00
mvschwarzandClaude Opus 4.7 4ac0d20e35 feat(daemon,cli): PL-004 Phase B coordination primitive — L2 Project (Classifier) + L5 View
Closes Phase B dispatch qitem-20260503040433-cfd76ab9 + delivery-orch handoff packet pl004-phase-b-driver-handoff-20260503.md. Foundation: openrig main 194a1ebb (Phase A R1 + SSE fix; locally promoted with caveats). Branch: phase-b-coordination-primitive-system (worktree-scoped; main NOT touched).

==== AUDIT (12 hard gates; ALL GREEN in clean Phase B worktree) ====

1. Phase A migrations 023-027 present (5 files at packages/daemon/src/db/migrations/02[3-7]_*.ts).
2. db/connection.ts + db/migrate.ts unchanged from Phase A; same Migration{name,sql} shape used.
3. Phase A stream-store.ts read interface evaluated — chose path (b) direct query against `stream_items` for the future "find unclassified" pattern (NOT touching stream-store; classifier write path doesn't actually need the listUnclassified pattern; project verb takes streamItemId from caller).
4. queue-repository.ts read APIs sufficient — view-projector queries queue_items + queue_transitions tables directly via shared db handle (read-only, no Phase A write contract changed).
5. event-bus.ts RigEvent union has Phase A's 9 events; Phase B appends 6 new events alongside (untouched: stream.emitted, queue.created, queue.handed_off, queue.claimed, queue.unclaimed, qitem.fallback_routed, qitem.closure_overdue, inbox.absorbed, inbox.denied).
6. whoami-service.ts + node-inventory.ts present; isAlive wired via direct sessions-table query (`SELECT 1 FROM sessions WHERE session_name = ? AND status = 'running'`) — simpler than threading whoami-service through the dep graph, same liveness signal.
7. POC rigx project / view-proto sources present at substrate (348 + 304 lines); contract reverse-engineered.
8. No in-flight Phase A revisions on main (verified via git log).
9. Cross-loop POC stable (per Phase A R1 promotion artifact).
10. Phase A locally promoted at 194a1ebb (delivery-ready/mode-2/coordination-primitive-system-phase-a.md status: promoted).
11. Test discipline: ALL test runs use explicit OPENRIG_HOME + OPENRIG_DB under /tmp/pl004-phase-b-* per Phase A R1 incident lesson.
12. SSE/static routes mounted BEFORE /:id catchalls in routes/projects.ts (sse + watch + lease + list before /:projectId) and routes/views.ts (list + sse + watch + /:viewName/sse before /:viewName) per Phase A R1 SSE fix lesson; live GET tests assert content-type: text/event-stream + regression guard against not_found shadowing.

==== R0 SCOPE DELIVERED (21 files; +2953 / -2) ====

Migrations (3 NEW; numbering 028-030 contiguous after Phase A's 027):
- 028_project_classifications: project_id PK + UNIQUE(stream_item_id) for idempotency + classifier_session + 6 classification fields + ts_projected.
- 029_classifier_leases: lease_id PK + classifier_session + acquired_at/expires_at/last_heartbeat + state enum + reclaim fields. Single-writer enforced via partial UNIQUE INDEX on state='active'.
- 030_views_custom: view_id PK + view_name UNIQUE + definition (operator-supplied SQL) + registered_by_session + last_evaluated_at.

Domain (3 NEW; per IMPL § Domain):
- classifier-lease-manager.ts (392 lines): acquire (idempotent same-session) / heartbeat (extends TTL) / evaluateDeadness (TTL OR isAlive=false → expired + classifier.dead event) / reclaim (operator-verb; --if-dead respects isAlive) / requireActiveHolder (validation gate for classifier write path). attachIsAlive() setter for post-construction wiring (whoami/sessions table later in dep graph).
- project-classifier.ts (222 lines): classify (lease check + idempotency check + INSERT + project.classified event) / getById / getByStreamItemId / list. Pure SQLite-canonical write; no Hono.
- view-projector.ts (350 lines): show (dispatches to 6 built-in OR custom) / 6 built-in views (recently-active, founder, pod-load, escalations, held, activity) as parameterized SQL with fixture-rig exclusion (OPENRIG_VIEW_INCLUDE_FIXTURES=1 opt-in) + --rig filter / registerCustomView (UNIQUE on view_name; reserved built-in names rejected; re-registration updates) / listCustomViews / notifyViewChanged (emits view.changed event for SSE consumers).

Routes (2 NEW):
- routes/projects.ts (183 lines): POST /lease/acquire, /lease/heartbeat, /reclaim-classifier, /project; GET /lease, /list, /sse + /watch (SSE handler dual-mounted), /:projectId. Routes mounted in correct order (literal before /:id catchall per R1 lesson).
- routes/views.ts (124 lines): GET /list, /sse + /watch (generic SSE), /:viewName/sse (view-filtered SSE), /:viewName (run); POST /custom/register. Routes mounted in correct order.

Event-bus extension (light-touch in domain/types.ts; +14 lines):
- 6 NEW Phase B events appended to RigEvent union: project.classified, classifier.lease_acquired, classifier.lease_expired, classifier.dead, classifier.reclaimed, view.changed. Phase A's 9 events untouched.

Startup wiring (light-touch in startup.ts; +33 lines):
- Imports for 3 new migrations + 3 new domain services. Migration list extended by 3 (028, 029, 030). classifierLeaseManagerInstance constructed early (shared between leaseManager dep slot and projectClassifier). attachIsAlive wired in the SessionTransport-construction IIFE alongside Phase A's queueRepoInstance.attachTransport (sessions-table query for liveness check).

Server wiring (light-touch in server.ts; +13 lines):
- AppDeps extended with projectClassifier?, classifierLeaseManager?, viewProjector?. Context setters for the 3 new services. app.route("/api/projects", projectsRoutes()) + app.route("/api/views", viewsRoutes()) mounted alongside Phase A routes.

CLI commands (2 NEW):
- commands/project.ts (194 lines): rig project lease-acquire / lease-heartbeat / lease-show / reclaim-classifier --if-dead / classify <streamItemId> --type --urgency --maturity --confidence --destination --action / list / show <projectId>. Mirrors Phase A's queue.ts pattern.
- commands/view.ts (101 lines): rig view list / show <viewName> --rig --limit --json / register --name --definition --session.

CLI registration (light-touch in cli/src/index.ts; +6 lines):
- Imports projectCommand + viewCommand. ProgramDeps extended with projectDeps? + viewDeps?. addCommand calls.

Tests (5 NEW daemon files + 2 NEW CLI files = 7 new test files; 69 new tests):
- classifier-lease-manager.test.ts (16 cases): acquire idempotency, lease_held conflict, heartbeat extension, deadness via isAlive AND TTL, reclaim semantics, --if-dead refusal-when-alive, requireActiveHolder validation, partial UNIQUE singleton enforcement at DB layer.
- project-classifier.test.ts (7 cases): valid lease + classify, no-lease rejection, lease_held by other session, idempotency_violation on re-projection (with all 6 closure-reason-equivalent fields preserved), null-default classification fields, list filtering.
- view-projector.test.ts (15 cases): all 6 built-in view names + queries, fixture exclusion default + opt-in, --rig filter, view_not_found, custom view registration + reserved-name rejection + re-registration update + list. notifyViewChanged event.
- projects-routes.test.ts (10 cases): all POSTs + GETs round-trip; idempotency 409; reclaim semantics including --if-dead; SSE route-order regression guard (3 cases per R1 SSE lesson: text/event-stream + handler-reached + not-shadowed).
- views-routes.test.ts (9 cases): /list + /show + /custom/register + reserved-name 409 + view_not_found 404 + SSE route-order regression guard (3 cases including /:viewName/sse).
- project.test.ts (6 CLI cases): registration check, lease-acquire POST shape, classify POST shape, idempotency_violation 409 surfacing + non-zero exit, reclaim-classifier --if-dead passthrough, list query params.
- view.test.ts (6 CLI cases): registration check, list GET, show with rig+limit query params, show without query params, view_not_found 404 surfacing + non-zero exit, register POST shape.

==== RE-VERIFICATION COMMANDS (all green; node v22.22.1) ====

Focused (under explicit temp-rooted OPENRIG_HOME + OPENRIG_DB):
- daemon: OPENRIG_HOME=/tmp/pl004-phase-b-d3 OPENRIG_DB=/tmp/pl004-phase-b-d3/openrig.sqlite npx vitest run [5 Phase B daemon test files] → 57/57 PASS.
- cli: OPENRIG_HOME=/tmp/pl004-phase-b-c OPENRIG_DB=/tmp/pl004-phase-b-c/openrig.sqlite npx vitest run test/project.test.ts test/view.test.ts → 12/12 PASS.
- daemon tsc + build: exit 0; cli tsc + build: exit 0.

Compiled-output runtime checks:
- node import dist/domain/classifier-lease-manager.js → ClassifierLeaseManager: function ✓
- node import dist/domain/view-projector.js → ViewProjector: function; BUILT_IN_VIEW_NAMES.length: 6 ✓
- node import dist/index.js → project subs: classify, lease-acquire, lease-heartbeat, lease-show, list, reclaim-classifier, show; view subs: list, register, show ✓

Full per-package suites (NO env override; honest baseline):
- daemon: 2391/2391 PASS / 0 FAIL (Phase A R1 baseline 2334 + 57 new Phase B = 2391).
- cli: 769/773 PASS / 4 FAIL named pre-existing flakes (chatroom wait timing, config-store env pollution, two preflight host-port). NO new regressions; matches Phase A R1 baseline.

==== STREAM-READ TRICHOTOMY CHOICE (gate 6) ====

Per advisory review Rec 2 + IMPL audit row 3: the classifier did not require a "find-unclassified" filter on stream-store because the project verb takes streamItemId from the caller. Each agent decides what to project; the daemon enforces the lease + idempotency. So none of the three escape-valves was triggered:
- (a) extending stream-store: not needed (no boundary touch on Phase A).
- (b) direct query: wired via shared db handle for view-projector (queue_items + queue_transitions read-only).
- (c) post-filter in code: not needed.

==== BOUNDARIES PRESERVED ====

- Worktree-only changes; main HEAD unchanged at 194a1ebb throughout (verified pre and post).
- 21 files changed (10 modified + 11 new), all in packages/daemon/{src,test}/* and packages/cli/{src/commands,src/index,test}/*.
- Phase A surfaces NOT touched: stream-store.ts, queue-repository.ts, queue-transition-log.ts, inbox-handler.ts, outbox-handler.ts, hot-potato-enforcer.ts, routes/{stream,queue}.ts, commands/{stream,queue}.ts. Existing 22 migrations + Phase A's 5 migrations (023-027) NOT modified.
- Lifecycle v2 surfaces NOT touched (restore-orchestrator.ts, seat-handover-service.ts, snapshot-repository.ts).
- Phase C/D/E surfaces NOT touched.
- Velocity prior-art .mjs UNCHANGED.
- POC rigx project / view-proto NOT touched (preserved as-is for dogfood window).
- No --host cross-host promotion added.
- No host daemon mutation. No host credential mutation. No live rig topology mutation.
- No standing Codex auto-review or approval-heavy loop.
- Normal posture approvals_reviewer=user honored throughout.

==== HANDOFF RECOMMENDATION: ready-for-guard-review ====

Driver-side focused verification passed (57 + 12 = 69 focused tests under explicit temp DB isolation + compiled-output runtime checks + per-package baselines clean). All 12 audit hard gates GREEN. All 4 ratified Phase B contract surfaces (classifier lease + idempotency + reclaim + 6 built-in views + SSE) implemented per PRD § L2 + § L5.

Per IMPL § Driver Handoff Contract: hand to guard at the milestone-diff boundary. Guard adjudicates against the 18 § Guard Checkpoint Focus items. Then tester runs cross-loop dogfood proof per IMPL § Cross-Loop Dogfood Plan + Tester Proof Expectations.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-02 21:28:09 -07:00
mvschwarzandClaude Opus 4.7 194a1ebb4c fix(daemon): PL-004 Phase A R1 — SSE route-order shadowing fix
Closes QA verdict revise-required from delivery-orch-lead per qitem-20260503024929-ea365d2b + handoff packet substrate/rigs/openrig-velocity/state/delivery-orch/pl004-phase-a-r1-sse-route-fix-driver-handoff-20260503.md.

Foundation: openrig fd4fb30 (PL-004 Phase A R1). Branch: phase-a-coordination-primitive-system (worktree-scoped; main NOT touched).

ROOT CAUSE:
The R1 commit fd4fb30 mounted GET /watch and GET /sse routes AFTER the bare-param catchalls (GET /:qitemId on queue.ts:278; GET /:streamItemId on stream.ts:69). Hono dispatches GET /api/queue/sse to the /:qitemId handler (with qitemId="sse"), which calls QueueRepository.getById("sse") → null → returns 404 with `{"error":"qitem_not_found"}`. Same problem on stream side: GET /api/stream/sse → /:streamItemId handler → 404 `{"error":"stream item not found"}`.

The QA proof caught this; my R1 test was inadequate (HEAD comparison; both /watch and /sse returned the same 404 status, so the "they match" assertion held while both were broken).

FIX:
1. Moved sseHandler definition + `app.get("/watch", sseHandler)` + `app.get("/sse", sseHandler)` BEFORE the bare /:qitemId GET in routes/queue.ts (now mounted alongside /whoami, /list, /overdue — matching the existing literal-first convention with explicit comment).
2. Same fix in routes/stream.ts: SSE routes now mounted BEFORE GET /:streamItemId.
3. Removed the duplicate handler definition + mounts at end of both files.

NEW LIVE-GET TESTS (replacing inadequate HEAD comparison):
queue-routes.test.ts (3 cases under "R1 SSE route — live GET reaches the SSE handler"):
- GET /api/queue/sse → 200 + content-type: text/event-stream (handler reached)
- GET /api/queue/watch → 200 + content-type: text/event-stream (handler reached)
- GET /api/queue/sse does NOT return qitem_not_found (route-order regression guard)

stream-routes.test.ts (3 cases):
- GET /api/stream/sse → 200 + content-type: text/event-stream
- GET /api/stream/watch → 200 + content-type: text/event-stream
- GET /api/stream/sse does NOT return stream-item-not-found (regression guard)

Each test cancels the response body (`res.body?.cancel()`) so the long-lived SSE stream releases cleanly. The content-type assertion proves the SSE handler is reached (vs the JSON 404 the catchall would emit).

RE-VERIFICATION COMMANDS (all green; node v22.22.1):

Focused (under explicit temp-rooted OPENRIG_HOME + OPENRIG_DB):
- `OPENRIG_HOME=/tmp/pl004-r1-sse-final-d OPENRIG_DB=/tmp/pl004-r1-sse-final-d/openrig.sqlite npx vitest run test/queue-repository.test.ts test/queue-routes.test.ts test/coordination-integration.test.ts test/inbox-handler.test.ts test/hot-potato-enforcer.test.ts test/stream-routes.test.ts test/stream-store.test.ts test/outbox-handler.test.ts` → 93/93 PASS (89 R1 baseline + 4 new SSE route-order regression tests).
- daemon tsc --noEmit + npm run build: exit 0.

Compiled-output runtime live-GET check (the canonical anti-regression proof):
```
node -e '
  ... boot Hono app + queueRoutes() + streamRoutes() from dist/ ...
  for (const path of ["/api/queue/sse", "/api/queue/watch", "/api/stream/sse", "/api/stream/watch"]) {
    const res = await app.request(path);
    console.log(path, "→ status:", res.status, "ctype:", res.headers.get("content-type"));
    await res.body?.cancel();
  }
'
```
Output (all 4 paths):
- /api/queue/sse → status: 200 ctype: text/event-stream
- /api/queue/watch → status: 200 ctype: text/event-stream
- /api/stream/sse → status: 200 ctype: text/event-stream
- /api/stream/watch → status: 200 ctype: text/event-stream

Honest per-package baselines (NO env override):
- daemon: 2334/2334 PASS / 0 FAIL (R1 baseline 2330 + 4 new).
- cli: 757/761 PASS / 4 FAIL named pre-existing flakes (chatroom wait timing, config-store env pollution, two preflight host-port). NO new regressions; matches prior baseline.

DB ISOLATION:
All test runs used explicit temp-rooted OPENRIG_HOME + OPENRIG_DB under /tmp/pl004-r1-sse-*. No daemon launched. No production DB opened.

BOUNDARIES PRESERVED:
- Worktree-only changes; main checkout `/Users/wrandom/code/projects/openrig` HEAD unchanged at ab822eef throughout. Verified pre and post.
- 4 files changed (all in packages/daemon/{src,test}/routes-and-tests). No CLI changes; no domain changes.
- No host daemon mutation. No host credential mutation. No live rig topology mutation.
- No standing Codex auto-review or approval-heavy loop.
- Normal posture approvals_reviewer=user honored throughout.

LESSON LEARNED (filed inline; carry into future PR review discipline):
HEAD-method tests against SSE/streaming routes can pass while the underlying route-resolution is broken. Test discipline for streaming routes: GET the route, assert response.status + content-type header, then cancel the body to release the stream. Hono's app.request returns the Response synchronously even when the body is a stream; checking headers without consuming body avoids hangs.

HANDOFF RECOMMENDATION: ready-for-qa-proof.

QA's sustained proof loop now has a green-for-real SSE surface; can resume the cross-loop dogfood + Velocity-pair cross-rig validation + reissued VM proof per the original R1 dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-02 19:54:23 -07:00
mvschwarzandClaude Opus 4.7 fd4fb309b5 fix(daemon,cli): PL-004 Phase A R1 — topology-backed validation + default nudge + handoff-and-complete + whoami + SSE alias + CLI tests
Closes adjudication revise-required from delivery-orch-lead@openrig-velocity
per qitem-20260503021500-49b0db82 + handoff packet
substrate/rigs/openrig-velocity/state/delivery-orch/pl004-phase-a-revision-driver-handoff-20260503.md.
Foundation: openrig d8d8ee9 (Phase A R0). Branch: phase-a-coordination-primitive-system (worktree-scoped; main NOT touched).

PRIOR REVIEW INPUTS (all four corroborate the R1 deliverable list):
- Codex independent review (revise-required): qitem-20260503020120-ef9b04db / artifact pl004-phase-a-independent-codex-review-20260503.md
- Claude independent review (clean-with-followups): qitem-20260503020117-0efbff1d / artifact pl004-phase-a-independent-claude-review-20260503.md
- Empirical QA (proof-corroborated-with-caveats): pl004-phase-a-empirical-qa-20260503.md
- Model-diverse blocker packet (blocked-pending-adjudication): coordination-primitive-system-phase-a-d8d8ee9-model-diverse-review-blocker-20260503.md
- Adjudication: orch-lead@openrig-velocity-claude → revise-required.

R1 SCOPE DELIVERED (4 ratified Phase A gaps):

1. Topology-backed validateRig wired in startup.ts (closes Codex finding 1; closes Claude FU2-rebound):
   - packages/daemon/src/startup.ts:131 now constructs QueueRepository with `validateRig: topologyValidateRig` that splits `<member>@<rig>` and rejects rigs absent from rigRepo.findRigsByName(). Bare ids (no @) also rejected (no canonical rig binding).
   - packages/daemon/src/domain/queue-repository.ts: `create()` and `handoff()` already called the hook; new `handoffAndComplete()` also enforces it.
   - Route + repo + CLI rejection coverage: queue-routes.test.ts adds 4 R1 tests (unknown-rig 400 on create / on handoff / on handoff-and-complete + accept-known-rig); queue-repository.test.ts already had the hook test.

2. Default nudge wiring + persistence (closes Codex finding 2):
   - QueueRepository now takes optional `transport: QueueNudgeTransport` (minimal interface; concrete impl is SessionTransport). After successful create / handoff / handoff-and-complete commits, fires `transport.send(destination, "Queue handoff: <qitemId> - check your queue.", { verify: true })` and persists last_nudge_attempt + last_nudge_result via existing recordNudgeAttempt(). recordNudgeAttempt() is no longer dead code.
   - Opt-out: `nudge: false` in the input skips the call entirely. CLI exposes `--no-nudge` on create / handoff / handoff-and-complete. Default behavior is nudging.
   - InboxHandler.absorb() passes `nudge: false` when creating the absorbed qitem (the inbox surface already had its own delivery via the original drop; second nudge on absorb would be duplicative).
   - Nudge failure is recorded as `failed:<reason>` in last_nudge_result; the underlying queue mutation is NOT unwound (operator can retry the wake separately).
   - Post-construction transport attachment: new `attachTransport()` setter lets startup.ts wire the transport AFTER both QueueRepository and SessionTransport are constructed (SessionTransport depends on agentActivityStore which depends on eventBus, so it's later in the dep graph).
   - 5 R1 tests in queue-repository.test.ts: nudge default + opt-out + failure-recording + handoff nudges + attachTransport post-hoc wiring.

3. Missing route/CLI surfaces (closes Codex finding 3):
   - POST /api/queue/:qitemId/handoff-and-complete (new route) — variant of handoff() that closes source as state=done (terminal) instead of state=handed-off (intermediate). Same atomic close+create + chain_of_record + default-nudge contract. Mirrored on the CLI as `rig queue handoff-and-complete <qitemId> --from --to [--body --note --priority --tier --tags --no-nudge --json]`.
   - GET /api/queue/whoami (new route; mounted before /:qitemId so literal path wins) — returns {session, asDestination:{pending,inProgress,blocked,recent[]}, asSource:{total}} for the queried session. Read-only. CLI: `rig queue whoami --session <s> [--recent-limit N --json]`.
   - GET /api/stream/sse and GET /api/queue/sse — Phase A contract route names per IMPL § Routes (which named /api/stream/sse). Mounted as a SECOND alias alongside the existing /watch handler in both stream.ts and queue.ts (same handler function dual-mounted; either path emits the identical event stream). Documented as "compat alias" in code comments. No behavior change for existing /watch consumers.
   - 3 R1 tests in queue-routes.test.ts (whoami GET success + missing-session 400 + handoff-and-complete success + handoff-and-complete missing-fromSession). 1 R1 test in stream-routes.test.ts (sse mounted alongside watch). 2 R1 tests in queue-repository.test.ts for whoami + handoff-and-complete repository methods.

4. CLI tests for queue + stream (closes Codex finding 3 + Claude FU8):
   - NEW packages/cli/test/queue.test.ts (9 cases): registration check + create POST shape + --no-nudge passthrough + update done-without-closure_reason error rendering with validReasons + handoff-and-complete POST shape + whoami query params + unknown-rig 400 surfacing + handoff --no-nudge + list filter params.
   - NEW packages/cli/test/stream.test.ts (8 cases): registration check + emit POST shape + --id idempotent passthrough + list filter + show + archive + non-2xx exit on emit + 404 exit on show.
   - Pattern mirrors compact-plan.test.ts: vi.mock daemon-lifecycle, inject clientFactory with stubbed HTTP client. No real daemon. No DB. No network.

ASYNC CASCADE (mechanical):
   - QueueRepository.create() / handoff() / new handoffAndComplete() are now async (they `await` the post-commit nudge call). Routes + InboxHandler.absorb() updated to await. Test files updated:
     * queue-repository.test.ts: every it() callback async; every repo.create()/repo.handoff() awaited; .toThrow() patterns refactored to .rejects.toThrow() where appropriate.
     * inbox-handler.test.ts: same async-cascade for inbox.absorb() callers.
     * queue-routes.test.ts: route handler at /inbox/:inboxId/absorb now awaits InboxHandler.absorb().
   - InboxHandler.absorb() is now async; the queueRepo.create() call inside still happens BEFORE the inbox UPDATE transaction (Claude FU1 atomicity concern from prior review remains as a separate followup; in-scope fix would be to wrap both in a single SAVEPOINT-nested transaction).

RE-VERIFICATION COMMANDS (all green; node v22.22.1):

Focused (under explicit temp-rooted OPENRIG_HOME + OPENRIG_DB):
- daemon: `OPENRIG_HOME=/tmp/pl004-r1-final-d OPENRIG_DB=/tmp/pl004-r1-final-d/openrig.sqlite npx vitest run test/queue-repository.test.ts test/queue-routes.test.ts test/coordination-integration.test.ts test/inbox-handler.test.ts test/hot-potato-enforcer.test.ts test/stream-routes.test.ts test/stream-store.test.ts test/outbox-handler.test.ts` → 89/89 PASS (68 baseline + 21 R1).
- cli: `OPENRIG_HOME=/tmp/pl004-r1-final-c OPENRIG_DB=/tmp/pl004-r1-final-c/openrig.sqlite npx vitest run test/queue.test.ts test/stream.test.ts` → 17/17 PASS (NEW; no prior CLI tests for queue/stream).
- daemon tsc + build: exit 0; cli tsc + build: exit 0.

Compiled-output runtime checks:
- node import dist/domain/queue-repository.js → QueueRepository: function; attachTransport: function; handoffAndComplete: function; whoami: function.
- node import dist/index.js → queue subs: claim,create,fallback,handoff,handoff-and-complete,inbox-absorb,inbox-deny,inbox-drop,inbox-pending,list,outbox-list,outbox-record,overdue,show,transitions,unclaim,update,whoami; stream subs: archive,emit,list,show.

Full per-package suites (NO env override; honest baseline):
- daemon: 2330/2330 PASS / 0 FAIL.
- cli: 757/761 PASS / 4 FAIL named pre-existing flakes (chatroom wait timing, config-store env pollution, two preflight host-port). NO new regressions; matches prior PL-004 review baseline.

DB ISOLATION (per dispatch boundary):
- All test runs used explicit temp-rooted OPENRIG_HOME + OPENRIG_DB under `/tmp/pl004-r1-*`.
- No daemon launched. No production DB opened. The d8d8ee9 prior incident (5 additive tables in production DB) remains inert per orch decision; this commit does not interact with production state.

BOUNDARIES PRESERVED:
- Worktree-only changes; main checkout `/Users/wrandom/code/projects/openrig` HEAD unchanged at ab822ee throughout. No merge / rebase / cherry-pick / commit on main. Verified pre and post.
- 12 files changed (10 modified + 2 new), all in packages/daemon/{src,test} and packages/cli/{src/commands,test}.
- M2/M3/M4 surfaces (Restore-Packet vertical) NOT touched.
- Velocity prior-art .mjs unchanged.
- No host daemon mutation. No host credential mutation. No live rig topology mutation. No baseline VM mutation.
- No production DB cleanup attempted (per dispatch boundary).
- No standing Codex auto-review or approval-heavy loop (per safety stop).
- Normal posture approvals_reviewer=user honored throughout.

HANDOFF RECOMMENDATION: ready-for-qa-proof.

Driver-side focused verification passed (89 + 17 = 106 focused tests under explicit temp DB isolation + compiled-output runtime checks). QA-side sustained proof loop (cross-loop dogfood from disposable VM + Velocity-pair cross-rig validation + nudge persistence under real SessionTransport) belongs to velocity-qa per the velocity SOP correction (driver fast + implementation + focused verify; QA owns sustained VM/dogfood/E2E/runtime proof loops).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-02 19:35:53 -07:00
mvschwarzandClaude Opus 4.7 d8d8ee91c8 feat(daemon,cli): PL-004 Phase A coordination primitive system — stream/queue/inbox/outbox + hot-potato strict-rejection
5 SQLite migrations (023-027): stream_items, queue_items, queue_transitions,
inbox_entries, outbox_entries. Existing 22 migrations untouched; runner
unchanged.

6 host-scoped domain services (Hono-free):
- stream-store (L1 append-only intake; idempotent emit; soft archive)
- queue-repository (L3 owned-work; transactional handoff; pod-fallback;
  overdue lookup; nudge/heartbeat tracking)
- queue-transition-log (append-only audit; UPDATE/DELETE forbidden)
- hot-potato-enforcer (load-bearing API contract: state=done requires
  closure_reason in 6-value enum; tier→SLA mapping for closure_required_at)
- inbox-handler (mailbox; auth hook; idempotent absorb to queue_item)
- outbox-handler (sender-side audit; symmetric to inbox)

2 routes wired into createApp: /api/stream, /api/queue (host-scoped, not
rig-scoped). Hot-potato structured errors include validReasons enum so
operators can self-correct.

9 RigEvent union members appended (stream.emitted, queue.created,
queue.handed_off, queue.claimed, queue.unclaimed, qitem.fallback_routed,
qitem.closure_overdue, inbox.absorbed, inbox.denied). Existing event types
untouched.

2 top-level CLI commands (rig stream + 4 subs; rig queue + 16 subs) backed
solely by the daemon HTTP API. POC rigx queue/stream filesystem path
preserved untouched; no dual-authority.

68 new tests (8 files): hot-potato-enforcer (19), stream-store (7),
queue-repository (12), inbox-handler (8), outbox-handler (6), stream-routes
(5), queue-routes (8), coordination-integration (3). Daemon suite 2309/2309
PASS — zero regressions vs baseline.

Docs: architecture.md (new Coordination Layer section under §3 schema, new
service inventory under §5, event surface in §8, startup sequence in §9),
cli-reference.md (new Coordination Primitive section), codemap.md (new
migrations + domain services + routes + commands inventory).

No-touch surfaces honored: restore-orchestrator, seat-handover-service,
snapshot-repository unchanged. No --host promotion. Phase B/C/D/E remain
out of scope per IMPL.

Source artifact: shared-docs/openrig-work/missions/recursive-self-improvement-v2/work-discovery-and-slice-shaping/slices/coordination-primitive-system-phase-a/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-02 18:31:59 -07:00
mvschwarzandClaude Opus 4.7 ab822eef53 test(cli): Restore-Packet M4 — command-registration meta-tests + final regression check
Closes M4 dispatch qitem-20260502024605-55720e41. M3 marshal-acceptance gate PASSED at predecessor a5132da (read + validate actual implementations landed).

M4 SCOPE (per IMPL § M4 line 193-207):
- Add command-registration meta-tests catching any future regression where `restore-packet` would import-but-not-register (createProgram chain wiring break).
- Re-run full regression suite to confirm no spillover from M2/M3 work.
- ADD-ONLY milestone: no functional code changes; no implementation mutations.

NEW TESTS (3 cases at packages/cli/test/restore-packet.test.ts):
- M4: createProgram registers restore-packet as a top-level command (program.commands names contain "restore-packet").
- M4: rig --help discovers restore-packet in help output (program.helpInformation contains "restore-packet").
- M4: rig restore-packet --help discovers all 3 subcommands (write, read, validate) via subcommand helpInformation.

Reference precedent: packages/cli/test/compact-plan.test.ts:166-176 (createProgram + parseAsync registration assertion shape).

RE-VERIFICATION COMMANDS (all green; node v22.22.1):

cd packages/cli:
- npx vitest run test/restore-packet.test.ts → 103/103 PASS (100 baseline + 3 new M4).
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/schemas/restore-summary.schema.json present.

Compiled-output runtime check (Quality Lesson v10 + v12 carry-forward):
- node -e '<import dist/index.js; createProgram; helpInformation contains "restore-packet"; subcommand discovery yields ["read","validate","write"]>' → "compiled-output: restore-packet registered with subs ['read','validate','write']" ✓

Per-package honest baselines (Quality Lesson v8):
- packages/cli: 747 total / 743 PASS / 4 FAIL named flakes (chatroom wait timing, config-store env pollution, two preflight host-port). Baseline: 740 → 743 = +3 new tests; 4 flakes unchanged.
- packages/daemon: 2242 total / 2242 PASS / 0 FAIL. M4 did NOT touch daemon code; no spillover.

ENVIRONMENT NOTES:
- Node version: v22.22.1 (matches better-sqlite3 native module 127). No new Node-coordination relevance introduced.

M4 MARSHAL-ACCEPTANCE GATE (per IMPL § M4 line 202-207):
ALL CRITERIA MET:
- New command-registration test green: DONE (3/3).
- ~25-30 Tier 1 cases green target: DONE (vertical at 103/103 focused — well above precedent target of 33/33 from session_source fork v1).
- Command registration verified (CLI help discovery passes): DONE (compiled-output runtime check).
- No regression in full packages/cli (740 + N new / 4 named flakes): DONE (743/747; 4 flakes unchanged).
- No regression in full packages/daemon (2242/2242): DONE.
- Posture full QA/testing window: DONE.

BOUNDARY DISCIPLINE:
- ONLY 1 file changed (verified via git diff --cached --stat):
  packages/cli/test/restore-packet.test.ts (+31; 3 new test cases inserted within the existing M3 describe block).
- ZERO functional code changes (M4 is meta-test + regression only per dispatch).
- M2 surfaces UNTOUCHED: write CLI, parsers, redaction, runtime-detect, omitted-records, types, schema embed, packet-writer, daemon full-read route, daemon redaction module, daemon route tests.
- M3 surfaces UNTOUCHED: read action, validate action, schema-validator severity differentiation.
- M5 (live host walk) / M6 (VM proof) surfaces UNTOUCHED.
- Velocity prior-art .mjs UNCHANGED.
- Unrelated dirty work (docs/*, ps.ts, codex-runtime-adapter, daemon SKILL.md) NOT staged.
- No auth tokens, device codes, or credentials in fixtures.

CUMULATIVE QUALITY-LESSON CARRY-FORWARD (v1-v9 + v10-v12 candidates):
- v6, v7 refined; v8 (honest baseline match per-package); v9 (no real-looking credentials in fixtures).
- v10 (asset packaging): registration verified via compiled dist/.
- v11 (nested-content coverage): preserved at M2b R2 + M2c regression.
- v12 (CLI surface coverage): M4 directly applies — registration check exercises createProgram surface.

Posture: full QA/testing window (matches dispatch + IMPL § M4 line 207).
Dispatch: qitem-20260502024605-55720e41.
Predecessor: openrig a5132da (M3 marshal-gate PASS).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-01 19:47:58 -07:00
mvschwarzandClaude Opus 4.7 a5132dad87 feat(cli): Restore-Packet M3 — rig restore-packet read + validate actual implementations
Closes M3 dispatch qitem-20260502023319-d997e182. M2 marshal-acceptance gate PASSED at predecessor c7b74fa (cumulative across M2a + M2b + M2c-CLI + M2c-Daemon).

M3 SCOPE:
- read: replaces M3-pending stub with real implementation. Default human output prints restore-instructions.md body + summary metadata (source_session_id, source_rig, source_cwd, source_runtime, target_rig, target_runtime, generator_version, bounded_latest_transcript bound + message_count, omitted_classes) + transcript digest (byte size of transcript.md when present, or absence-line per contract § 1 parity rule). --json mode emits raw restore-summary.json verbatim (round-trippable per contract § 8).
- validate: replaces M3-pending stub with real implementation. Schema validation via existing validateRestoreSummary. Packet-shape check: 4 required files per contract § 1 (restore-instructions.md, transcript-latest.md, touched-files.md, restore-summary.json). Parity check (per contract § 1 + § 8): full_transcript summary key ↔ transcript.md presence; either-side-only is a required-field-class error. redaction_policy_id enum check ONLY (per contract § 4 + § 8: validator does NOT scan transcript content for residual credential patterns; v0 deliberate behavior matches Velocity precedent). --json mode emits { valid, errors: [{field,value,rule,severity}] }. Exit code matrix per contract § 8: required-field violations → exit nonzero; parity violations → exit nonzero; optional-field malformations → exit 0 with WARNING-level output; no errors → exit 0.

SCHEMA-VALIDATOR UPDATE:
- packages/cli/src/restore-packet/schema-validator.ts: differentiates severity per error. Errors at instancePath beginning with /full_transcript receive severity "warning" (full_transcript is the only top-level optional field in v0; nested malformations are optional-field issues). All other errors stay severity "error". `valid: false` semantics preserved on ANY ajv violation (preserves M2c writer pre-rename guard); the M3 validate command consults errors[].severity to decide the exit code per contract § 8.

NEW PACKET-SHAPE CONST:
- REQUIRED_PACKET_FILES const at packages/cli/src/commands/restore-packet.ts: ["restore-instructions.md","transcript-latest.md","touched-files.md","restore-summary.json"] per contract § 1. Single source of truth for the validate packet-shape check (and used by read for transcript-presence detection).

TESTS (TDD red-then-green; 14 new cases at packages/cli/test/restore-packet.test.ts):

read tests (4 cases):
- read human output: prints restore-instructions body + summary metadata + transcript digest line.
- read --json: stdout round-trippable to restore-summary.json (deep-equal check).
- read missing restore-summary.json: explicit error, exit nonzero.
- read packet without transcript.md AND without full_transcript key: success with "transcript.md absent" line.

validate tests (10 cases):
- validate ACCEPT well-formed packet WITH transcript.md + full_transcript key (parity respected).
- validate ACCEPT well-formed packet WITHOUT transcript.md AND WITHOUT full_transcript key (parity via absence).
- validate REJECT packet with full_transcript key but transcript.md absent (parity violation, exit nonzero).
- validate REJECT packet with transcript.md present but full_transcript key omitted (parity violation, exit nonzero).
- validate REJECT packet with required field missing (per-field error reported; source_session_id deletion case).
- validate REJECT packet missing one of 4 required files (touched-files.md deletion case).
- validate --json: machine-readable shape { valid: boolean, errors: [...] }.
- validate optional-field malformation (full_transcript.line_count wrong type): exit 0 with WARNING text per contract § 8 canonical rule.
- validate redaction_policy_id ENUM-ONLY check: packet with redaction_policy_id="openrig-v0" + transcript.md containing FAKETESTSTRING-NOT-A-REAL-CREDENTIAL-prefixed string → PASSES validate (deliberate v0 behavior per contract § 4 + § 8; matches Velocity precedent).
- validate redaction_policy_id WRONG enum value → REJECT.

All tests use createProgram().parseAsync against fixtures built via the M2c-CLI write action surface (Quality Lesson v12 carry-forward).

RE-VERIFICATION COMMANDS (all green; node v22.22.1 per better-sqlite3 native module 127):

cd packages/cli:
- npx vitest run test/restore-packet.test.ts → 100/100 PASS (86 baseline + 14 new M3).
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/schemas/restore-summary.schema.json present.

Compiled-output runtime checks (Quality Lesson v10 + v12):
- node -e '<createProgram parseAsync ["read", target, "--json"]>' → JSON.parse stdout yields summary; round-trip OK; source_session_id correct.
- node -e '<createProgram parseAsync ["validate", target, "--json"]>' → JSON.parse stdout yields { valid: true, errors: [] }.

Per-package honest baselines (Quality Lesson v8):
- packages/cli: 744 total / 740 PASS / 4 FAIL named flakes (chatroom wait timing, config-store env pollution, two preflight host-port). Baseline: 726 → 740 = +14 new tests; 4 flakes unchanged.
- packages/daemon: 2242 total / 2242 PASS / 0 FAIL. M3 did NOT touch daemon code; no spillover regression.

ENVIRONMENT NOTES:
- Node version: v22.22.1 (matches better-sqlite3 native module 127 per M2c-Daemon learning).
- All test commands run on host node; no Mode 3 Tart VM needed (write-side adapters already proven; read/validate touch only fs + JSON + schema-validator).

M3 MARSHAL-ACCEPTANCE GATE (per IMPL § M3 line 186-191):
ALL CRITERIA MET:
- All M2 tests still green: DONE (no regression).
- All M3 read/validate tests green: DONE (14/14).
- Per-field error reporting verified: DONE (source_session_id deletion test).
- Parity check tests (both directions) pass: DONE (parity-1 + parity-2 cases).
- redaction_policy_id enum-only test passes (deliberate v0 behavior preserved): DONE.
- Exit code matrix verified: DONE (required-field error → nonzero; parity → nonzero; warning-only → 0; clean → 0).
- Compiled-output runtime check for both read and validate passes: DONE.
- Posture full QA/testing window: DONE.
- Full CLI suite numbers match 726 + N new / 4 named flakes: DONE.
- Daemon suite remains 2242/2242 (no spillover): DONE.

BOUNDARY DISCIPLINE:
- ONLY 3 files changed (verified via git diff --cached --stat):
  packages/cli/src/commands/restore-packet.ts (+174/-8; read + validate impls + REQUIRED_PACKET_FILES const)
  packages/cli/src/restore-packet/schema-validator.ts (+9/-5; severity differentiation for full_transcript-path errors)
  packages/cli/test/restore-packet.test.ts (+312/-0; 14 new M3 cases)
- M2 surfaces UNTOUCHED: write CLI (M2c-CLI R2 deriveProvenance + adapter logic frozen); Codex/Claude parsers; redaction; runtime-detect; omitted-records; types; schema embed; packet-writer; daemon full-read route; daemon redaction module; daemon route tests.
- M4/M5/M6 surfaces UNTOUCHED.
- Velocity prior-art .mjs UNCHANGED.
- Unrelated dirty work (docs/*, ps.ts, codex-runtime-adapter, daemon SKILL.md) NOT staged.
- No real auth tokens, device codes, or credentials in fixtures (synthetic patterns use FAKETESTSTRING-NOT-A-REAL-CREDENTIAL prefix per dispatch guidance).

CUMULATIVE QUALITY-LESSON CARRY-FORWARD (v1-v9 + v10-v12 candidates):
- v6, v7 refined (M2c-Daemon's auth-NONE-per-orch-decision is a clean v7-refined posture-chain example).
- v8 (honest baseline match per-package).
- v9 (no real-looking credential strings; fixture uses obviously-synthetic FAKETESTSTRING-NOT-A-REAL-CREDENTIAL prefix).
- v10 (asset packaging): read + validate modules importable from compiled dist/.
- v11 (provider transcript parser nested-content coverage): preserved at M2b R2 + M2c regression suite.
- v12 (writer-internal round trips not enough for CLI source adapters): exercised at M3 — all 14 new tests use createProgram().parseAsync against fixture packets built via M2c write action.

Posture: full QA/testing window (matches dispatch + IMPL § M3 line 191).
Dispatch: qitem-20260502023319-d997e182.
Predecessor: openrig c7b74fa (M2 marshal-gate PASS).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-01 19:40:01 -07:00
mvschwarzandClaude Opus 4.7 c7b74fa08f feat(daemon,cli): Restore-Packet M2c-Daemon — full-read transcript route + final M2 regression
Closes M2c-Daemon dispatch qitem-20260502020626-8cd2b7a6. Final sub-chunk of M2; M2 marshal-acceptance gate evaluated as a whole at this commit.

Predecessors:
- M2a R2 ACCEPT: openrig 5e3a... (schema-validator + JSON schema asset).
- M2b ACCEPT: openrig bdf4cf6 (runtime parsers + redaction + omitted-records).
- M2b R2 ACCEPT: openrig 5c87145 (Claude parser nested tool_use/tool_result).
- M2c-CLI ACCEPT: openrig e5de3ab (packet-writer atomic emission + write action).
- M2c-CLI R2 ACCEPT: openrig a8401f6 (--source-jsonl provenance fix; honest fallback).

AUTH PRIMITIVE (NAMED):
The new GET /api/transcripts/:session/full route uses NO session-scoped authorization. Per orch decision approved-option-a-open-local-transcript-full-read-with-redaction (escalation qitem-20260502020833-68e4eca3), the route adopts the existing tail/grep posture: open route within the daemon's local-trust boundary (127.0.0.1:7433 default), with route-level redaction as the protective primitive. Audit confirmed the daemon middleware stack (server.ts:207-254) has no authentication layer; the only auth-bearing route in the daemon is activity.ts:8-27 (bearer/header token). No caller-identity primitive exists for transcripts today.

ORCH-REFRAMED TEST PLAN: the originally-requested session-scoped denial test (case b in dispatch) was reframed by orch to session-resolution failure (404 unknown session), since no caller-identity primitive exists. A future Product Lab signal should propose a coherent transcript-read auth policy across tail/grep/full — that work is OUT OF SCOPE for M2c-Daemon and is filed as a follow-up signal in this closeout.

ROUTE PATH: GET /api/transcripts/:session/full (consistent with /:session/tail and /:session/grep naming).

DAEMON SIDE (packages/daemon/):

1. NEW packages/daemon/src/domain/transcript-redaction.ts (35 lines):
   - TRANSCRIPT_SECRET_PATTERNS array: 5 regexes mirroring CLI's redaction.ts:31-37 (sk-*, gh[pousr]_*, github_pat_*, Bearer *, base64≥40). Patterns duplicated rather than imported because @openrig/cli is not a daemon dependency (and adding it would invert the package graph). Drift between these arrays is a defect; reviewers should diff them on any change.
   - redactTranscriptContent(text): pure function applying the v0 policy.

2. EXTEND packages/daemon/src/domain/transcript-store.ts:
   - readFull(rigName, sessionName): string | null — returns raw file contents (no terminal-cleanup heuristics; callers handle as needed). Returns "" for empty, null for missing/error. Imports readFileSync from node:fs.

3. EXTEND packages/daemon/src/routes/transcripts.ts:
   - GET /:session/full route at L222-244. Resolves session via existing resolveSessionToRig (inherits 404 unknown / 409 ambiguity). Calls transcriptStore.readFull → applies redactTranscriptContent BEFORE c.json serialization. Per Quality Lesson v9: the wire payload MUST be already redacted; client-side redaction is not relied upon.
   - Disabled-store check matches tail/grep behavior (404 with explicit "Transcripts are disabled" guidance).
   - Missing-file response: 404 with explicit "No transcript for '<session>'" guidance (mirrors tail/grep wording).

DAEMON ROUTE TESTS (packages/daemon/test/transcript-routes.test.ts):

6 new test cases (4 dispatch-required + 1 ambiguity bonus per orch's "if easy" + 1 missing-file companion):
- transcript-routes.test.ts:270-283 — (a) authorized success: existing session returns 200 with full content.
- transcript-routes.test.ts:285-296 — (b) session-resolution failure (orch reframe of denial): unknown session returns 404 with "not found" + "rig ps" guidance.
- transcript-routes.test.ts:298-325 — (c) route-level redaction: 4 synthetic credential patterns (sk-*, ghp_*, github_pat_*, Bearer *) planted in fixture; assertion that none appear in wire payload AND [REDACTED] marker present AND surrounding context preserved.
- transcript-routes.test.ts:327-336 — (d-disabled) transcripts disabled: 404 with "disabled" guidance.
- transcript-routes.test.ts:338-351 — (d-missing) known session but no file: 404 with "No transcript" guidance.
- transcript-routes.test.ts:353-372 — ambiguity 409 across rigs (matches tail/grep precedent; orch easy-bonus case).

CLI FINAL M2 REGRESSION (packages/cli/test/restore-packet.test.ts):

6 new test cases:
- M2c-Daemon final regression — Velocity 4-pack round-trip via CLI: 4 cases, one per Velocity prior-art role (driver, guard, planner, tester). Loads each role's restore-summary.json from field-notes/2026-04-27-velocity-claude-from-codex-restore/, locates the original Codex JSONL via source_jsonl field, runs --source-jsonl through createProgram().parseAsync (Quality Lesson v12 surface), passes --source-session-id-override + --source-rig-override matching the reference packet's source_session (Codex JSONLs carry UUID-shaped rollout ids, not <seat>@<rig> shape — the operator-supplied canonical name in the prior-art .mjs is mirrored via overrides). Asserts: schema validation, source_session_id == reference source_session, source_rig from @ split, source_cwd matches reference, redaction_policy_id == "openrig-v0", omitted_classes non-empty (Velocity packets have many reasoning_records and function_call_output records). Driver case parses 16,455 JSONL lines → 1,218 extracted messages, matching the original .mjs run exactly. Tests gated on file presence; skip with console.warn when source JSONLs absent (other-host portability).

- M2c-Daemon final regression — CLI-surface redaction + omitted round-trips: 2 cases.
  - --source-jsonl with credential-pattern fixture: synthetic sk-* in assistant message → emitted transcript-latest.md does NOT contain the secret AND contains [REDACTED]; summary.omitted_classes contains "redacted_secrets"; redaction_policy_id == "openrig-v0".
  - --source-jsonl with M2b R2 nested Claude content (tool_use + tool_result inside top-level assistant/user records — Quality Lesson v11 reproducer): emitted summary.omitted_classes contains both function_call_output and raw_tool_outputs; provenance fields preserved from per-record sessionId; touched-files inventory captures path from omitted tool_result. Exercises Quality Lesson v11 + v12 simultaneously via CLI surface.

PROVENANCE PARITY WITH VELOCITY PRIOR ART:
Driver: 1218 extracted messages (matches original .mjs exactly).
Guard: 1031, Planner: 602, Tester: 854 (no original-run reference cached but counts realistic).
All four packets validate against v0 schema; all four preserve source_session_id + source_rig + source_cwd from operator overrides matching the reference packets.

RE-VERIFICATION COMMANDS (all green):

cd packages/cli:
- npx vitest run test/restore-packet.test.ts → 86/86 PASS (75 baseline + 5 R2 + 6 M2c-Daemon final regression).
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/schemas/*.json present.

cd packages/daemon:
- npx vitest run test/transcript-routes.test.ts → 17/17 PASS (11 baseline + 6 new full-read).
- npx tsc --noEmit → exit 0.
- npm run build → exit 0.

Compiled-output check (Quality Lesson v10 + v12):
- node -e 'await import("./dist/routes/transcripts.js")' → transcriptRoutes: function ✓
- node -e 'await import("./dist/domain/transcript-redaction.js")' → redactTranscriptContent: function; sample input "token sk-FakeAbCdEfGhIjKlMnOpQr seen" → "token [REDACTED] seen" ✓

Per-package honest baselines (Quality Lesson v8):
- packages/cli (npx vitest run --root packages/cli): 730 total, 726 PASS / 4 FAIL named flakes (chatroom wait timing, config-store env pollution, two preflight host-port).
- packages/daemon (npx vitest run --root packages/daemon): 2242 total, 2242 PASS / 0 FAIL.
No regression vs M2c-CLI R2 baseline (CLI: 720→726 = +6 new; daemon: 2236→2242 = +6 new).

M2 MARSHAL-ACCEPTANCE GATE (per IMPL § M2 line 142-152, evaluated end of M2c-Daemon):
- M1 contract approved → DONE.
- restore-packet write emits valid packets matching contract § 1 shape (4 required + optional transcript.md) for BOTH adapters → DONE.
- Velocity JSONL round-trip green for ALL FOUR role packets at provenance + omitted + redaction + schema fields → DONE (4 CLI tests).
- Mock-daemon --source-session round-trip green (exercising new full-read route shape) → DONE (preserved at restore-packet.test.ts:1088-1148; updated for R2 with --source-rig-override).
- Mutual-exclusion + auto-detect tests still green → DONE (M2a regression preserved).
- Atomic emission verified (validation failure → no partial directory) → DONE (preserved at restore-packet.test.ts:943-963 + 928-941).
- Redaction + omitted-record filtering verified end-to-end → DONE (new CLI redaction test + Velocity 4-pack + daemon route test c).
- Daemon route tests for full-read route landed and green (4 cases: auth + redaction + disabled/missing) → DONE (6 cases shipped — 4 required + 2 bonus per orch).
- Closeout names auth primitive + cites daemon route tests by file:line → DONE (above).
- No regression in existing CLI / daemon test suites → DONE.
- Posture declared full QA/testing window → DONE.

BOUNDARY DISCIPLINE:
- ONLY 5 files changed (verified via git diff --cached --stat):
  packages/cli/test/restore-packet.test.ts (+235)
  packages/daemon/src/domain/transcript-redaction.ts (NEW, +35)
  packages/daemon/src/domain/transcript-store.ts (+25/-1; readFull addition)
  packages/daemon/src/routes/transcripts.ts (+49)
  packages/daemon/test/transcript-routes.test.ts (+121)
- M3+ surfaces UNTOUCHED (read/validate stubs remain M3-pending; live host walk M4-pending; VM proof M5-pending).
- Velocity prior-art .mjs at field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/codex-jsonl-to-restore-packet.mjs UNCHANGED.
- Existing tail/grep routes UNCHANGED (per orch — no retrofit in this chunk).
- Codex/Claude parser, redaction (CLI), runtime-detect, omitted-records, types, schema-validator, M2a CLI shell mutual-exclusion, M2c-CLI packet-writer, R2 deriveProvenance: ALL PRESERVED.
- Unrelated dirty work (docs/*, ps.ts, codex-runtime-adapter, daemon SKILL.md) NOT staged.
- No auth tokens, device codes, or real credentials in fixtures (Quality Lesson v9). Synthetic patterns in redaction tests use "Fake" prefix to make non-credential intent unambiguous.
- No transcript content in daemon stdout/stderr (route returns JSON via c.json; no console.log of content).

PRODUCT LAB FOLLOW-UP SIGNAL (per orch directive):
A future slice should propose a coherent transcript-read auth policy across /:session/tail, /:session/grep, and /:session/full. Today these are all open routes within the local-trust boundary; route-level redaction is the only protective primitive. Discovery should examine: (1) whether to add session-token bearing at peer-handshake time, (2) whether to repurpose the activity-hook bearer-token primitive for transcript reads, (3) whether to factor a daemon-wide auth middleware that gates by route classification. This signal will be filed as a durable artifact in the dogfood-ready or work-discovery pool by the velocity-driver after this commit lands.

CUMULATIVE QUALITY-LESSON CARRY-FORWARD (v1-v9 + v10-v12 candidates):
- v6, v7 refined, v8 (honest baseline match per-package), v9 (no transcript content / credentials in logs / fixtures).
- v10 (asset packaging): packet-writer + daemon route handler + daemon redaction module ALL importable from compiled dist/.
- v11 (provider transcript parser nested-content coverage): exercised in regression suite (CLI-surface omitted-record round-trip with nested Claude shape).
- v12 (writer-internal round trips not enough for CLI source adapters): exercised in regression suite (Velocity 4-pack round-trip + redaction round-trip + nested-content round-trip ALL via createProgram().parseAsync).

Posture: full QA/testing window (matches dispatch + IMPL § M2 line 152).
Dispatch: qitem-20260502020626-8cd2b7a6.
Stop-and-flag: qitem-20260502020833-68e4eca3 (orch decision approved-option-a).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-01 19:20:32 -07:00
mvschwarzandClaude Opus 4.7 a8401f6181 fix(cli): Restore-Packet M2c-CLI R2 — --source-jsonl provenance from parsed session_meta
Closes guard BLOCK on M2c-CLI at e5de3ab. Per dispatch
qitem-20260502014948-af2d4713 + BLOCK artifact at
substrate/rigs/openrig-velocity/state/orch/restore-packet-M2c-cli-guard-checkpoint-block-2026-05-02.md.

Bug (R1 / e5de3ab):
- `--source-jsonl` adapter wrote the JSONL FILE PATH into
  restore-summary.json.source_session_id (not the parsed session id).
- packet-writer.ts:174-175 silently derived source_rig from sourceSessionId
  via @ split, masking the bug as `source_rig: "unknown"` for any value
  without @ shape.
- Schema-valid but semantically WRONG; exactly the source-provenance
  drift the restore-packet standard exists to prevent. Writer-internal
  tests bypassed the parse-to-write provenance path entirely.

Fix:
1. CLI adapter (commands/restore-packet.ts): after parseCodexJsonl /
   parseClaudeTranscript, derive canonical (source_session_id, source_rig)
   honestly via new deriveProvenance() helper.
   - For --source-jsonl: prefer parsed structured.sessionMeta?.sessionId
     over the file path; fail with explicit operator guidance if absent
     and no override flag supplied.
   - source_rig: <seat>@<rig> regex split; fail if shape doesn't match
     and no override flag supplied. No silent "unknown" fallback.
2. New flags --source-session-id-override and --source-rig-override give
   operators an explicit honest path when source JSONL has no session_meta
   (e.g., raw test fixtures, mid-session captures) or session id is bare.
3. packet-writer.ts: WritePacketOptions adds required `sourceRig: string`.
   Writer no longer parses sourceSessionId — that derivation moved to the
   adapter where it belongs.

Tests (TDD red-then-green; 5 new R2 cases at the CLI surface using
createProgram().parseAsync with synthetic JSONL fixtures, exercising the
parse-to-writePacket adapter path that 75/75 was bypassing):
- guard reproducer: --source-jsonl with session_meta sets
  source_session_id from parsed id and source_rig from <seat>@<rig> split.
- --source-jsonl with NO session_meta and NO overrides fails with
  explicit operator guidance referencing both override flags.
- --source-jsonl with NO session_meta + both overrides succeeds with
  overridden provenance.
- --source-jsonl with bare-id session_meta fails without
  --source-rig-override (no silent unknown).
- --source-jsonl with --source-session-id-override overrides parsed
  session_meta id (override-wins precedence).

Updates to existing tests:
- M2c-CLI atomic-emission, Velocity-shape round-trip, M2b R2 nested-content
  fixtures pass `sourceRig:` explicitly to writePacket() (now required).
- Mock-daemon --source-session round-trip uses --source-rig-override
  for bare "src-session" fixture and asserts source_rig is honored.

Re-verification (all green; honest baseline match per Quality Lesson v8):
- npx vitest run test/restore-packet.test.ts → 80/80 pass (75 baseline + 5 R2).
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/schemas/restore-summary.schema.json present.
- Compiled-output guard reproducer (BLOCK artifact verbatim) →
  source_session_id="velocity-driver@openrig-velocity",
  source_rig="openrig-velocity", validation_valid=true.
- Full CLI suite → 720/724; 4 failures match named flake baseline
  (chatroom wait timing, config-store env pollution, two preflight
  host-port). No regression vs 715/719 + same 4 flakes pre-R2.

M2c-Daemon surfaces (packages/daemon/src/routes/transcripts.ts +
packages/daemon/test/transcript-routes.test.ts) NOT touched per orch
sub-chunking. Velocity prior-art .mjs UNCHANGED. M3 read/validate stubs
preserved. No auth tokens or credentials in fixtures (Quality Lesson v9).

Quality Lesson v12 (NEW; filed at this dispatch): writer-internal round
trips are not enough for CLI source adapters — slices that claim adapter
functionality MUST include at least one compiled-output CLI-path
provenance check that exercises the actual command surface
(createProgram().parseAsync against synthetic JSONL with realistic
session_meta). Filed inline at this commit; will be formalized at M2
marshal-acceptance close.

Posture: full QA/testing window (matches M2 declared posture).
Dispatch: qitem-20260502014948-af2d4713.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-01 19:01:08 -07:00
mvschwarz e5de3abd12 feat(cli): Restore-Packet vertical M2c-CLI — packet-writer atomic emission + write action impl
Per orch decision (Path B sub-chunked M2c; M2c-CLI chunk first per
qitem-20260502013530-1fad3a3d acknowledgment): land packet-writer +
restore-packet write action full impl + CLI defaults + 12 new round-trip
tests. M2c-Daemon (full-read transcript route + auth + redaction +
daemon route tests) ships separately.

Files (3 changed, 1 new, 1095+ insertions):

NEW packages/cli/src/restore-packet/packet-writer.ts (~280 lines):
- writePacket(opts) → atomically writes the v0 packet directory.
- Atomic emission: writes to mkdtemp sibling tempdir, schema-validates
  the resulting restore-summary.json against RESTORE_SUMMARY_SCHEMA
  (the M2a R2 const), then fs.renameSync tempdir → targetDir. On any
  validation failure (in-memory pre-check OR on-disk recheck after
  write) OR write error: rmSync the tempdir; targetDir is NEVER created;
  caller sees the error.
- Emits 4 required files (restore-instructions.md, transcript-latest.md,
  touched-files.md, restore-summary.json) per contract § 1; emits
  optional transcript.md when includeFullTranscript=true and adds the
  full_transcript summary key in lockstep (parity rule).
- Bounded transcript-latest at 120 messages (TRANSCRIPT_BOUND const,
  matching contract § 7's v0 fixed bound).
- restore-summary.json carries all 21 contract § 2.1 required fields:
  source_session_id / source_rig (derived from session_name@rig
  splitting) / source_runtime / source_cwd / target_rig / target_runtime
  / target_workspace_root / default_target_repo / role_pointer /
  bounded_latest_transcript / touched_files / durable_pointers (4
  required arrays, all empty by default — operator-supplied via flags
  in v1+) / current_work_summary / next_owner / caveats /
  authority_boundaries / omitted_classes / redaction_policy_id
  ("openrig-v0" const) / source_trust_ranking / generator_version /
  generated_at (ISO-8601 now).

EDIT packages/cli/src/commands/restore-packet.ts:
- Replaced the M2a-deferred error stub in the write action with full
  implementation:
  - Mutual-exclusion + missing-source checks preserved from M2a.
  - --source-jsonl branch: readFileSync → runtime-detect (or explicit
    --source-runtime override) → parseCodexJsonl/parseClaudeTranscript →
    writePacket.
  - --source-session branch: getDaemonStatus + getDaemonUrl + DaemonClient
    GET to /api/transcripts/<session>/full (the route M2c-Daemon will
    land); same parser dispatch + writePacket pipeline.
  - --source-runtime explicit value validated against codex/claude-code
    enum; auto-detect when omitted; explicit error when ambiguous.
  - 7 new CLI flags for the contract-required fields not derivable from
    parser output: --target-rig, --target-runtime, --target-workspace-root,
    --default-target-repo, --role-pointer, --current-work-summary,
    --next-owner, --caveat (repeatable), --authority-boundaries,
    --source-trust-ranking, --generator-version. Sensible defaults for
    each so the minimum command shape is `rig restore-packet write
    --source-jsonl <path> --target <dir>`.
  - Per Quality Lesson v9: log only metadata (target dir, file list,
    message count, omitted-class counts). Transcript content NEVER
    written to stdout/stderr.
- read + validate subcommand stubs unchanged (M3-pending).

EXTEND packages/cli/test/restore-packet.test.ts (+12 tests; 63 → 75):

- 6 packet-writer atomic emission tests:
  - 4 required files + summary schema-valid
  - transcript.md present when includeFullTranscript=true; full_transcript
    key in summary
  - all 21 contract § 2.1 required fields populated from operator opts
  - reject when target dir already exists (preserves marker.txt)
  - atomic emission: schema-validation failure mid-write → no target dir
    created AND tempdir cleaned up
  - transcript-latest bounded at 120; message_count reflects actual
    count (150-message input → message_count=120)
- 4 Velocity-shape round-trips (driver/guard/planner/tester roles):
  synthetic Codex JSONL fixtures emulating Velocity shape (session_meta
  + tool_call + reasoning + 2 messages with redaction needle); each role
  → parser → packet-writer → schema-valid; provenance preserved at
  source_session_id, source_cwd, bounded_latest_transcript.message_count;
  omitted_classes contains reasoning_records + function_call_output +
  redacted_secrets; transcript.md does NOT contain "sk-FakeAbCdEfGhIjKlMn"
  (redaction applied end-to-end).
- 1 mock-daemon --source-session round-trip (compact-plan.test.ts:166-176
  pattern): mock daemon-lifecycle module-level; mock client serves
  /api/transcripts/<session>/full with synthetic JSONL; CLI write
  command produces packet; mutation methods (post/delete/postText/
  postExpectText) NOT called.
- 1 M2b R2 nested Claude content round-trip: full assistant tool_use +
  user tool_result fixture → packet emitted → omitted_classes contains
  function_call_output + raw_tool_outputs; touched_files.top_paths
  includes the path from the omitted tool_result content.

TDD discipline (per memory feedback_tdd_scope): all M2c-CLI tests
authored to fail FIRST. Test commit: 11 fail (modules don't exist), 1
pass (the writer's "transcript-latest bounded" test was passing on
parser data alone). Then packet-writer + write action implemented; all
75 pass.

Re-verification gates (per dispatch):

- npx vitest run test/restore-packet.test.ts → 75/75 pass.
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/restore-packet/packet-writer.js +
  dist/schemas/restore-summary.schema.json present.
- Compiled-output check: node -e 'import(.../packet-writer.js).then(...)'
  → writePacket: function. (Schema-validator embedded const pattern from
  M2a R2 means the validator works after raw tsc emit; packet-writer
  uses it directly.)
- Full CLI suite: 715/719 pass. M2b R2 baseline 703/707 + 12 new M2c-CLI
  tests = 715/719. Same 4 named environmental flakes (chatroom 10s
  timing, config-store env pollution, preflight x2 host port 7433); none
  touch M2c-CLI files. NO regression.

Boundary discipline (per orch sub-chunking decision):
- M2c-Daemon surfaces NOT touched (packages/daemon/src/routes/transcripts.ts
  unchanged; packages/daemon/test/transcript-routes.test.ts unchanged).
- The /api/transcripts/<session>/full route path is a DRAFT in this
  commit; M2c-Daemon owns the actual route implementation + auth
  primitive identification. If M2c-Daemon picks a different path, the
  same commit can adjust this CLI line.
- Velocity prior-art .mjs FROZEN reference UNCHANGED.
- restore-packet read + validate stay M3-pending stubs.
- M3/M4/M5/M6 surfaces NOT touched.
- No daemon code changes.
- No unrelated dirty work staged.

Quality lessons applied:
- v3 distinct test summary prefixes.
- v4 marshal-layer wire-shape: tests assert each contract § 2.1
  required field populated correctly + bounded_latest_transcript.bound
  const 120 + redaction_policy_id "openrig-v0".
- v5 test-naming-vs-instantiation: tests invoke writePacket and
  restorePacketCommand directly; mock-daemon test exercises real CLI
  command parsing + real CLI dispatch through a mocked DaemonClient.
- v6 boundary isolation: M2c-CLI stays in packages/cli/src/restore-packet/
  + packages/cli/src/commands/restore-packet.ts. Daemon code untouched.
- v7 refined: M2c-CLI does NOT extend M2b R2 grant; cites the M2c
  sub-chunking decision in this commit message.
- v8 honest baseline: full CLI suite re-run; named flakes baseline-checked.
- v9 synthetic tokens only: all redaction fixtures use synthetic patterns
  (sk-FakeAbCdEf..., etc.); no real provider tokens in fixtures or
  commits. CLI logs metadata only — never transcript content.
- v10 schema-packaging gap: packet-writer uses validateRestoreSummary
  which embeds the schema as a TS const (M2a R2 fix); compiled-output
  runtime check confirms reachability from dist.
- v11 (from M2b R2): nested-content-part counter for Claude parser is
  exercised end-to-end via the M2b R2 round-trip test which round-trips
  the guard reproducer fixture through packet-writer and asserts the
  resulting summary has function_call_output and raw_tool_outputs in
  omitted_classes.

Posture: full QA/testing window (per dispatch).

Source qitem: qitem-20260502013415-e9dab505 (M2c dispatch).
Sub-chunking decision: orch picked Path B (split M2c → M2c-CLI + M2c-Daemon)
in reply on qitem-20260502013530-1fad3a3d.
2026-05-01 18:44:01 -07:00
mvschwarz 5c87145e54 fix(cli): Restore-Packet M2b R2 — Claude parser nested tool_use/tool_result handling
Guard BLOCKED M2b at openrig bdf4cf6: Claude transcript parser silently
dropped `tool_use` parts (inside assistant content) and `tool_result`
parts (inside user content) without counting them against the omitted-
record-class enum, leading to all-zero `omittedCounts` on real Claude
transcripts where every assistant turn typically carries a `tool_use`
and most user turns carry a `tool_result`. M1 contract § 5 requires
`omitted_classes` to enumerate the classes ACTUALLY excluded from
transcript output; the previous parser would emit a contract-violating
all-zero summary at M2c.

R2 fix in src/restore-packet/claude-transcript-parser.ts:

- Replace `textFromClaudeMessage` with `walkClaudeContent` returning
  `{ text, toolUseCount, toolResultCount, toolPaths }`. The walker
  inspects each content part once, branching on `part.type`:
  - `"text"` → contribute to user-visible text.
  - `"tool_use"` → increment `function_call_output` counter; serialize
    `part.input` and route to path extraction.
  - `"tool_result"` → increment `raw_tool_outputs` counter; flatten
    `part.content` (string OR array of `{ type: "text" }` parts) and
    route to path extraction.
- The kept-message branch now records nested-part omissions BEFORE
  deciding whether to drop a record without visible text. A user turn
  that's pure tool_result (no visible text; common in real transcripts)
  no longer emits a kept message but DOES count its tool_result against
  the omitted-class counter and contributes the tool_result content to
  the path inventory.
- Path extraction from omitted nested parts mirrors the Codex parser's
  behavior of walking function_call args / custom_tool_call inputs for
  paths even though the records themselves are omitted from messages.

TDD discipline (per memory feedback_tdd_scope): 8 new tests authored to
fail FIRST. Test commit: 7 fail, 1 pass (a happy-path baseline). Then
the parser fix made all 8 pass. The committed state is the green state.

Tests added (8 new in `M2b R2 Claude parser nested-content-part handling`
describe block):

- guard reproducer fixture (the exact reproducer from the BLOCK
  artifact): assistant with tool_use + text, user with tool_result
  → omittedCounts.function_call_output=1, raw_tool_outputs=1, paths
  contains "/Users/wrandom/code/projects/openrig-hub".
- counts each tool_use part in a multi-tool assistant turn (tool_use*2 +
  text → function_call_output=2; path "/Users/wrandom/code/x.md"
  extracted from input).
- counts each tool_result part in a multi-result user turn
  (tool_result*2 → raw_tool_outputs=2; both paths extracted).
- mixed text + tool_use: visible text kept; tool_use counted.
- tool_use with non-path input: counted but contributes no paths
  (the `cmd: "echo hello"` doesn't match the /Users/wrandom/ or
  recognized-prefix patterns).
- tool_result with non-path content: same — counted but no paths.
- tool_result with array content shape: walks the array's text parts
  for paths.
- kept text-only assistant turn: counters stay at zero (no regression
  on the simple-text path).

Re-verification (per dispatch's required-fix list):

- npx vitest run test/restore-packet.test.ts → 63/63 pass (55 prior +
  8 new).
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/schemas/restore-summary.schema.json
  copied; dist/restore-packet/claude-transcript-parser.js emitted with
  the fix.
- Compiled-output runtime check using the guard's exact reproducer:
    node -e 'import(...claude-transcript-parser.js).then(...)'
  → output: `function_call_output: 1 raw_tool_outputs: 1 paths_count: 1`.
  Confirms the fix is reachable from compiled JS, not just source TS.
- Full CLI suite re-run: 703/707 pass. M2b baseline was 695/699; R2 adds
  8 tests, all green → 703/707. Same 4 pre-existing environmental flakes:
  - test/chatroom.test.ts > Chatroom wait detects newly arriving post-start
    message (10s timing flake)
  - test/config.test.ts > DaemonClient config integration > ConfigStore
    non-default host/port (env pollution)
  - test/preflight.test.ts > rig preflight --json (host port 7433 in use)
  - test/preflight.test.ts > rig preflight check marks (host port 7433 in use)
  All four touch zero files in this R2 commit. No regression.

Scope (R2 stays narrow per dispatch):

- Codex parser path-inventory shape NOT changed (the Claude walker
  mirrors Codex behavior; no Codex parser regressions).
- Velocity prior-art .mjs FROZEN reference UNCHANGED.
- M2c surfaces (packet-writer.ts, atomic emission, daemon full-read
  transcript route, daemon route tests) NOT touched.
- restore-packet write stays M2a-deferred error stub.
- restore-packet read + validate stay M3-pending stubs.
- M3/M4/M5/M6 surfaces NOT touched.
- No openrig source changes outside the two R2 files.
- No daemon code changes.
- No unrelated dirty work staged.

Quality lessons applied:

- v6 boundary isolation: R2 stays inside packages/cli/src/restore-packet/.
- v7 refined: R2 cites M2b R1 BLOCK artifact in this commit message; does
  NOT extend M2b R1 grant.
- v8 honest baseline: full CLI suite re-run; named flakes baseline-checked.
- v9 synthetic tokens only: all R2 fixtures use synthetic strings.
- v10 schema-packaging gap: schema-validator self-contained pattern
  unaffected; R2 modules are pure TypeScript with no runtime asset
  loading.
- v11 candidate (NEW from this BLOCK): "provider transcript parser
  nested-content-part coverage." Parser acceptance tests for provider
  transcript adapters MUST cover provider-native nested tool-call /
  tool-result / inline-tool-use shapes — not only top-level synthetic
  omitted records. R2 applies this lesson: 8 new focused tests cover
  Claude's nested-content-part shape AND the guard reproducer fixture
  is asserted byte-for-byte.

Posture: full QA/testing window (per dispatch).

Source qitem: qitem-20260502011841-fb53d7fd (M2b R2 dispatch).
Guard verdict that triggered this: rigs/openrig-velocity/state/orch/restore-packet-M2b-guard-checkpoint-block-2026-05-02.md.
2026-05-01 18:22:27 -07:00
mvschwarz bdf4cf6844 feat(cli): Restore-Packet vertical M2b — runtime parsers + runtime-detect + redaction + omitted-records
Per orch decision (Path B chunked M2; M2b chunk after M2a R2 ACCEPT at 40d0724):
five new sub-modules under packages/cli/src/restore-packet/ + extended test
coverage. Velocity prior-art `.mjs` borrowed STRUCTURALLY only (translated to
TypeScript); the .mjs file at field-notes/.../tools/codex-jsonl-to-restore-packet.mjs
remains FROZEN (unchanged).

Sub-modules:

- NEW src/restore-packet/types.ts: shared TypeScript types — ExtractedMessage,
  OmittedRecordClass enum (4 values per contract § 5), PathCount, TypeCounts,
  OmittedCounts, SessionMeta, StructuredTranscript, SourceRuntime. Both
  parsers emit StructuredTranscript; downstream packet-writer (M2c) consumes.

- NEW src/restore-packet/redaction.ts: openrig-v0 policy implementation.
  SECRET_PATTERNS const exports the 5-pattern list verbatim from Velocity
  prior art `:34-48` (sk-*, gh[pousr]_*, github_pat_*, Bearer *, base64 ≥40).
  redact(text) replaces matches with `[REDACTED]`. hasSecretPattern(text) is
  the boolean probe used by the parsers' redaction-counter.

- NEW src/restore-packet/omitted-records.ts: per-record-class classifier.
  classifyCodexRecord maps Codex `payload.type` to one of the 4 omitted
  classes or `kept`; classifyClaudeRecord does the same for Claude `type`
  values. OmittedCounter accumulates per-class counts and exposes
  activeClasses() for the summary's `omitted_classes` field.

- NEW src/restore-packet/runtime-detect.ts: detectRuntime(content) →
  "codex" | "claude-code" | null. Uses content shape (Codex `type:
  "response_item"|"session_meta"|"compacted"` markers vs Claude `type:
  "user"|"assistant"`). Per dispatch directive "Do NOT use file extensions
  or filenames as the detection key. Detect by content shape." — verified
  by negative tests with filename-shaped strings.

- NEW src/restore-packet/codex-jsonl-parser.ts: parseCodexJsonl(content) →
  StructuredTranscript. Translates Velocity `:81-160` to TypeScript:
  session_meta capture, compacted counter, response_item dispatch (kept
  user-visible messages get redacted + path-extracted; tool calls have
  paths extracted then omitted; reasoning records counted as omitted).
  Malformed JSONL lines skipped silently (matches Velocity behavior).

- NEW src/restore-packet/claude-transcript-parser.ts: parseClaudeTranscript(
  content) → StructuredTranscript. NEW work; no prior art. Emits the
  SAME shape as the Codex parser. Walks Claude transcript JSONL: captures
  sessionMeta from first record carrying cwd + sessionId; user/assistant
  records become messages (with redaction); attachment records go into
  raw_tool_outputs; other meta records go into reasoning_records.
  compactedCount is 0 (Claude transcripts don't emit compaction records).

Tests (37 new tests added to packages/cli/test/restore-packet.test.ts;
total 18 M2a + 37 M2b = 55):

- 7 redaction tests (each pattern + non-credential round-trip + pattern
  list size).
- 9 omitted-records tests (classifier branches per runtime + counter
  accumulation + active-classes ordering).
- 7 runtime-detect tests (codex via response_item / session_meta;
  claude via user/assistant; null on empty/garbage/ambiguous; explicit
  "no filename heuristic" negatives).
- 7 codex-jsonl-parser tests (session_meta + messages; reasoning +
  function_call + custom_tool_call filtering; compacted counter; redaction
  in content; malformed line skip; path extraction with frequency sort).
- 6 claude-transcript-parser tests (user + assistant; sessionMeta capture;
  attachment → raw_tool_outputs counter; redaction; compactedCount 0;
  shape conformance with codex parser via Object.keys equality).
- 1 interaction test (Codex parse → redact → omitted-records counter
  chain produces a structured representation with all 3 codex omitted
  classes counted AND the redacted_secrets counter incremented for the
  user message that had a credential pattern).

TDD discipline applied per memory feedback_tdd_scope: tests reference
modules that didn't exist → import failures → modules implemented to
green. The committed state is the green/passing state.

Re-verification (per dispatch's required-fix list):

- npx vitest run test/restore-packet.test.ts → 55/55 pass.
- npx tsc --noEmit → exit 0.
- npm run build → exit 0; dist/restore-packet/{types,redaction,
  omitted-records,runtime-detect,codex-jsonl-parser,claude-transcript-parser}.js
  emitted; dist/schemas/restore-summary.schema.json copied.
- Compiled-output runtime check: node -e import each compiled module →
  detectRuntime, redact, parseCodexJsonl, parseClaudeTranscript all
  loadable as functions.
- Full CLI suite re-run (per Quality Lesson v8 — never silently downgrade):
  695/699 pass. Baseline at M2a R2 was 658/662; M2b adds 37 tests, all
  passing → 695/699. Failure pattern UNCHANGED:
  - test/chatroom.test.ts > Chatroom wait detects newly arriving post-start
    message (10s timing flake)
  - test/config.test.ts > DaemonClient config integration > ConfigStore
    non-default host/port (env pollution)
  - test/preflight.test.ts > rig preflight --json (host port 7433 in use)
  - test/preflight.test.ts > rig preflight check marks (host port 7433 in use)
  All four pre-existing environmental flakes touch zero files in this M2b
  commit. No regression.

Boundary discipline (per dispatch — M2b does NOT touch):

- packet-writer.ts (M2c).
- Atomic write-via-tempdir-then-rename (M2c).
- Daemon full-read transcript route extension (M2c).
- Daemon route tests (M2c HARD DELIVERY FOLLOW-UP).
- restore-packet write actual implementation: stays the M2a-deferred
  error stub. UNCHANGED.
- restore-packet read + validate: stay M3-pending stubs. UNCHANGED.
- M3/M4/M5/M6 surfaces.

Quality lessons applied:

- v3 distinct test summary prefixes: assertion failures use ASSERTIONS_
  prefixes where applicable; test-runner reports its own PASS/FAIL.
- v4 marshal-layer wire-shape: shape-conformance test asserts both
  parsers expose identical top-level keys and identical omittedCounts
  enum keys (the 4 contract values).
- v5 test-naming-vs-instantiation: tests invoke parseCodexJsonl /
  parseClaudeTranscript / detectRuntime / redact / classify* / OmittedCounter
  directly, NOT mock-spy.
- v6 boundary isolation: M2b stays inside packages/cli/src/restore-packet/;
  daemon/test code untouched.
- v7 refined: M2b does NOT extend M2a R2 grant; this commit cites the
  separate M2b grant via the dispatch packet ID.
- v8 honest baseline: full CLI suite re-run; named flakes baseline-checked.
- v9 no auth tokens: all fixtures use synthetic tokens (sk-AbCdEf...,
  ghp_AbCdEfGh..., etc. — clearly synthetic, NOT real provider tokens).
- v10 schema-packaging gap (M2a R2 lesson): M2b sub-modules are pure
  TypeScript (no runtime asset loading), so the M2a R2 packaging issue
  doesn't apply here. The schema embed + drift-catcher pattern from M2a
  R2 stands.

Posture: full QA/testing window (per dispatch).

Source qitem: qitem-20260502010558-463ff419 (M2b dispatch).
Parent: M2a R2 ACCEPT at openrig 40d0724 (qitem-20260502005113-24bf9de8 closure).
2026-05-01 18:13:38 -07:00
mvschwarz 40d07241ab fix(cli): Restore-Packet M2a R2 — schema packaging fix; validator self-contained after raw tsc emit
Guard BLOCKED M2a at de2dbd4 with a build/packaging gap: schema-validator
read `restore-summary.schema.json` from disk via import.meta.url, but tsc
does not copy non-TS files into dist. Compiled validator hit ENOENT at
runtime in the guard's reproducer (raw `npx tsc --outDir <cache> --rootDir
src` + `node -e "import(...).then(m => m.validateRestoreSummary({}))"`).

R2 fix (combined approach 2 + approach 1 from guard verdict):

(1) src/restore-packet/schema-validator.ts: schema is now embedded as a
typed TS const RESTORE_SUMMARY_SCHEMA (verbatim from contract § 2.1 and
the canonical .json file). Validator uses the const directly; no
import.meta.url file read at runtime. tsc emits the const into compiled
JS, so `tsc` alone (no copy step) leaves a working validator.

(2) src/schemas/restore-summary.schema.json: kept as the canonical source
for downstream IDE / tooling consumption. Unchanged content.

(3) test/restore-packet.test.ts: added a drift-catcher test that loads
the JSON file and asserts `RESTORE_SUMMARY_SCHEMA` (the TS const) equals
it byte-for-byte. Prevents the two forms from diverging silently. Test
count 17 → 18.

(4) packages/cli/package.json: build script changed from `tsc` to
`tsc && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/`.
This is approach 1 from the guard verdict — keeps the JSON Schema
reachable in `dist/schemas/` for downstream tooling AFTER `npm run build`.
Note: the guard's reproducer uses raw `tsc` (not `npm run build`), so the
copy step is for distribution only; the runtime correctness comes from
approach 2 (the embedded TS const).

Re-verification (per guard's required-fix list):

- `npx vitest run test/restore-packet.test.ts` → 18/18 pass (M2a focused
  suite + drift-catcher; M2a's prior 17 still green plus the new test).
- `npx tsc --noEmit` → exit 0; no type errors.
- Guard's reproducer:
    rm -rf node_modules/.cache/openrig-cli-m2a-r2-build-check
    npx tsc --outDir node_modules/.cache/openrig-cli-m2a-r2-build-check --rootDir src
    node -e "import('node_modules/.cache/openrig-cli-m2a-r2-build-check/restore-packet/schema-validator.js').then((m) => { const r = m.validateRestoreSummary({}); console.log('valid:', r.valid, 'errors:', r.errors.length); }).catch(...)"
  → output: `valid: false errors: 21` (21 required-field violations for
  empty `{}` summary, exactly as expected from the schema's 21-required-
  field set). NO ENOENT.

Full CLI suite re-run (per Quality Lesson v8 — never silently downgrade):

- npx vitest run (full suite) → 658/662 pass.
- M2a baseline was 657/661; R2 adds the drift-catcher test, so totals
  shift by +1/+1. Failure pattern UNCHANGED:
  - test/chatroom.test.ts > Chatroom CLI > chatroom wait detects a newly
    arriving post-start message (10s timing flake)
  - test/config.test.ts > DaemonClient config integration > ConfigStore
    resolves non-default host/port from config file (env pollution from
    prior test runs)
  - test/preflight.test.ts > Preflight CLI > rig preflight --json prints
    structured result (host port 7433 in use; real daemon running)
  - test/preflight.test.ts > Preflight CLI > rig preflight prints check
    marks and exits 0 on pass (same host port 7433 issue)
  All four are pre-existing environmental flakes (host has a real daemon
  on port 7433; chatroom timing flake; env pollution between test runs).
  None of the four failures touch any file in this R2 commit. No
  regression introduced.

Scope (R2 stays narrow per dispatch):

- `restore-packet write` still a M2a shell that enforces mutual-exclusion
  + required-source behavior + returns the M2a deferred-implementation
  error. UNCHANGED.
- `restore-packet read` + `restore-packet validate` still return M3-pending
  errors. UNCHANGED.
- M2b parsers + redaction + omitted-records + packet-writer + daemon
  route extension + daemon route tests NOT touched.
- No openrig source changes outside the three R2 files.

Posture: full QA/testing window (per dispatch's required-posture line).

Source qitem: qitem-20260502005113-24bf9de8 (M2a R2 dispatch).
Guard verdict: rigs/openrig-velocity/state/orch/restore-packet-M2a-guard-checkpoint-block-2026-05-02.md.
2026-05-01 17:55:42 -07:00
mvschwarz de2dbd4f3a feat(cli): Restore-Packet vertical M2a — schema + validator + CLI shell + wiring
Per orch decision (Path B chunked M2; M2a chunk):

- NEW packages/cli/src/schemas/restore-summary.schema.json — JSON Schema
  draft-07 for restore-summary.json, derived byte-for-byte from M1 contract
  § 2.1 at substrate/openrig-work/specs/restore-packet/SCHEMA.md. 21 required
  fields (16 standard + 5 v1-promoted-to-v0-required) plus the optional
  full_transcript object. Nested required-shapes for bounded_latest_transcript
  (with const bound: 120), touched_files, and durable_pointers (4 required
  arrays). additionalProperties: false at the root.

- NEW packages/cli/src/restore-packet/schema-validator.ts — TypeScript
  validator using ajv 8.18.0 + ajv-formats 3.0.1 (added to deps). Loads the
  schema file once (module-level cache), compiles into an ajv validator,
  and returns per-field ValidationError records (field, value, rule,
  severity) per contract § 8 reporting shape. M3 validate command + M2c
  generator-side emit-time self-check both consume this module.

- NEW packages/cli/src/commands/restore-packet.ts — commander command shape
  per IMPL § M2 line 105-141. Three subcommands: write / read / validate.
  M2a implements write's mutual-exclusion gate (--source-session vs
  --source-jsonl: exactly-one-required) and the missing-target check via
  commander requiredOption. read + validate are M2a stubs that report an
  M3 deadline; their CLI surfaces are registered now so the command tree
  is discoverable.

- EDIT packages/cli/src/index.ts — register restorePacketCommand after
  restoreCheckCommand (alphabetical: restore < restore-check < restore-packet
  per re-anchor finding); wire RestorePacketDeps into ProgramDeps.

- EDIT packages/cli/package.json — add ajv ^8.18.0 + ajv-formats ^3.0.1 to
  dependencies. Both already hoisted in monorepo node_modules at the same
  versions; this commit declares them honestly.

- NEW packages/cli/test/restore-packet.test.ts — 17 Tier 1 tests:
  - 11 schema-validator accept/reject tests covering each required-field
    family (source_session_id required; source_runtime enum; source_cwd
    pattern; bounded_latest_transcript.bound const 120; durable_pointers
    nested required arrays; redaction_policy_id enum; source_trust_ranking
    minItems; generated_at format; additionalProperties: false; optional
    full_transcript accepted; valid baseline accepted).
  - 6 CLI shell tests: subcommands present (read/validate/write); write
    mutual-exclusion both supplied; write neither supplied; --target
    missing; read M2a stub registered; validate M2a stub registered.

TDD discipline (per memory feedback_tdd_scope): test file authored before
schema-validator.ts and restore-packet.ts (failing import), then
implementations brought the suite to green. Committed state is the green
state.

Test runs:
- packages/cli vitest run test/restore-packet.test.ts → 17/17 pass.
- packages/cli vitest run (full suite) → 657/661 pass; the 4 failures are
  the pre-existing environmental flakes from prior session (preflight x2:
  port 7433 in use; chatroom wait: 10s timeout flake; config-store: env
  pollution). All four touch zero files in this M2a commit. No regression.

Posture declared: full QA/testing window. Out of scope for M2a (per orch
chunked decision): codex-jsonl-parser / claude-transcript-parser /
runtime-detect / redaction / omitted-records / packet-writer (M2b);
daemon route extension + daemon route tests (M2c); read/validate command
implementations (M3); M4-M6 proof work.

Source qitem: qitem-20260501232012-e660c0ce (Restore-Packet vertical M2).
Orch chunked-M2 decision: arrived as inline reply on the scope-check flag
qitem-20260501232237-7748b7d6.
2026-05-01 17:38:09 -07:00
mvschwarz 6af2754824 test(daemon, cli): Agent Starter v1 vertical M2 Revision 2 — real-adapter, route end-to-end, CLI plan content, continuityOutcome + SQLite roundtrip
Guard blocked M2 R1 (commit 2f8f269) with four findings — all in tests;
the source patch in `rigspec-instantiator.ts` is correct. M2 R2 lands
the four test patch rows from IMPLEMENTATION.md § M2 Revision 2.

Patch rows:

- M2-R2-1: replace mockClaudeAdapter / mockCodexAdapter with real
  ClaudeCodeAdapter / CodexRuntimeAdapter; mock at the tmux + fsOps
  boundary. The real `deliverStartup` now fires the real `guidance_merge`
  branch and the per-seat CLAUDE.md (claude) / AGENTS.md (codex) ends
  up containing the starter content inside a managed-block envelope.

- M2-R2-2: tighten POST /api/up positive to apply mode + assert STARTER
  reaches startup-orchestrator via `node_startup_context.resolved_files_json`
  SQLite roundtrip; add credential-bearing fixture failed-scan negative
  that confirms the launch refuses with a clear error AND no
  startup_context row is persisted (load-bearing Finding 2 contract at
  the route boundary). Schema-composition rejection cases (fork+starter_ref,
  terminal+starter_ref) remain plan-mode tests since they fail upfront.

- M2-R2-3: CLI `rig up --plan --json` now asserts that resolved starter
  contents reach plan output. The daemon's mocked plan response carries
  a `resolve_starter` stage with `detail.starterContent`; the CLI's
  --json branch (commands/up.ts:175-181) renders the full daemon
  response, so starter resolution is visible to operators. Path A from
  the M2 R2 dispatch — no CLI source change.

- M2-R2-4: instantiator test gains two assertions:
  (a) continuityOutcome stays "fresh" when starter is the only continuity
      source (asserts startNode call args carry no resumeToken/forkSource/
      rebuildArtifacts AND the resolved promise carries
      continuityOutcome="fresh");
  (b) STARTER layer survives `node_startup_context.resolved_files_json`
      JSON roundtrip with the registry path / appliesOn / deliveryHint
      shape intact.

Test-app helper extended with optional `podInstantiatorFsOps` and now
exposes `startupOrchestrator` so apply-mode route tests and spy-based
assertions work without monkey-patching.

No source-code changes: the rigspec-instantiator.ts patch from M2 R1
is unchanged and correct.

Marshal-acceptance:
- daemon vitest: 143 files / 2236 tests pass.
- cli up.test.ts: 28/28 pass.
- 4 other CLI failures (preflight x2, chatroom-wait, config-store)
  are pre-existing environmental flakes — host has a real daemon on
  port 7433 — and pre-date M2 / M2 R2.
2026-05-01 10:48:43 -07:00
mvschwarz 2f8f26952b feat(daemon): Agent Starter v1 vertical M2 — adapter pass-through + rig up integration
Wire AgentStarterResolver into pod-rig instantiation and prepend a STARTER
layer in `resolvedStartupFiles`, so an agent member with `starter_ref` flows
its starter artifacts through the existing `deliverStartup` adapter seam
without any adapter-side code change. Resolver-throw aborts the launch with
a clear startup error and no `deliverStartup` call (load-bearing
credential-safety contract — Finding 2).

Source changes:
- packages/daemon/src/domain/rigspec-instantiator.ts: in `launchExistingAgentMember`,
  invoke the resolver when `member.starterRef` is set; on
  `AgentStarterCredentialScanFailedError` / missing-entry / malformed-yaml,
  return `{status: "failed"}` before any adapter `deliverStartup` runs.
  STARTER artifacts are prepended ahead of `dedupedResolvedFiles` to form
  the new STARTER layer at the front of the layer chain.

New tests:
- agent-starter-instantiator.test.ts (5 tests): resolver invoked + STARTER
  prepended; resolver-throw aborts launch with no `deliverStartup` call;
  missing entry aborts; absent starterRef = no STARTER layer; rebuild
  composition allowed.
- agent-starter-adapter-claude.test.ts (1 test): claude-code adapter
  receives STARTER artifacts at front of `deliverStartup`.
- agent-starter-adapter-codex.test.ts (1 test): codex adapter receives
  STARTER artifacts at front of `deliverStartup`.

Updated tests:
- up-route.test.ts: 3 new M2 cases (positive plan-mode wiring proof,
  fork+starter_ref rejected per Finding 1, terminal+starter_ref rejected
  per secondary cleanup). Required threading optional `upRouterFsOps`
  through `createTestApp` so the route can read a real fixture spec.
- pod-rigspec-instantiator.test.ts: M1 forward-compat smoke now feeds the
  resolver a fixture registry entry (M2 actively reads the registry; M1
  test was a passthrough).
- packages/cli/test/up.test.ts: smoke proving `rig up <fixture-with-starter_ref> --plan`
  forwards the spec path to the daemon and surfaces "planned".

Marshal-acceptance:
- daemon vitest: 143 files / 2233 tests pass (M1 R2 baseline 2223 + 10
  new M2 tests).
- cli up.test.ts: 28/28 pass. Other CLI failures (preflight, chatroom,
  config-store) are environmental — host has a real daemon running on
  port 7433 — and pre-date M2.
2026-05-01 10:18:32 -07:00
mvschwarzandClaude Opus 4.7 2673e30ec6 fix(daemon): Agent Starter v1 vertical M1 R2 — codec emits starter_ref; expansion preserves it; resolver redacts match-line
Revision 2 of M1. Closes three guard-blocked findings against commit
eb4acf2. All within M1 envelope; no M2/M3/M4/M5 scope expansion.

Finding 1 (R2-1): RigSpecCodec.serialize() dropped starterRef
====================================================================
M1 R1 normalized snake-case `starter_ref` input into `member.starterRef`
but the canonical pod-aware serializer at rigspec-codec.ts:48-68 had no
emission branch for it. The forward-compat smoke at
pod-rigspec-instantiator.test.ts:657-671 was therefore a false proof —
the YAML it passed to instantiate dropped `starter_ref:` silently.

Patch:
- rigspec-codec.ts: insert `if (m.starterRef) member["starter_ref"] = { name: m.starterRef.name };`
  after the session_source emission branch.
- rigspec-codec.test.ts: 3 new tests covering
  - serialize → parse → validate → normalize roundtrip with starter_ref
    asserting wire-shape (`yaml.toContain("starter_ref:")`) AND
    seed-equality (`member.starterRef === seed`);
  - starter_ref + session_source.mode="rebuild" both survive (composition
    allowed per Finding 1 of M1 R1);
  - specs without starter_ref do not emit a spurious starter_ref: line.
- pod-rigspec-instantiator.test.ts: forward-compat smoke now asserts
  `expect(yaml).toContain("starter_ref:")` and `expect(yaml).toContain("fixture-starter")`
  before instantiation — closes the false-proof gap.

Finding 2 (R2-2): ExpansionPodFragment + buildSyntheticSpec drop starterRef
==========================================================================
The slice packet's component 1 named expansion pass-through, but
ExpansionPodFragment.members had no `starterRef?` field and
RigExpansionService.buildSyntheticSpec at rig-expansion-service.ts:135-154
had no `starter_ref` emission branch. An expansion request couldn't
carry starterRef through the same route that already carried sessionSource.

Patch:
- types.ts (ExpansionPodFragment.members near line 743): add
  `starterRef?: StarterRefSpec` mirroring `sessionSource?: SessionSourceSpec`.
- rig-expansion-service.ts (member emission near line 153): insert
  `...(member.starterRef ? { starter_ref: { name: member.starterRef.name } } : {})`
  after the session_source emission spread.
- rig-expansion-service.test.ts: 2 new tests
  - positive: spy on podInstantiator.materialize to capture the synthetic
    YAML; assert it contains `starter_ref:` + the starter name when an
    expansion request declares starterRef on a member;
  - negative regression: absent starterRef does not leak `starter_ref:`
    into synthetic YAML.

Finding 3 (R2-3): AgentStarterCredentialScanFailedError leaks match-line content
================================================================================
At lines 256/265/273 of agent-starter-resolver.ts, the refusal reason
string included `(line: ${JSON.stringify(line)})` — echoing the matched
line content from the malicious file. Once M2 wires this resolver into
`rig up`, error flows would surface credential-bearing lines into logs/
API responses. Hard refusal must identify file + line number + refusal
code WITHOUT echoing secret content.

This closes the v0 wrapper diagnostic-`match`-field caveat at the
daemon-tier productization layer. The wrapper's caveat was operator-
focused; the daemon resolver's audience is broader (logs, API, dashboards)
and demands tighter redaction.

Patch:
- agent-starter-resolver.ts: replace `JSON.stringify(line)` echo in all
  three error sites with the literal phrase `(content redacted)`.
  Preserved: refusal code (`credential_path_disallowed` /
  `credential_content_disallowed`); line number; file path. Comment
  added at site 1 explaining the redaction rationale + cross-link to
  the v0 wrapper caveat.
- agent-starter-resolver.test.ts: 2 new tests + extensions to 3
  existing M1 R1 negative tests
  - all 3 R1 negatives now also assert `reason.toContain("content redacted")`
    AND `reason.not.toContain("<fixture-secret-string>")` AND
    `message.not.toContain("<fixture-secret-string>")` for path,
    content, and uppercase-marker fixtures;
  - new: sk- token-shaped fixture's reason MUST NOT contain the token
    substring;
  - new: error reason still preserves non-sensitive diagnostics
    (refusal code regex match + `line N` regex match + filePath
    presence).

M1 R2 marshal-acceptance gate (per IMPL § M1 Revision 2):
- [x] R2-1 + R2-2 + R2-3 tests green INCLUDING codec roundtrip
  wire-shape assertion + expansion YAML wire-shape assertion +
  resolver redaction negative tests. ✓
- [x] All M1 R1 tests still green (no regression). ✓
- [x] No regression in existing rigspec-codec.test.ts (was 5; now 8 — +3 R2-1),
  rig-expansion-service.test.ts (was 14; now 16 — +2 R2-2), or daemon
  full suite. ✓
- [x] Posture: full QA/testing window (preserved). ✓

Verification:
- Focused (6 test files): 109/109 pass (agent-starter-schema 12,
  agent-starter-resolver 16, rigspec-schema 33, rigspec-codec 8,
  rig-expansion-service 16, pod-rigspec-instantiator 24)
- Daemon full regression: 2223/2223 pass (was 2216 at M1 R1; +7 net new
  R2 tests across the three findings)
- 140 test files (no new files in R2; all extensions to existing tests)

STOP-AND-ESCALATE clean:
- No M2/M3/M4/M5 surface touched (instantiator launchExistingAgentMember
  unchanged; runtime adapter deliverStartup unchanged; CLI up.ts
  unchanged; route up.ts unchanged)
- No starter_ref + session_source.mode="fork" composition accepted
  (Finding 1 of R1 still enforced via existing tests)
- Credential-scan still THROWS (Finding 2 of R1 still enforced; R2-3 tightens the diagnostic
  surface)
- No M5 shrinkage; M5 contract preserved
- No fleet-ok posture for any milestone past M2

Closeout feedback for proof packet — Product Lab feeder-loop quality
lesson v4: "claimed-surface-vs-implemented-surface cross-check at
planning stage." Same family as the prior misses (codex pod-aware,
agent-starter inspect, summary-line-prefix). Pattern: contract names a
surface (here: codec roundtrip, expansion pass-through, productized
error redaction) but implementation doesn't enumerate the surface
literally; the writer/reader assume "of course it's covered." Mitigation:
planner cross-checks each contract phrase against the file:line
implementation table BEFORE driver dispatch; reviewer cross-checks each
test claim against the wire-shape assertion (a green smoke that doesn't
assert the wire shape is a false proof).

Out-of-slice (preserved untouched):
- packages/cli/src/commands/ps.ts, packages/cli/test/ps.test.ts,
  docs/as-built/cli-reference.md (concurrent-agent dirty work)
- packages/daemon/src/adapters/codex-runtime-adapter.ts +
  packages/daemon/test/codex-runtime-adapter.test.ts (preserved Codex
  TOML config-fragment dedup work flagged in prior slices)
- Untracked CLAUDE.md at openrig root (substrate/host overlay)
R2 commit staged ONLY the 8 in-slice files via explicit pathspec.

Slice rails:
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/work-discovery-and-slice-shaping/slices/knowledge-context-agent-starter-v1-vertical/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-01 09:58:52 -07:00
mvschwarzandClaude Opus 4.7 eb4acf2c1d feat(daemon): Agent Starter v1 vertical M1 — schema + resolver scaffolding
M1 milestone of the agent-starter-v1-vertical slice. Schema + resolver
scaffolding only; M2 wires the resolver into the launch path; M3/M4
are real-runtime e2e proofs; M5 is disposable Tart VM proof.

THIS COMMIT IS M1 ONLY. M2/M3/M4/M5 surfaces are explicitly NOT touched
per delivery-orch directive ("marshal-acceptance gate at M1 must pass
before M2 dispatches separately").

What M1 ships (6 files, +361 / -1):
=================================

Schema (3 files):
- packages/daemon/src/domain/types.ts: new `StarterRefSpec` interface +
  `starterRef?: StarterRefSpec` field on `RigSpecPodMember` after
  `sessionSource?`. Doc-comment notes: artifact-seeded fresh-launch
  context; composes additively with `session_source`.
- packages/daemon/src/domain/rigspec-schema.ts: new
  `validateStarterRef()` invocation after `validateSessionSource` at
  line 308, plus the function itself enforcing four rules:
    1. malformed `name` rejection (must be alphanumeric + optional
       `_`/`-`; path-traversal guard);
    2. terminal runtime + `starter_ref` rejection (analogous to
       existing terminal `session_source` rejection at line 321 —
       terminal `deliverStartup` is a no-op so a starter has nothing
       to seed);
    3. **`starter_ref` + `session_source.mode="fork"` REJECTION**
       (per slice review-independent finding 1 — the v1+ "Real
       native-fork-from-registered-thread-id starter proof" trigger
       covers that composition);
    4. `starter_ref` + `session_source.mode="rebuild"` ACCEPTED
       (additive: both apply on `fresh_start`; rebuild artifacts and
       starter artifacts compose independently in the launch pipeline).
  Plus new `normalizeStarterRef` helper invoked at line 758 of the
  pod-aware normalization map.

Resolver (1 new file):
- packages/daemon/src/domain/agent-starter-resolver.ts (NEW): new
  `AgentStarterResolver` class with `resolveStarter(name)` returning
  `{ files: ResolvedStartupFile[], registryPath }`. Plus
  `AgentStarterCredentialScanFailedError` class. Resolver semantics:
    - Constructor lookup chain (via static helper):
      `opts.registryRoot` > `env[envVarName]` > `homeDirRoot` (if
      exists) > `substrateFallback`. Default env var name is
      `OPENRIG_AGENT_STARTER_ROOT`; default home subpath is
      `~/.openrig/agent-starters/`; default substrate fallback is
      the v0 prototype location.
    - `resolveStarter(name)`:
      * THROWS on invalid name (path-traversal guard).
      * THROWS on missing registry entry.
      * THROWS on empty file or malformed YAML (best-effort: requires
        `starter_id:` field; M1 doesn't pull in a YAML parser to keep
        the dependency surface narrow — M2 can swap in real parsing
        when it walks the priming-pack manifest).
      * **THROWS `AgentStarterCredentialScanFailedError` on a failed
        no-credentials scan** (per review-independent finding 2 —
        resolver does NOT return a structured "ok: false" the
        orchestrator could ignore; failed scan is a hard refusal point).
      * On clean scan: emits `ResolvedStartupFile[]` rooted at the
        registry directory. M1 scaffolding shape: one entry per
        starter (the registry YAML itself, marked
        `deliveryHint: "guidance_merge"`, `appliesOn: ["fresh_start"]`).
        M2 can extend the resolver to walk the priming-pack manifest's
        `read_full` paths if M3/M4 e2e proof needs richer per-layer
        artifacts; the type contract and refusal semantics are what
        M1 locks in.
- No-credentials scan implementation mirrors v0 wrapper at
  `specs/agent-starters/lib/agent-starter-helpers.sh::scan_starter_file`:
  three deny families (literal credential paths;
  `(credentials|auth\.json|secrets|tokens?\.json)` path regex;
  case-insensitive credential-string regex including `api_key`,
  `secret_key`, `bearer_token`, `sk-` token shapes, `gh[ps]_` token
  shapes, `password=`/`password:`). Allowlist exception: `transcript_path`
  values under `~/.claude/projects/` or `~/.openrig/transcripts/` are
  accepted; same paths under any other field/key/comment are rejected.

Tests (3 files):
- packages/daemon/test/agent-starter-schema.test.ts (NEW, 12 tests):
  acceptance (alone, with rebuild composition); codec roundtrip
  (normalize produces `starterRef`; absent → undefined; preserved
  alongside `sessionSource`); rejection (empty/non-string/non-object
  name; disallowed characters; path-traversal); composition rejection
  (fork; terminal runtime); forward-compat (existing specs validate).
- packages/daemon/test/agent-starter-resolver.test.ts (NEW, 14 tests):
  lookup-chain branches (4 tests covering opts.registryRoot, env var,
  homeDirRoot when exists, substrateFallback when home doesn't exist);
  successful resolve emits `ResolvedStartupFile[]` with correct
  `ownerRoot`/`appliesOn`/`required`; credential-scan refusal (THROWS
  on path match; THROWS on content match; THROWS on case-insensitive
  uppercase API_KEY); allowlist exception (transcript_path under
  `~/.claude/projects/` accepted); error throws (missing entry; missing
  `starter_id:` field; empty file; invalid name with traversal chars;
  invalid name with slash).
- packages/daemon/test/pod-rigspec-instantiator.test.ts (UPDATE, +1
  test): forward-compat smoke that a pod-aware spec carrying
  `starterRef` on a member instantiates without error (M2 will wire
  it into the launch path; M1 verifies no regression).

M1 Marshal-Acceptance Gate (per IMPL § M1):
- [x] All new schema/normalization/codec tests green INCLUDING:
  - rebuild composition accepted ✓
  - fork composition rejected with explicit error (Finding 1) ✓
  - terminal + starter_ref rejected with explicit error ✓
- [x] All resolver fetch tests green INCLUDING:
  - no-credentials negative case throws
    AgentStarterCredentialScanFailedError (Finding 2) ✓
- [x] Registry-root lookup chain tests green for all four branches ✓
- [x] No regression in existing rigspec-schema.test.ts (33/33) ✓
- [x] Posture: full QA/testing window (declared) ✓

Daemon full regression: 2216/2216 passing (was 2189 + 1 skipped at
codex-auth-refusal closeout / `53e9409`; now 2216 + 0 skipped — +27
net new tests; the 1 prior skip was the documented codex auth-refusal
deferral that revision-2 closed). 140 test files (was 138; +2 new files).

STOP-AND-ESCALATE triggers (per delivery-orch verbatim) all clean:
- No M2/M3/M4/M5 surface touched (instantiator left untouched at the
  `launchExistingAgentMember` site; adapter `deliverStartup` left
  untouched; CLI `up.ts` left untouched; route `up.ts` left untouched)
- No `starter_ref + session_source.mode=fork` accepted (Finding 1
  enforced)
- Credential-scan hard refusal preserved (Finding 2 enforced — resolver
  THROWS, does not return)
- No M5 shrinkage; M5 still requires both Claude AND Codex disposable
  VM proof
- No fleet-ok-posture for any milestone past M2
- Cross-runtime alignment flag from prior codex-auth-refusal slice
  preserved as follow-up (NOT settled)

Greenfield confirmation (planner-verified at audit time, re-confirmed
at commit):
- `grep -rn "starter_ref|agent_starter|AgentStarter|starterRef|StarterRefSpec" packages/`
  returned zero hits before this commit; M1 lands in untouched
  territory.

Out-of-slice (preserved untouched):
- `docs/as-built/cli-reference.md`, `packages/cli/src/commands/ps.ts`,
  `packages/cli/test/ps.test.ts` — pre-existing dirty work from
  concurrent agents
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`,
  `packages/daemon/test/codex-runtime-adapter.test.ts` — pre-existing
  Codex TOML config-fragment dedup work flagged as untouched in prior
  slices
- Untracked `CLAUDE.md` at openrig root — substrate/host overlay,
  preserved
M1 commit staged ONLY the 6 in-slice files via explicit pathspec.

Slice rails:
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/work-discovery-and-slice-shaping/slices/knowledge-context-agent-starter-v1-vertical/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-01 09:36:25 -07:00
mvschwarzandClaude Opus 4.7 53e9409306 fix(daemon): codex auth-refusal surfaces as attention_required across pod-aware launch path
Revision 2 of the codex-auth-refusal-attention-required slice. Closes the
gap velocity-guard caught in commit 63ee206: the legacy `CodexResumeAdapter`
path was correct, but the production pod-aware path
(`CodexRuntimeAdapter.launchHarness` → `verifyResumeLaunch`) still returned
`{ok: true}` when the probe reported `attention_required`, which
`launchHarness` translated into `{ok: true, resumeToken, ...}`. The slice's
"end-to-end" claim was false for pod-aware nodes.

Four classification-routing patches, no schema/persistence/auth-credential
mutation:

1. `runtime-adapter.ts` (Row 5 + Row 8): extend
   `HarnessLaunchRecovery = "retry_fresh"` → `"retry_fresh" | "attention_required"`;
   add optional `evidence?: string` field to `HarnessLaunchResult.ok=false`;
   append `"codex_auth_refusal"` to `ATTENTION_REQUIRED_READINESS_CODES`
   (defensive, keeps readiness path semantically aligned with launch path).

2. `codex-runtime-adapter.ts:495-548` (Row 6): insert
   `attention_required` branch in `verifyResumeLaunch` BEFORE the trailing
   `return {ok: true}` at line 547. Mirrors `no_saved_session` shape with
   `recovery: "attention_required"` + last-12-line pane evidence (verbatim
   Claude shape per `claude-resume.ts:97`).

3. `startup-orchestrator.ts:184-219` (Row 7 part A): extend `StartupResult`
   `ok=false` variant with `evidence?: string`; extend `fail()` to accept
   evidence. Add new branch in launch-failure block: when `launchResult.recovery
   === "attention_required"`, call `this.fail(input, "attention_required",
   errors, launchResult.evidence)` instead of falling through to the failed
   path. NOT routed to fresh-fallback (auth-refusal is operator-recoverable,
   not a stale-token signal).

4. `restore-orchestrator.ts:858-877` (Row 7 part B): HOISTED
   `attention_required` check above BOTH the `isPodAware && resumeRequested`
   branch (line 859) and the `isPodAware` branch (line 867). The IMPL
   pointed only at lines 867-877, but the actual production path for
   pod-aware Codex resume goes through line 859 first; without the hoist,
   the patch was unreachable for the resume-requested case (which is the
   common production scenario). Mirrors the runtime-agnostic legacy mapping
   at lines 725-735 — `RestoreNodeResult` shape unchanged (already supports
   `status: "attention_required"` + `attentionEvidence`).

**Broader behavior change to flag:** the hoisted check at
`restore-orchestrator.ts:858` fires for ANY pod-aware
`startupStatus: "attention_required"`, not just Codex auth-refusal. This
includes existing readiness codes (`update_gate`, `trust_gate`, `mcp_gate`,
`login_required`). Previously these were collapsed to `status: "failed"`
even though the session-row `startupStatus` was already `"attention_required"`
and the error string already said "Restore startup requires attention" — a
pinned dishonesty in the prior pod-aware mapping. The patch aligns the
RestoreNodeResult status with the already-honest startupStatus across all
attention-required readiness codes. One existing test
(`restore-orchestrator.test.ts:1478` "pod-aware Codex restore … update gate")
was updated to assert the new honest mapping; renamed from "fails when …
update gate" to "surfaces attention_required when … update gate".

**Dirty-file staging discipline applied (per packet § Dirty-Files
Provenance):** `packages/daemon/src/adapters/codex-runtime-adapter.ts` and
`packages/daemon/test/codex-runtime-adapter.test.ts` carry pre-existing
in-progress Codex TOML config-fragment dedup work (lines ~649-687 and
~925-980). Only the auth-refusal hunks (`verifyResumeLaunch` at line 520
and the new auth-refusal tests around line 854) are staged in this commit.
The config-fragment hunks remain unstaged in the working tree.

Cross-runtime alignment audit flag from revision 1 still preserved as
follow-up: Claude `looksLikeClaudeLoginPrompt` returns `failed/login_required`
(terminal); Codex `looksLikeCodexAuthRefusal` returns `attention_required`
(recoverable). Recommend separate slice to settle.

Tests:
- `codex-runtime-adapter.test.ts`: +2 cases (verifyResumeLaunch
  attention_required for both Codex token-refresh-failure variants).
- `startup-orchestrator.test.ts`: +1 case (recovery: "attention_required" →
  startupStatus: "attention_required" with evidence preserved; NO
  fresh-fallback retry).
- `lifecycle-scenario-matrix.test.ts`: +1 case (pod-aware Codex auth-refusal
  end-to-end via launchHarness → verifyResumeLaunch → orchestrator;
  asserts per-node `status: "attention_required"` + `attentionEvidence`
  + rig-level `partially_restored`; launchHarness called once, no
  fresh-fallback).
- `restore-orchestrator.test.ts`: 1 test updated to assert new honest mapping.

Daemon suite: 2189/2189 passing, 0 skipped (was 2185 at HEAD 63ee206;
+4 net new tests; 0 regressions).

Slice rails: substrate/shared-docs/openrig-work/missions/recursive-self-
improvement-v2/primitive-hardening/slices/codex-auth-refusal-attention-
required/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 13:36:07 -07:00
mvschwarzandClaude Opus 4.7 63ee206e93 feat(daemon): codex auth-refusal surfaces as attention_required end-to-end
Closes the deferral recorded by the lifecycle scenario matrix slice: Codex
resume against a stored OAuth token whose refresh fails (operator logged out
elsewhere, account changed, device key revoked, etc.) is alive-but-recoverable,
not terminal `failed`. Operator runs `codex login` and the seat continues —
the same recovery affordance Claude already had for resume-selection prompts.

Three source surfaces patched, all classification-routing only:

1. native-resume-probe.ts — new `looksLikeCodexAuthRefusal` helper requiring
   BOTH the access-token phrase AND the operator-instruction phrase to avoid
   false-positives on incidental debug output. Returns
   `{status: "attention_required", code: "codex_auth_refusal"}`. Inserted in
   the Codex branch right after the explicit `no_saved_session` failure check
   so the explicit auth-refusal path takes precedence over the trust /
   model-selection / update inconclusive gates and over `paneCommand=codex`
   active-runtime detection.

2. codex-resume.ts — `verifyResume` adds `attention_required` pass-through
   mirroring `claude-resume.ts:92-99`. Returns `{ok: false, code:
   "attention_required", message, evidence}` where `evidence` is the last 12
   lines of pane content (verbatim Claude shape).

3. restore-orchestrator.ts — Codex branch of `attemptResume` (the
   non-pod-aware legacy resume path) now mirrors the Claude translation
   table: `ok → resumed`, `retry_fresh → retry_fresh`, `attention_required →
   attention_required` with `message+evidence`, else → `failed`. The
   runtime-agnostic per-node mapping at lines 725-735 emits `status:
   "attention_required"` with `attentionEvidence` for both runtimes — no
   further wiring needed. The polymorphic dispatcher return type at lines
   923-928 already declared all four kinds and is untouched.

NOT changed (and explicitly checked):
- restore-orchestrator.ts:725-735 per-node mapping (runtime-agnostic).
- restore-orchestrator.ts:923-928 dispatcher return type union.
- node-inventory.ts attention_required projection (covers both runtimes).
- context-monitor.ts attention_required reconciliation.
- startup-orchestrator.ts (does not gate Codex probe path).
- Schema migrations / nodes / sessions tables.

Pane patterns sourced verbatim from codex-cli 0.125.0 binary strings at
/opt/homebrew/Caskroom/codex/0.125.0/codex-aarch64-apple-darwin (driver did
not improvise patterns from training-data guesses per packet § Pattern
Research):

  "Your access token could not be refreshed because you have since
   logged out or signed in to another account. Please sign in again."
  "Your access token could not be refreshed. Please log out and sign
   in again."

Cross-runtime alignment audit flag (NOT settled in this slice — recorded as
a follow-up question for orch per packet § Out-Of-Scope Audit Flag): Claude's
`looksLikeClaudeLoginPrompt` at native-resume-probe.ts:79-85 returns
`{status: "failed", code: "login_required"}` (terminal); the new Codex
auth-refusal pattern returns `{status: "attention_required", code:
"codex_auth_refusal"}` (recoverable). Both runtimes can present a "needs
login" state. Whether Claude should align with Codex (recoverable) or Codex
should align with Claude (terminal) is a provider-auth-semantics decision
beyond a probe-pattern slice. Recommend a separate slice to settle.

Tests (3 files, 271 total insertions across source+test):
- native-resume-probe.test.ts: +5 cases (auth-refusal positive x2, both-anchors-
  required negative, no_saved_session/trust_gate/active_runtime non-collision).
- codex-resume.test.ts: +2 cases (verifyResume attention_required pass-through
  with evidence shape; both-anchors-required negative).
- lifecycle-scenario-matrix.test.ts: REPLACE the documented `it.skip` at line
  411 with an end-to-end orchestrator test asserting Codex auth-refusal →
  per-node `status: "attention_required"` + `attentionEvidence` + rig-level
  `partially_restored` rollup. The 1-skipped baseline from the prior
  lifecycle slice is now 0-skipped.

Daemon suite: 2185/2185 passing (was 2176/2176-ish + 1 skipped at fcfc7dc;
net +9 from this slice; deferral closed; zero regressions).

Slice rails: substrate/shared-docs/openrig-work/missions/recursive-self-
improvement-v2/primitive-hardening/slices/codex-auth-refusal-attention-
required/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 13:08:26 -07:00
mvschwarzandClaude Opus 4.7 fcfc7dc93c fix(cli): rig expand recovery guidance points to shrink+re-expand, not doomed rig launch
The pod-aware /launch route at sessions.ts:113-118 returns
HTTP 409 `pod_aware_launch_unsupported` for any node where
`node.podId` is non-null. RigExpansionService at lines 42-74
ALWAYS materializes via the pod-aware seam, so every node `expand`
creates has `podId` set. Therefore every retry target previously
printed by `expand`'s partial-failure output (`rig launch <rigId>
<logicalId>`) is doomed to refusal — a load-bearing identity-honesty
contradiction documented in the 2026-04-27 add-node brittleness
field note and re-confirmed empirically at HEAD ab01331.

The honest recovery path (per the field note's confirmed sequence
and the operating semantics of `rig shrink`) is:

  1. Verify other seats are healthy: rig ps --filter rigName=<rig>
  2. Shrink the failed new pod: rig shrink <rig> <pod-namespace>
  3. Fix the spec/config issue.
  4. Re-run: rig expand <rig> <pod-fragment-path>

Patch is local to packages/cli/src/commands/expand.ts. Does NOT
relax the daemon /launch route's authority boundary; does NOT
change RigExpansionService; `rig.expanded` event shape unchanged.

Lands as part of the topology-mutation-add-seat-proof-pass slice
(primitive-hardening #4 topology mutation, foundation class).
Driver Section A audit (fleet-ok): 4 PASS + 1 NEEDS-HONEST-PATCH
(this row, this commit) + 1 NEEDS-DEFERRAL (Tier 2 stretch).
Mode 1 narrowing trigger satisfied per packet § Mode And Risk Policy.

Section B disposable-rig matrix proof BLOCKED on `operator auto
window` permission posture; not improvised down to a weaker proof
class per packet stop-and-flag rule and the 2026-04-30
permission-posture policy update. Section B routes to orch for
posture grant coordination.

Tests:
- packages/cli/test/expand.test.ts: T3a updated to assert the new
  shrink+re-expand recovery shape and "Failed targets:" line; T3b
  replaced with explicit negative-grep on the doomed
  `rig launch <rigId> <logicalId>` form.
- CLI suite: 636/640 PASS (4 pre-existing env-dependent failures
  unchanged: chatroom-timing, config-file, preflight x2 — none touch
  expand.ts).
- Daemon suite: 2176/2177 (1 skipped is the documented Codex
  auth-refusal deferral from the prior lifecycle slice; no
  regressions).

Slice rails: substrate/shared-docs/openrig-work/missions/recursive-
self-improvement-v2/primitive-hardening/slices/topology-mutation-add-
seat-proof-pass/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 10:06:45 -07:00
mvschwarzandClaude Opus 4.7 ab01331f50 feat(daemon): codex resume verifies pane via native probe
Closes the false-positive `resumed` shape that fire-and-forget left open.
Codex resume now polls getPaneCommand + capturePaneContent through
assessNativeResumeProbe (using the existing Codex shape — no new probe
patterns) and returns:

- ok: true            when probe reports `resumed` (codex foreground or TUI)
- code: retry_fresh   when probe reports `failed: no_saved_session` or pane
                      returned to shell
- code: resume_failed when probe stays inconclusive past timeout

Mirrors ClaudeResumeAdapter.verifyResume; restore-orchestrator.ts:944-947
keeps the existing Codex mapping unchanged (`ok:true → resumed`,
otherwise `failed`).

Lands as part of the lifecycle reboot/recovery scenario matrix slice
(primitive-hardening #2 rig lifecycle, bedrock). Driver matrix audit:
5 rows PASS at HEAD, 1 NEEDS-HONEST-PATCH (this commit), 1 NEEDS-DEFERRAL
(Codex `attention_required` for auth refusal — multi-surface feature
requiring probe extension; deferred to a follow-up provider-auth-attention
slice).

Tests:
- packages/daemon/test/codex-resume.test.ts +6 verifyResume cases
- packages/daemon/test/lifecycle-scenario-matrix.test.ts (new file, 23
  Tier 1 in-process tests + 1 documented `it.skip` deferral covering all
  six scenarios: clean start, warm resume, host reboot/tmux absence,
  provider auth loss, partial boot, operator recovery)
- Daemon suite: 2176 passed | 1 skipped (was 2146 baseline at 578bd5c)

Slice rails: substrate/shared-docs/openrig-work/missions/recursive-self-
improvement-v2/primitive-hardening/slices/lifecycle-reboot-recovery-
scenario-matrix/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 08:22:38 -07:00
mvschwarzandClaude Opus 4.7 6b7043a04d feat(cli): rig ps and rig whoami accept --host (CLI-side ssh shell-out)
Promote the `--host <id>` flag from `rig send`/`rig capture` (closed at
cdce3a6) to `rig ps` and `rig whoami`. Strictly-mechanical extension:
the shared cross-host infrastructure (`host-registry.ts`,
`cross-host-executor.ts`, `cross-host-cli-helpers.ts`) is REUSED AS-IS
with zero modifications. Only the two command files gain the wiring,
mirroring the `send.ts`/`capture.ts` shape exactly.

`rig ps --host <id>`:
- Forwards every shaping flag (`--nodes`, `--full`, `--limit`,
  `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`
  in operator-declared order; non-set flags are not pushed.
- Verbatim remote stdout passthrough on success — the remote `rig ps`
  already produced the correct shape (bare-array or envelope per its
  own shaping flags); we do NOT double-wrap.
- Failure surfaces the four-`failedStep` enum from the closed
  cross-host-rig-commands slice (`ssh-unreachable`,
  `permission-gate` with keychain-over-SSH hint,
  `remote-daemon-unreachable`, `remote-command-failed`) via the shared
  `emitCrossHostFailure` helper.

`rig whoami --host <id>`:
- Forwards `--node-id`, `--session`, `--json` to the remote
  `rig whoami`.
- **Load-bearing identity-resolution distinction**: cross-host runs
  BEFORE local `resolveIdentitySource()`. The local resolution walks
  env vars and tmux pane metadata; cross-host must NOT require any of
  that — the remote rig has its own daemon, tmux, and identity
  context. A test asserts that `rig whoami --host vm-a --json` with no
  local `--node-id`/`--session` does NOT trigger the "Cannot determine
  identity" error path and DOES surface the remote rig's response.

`PsDeps` and `WhoamiDeps` interfaces extend `StatusDeps` with optional
`hostRegistryLoader` and `crossHostRun` DI seams, mirroring
`SendDeps`/`CaptureDeps` exactly. Tests inject mocks; no real ssh / no
real `~/.ssh/` reads / no real network in any test.

Out of scope per slice IMPL boundaries: modifications to the cross-
host infrastructure modules (HARD GATE row 5 verified clean —
`git diff HEAD -- host-registry.ts cross-host-executor.ts
cross-host-cli-helpers.ts` returns 0 lines); daemon source (HARD GATE 2
verified — `git diff HEAD -- packages/daemon/` returns 0 lines);
`--host` on any other CLI command; `rig host` sub-namespace; registry
shape changes; failure-mode taxonomy changes; non-SSH transports;
connection pooling; multi-hop SSH; cross-host queue routing or seat-
handover composition. The local-path (no `--host`) for both commands
is byte-identical to the prior source — verified by compat regression
tests asserting that no-host invocations do NOT call the registry
loader OR the executor (both spied; both never invoked) and by the
existing ps.test.ts (43/43) and whoami.test.ts (18/18) suites passing
unchanged.

Tests (CLI-only; no VM cycle; no daemon mutation; no real ssh):

- `packages/cli/test/cross-host-commands.test.ts` — 20 new cases (10
  for `ps --host`, 10 for `whoami --host`):
  - argv reconstruction with all flags forwarded in operator order;
  - bare `--host vm-a` produces `["rig","ps"]` / `["rig","whoami"]`;
  - cross-host runs BEFORE local identity resolution (whoami
    load-bearing test);
  - verbatim remote stdout passthrough on success (no
    double-JSON-wrapping);
  - 4 failure modes (ssh-unreachable, permission-gate with hint,
    remote-daemon-unreachable, remote-command-failed);
  - JSON failure envelope (top-level `failedStep`);
  - help text mentions `--host`;
  - compat regression: no-`--host` paths do NOT call registry loader
    OR executor.

Daemon untouched. CLI regression: 636/640 PASS (the same 4 pre-
existing environment-dependent failures from prior slices unchanged).
ps.test.ts 43/43 and whoami.test.ts 18/18 unchanged. cross-host-
commands.test.ts goes from 20 to 40 passing cases. `git diff --check`
clean.

Doc patches: `cli-reference.md` adds `[--host <id>]` to both `rig ps`
and `rig whoami` Usage lines plus a Notes bullet on each describing
the cross-host contract. The `rig whoami` bullet additionally calls
out that identity resolution happens on the REMOTE rig (not locally),
which is the load-bearing distinction.

Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/host-ps-whoami-promotion/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-host-ps-whoami-promotion-driver-checkpoint-20260430.md
Guard accept:      shared-docs rigs/openrig-velocity-claude/state/orch/velocity-host-ps-whoami-promotion-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 00:51:35 -07:00
mvschwarzandClaude Opus 4.7 315745a167 feat(cli): rig ps --fields rejects unknown keys with per-level allow-list
Mirror the closed `--filter` rejection pattern (`ps.ts:55,77-80`) for
the `--fields` projection: an unknown key produces an actionable error
naming the unknown key(s) and the sorted supported list, and exits
with code 1. Validation runs BEFORE any HTTP call so operator scripts
that gate on exit status see the same shape as `--filter` rejection.

Per-level allow-lists (the rig and node entries have different
schemas):

- `ALLOWED_RIG_FIELDS` — 9 keys exactly matching `PsEntry` (rigId,
  name, rigName, nodeCount, runningCount, status, lifecycleState,
  uptime, latestSnapshot).
- `ALLOWED_NODE_FIELDS` — 16 keys exactly matching `NodeEntry`
  (rigId, rigName, logicalId, podId, podNamespace,
  canonicalSessionName, nodeKind, runtime, sessionStatus,
  startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
  resumeCommand, latestError, agentActivity).

Load-bearing aliasing decision (preserved per closed `rig ps trust
and scale-safety` slice at openrig 0a9fb43): `name` is rig-level only;
`rigName` is the alias and is accepted at both levels. When an
operator types `--fields name` against a node query, the rejection
fires a level-aware `Hint: 'name' is a rig-level field; use 'rigName'
for node entries.` so the node-side rejection nudges toward the
correct alias rather than silently producing `{name: undefined}`.

Implementation:

- New constants `ALLOWED_RIG_FIELDS` and `ALLOWED_NODE_FIELDS` next
  to `ALLOWED_FILTER_KEYS`.
- New `parseFields(input, allowed, level)` helper next to
  `parseFilter`, mirroring its shape: split on comma, trim, drop
  empties; reject empty result; reject unknown keys with quoted
  key(s), `key`/`keys` plural form, sorted supported list, and the
  level-aware hint.
- Validation wired up-front in the action handler (`opts.nodes`
  determines level; both rig and node downstream branches use the
  validated `fields` array). Bails with `process.exitCode = 1` on
  rejection. The `useEnvelope` predicate is unchanged.
- Help text body lists per-level supported fields plus the
  `name`-vs-`rigName` distinction and the no-nested-field caveat.
- `cli-reference.md` `--fields` line refreshed with the per-level
  enumeration plus the rejection contract.

Out of scope per slice IMPL boundaries: daemon source (HARD GATE on
audit row 5 confirmed CLI is sole projection seam — daemon
`grep -n 'fields|selectFields'` returns empty); other CLI commands;
new top-level commands; nested-field projection / wildcards /
case-folding; `--host` promotion to `rig ps` (C9b is a separate
slice). The `name`/`rigName` aliasing at rig-level is preserved
unchanged from the closed 0a9fb43 contract.

Tests (CLI-only; no VM cycle; no daemon mutation):

- `packages/cli/test/ps.test.ts` — 10 new cases covering: rig-level
  single-unknown rejection with sorted supported list; multi-unknown
  rejection with `keys` plural; empty `--fields`; whitespace-only
  after trim; node-only field at rig-level; both `name` AND
  `rigName` accepted at rig-level (aliasing positive); `name` at
  node-level fires `rigName` hint AND its sorted node-level
  supported list does NOT contain `name` (negative regex);
  bogus key at node-level; `--filter` rejection still works after
  `--fields` rejection wired in (regression); combined
  `--filter status=running --fields rigName,status` regression. The
  pre-existing positive `ps --json --fields projects only named
  fields` test at `:546` still passes for valid inputs.
- ps.test.ts: 43/43 PASS (33 existing + 10 new).
- CLI regression: 616/620 PASS — the 4 unrelated failures are
  pre-existing env-dependent (chatroom timing, port 7433, preflight)
  unchanged across this slice; +10 new cases land cleanly vs prior
  606 baseline. `git diff --check` clean.

Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/fields-ux-rejection/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-fields-ux-rejection-driver-checkpoint-20260430.md
Guard accept:      shared-docs rigs/openrig-velocity-claude/state/orch/velocity-fields-ux-rejection-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 00:24:53 -07:00
mvschwarzandClaude Opus 4.7 578bd5c57d feat(daemon): session_source: { mode: rebuild } (Option A: artifact-set seat)
Add Option A of the Seat Continuity and Handover primitive: a v1 narrow
MVP for `session_source: { mode: rebuild, ref: { kind: artifact_set,
value: [paths...] } }` declared at member-level in a rig spec or
`rig expand` payload. Fresh-launches the harness and seeds the running
TUI with operator-declared artifacts in trust-precedence order
(highest-trust first: rig CULTURE, role doc, handover packet, queue
files, session logs).

Identity-honesty bedrock: the rebuild seat persists with NO
`resumeToken` and `continuityOutcome: "rebuilt"` (NEVER `"fresh"`,
`"resumed"`, or `"forked"`). Verified at four layers — schema dispatch,
orchestrator threading (no `resumeToken`, no `forkSource`),
SQLite read-back (sessions.resume_token IS NULL), and negative-grep on
resolver output JSON (no `restored`/`resumed`/`snapshot`/`forked` tags).

Schema (types, rigspec-schema, rigspec-codec):

- `SessionSourceSpec` refactored from a single fork-only shape to a
  discriminated union `SessionSourceForkSpec | SessionSourceRebuildSpec`
  so consumers can typed-narrow on `mode`. Existing fork consumers
  unchanged.
- `validateSessionSource` dispatches on `mode` and delegates to either
  `validateForkSessionSource` (existing behavior, sharper "v1 fork
  mode" wording) or `validateRebuildSessionSource` (new). Rebuild
  rejects: terminal runtime; non-`artifact_set` ref.kind (with
  cross-mode hint when the kind belongs to fork); empty/non-array
  `ref.value`; empty-string entries within `ref.value`.
- `normalizeSessionSource` likewise dispatches and produces a typed
  `SessionSourceSpec` for either mode.
- Codec serializer's `ref.value` slot already accepts the array shape
  via the YAML library; commented for clarity.

Expansion (routes/rigs):

- `normalizeExpansionPodFragment` now reads both `mode: fork` and
  `mode: rebuild` shapes from the `/api/rigs/:id/expand` body.

Resolver (NEW: session-source-rebuild-resolver):

- `resolveRebuildArtifacts(spec, opts)` loads each declared path,
  preserves operator-declared trust-precedence order, records missing
  paths as gaps, and refuses (with a clear error citing trust-
  precedence order as guidance) only when ALL declared paths fail to
  resolve. The function does NOT execute, parse, or evaluate artifact
  contents — it just records that a path exists and hands metadata to
  the orchestrator's existing `deliverStartup` seam. Pluggable
  `exists` injection seam for unit tests.

Orchestrator (startup-orchestrator):

- `StartupResult.continuityOutcome` literal widened to include
  `"rebuilt"` (the broader `ContinuityOutcome` union in `types.ts`
  already included it from L2 lifecycle work).
- `StartupInput.rebuildArtifacts?: ResolvedStartupFile[]` added.
- When `rebuildArtifacts` is set, the artifacts are prepended to
  `resolvedStartupFiles` so the existing partition + delivery path
  handles them in operator order.
- `launchHarness` is called with NO `resumeToken` and NO `forkSource`
  on rebuild — fresh-launch shape. `continuityOutcome` is set to
  `"rebuilt"`.

Instantiator (rigspec-instantiator):

- Dispatches on `member.sessionSource?.mode`: `"fork"` builds the
  `forkSource` opt (existing behavior); `"rebuild"` resolves artifacts
  via the new resolver and passes them as `rebuildArtifacts`. If the
  resolver refuses (no paths resolved), the instantiator returns
  `{status: "failed", error: <resolver error>}` before calling
  `startNode`.

Out of scope for Option A (per slice IMPL): the full code-direct seat
handover (Option B / Mode 3 sub-mission); `SeatHandoverService` daemon
module; `rig seat handover` / `rig occupant rebuild` CLI commands;
nodes-table schema migration (HARD GATE on audit row 4 cleared:
`continuityOutcome: "rebuilt"` already in the enum); live session-
binding mutation; cross-host work; auto-discovery of artifacts
(operator declares paths in `ref.value` in trust-precedence order);
content execution or evaluation from artifacts in the resolver.

Tests (Tier 1, in-package mocks only — no real Claude / Codex; no
host `~/.claude` / `~/.codex` / `~/.openrig` mutation; resolver real-
filesystem smoke uses `mktempSync` temp paths only):

- `test/session-source-rebuild.test.ts` — 26 cases: 10 schema accept/
  reject (full Honest Refusal Matrix for rebuild + cross-mode); 1
  codec roundtrip; 4 resolver unit (all-existing, gaps recorded,
  all-missing fail, operator order preserved); 6 orchestrator
  integration including SQLite read-back asserting NO `resumeToken`
  on rebuild seat and `continuityOutcome: "rebuilt"`; 3 honest-UX
  literal contract; 2 real-filesystem smoke.
- `test/session-source-fork.test.ts` — two assertion strings updated
  to match the new multi-mode error wording (`"v1 supports fork
  only"` → `"v1 supports fork or rebuild"`; `"v1 supports native_id
  only"` → `"v1 fork mode supports native_id only"`). Pure wording
  propagation, no behavior change. All 33 fork cases still PASS.
- Daemon regression: 137 files, 2146/2146 PASS.

Tier 2 (real-runtime rebuild against a disposable Tart/OpenRig VM)
is the tester's call per the corrected SOP framing — disposable-VM
proof is default-allowed; skip only with concrete substrate reason.
Human gate applies only for protected/non-disposable hosts, host
`~/.claude` / `~/.codex` mutation, unexpected external spend/service
calls, destructive lifecycle, or leaving the disposable VM boundary.

Doc patches: `cli-reference.md` adds a new "Session source declaration
(`session_source`)" subsection covering BOTH modes (fork + rebuild)
with example YAML and validation rules. `architecture.md` adds a
one-paragraph reference covering both modes alongside the existing
cross-host notes.

Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/seat-continuity-and-handover/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-seat-continuity-handover-driver-checkpoint-20260430.md
Guard accept:      shared-docs rigs/openrig-velocity-claude/state/orch/velocity-seat-continuity-handover-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-29 23:07:22 -07:00
mvschwarzandClaude Opus 4.7 cdce3a6864 feat(cli): cross-host rig send/capture via --host (CLI-side ssh shell-out)
Add a v0 narrow MVP for the Cross-Host Rig Commands primitive: declare
remote hosts in `~/.openrig/hosts.yaml` and route `rig send` or
`rig capture` to a declared host via single-hop ssh. CLI-side shell-out
only — the local daemon is not involved in routing, and there is no
daemon-to-daemon networking.

Load-bearing identity rule: SSH success is NOT verify success. The
remote rig is authoritative on `--verify`; its `Verified: yes/no` line
is surfaced verbatim and is never collapsed into the SSH exit code.

New modules (CLI):

- `host-registry.ts` — read-only loader/validator for `hosts.yaml`.
  Schema: `hosts: [{ id, transport, target, user?, notes? }]`. v0
  validates required fields, unique ids, and refuses non-`ssh`
  transport with a v0-scope message. Missing-file error names the
  canonical path.
- `cross-host-executor.ts` — `runCrossHostCommand(host, argv, opts)`
  with pluggable spawn for testing. Returns a structured
  `CrossHostResult` whose `failedStep` enum maps each dossier failure
  mode to a distinct value:
  * `ssh-unreachable` — ssh exit 255 / spawn error with no auth signal.
  * `permission-gate` — ssh exit 255 with Permission/Keychain/host-key
    pattern; includes a hint to the keychain-over-SSH field note.
  * `remote-daemon-unreachable` — ssh succeeded; remote rig stderr
    matched "Daemon not running" / "Failed to fetch ... from daemon".
  * `remote-command-failed` — ssh succeeded; remote rig exited
    non-zero for any other reason.
- `cross-host-cli-helpers.ts` — shared `emitCrossHostError` /
  `emitCrossHostFailure` so `send` and `capture` produce identical
  operator-actionable error formats.

Modified commands:

- `send.ts` — adds `--host <id>` option. When set, short-circuits
  before the daemon path: loads registry, resolves id, reconstructs
  argv (`["rig","send",session,text,...flags]`), runs via the
  executor. Annotates output with `[via host=<id> (<target>)]`; JSON
  output wraps `{ cross_host: {host, target}, result }`. SendDeps adds
  optional `hostRegistryLoader` and `crossHostRun` injection points.
- `capture.ts` — same shape with capture's argv (`["rig","capture",
  session?,...flags]` forwarding `--rig`, `--pod`, `--lines`,
  `--json`).

Out of scope for v0 (per slice IMPL): non-SSH transports; `--host` on
`rig ps` / `rig whoami` / other commands; connection pooling;
multi-hop SSH; cross-host queue routing; cross-host seat handover; any
`rig host` sub-namespace; auto-generated or auto-modified `hosts.yaml`.
The daemon is untouched — `packages/daemon/` has no changes in this
slice.

Tests (Tier 1, in-package mocks only — no real ssh, no real network,
no `~/.ssh/` reads, no `hosts.yaml` outside `mktempSync` temp paths):

- `host-registry.test.ts` — 19 cases: accept/reject schema rules,
  duplicate-id detection, transport refusal, malformed YAML, unknown
  id discoverability, empty-registry honesty.
- `cross-host-executor.test.ts` — 18 cases: `shellQuote`; full
  `classifyResult` exit-code → failedStep table; argv construction
  (`-l <user>`, `ConnectTimeout=N`); happy path; **--verify honest
  pass-through**; permission-gate detection; transport defense-in-
  depth; stdin forwarding; custom connect timeout.
- `cross-host-commands.test.ts` — 20 cases: send + capture short-
  circuit, argv reconstruction, **--verify honesty in command path**,
  flag forwarding, JSON envelope shape, each of the 4 failure modes
  surfacing operator-actionable errors with field-note hints, compat
  regression (no `--host` ⇒ neither registry loader nor executor is
  called).

Doc patches: `cli-reference.md` documents `--host` on send + capture
plus a new "Cross-host execution (`--host <id>`)" subsection (registry
shape, validation rules, four `failedStep` values, v0 out-of-scope
list). `architecture.md` adds a one-line note that `--host` is CLI-
side shell-out.

Tier 1.5 (ssh localhost loopback) skipped — not part of standard dev-
host config; recorded as a deferred follow-up. Tier 2 (real disposable
Tart VM proof against an isolated remote rig) deferred per planner
recommendation with explicit `human-gate: tart-vm-cross-host-proof`
annotation.

Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/cross-host-rig-commands/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-cross-host-rig-commands-driver-checkpoint-20260430.md
Guard accept:      shared-docs rigs/openrig-velocity-claude/state/orch/velocity-cross-host-rig-commands-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-29 22:31:40 -07:00
mvschwarzandClaude Opus 4.7 c7b6df192f feat(daemon): session_source: { mode: fork } member-level launch input
Add the v1 narrow MVP for the Session Source Fork primitive: declare
`session_source: { mode: fork, ref: { kind: native_id, value: <id> } }`
on a rigspec member to start a new managed seat from a prior native
runtime conversation source. Identity-honesty bedrock: the new seat's
persisted resume token is the NEW post-fork token; the parent token is
NEVER written onto the new seat record.

Schema (rigspec-schema, rigspec-codec, types):
- Add SessionSourceSpec interface; add sessionSource field to
  RigSpecPodMember and ExpansionPodFragment.members[].
- Encode the full Honest Refusal Matrix in the schema validator: terminal
  runtime + sessionSource → reject; mode != "fork" → reject; ref.kind
  ∈ {artifact_path, name, last} → reject with v1-scope reason; missing or
  empty value → reject.
- Codec serializer emits session_source block when present; round-trip
  through serialize → parse → normalize is preserved.

Expansion (rig-expansion-service, routes/rigs):
- normalizeExpansionPodFragment reads session_source / sessionSource from
  /api/rigs/:id/expand body.
- buildSyntheticSpec passes session_source through into the synthetic YAML
  fed to the materializer.

Runtime adapter contract (runtime-adapter):
- Add ForkSource interface; extend RuntimeAdapter.launchHarness opts with
  optional forkSource. resumeToken and forkSource are documented as
  mutually exclusive; the captured resumeToken in the result is the NEW
  post-fork token, never the parent.

Adapters (claude-code, codex, terminal):
- claude-code: for forkSource.kind="native_id", build
  `claude --permission-mode acceptEdits --resume <parent> --fork-session
  --name <seat>` and capture the NEW post-fork session id via
  captureResumeToken(opts.name).
- codex: for forkSource.kind="native_id", build
  `codex[ -p <profile>] fork[ --add-dir ...] <parent>` and capture the
  NEW thread id via captureFreshThreadId(binding). Refuse on capture
  failure (no token == no honest seat).
- terminal: defensive refusal with clear error.
- All three: refuse other ref.kind values (defense-in-depth on top of
  schema gate); refuse simultaneous resumeToken + forkSource.

Orchestrator (startup-orchestrator, rigspec-instantiator):
- StartupInput.forkSource threaded through to adapter.launchHarness when
  no resumeToken is set; continuityOutcome literal widened to
  "resumed" | "fresh" | "forked" (broader ContinuityOutcome union already
  included "forked").
- rigspec-instantiator passes member.sessionSource → forkSource when
  starting a new node.

Tier 1 proof: 33/33 cases in test/session-source-fork.test.ts cover schema
accept/reject matrix, codec round-trip, adapter command shape + capture +
mutual-exclusion, terminal refusal, orchestrator integration (including
SQLite read-back asserting persisted resume_token equals NEW + NOT equals
PARENT), expansion pass-through, and identity-honesty literal contract.
Daemon suite: 2120/2120 pass.

Tier 2 (real Tart VM fork against disposable ~/.claude and ~/.codex)
deferred per planner recommendation with explicit human-gate annotation.

Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/session-source-fork/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-session-source-fork-driver-checkpoint-20260430.md
Guard accept:      shared-docs rigs/openrig-velocity-claude/state/orch/velocity-session-source-fork-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-29 20:18:14 -07:00
mvschwarzandClaude Opus 4.7 4e277d7744 docs(cli-reference): bring rig ps section in line with shipped 0a9fb43
The rig ps section in docs/as-built/cli-reference.md predated the closed
"rig ps trust and scale-safety" slice and only documented `[--json]` and
`[--nodes]`. Source HEAD ships `--full / --limit / --fields / --summary /
--filter`, a rig-level `LIFECYCLE` column with `run`/`rec`/`stp`/`deg`/`att`
codes, a `rigName` alias, and a node-tier inventory exposing
STATUS/STARTUP/LIFECYCLE/ACTIVITY/RESTORE/ERROR side-by-side so startup
status and live runtime state can be diagnosed without a separate command.

This patch updates the doc to:

- list the full shaping flag set on both rig and `--nodes` usages;
- document the `LIFECYCLE` column and node-tier diagnostic columns;
- document the `rigName` alias and the bare-array vs envelope JSON shape
  decision (envelope appears only when at least one shaping flag is set);
- document the human-output truncation budgets (50 rigs / 100 nodes) plus
  the footer/narrowing instruction;
- document the `--summary` aggregate-only mode and its cross-facet caveat;
- document the `--filter` allow-list and rejection of unknown keys;
- document the `--fields` shallow-projection caveat (no nested-field
  drilling);
- update exit-code 1 to include "invalid `--filter` / `--limit`".

Doc-only patch from the runtime-truth-and-inventory bedrock slice
(recursive-self-improvement-v2, primitive-hardening). Audit deliverable
and accept artifact at:

- shared-docs rigs/openrig-velocity-claude/state/orch/velocity-runtime-truth-inventory-driver-checkpoint-20260430.md
- shared-docs rigs/openrig-velocity-claude/state/orch/velocity-runtime-truth-inventory-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-29 18:53:19 -07:00
mvschwarzandClaude Opus 4.7 0a9fb436cd feat(daemon,cli): add rigName alias, lifecycleState column, and bounded ps output options
Builds on the Lifecycle v2 epic (L1+L2+L3+L3b) by making `rig ps` operator-
trustworthy and scale-safe. The slice covers three linked outcomes against
the same operator surface:

1. **Naming consistency.** Per-node `NodeInventoryEntry` exposes the rig's
   name as `rigName`; rig-summary `PsEntry` historically used `name`. Agent
   code that projected `.rigName` from `rig ps --json` saw silent
   `null`/`undefined`. Fixed by adding `rigName` as an alias on `PsEntry`,
   always populated and always equal to `name`. Existing consumers that
   read `.name` continue to work; agent code should now prefer `rigName`.

2. **Lifecycle visibility.** L2's `lifecycleState` column was already in
   the default human `rig ps` output; this slice asserts it via test and
   documents it in the help text.

3. **Output budget.** Hosts with realistic agent counts (~75 nodes already
   on the dev box; targeting 100+) flooded operator terminals and agent
   context windows. The fix is hybrid: human terminal output truncates by
   default to 50 rigs / 100 nodes with an honest footer naming actual
   totals and the `--full` opt-out; JSON output preserves the bare-array
   default for machine consumers but adds opt-in `--limit`/`--fields`/
   `--summary`/`--filter` flags that switch to an envelope shape with
   `entries`/`totalRigs`/`truncated`/`hint`. The envelope only triggers
   when at least one shaping flag is specified.

Decisions resolved (per plan, ratified by orch-lead with amendments):

- A2 — alias `rigName` alongside `name` (zero JSON consumer breakage).
- B1 — keep LIFECYCLE column in default human output (already present from L2).
- C hybrid — human truncates default with footer; JSON unbounded by default;
  envelope only on `--limit`/`--fields`/`--summary`/`--filter` flag use.

Per orch amendments (overriding plan where they conflict):

- **A. CLI-side shaping.** All filtering/limiting/summary/projection
  happens in the CLI; no daemon query parameters added; `/api/ps` stays
  bare-array with the new `rigName` alias only. `routes/ps.ts` and
  `routes/rigs.ts` are NOT touched.
- **B. Guard pre-commit review.** Driver sent kickoff with `Verified: yes`,
  implemented + verified, filed a pre-commit checkpoint, created a durable
  guard review qitem, and waited for guard accept BEFORE committing. This
  commit lands only after guard issued ACCEPT in
  `velocity-slice-rig-ps-trustworthy-guard-checkpoint-approve-20260428.md`.
- **C. Smallest fixture proving budget boundary.** A 60-rig synthetic
  fixture (just over the 50-budget) proves human-truncation correctness
  in 35ms; the L4 proof at `fad10a9` already exercised real 100x10 scale.
  Justification recorded in the test comment.

Implementation:

- `packages/daemon/src/domain/ps-projection.ts`: add `rigName: string`
  field to `PsEntry` (always populated, always equal to `name`).
  `lifecycleState` typing remains required (post-L2); empty rigs derive
  `stopped`. `routes/ps.ts` is unchanged because `PsEntry` IS the wire
  shape and `rigName` flows through automatically.

- `packages/cli/src/commands/ps.ts`: add `--full`, `--limit`, `--fields`,
  `--summary`, `--filter` flags. Implement allow-list filter parser
  (`status`, `lifecycleState`, `name-prefix`, `name`) with explicit
  exit-1 errors for unknown keys / malformed values / non-integer
  `--limit`. Implement human truncation at `HUMAN_RIG_BUDGET=50` /
  `HUMAN_NODE_BUDGET=100` with honest footer. Implement JSON envelope
  triggered ONLY by `useEnvelope` (any of `--limit`/`--summary`/
  `--fields`/`--filter`); default `--json` and `--full --json` keep
  the bare-array shape for back-compat. Help text rewritten with all
  new flag examples and a note about the `rigName` alias.

Filter parser security: in-memory filtering over already-fetched arrays;
allow-list Set rejects unknown keys before any HTTP call; no SQL
injection vector.

Tests added (17 cases total: 2 daemon + 15 CLI):

- `daemon/test/ps-projection.test.ts`: `rigName` alias populated equal
  to `name` on every entry; `lifecycleState` always populated even for
  empty rigs.
- `cli/test/ps.test.ts`: `--json` rigName pass-through (jq `.[].rigName`
  regression named in the source qitem); `--json` default bare array;
  `--json --limit` envelope; `--json --fields` projection;
  `--json --summary` aggregate-only; `--filter status=running`;
  `--filter lifecycleState=attention_required`; filter rejects unknown
  keys; filter rejects malformed value; `--limit` rejects non-integer;
  60-rig fixture proves 50-budget human truncation with honest footer;
  `--full` disables truncation; `--json` default bare-array preserved
  with 60 rigs (back-compat); `--nodes --json` default unbounded
  (back-compat); `--nodes --json --limit` envelope.

Verification:

- focused daemon: `npx vitest run test/ps-projection.test.ts test/node-inventory.test.ts`
  → 43/43 green.
- focused CLI: `npx vitest run test/ps.test.ts` → 33/33 green.
- full daemon suite: 2087/2087 (+2 from L3b baseline of 2085).
- full CLI suite: 4 pre-existing failures (port 7433 in use; chatroom
  timeout; config reads env). Same set documented in L2/L3/L3b
  checkpoints; verified not L3-followup regressions.
- `npx tsc --noEmit` clean on both packages.

Invariants preserved:

- **Backward compat for `rig ps --json`**: default (no flags) returns
  bare array with `rigName` alias added; `name` field preserved; existing
  consumers reading `.name` keep working.
- **Backward compat for `rig ps --nodes --json`**: same.
- **Envelope only on flag use**: `--full --json` keeps bare array;
  envelope only when shaping flag specified.
- **No silent nulls**: `lifecycleState` typed as required + asserted in
  test.
- **Truncation honesty**: footer always names actual totals + next-command
  hint; never silently drops.
- **Filter parser security**: allow-list only; no injection vector.
- **L1/L2/L3/L3b invariants untouched**: no changes to reconcile,
  lifecycle derivation, restore-attempt model, snapshot selection, or
  any L1-L3b surface.
- **No new schema migration; no daemon query parameters; no
  RestoreOrchestrator/rig-repository/node-inventory changes; no
  UI/MCP/Auth-C/OAuth/account/quota/`~/.codex` changes; no host
  lifecycle mutation; no kernel-rig dependency; no recurring
  drain/watchdog; no retired queue-fallback revival.**

Refs:
- rigs/openrig-build/state/orch/velocity-slice-rig-ps-trustworthy-and-scale-safe-plan-20260428.md
- rigs/openrig-build/state/orch/velocity-slice-rig-ps-trustworthy-driver-checkpoint-20260428.md (pre-commit driver checkpoint)
- rigs/openrig-build/state/orch/velocity-slice-rig-ps-trustworthy-guard-checkpoint-approve-20260428.md (guard ACCEPT)
- L3b predecessor: e70694f fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists
- L3 predecessor: 66785c8 feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation
- L2 predecessor: fad10a9 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection
- L1 predecessor: 3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 16:58:52 -07:00
mvschwarzandClaude Opus 4.7 e70694f660 fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists
Lifecycle v2 L3b builds on L3 (66785c8) by closing the rig-up-by-name +
manual-snapshot gap reproduced in the L4 VM proof at fad10a9 cycle 1. Before
L3b, an operator running `rig up <rigName>` against a rig that has only
manual snapshots got HTTP 404 with `code: "no_snapshot"` and the message
"Rig exists but has no auto-pre-down snapshot." even though `rig restore
<snapshotId> --rig <rigId>` succeeded against the same manual snapshot.

After L3b, `rig up <rigName>` consults the latest snapshot of any kind whose
metadata is sufficient for `RestoreOrchestrator.restore`'s pre-validation,
preferring `auto-pre-down` when present. The selected snapshot kind is
echoed in the API response so operators see what was used; the CLI prints
a one-line manual-fallback note when the kind is not `auto-pre-down`.

Decisions resolved (per plan, ratified by orch-lead with amendments):

A. Re-use `rig up <rigName>` (no new explicit operator command). The
   404-on-manual-only behavior was a missing-implementation symptom, not a
   safety feature. Adding a separate `rig recover` would force operators to
   memorize a "use this command for this snapshot kind" dichotomy that PM
   spec D2 warns against.

Per orch amendments (overriding the plan where they conflict):

1. **Validate against actual SnapshotData.** There is no `data.bindings[]`
   field, and `Session` has no `runtime` field — runtime lives on `nodes`.
   Resume tokens are NOT required (resume-tokenless rigs remain restorable).
   The new helper checks the same minimum metadata
   `RestoreOrchestrator.validatePreRestore` actually requires: rig with id,
   nodes, edges, sessions arrays, checkpoints object; and when sessions is
   non-empty each session has a non-empty `sessionName` and `nodeId`. Empty
   sessions arrays are accepted (matches the validator).

2. **Candidate selection does not LIMIT 1 before validation.** The SQL
   orders snapshots by `(kind = 'auto-pre-down') DESC, created_at DESC,
   id DESC`, then a JS loop validates each candidate and skips invalid
   rows (corrupted JSON, missing structural metadata, missing per-session
   linkage). Auto-pre-down preference is preserved.

3. **Test files** follow repo conventions:
   `snapshot-repository.test.ts`, `up-route.test.ts`, `rigs-routes.test.ts`,
   and `up.test.ts`.

Implementation:

- `packages/daemon/src/domain/snapshot-repository.ts`: new method
  `findLatestRestoreUsable(rigId): Snapshot | null`. Free helper
  `isRestoreUsableSnapshotData(data)` validates structural metadata.
  `findLatestAutoPreDown` and `findLatestUsableSnapshot` (L2) are
  unchanged.

- `packages/daemon/src/routes/up.ts`: `restoreByRigId` calls the new
  helper. Response `snapshotKind: snapshot.kind` echoed on success and on
  pre-restore-validation 409 paths. 404 message updated to "Rig exists but
  has no restore-usable snapshot. Start fresh with: rig up <spec-path>".

- `packages/daemon/src/routes/rigs.ts`: `/:id/up` mirrors the same
  changes.

- `packages/cli/src/commands/up.ts`: when the daemon response carries
  `snapshotKind` and it's not `auto-pre-down`, the CLI prints
  `Restoring from manual snapshot (kind=<kind>); no auto-pre-down snapshot
  available.` BEFORE the "Rig restored" line so operators see the
  fallback explicitly.

Tests added (matching plan closeout checklist + orch amendment #3):

- `daemon/test/snapshot-repository.test.ts`: 10 cases for
  `findLatestRestoreUsable` —
    auto-pre-down preferred over manual,
    manual returned when no auto-pre-down,
    latest of multiple manuals (created_at DESC, id DESC),
    auto-pre-down preferred over a NEWER manual,
    null when no snapshot exists,
    skips corrupted JSON and considers next,
    skips snapshot with empty sessionName on a session,
    skips snapshot with missing nodeId on a session,
    null when no snapshot has restore-usable structural metadata,
    accepts empty sessions array (matches validatePreRestore),
    plus a regression confirming `findLatestAutoPreDown` is unchanged.
- `daemon/test/up-route.test.ts`: 3 cases — auto-pre-down preference;
  manual-fallback restore returns `snapshotKind: "manual"`; 404 with
  updated `restore-usable` message and no `auto-pre-down snapshot` text.
- `daemon/test/rigs-routes.test.ts`: 3 cases mirroring the above for
  `/api/rigs/:id/up`.
- `cli/test/up.test.ts`: 2 cases — fallback note printed when
  `snapshotKind="manual"`; NOT printed when `snapshotKind="auto-pre-down"`.

Verification:

- focused daemon: `npx vitest run \
    test/snapshot-repository.test.ts \
    test/up-route.test.ts \
    test/rigs-routes.test.ts` → 65/65 green.
- focused CLI: `npx vitest run test/up.test.ts` → 27/27 green.
- full daemon suite: 2085/2085 (+17 from L3 baseline of 2068).
- full CLI suite: 4 pre-existing failures (port 7433 in use; chatroom
  timeout; config env reads). Confirmed not L3b regressions; same set
  L2/L3 documented.
- `npx tsc --noEmit` clean on both packages.

Invariants preserved:

- **Auto-pre-down preference unchanged** when both kinds are present.
- **`findLatestAutoPreDown` unchanged**; still returns auto-pre-down
  only with no kind fallback (regression-checked).
- **`findLatestUsableSnapshot` (L2) untouched** — different concept
  (requires resume tokens; consumed by lifecycle projection).
- **Direct `rig restore <snapshot> --rig <id>` unchanged.**
- **Fail-closed when no usable snapshot exists**: returns 404 with
  actionable message, no silent fallback to incompatible snapshots.
- **No schema migration**; the existing `snapshots.kind` text column is
  queried, not constrained.
- **No `RestoreOrchestrator` changes**; helper validation matches what
  the orchestrator already enforces in `validatePreRestore`.
- **No UI/MCP/Auth-C/OAuth/account/quota/`~/.codex` changes**;
  `routes/up.ts` and `routes/rigs.ts` /:id/up are the only routes
  touched.
- **No host lifecycle mutation.**

L3b implementation accept may close L3b directly without a new VM cycle
per the plan (the L4 proof at fad10a9 already reproduced the gap; the
fix is unit/integration-test-coverable; no new runtime behavior beyond
snapshot selection). If guard requests a focused VM cycle, tester can run
one targeted L4-3 cycle.

Refs:
- rigs/openrig-build/state/orch/velocity-slice-L3b-snapshot-selection-rig-up-by-name-plan-20260428.md
- rigs/openrig-build/state/orch/velocity-conveyor-state-l4-vm-reboot-proof-fad10a9-20260428.md (cycle 1 reproduced the gap)
- L3 predecessor: 66785c8 feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation
- L2 predecessor: fad10a9 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection
- L1 predecessor: 3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 14:31:17 -07:00
mvschwarzandClaude Opus 4.7 66785c8ee7 feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation
Lifecycle v2 L3 builds on L1 cold-start truth repair (3ad1df6) and L2 lifecycle
projection (fad10a9) to make restore attempts durable, interrupt-aware, and
reconcilable with later runtime truth. Four decisions resolved per the plan
packet:

1. **Attempt id source** — the persisted `restore.started` event seq IS the
   attempt id. No new `restore_attempts` table; the event log already orders
   restore lifecycle events durably. If a future need for in-flight status
   beyond the event log appears, escalate before adding a table or a
   `restore.in_progress` heartbeat.

2. **Claude resume-selection prompt** classified as
   `restoreOutcome=attention_required`. Do NOT auto-select any option;
   surface evidence (recent pane content) so an operator/UI can act.
   Auto-selection is deferred to a follow-up policy slice.

3. **Runtime-truth reconciliation** via new
   `RestoreOrchestrator.reconcileNodeRuntimeTruth(rigId, nodeId)`. Upgrades
   `failed`/`attention_required` to `operator_recovered` ONLY when ALL four
   visible-runtime-evidence preconditions hold:
     1. tmux session exists (`tmuxAdapter.hasSession`),
     2. foreground process is claude or codex (`paneCommand`),
     3. resume token was used (sessions row carries non-null `resume_token`),
     4. pane is at usable state (`nativeResumeProbe.assessNativeResumeProbe`
        returns `resumed`, NOT a resume-selection prompt).
   Reconciliation NEVER mutates or deletes the original failure event;
   instead it APPENDS `restore.outcome_reconciled` carrying attempt id, node
   id, from/to transition, and the four evidence fields. The terminal
   post-reconciliation outcome is `operator_recovered`; producing `ready` is
   forbidden.

4. **Rig-up-by-name + manual-snapshot fix is OUT OF SCOPE** for L3 and will
   become a separate slice (L3b). `routes/up.ts` and `up-command-router.ts`
   are not touched here.

Implementation:

- `packages/daemon/src/domain/types.ts`: extend `NodeRestoreOutcome` with
  `attention_required` and `operator_recovered`; extend
  `RestoreNodeResult.status` correspondingly; add `attentionEvidence` to
  `RestoreNodeResult`; add `restore.outcome_reconciled` to the `RigEvent`
  union.

- `packages/daemon/src/domain/native-resume-probe.ts`: new
  `attention_required` status; new code `claude_resume_selection_prompt`;
  detector requires both an explicit "Choose ... conversation" verb AND a
  numbered/arrow option marker so similar TUI strings don't false-positive.

- `packages/daemon/src/adapters/claude-resume.ts`: when the probe returns
  `attention_required`, propagate as
  `{ ok: false, code: "attention_required", message, evidence }` so the
  orchestrator can map to per-node `attention_required` instead of `failed`.

- `packages/daemon/src/domain/restore-orchestrator.ts`:
  - `restore()` accepts optional `onAttemptStarted: (attemptId) => void`
    callback fired with the persisted `restore.started` event seq.
  - `attemptResume()` returns a discriminated union covering the new
    `attention_required` kind.
  - Legacy resume path translates `attention_required` to per-node
    `RestoreNodeResult.status="attention_required"` with pane evidence.
  - `rollupRestoreRigResult()` rolls `attention_required` up as
    `partially_restored`.
  - New `reconcileNodeRuntimeTruth(rigId, nodeId): Promise<ReconcileNodeResult>`
    method implements Decision 3.

- `packages/daemon/src/domain/node-inventory.ts`: `deriveRestoreOutcome`
  recognizes the new outcomes; `deriveContinuityOutcome` maps
  `operator_recovered` → `resumed` and `attention_required` → null;
  `deriveNodeLifecycleState` recognizes the explicit `attention_required`
  outcome (alongside the L2 failed+alive proxy).

- `packages/daemon/src/routes/snapshots.ts`: restore route returns
  `{ ok: true, attemptId, status: "started", rigId }` with HTTP 202 AS SOON
  AS the orchestrator emits `restore.started`. Per-node restore work runs
  in the background; pre-restore validation failures and other
  "couldn't even start" errors return the original error mappings (404 / 409
  / 500) because no `restore.started` was emitted.

- `packages/cli/src/commands/restore.ts`: prints `Restore attempt id: <id>`
  and `Status: started` on the 202 path; installs SIGINT/SIGTERM handler
  that prints an honest message ("Client interrupt received; daemon-side
  restore may continue. Use 'rig ps --nodes' or 'rig restore-check' to
  follow progress."). Decision 1 amendment honored: the message references
  ONLY existing surfaces, never a non-existent `rig events` command.

Tests added (matching plan closeout checklist):

- `daemon/test/native-resume-probe.test.ts`: 4 cases — numbered prompt
  classification, › arrow variant, regression for active TUI not being
  classified as attention_required, regression that bare "Choose a
  conversation" mention without numbered options is not classified.

- `daemon/test/restore-orchestrator.test.ts`: 12 cases —
  `onAttemptStarted` invoked with restore.started seq;
  `onAttemptStarted` NOT invoked when pre-restore validation fails;
  reconcile success path for both `failed` and `attention_required`
  starting outcomes; audit-trail preservation; four no-op cases (one per
  precondition); refusal to upgrade non-failed/attention_required outcomes;
  rejection when no `restore.started` recorded; assertion that the upgrade
  target is always `operator_recovered`, never `ready`.

- `daemon/test/snapshots-routes.test.ts`: existing route tests updated to
  the new async contract (202 + attemptId + queryable event seq).

- `cli/test/restore.test.ts` (NEW): 4 cases — CLI prints attempt id from
  202 response; SIGINT handler text references only existing surfaces and
  not a fake `rig events`; 404 path; 409 pre-restore-validation-failed
  path with blockers.

Verification:

- focused daemon: `npx vitest run test/restore-orchestrator.test.ts test/native-resume-probe.test.ts test/snapshots-routes.test.ts`
  → 125/125 green.
- focused cli: `npx vitest run test/restore.test.ts` → 4/4 green.
- full daemon suite: 2068/2068 green (up from 2052 L2 baseline; +16 L3 tests).
- full cli suite: 4 pre-existing failures (port 7433 in use; chatroom timeout
  flake; config reads env). Confirmed not L3 regressions by stash-and-rerun
  on baseline before L3 changes.
- `npx tsc --noEmit` clean on both packages.

Invariants preserved:

- Permission errors remain fail-closed (L1 unchanged).
- L2 lifecycleState projection unchanged for pre-L3 outcomes; new outcomes
  flow correctly.
- `restoreOutcome=ready` is NEVER produced anywhere. The reconciler upgrade
  target is locked to `operator_recovered`.
- Audit trail: original failure events are NEVER mutated or deleted; the
  reconciler always APPENDS `restore.outcome_reconciled`.
- No new `restore_attempts` table; no new schema migration.
- No `rig events` CLI command added or referenced (Decision 1 amendment).
- No auto-selection on resume prompts (Decision 2).
- No `routes/up.ts` / `up-command-router.ts` changes (Decision 4: rig-up-by-name
  fix is L3b).
- No CLI cancellation as a daemon protocol; ship the honest interrupt message
  only.

L3 implementation accept does NOT close L3. Full closure waits on a follow-up
L4 sub-cycle (or L4-2) where tester proves D5/D6 surfaces with L3 in lane
(mirror of L1/L2).

Refs:
- rigs/openrig-build/state/orch/velocity-slice-L3-restore-attempt-state-model-plan-20260428.md
- L1 predecessor: 3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile
- L2 predecessor: fad10a9 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 13:58:36 -07:00
mvschwarzandClaude Opus 4.7 fad10a9311 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection
Lifecycle v2 L2 builds on the L1 cold-start truth repair (3ad1df6) by giving
operators a first-class projection that distinguishes recoverable rigs/nodes
from genuinely-stopped ones. Pre-L2, a detached session row and a stopped-from-
scratch rig look identical to `rig ps` and `rig up`; the wording divergence
between "Recover from snapshot" and "Turn on" is unreachable without this state.

All new state is DERIVED — no schema migration, no new column, no auto-restore
on daemon start. Decisions per plan packet:
1. v0 derives `recoverable` from session detachment + snapshot resume metadata;
   persistence deferred until performance/projection needs justify it.
2. CLI says "Recover" when the rig's latest usable snapshot can resume; says
   "Turn on" when no usable snapshot exists. Help-text honesty: "Recover"
   describes what `rig up` does, it does not promise success before tester L4.
3. Rig-level `degraded` = mixed live + detached on the same rig; per-node
   `attention_required` = the post-L3 Claude resume-prompt proxy (failed
   restoreOutcome with a still-alive tmux session).

Per-node lifecycleState (additive on NodeInventoryEntry):
  attention_required > running > recoverable > detached, in priority order.

Per-rig lifecycleState (additive on PsEntry):
  any attention_required → attention_required;
  all running → running;
  all non-running, any recoverable → recoverable;
  all non-running, none recoverable → stopped;
  mixed → degraded.

Implementation:
- packages/daemon/src/domain/types.ts: declare NodeLifecycleState +
  RigLifecycleState; add lifecycleState to NodeInventoryEntry.
- packages/daemon/src/domain/rig-repository.ts: free function plus instance
  method `findLatestUsableSnapshot(rigId)` returns the latest snapshot whose
  data carries at least one non-null resume token; returns null otherwise.
- packages/daemon/src/domain/node-inventory.ts: new helper
  `deriveNodeLifecycleState`; getNodeInventory loads the rig's usable
  snapshot once and folds per-node state.
- packages/daemon/src/domain/ps-projection.ts: new helper
  `deriveRigLifecycleState`; getEntries derives per-rig lifecycleState by
  folding per-node states from getNodeInventory.
- packages/daemon/src/routes/rigs.ts: GET /api/rigs/summary enriched with
  lifecycleState so CLI surfaces (rig up wording) work without a second
  round trip.
- packages/cli/src/commands/ps.ts: add LIFECYCLE column to rig + node tables
  with abbreviated codes (run/rec/stp/deg/att, run/rec/det/att); JSON
  output passes through lifecycleState from API.
- packages/cli/src/commands/up.ts: when isRigName resolves to an existing
  rig (no library spec match), fetch /api/rigs/summary and print
  "Recovering rig 'X' from latest snapshot..." for recoverable, "Turning
  on rig 'X'..." for stopped. JSON output is unaffected (no prefix).

Tests added (matching plan closeout checklist):
- packages/daemon/test/node-inventory.test.ts: 6 lifecycleState cases
  (running, recoverable with token, detached when token absent, detached
  with no snapshot, attention_required on failed+alive, exited+detached).
- packages/daemon/test/ps-projection.test.ts: 5 rig-level cases plus
  deriveRigLifecycleState fold helper full-branch coverage. Also migrated
  the test fixture to createFullTestDb so the full migration set (including
  pods) is available — required because ps-projection now folds via
  getNodeInventory.
- packages/daemon/test/rig-repository.test.ts: 5
  findLatestUsableSnapshot cases (no snapshot, all-null tokens, partial
  tokens, latest-of-many ordering, returns even when only some nodes have
  tokens — per-node state then determines individual recoverability).
- packages/cli/test/ps.test.ts: 4 cases (rig table LIFECYCLE column with
  all five abbreviated codes; rig JSON includes lifecycleState; node
  table column; node JSON includes lifecycleState).
- packages/cli/test/up.test.ts: 4 wording-divergence cases (recoverable
  prints Recovering, stopped prints Turning on, running prints neither,
  --json output is clean).

Verification:
- npx vitest run focused (daemon node-inventory + ps-projection +
  rig-repository): 62/62 green.
- npx vitest run focused (cli ps + up): 43/43 green.
- Full daemon suite: 2052/2052 green.
- Full CLI suite has 4 pre-existing failures (port 7433 in use,
  chatroom timeout flaky, config reads env) — confirmed by running them
  on the pre-L2 baseline. Not caused by L2.
- npx tsc --noEmit clean on both packages.

Invariants preserved:
- No persisted column added (decision #1 derive-only).
- Existing `sessionStatus`/`startupStatus`/`restoreOutcome` fields
  unchanged — `lifecycleState` is additive.
- No auto-restore on daemon start; `createDaemon` reconcile loop is
  unchanged from L1.
- Help text says "Recovering ... from latest snapshot" which describes
  what `rig up` does without promising success before tester L4 VM proof.
- `recoverable` derivation handles null fields gracefully (snapshot exists
  but token is null → node stays detached, not falsely recoverable).
- Permission errors (L1 fail-closed) propagate unchanged through the new
  projection; lifecycleState only consults sessionStatus + restoreOutcome
  + snapshot data.

L2 implementation accept does NOT close L2 — full closure waits on tester
L4 VM-reboot end-to-end proof, mirror of L1.

Refs:
- rigs/openrig-build/state/orch/velocity-slice-L2-recoverable-state-safe-defaults-plan-20260428.md
- L1 predecessor commit: 3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 13:04:40 -07:00
mvschwarzandClaude Opus 4.7 3ad1df6339 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile
After a host reboot the tmux socket file at /tmp/tmux-<uid>/<name> is gone, so
`tmux has-session` exits non-zero with a transport-absent message rather than
the server/session-absent messages the existing classifier knows. The startup
reconciler caught the unknown error, recorded it silently, and skipped marking
the row detached, leaving DB rows that said `running`/`ready` against a tmux
reality where the sessions were gone.

Add `isTmuxTransportAbsentError` to the tmux adapter; classify post-reboot
socket-absence (`No such file or directory`) and `Connection refused` against
a tmux socket path as absence. Wire it into `hasSession`, `listSessions`,
`listWindows`, and `listPanes` for symmetry. Permission errors (`Permission
denied`, `Operation not permitted`, `EACCES`, `EPERM`) remain rethrown so
callers stay fail-closed; the classifier checks the permission keywords
before any absence pattern.

Capture each `Reconciler.reconcile(rig.id)` aggregate at startup and log a
compact summary line:

  startup reconcile: rigs=<n> checked=<n> detached=<n> errors=<n>

Per-rig errors are written as warnings without crashing startup. Logging is
guarded so it can never throw out of `createDaemon`.

Tests:
- packages/daemon/test/tmux-adapter.test.ts — 4 new `hasSession` cases
  (2 socket-absent variants, 2 permission rethrow regressions).
- packages/daemon/test/reconciler.test.ts — explicit socket-absent detach
  case + explicit permission-rethrow case.
- packages/daemon/test/startup.test.ts — assertion that the compact reconcile
  summary line appears in startup output.

Refs:
- openrig-work/field-notes/2026-04-28-openrig-post-reboot-tmux-restore-postmortem.md
- rigs/openrig-build/state/orch/velocity-slice-L1-cold-start-tmux-truth-repair-plan-20260428.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-28 12:26:48 -07:00
mvschwarz 17812d5d3b feat(agents): make runtime config spec-driven 2026-04-26 18:30:08 -07:00
mvschwarz 3071234eca fix: relax managed claude permission provisioning 2026-04-26 17:02:06 -07:00
mvschwarz de62cc2593 fix: allow release docs in docs guard 2026-04-26 16:56:13 -07:00
mvschwarz d7af73706a feat: support portable rig specs and codex profiles 2026-04-26 16:48:53 -07:00
mvschwarz 5a78aedc02 feat(daemon): provision managed runtime activity hooks 2026-04-24 14:41:13 -07:00
mvschwarz 56b90d48c3 fix(daemon): gate codex model selection readiness 2026-04-24 14:10:35 -07:00
mvschwarz 2e4067f41d fix(cli): honor wait-for-idle request timeout 2026-04-24 13:44:54 -07:00
mvschwarz e9b1251591 fix(daemon): avoid flavor text in claude activity fallback 2026-04-24 13:20:39 -07:00
mvschwarz 878a18296d fix(daemon): classify current claude activity for wait-for-idle 2026-04-24 13:11:23 -07:00
mvschwarz 437eaafbb5 feat(cli): add wait-for-idle send mode 2026-04-24 10:52:48 -07:00
mvschwarz d50f7b9aa5 fix(daemon): add codex queue state writable root 2026-04-24 08:52:58 -07:00
mvschwarz 388ea09e5b feat(workflow): add hook-backed agent activity truth 2026-04-24 00:26:19 -07:00
mvschwarz 2def07e878 feat(workflow): surface agent activity truth 2026-04-23 23:57:34 -07:00
mvschwarz 462550807a feat(cli): add compact plan thresholds 2026-04-23 23:09:23 -07:00
mvschwarz 6a7e38fcf3 fix(daemon): keep polling for codex update gate 2026-04-23 22:35:39 -07:00
mvschwarz f1e4192975 fix(restore): handle codex update-gated restore 2026-04-23 22:23:37 -07:00
mvschwarz aefe4b4a5d feat(restore): prevalidate restore attempts 2026-04-23 21:01:44 -07:00
mvschwarz 501f77766b fix(restore): correct restore truth semantics 2026-04-23 20:44:28 -07:00
mvschwarzandClaude Opus 4.6 e49bae30eb feat(cli): add discovered seat handover mutation
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 19:46:10 -07:00
mvschwarzandClaude Opus 4.6 910d9786b5 feat(cli): add seat handover dry-run planner
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 19:19:10 -07:00
mvschwarzandClaude Opus 4.6 5f3844fa39 feat(cli): add seat handover status surface
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 19:13:03 -07:00
mvschwarzandClaude Opus 4.6 9e6faddc32 feat(cli): add native workflow heartbeat
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 18:59:15 -07:00
mvschwarzandClaude Opus 4.6 6f90d644b4 fix(daemon): honor codex model and relax managed git guardrails
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 18:41:11 -07:00
mvschwarz 6c7cc0d3f6 Validate startup actions in restore-check 2026-04-23 09:18:42 -07:00
mvschwarz f657e53c3a Add restore-check startup-context integrity checks 2026-04-23 09:12:18 -07:00
mvschwarz f09dc5f6c5 Block restore-check recovery on identity gaps 2026-04-23 08:31:56 -07:00
mvschwarz b2d121c4d4 Add restore-check recovery planning 2026-04-23 08:18:40 -07:00
mvschwarz 6696649095 Merge Claude hook checks across settings levels 2026-04-23 07:51:13 -07:00
mvschwarz 331e9321e9 Add restore-check Claude hook inspection 2026-04-23 07:44:44 -07:00
mvschwarz 0843d4bc30 Add host infra evidence path checks 2026-04-23 07:19:30 -07:00
mvschwarz 186fc4b221 Preserve host infra state for missing rig restore check 2026-04-23 06:57:00 -07:00
mvschwarz c56934f427 Add restore-check host infra declaration 2026-04-23 06:51:38 -07:00
mvschwarz 69df0f1c99 Add restore-check route fallback repair packet 2026-04-23 06:34:51 -07:00
mvschwarz 2b6224c2be Add restore-check fully-back assertions 2026-04-23 06:19:46 -07:00
mvschwarz cee9a8481a Add read-only compact plan command 2026-04-23 06:04:43 -07:00
mvschwarz 69c1bffe7b Add Claude compact-in-place skill 2026-04-23 05:45:41 -07:00
mvschwarzandClaude Opus 4.6 5109053de5 fix(restore-check): per-action safe classification + conservative default
Cycle-2 of H46 Slice 2 per QA REJECT on safe contract.

safe now means execution safety per action, not blanket true:
- safe:false for mutating actions (daemon start, chmod, create
  files, snapshot creation, queue file creation)
- safe:true for read-only/informational actions (check daemon
  status, list rigs, transcript info, hook inspection guidance)
- Conservative default: omitted remediationSafe → safe:false so
  new checks without explicit classification don't invite agents
  to auto-execute mutating commands

blocking remains severity (must-fix-before-restore vs caveat).
Agents distinguish severity via blocking, execution safety via
safe. Both fields are independent.

Comment on CheckEntry.remediationSafe updated to document
conservative default. CLI daemon-down local result updated to
safe:false (daemon start is mutating).

Tests: omission-default regression proving omitted remediationSafe
→ safe:false; blocker test expects safe:false for daemon start;
caveat test expects safe:false for snapshot creation; route test
for non-null repairPacket with blocking field on broken fixture.

Service 37/37; route 8/8; CLI 12/12 = 57 total. Daemon 1861/1861;
CLI 471/472 (preexisting chatroom); tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 04:58:27 -07:00
mvschwarzandClaude Opus 4.6 6c52cbfef4 feat(restore-check): repair manifest with blocking/safe severity
H46 Slice 2: populate repairPacket with agent-readable repair steps.

repairPacket shape: Array<{step, command, rationale, safe, blocking}>
- step: 1-indexed ordinal
- command: prose remediation action (not auto-executed)
- rationale: evidence of what was wrong
- safe: true (all Slice 2 actions are read-only suggestions)
- blocking: true for red/must-fix-before-restore; false for
  yellow/caveat. Agents distinguish severity via blocking, not safe.

Packet is null when verdict is restorable (nothing to fix).
Ordering: blockers first, then caveats, both in check order.
1-indexed steps after filtering entries with empty remediation.

CLI daemon-down and 500/unknown local results also produce
repairPacket with correct blocking/safe fields and exit codes.
Human output shows repair step count + blocker/caveat breakdown.

Unknown verdict includes repairPacket with blocking:true entries
so agents can identify probe-recovery actions.

Tests: service 36/36 (+6 repair-packet); route 8/8 (+2 broken-
fixture repairPacket + blocking field); CLI 12/12 (+1 daemon-down
repairPacket + 1 500-repairPacket + 1 human repair-step output).
Full daemon 1860/1860; CLI 471/472 (preexisting chatroom);
tsc + build clean both workspaces.

TDD note: RED evidence was produced by stashing production edits
and running new tests against Slice 1 baseline (repairPacket
always null). QA contributed compatible test variants before
ownership coordination was established; absorbed intentionally.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 04:45:17 -07:00
mvschwarzandClaude Opus 4.6 6aa68d89b2 test(restore-check): temp OPENRIG_HOME mtime regression for read-only invariant
Cycle-5 follow-up to 004e36f (not amended). r2 reproduced a
directory mtime mutation from the old write+delete probe. The
cycle-4 test only tracked exists() references, which was too weak.

This test:
- Creates a temp dir and sets OPENRIG_HOME to it
- Pins mtime to a known past value via utimesSync
- Runs RestoreCheckService.check({noQueue:true, noHooks:true})
- Asserts no .restore-check-probe file exists
- Asserts directory mtimeMs is unchanged (no filesystem mutation)
- Restores original OPENRIG_HOME in finally block

Proves the accessSync(W_OK) replacement from 004e36f is genuinely
non-mutating at the filesystem level, not just at the import level.

Service 26/26; route 6/6; full daemon 1848/1848; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 04:22:15 -07:00
mvschwarzandClaude Opus 4.6 004e36f2fa fix(restore-check): non-mutating state-dir writability probe
Cycle-4: r2 found checkStateDirWritable() created and deleted a
.restore-check-probe file, violating H46's read-only boundary.

Replaced writeFileSync/unlinkSync with accessSync(stateDir, W_OK)
— a non-mutating permission check that throws on non-writable
without touching the filesystem.

Regression test added: service.check() does not reference any
probe file path through the exists() dep. Removed unused fs
imports (writeFileSync, unlinkSync).

Service 26/26; route 6/6; full daemon 1848/1848; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 04:17:22 -07:00
mvschwarzandClaude Opus 4.6 d8ef8dd18a fix(restore-check): daemon probe throw produces unknown, not not_restorable
Cycle-3 of H46 Slice 1. QA found probeDaemonHealth() throw still
produced not_restorable because checkDaemonReachable() returned a
red CheckEntry on catch, and buildResult() aggregated red into
not_restorable.

checkDaemonReachable() now returns null on probe throw. Caller
short-circuits to buildUnknown() when null is returned. Definite-
down (healthy=false, negative text) still produces red/
not_restorable. Only probe exceptions (socket unavailable, etc.)
produce unknown.

Test added: probeDaemonHealth throw → verdict unknown + daemon
check red with "unable to determine" evidence.

Service 25/25; route 6/6; full daemon 1847/1847; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 04:07:53 -07:00
mvschwarzandClaude Opus 4.6 89e25027f8 fix(restore-check): add spec/root checks + probe-error→unknown + honest hooks
Cycle-2 of 4a333f2 per QA REJECT.

QA Fact 1 (missing spec/root checks): added checkSpecPresent()
with three states: rig root missing → red; rig root exists but
rig.yaml missing → yellow; both present → green. Tests added for
all three paths.

QA Fact 2 (probe error → not_restorable instead of unknown):
listRigs and getNodeInventory catch blocks now call buildUnknown()
which produces verdict: "unknown" (not "not_restorable"). Operators
can distinguish "definitely broken" from "checker couldn't inspect."
2 tests added for thrown probes expecting unknown verdict.

QA Fact 3 (hooks false-green): checkHooks() now returns yellow with
"not yet implemented (Slice 2)" evidence instead of false-green.
--no-hooks removes the check entirely; without it, hooks are
honestly classified as uninspected. Test added asserting yellow
status. Verdict aggregation test updated to use --no-hooks for
"all green" path.

Route tests fixed: removed dynamic import; use static import
of createApp directly.

Tests: service 24/24 (+6); route 6/6; CLI 11/11. Full daemon
1846/1846; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 04:03:41 -07:00
mvschwarzandClaude Opus 4.6 4a333f294b feat: native rig restore-check command
H46 Slice 1: read-only restore-readiness diagnostic across host,
rigs, and seats.

CLI: rig restore-check [--rig <name>] [--no-queue] [--no-hooks]
[--json]. Exit 0 for restorable/restorable_with_caveats, 1 for
not_restorable, 2 for unknown/probe-error. Human table shows
per-check evidence + remediation. JSON includes verdict, counts,
checks array (check/status/evidence/remediation), repairPacket
null (Slice 2).

Daemon: RestoreCheckService (framework-free, read-only per ADR-0001)
consumes NodeInventory (ADR-0002) + TranscriptStore + SnapshotRepo.
No state mutation, no new ServiceOrchestrator call site (ADR-0003).
Route at GET /api/restore-check?rig=<name>&noQueue=true&noHooks=true.

Three-tier check model:
- Host: daemon.reachable (anchored positive match preserving
  false-green fix), host.state-dir-writable
- Rig: snapshot presence
- Seat: transcript (infra exempt), resume-path, queue-file
  (skippable via --no-queue), hooks (skippable via --no-hooks)

Verdict: all-green=restorable, any-yellow=caveats, any-red=not_restorable.

Daemon false-green regression matrix: exact "Daemon not running",
lowercase, empty output, suspicious non-anchored "running",
canonical anchored "Daemon running" — 5 test cases.

Tests: daemon service 18/18 + route 6/6; CLI 11/11. Full daemon
1840/1840; CLI 470/471 (preexisting chatroom); tsc + builds clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 03:55:10 -07:00
mvschwarzandClaude Opus 4.6 9efaa249f1 test(cli): add --refresh success/failure/path tests for rig context
Cycle-4 of H44 Slice 2 per QA narrow test-only rejection.

3 CLI refresh tests added:
- --refresh calls /nodes?refresh=true once then normal inventory
  (path tracking via requestedPaths array)
- refresh 502 failure exits 2 with "Context refresh failed" +
  "Fix:" copy (hard-fail, not silent stale)
- refresh success returns same JSON semantics as non-refresh

CLI context tests now 13/13. All builds clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 03:31:24 -07:00
mvschwarzandClaude Opus 4.6 fbddd1155e test: daemon refresh route + UI context prominence regressions
Cycle-3 of H44 Slice 2 per QA REJECT-AND-REVISE on a5d5fc3.

QA item 1 (daemon route tests): 4 tests added to sessions-routes
proving ?refresh=true calls pollOnce before projection, non-refresh
does not call it, refresh failure returns 502 with code
context_refresh_failed, and missing contextMonitor is handled.

QA item 2 (UI tests): 4 RigNode tests proving prominent context
number for known-fresh (red >=80, amber 60-80, green <60), stale
at 50% opacity, unknown shows '?'. 2 Explorer tests proving
context percentage indicator per agent row with amber color +
fallback to runtime label for unknown context.

All existing tests preserved. Gates: daemon 1816/1816;
CLI 456/457 (preexisting chatroom); UI 402/402 (38 files);
all builds clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 03:26:59 -07:00
mvschwarzandClaude Opus 4.6 a5d5fc3eae fix(context): strict threshold validation + refresh hard-fail + CLI tests
Cycle-2 of bad82b3 per QA REJECT-AND-REVISE.

QA Fact 1 (missing tests): added 10 CLI context tests covering
human output, JSON shape, --rig scoping, --threshold filtering
with stale/unknown retention, sort order, stale-vs-ok, unknown
honesty, and invalid threshold rejection.

QA Fact 2 (threshold parsing bug): replaced bare parseInt with
strict Number() + isInteger + 0-100 range check. Invalid input
(abc, -1, 101) now exits 2 with copy matching rigx-context
oracle: "must be an integer percentage".

QA Fact 3 (refresh failure semantics): refresh failure is now a
hard error — exits 2 with fact/detail/fix copy. No silent stale
data display. Non-refresh reads continue to work normally.

Tests: context.test.ts 10/10; CLI 456/457 (preexisting chatroom);
daemon 1812/1812; UI 396/396; tsc clean all workspaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 03:20:28 -07:00
mvschwarzandClaude Opus 4.6 bad82b3a7b feat: native rig context command + dashboard context prominence
H44 Slice 2: context-usage as a first-class operator surface.

CLI: new `rig context` command with two-axis status model
(urgency x freshness) matching the shipped rigx-context wrapper.
Flags: --rig, --threshold (keeps unknown + stale visible),
--refresh (triggers daemon re-sample), --json. Sort: CRITICAL >
WARNING > stale > ok > unknown. Only low+fresh = ok; low+stale =
stale (not ok). Human table + fleet summary.

Daemon:
- FRESHNESS_THRESHOLD_MS updated from 120_000 (2min) to 600_000
  (10min) per PM spec — aligns CLI/UI/daemon freshness semantics.
- contextMonitor wired through AppDeps so routes can call
  pollOnce() for --refresh plumbing.
- GET /api/rigs/:id/nodes?refresh=true triggers pollOnce before
  inventory projection; 502 on refresh failure (honest, non-silent).

UI:
- RigNode.tsx: context percentage enlarged from 8px badge to
  prominent 16px bold number; color-coded (green <60 / amber
  60-80 / red >=80); stale samples at 50% opacity; unknown shows
  "?" instead of "CTX unknown".
- Explorer.tsx: context percentage indicator added per agent row;
  replaces runtime label when context data available; same color
  coding + stale opacity.
- useNodeInventory.ts: contextUsage type added to
  NodeInventoryEntry interface.

Files: 10 across CLI/daemon/UI. No new /api/context endpoint —
reuses existing node inventory routes per spec.

Gates: daemon 1812/1812; CLI 446/447 (preexisting chatroom);
UI 396/396; tsc clean across all 3 workspaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 03:13:36 -07:00
mvschwarz efa6f41575 docs(skills): add OpenRig operator triage guidance 2026-04-23 00:49:40 -07:00
mvschwarzandClaude Opus 4.6 bc9c9025b8 test(restore): production TmuxAdapter probe-error end-to-end regression
Cycle-3A test-only addendum. Prior fail-closed test used a mocked
hasSession vi.fn throw. This test wires a REAL TmuxAdapter with
a mock exec that throws Permission denied for has-session commands.
Proves the actual adapter error-classification contract (known
absence vs unexpected probe failure) propagates through
classifyRunningSessions to produce rig_not_stopped without DB
mutation or pre_restore snapshot creation.

Closes the r2 review requirement for a production-adapter-backed
restore regression rather than relying on logical sufficiency
from separate adapter + orchestrator mocked tests.

Gates: restore-orchestrator 55/55; full daemon 1812/1812;
tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 00:10:37 -07:00
mvschwarzandClaude Opus 4.6 daccae566f fix(tmux): classify hasSession errors as absence vs unexpected probe failure
Cycle-3 of snapshot-restore fix. r2 found that hasSession swallowed
all errors as false, so classifyRunningSessions could never reach
its unknown/fail-closed branch with the real adapter. A permission
denied or socket error would be treated as "session absent" and
restore would proceed over a session it couldn't actually verify.

hasSession now distinguishes:
- Known absence (return false): "session not found", "can't find
  session", "no server running" — session genuinely doesn't exist.
  Post-crash restore proceeds for these.
- Unexpected probe failure (rethrow): permission denied, socket
  error, unrecognized exec failure. classifyRunningSessions catches
  the throw, classifies as unknown, and blocks restore with
  rig_not_stopped. No DB mutation, no pre_restore snapshot.

Added isSessionAbsenceError() helper alongside existing
isNoServerError().

Tests added/updated:
- tmux adapter: 6 hasSession tests (exits-0 true, session-not-found
  false, can't-find false, no-server false, permission-denied throws,
  generic-error throws)
- restore: fail-closed test uses Permission denied error (previously
  used "tmux server not running" which is a known-absence pattern)

Gates: tmux-adapter 33/33; restore-orchestrator 54/54;
snapshots-routes 20/20; startup 9/9; full daemon 1811/1811;
tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-23 00:05:57 -07:00
mvschwarzandClaude Opus 4.6 9010f4563a fix(restore+tmux): classify sessions without mutation + reliable has-session
Cycle-2 revision of 44b7a3d per r2 REJECT-AND-REVISE + qa_mentor
VM RED.

r2 MUST-FIX 1 (pre_restore snapshot ordering): cycle-1 mutated DB
(marked stale sessions detached) before capturing pre_restore
snapshot, so the snapshot recorded post-mutation state. Fixed:
classifyRunningSessions reads + checks tmux WITHOUT mutating DB;
pre_restore snapshot captures while DB still has original running
state; stale sessions marked detached only AFTER snapshot.

r2 MUST-FIX 2 (latestByNode miss): cycle-1 used a per-node Map
proxy that could miss older live sessions behind newer detached
rows. Fixed: classifyRunningSessions iterates ALL sessions (not
just latest-per-node); any running session with live tmux blocks.

qa_mentor VM RED (hasSession misclassification): TmuxAdapter
hasSession relied on listSessions format-string parsing which
failed when tab delimiters were malformed. Fixed: hasSession now
uses `tmux has-session -t <name>` directly (exit-code-based; no
output parsing needed).

Tests added (4 restore + 3 tmux adapter updated):
- pre_restore snapshot preserves original running status
- older-live + newer-detached same-node blocks
- tmux-check-throws → fail-closed (rig_not_stopped, no mutation,
  no pre_restore snapshot)
- tmux adapter hasSession tests use has-session command

Startup composition tests updated: mock exec throws for
has-session commands to simulate missing sessions correctly.

Gates: restore-orchestrator 54/54; snapshots-routes 20/20;
tmux-adapter 30/30; startup 9/9; full daemon 1808/1808 (128
files); tsc + build clean.

Recommend r1+r2 re-review + qa_mentor VM re-dogfood.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 23:48:06 -07:00
mvschwarzandClaude Opus 4.6 44b7a3d872 fix(restore): reconcile stale DB sessions before blocking on running check
After a tmux crash, DB sessions still show status='running' even
though tmux sessions are dead. RestoreOrchestrator.restore()
checked hasRunningSessions() against DB state alone, refusing
restore with 'rig_not_stopped' when the rig was actually stopped.

Add reconcileStaleRunningSessions() before the running-sessions
check: verifies each DB-running session against tmux reality via
hasSession(); marks dead sessions as 'detached'. Genuinely live
tmux sessions still block restore (inverse invariant preserved).
Fails closed: if tmux check errors, session stays running (honest
about uncertainty).

Error message updated from generic 'must be stopped' to 'has live
sessions' with concrete recovery guidance.

Tests added (3):
- Post-crash: stale DB running + tmux dead → restore proceeds
- Inverse: live tmux sessions → restore still blocks (409)
- Partial failure: one node launch fails → all snapshot nodes
  present in result with logicalIds (no silent pod drops)

Route test (snapshots-routes) updated: 409 test now uses
hasSession=true to simulate genuinely live sessions.

Gates: restore-orchestrator 51/51; snapshots-routes 20/20; full
daemon 1805/1805 (128 files); tsc + build clean.

Restore semantics materially changed (reconcile-before-check):
recommend r1/r2 adversarial review + qa_mentor VM dogfood.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 23:24:58 -07:00
mvschwarzandClaude Opus 4.6 8411d0bb0d fix(transcript-store): filter full Codex startup box including model/directory lines
Cycle-2 revision of 3cafd39 per QA merge-gate rejection.

QA repro: box-wrapped Codex startup banner inner lines (blank
rows, model:, directory:) survived cleanup because
isStartupSplashLine only matched branded headers. The full
╭───╮ / │...│ / ╰───╯ box structure contains 5-7 lines; only 1
was filtered.

Extended isStartupSplashLine with two context-safe additions:
- Box-drawing-only lines (│ + whitespace + │, border rows):
  filtered when the line contains box chars but is empty after
  stripping them.
- model:/directory: lines INSIDE box wrappers: filtered only when
  the original line has │/╭/╰/╮/╯ chars (context discriminator).
  Standalone model:/directory: output without box wrappers
  survives — a new preservation test confirms.

Tests: 33/33 transcript-store (+1 standalone model/directory
preservation). Full daemon 1802/1802. tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 23:00:27 -07:00
mvschwarzandClaude Opus 4.6 3cafd3927d fix(transcript-store): filter startup splash/header lines from cleaned view
TranscriptStore's cleaned transcript view already handled 3 of 4
PM noise classes (orphan cursor fragments, status overlays,
spinner/chrome/TUI). Startup splash/header lines survived cleanup.

Add isStartupSplashLine() filter for:
- Claude Code version headers ("Claude Code v2.1.101")
- Claude model/plan lines ("Opus 4.6 (Claude Max)")
- Codex version headers ("OpenAI Codex (v0.120.0)"), including
  box-drawing-wrapped variants from Codex's TUI banner

Pattern uses ^ anchoring so legitimate sentences mentioning these
strings ("I was running Claude Code v2.1.101 when...") survive.
Box-drawing chars stripped before matching to handle Codex's
bordered startup banner.

Conservative scope per QA directive: does NOT filter generic
"model:" or "directory:" lines that could be legitimate command
output. Only filters clearly-branded startup header patterns.

Tests added (2): startup splash lines removed from both runtimes
+ preservation of legitimate sentences mentioning splash strings.

Product recommendation: rig transcript stays cleaned-by-default;
--raw/--clean split deferred unless forensic workflows request it.

Gates: transcript-store 32/32 (+2); full daemon 1801/1801;
tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 22:55:54 -07:00
mvschwarzandClaude Opus 4.6 3b7ffa7d3b fix(claude-adapter): remove echo from baseline + file-idempotent provisioning
Cycle-2 revision of 3d8d7e8 per adversarial-pair synthesis.

r2 HIGH: Bash(echo:*) is not a convenience-safe pattern — echo
can write via shell redirection (echo "x" > file). Removed from
the baseline. Remaining 10 patterns are genuinely read-only Bash
commands. Comment and constant renamed from STANDARD_SAFE_BASELINE
to CONVENIENCE_BASELINE; provenance baseline marker changed from
"standard-safe" to "convenience"; test section header updated.
Code comment documents shell-redirection residual risk explicitly.

r2 MEDIUM: provenance timestamp made repeated provisioning
non-file-idempotent. Fixed by tracking whether any new patterns
were actually added; if all patterns are already present, the
write is skipped entirely (preserves existing file content +
provenance timestamp). New regression test verifies two
consecutive deliverStartup calls produce byte-identical
settings.json.

Gates: claude-adapter 27/27 (+1 file-idempotency regression);
full daemon 1799/1799; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 20:20:07 -07:00
mvschwarzandClaude Opus 4.6 3d8d7e83c8 feat(claude-adapter): standard-safe permission baseline at spawn
Extend provisionRigPermissions() to inject the standard-safe
Bash baseline alongside the existing Bash(rig:*) on every rig up /
restore / launch. Addresses B1 momentum-killer where Claude Code
sessions reset to ask-all on benign read-only ops (ls, cat, tail,
head, wc, grep, rg, pwd, echo, which).

11 patterns in STANDARD_SAFE_BASELINE using Bash(cmd:*) colon-form.
Additive only — never removes existing allow entries. Provenance
marker (_openrig_provenance) written so operators can distinguish
rig-injected from human-authored entries.

Path C partial fix per PM spec: durable across OpenRig lifecycle
events (re-provisions on every touch); unknown durability across
arbitrary Claude updates (spike never concluded). Honest position
documented in architect brief.

v0 scope: no rig.yaml schema changes, no new CLI commands, no new
routes, no teardown cleanup. Same 11 patterns for every Claude
Code member (per-rig customization deferred to v0.5).

Tests added (6): baseline applied on fresh settings, additive with
existing entries, idempotent on re-run, provenance marker present,
deny/ask preserved, non-permissions keys preserved.

Gates: claude-adapter 26/26; full daemon 1798/1798; tsc + build
clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 20:06:21 -07:00
mvschwarzandClaude Opus 4.6 09f8cc8682 fix(cli): include postinstall script in tarball + classify native errors honestly
Cycle-2 revision of 638c541 per adversarial-pair + VM synthesis.

r2 MUST-FIX: npm pack excluded scripts/check-abi.mjs because the
files field only listed ['dist', 'daemon', 'ui']. Fresh installs
failed with MODULE_NOT_FOUND before any ABI logic ran. Fixed by
adding 'scripts' to the files array. New repo-level test
(scripts/check-packing.test.mjs) verifies the script IS in the
tarball; prevents silent drift.

r2 HIGH: catch block classified every native-addon-load failure as
ABI mismatch. A MODULE_NOT_FOUND error got the "native binary does
not match" headline + wrong nvm/rebuild fix. Fixed by branching on
NODE_MODULE_VERSION in the error message: ABI mismatch gets the
specific message; other failures get a generic "native addon failed
to load" with honest detail + appropriate fix commands.

Files changed:
- packages/cli/package.json (files field + already had postinstall)
- packages/cli/scripts/check-abi.mjs (error classification branch)
- packages/cli/test/check-abi.test.ts (+2 tests: MODULE_NOT_FOUND
  gets generic message, EACCES gets generic message; total 10/10)
- scripts/check-packing.test.mjs (tarball-content regression)

Gates: check-abi 10/10; check-packing 1/1; CLI 446/447
(1 preexisting chatroom); daemon 1792/1792; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 17:13:13 -07:00
mvschwarzandClaude Opus 4.6 638c541151 feat(cli): postinstall ABI sanity check for better-sqlite3
npm install @openrig/cli now runs a postinstall check that fails
loudly if the Node runtime can't load better-sqlite3's native
binary. Layered on top of the engines.node constraint from c377fa9:
engines warns at npm level; this hard-blocks at install time with
a copy-paste fix command.

Two-phase check (Option C hybrid):
1. Version-range fast path: rejects odd-numbered Node majors
   (21/23/25) and versions below 20 before attempting any native
   load. Short-circuits with clear error + nvm install fix.
2. Native addon load: on supported versions, requires
   better-sqlite3 and catches ABI mismatch (wrong
   NODE_MODULE_VERSION). Reports npm rebuild fix command.

Error messages follow honest-errors principle: what failed + why
it matters + copy-paste fix (nvm install 22 or npm rebuild).

Files:
- packages/cli/scripts/check-abi.mjs (postinstall script)
- packages/cli/package.json scripts.postinstall wiring
- packages/cli/test/check-abi.test.ts (8 unit tests covering
  supported versions, odd versions, old versions, ABI mismatch,
  and fast-path short-circuit verification)

Gates: check-abi 8/8; CLI 444/445 (1 preexisting chatroom fail);
daemon 1792/1792; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 16:02:43 -07:00
mvschwarzandClaude Opus 4.6 c377fa935c fix: add Node LTS-only engine constraints to block odd-version installs
Installing @openrig/cli under Node 25 (homebrew default) silently
succeeds at npm but the daemon fails at runtime because
better-sqlite3 native prebuilds only ship for even-numbered LTS
lines (20, 22, 24). No engines field existed in packages/cli or
packages/daemon package.json to warn on install; README said
"Node.js 20+" with no upper bound.

Added engines.node "^20 || ^22 || ^24" to:
- packages/cli/package.json (published to npm; npm warns on
  mismatch when engine-strict or --engine-strict is set)
- packages/daemon/package.json (private; consistency)
- package.json (monorepo root; local dev)

Updated README Requirements to name supported LTS versions
explicitly and explain why odd releases are unsupported.

Added scripts/check-engines.test.mjs (runs under npm run
test:repo) to verify all three package.json files carry the
LTS-only constraint, preventing silent drift back to ">=20".

Postinstall ABI check deferred to separate scope (would require
VM gate for install-time validation under multiple Node versions).

Gates: check-engines 3/3; daemon 1792/1792; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 12:06:01 -07:00
mvschwarzandClaude Opus 4.6 0cbeca0c6d fix(session-transport): restrict status-bar idle check to last non-blank line
The IDLE_STATUS_BAR_PATTERNS check (Codex model footer / Claude
edit-accept bar) was applied across the last 3 non-blank lines,
same as the prompt-pattern check. During short Codex work bursts,
a stale status bar from a prior idle state could remain in the
last 3 non-blank lines while the agent was actively working,
causing looksLikeMidWork() to false-negative (classify active work
as idle and let rig send through).

Split the two discriminators:
- IDLE_PROMPT_PATTERNS: still checked across last 3 non-blank
  (prompt line may sit above the status bar in the visual stack)
- IDLE_STATUS_BAR_PATTERNS: now checked on the LAST non-blank line
  ONLY — status bars render at the visual bottom; if anything else
  appears below them in the non-blank sequence, active work has
  been rendered over the stale bar

Existing stale-scrollback tests pass (idle status bar IS the last
non-blank line in genuine idle states). Short-burst false-negative
now correctly blocked (stale status bar is NOT the last non-blank
line when active work footer appears below).

Tests: 33/33 session-transport (+1 short-burst regression fixture);
full daemon 1792/1792; tsc + build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-22 10:17:58 -07:00
mvschwarzandClaude Opus 4.6 18bbd7f08f test(session-transport): realistic pane fixtures with terminal geometry
Test-infrastructure improvement: add buildPaneFixture() helper and
5 realistic full-screen pane fixtures that include scrollback,
status bars, separator lines, and trailing blank padding matching
real tmux capturePaneContent output.

Tests verify 14a9170's non-blank-window approach handles real render
shapes:
- Codex active-working: scrollback + working footer + padding →
  blocks (mid_work)
- Codex idle-at-prompt: stale Working in scrollback + status bar
  + padding → allows (stale-scrollback correctly classified)
- Claude Code active-working: tool output + working footer +
  padding → blocks
- Claude Code idle-at-prompt: stale Working in scrollback +
  edit-accept bar + padding → allows
- Codex trust-prompt full-screen: instructions + numbered choices
  + hint + heavy padding → blocks

Residual noted in test comment: if a stale Codex status bar
("gpt-5.4 ... Context [...]") from a prior idle state remains in
the last 3 non-blank lines during active work (very short work
burst), the idle discriminator false-negatives. In realistic
renders the distance is larger; fixture models that. The edge case
is a genuine gap worth a follow-up production-code refinement.

No production code changes. Test-only: +5 tests, +1 helper.
session-transport 32/32; full daemon 1791/1791; tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-21 21:30:24 -07:00
mvschwarz 14a91700ca fix(session-transport): detect padded trust prompts 2026-04-21 18:50:20 -07:00
mvschwarzandClaude Opus 4.6 55385068f1 fix(session-transport): block send through trust/consent prompt choices
Codex and Claude Code trust-prompt choice lines like '› 1. Yes,
continue' passed through looksLikeMidWork() because no existing
MID_WORK_PATTERNS matched numbered-choice text. Sending through
a trust prompt bypasses user consent.

Add /^[❯›]\s*\d+\.\s/m to MID_WORK_PATTERNS — matches prompt-char
+ digit + period + space (numbered choice lines from either
runtime). Interacts correctly with the idle-prompt discriminator
from 69479d9: if the trust prompt is stale scrollback with an idle
prompt below, the discriminator overrides and send proceeds; if the
trust prompt is current (no idle prompt below), the guard blocks.

Tests added (2): Codex trust-prompt block, Claude Code trust-prompt
block. Existing stale-scrollback + idle-prompt + genuine-mid-work
tests preserved (26/26 session-transport). Full daemon: 1785/1785.
tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-21 18:14:48 -07:00
mvschwarzandClaude Opus 4.6 69479d9a39 fix(session-transport): tighten idle-prompt discriminator per r2 adversarial review
Cycle-2 revision of 85d0844. r2 found the idle-prompt override
regex /^[❯›]\s/ was too broad — it matched any line starting with
the prompt char, including active input like '❯ Working on a task.'
where "Working" should trigger the mid-work guard.

Tightened to two complementary discriminators:
- IDLE_PROMPT_PATTERNS: /^[❯›]\s*$/ (empty prompt only — prompt
  char + optional whitespace + end-of-line)
- IDLE_STATUS_BAR_PATTERNS: Codex model footer (gpt-N... Context)
  and Claude edit-accept bar — these only render at idle prompt,
  never during active tool execution

r2 fixture A (active Claude input '❯ Working on a task.') now
correctly blocks. r2 fixture B (trust-prompt '› 1. Yes, continue')
is documented as a pre-existing gap: no MID_WORK_PATTERNS match
that content, so looksLikeMidWork was never triggered pre- or
post-fix. Trust-prompt detection would require scope expansion
per PM constraint #4; filed as separate concern. Original
stale-scrollback fixes preserved (both Codex and Claude idle
tests still pass via the status-bar discriminator).

Tests: 25/25 session-transport (5 new: 2 stale-scrollback override,
1 genuine mid-work preserved, 1 r2 fixture A active-input blocks,
1 r2 fixture B trust-prompt documented). Full daemon: 1784/1784.
tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-21 17:52:23 -07:00
mvschwarzandClaude Opus 4.6 85d0844303 fix(session-transport): skip mid-work block when idle prompt follows stale scrollback
looksLikeMidWork() checked the last 5 pane lines for patterns like
"Working" and "esc to interrupt" but did not account for the pane
having settled to an idle prompt since the working text appeared.
Stale scrollback from a completed task would trip the guard and
hard-block rig send to idle-at-prompt agents.

Add IDLE_PROMPT_PATTERNS that recognise Claude Code (❯) and Codex
(›) prompt characters. When mid-work patterns match, a second pass
checks the last 3 non-blank lines for prompt indicators. If a
prompt is found, the match is classified as stale scrollback and
the guard returns false — send proceeds without --force.

Genuine mid-work (working footer with no idle prompt below it) is
still blocked. The guard is not removed or weakened for real
interruption protection.

Tests added (3):
- Codex pane with "Working" + "esc to interrupt" above idle "›"
  prompt → send proceeds (was false-positive)
- Claude Code pane with "Working…" above idle "❯" prompt → send
  proceeds (was false-positive)
- Working footer present with NO prompt below → send still blocks
  (regression: real protection preserved)

Gates: session-transport.test.ts 23/23; full daemon suite 1782/1782
(128 files); tsc --noEmit clean; npm run build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-21 17:22:01 -07:00
mvschwarz a4071441c3 fix: remove ellipsis-only mid-task heuristic 2026-04-20 13:28:11 -07:00
Mike Schwarz 31b9d63307 Merge pull request #11 from mvschwarz/docs/release-notes-archive
docs: add release notes archive
2026-04-19 13:12:08 -07:00
mvschwarz beb3cc453e docs: add release notes archive 2026-04-19 13:09:36 -07:00
mvschwarz a41d1091be Release 0.1.12 2026-04-19 10:50:39 -07:00
mvschwarz ca5f36d5b7 test: allow canonical reference docs in guard 2026-04-19 10:50:36 -07:00
Mike Schwarz 74cf893c00 fix: clean transcript redraw and status noise (#10)
* fix: clean transcript redraw and status noise

* fix: avoid transcript cleanup false positives
2026-04-19 10:45:17 -07:00
mvschwarz 37e2d5fda7 daemon: normalize user-clearable startup blockers 2026-04-19 10:39:23 -07:00
mvschwarz 0d7e6eca65 daemon: normalize stale Claude startup state 2026-04-19 01:31:16 -07:00
mvschwarz 233ec8128c demo: switch proof scripts to rig commands 2026-04-19 01:25:56 -07:00
mvschwarz 43a0ace5d0 daemon: reject pod-aware node relaunch via legacy route 2026-04-19 01:22:50 -07:00
mvschwarz 848b0327be cli: extend import instantiate timeout budget 2026-04-19 01:18:54 -07:00
mvschwarz 2500ee028e cleanup stray daemon test database artifact 2026-04-19 01:10:32 -07:00
mvschwarz 105ed7e34d fix Claude MCP startup blockers for managed sessions 2026-04-19 01:10:20 -07:00
mvschwarz c44507fd11 harden restore continuity and recovery guidance 2026-04-19 00:23:45 -07:00
mvschwarz 30cf604d4c daemon: accept codex footer with trailing blank lines 2026-04-18 14:42:44 -07:00
mvschwarz e4619cf13a daemon: recognize resumed codex tui after header scrolls 2026-04-18 14:42:44 -07:00
mvschwarz a8adfe257a daemon: capture codex resume ids from wrapped processes 2026-04-18 14:42:44 -07:00
mvschwarz 8ca8e43753 daemon: ignore stale codex update banner once tui is live 2026-04-18 14:42:44 -07:00
mvschwarz 8b074614dc cli: detect unhealthy tmux control sockets 2026-04-18 13:57:38 -07:00
mvschwarz d10d277ebf Release 0.1.11 2026-04-14 12:40:58 -07:00
mvschwarz 9632c2b9bf fix(cli): use real cmux settings for setup and doctor 2026-04-14 12:40:13 -07:00
mvschwarz b8758ed0ee Fix cmux onboarding detection and setup 2026-04-14 11:21:45 -07:00
mvschwarz fd22057318 Release 0.1.10 2026-04-13 22:53:26 -07:00
mvschwarz 138618637c Document launch walkthrough with annotated screenshots 2026-04-13 22:06:40 -07:00
mvschwarz 4eda5c5ef4 Harden launch onboarding and first-run setup 2026-04-13 16:44:29 -07:00
mvschwarz d8881d867e fix(cli): recover daemon state when state file is missing 2026-04-13 14:26:25 -07:00
mvschwarzandClaude Opus 4.6 697f8e60f8 fix(runtime-adapters): propagate rig-role skip into result contracts
Cycle-1 (3023de8) prevented the CLAUDE.md / AGENTS.md write but
left the adapter reporting the entry as projected (via
ProjectionResult.projected) and the file as delivered (via
StartupDeliveryResult.delivered++). r2 caught: adapter contract
lies — skipped work shows up as completed, violating the
honest-detection constraint the cycle-1 fix was meant to restore.

mergeGuidance now returns boolean (true=merged, false=rig-role skip).
projectEntry propagates the return. project()'s loop routes the
entry into the `skipped` array when mergeGuidance reports skip.
deliverStartup's guidance_merge case `continue`s past the
`delivered++` on skip so counts reflect actual writes.

Same single-chokepoint shape as cycle-1; no new special cases, still
narrow on blockId === 'rig-role'. Architect's three binding
constraints: (1) adapter-layer only (both adapters + tests); (2)
honest detection — this IS the restoration (the point of the patch);
(3) no broader-allow special case — still only rig-role.

Tests updated + added (6 cycle-1 tests + 2 new cycle-2 regression):
- projectEntry rig-role asserts `result.skipped` contains 'rig-role'
  AND `result.projected` does not (each adapter)
- projectEntry non-rig-role asserts `result.projected` contains
  block id AND `result.skipped` does not (each adapter — NEW
  regression-on-contract)
- deliverStartup rig-role asserts `result.delivered === 0` AND
  `result.failed === []` (each adapter — was ===1 in cycle-1)

Gates: vitest 40/41 in claude+codex adapter suites (1 preexisting
unrelated Codex thread-id env fail still present via stash+retest);
tsc --noEmit clean in packages/daemon. New commit on top of 3023de8
per cycle discipline, not amended.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 12:56:23 -07:00
mvschwarzandClaude Opus 4.6 3023de8112 fix(runtime-adapters): skip rig-role managed-block injection
The rig-role managed-block regenerator pairs (target-file x spec)
without seat correlation, so multi-seat pods collide into one
CLAUDE.md/AGENTS.md body. openrig-pm + corpus-pm were behaviorally
affected; openrig-build saw the on-disk symptom only (startup.files
insulated runtime).

SHAPE 1 surgical fix per plat-architect: in claude-code-adapter and
codex-runtime-adapter, refuse mergeManagedBlock when blockId is
`rig-role`. Per-seat role content travels through the send_text
startup path instead, which preserves seat identity. Skip is logged
(structured, never silent) so the collision can't land quietly.

Adapter-layer only (both adapters + their tests). No domain, route,
or UI changes. Narrow check on the single blockId; no broader
special-case.

Tests added (6):
- projectEntry + category=guidance + effectiveId=rig-role does not
  write CLAUDE.md / AGENTS.md (each adapter)
- projectEntry with non-rig-role guidance still merges (regression,
  each adapter)
- deliverStartup guidance_merge for path=rig-role does not write
  (each adapter)

Gates: vitest 38/39 in claude+codex adapter suites (1 preexisting
unrelated Codex thread-id env fail confirmed via stash); tsc
--noEmit clean in packages/daemon.

Shape 2 (config sweep of agent.yaml rig-role references) is a
separate follow-up PR per architect scoping.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 12:43:33 -07:00
mvschwarz bc2c899b45 fix(cmux-transport): reject error text and parse panel rows safely 2026-04-13 11:06:03 -07:00
mvschwarzandClaude Opus 4.6 e74ebbc91b fix(cmux-transport): narrow regex capture for strict TS
parseCmuxCommands fed match[1] (typed string | undefined under
noUncheckedIndexedAccess) into Set.add(string), tripping TS2345.
Guard the capture before adding.

Tests unchanged: cmux-transport 24/24, cmux-adapter 33/33.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 10:30:53 -07:00
mvschwarzandClaude Opus 4.6 e4a03ae48b fix(cmux-transport): track cmux 0.63.x surface with legacy fallback
cmux 0.63.x removed list-surfaces and agent-pids and added
list-panels / list-panes / list-pane-surfaces. The adapter hard-coded
the removed commands, so every surface.list request failed and
queryAgentPIDs misreported state.

Probe `cmux --help` at factory time; dispatch surface.list to
list-panels (modern) with a list-surfaces fallback; normalize the
panels payload to {surfaces} so CmuxAdapter.listSurfaces is unchanged.
workspace.agentPIDs uses legacy agent-pids when present, otherwise
throws CmuxSurfaceError{code:'unavailable', method}, preserving the
existing 'unavailable' vocabulary without a route-layer change.

Adapter-only scope: cmux-transport.ts + its test. No domain/route/UI
changes. cmux-transport.test.ts 24/24 green; cmux-adapter.test.ts
33/33 unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 09:17:14 -07:00
mvschwarzandClaude Opus 4.6 b9e64c004a docs: cli-reference count + setup row (DRIFT-B)
Reflects the 41 program.addCommand() registrations in
packages/cli/src/index.ts:98-138, including setup (setup.ts:383).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-13 07:27:00 -07:00
da581a9471 feat: PM toolkit — 7 skills, PM agent, and pm-team rig (#4)
* feat: add PM toolkit — 7 skills, PM agent, and pm-team rig

Adds a complete product management workflow as shared skills:

Skills (specs/agents/shared/skills/pm/):
- office-hours: YC-style validation with 6 forcing questions → validation.md
- context-builder: Three-layer context gathering → background.md
- requirements-writer: Conversational intake → requirements.md (GIVEN/WHEN/THEN)
- ui-mockup: ASCII wireframes + HTML mockups + live prototypes
- plan-review: Strategy + design + engineering review before dev
- exec-summary: 1-2 page summary orienting sales, leadership, and engineering
- backlog-capture: Rapid idea capture with deduplication

Agent (specs/agents/product-management/pm/):
- PM agent with startup guidance and 8-step feature flow

Rig (specs/rigs/focused/pm-team/):
- PM lead + researcher + coder topology with delegation edges

The skills chain together: each step's output (validation.md → background.md →
requirements.md → mockups → executive-summary.md) feeds the next step
automatically. Downstream skills read upstream outputs by convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Fix PM toolkit spec validation

---------

Co-authored-by: Matti Kakkori <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Mike Schwarz <[email protected]>
2026-04-12 15:30:17 -07:00
mvschwarz c10b8f8f81 docs: fix rig bundle reference launch semantics 2026-04-11 21:52:34 -07:00
Mike SchwarzandClaude Opus 4.6 81262deb52 Add canonical RigBundle reference documentation
Forensically traced from bundle-types.ts, bundle-archive.ts, and
pod-bundle-assembler.ts. Covers:

- Archive format (.tar.gz with .sha256 sibling digest)
- Manifest schema v2 (pod-aware) with full field reference
- Security model (self-consistency, not authenticity)
- Safety guarantees (no symlinks, no traversal, digest + integrity)
- CLI surface (create, inspect, install, rig up)
- Assembly process (how agent refs are resolved, vendored, rewritten)
- Validation rules for manifest, archive safety, and assembly
- Legacy v1 compatibility note

Validated against a real bundle (specright-pm.rigbundle): manifest
fields, agent entries, integrity section all match documented schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 21:32:25 -07:00
Mike SchwarzandClaude Opus 4.6 fccb65a211 Add optional docs field to RigSpec (#8)
* Add optional docs field to RigSpec for bundled documentation

Add a top-level `docs` array field to the pod-aware RigSpec schema.
Each entry has a `path` field (safe relative path, same validation as
culture_file and startup file paths).

The engine does not consume these files for instantiation — they are
metadata for humans and agents setting up the environment before launch.
The bundle packer includes listed docs in the archive so setup
instructions travel with the distributable artifact.

Schema changes:
- types.ts: add RigSpecDoc interface and docs field to RigSpec
- rigspec-schema.ts: validate docs array with path safety checks
- rigspec-schema.ts: normalize docs into typed array
- rigspec-codec.ts: serialize docs field

Bundle changes:
- pod-bundle-assembler.ts: collect docs files into the bundle archive

Tests:
- rigspec-schema-docs.test.ts: 7 tests covering validation, rejection,
  normalization, and path safety

Reference doc:
- docs/reference/rig-spec.md: document the docs field

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Fix review findings: null guard, missing-docs failure, bundle tests

Addresses all findings from R2 and orch1-peer independent reviews:

1. Guard against null/non-object entries in docs validation — docs: [null]
   and docs: [42] now return clean validation errors instead of crashing
   the validator (R2 #1, peer #1)

2. Bundle assembly fails loudly when a declared docs file is missing from
   disk — prevents silently shipping bundles that advertise docs they
   don't contain (R2 #2, peer #2)

3. Add bundle-level tests proving docs are collected and missing docs
   fail assembly (R2 #3)

Tests: 5 new tests (3 schema null/primitive/string + 2 bundle positive/negative)
Full suite: 128 files, 1722/1722 passing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Provision runtime config and disclose setup mutations

* Fix: force acceptEdits for managed sessions, don't inherit restrictive modes

deliverStartup unconditionally sets permissions.defaultMode = "acceptEdits"
for managed Claude sessions instead of preserving existing restrictive modes
via nullish coalesce. A pre-existing denyAll or other restrictive mode in
.claude/settings.local.json would previously survive provisioning, causing
managed rigs to hit prompt/block behavior in reused workspaces.

Found by: dev1-qa independent repro
Fix: change ?? "acceptEdits" to unconditional "acceptEdits" assignment
Test: regression test seeds denyAll, verifies deliverStartup overrides it

Full suite: 128 files, 1723/1723 passing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Fix JSON normalization, disclosure copy, and README accuracy

Addresses CodeRabbit findings verified by QA:

1. readJsonObject now excludes arrays (typeof "object" includes arrays
   in JS). Prevents silent property loss when settings.local.json or
   .mcp.json contains a bare JSON array instead of an object.

2. provisionPermissionsAndMcps refactored to use readJsonObject,
   readJsonObjectField, and readStringArray consistently instead of
   raw JSON.parse + manual type checks. Same helpers used elsewhere
   in the adapter.

3. Setup disclosure for .claude/settings.local.json now mentions
   statusLine mutation alongside permissions (CodeRabbit item 3).

4. README clarifies that managed runtime boot performs config writes
   while rig setup only discloses the paths (CodeRabbit item 6).

5. Regression test: array-valued settings.local.json and .mcp.json
   are recovered to proper objects during provisioning.

Full suite: 128 files, 1724/1724 passing

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* Harden setup disclosure tests against copy churn

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 18:42:35 -07:00
Mike Schwarz ae4ebb53a7 Release 0.1.9 2026-04-11 15:16:34 -07:00
Mike Schwarz 93a36c5f57 Preserve cmux context and harden cmux health 2026-04-11 15:10:00 -07:00
Mike Schwarz 0aefd99a0e Sanitize daemon launcher env for cmux 2026-04-11 14:35:58 -07:00
Mike SchwarzandClaude Opus 4.6 db8c54208a Add rig-architect skill as a packaged builtin
- Add rig-architect SKILL.md to shared skills (specs/agents/shared/skills/rig-architect/)
- Register in shared agent.yaml so it's available via the normal skill system
- Skill teaches: design process (intent to topology), authoring workflow,
  agent startup content strategy, diagnosis protocol, pattern catalog
- References ~/.openrig/reference/ docs for canonical spec format details
- Knowledge-transfer style skill, not workflow-compliance

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 14:06:40 -07:00
Mike Schwarz d4088d042e Prepare demo setup runtimes and Claude onboarding 2026-04-11 14:06:24 -07:00
Mike SchwarzandClaude Opus 4.6 48f3c9dd25 Move reference docs to docs/reference/ as canonical source of truth
- Move reference docs from packages/daemon/docs/reference/ to
  docs/reference/ at the repo root — this is the authoring location
- Update .gitignore to track docs/reference/ alongside docs/as-built/
- Update build-package.sh to copy from docs/reference/ into the
  assembled CLI package (not from inside the daemon package)
- Daemon startup still copies the bundled docs to ~/.openrig/reference/
  on start for stable agent-accessible paths

Flow: docs/reference/ (edit here) → build → cli/daemon/docs/reference/
(npm package) → daemon start → ~/.openrig/reference/ (agents read here)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 13:55:43 -07:00
Mike SchwarzandClaude Opus 4.6 b60725bccd Add canonical reference docs and ship them to ~/.openrig/reference/
- Add docs/reference/ inside daemon package: rig-spec.md, agent-spec.md,
  agent-startup-guide.md, edge-types.md — all forensically traced from
  actual parser/validator code, examples validated against rig spec validate
  and rig agent validate
- Daemon startup copies bundled reference docs to ~/.openrig/reference/
  on every start, keeping them in sync with the installed version
- Build script includes daemon/docs/ in the assembled CLI package
- Agents can read reference docs at a stable path (~/.openrig/reference/)
  without needing CLI discovery commands

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 13:49:08 -07:00
Mike SchwarzandClaude Opus 4.6 36f6cc199b docs: update launch onboarding for rig setup
Quick Start leads with rig setup -> rig up demo -> rig ui open.
Post-demo guidance points to orch1.lead and Open CMUX with tmux
attach fallback. Setup vs doctor distinction added with honest
best-effort phase-1 framing. Agent-driven profile-choice guidance
tells agents to ask users about core vs full before invoking.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:35:56 -07:00
Mike SchwarzandClaude Opus 4.6 357d490ef3 feat: add doctor-backed verification to rig setup
runSetup now resolves runDoctorChecks() including async checks and
embeds verification.checks in the result using doctor check names
and statuses as-is. ready is derived from both setup step failures
and verification fail statuses — warn/skipped alone do not flip
ready to false. Command handler builds default doctorDeps for
non-dry-run execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:30:58 -07:00
Mike SchwarzandClaude Opus 4.6 e71ac524dc feat: add rig setup with phase-1 profile model
Non-interactive rig setup command with core and full profiles. Core
profile: brew check, tmux/cmux install, tmux config managed block,
verification. Full profile extends with jq and gh. --dry-run shows
plan without mutations and exits 0. --json returns structured result
with step ids and statuses. Failure in one step does not crash setup;
later steps are attempted or skipped honestly.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:23:07 -07:00
Mike SchwarzandClaude Opus 4.6 0dc01f188e feat: distinguish shell and daemon cmux health in rig doctor
Rename shell cmux check to cmux_shell. Add async cmux_daemon check
that probes the running daemon's /api/adapters/cmux/status. When shell
cmux works but daemon cannot control it, report warn with mismatch
guidance. Daemon not reachable uses honest 'skipped' status. Shell
cmux missing omits cmux_daemon entirely. DoctorCheck status union
extended with 'skipped'.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 11:10:45 -07:00
Mike SchwarzandClaude Opus 4.6 9e9b3e020b fix: sanitize daemon launch env for packaged cmux
Add buildDaemonEnv() that scrubs terminal/GUI/session variables
(GHOSTTY_*, XPC_*, __CF*, TERM_PROGRAM, TERMINFO, CMUX_SOCKET_PATH,
CMUX_WORKSPACE, CMUX_SURFACE_ID) from the daemon process environment.
Preserves HOME, PATH, USER, SHELL, and all non-scrubbed vars. Explicit
OPENRIG_* launch values always override inherited values. startDaemon()
now uses buildDaemonEnv() instead of raw process.env spread.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-11 10:56:30 -07:00
Mike Schwarz 68eb886041 Fix packaged CLI runtime and demo launch 2026-04-10 08:33:31 -07:00
Mike SchwarzandClaude Opus 4.6 6e0d9f8943 Bump version to 0.1.7
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 02:27:14 -07:00
Mike Schwarz 099edb33e1 Add rig destroy recovery command (#1)
* Add rig destroy recovery command

* Remove ignored plan doc from destroy PR

* Harden rig destroy safety and honesty
2026-04-10 02:23:56 -07:00
Mike Schwarz 39ab0f5508 Guard against tracked docs outside as-built (#3) 2026-04-10 02:13:52 -07:00
Mike Schwarz 9199ed7954 Warn when tmux mouse mode is disabled (#2)
* Warn when tmux mouse mode is disabled

* Improve tmux mouse doctor guidance
2026-04-10 02:12:29 -07:00
mvschwarzandClaude Opus 4.6 b9330f635b Rewrite README for launch
Lead with value prop, screenshot, and quick start instead of sysadmin
content. Add demo rig preview, architecture overview, key concepts,
agent-managed software example, and Managed Agents comparison link.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-10 00:13:46 -07:00
Mike Schwarz 4fcd9764eb Add cmux recovery and doctor guidance 2026-04-09 23:37:59 -07:00
Mike SchwarzandClaude Opus 4.6 9abb516e6a fix: make cmux system status honest with live probe
GET /api/adapters/cmux/status now calls connect() on every request
to perform a live capabilities probe instead of returning stale
startup-cached state. System panel label updated to 'cmux control'
with honest description reflecting current OpenRig control health.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 22:15:33 -07:00
Mike SchwarzandClaude Opus 4.6 428529d883 feat: expose cmux action for every topology node
Remove cmuxSurface gate on topology toolbar CMUX button — now renders
for all nodes with rigId. Retarget from /focus to /open-cmux. Both
unbound and bound nodes use the open-or-focus route. Flash feedback
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 22:04:51 -07:00
Mike SchwarzandClaude Opus 4.6 3f7e7e1ce5 feat: wire node detail Open CMUX action to open-or-focus route
Rename node detail cmux button to Open CMUX, retarget from /focus
to /open-cmux route. Button renders for all nodes including unbound.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 21:57:17 -07:00
Mike SchwarzandClaude Opus 4.6 1663286d33 feat: add cmux open-or-focus node route
Add NodeCmuxService with openOrFocusNodeSurface() and POST /open-cmux
route. Three branches: focus existing bound surface, create new terminal
for tmux-backed nodes (attaches into tmux), create honest helper console
for external-cli nodes. All side effects checked honestly — sendText and
focusSurface failures return ok:false. Binding persisted on creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 21:45:34 -07:00
Mike SchwarzandClaude Opus 4.6 496aef5a28 feat: modernize cmux adapter and transport
Update cmux transport to modern CLI shape: focus-panel, send --surface,
current-workspace, new-surface. Adapter normalizes real cmux response
payloads: workspace_id for current workspace, ref-first fallback chain
(created_surface_ref -> created_surface_id -> surface_ref -> surface_id)
for surface creation. Version logic stays in transport layer only.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 21:12:48 -07:00
Mike SchwarzandClaude Opus 4.6 7a29f192d0 Review fix 5: narrow runtime env-health contract honestly
Update services-readiness.ts comments to list shipped consumers
(boot-time gate, on-demand receipt capture) and mark background
monitor and rig ps env summary as deferred. Update architecture.md
with explicit runtime truth model. Update cli-reference.md to
reflect wired --volumes and note rig ps env gap.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 20:13:41 -07:00
Mike SchwarzandClaude Opus 4.6 94ed046c72 Review fix 4: resolve relative local: agent refs for drilldown
Fix resolveMemberAgent in LibraryReview to resolve relative local:
refs against the rig source path instead of only matching the narrow
local:agents/<name> pattern. Handles both simple and deeply nested
relative refs like local:../../../agents/apps/vault-specialist.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:54:15 -07:00
Mike SchwarzandClaude Opus 4.6 d6521cb89d Review fix 3: wire rig env down --volumes end-to-end
Add policyOverride option to ServiceOrchestrator.teardown() so callers
can override the persisted down policy. Route threads volumes=true as
policyOverride=down_and_volumes. Existing callers with no opts still
use persisted policy. Dead comment block removed from route.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:46:54 -07:00
Mike SchwarzandClaude Opus 4.6 8c1479faf7 Review fix 2: make env route honest about probe refresh failure
Track probe outcome as probeStatus (fresh/stale/no_orchestrator) in
GET /api/rigs/:rigId/env response. Failed probes and null results
return stale with probeError instead of silently serving cached
receipts as fresh truth. No probeStatus when hasServices is false.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:39:53 -07:00
Mike SchwarzandClaude Opus 4.6 ef2b03272a Review fix 1: reject service-backed .rigbundle launch honestly
Fail early in the pod-bundle bootstrap path when the rig spec contains
a services block. Service-backed rigs require a stable source directory
and cannot be launched from .rigbundle archives. The rejection stays
inside the try/finally cleanup block so extracted temp dirs are always
cleaned up. Error uses resolve_spec stage with services_unsupported code.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 19:34:50 -07:00
Mike Schwarz 2f4722cd03 docs: refresh as-built architecture and cli reference 2026-04-09 18:54:03 -07:00
Mike Schwarz 8c10e9632e Teach CLI and openrig-user about managed apps 2026-04-09 18:18:13 -07:00
Mike SchwarzandClaude Opus 4.6 971da63cc9 Update lockfile for 0.1.6
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 17:54:37 -07:00
Mike SchwarzandClaude Opus 4.6 778aa94ae9 Bump version to 0.1.6
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 17:48:40 -07:00
Mike Schwarz fa4cd5034c update gitignore 2026-04-09 17:38:25 -07:00
Mike Schwarz 1cefe8e07b Fix vault specialist dogfood regressions 2026-04-09 17:18:53 -07:00
Mike SchwarzandClaude Opus 4.6 e653db411e Add Apache 2.0 license
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 17:08:40 -07:00
Mike SchwarzandClaude Opus 4.6 d6981151ef Vault specialist: add specialist visibility in library review
Add Specialist Agent summary card to LibraryReview for service-backed
rigs, showing canonical identity (vault.specialist). Update test mocks
to canonical topology and summary mentioning specialist. Non-service
rigs explicitly tested to show no specialist card.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:56:36 -07:00
Mike SchwarzandClaude Opus 4.6 672d724dff Vault specialist: swap secrets-manager to vault.specialist topology
Update secrets-manager rig to use canonical managed app convention:
pod id vault, member id specialist, agent_ref pointing to the new
vault-specialist AgentSpec. Summary explicitly mentions the Vault
specialist agent.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:51:50 -07:00
Mike SchwarzandClaude Opus 4.6 1ba4540451 Vault specialist: add agent spec with guidance, startup, and skill
New built-in vault-specialist AgentSpec at agents/apps/vault-specialist/
with Vault-specific role guidance, startup context (dev mode, proof
token, access paths), and vault-user skill (health, API auth, secret
CRUD). Default profile loads shared skills plus vault-user.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 16:48:36 -07:00
Mike Schwarz 72b1dc7069 Fix managed app env tab dogfood regressions 2026-04-09 15:11:33 -07:00
Mike SchwarzandClaude Opus 4.6 a6c1487ffa Phase A Step 6: runtime env panel with useRigEnv hook
Extend env route to return surfaces from specJson. Add useRigEnv hook
with 10s polling. Add RigEnvPanel component showing overall env state
(Healthy/Degraded/Stopped/Unknown), per-service health, surface links,
View Logs, and Stop Env. Add Env tab to RigDetailPanel between Info
and Chat, shown only for service-backed rigs.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:43:26 -07:00
Mike SchwarzandClaude Opus 4.6 d16fadcbb0 Phase A Step 5: env-aware library review with setup prompt
Add environment tab to RigSpecDisplay gated by showEnvironmentTab prop,
only passed from LibraryReview. Tab shows compose services, health gates,
and surfaces. Add Copy Setup Prompt action for service-backed rigs with
buildSetupPrompt helper. Draft/open-file review explicitly excluded via
regression test.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:30:39 -07:00
Mike SchwarzandClaude Opus 4.6 4ab7ee68a3 Phase A Step 4: upgrade SpecsPanel with filter chips and richer rows
Add unified library section with All/Apps/Rigs/Agents filter chips.
Use single useSpecLibrary() all-entries query for the merged list.
Richer rows show type badge (APP/RIG/AGENT), stability badge
(Stable/Experimental/Community), and summary. Drafts remain in
their original Rig Specs / Agent Specs sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:21:18 -07:00
Mike SchwarzandClaude Opus 4.6 464b810fec Phase A Step 3: extend rig-spec review data with services details
Add RigSpecServicesReview to review output for service-backed rigs.
Domain extracts kind, composeFile, projectName, downPolicy, waitFor,
and surfaces from raw YAML (filesystem-free). Route layer enriches
with composePreview by reading the compose file (best-effort). Wait
target types match the canonical RigServicesWaitTarget contract.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 14:08:46 -07:00
Mike SchwarzandClaude Opus 4.6 fa867b5ae6 Phase A Step 2: extend library entry metadata with hasServices
Add hasServices?: boolean to SpecLibraryEntry in both daemon and UI.
In classifySpec(), detect services block by parsing raw YAML for rig
entries. Only set when true to keep agent entries clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 13:48:57 -07:00
Mike SchwarzandClaude Opus 4.6 9c16026744 Phase A Step 1: move secrets-manager into indexed built-in library tree
Move secrets-manager spec from specs root into rigs/launch/secrets-manager/
so it is naturally discoverable by the built-in library scanner. Fix
agent_ref relative path for the new depth. Update starter tests to expect
secrets-manager in the indexed rig library scan.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 13:43:30 -07:00
Mike Schwarz 8eef407062 Fix rig env T07 dogfood regressions 2026-04-09 06:24:33 -07:00
Mike SchwarzandClaude Opus 4.6 70998e8ea8 Phase 5 T06: Secrets Manager proof-of-concept spec
Built-in secrets-manager rig spec backed by HashiCorp Vault in dev mode.
Compose file with health check, wait_for HTTP health target, surfaced
URLs (UI + API) and commands. Receipt-only continuity (no checkpoint
hooks in v1). Proves the generic services substrate without special-case
code.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:20:11 -07:00
Mike SchwarzandClaude Opus 4.6 aa4f1659ee Phase 5 T05: env routes, CLI, and startup wiring
Daemon: GET /api/rigs/:rigId/env (status with fresh receipt),
GET /env/logs (service logs), POST /env/down (teardown services).
Routes mounted under existing rig-scoped pattern.

CLI: rig env status/logs/down subcommands with --json support.
No rig env restart (v1 contract).

Startup wires serviceOrchestrator + composeAdapter into AppDeps
and middleware context. 5 CLI tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:18:43 -07:00
Mike SchwarzandClaude Opus 4.6 b17c8857c4 Phase 5 T04: snapshot/restore/teardown continuity for services
Snapshot captures envReceipt from persisted services record. Teardown
calls ServiceOrchestrator.teardown() per down_policy (both early-return
and normal paths). SnapshotData gains optional envReceipt field.
Receipt-only honesty: no checkpoint artifacts unless hooks are defined.
6 tests covering snapshot capture, teardown policies, no-services path.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:11:55 -07:00
Mike SchwarzandClaude Opus 4.6 0f564a2905 Phase 5 T02+T03: Compose adapter, ServiceOrchestrator, bootstrap/restore service gate
T02: ComposeServicesAdapter (adapters/) for docker compose up/down/
status/logs/probe. ServiceOrchestrator (domain/) as leaf service for
boot/teardown/captureReceipt. services-readiness.ts shared evaluator
for wait targets + derived health. 21 tests.

T03: Bootstrap prelaunch hook in PodRigInstantiator.instantiate() runs
service gate between topology creation and node launch. Bootstrap
orchestrator builds hook via canonical pod-aware codec, persists
services record, boots services. Restore orchestrator boots services
before agent restore. service_boot_failed added to InstantiateOutcome
and RestoreOutcome. Startup wires ServiceOrchestrator into both
orchestrators. 4 integration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 05:06:08 -07:00
Mike Schwarz ec47323f5e Add compose services orchestrator 2026-04-09 04:43:11 -07:00
Mike Schwarz 959394a4ca Add rig services schema and persistence 2026-04-09 04:30:08 -07:00
Mike SchwarzandClaude Opus 4.6 76006c2ece Vendor mental-model-ha and wire into orchestrator + implementer
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 04:24:33 -07:00
Mike Schwarz fe0a000d51 Bump version to 0.1.5 2026-04-09 04:09:42 -07:00
Mike Schwarz b56ce63b13 Fix refactor launch timeout and spec scanning 2026-04-09 04:04:05 -07:00
Mike Schwarz 0e4bfaa418 Revert "Standardize canonical session names"
This reverts commit 4b0544a127.
2026-04-09 03:54:48 -07:00
Mike Schwarz 4b0544a127 Standardize canonical session names 2026-04-09 03:31:58 -07:00
Mike Schwarz 7ac72c37d0 Reorganize bundled spec library 2026-04-09 03:31:49 -07:00
Mike Schwarz e6f854a3e6 Bump version to 0.1.4 2026-04-09 03:06:27 -07:00
Mike Schwarz dd0385e310 Expand review team operating manual 2026-04-09 03:05:37 -07:00
Mike SchwarzandClaude Opus 4.6 4771f9adfa Include full skill directories for systematic-debugging and test-driven-development
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 01:55:31 -07:00
Mike SchwarzandClaude Opus 4.6 83df1da3d4 Replace stub skills with real source versions
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 01:54:20 -07:00
Mike SchwarzandClaude Opus 4.6 ebf106100b Vendor skills and wire demo rig for self-contained launch
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-09 01:29:45 -07:00
4302 changed files with 26154 additions and 823928 deletions

No files matched your search

@@ -1,75 +0,0 @@
---
name: developing-openrig
description: "Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
---
# Developing OpenRig
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
code is right, and fixing the map is a welcome contribution.
## First: which OpenRig are you in?
If you are an agent running inside OpenRig, two different OpenRigs are in play:
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
not by restarting the daemon you live in.
## Find your way
| You want to know | Read | How much to trust it |
|---|---|---|
| What OpenRig can already do, before you decide something is missing | `rig context get onboarding-width/public-what-you-can-do.md`; in this checkout, `packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md` (the installed copy can differ) | The capability map; check it before proposing a new command or calling a gap a defect |
| Whether a `rig` command already exists | `rig --help`: the full command list, before you decide a command is missing | The running binary; read the whole list, not only the command you expected |
| Whether a problem is already known | The "Known problems to compare against" section of [`docs/reference/help.md`](../../../docs/reference/help.md) | Lists known issues by symptom; check an issue's current status before relying on it |
| Whether a feature is already planned | [`ROADMAP.md`](../../../ROADMAP.md) | Check it before proposing a feature |
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Each module names the commit it was last checked against in `last-verified-against-source`; compare that marker with `git log` before relying on it |
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
| Which shipped skill covers a task | [`skills/_canonical/core/openrig-skills/SKILL.md`](../../../skills/_canonical/core/openrig-skills/SKILL.md) | The index of the skills that ship with OpenRig |
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
## Before you change an artery
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
projection, process observation, migrations, restore):
1. Read the artery row: what depends on it, and the past regressions listed there.
2. Describe the downstream effect in your pull request, not just the diff.
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
## Test it
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
it won't touch the daemon your session runs on.
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
## How we judge changes
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
- A change that removes friction, or makes state more truthful, is usually welcome.
- OpenRig runs on agents working in context with tools. Before adding code for a behaviour, ask whether an agent with
the right instructions and today's commands could do it. A skill or a page of guidance is often the better change.
- Where to look: the capability map above, and the `agent-operated-workflows` skill, which ships in the
`openrig-core` plugin (`packages/daemon/assets/plugins/openrig-core/skills/agent-operated-workflows/SKILL.md`).
- A change that adds a refusal, a prompt or a required step needs the concrete case in CONTRIBUTING.md: who is
harmed, how, and what it costs everyone else.
- One concern per pull request. Keep the diff reviewable in one sitting.
## When the map is wrong
Fix it in the same pull request, or open an issue. Update a document's `last-verified-against-source` marker only when
you have actually checked it against that commit.
-15
View File
@@ -1,15 +0,0 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
@@ -1,75 +0,0 @@
---
name: developing-openrig
description: "Use when changing OpenRig's own source in a clone of the openrig repository: finding which package or file owns a behaviour, choosing which tests to run, testing a change without disturbing the OpenRig daemon your own session runs on, judging whether a diff touches a high-risk area, or preparing a pull request. Not for operating rigs (openrig-skills) or designing rig topologies."
---
# Developing OpenRig
This skill is a map to the other maps. Each one is incomplete and may be stale: where a map and the code disagree, the
code is right, and fixing the map is a welcome contribution.
## First: which OpenRig are you in?
If you are an agent running inside OpenRig, two different OpenRigs are in play:
- **The installed one runs your session.** `rig --version` and `rig daemon status` describe it.
- **The checkout is what you're changing.** `git rev-parse HEAD` describes it.
Restarting the installed daemon does not run your checkout, and stopping it stops the agents running on it, including
you. Test your change with the repo's own harnesses, which start a private daemon and tmux server (see "Test it" below),
not by restarting the daemon you live in.
## Find your way
| You want to know | Read | How much to trust it |
|---|---|---|
| What OpenRig can already do, before you decide something is missing | `rig context get onboarding-width/public-what-you-can-do.md`; in this checkout, `packages/daemon/context-packs-src/onboarding-width/public-what-you-can-do.md` (the installed copy can differ) | The capability map; check it before proposing a new command or calling a gap a defect |
| Whether a `rig` command already exists | `rig --help`: the full command list, before you decide a command is missing | The running binary; read the whole list, not only the command you expected |
| Whether a problem is already known | The "Known problems to compare against" section of [`docs/reference/help.md`](../../../docs/reference/help.md) | Lists known issues by symptom; check an issue's current status before relying on it |
| Whether a feature is already planned | [`ROADMAP.md`](../../../ROADMAP.md) | Check it before proposing a feature |
| How the packages fit, the request path, where to add a command, route, migration, adapter, skill, context pack or scenario | [`ARCHITECTURE.md`](../../../ARCHITECTURE.md) | Checked against the commit it names; counts come with the command to refresh them |
| Whether your change touches a high-risk area, what depends on it, what broke there before | [`docs/as-built/arteries.md`](../../../docs/as-built/arteries.md) | Incomplete by design: absence from it doesn't make a change safe |
| What to run before you push, and what each layer can and can't prove | [`docs/as-built/test-layers.md`](../../../docs/as-built/test-layers.md) | Checked against the commit it names |
| The exact behaviour of a `rig` command | `rig <command> --help` on the running binary, then `packages/cli/src/commands/` | The binary and the code win over any document |
| A subsystem in depth | [`docs/as-built/`](../../../docs/as-built/README.md) | Each module names the commit it was last checked against in `last-verified-against-source`; compare that marker with `git log` before relying on it |
| Rig spec, agent spec and other user-facing reference | [`docs/reference/`](../../../docs/reference/) | Ships to every user; if you find drift, fix it there |
| Which shipped skill covers a task | [`skills/_canonical/core/openrig-skills/SKILL.md`](../../../skills/_canonical/core/openrig-skills/SKILL.md) | The index of the skills that ship with OpenRig |
| How to open a good pull request, and what review looks like | [`CONTRIBUTING.md`](../../../CONTRIBUTING.md) | Current |
If none of these answers your question, search the code before inventing an answer. A missing map is worth an issue.
## Before you change an artery
If your diff touches anything in `arteries.md` (message delivery, launch and resume, the queue, rig identity, skill
projection, process observation, migrations, restore):
1. Read the artery row: what depends on it, and the past regressions listed there.
2. Describe the downstream effect in your pull request, not just the diff.
3. Add or extend a stub-agent scenario, or say precisely why the stub can't exercise it and what you ran instead.
## Test it
The short version is `npm run build`, `npm run lint`, `npm test`. `test-layers.md` has the full ladder, including the
stub-agent scenarios, which run the real CLI, daemon, tmux and SQLite with scripted agents and no model cost.
- The scenario runner starts its own daemon and tmux server and drops your session's `TMUX` and daemon variables, so
it won't touch the daemon your session runs on.
- A stub proves OpenRig's own plumbing, not how Claude Code or Codex behave. Say which you exercised.
## How we judge changes
OpenRig is a coordination layer for a trusted environment: it should help agents and people keep work flowing.
- A change that removes friction, or makes state more truthful, is usually welcome.
- OpenRig runs on agents working in context with tools. Before adding code for a behaviour, ask whether an agent with
the right instructions and today's commands could do it. A skill or a page of guidance is often the better change.
- Where to look: the capability map above, and the `agent-operated-workflows` skill, which ships in the
`openrig-core` plugin (`packages/daemon/assets/plugins/openrig-core/skills/agent-operated-workflows/SKILL.md`).
- A change that adds a refusal, a prompt or a required step needs the concrete case in CONTRIBUTING.md: who is
harmed, how, and what it costs everyone else.
- One concern per pull request. Keep the diff reviewable in one sitting.
## When the map is wrong
Fix it in the same pull request, or open an issue. Update a document's `last-verified-against-source` marker only when
you have actually checked it against that commit.
-15
View File
@@ -1,15 +0,0 @@
{
"name": "openrig-app",
"description": "The OpenRig plugin: work with your OpenRig team from this app.",
"owner": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"plugins": [
{
"name": "openrig",
"source": "./packages/daemon/assets/plugins/openrig",
"description": "Work with your OpenRig team from your coding agent: hand it work, follow that work, answer its questions and read what it returns."
}
]
}
-1
View File
@@ -1 +0,0 @@
github: mvschwarz
-60
View File
@@ -1,60 +0,0 @@
name: Bug report
description: Something OpenRig does is wrong, or a command fails
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks. Include a small reproduction and the relevant versions.
This report will be public. Remove API keys, tokens, private prompts, personal details and private paths from commands, logs and configuration.
For a suspected vulnerability, follow our [security policy](https://github.com/mvschwarz/openrig/security/policy) instead of posting details here.
- type: input
id: version
attributes:
label: OpenRig version
description: Output of `rig --version`
placeholder: 0.5.14 (2edc1b7a)
validations:
required: true
- type: input
id: platform
attributes:
label: OS, Node and tmux versions
placeholder: macOS 15.6, Node 22.22.1, tmux 3.5a
validations:
required: true
- type: dropdown
id: harness
attributes:
label: Harnesses involved
multiple: true
options:
- None / not sure
- Claude Code
- Codex
- Pi / Oh My Pi
- Other (say which below)
validations:
required: true
- type: textarea
id: repro
attributes:
label: What you ran and what happened
description: The command(s), relevant redacted output, and what you expected instead. Paste text where you can; screenshots are useful for visual issues.
render: shell
validations:
required: true
- type: textarea
id: state
attributes:
label: Instance state that might matter
description: Fresh install or upgrade? Include only relevant redacted lines from status output or your rig spec; do not upload full transcripts, credentials or your instance directory.
- type: checkboxes
id: checks
attributes:
label: Checks
options:
- label: I searched existing issues and Discussions for this.
required: true
- label: I am on the latest published version, or I said which version above.
required: true
-14
View File
@@ -1,14 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Ask a question
url: https://github.com/mvschwarz/openrig/discussions/categories/q-a
about: Setup, usage and "is this expected" questions go to Discussions Q&A.
- name: Share an idea or a setup
url: https://github.com/mvschwarz/openrig/discussions
about: Ideas and Show and tell live in Discussions.
- name: Documentation
url: https://openrig.dev/docs
about: Getting started, CLI reference, configuration and troubleshooting.
- name: Security report
url: https://github.com/mvschwarz/openrig/security/policy
about: Read how to arrange a private report. Do not include vulnerability details in public issues.
@@ -1,32 +0,0 @@
name: Feature request
description: Something you want OpenRig to do that it does not
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
For open-ended ideas or "how should this work" questions, [Discussions › Ideas](https://github.com/mvschwarz/openrig/discussions/categories/ideas) is the better place. Use this form when you can name the outcome you want.
This request will be public; use examples without credentials, private prompts or personal details.
- type: textarea
id: outcome
attributes:
label: What are you trying to do, and what stops you today?
description: Describe the situation, not the solution. One real example beats a general case.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What would you like OpenRig to do?
description: A command, a setting, a behaviour. Rough is fine.
validations:
required: true
- type: textarea
id: workaround
attributes:
label: How do you handle it now?
- type: input
id: version
attributes:
label: OpenRig version
placeholder: 0.5.14
-21
View File
@@ -1,21 +0,0 @@
<!-- Thanks for the PR. Three short answers are all a reviewer needs. -->
## What a user gets
<!-- One or two sentences: the behaviour before, the behaviour after. Link the issue if there is one. -->
## How you verified it
<!-- What you actually ran and saw, and which revision/local changes you tested. The documented checks are `npm run build`, `npm test`, and `npm run lint`; say what you could not run. Redact credentials and private information from logs or screenshots. -->
## Anything you were unsure about
<!-- Design choices, edge cases you did not cover, places a reviewer should look hardest. Empty is a fine answer. -->
## If this is security-related
<!-- Exploitable? Report it privately first (SECURITY.md). Otherwise: how does this go wrong for someone using OpenRig as designed (your own machine or a trusted private network), with evidence? Who causes it, and how do they reach the install? What does the change cost everyone else? See CONTRIBUTING.md. -->
- [ ] One concern per PR; no version bump; no `CHANGELOG.md` edit
- [ ] Tests added or updated where the change is testable
- [ ] I listed the checks I ran, their results, and any checks I could not run
-20
View File
@@ -1,20 +0,0 @@
# Getting help with OpenRig
- **Docs:** https://openrig.dev/docs — getting started, CLI reference, configuration,
troubleshooting. An index linking to agent-friendly documentation is at
https://openrig.dev/llms.txt.
- **Agents helping a user:** read [docs/reference/help.md](../docs/reference/help.md), or run `rig context get help`.
The same guide is at https://www.openrig.dev/help/agents.
- **Email:** hello@openrig.dev, which needs no GitHub account.
- **Questions:** [Discussions › Q&A](https://github.com/mvschwarz/openrig/discussions/categories/q-a).
- **Bugs:** [open an issue](https://github.com/mvschwarz/openrig/issues/new/choose) with the bug
template. Include `rig --version`, your OS, Node and tmux versions, the harnesses involved, and
relevant redacted command output.
Reports are public: remove credentials, private prompts and personal information.
- **Ideas and setups:** [Ideas](https://github.com/mvschwarz/openrig/discussions/categories/ideas)
and [Show and tell](https://github.com/mvschwarz/openrig/discussions/categories/show-and-tell).
- **Videos:** https://www.youtube.com/@openrig
- **Security:** see [SECURITY.md](../SECURITY.md); please report privately.
We aim to acknowledge issues and pull requests within one day. A substantive PR review decision
within seven days is a target, not a guaranteed deadline. Details in [CONTRIBUTING.md](../CONTRIBUTING.md).
-33
View File
@@ -1,33 +0,0 @@
name: Portability report
# Lists lines a pull request adds that contain values tied to one machine, network or account
# (credentials, home paths, temporary paths, network addresses, email addresses). Findings never
# fail the job; the author reads the report and decides what to change. An operational error (for
# example a git failure) does fail it. The repository is public, so this summary lists file and
# line only; authors see the matched text when they run the report locally or on push.
# See scripts/portability-report.mjs.
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
portability-report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Report added lines with machine-specific values
run: >
node scripts/portability-report.mjs
--from ${{ github.event.pull_request.base.sha }}
--to ${{ github.event.pull_request.head.sha }}
--locations-only
--out "$GITHUB_STEP_SUMMARY"
-177
View File
@@ -1,177 +0,0 @@
name: Tests
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
# list each published package would contain. Each suite is its own job so one failure does not
# hide the others. No provider credentials or operator configuration are supplied.
# Package suites and the Linux seat-environment check deny external networking;
# scenario runtime has no external route.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: tests-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-package:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: Record the published file list of each package
run: |
set -euo pipefail
mkdir -p pack-lists
for p in cli daemon tui; do
(cd packages/$p && npm pack --dry-run --json --ignore-scripts) \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const f of JSON.parse(s)[0].files)console.log(f.path)})' \
| sort > pack-lists/$p.txt
echo "$p: $(wc -l < pack-lists/$p.txt) files"
done
- uses: actions/upload-artifact@v4
with:
name: pack-lists
path: pack-lists/
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
repo-checks:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:repo
linux-seat-environment:
if: ${{ github.event_name != 'push' }}
# Cover /proc seat environments and Linux canonical-input limits that macOS cannot exercise.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install the real terminal and staging tools
run: |
sudo apt-get update
sudo apt-get install --yes tmux rsync
- run: npm ci
- run: npm run build
- name: Check seat environments without ambient credentials or external network
run: |
set -euo pipefail
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-seat-env.XXXXXX")
mkdir -p "$suite_root/home"
# Only loopback exists in this namespace. Drop back to the runner user
# before the CLI, private daemon and private tmux server are started.
sudo unshare --net -- /bin/bash -s -- "$(id -un)" "$suite_root" "$PATH" <<'SUITE'
set -euo pipefail
ip link set lo up
exec runuser -u "$1" -- env -i HOME="$2/home" PATH="$3" TMPDIR="$2" TMUX_TMPDIR="$2" \
CI=true TERM=xterm-256color \
npm run test -w packages/daemon -- test/seat-env-parent-session.integration.test.ts test/pi-shell-launch-native.test.ts --maxWorkers=1 --reporter=verbose
SUITE
package-tests:
if: ${{ github.event_name != 'push' }}
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
# file-inode behaviour and release tags.
runs-on: macos-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
package: [daemon, cli, tui, ui]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: brew install tmux
- run: npm ci
- name: Build the packages the tests import
run: npm run build
- name: Run the suite without ambient credentials or external network
run: |
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-test.XXXXXX")
suite_root=$(cd "$suite_root" && pwd -P)
mkdir -p "$suite_root/home" "$suite_root/bin"
# Same system bytes, nonprivileged mode; no system file is changed.
/usr/bin/install -m 0755 /bin/ps "$suite_root/bin/ps"
# Only this job's temporary sockets (including tmux) are reachable.
env -i HOME="$suite_root/home" PATH="$PATH" TMPDIR="$suite_root" TMUX_TMPDIR="$suite_root" \
CI=true TERM=xterm-256color \
sandbox-exec -D IPC_ROOT="$suite_root" -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))(allow network-outbound (subpath (param "IPC_ROOT")))' \
/bin/bash -s "${{ matrix.package }}" <<'SUITE'
set -euo pipefail
node scripts/ci-macos-process-probe.mjs
# The production readers use bare ps. Select real system bytes only after
# self/owned-child observation and network/IPC confinement are proved.
export PATH="$TMPDIR/bin:$PATH"
test "$(command -v ps)" = "$TMPDIR/bin/ps"
exec npm run test -w "packages/$1"
SUITE
installed-scenario:
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Installed package, daemon restart, and seeded durability failure
run: bash scripts/run-pr-scenarios.sh
- uses: actions/upload-artifact@v4
if: always()
with:
name: installed-scenario-evidence
path: dist/pr-scenarios/
if-no-files-found: error
+2 -16
View File
@@ -1,5 +1,4 @@
node_modules/
node_modules
dist/
.DS_Store
*.log
@@ -22,14 +21,8 @@ notes/*
*.sqlite-wal
*.sqlite.pre-reboot-backup
.openrig/
.claude/*
!.claude/skills/
.claude/skills/*
!.claude/skills/developing-openrig/
.agents/*
!.agents/skills/
.agents/skills/*
!.agents/skills/developing-openrig/
.claude
.agents
.openrig
.worktrees/
@@ -38,10 +31,3 @@ demo/proof/
# Build artifacts
*.tgz
# OPR.0.5.3.7 R2 — the shipped context-pack projection is DERIVED AT PACKAGE TIME
# (scripts/generate-context-packs.mjs), never committed or edited in place.
packages/daemon/context-packs/
# Internal build receipts; never commit
/.evidence/
-334
View File
@@ -1,334 +0,0 @@
# OpenRig architecture
> **This map is not the territory.** It is a zoomed-out guide for finding your way around the
> code. It is incomplete by design and it may be stale. Where it disagrees with the code, the code
> is right; please fix the page. Every path, symbol, count and command below was checked against
> commit `c750304f` (0.6.6 development). Each count shows the command that produced it, so you
> can re-run it.
## What OpenRig is
OpenRig runs a team of coding agents on your machine as one system. A local daemon keeps the team's
state in SQLite and runs each agent as an ordinary Claude Code or Codex session (or a plain
terminal, a Pi or Oh My Pi (OMP) runner, or a scripted test stub) inside tmux. People and agents drive it through the
`rig` CLI, a terminal UI (TUI) and an MCP server, all of which talk to the daemon over local HTTP.
The agents themselves are unmodified. What OpenRig adds is the coordination around them: launching
and restoring agents, delivering messages, keeping durable work in a queue, and giving each agent
the right skills and context.
## Packages
| Directory | Package | What it is | How it ships |
|---|---|---|---|
| `packages/daemon` | `@openrig/daemon` (private) | The daemon: HTTP routes, domain services, SQLite migrations, runtime adapters, built-in specs, skills, plugins, static context-pack sources | Copied into `@openrig/cli` |
| `packages/cli` | `@openrig/cli` | The `rig` command, the MCP server, daemon start and stop | The only published package |
| `packages/tui` | `@openrig/tui` (private) | The terminal UI | Copied into `@openrig/cli`; opened by `rig tui`, by bare `rig` in a terminal, or by the `openrig-tui` bin |
| `packages/ui` | `@openrig/ui` (private) | The React web UI, in **maintenance mode**: it still ships and runs, but gets no new feature work. The CLI and TUI are the supported surfaces | Built files copied into `@openrig/cli` and served by the daemon |
| `packages/test-system` | none (not an npm workspace) | The stub-agent scenario library, the CI scenario harness, live-model eval cases | Not shipped |
**What bundles what.** `packages/cli/package.json` publishes `LICENSE`, `dist`, `daemon`, `ui`,
`tui` and `scripts`. `scripts/build-package.sh` (the CLI's `prepublishOnly`) builds the daemon, UI,
TUI and CLI, then assembles them inside `packages/cli/`: the daemon's `dist`, `assets`, `specs` and
`policies`, the generated context packs and a copy of `docs/reference/` go under `daemon/`; the UI
build goes to `ui/dist`; the TUI build goes to `tui/dist`. So `docs/reference/` ships to every
user; `docs/as-built/` and this file do not.
**Sharing code between packages.** The CLI and TUI import daemon code only through the subpaths in
the `exports` field of `packages/daemon/package.json` (21 at this commit), for example
`@openrig/daemon/attention`, which points at `packages/daemon/src/attention-surface.ts`. The CLI
uses 19 of them and the TUI uses 2. In development they resolve through the npm workspace. At
package time `scripts/rewrite-daemon-imports.mjs` rewrites them to the shipped `daemon/dist` copy
and fails the build on a specifier with no `exports` entry. To share new daemon code, add a
`*-surface.ts` file and an `exports` entry.
## The request path
```
rig (CLI) TUI MCP server (rig mcp serve, stdio)
\ | /
+---- HTTP to the daemon (port 7433 by default) --------------+
|
packages/daemon/src/server.ts createApp(): context middleware, /healthz, app.route("/api/...")
|
packages/daemon/src/routes/ one Hono router per area; reads services with c.get(...)
|
packages/daemon/src/domain/ services, repositories, orchestrators, event bus
/ | \
SQLite (db/) tmux (adapters/tmux.ts) runtime adapters (adapters/*-adapter.ts)
claude-code, codex, pi, omp, terminal, stub
```
**Inside the daemon** (`packages/daemon/src/`):
- **Startup.** `index.ts` is the process entry: it resolves where to listen, starts the server and
runs the periodic queue sweeps. It calls `createDaemon()` in `startup.ts`, which opens the
database (`db/connection.ts`: WAL mode, foreign keys on), runs `migrate(db, ALL_MIGRATIONS)`,
constructs every service and adapter, and passes them to `createAppWithWebSocket(deps)` in
`server.ts`.
- **`server.ts`.** In `createApp(deps)`, an early `app.use("*", ...)` middleware (after the
request-phase observer and the JSON body-parse tracker) puts each
service on the request context (`c.set("<name>" as never, deps.<name>)`), serves `/healthz`, and
mounts each router with `app.route("/api/<area>", ...)`. Unknown `/api/*` paths get a JSON 404;
other GET requests serve the web UI's built files.
- **`routes/`.** Thin handlers. For example, `routes/ps.ts` reads `psProjectionService` from the
context and returns its entries as JSON. `routes/terminal-ws.ts` registers the terminal
WebSocket, and `routes/require-sender-identity.ts` is a shared helper rather than a router.
- **`domain/`.** The behaviour: repositories over SQLite (`rig-repository.ts`,
`queue-repository.ts`, `session-registry.ts`), orchestration (`startup-orchestrator.ts`,
`node-launcher.ts`, `rigspec-instantiator.ts`), message delivery (`session-transport.ts`,
`seat-delivery-guard.ts`) and events (`event-bus.ts`). Larger areas have their own
subdirectories, such as `gateway/`, `provider/`, `policies/`, `scope/`, `context-packs/` and
`mission-control/`.
- **`adapters/`.** The outside world. `tmux.ts` wraps tmux. The six `RuntimeAdapter`
implementations (OMP's extends Pi's) launch each runtime and project skills and files into it. `cmux.ts` and
`compose-services-adapter.ts` cover other integrations.
- **`db/`.** `connection.ts`, `migrate.ts`, `all-migrations.ts` and `migrations/`.
- **`middleware/auth-bearer-token.ts`.** The bearer-token check for operator write routes, plus
the loopback and Tailscale bind detection used at startup.
- **Live updates** go out as server-sent events: `routes/events.ts` uses Hono's `streamSSE`, and
the TUI subscribes to `/api/activity/events`.
- **State** lives in `$OPENRIG_HOME` (default `~/.openrig`). The database is `openrig.sqlite`
there (`daemon-db-path.ts`).
**The clients:**
- **CLI.** `createProgram()` in `packages/cli/src/index.ts` registers every top-level command;
the commands live in `packages/cli/src/commands/`. Most are thin clients: they call a daemon route
through `DaemonClient` (`packages/cli/src/client.ts`) and render the result. A few lifecycle
helpers open the database file directly. Bare `rig` in a terminal opens the TUI
(`packages/cli/src/front-door.ts`).
- **MCP.** `createMcpServer(client)` in `packages/cli/src/mcp-server.ts` registers the tools over
the same `DaemonClient`. `rig mcp serve` (`packages/cli/src/commands/mcp.ts`) runs it on stdio.
- **TUI.** `packages/tui/src/daemon-client.ts` is its only HTTP module. It reads existing daemon
projections, for example `/api/ps`, `/api/rigs/summary` and `/api/queue/list`, and the
`/api/activity/events` stream when the daemon offers it. The daemon URL comes from `--url`,
`OPENRIG_URL`, or `http://127.0.0.1:7433`. Agents can drive a running TUI through its control
socket (`packages/tui/src/socket-server.ts`).
- **Web UI.** Served by the daemon from the bundled `ui/dist`.
**In a development checkout,** `rig daemon start` prefers `packages/daemon/dist` over the bundled
copy (`resolveDaemonPath` in `packages/cli/src/daemon-lifecycle.ts`), and the TUI launcher prefers
`packages/tui/dist` (`packages/cli/src/front-door.ts`). Rebuild the package you changed. A daemon
that is already running keeps running the code it started with.
## Key counts
At commit `c750304f`. Run these from the repository root to refresh them.
| What | Count | Command |
|---|---|---|
| Database migrations | 94 files (latest: `095_rig_non_interruptive.ts`; there is no 093) | `git ls-files packages/daemon/src/db/migrations \| wc -l` |
| Files in `routes/` | 67 | `git ls-files packages/daemon/src/routes \| wc -l` |
| ... of which create a Hono router | 65 | `git grep -l 'new Hono' -- packages/daemon/src/routes \| wc -l` |
| `app.route(...)` mounts in `server.ts` | 69 | `grep -c 'app.route(' packages/daemon/src/server.ts` |
| Top-level `rig` commands | 87 | `grep -c 'program.addCommand(' packages/cli/src/index.ts` |
| Runtime adapters | 6 (`claude-code`, `codex`, `pi`, `omp`, `terminal`, `stub`); the command counts 5 because `OmpRuntimeAdapter` extends `PiRuntimeAdapter` | `git grep -l 'implements RuntimeAdapter' packages/daemon/src \| wc -l` |
| MCP tools | 18 | `grep -c 'server.tool(' packages/cli/src/mcp-server.ts` |
| Daemon `exports` subpaths | 21 | `node -p 'Object.keys(require("./packages/daemon/package.json").exports).length'` |
| Library scenarios | 15 | `grep -l '^scenario:' packages/test-system/scenarios/*.yaml \| wc -l` |
## Where to add things
Each recipe names the files to touch. Read a neighbouring example before you start; the code is
the authority.
### A CLI command
1. Create `packages/cli/src/commands/<name>.ts` exporting a function that returns a commander
`Command` and takes optional injected dependencies. `packages/cli/src/commands/unarchive.ts`
is a short example.
2. In the action, get the daemon with `getDaemonStatus(...)` and `daemonStatusGuard(status)`
(`packages/cli/src/daemon-lifecycle.ts`), then call it with
`new DaemonClient(getDaemonUrl(status))` (`packages/cli/src/client.ts`). Offer `--json` for
agents, and set `process.exitCode` on failure.
3. Register it in `createProgram()` in `packages/cli/src/index.ts` with
`program.addCommand(...)`. If it takes injected dependencies, add them to `ProgramDeps` in the
same file.
4. Add `packages/cli/test/<name>.test.ts`. `packages/cli/test/archive.test.ts` shows how tests
inject mocked lifecycle dependencies.
5. If the command needs new daemon behaviour, add a route as well.
### A daemon route
1. Create `packages/daemon/src/routes/<area>.ts` with a Hono router. Both styles exist: a
constant (`export const psRoutes = new Hono()` in `routes/ps.ts`) and a factory
(`whoamiRoutes()` in `routes/whoami.ts`).
2. Read services from the request context with `c.get("<name>" as never)`. Keep the behaviour in
`packages/daemon/src/domain/`.
3. Mount it in `createApp()` in `packages/daemon/src/server.ts` with
`app.route("/api/<area>", ...)`, above the `/api/*` 404 catch-all.
4. A new service needs three more edits: a field on `AppDeps` and a `c.set(...)` line in
`server.ts`, and its construction in `createDaemon()` in `packages/daemon/src/startup.ts`.
5. Test it the way `packages/daemon/test/whoami-routes.test.ts` does: build a small Hono app,
`c.set` the service, mount the router and call `app.request(...)`. `createFullTestDb()` in
`packages/daemon/test/helpers/test-app.ts` gives you an in-memory database migrated with the
test fixture list (`migrationsForFullTestDb`).
### A database migration
1. Create `packages/daemon/src/db/migrations/<NNN>_<name>.ts` exporting a `Migration`
(`{ name: "<NNN>_<name>.sql", sql: "..." }`; the type is in `packages/daemon/src/db/migrate.ts`).
`089_classification_identity_provenance.ts` is a one-line example.
2. Take the next number after the highest on `main` (095 at this commit). Parallel pull requests
often pick the same number, so check again and renumber if `main` moved before you merge.
3. Import it in `packages/daemon/src/db/all-migrations.ts` and append it to `ALL_MIGRATIONS`.
Startup runs exactly that list (`startup-migrations-mirror.test.ts` pins this).
4. Add it to `migrationsForFullTestDb` in `packages/daemon/test/helpers/test-app.ts`, or list it in
`migrationsForFullTestDbExclusions` with a reason. `migration-fixture-parity.test.ts` fails
otherwise.
5. `migrate()` sorts migrations by name and records each applied name in `schema_migrations`, so
an install that already applied a migration never runs it again. Editing a migration that has
shipped changes nothing for those installs; add a new one instead. A long-lived install applies
every new migration on its next start, against real data.
### A runtime adapter
1. Implement `RuntimeAdapter` from `packages/daemon/src/domain/runtime-adapter.ts`: `runtime`,
`listInstalled`, `project`, `deliverStartup`, `launchHarness` and `checkReady`. The smallest
implementation is `packages/daemon/src/adapters/terminal-adapter.ts`; the scripted test double
is `stub-runtime-adapter.ts`.
2. Register it in `packages/daemon/src/startup.ts`. The adapter maps are keyed by runtime name:
`adapters:` (for the pod instantiator) and `runtimeAdapters:`. The context monitor has its own
map for the runtimes it samples.
3. Accept the runtime name in rig spec validation: `SUPPORTED_RUNTIMES` in
`packages/daemon/src/domain/rigspec-preflight.ts`. Other runtime lists, such as `RuntimeHint` in
`domain/discovery-types.ts` and `LEGACY_KNOWN_RUNTIMES` in `domain/rigspec-schema.ts`, may need
it too; search for an existing runtime name such as `"pi"` to find them.
4. Launch, projection and readiness are arteries. Read
[docs/as-built/arteries.md](docs/as-built/arteries.md) first.
### A shipped skill
Skills ship from three copies in this repository (the "edges"), listed in
`scripts/skill-edge-layout.generated.json`:
| Edge | Path | Layout |
|---|---|---|
| `spec` | `packages/daemon/specs/agents/shared/skills/` | by category (`core`, `pm`, `pods`, `process`) |
| `plugin` | `packages/daemon/assets/plugins/openrig-core/skills/` | flat |
| `canonical` | `skills/_canonical/` | public mirror of the `spec` copy |
Each skill in the layout file lists which edges carry it. At this commit, 33 skills are in `spec`
and `canonical`, 17 only in `plugin`, and 2 in all three.
- Each skill has one editable home: its `spec` copy when it has a `spec` edge, otherwise its
`plugin` copy. Its other edges are generated from that home.
- `npm run mirror-skills:check` (part of `npm test`) hashes every file in each edge and compares the
hashes with `scripts/skill-edge-digests.generated.json`. It also fails on a `SKILL.md` the layout
does not list, on a generated copy that differs from its home, and on a home holding text the
public copies would drop.
- The full apply, `npm run mirror-skills`, regenerates every generated copy from its home and
refreshes the digests. It reads nothing outside the repository and never writes to a home.
- **To fix a skill:** edit its home, run `npm run mirror-skills`, then run
`npm run mirror-skills:check`.
- **To add a skill:** open an issue first. Its layout entry is generated from files that are not
in this repository.
- Every shipped skill also becomes a context pack at package time (next recipe).
### A context pack
- **Static packs** are hand-written: a directory under `packages/daemon/context-packs-src/<name>/`
with a `manifest.yaml` and its files. Keep `version: "0"` in the manifest; the generator stamps
the package version. A pack file may be a symlink to a file under `docs/reference/`, so a
reference document has one source (for example `help/help.md` points to
`docs/reference/help.md`). Any other symlink fails the build. There are 5 static packs at this
commit.
- **Generated packs** go to `packages/daemon/context-packs/`, which is gitignored and never edited
by hand. `scripts/generate-context-packs.mjs` builds it at package time from every shipped skill
and every static pack.
- `npm run generate-context-packs:check` (part of `npm test`) validates every pack through the
daemon's own manifest parser, so build the daemon first. It also runs the leak scan over the
static sources, including the `docs/reference/` files they link to.
- The daemon serves the shipped packs through `rig context get`; `startup.ts` registers the bundled
directory as its built-in root.
### A scenario
Scenarios run the real CLI, daemon, tmux and SQLite with scripted stub agents (`runtime: stub`)
and no model cost.
1. Add `packages/test-system/scenarios/<defect-class>.yaml`, named for the class of bug it
catches. Topology fixtures sit beside it (`*-stub.yaml`).
2. The format is checked by `validateScenario` in
`packages/daemon/test/helpers/scenario-schema.ts`. Step verbs: `up`, `down`, `send`, `restart`,
`restore`, `emit`, `mutate`, `policy`, `seed_regression`, `daemon`, plus `expect`. `expect` may
only read shipped surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`,
`tui_socket`, `policy_provenance`.
3. Today the runner binds `up`, `down`, `send`, `restart` and `daemon`. `restore`, `emit`,
`mutate` and `policy` fail with `UnboundActionError`, and `seed_regression` needs a fault
controller (`packages/daemon/test/helpers/scenario-real-deps.ts`).
4. Write a header comment naming the defect class and the seed: what a seeded regression plants,
and which `expect` must catch it. A scenario counts only as a pair: it passes on healthy code
and fails on the seeded run (`packages/test-system/README.md`).
5. **Run it on your machine** after `npm run build`:
`node --import tsx packages/daemon/scripts/run-scenarios.mjs <file.yaml>`. It uses a private
daemon and a private tmux server, and builds its environment from `HOME`, `PATH` and `TERM`
only, so your session's `TMUX` and daemon variables are dropped and it is safe to run from
inside tmux, including an agent's pane. It supplies no fault controller, so a
`seed_regression` step fails there; the seeded pair runs in the container path.
6. **CI.** The `installed-scenario` job runs `scripts/run-pr-scenarios.sh` in disposable,
network-less containers built from the packed package. It runs four cases: `fixture` and `library`
(`queue-baton-survives-restart.yaml`), each with a healthy run and a seeded lost-baton run, plus
`transcript` and `capture`, healthy runs only. Adding a seeded case means changing three files:
the `--case` list in `scripts/run-pr-scenarios.sh`, `CASES` in
`packages/test-system/ci/result.mjs`, and the fault controller in
`packages/test-system/ci/run.mjs`; a healthy-only case goes in `PASSING_CASES` in the first two
and needs no fault controller. [docs/as-built/test-layers.md](docs/as-built/test-layers.md)
covers both run modes, running a case on your own Docker host, and what a stub can and cannot
prove.
## Guards you will meet
`npm test` runs `test:repo` (builds the daemon, runs `node --test scripts/*.test.mjs`, the docs
guard, `mirror-skills --check` and `generate-context-packs --check`) and then the daemon, CLI and
TUI suites. `npm run test:ui` runs the web UI suite separately. `npm run lint` typechecks all four
packages.
- **Docs guard** (`scripts/check-docs-guard.mjs`). Under `docs/`, git may track only
`docs/as-built/`, `docs/reference/`, `docs/releases/` and exactly `docs/DESIGN.md`;
`.gitignore` ignores the rest of `docs/`. Remember that `docs/reference/` ships inside the npm
package, and the daemon copies its Markdown files to `$OPENRIG_HOME/reference/` when it
starts. Files in `docs/as-built/` carry a `last-verified-against-source` commit in their
frontmatter.
- **Internal-leak guard** (`scripts/internal-leak-scanner.mjs`). Its term lists come from an
optional local file that `OPENRIG_LEAK_GUARD_RULES` names (`scripts/leak-guard-rules.mjs`);
without one the term checks are skipped and every other check runs, and the release-time
substance gate requires it. It runs in `generate-context-packs.mjs` (static pack sources, on every
`npm test`), in the full skill mirror apply (each public skill), in `scripts/build-package.sh`
(the daemon `specs/` tree before it is copied into the package), and in the release-time
substance gate (`scripts/check-substance-gate.mjs`, `npm run gate:substance`) over the packed
CLI. No workflow runs it over a pull request's whole diff.
- **Portability report** (`.github/workflows/portability-report.yml`, running
`scripts/portability-report.mjs`). On each pull request it lists added lines containing
machine-specific values: credentials, home and temporary paths, network addresses, email
addresses. It never fails the job; you read it and decide.
- **Generated files.** Do not hand-edit `scripts/*.generated.json` or anything under
`packages/daemon/context-packs/`. The skill-edge digests are refreshed by
`npm run mirror-skills` (see "A shipped skill").
The internal-token rules support optional `allowed_context_lines` for reviewed generic
examples: the complete line must match exactly, including indentation and case. Unlike
`allowed_context_substrings`, this does not exempt a line with added or substituted text;
internal-path checks and the substance gate's human-review dispositions still apply.
- **Hosted CI** (`.github/workflows/tests.yml`): `build-and-package`, `typecheck`, `repo-checks`,
`package-tests` (macOS; daemon, CLI, TUI and UI suites with no credentials and no external
network) and `installed-scenario`.
## Pointers
- [CONTRIBUTING.md](CONTRIBUTING.md): setup, what a pull request needs, what to expect from review.
- [docs/reference/developing.md](docs/reference/developing.md): which checks block and which are
advisory, and what `.github/workflows/tests.yml` runs.
- [docs/reference/worktree-builds.md](docs/reference/worktree-builds.md): building in a git
worktree, with its own `npm ci` rather than a symlinked `node_modules`.
- [docs/reference/](docs/reference/): user and operator reference; it ships with the package.
- [docs/as-built/](docs/as-built/README.md): module-by-module descriptions of the system. **These
are being re-verified for 0.6.6.** Each page's `last-verified-against-source` names the commit it was
checked against; until a page is re-verified, treat its counts as historical. Use them for
orientation, then check the code.
- [docs/as-built/test-layers.md](docs/as-built/test-layers.md): what each test layer covers and
what it cannot catch.
- [docs/as-built/arteries.md](docs/as-built/arteries.md): the areas where a small change has a
large effect, what depends on them, and what broke there before.
- [packages/test-system/ci/README.md](packages/test-system/ci/README.md): the CI scenario job.
- [docs/releases/](docs/releases/) and [CHANGELOG.md](CHANGELOG.md): release history, written by
the maintainers at release time.
-8154
View File
File diff suppressed because it is too large. Load diff
-75
View File
@@ -1,75 +0,0 @@
# Code of Conduct
## Our pledge
We as members, contributors, and maintainers pledge to make participation in OpenRig a
harassment-free experience for everyone, regardless of age, body size, visible or invisible
disability, ethnicity, sex characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive,
and healthy community.
## Our standards
Examples of behaviour that contributes to a positive environment:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from
the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behaviour:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their
explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable
behaviour and will take appropriate and fair corrective action in response to any behaviour that
they deem inappropriate, threatening, offensive, or harmful. Maintainers have the right and
responsibility to remove, edit, or reject comments, commits, code, issues, and other contributions
that are not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all project spaces (the repository, Discussions, issues and
pull requests) and when an individual is officially representing the project in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behaviour may be reported to the
maintainers at **[email protected]**. All complaints will be reviewed and investigated
promptly and fairly.
All maintainers are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement guidelines
Maintainers will follow these community impact guidelines in determining the consequences for any
action they deem in violation of this Code of Conduct:
1. **Correction.** A private, written warning, with clarity around the nature of the violation and
an explanation of why the behaviour was inappropriate. A public apology may be requested.
2. **Warning.** A warning with consequences for continued behaviour, including no interaction with
the people involved for a specified period.
3. **Temporary ban.** A temporary ban from any sort of interaction or public communication with
the community for a specified period.
4. **Permanent ban.** A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
version 2.1 by Coraline Ada Ehmke, available at
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html and licensed under
[Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0/).
This adaptation names OpenRig and its maintainers and summarizes the enforcement guidelines. Community impact guidelines
were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
-135
View File
@@ -1,135 +0,0 @@
# Contributing to OpenRig
Thanks for being here. OpenRig is built in the open and by the thing it is: a rig of coding
agents and a small group of people. External pull requests and issues have started arriving
faster than we planned for, which is the best problem to have. This page says how to get a
change in with the least friction on both sides.
## Before you start
- **Bugs:** open an issue with the bug template. Include your OpenRig version (`rig --version`),
OS, Node and tmux versions, which harnesses are involved (or none), and the relevant command and output.
Reports are public: remove credentials, private prompts, personal details and private paths
before posting. Share a small reproduction rather than a full transcript or instance dump.
- **Features and behaviour changes:** open an issue or a Discussion in *Ideas* first. Search issues
and Discussions, and check ROADMAP.md, before proposing a feature. A short "what I am trying to do
and what stops me" saves both of us a rewrite. Small, obvious fixes do not need an issue.
- **Questions:** use [Discussions › Q&A](https://github.com/mvschwarz/openrig/discussions/categories/q-a),
not an issue.
## Before a security or integration PR
OpenRig connects your coding agents to each other: shared context, messaging, coordination and long-running seats.
It's designed for your own machine or a trusted private network, for you and people you trust, not for the open
internet. It doesn't try to act for you in the outside world; your agents already have tools for that.
**Security-related PRs.** If you've found something exploitable, report it privately first (see
[SECURITY.md](SECURITY.md)) rather than in a public PR. For hardening changes, tell us in the PR description:
- the scenario: how this goes wrong for someone using OpenRig as it's designed to be used, and the evidence you have
- who or what causes it, and how they reach the install
- what the change costs everyone else: a refusal, an extra step, a new setting
We look at the finding and the fix separately. We may agree with a finding and fix it differently, or decide the fix
costs more than it protects. If the answers are missing we'll ask once, and close the PR with thanks if they don't
come.
**Integrations with other tools or projects.** Open an issue or an Ideas Discussion and wait for a maintainer's yes on
scope before you build it; an issue on its own isn't a yes. Often the best home for an integration is your own
repository, and we're happy to link to it.
## Setting up
Node `^22 || ^24`, a working `tmux` and `rsync` are required. macOS includes `rsync`; many Linux
installs don't (`sudo apt-get install rsync` on Debian or Ubuntu). `npm test` and `npm run mirror-skills`
use it. Use the npm that comes with your Node, which is npm 10 or newer (`npm --version` shows which you
have). A separately packaged npm 9, such as Debian or Ubuntu's `npm` package, fails a fresh `npm ci` with
`ENOENT` on `chmod` of `node_modules/@openrig/tui/dist/main.js`. Then:
```bash
git clone https://github.com/mvschwarz/openrig.git
cd openrig
npm ci # exactly what package-lock.json records
npm run build # all workspaces
npm test # repo checks + daemon, cli, tui test suites
npm run lint # typecheck every package
```
`npm ci` installs the locked versions and never rewrites `package-lock.json`, which is what CI
runs too. Use `npm install <package>` only when you're adding or changing a dependency, and commit
the lockfile change with it.
`npm test` builds the daemon and runs repository checks before the package suites. Read the
specific failure. Each shipped skill has one editable home in this repository (see
["A shipped skill" in ARCHITECTURE.md](ARCHITECTURE.md#a-shipped-skill)). Edit its home, run
`npm run mirror-skills` to regenerate its other copies and refresh the digests, then run
`npm run mirror-skills:check` and commit the result. The apply reads nothing outside the repository.
The repository checks include a docs guard: a
tracked file under `docs/` must be in `docs/as-built/`, `docs/reference/` or `docs/releases/`
(`docs/DESIGN.md` is the one exception). They check that shipped first-run text names only teams, skills and
commands that exist, and they validate the context-pack sources;
`npm run generate-context-packs` rebuilds the generated packs locally, which aren't committed.
The UI unit-test suite is separate from `npm test`: run `npm run test:ui`. CI runs it on every pull
request as `package-tests (ui)`, so a change has to pass it.
[docs/as-built/test-layers.md](docs/as-built/test-layers.md) lists every test layer, what CI runs,
and what each layer can and cannot prove.
For hands-on development, read the [check requirements](docs/reference/developing.md),
[worktree setup](docs/reference/worktree-builds.md), and
[machine changes](README.md#what-openrig-changes-on-your-machine). Use an isolated environment
for changes that start daemons or agents; `OPENRIG_HOME` alone does not isolate provider settings.
Permission configuration is an [explicit choice](docs/reference/getting-started.md#have-your-agent-configure-permissions).
A checked-out tree is not the installed daemon; restarting an installed daemon does not adopt
your working copy.
## Making the change
- One concern per pull request. Keep unrelated refactors separate; explain any refactor needed
for the fix.
- Keep the diff small enough to review in one sitting. If it is not, say why in the description.
- Add or update a test where the change is testable. Use focused deterministic tests where
possible. For terminal or provider behaviour, state what was exercised with the actual runtime
and what was simulated; a stub alone does not prove the native interaction works.
- Do not edit `CHANGELOG.md`. Maintainers write release notes at the tag.
- Do not bump versions.
- Match the surrounding style. `npm run lint` typechecks; it does not format code.
- Write commit messages in the form the log already uses: `fix(cli): …`, `feat(daemon): …`,
`docs(reference): …`, `test: …`.
## The pull request
Fill in the template. The three things a reviewer needs are: what a user gets, how you verified
it, and anything you were unsure about. State the revision and relevant local changes you tested,
what you actually ran, and any checks you could not run. Redact private information from evidence.
Contributions follow the repository's [Apache-2.0 license](LICENSE). Preserve attribution and any
applicable license notices when adapting third-party material.
## What to expect from us
- We aim to acknowledge issues and pull requests within **one day**. An acknowledgement is not
a completed review or a merge decision.
- Our target for a first substantive review decision on an external PR is **seven days**. If it
takes longer, we explain what is pending on the PR. This is a target, not a guaranteed deadline.
- Labels you will see: `needs-repro` (we could not reproduce it yet; waiting on versions or steps),
`fixed-on-main` (merged, not yet on npm), `good first issue`, `help wanted`, `discussion`
(direction question; continues in Discussions).
Reviews here are done by people and by the project's own agents. An agent may ask the first
clarifying question or run the reproduction; a maintainer makes the merge decision.
## Where things live
- **Start here:** [ARCHITECTURE.md](ARCHITECTURE.md) maps the packages, the request path, and where to
add a command, route, migration, adapter, skill, context pack or scenario.
[docs/as-built/arteries.md](docs/as-built/arteries.md) lists the areas where a small change has a
large effect; if your change touches one, read it first.
- **Using a coding agent?** Claude Code and Codex load the repository's `developing-openrig` skill
when they work in this checkout (`.claude/skills/` and `.agents/skills/`). It points at the maps
above and at what to run before you push.
- Repository reference: `docs/reference/`; user documentation: [openrig.dev/docs](https://openrig.dev/docs).
- Skills: `packages/daemon/specs/agents/shared/skills/`, plugin skills under
`packages/daemon/assets/plugins/`, and `skills/_canonical/`; static context-pack sources:
`packages/daemon/context-packs-src/`.
Generated packs live in `packages/daemon/context-packs/` and are not hand-edited or committed.
- Releases: [GitHub Releases](https://github.com/mvschwarz/openrig/releases) and npm `@openrig/cli`.
-12
View File
@@ -4,10 +4,6 @@ This document captures the current OpenRig visual system so the product can be
recreated, extended, or translated into other media without reverse-engineering
the UI from code.
It describes the web UI in `packages/ui`, which is in maintenance mode (see
`docs/reference/developing.md`). The terminal UI has its own theme layer in
`packages/tui/src/theme.ts`.
## Essence
OpenRig is a local control plane for multi-agent work. The interface should feel
@@ -86,12 +82,6 @@ terminal emulator.
The canonical tokens live in `packages/ui/src/globals.css` and are surfaced to
Tailwind in `packages/ui/tailwind.config.ts`.
The values below are the light set (`:root`). A `.dark` block in the same file
redeclares the same names for Vellum Dark. The theme selector offers Vellum
Light, Vellum Dark and System (`packages/ui/src/lib/theme.ts`). With no stored
choice the UI follows the operating system's color scheme (`system`); the choice
is stored under the `openrig.theme` local-storage key.
Paper:
- `--background: 47 20% 97%` - base paper, `#faf9f5`
@@ -240,8 +230,6 @@ Runtime marks:
- Claude: pixel-style Claude mark, label `Claude`
- Codex: Codex CLI terminal-circle mark, label `Codex`
- Pi: blue rounded-square π mark, label `Pi`
- Oh My Pi: violet rounded-square π mark, label `Oh My Pi` (short label `OMP`)
- Terminal: compact terminal glyph, label `TTY` or `Terminal`
- Unknown: neutral fallback
+73 -434
View File
@@ -1,318 +1,42 @@
# OpenRig
[![npm version](https://img.shields.io/npm/v/@openrig/cli)](https://www.npmjs.com/package/@openrig/cli) [![npm downloads](https://img.shields.io/npm/dw/@openrig/cli)](https://www.npmjs.com/package/@openrig/cli) [![License: Apache 2.0](https://img.shields.io/github/license/mvschwarz/openrig)](LICENSE) [![GitHub stars](https://img.shields.io/github/stars/mvschwarz/openrig?style=social)](https://github.com/mvschwarz/openrig/stargazers)
Open source multi-agent harness for coding teams.
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
OpenRig is open-source software for building and running your own network of agents. It turns AI coding agents from a pile of terminal sessions into a persistent, organized team. Talk to a lead agent about the outcome you want; it can coordinate specialists across teams and bring you results and decisions that need your attention. Start with a repository and one useful change, then keep the team's work and context at the same addresses.
![OpenRig UI](https://openrig.dev/screenshots/remotion/hero-ui-workspace.png)
It's the open-source system behind my AI civilization experiments.
**Guide:** [Getting started](docs/reference/quickstart.md) · **Stuck?** [Help](docs/reference/help.md) · **Questions:** [Q&A](https://github.com/mvschwarz/openrig/discussions/92) · **Updates and demos:** [@_feralmachine on X](https://x.com/_feralmachine)
## See it running
![The OpenRig TUI: the build rig as a graph, then as a table of seats with runtime, model, context and state, then one seat in detail (real recording, 10 seconds)](assets/readme/openrig-agents-working.gif)
**Start here:** [the guided first-use path](docs/reference/quickstart.md): install, talk to the kernel operator, then choose a team for your first useful change.
Not setting this up today? Get the next walkthrough and occasional OpenRig updates → https://openrig.dev/follow
## Install and first run
**Let your coding agent set it up.** First check that `node --version` shows 22 or 24: the command below needs
Node.js and the `npx` that comes with it. Then, in your project folder, add OpenRig's `rigs` skill to the agent you
use:
```sh
# Claude Code
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent claude-code
# Codex
npx skills add https://github.com/mvschwarz/openrig/tree/v0.7.5/skills/rigs --skill rigs --agent codex
```
Then type `/rigs` in Claude Code, or `$rigs` in Codex, and say what you want built. The skill has your agent tell
you what it will run and change before it does, install OpenRig, open the OpenRig view and pass your goal to
OpenRig's operator.
You need Node.js 22 or 24 (22 on Apple silicon), tmux, macOS or Linux (WSL2 on Windows), and Claude Code or Codex
signed in. One provider is enough to start.
Already have OpenRig? Use the tag that matches `rig --version`, or upgrade first.
Prefer to type the commands yourself? [Install it yourself ↓](#install-it-yourself)
Requires Node.js 22 or 24 and tmux, on macOS or Linux, and one working Claude Code or Codex account: one is enough, since the kernel operator adapts the shipped `starter` team to whichever you have. On Linux, the distribution's own Node.js can be older (Ubuntu 24.04's is 18); install a supported version with [nvm](https://github.com/nvm-sh/nvm) (`nvm install 22`) or NodeSource. On a Mac with Apple silicon, use Node.js 22 ([compatibility history](docs/releases/v0.5.15.md#known-compatibility-limitation)). On a Mac where you aren't an administrator, or where Homebrew belongs to another account (its `brew install` is refused), install Node.js in your own home folder with [nvm](https://github.com/nvm-sh/nvm): run `touch ~/.zshrc` (a new Mac account has none, and nvm's installer only adds itself to a profile file that already exists), then nvm's install command, for nvm v0.40.8 `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash` ([nvm's page](https://github.com/nvm-sh/nvm#install--update-script) has the newest), open a new terminal, and run `nvm install 22`. nvm itself needs no administrator password, and `npm install -g` then installs into your nvm folder. If nvm's installer says you need Apple's command line tools, run `xcode-select --install`, then its install command again. On Windows, use WSL2, the Windows route OpenRig supports; native Windows isn't supported. OpenRig's automated tests don't run on WSL2 yet; see [one user's working setup](docs/reference/getting-started.md#wsl2-a-reported-working-setup).
> **What OpenRig changes on your machine**
> - **Install:** `@openrig/cli` goes in globally with npm.
> - **Setup:** `rig setup` adds an OpenRig block to `~/.tmux.conf`. It can install tmux (with Homebrew on macOS), and
> Herdr unless you pass `--no-herdr`. Plain `rig setup`, which is what the one-command install runs, checks both
> Claude Code and Codex and may install a missing one; `rig setup --providers claude` (or `codex`) limits it to
> yours.
> - **Starting OpenRig, for the coding agent you use** (paths are the usual defaults):
> - Codex: hooks and trust in `~/.codex/config.toml`.
> - Claude Code: workspace trust in `~/.claude.json`, and activity hooks in the project's
> `.claude/settings.local.json`, plus a status line unless you already have your own.
> - Discovery skills go in `~/.claude/skills` and `~/.agents/skills`.
> - **Starting a team in your project:** adds managed guidance to `AGENTS.md` or `CLAUDE.md`, plus the team's skills
> and plugins.
> - **Permissions:** by default, OpenRig's own kernel gets broader permissions than a project team gets by default.
> On Codex that's full access with no approval prompts (`-s danger-full-access -a never`). On Claude Code, its file
> tools, web fetches and operational commands, including reads under your home folder, run without asking. A
> permission policy, per-agent choice or Codex profile changes that. What your team can do depends on the team you
> choose and your settings; `workshop`, for example, ships with broad access.
> - **Backups:** OpenRig doesn't guarantee it can preserve or roll back these files, so back them up first.
>
> [Everything OpenRig writes, and why →](https://github.com/mvschwarz/openrig#what-openrig-changes-on-your-machine)
### Install it yourself
This runs OpenRig's install script from the `v0.7.5` release. It checks Node.js and npm, installs the latest published `@openrig/cli` with `npm install -g`, runs the Node.js and SQLite check, then runs `rig setup --dry-run` and `rig setup`. The first line prints that plan and changes nothing:
```bash
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh -s -- --dry-run
curl -fsSL https://raw.githubusercontent.com/mvschwarz/openrig/v0.7.5/scripts/install.sh | sh
```
If the last step isn't ready, the installer reports `INSTALLED, SETUP NOT COMPLETE` and points to the details from `rig setup` above. Follow that output and any reported Fix, then rerun `rig setup`; the CLI does not need reinstalling. If the only remaining failures are provider sign-ins, sign in to each selected provider as below and continue.
**Or step by step:**
## Quick Start
```bash
# Install
npm install -g @openrig/cli
rig setup --dry-run
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
rig setup
# Boot the conveyor starter rig (4 seats, mixed runtimes)
rig up conveyor
# Open the UI
rig ui open
```
To install with Bun instead, run `bun add -g @openrig/cli`. OpenRig still runs on Node.js, so install Node.js 22 as well. Bun may block this package's postinstall script, in which case the Node.js and SQLite check described under [what OpenRig changes on your machine](#what-openrig-changes-on-your-machine) does not run at install time.
After the conveyor rig boots, open the UI and click **intake.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
npm 11 and later can skip that postinstall script and print `npm warn install-scripts` naming `@openrig/cli`. That's expected: the CLI still works, and only the Node.js and SQLite check was skipped. To run it yourself, use `node "$(npm root -g)/@openrig/cli/scripts/check-abi.mjs"`; `rig doctor` checks the Node.js version only.
## Launch Walkthrough
Choose the working account you already have: **Claude Code, Codex, or both**. Reuse an explicit choice; no second subscription is required. `rig setup --dry-run` previews the broader setup; applying `rig setup` checks both harnesses and installs a missing one, plus Herdr unless declined with `--no-herdr`. Pass the choice with `--providers claude` or `--providers codex` so the other reads not selected instead of failed. The one-command install above doesn't know the choice, so it installs and checks both; for one provider, go step by step and run `rig setup --providers <choice>`. On macOS, the installing agent offers Ghostty once. Setup leaves an existing cmux installation alone. It is optional for the [selected-provider path](docs/reference/getting-started.md#choose-your-providers).
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
A team seat with no permission policy, per-seat choice or (for Codex) named profile launches with OpenRig's [team default](docs/reference/rig-spec.md#team-launch-defaults): Claude runs ordinary `rig` commands, project reads and common tests without prompting, while lifecycle commands such as `rig up` and `rig down` still ask; Codex also gets the OpenRig workspace and its pod's state directory as writable directories. Before launching, your agent recommends keeping that default, and offers to remember selected OpenRig commands in your native settings only if you want that, at personal project scope unless you explicitly choose user-wide sessions. It is not global YOLO or permission to invent work. On Yes, the agent [adds and verifies native rules](docs/reference/getting-started.md#have-your-agent-configure-permissions); No or no answer keeps the team default and leaves settings unchanged. An existing explicit choice is reused. Say “Undo the OpenRig command allowances added by this setup” to remove only its additions.
### 1. Open the conveyor rig
If permission prompts are slowing you down, consider the
[workshop bundle](https://openrig.dev/rigs/workshop). It ships with broad access
and [non-interruptive mode](docs/reference/non-interruptive-mode.md): its agents
bypass permission prompts and supported harness warning dialogs. The listing
and before-install view explain that access before you choose it.
When the UI opens, the explorer is already visible on the left. Click the `conveyor` rig to load its live topology.
Check `tmux -V` and only your selected CLI/login: `claude --version` plus
`claude auth status`, or `codex --version` plus `codex login status`. If needed,
sign in once with `claude auth login` or `codex login`; do not install or log in
to an unused provider.
### 2. Click `CMUX` on `intake.lead`
### Your agent opens OpenRig for you
Once the topology loads, go to the `intake.lead` node and click its `CMUX` button. That opens the intake terminal directly.
After installing, your agent opens the OpenRig TUI and the operator for you. To get back to them later, ask your
agent for OpenRig or your agents in any words. After checking the selected login, it starts the daemon if it's stopped
and opens the view:
```sh
rig daemon start
rig terminal open saved:kernel --window
```
`--window` opens a new terminal tab or window itself, or a space in your current
Herdr session. Below 120 measured columns,
the **operator** fills the first page; from 120, the dashboard and operator share
it equally. The advisor has a separate tab or tmux window.
An installing agent can run it from its shell on the daemon's
desktop; the person copies nothing. It uses Herdr when installed, otherwise
the same layout in plain tmux, and preserves existing conversations. `rig tui`
is the team dashboard. Installation is finished when you are talking to the
operator; if you choose to talk later, that handoff remains pending.
If setup stops, read the named step: missing tools and selected-provider logins
need their specific setup action. A harness permission rule, sandbox or automatic
permission decision can also block the installing agent's command. The agent
should name the command and reported reason, then help you resolve that specific
step within your chosen permissions. A permission refusal is not an OpenRig
installation result.
The desktop may need an Automation approval; SSH/headless sessions may have no
display. Herdr can show first-run panels, and an 80×24 window can cramp the view.
Follow [the OpenRig view checks and next steps](docs/reference/getting-started.md#what-can-interrupt-installation-and-the-openrig-view).
Herdr is visible only inside a terminal you can see. Changing the shared TUI to
`:terminals` does not open that terminal. The agent checks the visible window when
it has desktop access and says what it could not verify otherwise. Over headless
SSH, it asks you to open a new terminal window or tab and gives the exact connection
and attachment command for the current view. If you choose a manual attachment
after a failure,
ask the installing agent for the exact command using your current operator
session; run that in a new terminal window.
The kernel's operator helps you pick a first team. It asks what you want to build,
recommends one of three, and fits it to the providers you have.
| Team | Talk to | Agents and purpose |
| --- | --- | --- |
| `starter` | `dev-build@starter` | A Claude Code builder and Codex reviewer for one bounded change |
| `workshop` | `orch-lead@workshop` | A lead, builder, QA and reviewer for ongoing work in one repository; a rig bundle the operator installs from its pinned listing |
| `factory` | `orch-lead@factory` | Seven: a lead, advisor, build, QA, design and two independent reviewers for sustained product work |
As shipped, `starter` uses both Claude Code and Codex. With only one of them, ask
the kernel operator to adapt it: it writes a copy of the team for your providers
under the same name. [The naming guide](docs/reference/topology-naming.md) explains
how their pods and roles grow and how to name your own team. `first-project` is
starter's old name and still starts it,
unless you already have a rig named `first-project` or `starter`: then it refuses
and names the `rig up <name> --existing` command that brings that rig back.
Show the selected runtime, configured model and command before launch; confirm the
account supports the model instead of silently falling back. The kernel starts
automatically and selects from available authenticated providers independently of
these teams. A missing unused provider is not a setup requirement.
To launch the starter yourself instead (the manual path, without the operator):
```bash
cd /path/to/your/repository
rig specs preview starter --kind rig
rig up starter --cwd . --plan
rig up starter --cwd .
```
To see your agents, use `rig terminal open saved:kernel --window`. If that window cannot open, `rig tui --shared` is the named dashboard-only fallback; it does not show the operator conversation. The installing agent should resolve or explain the window failure, not finish by suggesting a command for you to type. Closing a viewing terminal does not mean you should relaunch the team.
Check project-seat readiness with `rig ps --nodes --rig starter` and resolve any authentication, trust or permission prompt before assigning work. If a seat stopped at such a prompt before its startup context arrived, `rig ps` shows the `rig seat continue <seat>` command that delivers it once the prompt is answered. Then give the builder one bounded outcome from your repository:
```bash
rig send dev-build@starter 'Implement <one useful change>. Track the task in the queue and return its ID. Keep it local, verify the behavior, ask dev-review in this rig to check the exact candidate, and record the result and how I can try it.'
rig queue list --destination dev-build@starter --limit 1000
```
Sending a message does not itself create a queue item; the builder records the task. Read the final artifact and the review of its exact candidate, then return to the same builder for the next change. [The guided first-use path](docs/reference/getting-started.md) covers readiness, a useful task, a reviewed result, Herdr/cmux terminals and recovery.
Not setting this up today? Get the next walkthrough and occasional OpenRig updates → https://openrig.dev/follow
## Community
- **Questions:** [Discussions › Q&A](https://github.com/mvschwarz/openrig/discussions/categories/q-a)
- **Bugs and feature requests:** [open an issue](https://github.com/mvschwarz/openrig/issues/new/choose)
- **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security policy](SECURITY.md) · [Getting help](.github/SUPPORT.md)
- **Working on OpenRig with a coding agent?** Start with the `developing-openrig` skill in `.agents/skills/` (Claude Code: `.claude/skills/`) and [CONTRIBUTING.md](CONTRIBUTING.md).
- **Videos:** [youtube.com/@openrig](https://www.youtube.com/@openrig)
- **Releases:** [GitHub Releases](https://github.com/mvschwarz/openrig/releases) and npm `@openrig/cli`
We aim to acknowledge issues and pull requests within one day; see [CONTRIBUTING.md](CONTRIBUTING.md#what-to-expect-from-us) for review targets.
## What OpenRig changes on your machine
OpenRig writes instance state, provider integration and workspace files as part
of setup and operation. These include **trust settings and executable hooks**.
The summary below follows this source revision; check `rig --version` when
using a published package, since repository guidance can be ahead of npm.
| When | What changes and why |
| --- | --- |
| **npm installation** | Installs the CLI (`rig` and `openrig-tui`), bundled components and dependencies under your npm prefix (with Bun, under Bun's global directory). OpenRig's postinstall checks the Node.js version and that the SQLite module loads; Bun may block this script. It does not run daemon or provider setup. |
| **`rig setup`** | Attempts missing tools and writes an OpenRig block in `~/.tmux.conf` for mouse support and scrollback. Installs herdr by default on macOS and Linux unless declined with `--no-herdr`; an unavailable herdr install is a warning. On macOS the installing agent offers Ghostty, and `--ghostty` attempts it after acceptance. Existing cmux settings are left unchanged. `--full` adds workstation tools. `--dry-run` shows setup's plan without applying it. |
| **Daemon startup** | Creates/updates instance state under `OPENRIG_HOME` (normally `~/.openrig`), including its database and managed plugin resources. Seeds the `openrig-skills` discovery skill, the `refocusing` skill and the person-facing `rigs` skill in `~/.claude/skills` and `~/.agents/skills`, subject to existing version ownership. When a Codex seat is already recorded and `runtime.codex.hooks_enabled` is enabled (the default), ensures its hook configuration and trust records as described below. With no recorded Codex seat, enabled hooks wait for its first launch; disabled hooks still remove a stale OpenRig-managed hook block, if present. |
| **Rig/seat launch and attachment** | Creates tmux sessions, supplies seat identity and daemon connection environment, and projects selected guidance, skills, plugins and runtime resources into the workspace. Managed startup pre-trusts the workspace. Claude context collection can also be provisioned for attached sessions and refreshed during monitoring. In a Git repository, newly created files under `.codex/plugins/shared:openrig-core/` (or the unqualified `.codex/plugins/openrig-core/`) are added to the repository's Git `info/exclude` inside an `# BEGIN OpenRig generated files` block; new `AGENTS.md`, `CLAUDE.md` and `CLAUDE.local.md` files stay visible with a warning. |
| **Bundle install** (`rig bundle install`, or `rig up` with a `.rigbundle` or GitHub link) | Writes the bundle's files into the install target: `--target`, or the current directory for `rig up` and for a GitHub link. `rig bundle install` with a local archive needs `--target`. Routes its declared skills, plugins and context packs into your libraries. A GitHub link's archive is kept under `OPENRIG_HOME/bundle-imports/`, and installs are recorded in `OPENRIG_HOME/bundle-audit.jsonl`. A bundle that carries a project creates it under `workspace.projects_root` and records it, with the rig's association, in the workspace catalog. |
| **Explicit permission configuration** | The built-in bootstrap does **not** add `rig` command allow rules to your settings files; a team seat with no permission policy, per-seat choice or (for Codex) named profile gets the per-launch [team default](docs/reference/rig-spec.md#team-launch-defaults) instead. Agent-guided setup recommends keeping it and requires your actual answer before the agent [adds remembered rules at your chosen scope](docs/reference/getting-started.md#have-your-agent-configure-permissions). No/no answer keeps the team default and preserves settings; existing choices and stricter rules remain relevant. Broader access is separate. |
The provider files are separate from instance state. Here `~` means the daemon
user's home; changing `OPENRIG_HOME` alone does not isolate provider configuration.
- **Claude Code:** startup writes workspace trust and onboarding completion.
With an explicit permission mode, it uses the launch-selected `HOME/.claude.json`,
or `<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
writes `HOME/.claude.json`, and also `<CLAUDE_CONFIG_DIR>/.claude.json` when the
daemon has that variable set. In the workspace, `.claude/settings.local.json` receives
the context collector's `statusLine` command and selected activity hooks;
a user's own status-line command is preserved, so it can prevent collector
installation. Selected settings resources can still replace overlapping keys.
The collector script lives under `.openrig/`, and the activity-hook relay under
the instance's `state/claude-activity-hooks/`. Selected settings/MCP resources can also
change that settings file and `.mcp.json`. The shared settings resource enables
Exa/Context7 MCP entries and leaves existing `permissions.defaultMode` and permission
rules unchanged. Managed launch flags select the session mode. Selected MCP resources
configure those external services. Built-in bootstrap
no longer writes a command allowlist to `~/.claude/settings.json` or removes
older allowances. `CLAUDE_CONFIG_DIR` does not relocate the project-local writes.
To keep your own status line alongside the collector, see
[Custom Claude status line](docs/reference/claude-status-line.md).
- **Codex:** writes the daemon's `CODEX_HOME/config.toml` (normally
`~/.codex/config.toml`). With hooks enabled, daemon startup ensures the OpenRig
activity relay commands and their trust hashes when a Codex seat is already
recorded; otherwise the first Codex launch does so. With hooks disabled, startup
can remove a stale OpenRig-managed hook block even without a Codex seat. Seat startup adds
`trust_level = "trusted"` for the workspace; selected config resources can
add MCP settings. Managed launches pass `-c check_for_update_on_startup=false`,
which writes no config; a recognized update notice that still appears can be
skipped, recording the skipped version in Codex's cache; this is not an update install.
Activity relays send event type/subtype, seat/runtime identity, timestamps and
native session identity to the configured OpenRig daemon's `/api/activity/hooks`
endpoint, using its activity token. That payload excludes prompt text and tool
arguments. Claude's collector writes context/token usage, session/transcript-path
metadata and available rate-limit data to the instance's `state/context-usage`
and `state/provider-usage`. Provider and selected MCP connections have their own
data flows. Daemon plugin initialization also checks the OpenRig plugin release
endpoint on GitHub.
Managed launches supply `HOME`, `CODEX_HOME` and `OPENRIG_*` identity/connection
variables. Claude uses `--permission-mode acceptEdits` and defaults to the classic
renderer for terminal scrollback. Codex uses `-s workspace-write` unless a named
profile governs its sandbox; the default does not force an approval-policy flag.
On that plain launch OpenRig first asks Codex for its own configuration and adds
`-c sandbox_workspace_write.network_access=true` only when Codex answers that no
configuration layer sets network access and no managed requirement could restrict
it, so the seat can reach the local daemon. If Codex can't be asked or doesn't
answer in time, the launch is left unchanged.
Fresh Codex launches also add writable access to the workspace's `.git` and the
pod's shared queue-state directory with `--add-dir`; the shared root comes from
`OPENRIG_SHARED_DOCS_ROOT` or `~/.openrig/shared-docs`.
A team seat with no permission policy, per-seat choice or (for Codex) named
profile also gets the per-launch team default: Claude gets `--settings` allowing
ordinary `rig` commands, project reads and common tests, with a session-only
PreToolUse hook that asks before lifecycle commands, and Codex also gets the
OpenRig workspace root as a writable directory. Neither writes a permission file.
Seats of the rig named `kernel` launch with an operational default instead,
unless a permission policy, a per-seat choice or (for Codex) a named profile
applies: Claude in `acceptEdits` with a per-launch allow list for its file tools,
web fetches and operational commands (including reads under your home folder),
and Codex with `-s danger-full-access -a never`. Neither writes a permission file.
YOLO is **off by default**. An explicitly selected full-bypass policy selects
Claude's `--dangerously-skip-permissions` or Codex's
`-s danger-full-access -a never`. The legacy environment-only `OPENRIG_YOLO=1`
path, with no policy attached, selects Claude's bypass flag, Pi's `--approve` and
Codex's `-s danger-full-access` without `-a never`; a resolved policy overrides
that environment setting. The `builtin:auto` policy launches Claude with
`--permission-mode auto`; Codex and Pi seats launch as they do without a policy (a
named Codex profile still governs its sandbox, and Pi keeps its configured
resource trust).
`--non-interruptive` on `rig up` or `rig bundle install` additionally passes
per-launch flags to full-bypass seats so Claude's bypass warning and Codex's
full-access and GPT-5.1 migration notices don't stop startup; it writes nothing to
their settings files and is saved on the rig. See
[non-interruptive mode](docs/reference/non-interruptive-mode.md).
Permission mode controls native execution permissions; work posture is separate
project guidance. Use `rig policy permissions list|show|current|apply` for rig
policy configuration (the four `rig policy` aliases remain compatible). Use
`rig seat set-permissions <seat> --mode <mode> --reason <text>` for an audited
future-launch choice: `floor`, `full_bypass`, or `inherit` to clear the seat
override. Additional Claude modes such as `auto` require support from the exact
managed Claude executable at the seat's working directory; selection and launch
each check it. Unsupported or changed contexts refuse without a fallback.
This does not relaunch the seat
or change its current native process, history, rules or hooks. `rig seat status`
separates the desired selection from the last launch arguments; neither proves
native enforcement. See the [permission guide](docs/reference/getting-started.md#per-seat-permission-mode).
Managed hook blocks target OpenRig's entries and retain unrelated hooks, but
trust entries and selected resource keys can be replaced. Claude's collector,
activity-hook and JSON resource writers leave unreadable or malformed settings
untouched; this is not a product-wide preservation guarantee. Codex's workspace
trust writer can treat an unreadable `config.toml` as empty and write a new trust
configuration. Read-modify-write operations can also race other writers; there
is no complete preservation or rollback guarantee. Back up relevant
files before first use. Daemon/bootstrap writes are automatic and do not each
have an interactive preview; `rig setup --dry-run` does not preview every later
startup effect.
> From here, give the intake agent a packet of work and watch it move through planning, build, review, and closeout.
## What It Does
@@ -320,86 +44,75 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
- **Define** topologies in YAML (RigSpec) with pods, edges, and continuity policies
- **Boot** everything with `rig up` — tmux sessions, harnesses, startup files, readiness checks
- **See** rigs, pods, and seats in the TUI topology table and graph; inspect projects, specs, feeds, and instance health
- **See** the topology in a live graph with explorer, node detail, and system log
- **Discover** existing Claude Code and Codex sessions in tmux and adopt them into a managed rig
- **Snapshot** the topology with `rig down --snapshot`, restore by name with `rig up <name>`
- **Snapshot** the full topology on `rig down`, restore by name with `rig up <name>`
- **Communicate** across agents with `rig send`, `rig broadcast`, and `rig chatroom`
- **Protect** a seat where you type by hand: `rig seat set-typing-guard <seat> --enabled true --reason <text>` holds automatic messages and wakes instead of typing them into that seat (off by default; see `rig seat set-typing-guard --help`)
- **Connect** Slack through an app you create in your own workspace; the experimental `rig slack manifest` prints that app's manifest ([setup guide](docs/reference/slack-app-setup.md))
- **Evolve** running topologies with `rig grow`, `rig shrink`, `rig launch`, `rig remove`
- **Evolve** running topologies with `rig expand`, `rig shrink`, `rig launch`, `rig remove`
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
## Teams
## Starter Rigs
Three teams grow with you, and the kernel operator helps you pick one:
- **`starter`**: a builder and a reviewer for one bounded change.
- **`workshop`**: a lead, a builder, QA and a reviewer for ongoing work in one repository. It's a rig bundle, installed from its pinned listing.
- **`factory`**: seven agents (a lead, an advisor, build, QA, design and two independent reviewers) for sustained product work. You bring ideas to the advisor, and the lead runs the team.
OpenRig ships with a conveyor starter rig for learning workflow handoff:
```bash
rig specs preview factory --kind rig
rig up factory
rig specs preview conveyor --kind rig
```
Specialist teams: `code-review` (two independent reviews), `research` (an analyst and a synthesizer) and `pm` (a product lead, a researcher and a builder for prototypes).
```
conveyor (rig, pod_aware)
Starter workflow rig: a station pipeline that can move multiple
work packets at once, with queue depth acting as natural backpressure.
Also built in: `secrets-manager`, a HashiCorp Vault instance run by a specialist agent.
Pod: intake (1 member)
lead — claude-code
Pod: plan (1 member)
planner — codex
Pod: build (1 member)
builder — claude-code
Pod: review (1 member)
reviewer — codex
```
Browse the library:
For a larger human-operated product-development topology, inspect `product-team`:
```bash
rig specs ls
rig specs preview product-team
```
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
Browse the library: `rig specs ls`
## How It Works
OpenRig is a local daemon + CLI + terminal UI + MCP server, built on tmux. OpenRig itself is under active development; only the older React web UI is in maintenance mode, with best-effort support.
OpenRig is a local daemon + CLI + MCP server + React UI, built on tmux.
```
CLI / TUI / MCP
CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services
Domain services (52)
|
SQLite + tmux + runtime adapters
```
- **CLI**: Commands for both humans and agents to launch teams, inspect state, send messages, track owned work, and manage context.
- **TUI**: Topology explorer, table and graph views, seat details, Specs, Projects, Terminals, Feed, and System. Navigate with the keyboard, mouse, or command bar.
- **MCP**: Tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
- **Runtimes**: Native Claude Code and Codex sessions, terminal nodes, and Pi and Oh My Pi via RPC runners.
## Terminal UI and Workspaces
The TUI shows the team's coordination state; herdr and cmux show the actual agent terminals alongside it. Use `rig tui commands` to list the TUI's command-bar navigation, or [try the interactive TUI tour](https://openrig.dev/tour/workspace).
![OpenRig TUI topology graph showing seven agent seats grouped into product, development, and QA pods](assets/ui/screenshots/tui-topology.png)
*Captured from the interactive TUI demo using fictional project data.*
With herdr installed and connected, open the starter's terminals together:
```bash
rig terminal open starter --provider herdr
```
For cmux, use `--provider cmux`. In the TUI, a rig's detail view has an `Open terminals ▸ rig <name>` link that opens a terminal view of the running seats of that rig (in a new window or tab, or the current Herdr session), laid out with herdr when installed, otherwise plain tmux; with herdr that is up to 16 seats per tab, in a workspace named after the rig. A seat it can't attach is reported as absent or degraded. The underlying sessions remain accessible through tmux. See the [terminal workspace guide](docs/reference/getting-started.md#share-the-dashboard-and-return-to-it) for setup and returning to an existing view.
- **CLI**: 40+ commands designed for both humans and agents. Every mutating command ends with what happened, current state, and next action.
- **UI**: Explorer sidebar, topology graph with pod grouping, node detail panel, system log, chatroom.
- **MCP**: 17 tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
- **Runtimes**: Claude Code, Codex, and terminal nodes. Adapters for Pi and OpenCode in development.
## Key Concepts
The first words you meet, in plain language: [What these words mean](docs/reference/quickstart.md#what-these-words-mean).
- **RigSpec**: Declarative multi-agent harness definition in YAML. Pods, members, edges, continuity policies, culture file.
- **AgentSpec**: Reusable agent blueprint with skills, guidance, hooks, profiles, and startup contracts.
- **Seat**: A stable role and address in a rig, such as `dev-build@starter`. The conversation occupying it can change while its identity and authored context remain.
- **Pod**: A group of related seats with shared guidance and context. Each agent still has its own context window.
- **Pod**: Bounded context group. Agents in a pod share memory and can maintain each other's context.
- **Discovery**: `rig discover` fingerprints existing tmux sessions. `rig adopt` brings them under management.
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes: resumed, fresh-primed, awaiting-decision (the original conversation can't resume; choose `--fresh`), attention_required, or failed.
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines. `rig up`, `rig bundle create`, `inspect` and `install` also take a GitHub folder link, `rig bundle check` checks a folder before you share it, and `rig bundle inspect` shows what a bundle will do before you install it. See [publishing a rig bundle](docs/reference/publishing-a-rig-bundle.md).
- **Culture**: A rig's `CULTURE.md` (its `culture_file`) sets coordination norms for the group, on top of OpenRig's default culture, which every seat receives.
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes (resumed, fresh, or failed).
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines.
- **Culture**: CULTURE.md sets coordination norms for the group. Research rigs get exploratory culture. Implementation rigs get conservative, trust-but-verify culture.
## Agent-Managed Software
@@ -413,121 +126,47 @@ rig send vault-specialist@secrets-manager "Check Vault health and report status.
Requires Docker for service-backed rigs.
## Upgrading an existing instance
For an existing installation, follow the [upgrade procedure](skills/_canonical/core/openrig-upgrade/SKILL.md) and the [0.5.14 release notes](docs/releases/v0.5.14.md); notes for later releases are in [CHANGELOG.md](CHANGELOG.md). Preserve live seats during the upgrade; `rig down` is not an upgrade step. Upgrading to 0.6.0 also requires Node.js 22 or 24: see [Moving off Node 20](#moving-off-node-20) and the [0.6.0 release notes](docs/releases/v0.6.0.md).
### Moving off Node 20
OpenRig 0.6.0 supports Node.js 22 and 24 only. Its SQLite binding
(better-sqlite3 13) requires Node 22 or newer. Node 20 is no longer supported;
the install check refuses it with an explanation.
If you run OpenRig on Node 20, switch Node first, then reinstall the CLI under
the new Node (a version manager keeps a separate global package set for each
Node):
```bash
nvm install 22 # or 24; fnm or your package manager work the same way
npm install -g @openrig/cli
rig --version
```
Your existing OpenRig data stays where it is. The daemon reopens the same
database under the new binding and applies any pending migrations in place.
Restart the daemon under the new Node by following the upgrade procedure above.
### Crossing the 0.5.9 layout boundary
The migration below still applies when upgrading from a pre-0.5.9 instance.
0.5.9 makes `$OPENRIG_HOME/context` the addressable context library, writes
Claude telemetry to `state/context-usage` (and provider telemetry to
`state/provider-usage`), and installs the default System World at
`context/system/system-world.yaml`. Existing instances cross this boundary by
an **Agent-Operated Migration** from the shipped `openrig-upgrade` skill. The
target runtime reads canonical-first with legacy-fallback while new writes use
the canonical roots; a custom context-library root stays stable during
activation. This is not a directory rename to do while an old collector writes.
```bash
# SKILL_DIR is the installed openrig-upgrade skill directory.
node "$SKILL_DIR/scripts/migrate-telemetry-state-0.5.9.mjs" --help
node "$SKILL_DIR/scripts/migrate-telemetry-state-0.5.9.mjs" --home "$OPENRIG_HOME"
node "$SKILL_DIR/scripts/migrate-telemetry-state-0.5.9.mjs" --home "$OPENRIG_HOME" --apply-state --preimage /safe/path/layout-0.5.9-before
# Activate the exact target runtime separately. After every bounded legacy tail is followed by newer paired samples at both new state roots:
node "$SKILL_DIR/scripts/migrate-telemetry-state-0.5.9.mjs" --home "$OPENRIG_HOME" --verify --preimage /safe/path/layout-0.5.9-before > /safe/path/layout-0.5.9-verify.json
# Run the separately invoked non-destructive finalizer only with that exact receipt:
node "$SKILL_DIR/scripts/migrate-telemetry-state-0.5.9.mjs" --home "$OPENRIG_HOME" --apply-library --preimage /safe/path/layout-0.5.9-before --verification /safe/path/layout-0.5.9-verify.json
# Restore only helper-owned preparation/finalizer effects if the observed upgrade must be reversed:
node "$SKILL_DIR/scripts/migrate-telemetry-state-0.5.9.mjs" --home "$OPENRIG_HOME" --rollback /safe/path/layout-0.5.9-before
```
`--help` prints the phase grammar without inventorying the instance. No phase
flag intentionally runs the read-only plan; unknown options fail nonzero before
plan or mutation.
Every phase emits JSON. Stop on any issue or incomplete receipt and follow its
`next` action; do not continue from copied legacy telemetry or retry a partial
mutation blindly. Preparation leaves legacy state and collector settings in
place. Verification accepts exact tail bytes only when that same seat has newer
paired context and provider samples under `state/`; finalization revalidates the
accepted tails, copies the library without overwrite, and switches config last.
The helper never removes the legacy telemetry or library. Retirement follows
separate stable runtime, writer, reader, and recovery proof. Daemon, database,
seat, plugin, and release lifecycle actions remain agent-owned.
## Requirements
- Node.js 22 or 24 (the supported versions in this release). Node 20 is no
longer supported. Node 26 and other versions are untested. On a Mac with Apple
silicon, use Node.js 22: see the [compatibility history](docs/releases/v0.5.15.md#known-compatibility-limitation).
- Node.js 20, 22, or 24 (even-numbered LTS releases; odd releases like 25 lack native addon prebuilds)
- tmux
- macOS or Linux. On Windows, use WSL2, the Windows route OpenRig supports; native
Windows isn't supported. OpenRig's automated tests don't run on WSL2 yet; one user's
working setup is in the [getting-started guide](docs/reference/getting-started.md#wsl2-a-reported-working-setup)
Optional:
- herdr or cmux for terminal workspaces showing the agents together
- cmux for `Open CMUX` node surface controls
- Docker for service-backed rigs and managed apps
## Setup and Troubleshooting
- `rig setup` attempts core machine preparation: it installs a missing Claude Code or Codex and writes tmux defaults. It installs Herdr by default on macOS and Linux; use `--no-herdr` if you decline it. On macOS it also installs a missing tmux with an existing Homebrew; elsewhere install tmux yourself (setup only checks it). On a Mac, the installing agent offers Ghostty once; accepting uses `rig setup --ghostty`, and declining leaves Terminal.app available. Existing cmux settings are preserved. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
- `rig setup --full` attempts a broader operator workstation setup (jq, gh, via Homebrew, so macOS only) on top of core.
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes. It shares setup's local provider login checks. These do not prove provider acceptance or a working agent; also inspect `rig ps --nodes --rig <rig>`.
- `rig setup` attempts core machine preparation: tmux, cmux, Claude Code, Codex, and tmux defaults. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
- `rig setup --full` attempts a broader operator workstation setup (jq, gh) on top of core.
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes.
Both commands support `--json` for agent-driven workflows.
Before setup or managed launch, review [what OpenRig changes on your machine](#what-openrig-changes-on-your-machine), including provider trust, hooks and selected runtime resources.
Managed runtime boot (during `rig up`) may modify runtime config for core bootstrap and spec-selected runtime resources. `rig setup` discloses these paths so agents know what may be changed:
- global Claude: `~/.claude/settings.json` for minimal OpenRig command allowlisting
- global Claude state: `~/.claude.json` for managed workspace trust and onboarding completion
- project Claude: `.claude/settings.local.json` for context collector/activity hooks and selected `claude_settings_fragment` resources
- project Claude MCP: `.mcp.json` for selected `claude_mcp_fragment` resources
- global Codex: `~/.codex/config.toml` for workspace trust and selected `codex_config_fragment` resources
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** First ask which available provider(s) the user wants, reusing an explicit choice, and pass it to setup (`rig setup --providers claude`, `codex` or `both`). Recommend the working account they already have and follow the selected-provider guide above. Offer core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) only when they want those additional tools. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues. Starting OpenRig and opening its view are the last steps of installing, not extras, so do them without stopping to ask whether to go on: start the daemon if it is stopped, open the OpenRig TUI and operator for them (say so, then run `rig terminal open saved:kernel --window --json`; no commands for the person to copy), and hand the person's goal and project folder to the kernel operator ([installing-agent handoff](docs/reference/getting-started.md#installing-agent-handoff)). Installation is finished when the person is talking to the operator; don't implement the project yourself. When OpenRig itself misbehaves, read [docs/reference/help.md](docs/reference/help.md) (installed agents can run `rig context get help`; the same text is at [openrig.dev/help/agents](https://www.openrig.dev/help/agents)): it covers the next step, known problems, and how to reach the team if you're still stuck.
**For agents:** Ask the user whether they want core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) before choosing the invocation. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues.
## Comparison with Claude Managed Agents
OpenRig is open source and self-hosted, with Claude Code and Codex in the same team. You operate it on your own infrastructure; the selected providers' model usage costs still apply.
Anthropic shipped Claude Managed Agents — a cloud-hosted, Claude-only runtime at $0.08/session-hour. OpenRig is the local side: open source, cross-harness, runs on your machine, costs nothing.
[Full comparison](https://openrig.dev/compare/claude-managed-agents)
## Links
- **Website**: [openrig.dev](https://openrig.dev)
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs) ([documentation index for agents](https://openrig.dev/llms.txt))
- **Blog**: [openrig.dev/blog](https://openrig.dev/blog) · [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
- **Blog**: [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs)
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
- **Videos**: [youtube.com/@openrig](https://www.youtube.com/@openrig)
- **X**: [@_feralmachine](https://twitter.com/_feralmachine)
- **Follow the project**: [openrig.dev/follow](https://openrig.dev/follow)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=mvschwarz/openrig&type=Date)](https://star-history.com/#mvschwarz/openrig&Date)
- **Twitter**: [@_feralmachine](https://twitter.com/_feralmachine)
## License
-49
View File
@@ -1,49 +0,0 @@
# Roadmap
These are things we'd welcome help with. Most came from the community, and most are small enough for one pull request.
This isn't a schedule or a promise: we release often, and an item ships when a good change for it lands.
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.10
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
1. Pick an item and comment on its issue to say you're working on it and coordinate with others. A maintainer can assign
you to make that work visible. If it has no issue yet, open one first.
2. Read `CONTRIBUTING.md`, and ask your agent to use the `developing-openrig` skill. It's a map of how OpenRig is
built, tested and changed.
3. Open a pull request that says what a user gets and how you verified it.
If more than one pull request arrives for the same item, we take the best one, and the first equivalent one breaks a
tie. Feel free to coordinate on the issue first.
## Testing
- **Behavioural scenarios for more command families.** `docs/as-built/test-layers.md` has a "Help wanted" list of
`rig` command families that have no scenario yet, each with a suggested first check. One family per pull request is
ideal.
## Agents and runtimes
- **Operator recovery for Pi and Oh My Pi seats** (#41).
- **More harnesses:** we're working out one way for any coding-agent CLI to plug into OpenRig. More on this soon
(design discussion: #43).
## Slack
- **A DM or private channel for single-user setups** (#313).
## Reliability and everyday use
- **Draft-aware delivery** (#48): automated sends wait while someone is typing in a seat, or a seat can be set to
inbox-only.
- **A host-aware resource budget for seats** (#80).
- **Predictable `rig up` over a stopped rig with the same name.**
- **Codex seats that leave a repository's tracked `AGENTS.md` alone** (related: #64).
- **Install and run on Node 26.**
Bugs aren't listed here. Please file them as issues; anything labelled `good first issue` is a good place to start.
-55
View File
@@ -1,55 +0,0 @@
# Security policy
## Supported versions
Only the latest published version on npm (`@openrig/cli`) is supported. Fixes are developed on
`main` and released after verification; a merged fix is not necessarily in the published package.
Include the affected version even when reporting a problem in an older installation.
## What OpenRig touches
OpenRig runs a local daemon, drives coding agents in tmux sessions on your machine, and writes
configuration for those harnesses, including workspace trust records and executable hooks: for
example `~/.claude.json`, skills under `~/.claude`, `$CODEX_HOME/config.toml` (default `~/.codex`),
and, in the project, `.claude/settings.local.json`, `.mcp.json` and managed blocks in `CLAUDE.md`,
`CLAUDE.local.md` or `AGENTS.md`. `rig setup` also edits `~/.tmux.conf` and, on macOS, cmux's
settings. OpenRig's own state lives under `$OPENRIG_HOME` (default `~/.openrig`). The README
section [What OpenRig changes on your machine](README.md#what-openrig-changes-on-your-machine)
describes these effects and the trust/permission choices. Unexpected access, disclosure or
permission changes are useful reports; include what you expected and what you observed.
## Reporting a vulnerability
Please do not open a public issue for a vulnerability.
Open the repository's [Security page](https://github.com/mvschwarz/openrig/security). If
**Report a vulnerability** is available, use it to send a private report to the maintainers.
The reporter and authorized advisory collaborators can also see that report; it is not public.
Availability depends on the repository setting, not on this file being present.
If that option is unavailable, ask in [Discussions Q&A](https://github.com/mvschwarz/openrig/discussions/categories/q-a)
for a private security contact. Post only the request for a channel: do not include the
vulnerability details, reproduction, logs, credentials or affected private systems in public.
Wait for the private channel before sharing the report.
Include: the OpenRig version, the harnesses involved, the steps to reproduce, and what an attacker
could do. A proof of concept is welcome; a working exploit against a third party's machine is not.
## What to expect
- We will acknowledge the report and coordinate assessment and next steps privately.
- Assessment and fix timing depend on impact and reproduction; no fixed deadline is promised.
- We will agree on disclosure and credit with you. You can ask to remain anonymous.
## Scope notes
- OpenRig is designed for your own machine or a trusted private network, for you and people you trust, not for the
open internet. We assess reports by their practical impact on a user in that setup. Exposing an install to the open
internet isn't a supported deployment. OpenRig's own gateway integrations with outside services, such as Slack, and
anything that lets an untrusted party reach the daemon or an agent are in scope.
- OpenRig assumes the machine and the accounts it runs under are trusted by their owner. Reports
that require a hostile local user with the same account are still welcome but are unlikely to be
treated as high severity.
- A harness carrying out an intentionally authorized operation is not by itself evidence of an
OpenRig vulnerability. Reports of OpenRig bypassing a chosen permission, changing trust
unexpectedly, or routing data or actions to the wrong destination are in scope.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

@@ -2,8 +2,8 @@
Source capture:
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
- host-forwarded UI: `http://127.0.0.1:<host-port>`
- guest daemon: `<guest-ip>:<guest-port>`
- host-forwarded UI: `http://127.0.0.1:17439`
- guest daemon: `192.168.64.9:7433`
Purpose:
- README walkthrough
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

-30
View File
@@ -1,30 +0,0 @@
{
"url": "https://context7.com/mvschwarz/openrig",
"public_key": "pk_HcRwWEoFBsmU1mo5VIvWw",
"projectTitle": "OpenRig",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"folders": [],
"excludeFolders": [
"docs/releases",
"packages/daemon/specs",
"packages/daemon/test",
"packages/test-system",
"skills/_canonical/process",
"skills/_canonical/pm",
"spike",
"docker",
"**/node_modules",
"**/fixtures"
],
"excludeFiles": [],
"rules": [
"Install with npm install -g @openrig/cli. OpenRig requires Node.js 22 or 24, tmux, and Claude Code or Codex signed in. The starter team uses both, and the kernel operator adapts it when you have only one.",
"Preview before launching: rig specs preview <spec>, then rig up <spec> --cwd . --plan, then rig up <spec> --cwd .",
"Check seat readiness with rig ps --nodes --rig <rig>, not only daemon health from rig status.",
"Inside a rig, an agent runs rig whoami --json first and treats its output as its identity.",
"rig send <session> \"message\" types into a peer's terminal and presses Enter; rig capture <session> reads that peer's screen.",
"Work another agent must act on goes in the queue (rig queue create or rig queue handoff); a message informs but does not assign work.",
"Use rig start to bring rigs back after a reboot or daemon restart.",
"Run rig --help for the current command surface; the CLI is authoritative over older examples."
]
}
+2 -2
View File
@@ -80,7 +80,7 @@ This produces automated proof artifacts in `demo/proof/`:
| Artifact | Source | Type |
|----------|--------|------|
| `up-transcript.txt` | `rig up demo/rig.yaml` output | Automatic |
| `ps-nodes.txt` | `rig ps --nodes --rig <rig>` after boot | Automatic |
| `ps-nodes.txt` | `rig ps --nodes` after boot | Automatic |
| `health-after-boot.json` | `check-demo-health.ts` after boot | Automatic |
| `native-resume-after-boot.txt` | immediate native probe after boot | Automatic |
| `native-resume-after-boot.json` | immediate native probe machine output | Automatic |
@@ -91,7 +91,7 @@ This produces automated proof artifacts in `demo/proof/`:
| `down-transcript.txt` | `rig down` output | Automatic |
| `tmux-check.txt` | `tmux ls` after teardown | Automatic |
| `restore-transcript.txt` | `rig restore <snapshotId> --rig <rigId>` output | Automatic |
| `ps-restored.txt` | `rig ps --nodes --rig <rig>` after restore | Automatic |
| `ps-restored.txt` | `rig ps --nodes` after restore | Automatic |
| `browser-screenshot.png` | Explorer + Graph + Detail Panel | **Manual** |
| `resume-test.txt` | Post-restore agent context check | **Manual** |
+2 -3
View File
@@ -35,8 +35,7 @@ echo ""
# 2. Node status
echo "Step 2: Node status after boot..."
# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session.
rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
echo ""
# 3. Health check after boot
@@ -96,7 +95,7 @@ echo ""
# 10. Node status after restore
echo "Step 10: Node status after restore..."
rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
echo ""
echo "=== Automated proof artifacts produced ==="
+2 -3
View File
@@ -32,8 +32,7 @@ fi
# Show node status
echo ""
echo "Node status:"
# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session.
rig ps --nodes --rig "$RIG_ID"
rig ps --nodes
# Establish a restore-safe baseline for the demo rig.
echo ""
@@ -56,6 +55,6 @@ echo "=== Demo topology is running ==="
echo "Dashboard: http://localhost:5173"
echo ""
echo "Next steps:"
echo " rig ps --nodes --rig '$RIG_ID' # Check node status"
echo " rig ps --nodes # Check node status"
echo " rig down $RIG_ID # Tear down (auto-snapshots)"
echo " rig restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
-69
View File
@@ -1,69 +0,0 @@
# 51-04 testbed image (openrig-testbed) — see IMPL-PLAN §1. Built HOST-side by
# scripts/build-testbed-image.sh, which resolves + records the base digest, pins node, stages the
# stub assets, and COPYs the local openrig pack tarball. Byte-reproducible BY CONTRACT: the base is
# digest-pinned (the verb refuses a tag-floating reference), node is pinned, and openrig comes from
# the tree — never the npm registry (0.5.1 is unreleased). Layers are ordered stable -> volatile.
# --- base: a digest-pinned LTS-slim Linux, supplied by the build verb (docker/testbed/base-image,
# which REFUSES a non-@sha256 reference). No floating tag is baked into this file. ---
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
# --- layer 1: userland + tmux + git + PTY/process essentials + the native-build toolchain (most stable) ---
# python3/make/g++ are REQUIRED, not optional: the sealed Q2 packaging ruling builds better-sqlite3
# FRESH ON TARGET (never a prebuilt/nested binary — that would risk a wrong-arch native module). Its
# install is `prebuild-install || node-gyp rebuild`; with a self-contained tarball npm reaches that
# step, prebuild-install is absent, and node-gyp needs python3+make+g++ to compile from source. Without
# them layer 3 dies ('prebuild-install: not found' → 'Could not find any Python installation'). This is
# testbed-image scope only (no multi-stage gold-plating — the image is a throwaway test target).
RUN set -eux; \
apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils git tmux tini procps \
python3 make g++; \
rm -rf /var/lib/apt/lists/*
# --- layer 2: pinned node (an in-range engines version — see scripts/check-engines) ---
ARG NODE_VERSION=22.23.4
ARG TARGETARCH
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) NODE_ARCH=x64 ;; \
arm64) NODE_ARCH=arm64 ;; \
"") echo "TARGETARCH is empty — the legacy (non-BuildKit) builder does not populate it. Pass --build-arg TARGETARCH explicitly (build-testbed-image.sh does) or enable BuildKit. REFUSING to default to amd64, which would install wrong-arch Node." >&2; exit 1 ;; \
*) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
esac; \
NODE_TARBALL="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; \
curl -fsSLo "/tmp/${NODE_TARBALL}" \
"https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TARBALL}"; \
curl -fsSLo /tmp/SHASUMS256.txt \
"https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt"; \
(cd /tmp && awk -v tarball="${NODE_TARBALL}" '$2 == tarball' SHASUMS256.txt | sha256sum -c -); \
tar -xJf "/tmp/${NODE_TARBALL}" -C /usr/local --strip-components=1 --no-same-owner; \
rm -f "/tmp/${NODE_TARBALL}" /tmp/SHASUMS256.txt; \
node --version; npm --version
# --- layer 3: the OpenRig package from the LOCAL pack tarball, never the npm registry ---
ARG OPENRIG_TARBALL
COPY ${OPENRIG_TARBALL} /tmp/openrig.tgz
RUN set -eux; \
npm install -g /tmp/openrig.tgz; \
rm -f /tmp/openrig.tgz; \
rig --version; \
rig --help
# --- layer 4: stub runtime assets (zero-token by construction; staged by the build verb into the
# build context as ./stub-assets/, hashed into the manifest via deriveStubAssetsHash) ---
COPY stub-assets/ /opt/openrig-testbed/stub-assets/
# --- layer 5: a non-root openrig user + a tini entrypoint into tmux-server-friendly init ---
RUN set -eux; \
useradd --create-home --shell /bin/bash openrig; \
mkdir -p /opt/openrig-testbed; \
chown -R openrig:openrig /opt/openrig-testbed
COPY entrypoint.sh /opt/openrig-testbed/entrypoint.sh
RUN chmod 0755 /opt/openrig-testbed/entrypoint.sh
USER openrig
WORKDIR /home/openrig
ENTRYPOINT ["/usr/bin/tini", "--", "/opt/openrig-testbed/entrypoint.sh"]
CMD ["sleep", "infinity"]
-8
View File
@@ -1,8 +0,0 @@
# Thin CI layer over the EXISTING installed-package testbed. No credentials,
# host mounts, provider binaries or additional package installation.
ARG TESTBED_IMAGE
FROM ${TESTBED_IMAGE}
COPY runner.mjs /opt/openrig-testbed/runner.mjs
COPY scenarios/ /opt/openrig-testbed/scenarios/
COPY library/ /opt/openrig-testbed/library/
CMD ["node", "/opt/openrig-testbed/runner.mjs", "healthy"]
-4
View File
@@ -1,4 +0,0 @@
# Docker Hub library/debian bookworm-slim OCI index, resolved 2026-09-29.
# Registry response bytes SHA256 verified against Docker-Content-Digest.
# amd64 and arm64 are present; image execution is verified in CI, not by this pin.
debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# 51-04 testbed entrypoint — runs under tini (PID1 reaping, layer 5). Keeps the container init
# tmux-server-friendly (tini reaps the tmux server's children) and seeds the per-container
# self-host identity when the operator supplies one (plan §3 — the 51-09 OPENRIG_SELF_HOST_ID
# adopt path; N containers boot as N distinct named hosts). Then execs the container command.
set -euo pipefail
if [ -n "${OPENRIG_SELF_HOST_ID:-}" ]; then
# Announce the adopted self-host id so `docker logs` shows which simulated host this is.
echo "[testbed] self-host-id=${OPENRIG_SELF_HOST_ID}" >&2
fi
exec "$@"
@@ -1,54 +0,0 @@
# L0 — resolve the host-side build inputs (prerequisite to every build)
**Runs host-side.** Populates the two documented slots the build verb refuses-until-resolved, so the
image is byte-reproducible against a pinned base + a known stub census. Do this once per base/stub
change; commit the resolved slots so the pin is durable.
## L0.1 — resolve the digest-pinned base
The VM seat cannot pull/inspect a registry digest; do it on the host with a runtime present.
```bash
# Pull the intended LTS-slim base, then read its immutable RepoDigest.
docker pull debian:bookworm-slim
BASE_REF="$(docker inspect --format '{{index .RepoDigests 0}}' debian:bookworm-slim)"
echo "${BASE_REF}" # e.g. debian@sha256:<64-hex> (or docker.io/library/debian@sha256:...)
```
Write the single resolved reference into the slot (replacing the comment block):
```bash
printf '%s\n' "${BASE_REF}" > docker/testbed/base-image
# Prove the fence accepts it (readBaseImage returns the digest, or exits non-zero):
node -e 'import("./scripts/testbed-build-inputs.mjs").then(m=>console.log(m.readBaseImage("docker/testbed/base-image")))'
```
**PASS:** `readBaseImage` prints `{ ref, name, digest: 'sha256:<64-hex>' }`. **FAIL:** any non-zero /
"tag-floating" / "unresolved" — the slot is not a valid digest pin; do not proceed to a build.
## L0.2 — finalize the stub-assets census list
The zero-token stub payload (plan §1 layer 4) is the container-local stub `rig.yaml` + its agent
fixture + `culture.md` that L3 boots. Add each repo-relative path, one per line, to
`docker/testbed/stub-assets.list` (comments/blanks ignored). Ground the exact set against L3 (the
`rig up` stub topology it settles) — do not guess; the list IS the census scope the manifest hashes.
```bash
# After populating the list, prove the census resolves over the staged set the build will stage:
node -e 'import("./scripts/testbed-build-inputs.mjs").then(m=>{
const fs=require("fs");
const files=fs.readFileSync("docker/testbed/stub-assets.list","utf8").split("\n").map(s=>s.replace(/#.*/,"").trim()).filter(Boolean);
console.log(m.deriveStubAssetsHash(".", files).receipt);
})'
```
**PASS:** the receipt lists exactly the intended stub assets, each with a 64-hex content digest.
**FAIL:** empty set / a missing file / a path escaping the root — fix the list, never a partial census.
## L0.3 — record evidence
```bash
GIT_SHA="$(git rev-parse HEAD)"; EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
{ echo "docker: $(docker --version)"; echo "base: ${BASE_REF}"; echo "stub-assets.list:"; cat docker/testbed/stub-assets.list; } > "${EVID}/L0-inputs.txt"
echo "VERDICT: PASS — base digest pinned + stub census resolved" >> "${EVID}/L0-inputs.txt"
```
@@ -1,47 +0,0 @@
# L1 — PTY allocation (docker run -t + tmux capture returns real bytes; resize propagates)
**Runs host-side, after L0 + a build.** Proves the image gives a real PTY and that tmux inside it
produces genuine captured bytes — the TUI-spike fixed-viewport pattern, reused. Never assert the
observable from memory; verdict against the captured bytes.
## Setup
```bash
GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"; EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
docker image inspect "${IMAGE}" >/dev/null # built by scripts/build-testbed-image.sh (FAIL loudly if absent)
NAME="orig-l1-${GIT_SHA:0:8}"
```
## L1.1 — allocate a PTY + start a tmux session inside
```bash
# -t allocates a PTY; the entrypoint (tini) holds the container. Detached, fixed 80x24 viewport.
docker run -d -t --name "${NAME}" "${IMAGE}"
docker exec "${NAME}" tmux new-session -d -s l1 -x 80 -y 24
docker exec "${NAME}" tmux send-keys -t l1 'printf "PTY-OK:%s\n" "$TERM"; tty' Enter
sleep 1
docker exec "${NAME}" tmux capture-pane -p -t l1 | tee "${EVID}/L1-capture.txt"
```
**PASS:** the capture contains a real `PTY-OK:` line and `tty` prints a real pts device
(`/dev/pts/...`), not "not a tty". **FAIL:** "not a tty" / empty capture.
## L1.2 — resize propagates
```bash
docker exec "${NAME}" tmux resize-window -t l1 -x 120 -y 40
docker exec "${NAME}" tmux display-message -p -t l1 '#{window_width}x#{window_height}' | tee "${EVID}/L1-resize.txt"
```
**PASS:** reports `120x40` (the resize propagated to the in-container tmux). **FAIL:** stays `80x24`.
## Teardown + evidence
```bash
docker rm -f "${NAME}" >/dev/null
{ grep -q 'PTY-OK:' "${EVID}/L1-capture.txt" && grep -q '/dev/pts/' "${EVID}/L1-capture.txt" && grep -q '120x40' "${EVID}/L1-resize.txt" \
&& echo "VERDICT: PASS — PTY allocated, real capture bytes, resize propagated" \
|| echo "VERDICT: FAIL — see L1-capture.txt / L1-resize.txt"; } | tee "${EVID}/L1-verdict.txt"
```
**Fence:** no `-v` HOME/workspace mounts; the container is disposable (fresh HOME).
@@ -1,55 +0,0 @@
# L2 — tmux server lifecycle (detach-survive, multi-pane, send-keys + capture round-trip)
**Runs host-side, after L1.** Proves the tmux server — the substrate the whole product rides —
behaves in-container: sessions survive client detach, multiple panes coexist, and a send-keys →
capture round-trip returns the exact bytes.
## Setup
```bash
GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"; EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
NAME="orig-l2-${GIT_SHA:0:8}"; docker run -d -t --name "${NAME}" "${IMAGE}"
```
## L2.1 — server survives detach
```bash
docker exec "${NAME}" tmux new-session -d -s l2 -x 80 -y 24
docker exec "${NAME}" tmux send-keys -t l2 'echo persist-marker-$$ > /tmp/l2.marker' Enter
sleep 1
# A detached session is the default with -d; confirm the server + session still list after a beat.
docker exec "${NAME}" tmux ls | tee "${EVID}/L2-sessions.txt"
docker exec "${NAME}" cat /tmp/l2.marker | tee "${EVID}/L2-marker.txt"
```
**PASS:** `tmux ls` shows `l2` still alive; the marker file exists. **FAIL:** "no server running" / no `l2`.
## L2.2 — multiple panes
```bash
docker exec "${NAME}" tmux split-window -t l2 -h
docker exec "${NAME}" tmux list-panes -t l2 -F '#{pane_index}' | tee "${EVID}/L2-panes.txt"
```
**PASS:** at least two pane indices (`0`,`1`). **FAIL:** one pane / error.
## L2.3 — send-keys + capture round-trip (exact bytes)
```bash
TOKEN="roundtrip-$(date +%s 2>/dev/null || echo fixed)-marker"
docker exec "${NAME}" tmux send-keys -t l2.0 "printf '%s\n' '${TOKEN}'" Enter
sleep 1
docker exec "${NAME}" tmux capture-pane -p -t l2.0 | tee "${EVID}/L2-roundtrip.txt"
```
**PASS:** the capture contains `${TOKEN}` verbatim (the exact bytes round-tripped through the server).
**FAIL:** token absent / garbled.
## Teardown + evidence
```bash
docker rm -f "${NAME}" >/dev/null
{ grep -q 'l2' "${EVID}/L2-sessions.txt" && [ "$(wc -l < "${EVID}/L2-panes.txt")" -ge 2 ] && grep -q "${TOKEN}" "${EVID}/L2-roundtrip.txt" \
&& echo "VERDICT: PASS — server survives detach, multi-pane, exact round-trip" \
|| echo "VERDICT: FAIL — see L2-*.txt"; } | tee "${EVID}/L2-verdict.txt"
```
@@ -1,146 +0,0 @@
# L3 — daemon-in-container (boots vs container-local sqlite; healthz on the PUBLISHED port; `rig up` settles a zero-token stub topology)
**Runs host-side, after L2.** This is the leg that exercises the stub payload — and where
`docker/testbed/stub-assets.list` is finalized (L0.2): the exact stub `rig.yaml` + agent fixture +
`culture.md` this `rig up` settles ARE the census scope. Zero tokens by construction (runtime: stub).
## THE PUBLISHED-DAEMON PROCEDURE (identical on both A/B arms)
The prior form of this runbook started a LOOPBACK-BOUND daemon and probed it through a published
port — unreachable by construction, so the probe failed on BOTH runtimes (operator A/B receipt
`Q2-AB-d121568ad-20260807-host/AB-RESULT.md`, sha256 3433e95f427828ce: Docker curl 52 / Apple curl
56). A control arm that cannot pass cannot judge the variable arm. The four coupled mechanics
below are the fix; all four are grounded at source and all four apply to BOTH arms verbatim.
**(a) BIND ADDRESS — explicit, never the default.** `packages/daemon/src/index.ts:148` reads
`OPENRIG_HOST` (alias `RIGGED_HOST`); when set, `:163` binds exactly that host; when UNSET, `:167`
defaults to `127.0.0.1` (+ tailscale when detected). A container reached through published ports
MUST set `OPENRIG_HOST=0.0.0.0`.
**(b) BEARER — required for the daemon to START on this bind, and separately PROVEN.**
`assertBindAuthInvariant` (`packages/daemon/src/middleware/auth-bearer-token.ts:240-264`)
short-circuits for loopback and tailscale binds; for any other bind it REFUSES TO START unless
`OPENRIG_AUTH_BEARER_TOKEN` is non-empty. That refusal is the product being honest — the procedure
satisfies it, never routes around it.
**VERIFIED-AT-SOURCE CORRECTION (do not "fix" the probe to match a wrong expectation):**
`/healthz` is registered directly on the app (`packages/daemon/src/server.ts:574`) and is
**UNAUTHENTICATED** — there is no global auth middleware (`app.use("*")` at `:441` only injects
deps), and `authBearerTokenMiddleware` is mounted inside exactly six route modules
(compaction, hosts, mission-control, rig-policy, sessions, transport). So the health probe does
NOT need an Authorization header and MUST NOT be scored on one. The bearer is therefore proven
separately, on a genuinely guarded surface: `/api/transport/*` guards its whole router
(`packages/daemon/src/routes/transport.ts:9`). Two probes, two distinct facts: the bind, and the
auth path.
**(c) PORT ALLOCATION — an EXPLICIT host port, never `0`.** Apple `container` 1.2.0 rejects the
ephemeral publish form (`Error: invalid publish host port range: 0`) where Docker accepts it —
a captured runtime-parity difference (operator receipt above). The procedure pins one explicit
port so both arms publish identically; ephemeral allocation is not a difference the A/B is
measuring.
**(d) PROBE — byte-identical on both arms.** Probe 1 (`/healthz`, no auth) proves the published
path reaches the daemon. Probe 2 (a guarded route WITH the bearer) proves the auth path. Both are
recorded; a runbook that records only probe 1 has not tested the bearer at all.
## Setup (both arms — only the runtime binary differs)
```bash
GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"; EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
NAME="orig-l3-${GIT_SHA:0:8}"
HOSTPORT=19433 # (c) EXPLICIT — never 0 (Apple 1.2.0 rejects ephemeral)
TESTBEARER="l3-testbed-$(date +%s)" # (b) throwaway, container-scoped, never a real credential
RUNTIME=docker # the Apple arm substitutes its CLI here; ALL ELSE IDENTICAL
"${RUNTIME}" run -d -t --name "${NAME}" \
-p "${HOSTPORT}:7433" \
-e OPENRIG_HOST=0.0.0.0 \
-e OPENRIG_AUTH_BEARER_TOKEN="${TESTBEARER}" \
-e OPENRIG_SELF_HOST_ID=testbed-l3 \
"${IMAGE}"
```
*Apple-arm note (captured, not a workaround): the loopback-qualified publish form
(`127.0.0.1:PORT:7433`) resets on Apple 1.2.0 while the unqualified `PORT:7433` works. The
unqualified form is therefore the procedure's single published form on BOTH arms — identical
input, not an Apple-only concession.*
## L3.1 — daemon boots vs a container-local sqlite + BOTH probes answer
```bash
"${RUNTIME}" exec "${NAME}" bash -lc 'rig daemon start && sleep 2 && rig status || true' | tee "${EVID}/L3-start.txt"
# PROBE 1 — the BIND, unauthenticated by design:
curl -fsS "http://127.0.0.1:${HOSTPORT}/healthz" | tee "${EVID}/L3-healthz.txt"; echo
# PROBE 2 — the AUTH path, on a genuinely guarded router:
curl -sS -o "${EVID}/L3-auth-probe.txt" -w '%{http_code}\n' \
-X POST "http://127.0.0.1:${HOSTPORT}/api/transport/send" \
-H "Authorization: Bearer ${TESTBEARER}" -H 'Content-Type: application/json' \
-d '{"session":"nonexistent@testbed","text":"auth-path probe"}' | tee "${EVID}/L3-auth-code.txt"
# NEGATIVE CONTROL — the same call with NO bearer must be refused:
curl -sS -o /dev/null -w '%{http_code}\n' \
-X POST "http://127.0.0.1:${HOSTPORT}/api/transport/send" \
-H 'Content-Type: application/json' -d '{"session":"nonexistent@testbed","text":"x"}' \
| tee "${EVID}/L3-auth-negative.txt"
"${RUNTIME}" exec "${NAME}" bash -lc 'ls -l "${OPENRIG_HOME:-$HOME/.openrig}"/*.sqlite* 2>/dev/null || find "$HOME" -name "*.sqlite*" 2>/dev/null' | tee "${EVID}/L3-db.txt"
```
**PASS:** probe 1 returns healthy JSON on the PUBLISHED port; probe 2 is NOT 401 (the bearer is
accepted — any application-level status such as an unknown-session 4xx still proves the auth path);
the negative control IS 401; a container-local sqlite exists.
**FAIL:** no healthz on the published port (bind wrong) / probe 2 401 (bearer path wrong) /
negative control NOT 401 (the guard is not guarding) / DB outside the container / a mounted real
HOME (fence breach).
## L3.2 — `rig up` settles a zero-token stub topology
Ship a minimal `runtime: stub` `rig.yaml` (+ agent fixture + culture.md) as the staged stub assets;
copy it into a container-local workspace and `rig up` it BY EXPLICIT SOURCE.
**STAGED PAYLOAD PATH — nested, and deliberately so.** The build verb stages each census entry
preserving its full REPO-RELATIVE path (`scripts/build-testbed-image.sh:51-52`:
`cp "${REPO_ROOT}/${rel}" "${CONTEXT}/stub-assets/${rel}"`), and the Dockerfile copies that context
dir wholesale (`docker/testbed/Dockerfile:52`). So in-container the trio lands at
`/opt/openrig-testbed/stub-assets/docker/testbed/stub-assets/`, NOT at the stub-assets root. That
nesting is load-bearing, not an accident: the same relative paths are what the manifest hashes into
the byte-reproducible census receipt (`build-testbed-image.sh:101`), so flattening the staging would
change the receipt bytes r1 verified. The RUNBOOK adapts to the staged reality; the staging stands.
(This exact failure was PREDICTED and scoped to this runbook —
`51-04-STUB-ASSET-TRIO-REVIEW-VERDICT-review-r1.md` sha-16 5d4a8bb1d0a1605b, "Honest forward-flag";
the note simply never reached the text until now.)
**EXPLICIT SOURCE — never bare `rig up`.** `rig up` takes a REQUIRED positional
(`packages/cli/src/commands/up.ts:77`: `.argument("<source>", "Path to a .yaml rig spec or
.rigbundle, or a library name…")`); a bare invocation exits `missing required argument 'source'`,
which is what the operator hit.
```bash
"${RUNTIME}" exec "${NAME}" bash -lc '
set -e
STAGED=/opt/openrig-testbed/stub-assets/docker/testbed/stub-assets
# Pre-flight fence: assert the payload where the build actually stages it. A staging change must
# fail LOUD here naming the path, never as a downstream "missing required argument".
[ -f "${STAGED}/rig.yaml" ] || { echo "L3.2 FAIL: no rig.yaml at ${STAGED} — staged payload moved; reconcile the runbook against scripts/build-testbed-image.sh"; ls -R /opt/openrig-testbed/stub-assets | head -40; exit 1; }
mkdir -p ~/work && cp -r "${STAGED}/." ~/work/
cd ~/work && rig up rig.yaml && sleep 3 && rig ps --json' | tee "${EVID}/L3-topology.txt"
```
**PASS:** the stub seat(s) reach a settled/ready state (zero LLM tokens consumed — `runtime: stub`).
**FAIL:** topology never settles / a non-stub runtime is invoked.
## Teardown + evidence
```bash
"${RUNTIME}" exec "${NAME}" bash -lc 'rig down || true' ; "${RUNTIME}" rm -f "${NAME}" >/dev/null
{ grep -qi 'health' "${EVID}/L3-healthz.txt" \
&& [ "$(cat "${EVID}/L3-auth-code.txt")" != "401" ] \
&& [ "$(cat "${EVID}/L3-auth-negative.txt")" = "401" ] \
&& echo "VERDICT: PASS — published bind reachable, bearer path proven, negative control refused, stub topology settles" \
|| echo "VERDICT: FAIL — see L3-*.txt"; } | tee "${EVID}/L3-verdict.txt"
```
## ACCEPTANCE GATE (non-negotiable, before any A/B rerun is requested)
Run this procedure on the **DOCKER** arm alone and prove it GREEN. A control that cannot pass is
not a control; the A/B stays blocked until the Docker baseline is green under this exact text.
Only then does the Apple arm run the identical procedure with its CLI substituted.
**Note:** the exact `rig ps` shape / ready predicate is grounded against the shipped stub adapter at
execution time — do not assert a remembered field name; read the real `--json` output.
@@ -1,51 +0,0 @@
# L4 — the 51-02 hermetic contract holds INSIDE the container (fail-closed not weakened)
**Runs host-side, after L3.** The hermeticity floor is "fresh container = fresh HOME" **by
construction** — no real-HOME/workspace mounts (verified in L1–L3 by the no-`-v` fence). This leg
proves the *active* half: the 51-02 env-helper still **REFUSES an injected foreign `OPENRIG_URL`**
inside the container. The container is not an excuse to weaken the fail-closed guard — a leaked daemon
target must hard-refuse exactly as it does host-side (the `DAEMON_TARGET_ENV_VARS` guard class).
## Claim
With a foreign `OPENRIG_URL` (a daemon target the helper did not set itself) present in the
environment, the 51-02 hermetic env prep **hard-refuses** (non-zero + a refusal naming the foreign
target) rather than silently adopting it — inside the container, same as host-side.
## Grounding note (confirm at execution — do NOT assert from memory)
Where the helper runs depends on whether the 51-02 hermetic-env module is **packaged** in the openrig
tarball the image installs (it currently lives at `packages/daemon/test/helpers/hermetic-env.ts` — a
test helper, which may not be in the pack set):
- **If packaged / resolvable in-container:** invoke the assertion directly in the container (L4.1).
- **If not packaged:** this leg is exercised via the **runner's container-execution mode (step-3)** —
see **L6.3** (`L6-container-runner-e2e.md`), which drives a run with a foreign target injected into the
base env and the host-side hermetic prep refusing before any container is created — record that as the
L4 form. Either way, the leg is real; pick the form the shipped surface supports and say which.
## L4.1 — in-container assertion (when the helper is resolvable)
```bash
GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"; EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
NAME="orig-l4-${GIT_SHA:0:8}"; docker run -d -t --name "${NAME}" "${IMAGE}"
# Inject a foreign daemon target and drive the hermetic prep; expect a LOUD non-zero refusal.
docker exec -e OPENRIG_URL='http://foreign-daemon.invalid:9999' "${NAME}" bash -lc '
node --input-type=module -e "
import { prepareHermeticEnv } from \"@openrig/daemon/test/helpers/hermetic-env.js\";
try { prepareHermeticEnv({ baseEnv: process.env }); console.log(\"NO-REFUSAL\"); process.exit(0); }
catch (e) { console.error(\"REFUSED: \" + e.message); process.exit(7); }
"' 2>&1 | tee "${EVID}/L4-refusal.txt"; RC=${PIPESTATUS[0]:-$?}
docker rm -f "${NAME}" >/dev/null
```
**PASS:** non-zero exit + `REFUSED:` naming the foreign `OPENRIG_URL`. **FAIL:** `NO-REFUSAL` /
exit 0 (the guard was weakened in-container). Adjust the import specifier to the helper's real shipped
path; if it does not resolve, switch to the step-3 form (grounding note) — do not green-pass by memory.
## Evidence
```bash
{ grep -q 'REFUSED:' "${EVID}/L4-refusal.txt" && echo "VERDICT: PASS — foreign OPENRIG_URL hard-refused inside the container" \
|| echo "VERDICT: FAIL / FORM-DEFERRED — see L4-refusal.txt + the grounding note"; } | tee "${EVID}/L4-verdict.txt"
```
@@ -1,104 +0,0 @@
# L5 — multi-host topology (N named self-hosts on one network) + the 51-09 live-leg rider
**Runs host-side, after L4.** Proves plan §3: N containers each = one simulated **named host**,
composed via the shipped **host registry over HTTP** (registry rows point at container-DNS names), one
daemon per container, container-local DB/HOME, no real-HOME mounts. The **only** shared surface is the
docker network.
**51-09 live-leg rider (orch, merge desk):** this same two-host setup is the executor for the shipped
51-09 collision/alignment runbook. After the topology settles, run BOTH 51-09 legs **in this same
host-side session** and return both evidence sets. Fixture + steps:
`packages/daemon/test/fixtures/self-host-live-legs/RUNBOOK.md` — **LEG A** (cross-host stamped-triple
round-trip: a cross-host send stores the ORIGIN host in the sender identity and the `↩ Reply:` hint
round-trips verbatim to the origin) and **LEG B** (founder-collision: a same-named rig on both hosts;
the received signature names the origin and the reply lands on the origin, not the local lookalike).
## L5.1 — boot two named self-hosts on one network
**IDENTITY IS READ, NEVER PREDICTED (PM ruling — capture-not-compose applied to identity).** The
self-host id is minted daemon-side and stored as a never-rewritten singleton; the runbook CAPTURES
each container's id from its own surface (`/healthz`, whose `selfHostId` field is emitted at
`packages/daemon/src/server.ts:589`) and then uses that CAPTURED value verbatim. It never predicts,
hardcodes, or derives an id. (`OPENRIG_SELF_HOST_ID` was NOT the knob: `docker/testbed/entrypoint.sh:8`
only ECHOES it for `docker logs`; nothing in the daemon reads it. Do not reintroduce it.)
**BIND + BEARER, carried from L3 verbatim.** L5 needs cross-container HTTP, so each daemon must be
reachable off-loopback — the same three coupled parts L3 proved, for the same reasons, with the same
never-weaken-the-guard rail: explicit `OPENRIG_HOST=0.0.0.0`, `OPENRIG_AUTH_BEARER_TOKEN` set (the
guard at `packages/daemon/src/middleware/auth-bearer-token.ts:240-264` REFUSES a non-loopback bind
without it), and an explicit published port when a host-side probe is used. Registry rows carry a
bearer POINTER (`--bearer-env` NAME), never a token value.
```bash
GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"; EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
NET="orig-net-${GIT_SHA:0:8}"; docker network create "${NET}" >/dev/null
TESTBEARER="l5-testbed-$(date +%s)" # throwaway, container-scoped, never a real credential
for h in H_A H_B; do
docker run -d -t --name "$h" --hostname "$h" --network "${NET}" \
-e OPENRIG_HOST=0.0.0.0 \
-e OPENRIG_AUTH_BEARER_TOKEN="${TESTBEARER}" \
-e OPENRIG_TESTBED_BEARER_ENV=OPENRIG_AUTH_BEARER_TOKEN \
"${IMAGE}"
done
for h in H_A H_B; do docker exec "$h" bash -lc 'rig daemon start && sleep 2 && rig status || true'; done | tee "${EVID}/L5-start.txt"
# CAPTURE each minted id from its own daemon (adopt-by-read; ids are random per boot).
ID_A="$(docker exec H_A bash -lc 'curl -fsS http://127.0.0.1:7433/healthz' | python3 -c "import json,sys; print(json.load(sys.stdin).get('selfHostId',''))")"
ID_B="$(docker exec H_B bash -lc 'curl -fsS http://127.0.0.1:7433/healthz' | python3 -c "import json,sys; print(json.load(sys.stdin).get('selfHostId',''))")"
printf 'H_A selfHostId=%s\nH_B selfHostId=%s\n' "${ID_A}" "${ID_B}" | tee "${EVID}/L5-selfids.txt"
[ -n "${ID_A}" ] && [ -n "${ID_B}" ] && [ "${ID_A}" != "${ID_B}" ] || { echo "L5.1 FAIL: missing or identical self-host ids"; exit 1; }
```
**PASS:** both containers report a NON-EMPTY, DISTINCT `selfHostId` on their own `/healthz`, and each
id is stable across a daemon restart in that container (51-09 incr 1 — the singleton is never
re-keyed). **FAIL:** an empty id / identical ids / an id that changes on restart.
*Readback note (captured product observation, NOT worked around here): `rig whoami` reports a SEAT
identity and a bare host container has no seat, so it cannot answer "which host am I". The
daemon-surface read above is the shipped substitute. The declared-identity + host-readback gap is
pooled at PM's desk (identity family); this runbook consumes what ships.*
## L5.2 — compose via the host registry over HTTP
Register `H_B` in `H_A`'s host registry pointing at the container-DNS name `H_B` (reachable on `${NET}`):
```bash
# CURRENT grammar (packages/cli/src/commands/host.ts:427-433): --id and --transport are REQUIRED;
# http transport takes --url; the bearer rides as a POINTER (env var NAME), never a value.
# The id is the CAPTURED ${ID_B} — adopt-by-read, never an authored name.
docker exec H_A bash -lc "rig host add --id '${ID_B}' --transport http --url http://H_B:7433 --bearer-env OPENRIG_AUTH_BEARER_TOKEN && rig host ls --json" | tee "${EVID}/L5-registry.txt"
```
**PASS:** `H_A`'s registry lists the CAPTURED id `${ID_B}` at the container-DNS URL; a probe reaches `H_B`'s daemon over
the network only. **FAIL:** unreachable / resolves off-network. (Ground the exact `rig host` flag shape
against the shipped CLI at execution — read `--json`, don't assert a remembered field.)
## L5.3 — execute the 51-09 live legs (the rider) in this session
Using `H_A`/`H_B` as the two hosts, follow the shipped runbook end-to-end and bank its evidence here:
```bash
# Source of truth for the exact seats/fixtures/steps + PASS predicates:
sed -n '1,200p' packages/daemon/test/fixtures/self-host-live-legs/RUNBOOK.md
# LEG A: cross-host send orch@rig-a -> dev@rig-b --host B; expect From: orch@rig-a@A + verbatim reply lands on H_A.
# LEG B: same-named rig 'shared' on both; expect From: lead@shared@A + reply lands on H_A's lead@shared.
# Capture each received pane + the stored source_session; write to L5-leg-a.txt / L5-leg-b.txt.
```
**PASS:** LEG A signature names `A` + stored `source_session == orch@rig-a@A` + verbatim reply lands on
`H_A`; LEG B signature names the origin `A` + reply lands on `H_A`'s lookalike, not `H_B`'s. Include the
51-09 fail-open control (stop `H_A`'s daemon → `From:` degrades to 2-part, no crash).
## Teardown + evidence
```bash
docker rm -f H_A H_B >/dev/null; docker network rm "${NET}" >/dev/null
{ echo "topology: 2 named hosts A/B on ${NET}, registry-composed"; echo "51-09 rider: LEG A + LEG B executed against the testbed containers"; \
echo "VERDICT: PASS|FAIL — per L5-selfids/registry/leg-a/leg-b captures (verdict on bytes, not memory)"; } | tee "${EVID}/L5-verdict.txt"
```
**Fences:** no real-HOME mounts; the docker network is the only shared surface; scenarios byte-unchanged.
**Docker-gap watch (plan §3):** if per-container network identity proves insufficient for a topology the
design needs, THAT is the named concrete gap the ruling requires before any Apple-stack revisit — route
to PM with the evidence, never install.
@@ -1,142 +0,0 @@
# L6 — the 51-02 runner drives a REAL scenario through the container (container-mode e2e) + host-mode parity
**Runs host-side, after L3** (needs the built image + a container-local daemon that boots). This is the
live proof of the 51-04 step-3 mechanism: the runner's **container-mode opt-in** stands the
scenario-local daemon up INSIDE the testbed image (by manifest identity), drives the SAME shipped
scenario through the SAME host-side reads pointed at the container's **published port**, and stamps the
image manifest id onto every results-ledger row. It also carries the **L4 hermetic fail-closed leg's
step-3 form** (the grounding note in `L4-hermetic-fail-closed.md` points here) and the **host-mode
byte-intact parity** check that closes the additive-opt-in fence behaviorally.
The step-3 pieces this exercises are unit-proven in the VM (`test/scenario-container.test.ts`,
`test/scenario-daemon-mode.test.ts`) with an INJECTED docker runner; L6 is where the REAL `docker`
invoker + a live container replace the injection — the leg no source-only proof can give.
## Topology of a container-mode run (what talks to what)
- The **daemon** runs inside the container (`rig daemon start` on the container port, container-local
HOME/DB, no mounts).
- The host-side **`rig` reads/writes** (the shipped CLI subprocesses the runner already uses) run on the
HOST against `OPENRIG_URL = http://127.0.0.1:<published port>` — the adapter self-sets that URL to its
OWN container (never a foreign target). So the host still needs the built CLI bin for the READ side.
## Prerequisites
```bash
# L0 resolved the base + stub-assets slots; the build verb produced the image + manifest.
scripts/build-testbed-image.sh # -> dist/testbed-image/manifest.json
GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"
EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
# Built HOST rig bin for the read side (the same bin run-scenarios.mjs uses).
npm run build -w packages/cli && npm run build -w packages/daemon
test -x packages/cli/dist/bin-wrapper.js || { echo "BLOCKER: build the CLI bin first"; exit 1; }
command -v docker >/dev/null || { echo "BLOCKER: docker not on PATH"; exit 1; }
```
## L6.1 — drive a real scenario through container-mode (the runner as a host-side wrapper)
The operator runs the runner in container-mode by supplying the step-3 `daemon` spawner + `imageId`.
This wiring composes only already-tested helpers (`spawnContainerDaemon`, `runScenarioFile`,
`withImageId`, `makeRealStagingDocker`). The REAL `docker` invoker is a COMMITTED helper, imported —
never re-inlined here. (The prior inline `execFile` invoker predated the `stdinFrom` tar-pipe
contract and ignored it, leaving the in-container `tar -xf -` an open never-fed stdin: the 08-12
seven-minute extraction hang, row 42576855. The committed helper feeds the pipe, checks BOTH exits,
and bounds every step with a timeout so a stall is a NAMED failure — proven hermetically in
`test/staging-docker-invoker.test.ts`. That proof covers the invoker only; live containment on a
real engine still rides THIS leg.) Run it host-side with the tsx loader:
```bash
node --import tsx - "$IMAGE" "$(jq -r .manifestDigest dist/testbed-image/manifest.json)" <<'RUN' | tee "${EVID}/L6-container.txt"
import { resolve } from "node:path";
import { runScenarioFile } from "./packages/daemon/test/helpers/scenario-pipeline.ts";
import { spawnContainerDaemon } from "./packages/daemon/test/helpers/scenario-container.ts";
import { makeRealStagingDocker } from "./packages/daemon/test/helpers/staging-docker-invoker.ts";
const [image, imageId] = process.argv.slice(2);
const RIG_BIN = resolve("packages/cli/dist/bin-wrapper.js");
const SCENARIO = resolve("packages/daemon/test/fixtures/scenarios"); // pick one committed scenario file below
// The committed real invoker: two-process tar pipe, dual-exit check, per-step timeout.
const docker = makeRealStagingDocker();
// Container-mode: inject the ScenarioDaemon-shaped container adapter + the manifest id.
const daemon = (scaffold /*, opts */) => spawnContainerDaemon(scaffold, { image, docker });
// COMMITTED scenario (the prior literal `scenario-collision.yaml` DOES NOT EXIST in the tree —
// operator-caught at the L4/L6 re-entry). L6 proves the container RUNNER, so it drives a real
// shipped scenario; override with L6_SCENARIO to run another COMMITTED file, never an improvised one.
const scenario = `${SCENARIO}/` + (process.env.L6_SCENARIO ?? "scenario-02-baton.yaml");
const baseEnv = { HOME: process.env.HOME, PATH: process.env.PATH, TERM: "xterm" };
const records = [];
const result = await runScenarioFile(scenario, {
rigBin: RIG_BIN, baseEnv, daemon, imageId,
deps: { appendRecord: (r) => records.push(r), normalizer: (_s, v) => v },
});
console.log("VERDICT:", result.verdict, "scenario:", result.scenario);
console.log("LEDGER:", JSON.stringify(records));
process.exit(result.verdict === "PASS" ? 0 : 1);
RUN
```
**PASS:** the scenario reaches its expected verdict driven against the in-container daemon, AND every
ledger row carries `imageId == manifest.manifestDigest` (grep the `LEDGER:` line). **FAIL:** the daemon
never comes up in the container / a read cannot reach the published port / a ledger row is missing the
image id. (Ground the exact expected verdict against the chosen scenario — a 51-03-dependent
cross-surface scenario is not expected green until its normalizer ships; pick a settled scenario.)
## L6.2 — host-mode parity (the additive-opt-in fence, behaviorally)
Run the IDENTICAL scenario in **host-mode** (no `daemon` override, no `imageId`) and compare verdicts:
```bash
L6_MODE=host node --import tsx packages/daemon/scripts/run-scenarios.mjs \
packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml | tee "${EVID}/L6-host.txt"
```
**PASS:** the host-mode verdict for the same scenario matches the container-mode verdict from L6.1 —
container-mode changed nothing about the host-mode path (the 51-02 contract is byte-intact; the ledger
rows differ ONLY by the added `imageId` key in container-mode). **FAIL:** host-mode verdict diverges
from pre-51-04 for the same scenario (a fence breach — that would demand a PM 51-02 gate).
## L6.3 — container-mode fail-closed (the L4 step-3 form)
Inject a FOREIGN daemon target into the base env and confirm the run HARD-REFUSES before standing any
container up (the container is not an excuse to weaken the `DAEMON_TARGET` guard — same as host-mode):
```bash
OPENRIG_URL='http://foreign-daemon.invalid:9999' node --import tsx - "$IMAGE" <<'RUN' 2>&1 | tee "${EVID}/L6-failclosed.txt"; RC=${PIPESTATUS[0]:-$?}
import { resolve } from "node:path";
import { runScenarioFile } from "./packages/daemon/test/helpers/scenario-pipeline.ts";
import { spawnContainerDaemon } from "./packages/daemon/test/helpers/scenario-container.ts";
const [image] = process.argv.slice(2);
const daemon = (scaffold) => spawnContainerDaemon(scaffold, { image, docker: async () => ({ stdout: "", stderr: "", code: 0 }) });
try {
await runScenarioFile(resolve("packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml"),
{ rigBin: resolve("packages/cli/dist/bin-wrapper.js"), baseEnv: process.env, daemon });
console.log("NO-REFUSAL"); process.exit(0);
} catch (e) { console.error("REFUSED: " + e.message); process.exit(7); }
RUN
```
**PASS:** non-zero exit + `REFUSED:` naming the foreign `OPENRIG_URL` — `prepareHermeticEnv` fail-closes
on the foreign target in `baseEnv` and NO container is created. **FAIL:** `NO-REFUSAL` / exit 0 (the
guard was bypassed in container-mode). This is the step-3 form of the L4 leg (the packaged-helper form
lives in `L4-hermetic-fail-closed.md`); either form is real — record which one the shipped surface ran.
## Teardown + evidence
Container-mode tears its own container down (the adapter's `stop` = `docker rm -f`), so a clean run
leaves nothing. Sweep any strays from a failed run, then verdict on the captured bytes:
```bash
docker ps -aq --filter "ancestor=${IMAGE}" | xargs -r docker rm -f >/dev/null
{ grep -q 'VERDICT: PASS' "${EVID}/L6-container.txt" \
&& grep -q "$(jq -r .manifestDigest dist/testbed-image/manifest.json)" "${EVID}/L6-container.txt" \
&& grep -q 'REFUSED:' "${EVID}/L6-failclosed.txt" \
&& echo "VERDICT: PASS — container-mode drives a real scenario by image identity, ledger carries the manifest id, host-mode parity holds, fail-closed refuses" \
|| echo "VERDICT: FAIL / FORM-DEFERRED — see L6-*.txt"; } | tee "${EVID}/L6-verdict.txt"
```
**Fences (binding):** the scenario YAML is byte-unchanged (51-03 fence); host-mode stays byte-intact
(L6.2 is the behavioral proof — any divergence is a PM 51-02 gate, not a fold); no real-HOME mounts;
the image is selected BY MANIFEST IDENTITY and its digest is the ledger's cross-version key.
-61
View File
@@ -1,61 +0,0 @@
# openrig-testbed verification runbooks (51-04 step-2, plan §2)
These are **host-executed, evidence-defined** runbooks — the 51-09 live-legs pattern. The VM seat has
no container runtime (locus ruling (a) host-side), so the daemon/operator lane runs them **host-side**
against the image `scripts/build-testbed-image.sh` produces, and returns evidence per the
census-receipt discipline. Each leg is a scripted check that never asserts from memory (the clause
that caught the Apple-container premise): run it, capture the real bytes, verdict against them.
## Legs
| Leg | Proves | Runbook |
|---|---|---|
| L0 | the digest-pinned base + stub-assets census are resolved (prerequisite to a build) | `L0-resolve-inputs.md` |
| L1 | PTY allocation: `docker run -t` + tmux capture-pane returns real bytes; resize propagates | `L1-pty-allocation.md` |
| L2 | tmux server lifecycle: detach-survive, multi-pane, send-keys + capture round-trip | `L2-tmux-server-lifecycle.md` |
| L3 | daemon-in-container: boots vs container-local sqlite, healthz on the published port, `rig up` settles a zero-token stub topology | `L3-daemon-in-container.md` |
| L4 | the 51-02 hermetic contract: the env-helper still REFUSES a foreign `OPENRIG_URL` **inside** the container (fail-closed not weakened by "we're in a container anyway") | `L4-hermetic-fail-closed.md` |
| L5 | multi-host: N containers as N distinct named self-hosts on one docker network, composed via the shipped host registry over HTTP (plan §3) — **and** the 51-09 live-leg rider | `L5-multi-host-and-51-09.md` |
| L6 | the 51-02 runner's **container-mode** (step-3) drives a REAL scenario through a container by manifest identity + host-mode byte-intact parity + the L4 fail-closed step-3 form; the ledger carries the image manifest id | `L6-container-runner-e2e.md` |
Sequencing: **L0 → build → L1 → L2 → L3 → L4 → L5**, with **L6 after L3** (it needs only the built image
+ an in-container daemon, plus the built HOST CLI bin for the read side). L3 is where the stub payload is
exercised (and where `docker/testbed/stub-assets.list` is finalized). L4 depends on the 51-02 hermetic
env-helper being present in the packaged image (it ships in the openrig package the Dockerfile installs);
if it is NOT packaged, its fail-closed leg is exercised in the **step-3 form** — L6.3.
**51-09 live-leg rider (orch, merge desk).** The testbed image's first multi-host run doubles as the
51-09 live-leg executor: L5 boots the containers as the two named hosts `H_A`/`H_B` and then runs the
shipped 51-09 collision/alignment runbook **in the same host-side session** —
`packages/daemon/test/fixtures/self-host-live-legs/RUNBOOK.md` (LEG A cross-host stamped-triple
round-trip, LEG B founder-collision). Execute both families together; return both evidence sets.
## Shared conventions
**Image identity.** All legs run against the image the build verb tagged, by manifest identity:
```bash
GIT_SHA="$(git rev-parse HEAD)"
IMAGE="openrig-testbed:${GIT_SHA}" # == manifest.image
```
Cross-check the running image against the emitted manifest before trusting a leg's result:
```bash
test "$(docker image inspect "${IMAGE}" --format '{{.Id}}')" # exists
cat dist/testbed-image/manifest.json # the census identity this run is comparable under
```
**Evidence dir (hashed, per the census-receipt discipline).** Each leg writes its captures + a
`VERDICT` line into a per-run evidence dir, then the operator hashes the dir so the evidence is
tamper-evident and comparable across image versions:
```bash
EVID="dist/testbed-image/evidence/${GIT_SHA}"
mkdir -p "${EVID}"
# ... leg writes L1-capture.txt, L1-resize.txt, etc. ...
( cd "${EVID}" && find . -type f -print0 | sort -z | xargs -0 sha256sum ) > "${EVID}.sha256"
```
Record in each leg's evidence: the exact command, the captured bytes, and a single
`VERDICT: PASS|FAIL — <one line>` on the observed bytes (never from memory).
**Fences (binding on every leg).** No real-HOME / real-workspace volume mounts (fresh container =
fresh HOME is the hermeticity floor); scenarios byte-unchanged; the only shared surface between
multi-host containers is the docker network. A leg that cannot run its check reports a **loud
blocker** with the exact command + the missing capability — it never green-passes from memory.
-14
View File
@@ -1,14 +0,0 @@
# openrig-testbed stub runtime assets (plan §1 layer 4: "the 51-01 stub script + its registry
# wiring — zero tokens by construction"). This file is the EXACT census scope the manifest hashes
# and the build verb stages into the image (census-scope-match-code-path — this list IS the code
# path; never a recursive walk that would over-count).
#
# One repo-relative path per line; comments (#) and blank lines are ignored.
#
# RESOLVED (2026-08-06 VM-side) — the concrete zero-token stub payload: a runtime:stub rig.yaml +
# its agent fixture + culture.md that boot a settled topology for the L3 daemon-in-container leg.
# Shape grounded on the proven 51-02 runner fixture (test/fixtures/scenarios/topo-stub-baton.yaml),
# not guessed. The base-image digest slot (docker/testbed/base-image) remains host-resolved (L0.1).
docker/testbed/stub-assets/rig.yaml
docker/testbed/stub-assets/culture.md
docker/testbed/stub-assets/agents/worker/agent.yaml
@@ -1,8 +0,0 @@
name: worker
version: "1.0.0"
resources:
skills: []
profiles:
default:
uses:
skills: []
-6
View File
@@ -1,6 +0,0 @@
# testbed-stub (51-04 container stub payload)
A minimal zero-token stub-runtime rig baked into the openrig-testbed image so the L3
daemon-in-container leg can `rig up` a settled topology without a live LLM. The single
seat runs the deterministic 51-01 stub harness. Not a product rig — it exists only to
prove the container boots a real daemon and settles a real topology headlessly.
-23
View File
@@ -1,23 +0,0 @@
# 51-04 testbed image — the container-local zero-token stub topology (plan §1 layer 4).
#
# One pod (dev) with one stub seat (worker). The stub runtime is the deterministic 51-01
# node-script harness, so this topology stands up headlessly in real tmux INSIDE the
# testbed container without a live LLM (the L3 daemon-in-container leg: `rig up` settles
# it, zero tokens by construction). Shape mirrors the proven 51-02 runner fixture
# (test/fixtures/scenarios/topo-stub-baton.yaml), which the scenario runner settles
# headlessly end-to-end — not a product rig.
#
# Canonical session of the seat = <pod>-<member>@<name> = dev-worker@testbed-stub.
version: "0.2"
name: testbed-stub
culture_file: culture.md
pods:
- id: dev
label: Dev
members:
- id: worker
agent_ref: "local:agents/worker"
profile: default
runtime: stub
cwd: .
edges: []
-105
View File
@@ -1,105 +0,0 @@
---
kind: as-built
title: As-Built Docs — Module Index
status: active
topics: [knowledge-and-context, observability]
domains: [engineering-advisor, operating-advisor, product-advisor]
applies-when: |
Starting a technical task and choosing the as-built module or source entry
point to read. This index describes the document tree; each module carries
its own source-verification stamp.
siblings: [codemap.md, arteries.md, test-layers.md, cli-reference.md, frontmatter-schema.md]
prerequisite-reads: []
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# OpenRig as-built docs
These pages describe the code and point to its owning files. This index was checked against
main commit `e8f0ab340db773392ec8be75b072d1c0f3068a50`. A linked module's
`last-verified-against-source` is its own verification boundary; updating this index does not
revalidate that module or identify a running daemon or published package.
For the repository overview and contributor recipes, start with [ARCHITECTURE.md](../../ARCHITECTURE.md).
Use [codemap.md](codemap.md) to locate source, [arteries.md](arteries.md) to identify dependent
paths before a change, and [test-layers.md](test-layers.md) to choose checks.
## Repository surfaces
The npm workspaces are `packages/daemon`, `packages/cli`, `packages/tui`, and `packages/ui`
([root package manifest](../../package.json)). The daemon provides the SQLite-backed HTTP
services; CLI, TUI and MCP clients consume them. Runtime adapters include Claude Code, Codex,
Pi, OMP, terminal and stub implementations under `packages/daemon/src/adapters/`.
The CLI package contains the assembled daemon, TUI and web UI. User reference documents ship
from `docs/reference/`; this as-built tree is developer documentation. The assembly is in
[scripts/build-package.sh](../../scripts/build-package.sh), with the published file list in
[packages/cli/package.json](../../packages/cli/package.json).
## Module index
### Daemon and runtime
| Module | Read for |
|---|---|
| [daemon-core.md](architecture/daemon-core.md) | Process startup, service wiring, database migrations and HTTP mounts. |
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | Runtime adapter methods, harness launch/resume and native-session checks. |
| [agent-spec-and-startup.md](architecture/agent-spec-and-startup.md) | Spec resolution, resource projection, startup delivery and identity. |
| [coordination-primitive.md](architecture/coordination-primitive.md) | Stream, queue, inbox/outbox, handoff and closure. |
| [workflow-runtime.md](architecture/workflow-runtime.md) | Workflow specifications, instances, projection, trails and watchdog policies. |
| [mission-control.md](architecture/mission-control.md) | Queue observations, actions and their audit records. |
| [lifecycle-snapshot-restore.md](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, restore decisions and continuity checks. |
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | Session messaging, capture, transcripts, chat and ask. |
| [workspace-primitive.md](architecture/workspace-primitive.md) | Workspace and repository declarations, project resolution and scope. |
| [content-surfaces.md](architecture/content-surfaces.md) | File reads/writes, path checks, progress indexing and steering composition. |
| [living-notes-review.md](architecture/living-notes-review.md) | Review gathering/composition, proof records, approvals, export and media. |
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | Plugin discovery, agent images, context packs and compaction policy. |
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Package assembly, reusable topology bundles and bootstrap/install paths. |
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | Cross-cutting invariants, events and compatibility notes. |
### Navigation, checks and reference
| Document | Read for |
|---|---|
| [codemap.md](codemap.md) | Question-to-module navigation and source entry points. |
| [arteries.md](arteries.md) | Code paths where changes affect several subsystems. |
| [test-layers.md](test-layers.md) | Test scope, isolation, scenarios and hosted CI. |
| [cli-reference.md](cli-reference.md) | CLI commands and options; confirm changing details in `packages/cli/src/index.ts` and command help. |
| [frontmatter-schema.md](frontmatter-schema.md) | Metadata fields used by these documents. |
| [architecture.md](architecture.md) | Compatibility pointer from the former monolithic architecture page. |
| [DESIGN.md](../DESIGN.md) | Visual and design-system specification. |
### Web UI reference
Each page below carries its own verification stamp. This index checks that the pages exist, not
their contents.
| Document | Topic |
|---|---|
| [ui.md](ui.md) | Compatibility pointer to the UI modules. |
| [shell-and-routing.md](ui/shell-and-routing.md) | Web UI shell and routing. |
| [topology.md](ui/topology.md) | Web topology views. |
| [project-and-for-you.md](ui/project-and-for-you.md) | Project and attention surfaces. |
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | Library/spec views and design-system pointers. |
For the terminal UI, start at `packages/tui/src/main.ts`, its HTTP client
`packages/tui/src/daemon-client.ts`, and the TUI sections of [test-layers.md](test-layers.md).
## Derive the document inventory
At the named commit there are **26 Markdown files**: **14** under `architecture/`, **4** under
`ui/`, and **8** at the root of this tree (including both compatibility pointers). Reproduce
these counts from the repository root:
```bash
source_commit=e8f0ab340db773392ec8be75b072d1c0f3068a50
git ls-tree -r --name-only "$source_commit" -- docs/as-built | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built/architecture | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built/ui | grep -E '\.md$' | wc -l
git ls-tree -r --name-only "$source_commit" -- docs/as-built | grep -E '^docs/as-built/[^/]+\.md$' | wc -l
```
Read a module's source stamp and prerequisite pointers before relying on it. Source code at the
named commit is the authority when prose disagrees; [frontmatter-schema.md](frontmatter-schema.md)
describes how the stamp is represented.
File diff suppressed because it is too large. Load diff
@@ -1,539 +0,0 @@
---
kind: as-built
title: Adapters and Runtimes — Claude/Codex/Pi/Stub/Terminal, tmux/cmux, Resume Honesty
status: active
topics: [agent-runtime, runtime-control]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Need the runtime-adapter contract — how OpenRig launches and resumes a
Claude Code, Codex, Pi, Oh My Pi, stub or terminal harness inside tmux, what
the five required adapter methods do, or how the daemon honestly assesses
whether a harness actually resumed vs fresh-launched (the resume-honesty
layer).
siblings: [daemon-core.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Adapters and Runtimes
How OpenRig drives the agent harnesses. Pod-aware launches go through a
`RuntimeAdapter`: five adapter classes implement one contract of five required
methods. Legacy (non-pod-aware) restore resumes through separate resume
adapters (`ClaudeResumeAdapter`, `CodexResumeAdapter`, `PiResumeAdapter` and
`OmpResumeAdapter` in `packages/daemon/src/adapters/*-resume.ts`), which are not
`RuntimeAdapter`s. 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 main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. The five-method RuntimeAdapter contract
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:143`
(`interface RuntimeAdapter`). Every adapter declares a `readonly runtime`
string (`runtime-adapter.ts:146`) and implements five required methods
(`runtime-adapter.ts:152–176`; **5** =
`sed -n '/^export interface RuntimeAdapter/,/^}/p' packages/daemon/src/domain/runtime-adapter.ts | grep -c -E '^ [a-zA-Z]+\('`):
| Method | Signature (`runtime-adapter.ts`) | Responsibility |
|---|---|---|
| `listInstalled` | `(binding)` `:153` | List currently installed/projected resources for a node. |
| `project` | `(plan, binding)` `:156` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
| `deliverStartup` | `(files, binding, sendInteractiveText?)` `:160` | Deliver resolved startup files to the runtime. The startup orchestrator passes the optional third argument for Claude Code only, so Claude's `send_text` files go through the checked send described in `agent-spec-and-startup.md`. |
| `launchHarness` | `(binding, opts)` `:170` | Launch the harness inside the bound tmux session; return a resume token. |
| `checkReady` | `(binding)` `:176` | Probe whether the harness is responsive and ready. |
The interface also has two optional members: `claudeManagedLaunch` (`:145`)
and `skillTargetPath?()` (`:150`), implemented by `PiRuntimeAdapter`
(`pi-runtime-adapter.ts:118`) and overridden by `OmpRuntimeAdapter`
(`omp-runtime-adapter.ts:12`).
Startup *action* execution (`slash_command` / `send_text`) is explicitly **not**
part of this contract — the contract docstring (`runtime-adapter.ts:137–142`)
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:172`).
Per the contract docstring (`runtime-adapter.ts:162–169`) `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:132` (`kind: "native_id" | "artifact_path" | "name" |
"last"`, `:133`; v1 MVP accepts `native_id` only — other shapes rejected at
schema validation, docstring `:122–131`, and by the Claude, Codex and Pi
adapters themselves).
### `HarnessLaunchResult` is a discriminated union with an honest failure arm
`HarnessLaunchResult` is a **discriminated union** (`runtime-adapter.ts:97–102`):
`| { ok: true; resumeToken?; resumeType?; appliedLaunch? }`
`| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence? }`.
The failure arm carries a typed `recovery` hint
(`HarnessLaunchRecovery = "retry_fresh" | "attention_required"`, `:95`) and
optional `evidence` (last-N pane lines, flowed through to
`RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
`:99–102`). This is the honest-failure shape, not a smoothed optional `error`.
Readiness has its own attention codes (`runtime-adapter.ts:74–87`):
`trust_gate`, `hook_trust_gate`, `update_gate`, `login_required`, `mcp_gate`,
`bypass_consent_gate`, `codex_auth_refusal` and `codex_client_incompatible`.
When `checkReady` reports one, the readiness wait returns at once and startup
ends `attention_required`.
### Evidence for a failed Claude resume
Previously, a failed Claude launch recorded its error without the probe code or
the captured input's identity, and a refused strict full-restore attention clear
had no corresponding refusal event. Probe-backed Claude launch failures now
carry an optional `probe` in `HarnessLaunchResult` and `node.startup_failed`.
Strict full-restore refusals emit `seat.attention_clear_refused`; a
`pane_not_usable` refusal includes that probe and the restore attempt ID.
A missing strict reconciler emits `strict_reconciler_unavailable` without
inventing an attempt ID or probe evidence, and still refuses the clear.
`native-resume-probe-evidence.ts` records the status, code, a closed-set login
refusal and row kind when matched, and the exact capture's line count, byte
count and SHA-256. Its new fields contain no captured pane text or free-form
probe detail. Existing attention evidence and recovery decisions keep their
previous behavior. MCP approval detection still takes precedence over the
login-refusal check.
Pane dimensions are optional, observed after the capture. Their read has a
250 ms deadline so a hung `listPanes` cannot indefinitely delay the failure
result or its event. On timeout, error, ambiguous pane identity or invalid
dimensions, the unavailable dimensions are `null`; late results are ignored.
The deadline bounds this diagnostic wait, not the underlying tmux command.
`native-resume-probe-evidence.test.ts` covers unavailable and late geometry;
`startup-orchestrator.test.ts` and `restore-orchestrator.test.ts` exercise the
real adapter/orchestrator and SQLite event paths with available, failing and
never-settling geometry reads. `seat-attention-reconciler.test.ts` checks that
a missing strict reconciler records its refusal without changing restore
history or sending input. These use synthetic terminal captures, not a
live Claude session. The stub scenario runtime cannot draw Claude's native
login-refusal UI, and its scenario runner does not bind the restore action.
## 2. The runtime adapters
**5** classes implement `RuntimeAdapter`
(`git grep -l 'implements RuntimeAdapter' packages/daemon/src | wc -l`):
`ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `PiRuntimeAdapter`,
`StubRuntimeAdapter` and `TerminalAdapter`. All live under
`packages/daemon/src/adapters/` (Architecture Rule 1: **0** files there import
Hono, `git grep -l 'from "hono' -- packages/daemon/src/adapters | wc -l`).
`OmpRuntimeAdapter` (Oh My Pi) extends `PiRuntimeAdapter`, so the daemon wires
**6** runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
(`startup.ts:989`, and the same map at `:1259`;
`grep 'adapters: {' packages/daemon/src/startup.ts | grep -o -E '"[a-z-]+": ' | wc -l`).
### Launch posture
A seat's launch posture is `floor`, `auto` or `full_bypass`. It comes from the
member's or rig's `permission_policy`: `builtinLaunchPosture()`
(`packages/daemon/src/domain/permission-policy/policy-ref.ts:165`) maps
`builtin:yolo` to `full_bypass`, `builtin:auto` to `auto`, and every other
built-in (`locked`, `standard`, `open`) to `floor`.
`resolvePermissionPolicyAttachment()` (`:172`) resolves the attachment, and
`rigspec-instantiator.ts` binds the result: the member attachment
(`resolveMemberPolicyAttachment`, `:1922`), then the persisted node or rig
attachment (`:2007`), then the floor, set on the binding as `launchPosture`
(`:2159`). `NativePermissionStore.apply()`
(`native-permission-store.ts:238`) then overlays a seat's explicit
`rig seat set-permissions` choice, or the kernel operational or team default
described below. `bundle-behaviour.ts` is the read-only
preview of the same mapping for an unopened bundle (see
`packaging-bootstrap-bundles.md`).
`packages/daemon/src/adapters/yolo-mode.ts` turns the posture into flags:
- **Claude Code** (`claudePostureFlag`): an explicit `permissionMode` becomes
`--permission-mode <mode>` (`:43`); `auto` becomes
`--permission-mode auto` (`:47`); `full_bypass` becomes
`--dangerously-skip-permissions`; otherwise the floor is
`--permission-mode acceptEdits` (`:50`). For a seat with no resolved
posture, the instance-wide YOLO setting `OPENRIG_YOLO=1` (`:27`; off by
default) selects the bypass; a resolved posture always wins (`:26`).
- **Codex** (`codexPostureArg`, `:62`): `full_bypass` becomes
`-s danger-full-access -a never` (`:67`); for a seat with no resolved
posture, `OPENRIG_YOLO=1` becomes `-s danger-full-access`; otherwise the
seat's `-p <profile>` or `-s workspace-write`. Codex with `auto` gets the
same: its selected profile, or the `-s workspace-write` floor without one.
**Non-interruptive mode** (`nonInterruptiveArgs()` in
`packages/daemon/src/adapters/non-interruptive.ts:12`) adds per-launch
arguments only for a `full_bypass` seat whose rig has it on
(`rigs.non_interruptive`): Claude Code gets
`--settings '{"skipDangerousModePermissionPrompt":true}'` (unless an explicit
non-bypass `permissionMode` is set), and Codex gets
`-c notice.hide_full_access_warning=true -c notice.hide_gpt5_1_migration_prompt=true`.
It never changes permissions or native settings files. `rig up
--non-interruptive` and `--no-non-interruptive` save the choice; otherwise the
rig spec's `non_interruptive`, then the setting `launch.non_interruptive`,
supplies the default (`packages/daemon/src/domain/bootstrap-orchestrator.ts:739`).
**Kernel operational default.** `NativePermissionStore.launchOverride()`
(`native-permission-store.ts:47`) gives a Claude Code or Codex seat in the
persisted rig named `kernel` operational launch arguments when the seat has no
explicit permission choice, its member and rig declare no permission policy,
and (for Codex) it has no `-p` profile (`launchDefault`, `:28`). Fresh start,
continue, restore and handover all take this decision per launch.
`operationalLaunchArgs()` (`packages/daemon/src/adapters/kernel-authority.ts:56`)
then replaces the non-interruptive arguments for that seat:
- Claude Code keeps the floor, `--permission-mode acceptEdits`, and gets
`--settings` whose `permissions.allow` is `KERNEL_CLAUDE_ALLOW`
(`:9`): `Skill`, `Read`, `Edit`, `Write`, `Glob`, `Grep`, `WebFetch`, `Bash(<command>:*)`
for 39 operational commands (`rig`, `tmux`, `node`, `npm`, `git`, `ssh`, `python3` and
others), `Bash(command -v:*)` (`:16`), two read-only provider login checks (`Bash(claude auth status:*)`,
`Bash(codex login status:*)`, `:18`), and `Read(~/**)` (`:19`), plus the `PreToolUse`
hook described under the team default. It is not
the bypass flag, and the user's own ask and deny rules still apply.
- Codex gets the `full_bypass` posture, `-s danger-full-access -a never`, plus
the two notice flags above.
It writes no settings file. If the rig lookup fails, the seat keeps its floor.
`rig seat status` reports the source as "kernel operational default"
(`kernel_default`, `native-permission-store.ts:182`).
**Team default.** Under the same conditions, a Claude Code or Codex seat in any
other rig gets `teamPermissionDefault` instead (`native-permission-store.ts:38`,
`:56`), on the same launch paths. A Claude Code seat at the floor with no native
permission mode keeps `--permission-mode acceptEdits` and gets
`--settings` (`kernel-authority.ts:64–66`):
`TEAM_CLAUDE_ALLOW` (`:33`) is `Skill`, `Read(./**)`, `Glob`, `Grep`,
`Bash(rig:*)`, eight read-only shell commands and 17 test-runner commands, and
`TEAM_CLAUDE_ASK` (`:40`) asks before 29 `rig` commands: the 23 that start, stop or
remove teams or agents (such as `up`, `down`, `seat stop`, `seat restart` and `bundle install`),
and six kept for a person: `import` (its `--instantiate` mode launches nodes), `seat
set-permissions` and `policy apply` under both spellings (an explicit permission choice),
`setup` (kept for its `--policy` path, which records one; a prefix rule can't tell that path
from ordinary setup, so ordinary setup prompts too), and `compact` (it replaces a peer's
working context). Other `rig` commands fall under the `Bash(rig:*)` allowance. The list was
42 before: 15 commands were removed and 2 added (`seat restart` and `policy permissions
apply`), 13 fewer prompts. The settings carry the allow list and a
`PreToolUse` Bash hook, `packages/daemon/assets/claude-team-permissions.cjs`, that applies
both lists (`kernel-authority.ts:43–53`): it asks before a lifecycle command unless the
command only asks for `--help` or `-h`, allows a simple literal command that matches an
allow rule, and otherwise leaves the decision to Claude (`claude-team-permissions.cjs:234`,
`:240`). Without the hook file, the settings carry both lists as plain `allow` and `ask`
rules (`kernel-authority.ts:46`). A Codex seat launching at
`-s workspace-write` gets `--add-dir` for the workspace root (`workspace.root`)
and its pod's state directory, each created first and skipped with a warning if
it is the home directory or an ancestor of it
(`packages/daemon/src/domain/codex-team-workspace.ts:18`), on fresh, fork and
resume launches (`codex-runtime-adapter.ts:378`, `codex-resume.ts:103`).
### ClaudeCodeAdapter (`claude-code-adapter.ts:55`)
- `readonly runtime = "claude-code"` (`:56`).
- **Projects** to `.claude/` targets: guidance managed blocks →
`<cwd>/CLAUDE.md` by default (`:194`, `:501`; default at
`domain/managed-blocks.ts:17`, and the rig may select `CLAUDE.local.md`
instead with `managed_blocks: { claude-code: <file> }`,
`managed-blocks.ts:15`); `skill_install` and skill resources → the seat's
skill root, `binding.seatSkillsRoot`, which is the `skills/` folder of the
seat's generated plugin in its own storage
(`domain/startup-orchestrator.ts:353–369`), or
`<cwd>/.claude/skills/<name>/` for a seat without one (`:215–216`,
`:849`); subagents → `.claude/agents`, plugins →
`.claude/plugins/<id>` (only plugins that apply to Claude), runtime
resources → `.claude/extensions/<id>` (`:584–586`); settings fragments
merged into `.claude/settings.local.json` (`:594`) and MCP fragments into
`<cwd>/.mcp.json` (`:597`).
- **Launches**: fresh = `claude <posture> --session-id <generatedId> --name
<name>` (`:329`); resume = `claude <posture> --resume <token> --name <name>`
(`:328`); fork = `claude <posture> --resume <parentId> --fork-session
--session-id <generatedId> --name <seat>`, which needs Claude Code 2.0.73 or
later. `<posture>` is the flag above plus any non-interruptive,
kernel operational or team default argument. Each command may carry a `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`
env prefix (`yolo-mode.ts:101–103`; on by default, off with
`OPENRIG_CLAUDE_DISABLE_ALTERNATE_SCREEN=0`), a `--model <model>` argument
(`:275`) and `--effort <effort>` (`:277`). A fresh launch and a fork both
return the generated `--session-id` as their `claude_id` token, so a forked
seat's process names its own conversation and strict proof can verify it.
Neither reads Claude's session files back.
- **Managed launch.** When the binding selects a native permission mode, the
command comes from `ClaudeManagedLaunch.prepare()`
(`domain/claude-managed-launch.ts:97`), which resolves `claude` on the
managed `PATH`, validates the mode against `claude --help` (5-second limit),
and launches through `/usr/bin/env -i` with only the seat's environment
(`PATH`, `HOME`, an optional `CLAUDE_CONFIG_DIR`, the OpenRig seat identity
and forwarded keys).
- **Managed bootstrap.** Before delivery the adapter marks the seat's working
directory as trusted and onboarding as completed in Claude's state file,
writing only when a flag is missing (`provisionManagedBootstrap`, `:697`).
It writes no permission settings. The Codex adapter does the same for
project trust in its `config.toml` (`codex-runtime-adapter.ts:736`).
- **Readiness** (`checkReady`, `:356`): verifies tmux session alive (`:361`),
captures the pane command and 40 pane lines, and assesses them with
`assessNativeResumeProbe` (§3) through `assessManagedProbe` (called `:368`,
defined `:386`); ready only when probe `status === "resumed"`. The
resume-launch verification loop `verifyResumeLaunch` (`:420`) retries up to
**16 attempts** (`:422`), failing loudly with `recovery: "retry_fresh"` on
`no_conversation_found` (`:429–434`) and with `recovery:
"attention_required"` plus pane evidence on an `attention_required` probe
(`:444–450`) — the adapter never relaunches on its own.
- **Fresh in-place handover readiness.** `assessFreshInPlaceProbe` qualifies an
otherwise ready history probe only for the matching fresh launch, binding and
launch generation. The current pane must show a prompt, and `assessManagedProbe`
must confirm the new launch's exact session token in the same foreground process
across two observations, with the pane and launch still current. It reuses the
existing managed identity proof, including its process fingerprint. A process
label alone, old scrollback, an unavailable screen, an unreadable process list
or an unidentified Claude process cannot admit the handover. Those cases stay
`awaiting_runtime`; a positive identity conflict and visible approval or login
gates keep their specific results. The handover waits within its existing
readiness budget before sending the context packet.
The fresh path requires the last `❯` prompt to sit inside Claude's complete
input frame: its top rule directly above the prompt and a bottom rule below.
A shell's echoed launch command cannot borrow an earlier header or input
frame, even when the matching Claude process is already running. A header
and prompt alone, or a partially drawn frame, remain pending. This may delay
readiness until the full frame is visible; other launch paths keep their
existing screen predicates.
Previously, unavailable observations and ambiguous headerless prompts could
retain the old ready result. `claude-fresh-in-place-scope.test.ts` now checks
missing observations, unknown or changing process identity, retained UI above
direct and script launch commands, partial frames, recovery and normal prompt
controls. `claude-handover-readiness.test.ts` exercises the real handover
service with migrated SQLite, modeled pane/process observations and one synthetic
clock. Readiness and the later packet-submission receipt remain separate checks;
these tests do not run a live Claude account.
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:65`)
- `readonly runtime = "codex"` (`:66`).
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
(`:309`, `:576`); `skill_install` and skill resources → the seat's skill
root, `binding.seatSkillsRoot`, which is the `skills/` folder beside the
seat's catalog in its own storage
(`domain/startup-orchestrator.ts:360–369`), or
`<cwd>/.agents/skills/<name>/` for a seat without one (`:563–564`,
`:1060`); plugins go to `.codex/plugins/<id>` (`:1063`).
- **Launches/resumes**: fresh = `codex[ --no-daemon]<posture>[<network>] -C
<cwd> …` (`:442`), then capture a fresh thread id (`:460`); resume is built
by `buildCodexResumeCore` (`:441`) as `codex<daemon><posture> resume
[<queueStateDirArg>] <token>` (`native-resume-probe.ts:81`); fork =
`codex<posture> fork<queueStateDirArg> <parentId>` (`:411`). `--no-daemon`
(`:389`) comes first, and only when `codex --help` lists it
(`domain/codex-daemon-support.ts`; an unreadable answer refuses the launch).
`-m <model>` (`:356`) and `-c 'model_reasoning_effort="<effort>"'`
(`:358`) are added when selected. With a thread id it returns `{ ok: true,
resumeToken: threadId, resumeType: "codex_id" }` (`:428`, `:457`, `:463`); a
fresh launch whose thread id is not captured returns `{ ok: true }` with its
applied launch but no token (`:466`). Every Codex launch, fresh, fork or resume,
also carries `-c check_for_update_on_startup=false`, so a managed seat leaves Codex
upgrades to the operator (`kernel-authority.ts:60`, added to the posture at
`codex-runtime-adapter.ts:363`).
- **Network default.** On the plain `-s workspace-write` floor,
`domain/codex-network-default.ts` asks a one-shot `codex app-server` for the
effective configuration; when it allows it, the launch adds
`-c sandbox_workspace_write.network_access=true` (`:19`). Otherwise it adds
nothing.
- Refuses `resumeToken` + `forkSource` together with a clear error
(`:350–351`) — honors the mutual-exclusivity contract.
### TerminalAdapter (`terminal-adapter.ts:19`)
- `readonly runtime = "terminal"` (`:20`).
- **All operations are no-ops** — "the shell is immediately interactive"
(`terminal-adapter.ts:15`): no-op `project`/`deliverStartup`, a
`launchHarness` (`:34`) that returns `{ ok: true }`, and a `checkReady` that
returns ready unconditionally (`:47–48`). Used for infrastructure nodes. A
terminal node cannot fork: `launchHarness` refuses a `forkSource` with a
clear error (`:38–42`), as the runtime-adapter docstring requires of
adapters without fork (`runtime-adapter.ts:129–130`).
### Pi, Oh My Pi and stub adapters
- `PiRuntimeAdapter` (`pi-runtime-adapter.ts:64`), `runtime` `"pi"` (`:65`).
- `OmpRuntimeAdapter` (`omp-runtime-adapter.ts:7`) extends it with
`runtime = "omp"` (`:8`).
- `StubRuntimeAdapter` (`stub-runtime-adapter.ts:69`), `runtime` defaulting to
`"stub"` (`:81`).
Pi and stub launch an OpenRig runner inside the seat's tmux pane (header
comments `pi-runtime-adapter.ts:1–10`, `stub-runtime-adapter.ts:1–14`). Pi
and Oh My Pi support fork and report `pi_session_file` or `omp_session_file`
resume tokens; the stub refuses fork and reports `stub_session`.
`createDaemon` constructs the adapters (`startup.ts:778`, `:779`, `:782`,
`:783`, `:788`) and creates the terminal adapter inline in the runtime adapter
maps (`startup.ts:989`, `:1259`); see `daemon-core.md` §4 "Startup sequence".
### Terminal providers
Opening a seat in a terminal app goes through `TerminalProvider`s under
`packages/daemon/src/domain/terminal/`, served by `/api/terminal`:
`HerdrAdapter` (`herdr-adapter.ts:351`), the default
(`terminal-service.ts:55–58`), and `CmuxProviderAdapter`
(`cmux-provider-adapter.ts:52`), which is best effort and refuses with
`cmux_unavailable` when cmux is not connected. A request that names no
provider takes the first of Herdr, then cmux, that is available and alive,
and stays on Herdr when neither is (`providerNameFor`,
`terminal-service.ts:175`); a named provider is used as named. These place existing tmux
sessions in views; they do not launch harnesses.
`RexAdapter` (`rex-adapter.ts`) opens a view as a new Rex session through
the `rex` CLI (`rex --autostart=false api call <method> -`, `rex-transport.ts`):
it creates an empty session (`session.create`), adds one window per grid page
(`session.new_window`), and checks the tiles once after a short wait. It is used only when named;
the CLI and TUI name it when they run inside Rex (`openRexView`,
`packages/cli/src/terminal-window-rex.ts`), and an omitted provider never picks
it. It refuses with `rex_unavailable` when the Rex server does not answer.
Discovery (`server.version` and `server.status`) has a 1 s budget, and
`status()` and `liveness()` share one probe. A request whose reply is lost (a
timeout, a killed client, an unreadable reply) is `unknown`, not failed: the
adapter never closes a session on it and reports `rex_outcome_unknown`.
Herdr and Rex build their layout trees from one grid helper
(`buildTileGrid`, `tile-layout.ts`).
### Tmux server selection
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
`domain/tmux-server-selection.ts`. `terminal.tmux_server` defaults to `inherit`;
`isolated` selects a socket derived from the canonical OpenRig home. The effective
choice is persisted in `tmux-server.json`. A registered live seat, or an
uninspectable old server, defers a requested change until a later restart.
The persisted `pinned` flag and active marker make isolation and migration records
mandatory, while ordinary inherited records remain advisory. Returning to inheritance
commits an unpinned record before clearing the marker, so offline cleanup cannot reuse
a stale inherited socket or keep enforcing a completed isolation choice.
`TmuxAdapter` receives the selection's `executionArgs` for both shell and argv
transports, including its direct probe/cleanup calls. They are empty for ordinary
`inherit` mode, preserving tmux's environment-based selection; `serverArgs` still
names the server for attachments. The opt-in control client uses the same execution
arguments for its connection, probes and fallback commands. The same startup choice feeds node
inventory, `TerminalService.status()` and previews, composed panes, shared TUI attachment and
CLI viewer management. Inherited SSH panes retain the remote-tmux replacement
wrapper and plain recovery command without requiring remote OpenRig. Isolated
SSH panes resolve the remote instance through its own `rig terminal attach`.
For isolation, self-attachment checks the caller's socket before binding;
offline destruction reads the persisted socket before removing state.
See [tmux server selection](../../reference/tmux-servers.md) for configuration
loading, first-upgrade migration, discovery scope and platform behavior.
## 3. Resume honesty
The daemon does not assume a harness resumed just because the launch command
ran. Three domain files (`packages/daemon/src/domain/`) carry the resume
assessment:
### `native-resume-probe.ts`
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:84`) reads pane
command + pane content and returns one of four honest statuses
(`NativeResumeProbeStatus`, `:7`):
- `resumed` — runtime-specific indicators confirm a resumed session.
- `failed` — terminal failure (e.g. Claude printed "No conversation found" →
code `no_conversation_found`, `:99–104`).
- `inconclusive` — we don't know yet (e.g. Claude trust gate, code
`trust_gate`, `:113–118`).
- `attention_required` — alive and recoverable but **needs operator action**
(e.g. Claude resume-selection prompt, code `claude_resume_selection_prompt`,
`:106–111`). This is the proxy for "an operator must choose the
conversation"; it is *distinct* from `inconclusive` and `failed` (comment
`:4–6`).
Other codes include `bypass_consent_gate`, `mcp_gate`, `login_required` and
`returned_to_shell` for Claude, and `codex_client_incompatible`,
`no_saved_session`, `codex_auth_refusal` and `model_selection_gate` for Codex;
an unknown runtime gets `unsupported_runtime`. For Claude, `assessManagedProbe`
upgrades an auto-mode or composer-only screen to `resumed` only after a stable
native process match on a single pane.
`buildNativeResumeCommand` (`:32`) builds the resume command per runtime:
claude → `claude --resume <token> [--name <name>]` (`:41`); codex →
`buildCodexResumeCore` (`:44`), i.e. `codex<posture> resume <token>`
(`:81`); other runtimes → `null` (`:46`).
At the adapter level (Architecture Rule 15, §4), the adapter's
`verifyResumeLaunch` returns `ok:false` with a `retry_fresh` recovery hint and
never relaunches itself. Acting on that hint is the caller's choice:
`StartupOrchestrator` retries once fresh on `retry_fresh` unless the caller
passes `allowFreshFallback: false` (`startup-orchestrator.ts:319–330`), and the
restore orchestrator passes `false` when a pod-aware node requested resume
(`restore-orchestrator.ts:1224`).
### `resume-metadata-refresher.ts`
`ResumeMetadataRefresher` (`resume-metadata-refresher.ts:59`) records which
sessions can be resumed, without overwriting a token it can't re-derive
exactly. `refresh(sessions, opts?)` (`:124`) works in two modes:
- **At shutdown** (default): a Codex session without a token gets its thread
id captured. For a Claude session with a token, it first reads Claude's live
per-process session file, and only if that read fails runs
`probeClaudeResume` (`:216`) — a real launch of the resume command in a
throwaway probe tmux session (`rigged-refresh-<name>-<ts>`, `:301`),
returning `"resumable" | "not_resumable" | "inconclusive"`. The probe result
is recorded without clearing the token.
- **`fillNullOnly`** (`:127`): a missing Claude token is filled from the
status-line sidecar, and a present Claude or Codex token is re-stamped
resumable only when it matches what the runtime reports. No probe runs.
### `codex-thread-id.ts`
Codex thread-id extraction (`codex-thread-id.ts`). Reads the Codex thread id
from the Codex *logs* SQLite databases in the Codex home (`CODEX_HOME` when
set, else `~/.codex`, `:40`): `readCodexThreadIdFromCandidateHomes(...)`
(`:34`) → `readCodexThreadIdFromLogs(...)` (`:244`) →
`resolveCodexDbPaths(homeDir, kind)` (`:302`), which globs
`logs_<N>.sqlite` (`:308`) and falls back to `logs_1.sqlite`. The logged
thread ids are then checked against the `threads` table in `state_<N>.sqlite`
(falling back to `state_5.sqlite`, `:320`; query `:269–280`); an id is
returned only when exactly one CLI conversation matches (`:282`). Uses
`better-sqlite3` (`:6`). Resolves the home dir by the harness PID
(`defaultResolveHomeDirByPid`, `:16`).
## 4. Relevant Architecture Rules
The rule texts live in `architecture-rules-and-event-system.md` §1; this
section cites them by number with the adapter-level evidence checked for this
module.
- **Rule 1** — §2: **0** files in `adapters/` import Hono.
- **Rule 5** — restore picks a node's adapter by that node's own runtime: the
saved startup context's runtime, else the node's (`restore-orchestrator.ts:1115–1116`),
used to choose the replay adapter (`:1138`).
- **Rule 13** — the readiness loop is `StartupOrchestrator.waitForReady`
(`startup-orchestrator.ts:562`; 1 s doubling to a 16 s cap), which calls each
adapter's `checkReady` (§2). The timeout comes from the
`runtime.readiness_timeout_seconds` setting (default 30, range 1–600,
`domain/readiness-timeout.ts:3`).
- **Rule 14** — the probe's `attention_required` (§3) reaches the restore
result through `HarnessLaunchResult.recovery` and `evidence` (§1), which
become `RestoreNodeResult.status` (`types.ts:457`, `:475`) and
`attentionEvidence` (`types.ts:478`). A startup's `continuityOutcome` is a
separate field that also allows `forked` (`startup-orchestrator.ts:101`).
- **Rule 15** — adapters return `ok:false` and never relaunch (§2, §3);
restore passes `allowFreshFallback: false` for pod-aware resume
(`restore-orchestrator.ts:1224`), and a resume that concludes failed rolls
back to zero sessions as `awaiting-decision` (`restore-orchestrator.ts:1055`,
`:1243`).
## See also
- `daemon-core.md` — where `createDaemon` constructs the runtime adapters.
- `agent-spec-and-startup.md` — the `StartupOrchestrator` that calls these
adapters and owns startup-action execution after `checkReady()`.
- `lifecycle-snapshot-restore.md` — how persisted resume tokens flow into
snapshot/restore (resume vs rebuild vs fresh).
- `architecture-rules-and-event-system.md` — the full architecture rule list.
- Source roots: `packages/daemon/src/domain/runtime-adapter.ts`,
`packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,pi-runtime-adapter,omp-runtime-adapter,stub-runtime-adapter,terminal-adapter,yolo-mode,non-interruptive,kernel-authority}.ts`,
`packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id,claude-managed-launch,codex-network-default,native-permission-store}.ts`,
`packages/daemon/src/domain/permission-policy/policy-ref.ts`.
@@ -1,403 +0,0 @@
---
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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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 pre-launch files → persist replay context → launch
→ wait → deliver post-launch files.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. `domain/…` and `routes/…` paths
> are under `packages/daemon/src/`; a bare file name such as
> `rigspec-schema.ts:119` is in `packages/daemon/src/domain/`. Each count sits
> beside the command that produces it; run the command from the repository
> root to refresh it.
## 1. Canonical spec and topology types
- **AgentSpec** (`types.ts:941`) — parsed from `agent.yaml`
(`agent-resolver.ts:150`). Owns imports, defaults, startup, resources, and
profiles. Canonical parse/normalize/validate is `domain/agent-manifest.ts`.
- **RigSpec** (`types.ts:1172`) — canonical pod-aware rig topology. Uses
`version: "0.2"` and `pods[]`; owns cross-pod `edges[]`, rig-level startup
overlays, and `cultureFile`, plus the optional `summary`, `permissionPolicy`,
`nonInterruptive`, `managedBlocks`, `docs`, `services` and `workspace` fields.
An authored `non_interruptive` must be a boolean (`rigspec-schema.ts:179`) and
sets the rig's saved non-interruptive choice at instantiation unless an
explicit launch option is given (`rigspec-instantiator.ts:1345`).
`version` is the spec-schema version, not the package version. It is not a
code constant: `RigSpecSchema.validate` requires only a non-empty string
(`rigspec-schema.ts:119`) and `RigSpecSchema.normalize` carries the authored
value through (`rigspec-schema.ts:253`). `"0.2"` is the canonical authored
value, and the pod-aware exporter writes it (`rigspec-exporter.ts:223`).
- **RigServicesSpec** (`types.ts:1221`) — optional `services` block on a
pod-aware RigSpec. Shipped kind is Compose-backed env management
(`kind: "compose"`) with `composeFile`, `projectName?`, `profiles?`,
`downPolicy?`, `waitFor?`, `surfaces?`, `checkpoints?`.
- **RigSpecPod** (`types.ts:1128`) — pod-local bounded context with
`members[]`, pod-local `edges[]`, pod startup, optional continuity policy.
- **RigSpecPodMember** (`types.ts:1072`) — member-level runtime/startup
surface: `agentRef`, `profile`, `runtime`, `model?`, `effort?`, `cwd`, `restorePolicy?`,
member startup overlays (`startup?`), plus `label?`, `codexConfigProfile?`,
`role?`, `permissionPolicy?`, `compactionStrategy?`, `mechanic?`,
`sessionSource?` and `starterRef?`.
- **Pod** (`types.ts:10`) — persisted DB entity for a pod.
- **ContinuityState** (`types.ts:20`) — persisted live continuity row keyed by
`podId + nodeId` (`PRIMARY KEY (pod_id, node_id)`,
`packages/daemon/src/db/migrations/014_agentspec_reboot.ts:24`).
## 2. Execution and projection types
The member's optional `claude_mcp: none` is normalized as `claudeMcp` and
persisted independently in `nodes.claude_mcp` (migration 142). The
expansion HTTP route preserves both `claude_mcp` and `claudeMcp`, including
invalid values for the canonical validator to reject. An authored relaunch
updates the seat choice after launch eligibility and startup-source resolution;
an already-bound seat or a failed tmux launch keeps its prior value. Export
describes the current runtime; a dormant choice on another runtime is omitted
and must be restored in the Claude declaration when rebuilding from that export.
The YAML export route names those agents in the existing `# Not carried:` report;
the JSON export is unchanged. `rigspec-routes.test.ts` covers both pod-aware and
legacy exports, omission controls and successful JSON export.
The shared `StartupOrchestrator` reads the current seat record, so both an authored start
and saved-context restore pass the choice to Claude. Legacy resume and handover
successors read it separately; a runtime-switch successor reads it only when
the target is Claude. `claude-mcp.ts` builds the strict empty-server arguments
for the adapters, shutdown resume probes and displayed recovery commands,
including the conversation picker. Shutdown skips a probe with an unreadable
saved choice and keeps the resume token without adding a shutdown refusal.
The native lineage selectors recognize these exact
arguments without treating JSON contents as session options. Invalid stored
values refuse launch. With no choice, the adapters add no MCP arguments.
`claude-mcp-lifecycle.test.ts` exercises real schema/storage, export, snapshot
and restore with simulated tmux/provider processes. `claude-mcp-launch.test.ts`
executes managed and unmanaged shell commands against a private argv-printing
fake, including fresh/resume/fork and legacy resume. These tests verify command
plumbing and identity handling; they do not claim live provider enforcement.
Restore/snapshot types are detailed in `lifecycle-snapshot-restore.md`; the
spec/projection types live here.
- **ResolvedNodeConfig** (`profile-resolver.ts:33`) — output of profile
resolution. Carries effective runtime/model/effort/cwd, narrowed restore policy,
selected resources, layered startup block, resolved spec identity, and the
resolved compaction strategy, continuity mechanic, lifecycle, activity and
skill loadout.
- **ProjectionPlan** (`projection-planner.ts:42`) — runtime projection plan for
a node: runtime, cwd, projection entries, startup block, diagnostics,
conflict/no-op classifications.
- **RuntimeAdapter** (`runtime-adapter.ts:143`) — the five-method contract
(adapter detail in `adapters-and-runtimes.md`): `listInstalled(binding)`
(`runtime-adapter.ts:153`), `project(plan, binding)` (`:156`),
`deliverStartup(files, binding, sendInteractiveText?)` (`:160`),
`launchHarness(binding, opts)` (`:170`), `checkReady(binding)` (`:176`). The
interface also declares an optional `claudeManagedLaunch` property (`:145`)
and an optional method, `skillTargetPath?(...)` (`:150`). Required methods:
**5**
(`sed -n '/^export interface RuntimeAdapter /,/^}/p' packages/daemon/src/domain/runtime-adapter.ts | grep -c -E '^ [a-zA-Z]+\('`).
- **HarnessLaunchResult** (`runtime-adapter.ts:97`) — returned by
`launchHarness`: either `{ ok: true, resumeToken?, resumeType?,
appliedLaunch? }` or `{ ok: false, error, recovery?, evidence? }`, where
`recovery` is `"retry_fresh"` or `"attention_required"` (`:95`).
- **StartupOrchestrator** (`startup-orchestrator.ts:140`) — drives the full
startup sequence (§4 below).
## 3. Parsing, validation, resolution pipeline
All of the files below are under `packages/daemon/src/domain/`, and none of
them imports Hono: **0**
(`git grep -l '"hono' -- packages/daemon/src/domain/{agent-manifest,rigspec-schema,rigspec-codec,startup-validation,path-safety,spec-validation-service,spec-review-service,agent-resolver,agent-preflight,profile-resolver,startup-resolver,projection-planner}.ts | wc -l`).
**Parse / validate:**
- `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.
### Profile resolution during launch
Preflight already resolves profiles in `profile-resolution-worker.ts`. Previously,
the pod-aware apply path, materialized-member launch and first-start retry called
the synchronous resolver again on the daemon's request thread. A fallback skill
scan or managed-catalog inspection could therefore delay every other request even
after preflight had finished ([#1653](https://github.com/mvschwarz/openrig/issues/1653)).
Those three launch paths now await the same resolver in a worker before projecting
resources or starting the member. Configuration precedence and launch order still
come from the existing resolver and instantiator.
An apply operation reuses one lazy worker across its members and terminates it on
success or failure. Unlike preflight, launch supplies no `scanScope`: each member
reads current files, including changes made by an earlier member's startup.
Standalone member launches and first-start retries use a short-lived worker for
their resolution. This adds worker startup and context-copy costs; it does not
reuse the preflight result or cache configuration between requests. The helper's
existing synchronous fallback for a deleted process working directory remains.
Worker errors become configuration-failure results. Apply can then follow the
existing all-members-failed cleanup, including service rollback and restoration
of replaced generations. Materialized launches report a failed member, and
first-start retries refuse before writing retained state.
`profile-resolution-worker.test.ts` holds a real fallback scan open with a FIFO,
requires an HTTP health response while that FIFO is still blocked, then checks
that the selected skill reaches projection. It covers all three launch paths and
a skill edit between members. These fixtures use SQLite and the real instantiator
with a simulated runtime; they do not establish native provider behavior or
responsiveness during other synchronous parts of startup.
## 4. Startup orchestration (the spec-startup contract)
`StartupOrchestrator.startNode` (`startup-orchestrator.ts:172`, a thin wrapper
over `startNodeWithWarnings`, `:178`) drives, in source order: apply the seat's
permission selection and the rig's non-interruptive setting to the binding
(`NativePermissionStore.apply`, `:183`; `:193`) → mark pending (`:209`) and
select the startup proof (`:210`) → project resources (`:224`) → deliver
pre-launch files (`:266`) → persist startup context (`:282`) → launch harness,
recording any resume token (`:301`, `:313`) → wait for ready (`:384`) → deliver
the session identity prompt and interactive files (`:420`, `:473`) → execute
`after_files` then the remaining `after_ready` actions (`:495`, `:502`; see
the resume preload below) → check readiness
again and, for a Claude resume, that the launched session agrees with the
requested token (`:507`–`535`) → mark ready (`:538`). The class doc comment
(`startup-orchestrator.ts:120`–`139`) lists ten steps; the code persists the
startup context before the harness launch.
**Pre-launch vs interactive delivery split** — the load-bearing seam:
- Pre-launch (filesystem, before harness boot): `guidance_merge`,
`skill_install` (`startup-orchestrator.ts:126` — "Deliver pre-launch files
(guidance_merge, skill_install → filesystem)"; delivered at `:266`). Any
file that is not `send_text` is delivered here.
- Post-launch (TUI, after harness is ready): `send_text`
(`startup-orchestrator.ts:130`; held for post-launch and delivered after
readiness at `:473`). Rebuild artifacts are put in front of these files.
- **Fresh launch:** the builtin `session_identity` action
(`rigspec-instantiator.ts:2655`) is sent as one turn together with the first
`send_text` file (`deliverInitialSessionPrompt`, `startup-orchestrator.ts:663`).
- **Resume, fork or rebuild:** applicable `after_ready` `send_text` actions are
sent ahead of the first `send_text` file as one turn.
The orchestrator persists replay context for future restores (consumed by
`lifecycle-snapshot-restore.md`): the projection entries, resolved startup
files, startup actions and runtime go to `node_startup_context`
(`startup-orchestrator.ts:282`), and a resume token returned by the launch is
written to the session row (`updateResumeToken`, `:313`).
**Checked startup text (Claude Code).** Interactive text for a Claude seat goes
through `sendInteractiveText` (`:777`): paste, wait 200 ms, press Enter, wait
200 ms, then read 200 lines of the pane. `inspectStartupStagedText`
(`startup-submission-evidence.ts:36`) classifies the composer as clear, staged
or unverified. While the composer shows Claude's own collapsed-paste label for
this text (`startupOwnCollapsedPaste`, `:49`), `settleOwnPaste`
(`startup-orchestrator.ts:867`) looks again, up to 25 times 200 ms apart,
without pressing anything. Text still staged gets one guarded Enter-only retry
(`:823`), and then a "press Enter in that pane" warning (`:836`). An
unverified or staged result is a warning, never a startup failure, and is
reported in a `submission` block on `node.startup_ready` and on the result.
**Startup proof.** A seat can be `ready` without being oriented.
`resolveStartupProof` (`startup-resolver.ts:81`) selects the last applicable
authored `startup_proof` action; the default is none. For a fresh launch on a
non-terminal runtime in `authenticated` mode, the orchestrator issues a
challenge after readiness (`issueStartupChallenge`, `startup-proof.ts:90`),
recorded as `node.startup_challenged` with a hash of the resolved startup files.
The expected answer is derived from the challenge and that hash
(`computeExpectedAnswer`, `:53`) and is never stored. The challenge text is
appended to the identity prompt, or sent on its own when there is no identity
action (`startup-orchestrator.ts:487`). On Claude, a short proof instruction
line (`STARTUP_PROOF_INSTRUCTION_LINE`, `startup-proof.ts:63`) is sent only
after the startup prompt was confirmed submitted (`sendProofInstruction`,
`startup-orchestrator.ts:760`). The seat answers with
`rig startup-proof submit --challenge-id … --answer …`
(`packages/cli/src/commands/startup-proof.ts:99`), which posts to the activity
hook route (`routes/activity.ts:239`); `verifyStartupProof`
(`startup-proof.ts:133`) records `node.startup_proof_verified` or
`node.startup_proof_rejected`. Orientation status is derived from those events
(`startup-proof.ts:239`), never from `startup_status`. Other fresh launches (no
proof selected, or a terminal runtime) record `node.startup_proof_skipped`
(`startup-orchestrator.ts:374`); resume, fork and rebuild keep the existing proof
history.
**Pending startup context and `rig seat continue`.** When a fresh launch stops at
a native prompt before its context is delivered, `fail()` (`:806`) marks the
failure `freshContextPending` and adds "run: rig seat continue <session>". A
Codex fork whose own id isn't verified at launch, or whose readiness times out,
ends `attention_required` with `forkContextPending` (its parent token) instead:
the role waits, and nothing is saved for an unverified child.
`canContinueStartup` (`:764`, using `readPendingStartupContext`, `:46`) allows
continuation only while that failure is the occupant's latest startup event.
`continueStartup` (`seat-lifecycle-service.ts:723`) re-checks the binding,
pane and runtime, then runs startup again on the same conversation without
relaunching the harness. A fork continuation first re-verifies the child with
`captureForkIdentity` (`codex-runtime-adapter.ts:668`; `startup-orchestrator.ts:559`)
and needs the exact pending event it started from; a fresh continuation keeps
the plain "still pending" check. It is reached by `rig seat continue`,
`POST /api/seat/continue/:seatRef` and the per-seat startup route's `continue`
action (`routes/startup.ts:159`).
**Startup layering is additive and ordered** (`resolveStartup`,
`startup-resolver.ts:15`–`22`): (1) agent base, (2) profile, (3) rig culture
file, (4) rig startup, (5) pod startup, (6) member startup, (7) operator debug
append. Files and actions are concatenated in that order with no
deduplication (`startup-resolver.ts:24`). This is the spec-startup contract's
invariant; the cross-cutting architecture rules are collected in
`architecture-rules-and-event-system.md`.
The launch path takes only the actions from `resolveStartup`. It builds the
startup *file* chain itself (`buildResolvedStartupFiles`,
`rigspec-instantiator.ts:2518`), in this order: agent base, profile, the shipped
`CULTURE-default.md` floor (`:327`), the rig `culture_file`, rig, pod, member,
the shipped `openrig-start.md` (`:2572`), and, for fresh launches when enabled,
the onboarding files (`:2582`). Managed-block guidance is deduplicated, and a
`starter_ref` layer is put in front when a member names one (`:2243`).
**Startup action constraints** (`startup-validation.ts`): startup entries must
be files (`:22`); no shell startup actions (`:65`); action types are
`slash_command`, `send_text` and `startup_proof` (`:7`), and a `startup_proof`
action must be idempotent with a value of `authenticated` or `none` (`:73`);
non-idempotent actions must not apply on restore (`:100`). Retrying a failed
startup is handled as a restore, which is why the orchestrator skips
non-idempotent actions on restore (`startup-orchestrator.ts:634`,
"retry-as-restore safety"). The exception is
`PodRigInstantiator.retryFirstStart` (`rigspec-instantiator.ts:1147`), which
re-runs a first start that failed at projection, before any harness launch.
**Remote import constraints**: `agent_ref` and AgentSpec imports accept
`local:<relative path>` and `path:<absolute path>` only
(`rigspec-schema.ts:41`, `agent-manifest.ts:40`); a terminal member uses the
`builtin:terminal` sentinel (`rigspec-schema.ts:532`). Remote `agent_ref`
sources remain unsupported: RigSpec validation rejects them
(`rigspec-schema.ts:563`), `rigPreflight` runs that validation
(`rigspec-preflight.ts:252`), and the resolver refuses a remote import again
(`agent-resolver.ts:203`).
## 5. Instantiation, preflight, export
- `runtime-adapter.ts` — adapter contract + bridge types.
- `rigspec-preflight.ts` — dual-stack legacy preflight (`RigSpecPreflight`,
`rigspec-preflight.ts:26`) plus rebooted `rigPreflight(...)` (`:246`).
- `rigspec-instantiator.ts` — dual-stack `RigInstantiator`
(`rigspec-instantiator.ts:33`) plus `PodRigInstantiator` (`:475`).
- `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; a parsed spec with a `pods` array
takes the pod-aware path (`routes/rigspec.ts:83`): validate
(pod-aware → `RigSpecSchema.validate`; legacy → `LegacyRigSpecSchema.validate`),
preflight (`rigPreflight({ rigSpecYaml, rigRoot, … })` 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 `schemaVersion: 1`,
`rigspec-exporter.ts:105`).
The import validation route delegates to `validateRigSpecImport`
(`routes/rigspec.ts:215–223`, `spec-validation-service.ts:10–19`). The same
helper powers local `rig spec validate` without contacting the daemon
(`packages/cli/src/commands/rig.ts:204–207`); it selects the pod-aware or legacy
validator and distinguishes YAML parse failures from validator exceptions.
## 6. Identity: whoami, materialize, bind, adopt
**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:19`–`23`). The CLI resolves identity
(`packages/cli/src/commands/whoami.ts:142`–`148`) in this order: explicit
`--node-id` → explicit `--session` → env vars (`OPENRIG_NODE_ID` /
`OPENRIG_SESSION_NAME`, or the legacy `RIGGED_NODE_ID` /
`RIGGED_SESSION_NAME`, `whoami.ts:158`) → tmux metadata → raw tmux
session-name fallback.
- Managed sessions prefer projected `OPENRIG_NODE_ID` /
`OPENRIG_SESSION_NAME`, set when the tmux session is created
(`node-launcher.ts:143`–`144`).
- Adopted sessions use tmux-owned metadata written at bind time.
The tmux metadata keys use the `@rigged_` prefix:
`ClaimService.setRiggedMetadata` writes `@rigged_node_id` /
`@rigged_session_name` / `@rigged_rig_id` / `@rigged_rig_name` /
`@rigged_logical_id` (`claim-service.ts:182`–`186`), called from `bind`,
`reconcileSession` and `createAndBindToPod`;
`RigLifecycleService.unclaimSession` clears the same five keys
(`rig-lifecycle-service.ts:209`–`213`); and `rig whoami` reads
`@rigged_node_id` and `@rigged_session_name`
(`packages/cli/src/commands/whoami.ts:173`, `:179`). MCP tool names use a
different prefix, `rig_*` (`packages/cli/src/mcp-server.ts`).
**Materialize / bind / adopt**: `POST /api/rigs/import/materialize`
(`routes/rigspec.ts:176`) creates a pod-aware topology without launching
sessions and refuses a legacy spec (`routes/rigspec.ts:189`);
`POST /api/discovery/:id/bind` (`routes/discovery.ts:77`) attaches a
discovered live session to an existing logical node (`logicalId`), or creates
a member in a pod (`podNamespace` + `memberName`) and binds it;
`POST /api/discovery/:id/adopt` (`routes/discovery.ts:139`) is the composite
route (bind to existing node, or create a new member in a target pod and bind
immediately). On the CLI, `rig bind` calls the bind route
(`packages/cli/src/commands/bind.ts:56`), and `rig adopt` materializes the
topology and then calls the bind route for each session
(`packages/cli/src/commands/adopt.ts:163`, `:212`). Authored pod namespace is
preserved through adoption so logical ids stay `${podNamespace}.${memberName}`
(`claim-service.ts:663`). Adopted-session parity is tmux-metadata parity, not
fake env-var parity.
## 7. Specialist rosters
A roster is an authored list of recommended specialist seats, kept outside the
specs. `rig roster list`, `show <id>` and `find <query>`
(`packages/cli/src/commands/roster.ts`) read version-1 roster JSON files from
`<workspace.root>/rosters/` (or `--folder`); they are read-only and need no
daemon route. Each roster names a purpose and a curator, and each member names
a seat, host, capabilities, engagement, `use_when` and `why`. `show` and
`find` add current observations of each member by matching host and seat
against `rig ps --nodes`, read from the configured daemon and again across
registered hosts, and report a gap rather than guessing when no node matches. A roster is a
recommendation, not an assignment or an authority. It is unrelated to the
topology roster a snapshot records (see `lifecycle-snapshot-restore.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 cross-cutting architecture
rules.
- Source roots: `packages/daemon/src/domain/{agent-manifest,rigspec-schema,
profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`,
`packages/daemon/src/routes/{rigspec,whoami}.ts`.
@@ -1,325 +0,0 @@
---
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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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 main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. Architecture rules
These are the invariants the codebase is built to preserve. 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. No runtime Hono in `domain/` and `adapters/`. `adapters/` has none; `domain/`
has type-only Hono imports (`import type`) in **4** files
(`git grep -l 'from "hono' -- packages/daemon/src/domain packages/daemon/src/adapters | wc -l`).
2. Routes depend on the domain; the domain does not depend on routes at
runtime. **1** type-only import crosses back:
`domain/project-classifier.ts:1` imports `IdentityProvenance` from
`routes/require-sender-identity.js`
(`git grep -lE 'from "(\.\./)+routes/' -- packages/daemon/src/domain packages/daemon/src/adapters | wc -l`).
3. Shared DB-handle invariants are enforced at construction time.
4. The reboot is engine-first: domain services land before public-surface
rewiring.
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`,
`attention_required`, `failed` (`types.ts:102`).
12. Session recency depends on monotonic ULIDs: `session-registry.ts` uses
`monotonicFactory()`. Restore does not pick the newest session by max
ULID; it resolves the active occupant recorded in the snapshot
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
`restore-orchestrator.ts:774` and `:971`).
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. Restore outcome states are a fixed set (`RestoreNodeResult.status`,
`types.ts:475`): `resumed`, `rebuilt`, `fresh-primed`,
`awaiting-decision`, `attention_required`, `failed`, plus
`operator_recovered` (set only by later reconciliation), `fresh`
(retained only for the legacy continuity-restoring skip path) and
`left_attached` (a seat joined with `rig attach` as an external CLI, which
restore leaves as it is). `rebuilt` = new process assembled from artifacts.
15. Restore honesty: a failed resume stops loudly as `awaiting-decision`, with
the blank session rolled back and no session running
(`restore-orchestrator.ts:1055`). No automatic fresh fallback. Fresh launch
is explicit follow-up only (`rig up --fresh <seats...>`,
`packages/cli/src/commands/up.ts:99`).
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: bounded capture — a periodic `tmux capture-pane` snapshot of
the trailing lines (default 1000, every 2 s for an active seat) replaces
the transcript file, replacing pipe-pane (`transcript-rotation.ts:3`,
`:27`–`28`; started at `node-launcher.ts:181`). An idle seat is captured
less often, within the 10-second freshness window (`:29`–`33`,
`:162`–`163`), and the file is rewritten only when the captured bytes
change (`:270`). `readTail` and `grep` strip ANSI; `readFull` returns the
content unfiltered. `rig ask` transcript search: `rg`
preferred, `grep -E` fallback.
20. Config precedence: CLI flag > env var > config file
(`~/.openrig/config.json`, or `$OPENRIG_HOME/config.json` when
`OPENRIG_HOME` is set; `packages/cli/src/config-store.ts:939`) > 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. The exception is the explicit `--wake`
flag (`packages/cli/src/commands/ask.ts:88`), which runs one headless
question against an existing agent session (`codex exec resume` or
`claude -p --resume`, `packages/cli/src/ask-wake.ts:58`–`60`).
23. Spec library truth is YAML on disk; daemon owns the structured
review/index/cache layer.
24. Adopted-session parity is tmux-metadata parity, not fake env-var parity.
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`, `send_text` and `startup_proof` only
(`startup-validation.ts:7`).
- Non-idempotent actions must not apply on restore.
- Retrying failed startup is handled as restore.
### Remote import constraints
The reboot supports `local:...` and `path:/abs/...` agent refs. Remote
`agent_ref` sources remain unsupported and fail in preflight (schema
validation, `rigspec-schema.ts:563`; restated in compat note 1).
### Startup, delivery and launch invariants
These hold across the startup, transport and adapter code and are stated in its
comments:
- **`ready` is not "submitted" and not "oriented".** A seat can reach
`startupStatus: ready` while its startup text is unverified or still staged;
`node.startup_ready` carries a `submission` block saying which
(`types.ts:220`). Orientation is a separate, challenge-verified proof:
`ready` never means oriented (`types.ts:222`–`227`). See
`agent-spec-and-startup.md`.
- **Default sends never block on busy or unknown.** `--wait-for-idle` is the
caller's opt-in to wait, and a failed wait returns without sending. Otherwise
only positive evidence of an open
picker or approval prompt refuses a send; a busy or unknown seat gets the
message with an advisory warning (`session-transport.ts:1427`–`1433`). The
audited `--dangerously-interact` override is the only way past an open prompt.
- **Non-interruptive mode is per-launch flags only.** It never changes
permissions or native settings files, applies only to full-bypass Claude Code
and Codex seats, and is saved per rig, off by default
(`adapters/non-interruptive.ts:9`–`26`; `rigs.non_interruptive`, migration
`095`). A rig spec can author it as `non_interruptive` (`rigspec-schema.ts:179`).
- **Inside a notify envelope, events are delivered from the log.** Within
`withNotifyEnvelope` (`event-bus.ts:106`–`112`), every event persisted through
the event bus must be registered before the callback returns; after commit
the bus delivers the committed rows from the log, and an unreadable row
emits `event.delivery_poisoned`. Legacy callers outside an envelope persist
inside their own transaction and notify subscribers explicitly after commit
(`persistWithinTransaction`, `:70`; for example `node-launcher.ts:241`).
## 2. Event system
The daemon's event surface is the single `RigEvent` discriminated union.
`RigEvent` is declared at `packages/daemon/src/domain/types.ts:108`
(`export type RigEvent =`) and runs through `types.ts:313`. It has **98 union
members** declaring **99** `type` literals: one member (`types.ts:109`) carries
both `proof.judged` and `proof.sources_changed`.
- Members:
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`
- Type literals:
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | sort -u | wc -l`
**96** of the 99 literals appear as a `type: "<x>"` literal somewhere in
`packages/daemon/src` outside `types.ts` (mostly in `domain/` and `routes/`;
`seat.model_divergence` is built in `startup.ts`). **3** are declared but never
constructed: `session.status_changed`, `continuity.sync`, and `continuity.degraded`
(per #490, `qitem.closure_overdue` is produced by `QueueRepository.recordClosureOverdue`
when swept by `queue-stuck-sweep`, and `mission_control.view_refreshed` was retired).
List them with:
`for t in $(sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | tr -d '"'); do git grep -q -F "type: \"$t\"" -- packages/daemon/src ':!packages/daemon/src/domain/types.ts' || echo "$t"; done`
### Per-prefix event families
Each count below is the number of `type` literals per prefix in the union body
(`types.ts:108`–`313`):
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"[a-z_]+(\.[a-z_]+)+"' | tr -d '"' | cut -d. -f1 | sort | uniq -c | sort -rn`.
| Prefix | Types | Sample / role |
|---|---|---|
| `node.*` | 15 | `node.added` (`types.ts:126`) … `node.startup_proof_rejected` (`:229`) — lifecycle/startup |
| `workflow.*` | 8 | workflow runtime (detail in `workflow-runtime.md`) |
| `session.*` | 8 | session discovery / status / detach / vanish / stop / clean / resume-token audit |
| `rig.*` | 7 | `rig.created` / `rig.deleted` / `rig.imported` / `rig.stopped` / `rig.archived` / `rig.unarchived` / `rig.expanded` (`:241`) |
| `watchdog.*` | 5 | `watchdog.evaluation_fired` (`:280`) … `watchdog.job_stopped` (`:284`) |
| `seat.*` | 5 | `seat.model_divergence` (`:115`) … `seat.handover_completed` (`:200`) — model divergence, fresh launch, attention clear, handover |
| `queue.*` | 5 | queue lifecycle (`:249`–`:252`, `:262`; detail in `coordination-primitive.md`) |
| `package.*` | 5 | legacy package/install engine events |
| `mission_control.*` | 4 | audit/notification (`:308`–`:313`; detail in `mission-control.md`) |
| `bootstrap.*` | 5 | legacy bootstrap-run events |
| `restore.*` | 4 | restore start/complete/subset-complete/reconcile (detail in `lifecycle-snapshot-restore.md`) |
| `classifier.*` | 4 | classifier-lease lifecycle |
| `qitem.*` | 2 | `qitem.fallback_routed` (`:253`), `qitem.closure_overdue` (`:254`) |
| `proof.*` | 2 | `proof.judged`, `proof.sources_changed` — one union member (`:109`) |
| `pod.*` | 2 | `pod.created` (`:215`), `pod.deleted` (`:216`) |
| `inbox.*` | 2 | `inbox.absorbed` (`:255`), `inbox.denied` (`:256`) |
| `continuity.*` | 2 | `continuity.sync` (`:230`), `continuity.degraded` (`:231`) |
| `agent.*` | 2 | `agent.activity` (`:151`), `agent.session_identity` (`:158`) |
| singletons | 12 | one type each: `workflow_spec.*`, `view.*`, `transport.*` (`:155`), `topology.*` (`:138`), `stream.*` (`:245`), `snapshot.*`, `project.*`, `kernel.*`, `event.*` (`:110`), `chat.*` (`:239`), `bundle.*`, `binding.*` |
Family counts sum to 99 type literals (18 multi-type families totalling 87 +
12 singletons).
### Emission and delivery
Events are emitted via `eventBus.emit({ type: ... })` (`event-bus.ts:57`) or,
inside a caller-managed transaction, `eventBus.persistWithinTransaction(...)`
(`event-bus.ts:70`) with subscribers notified after commit — across domain
services (`stream-store.ts`, `workflow-runtime.ts`, `restore-orchestrator.ts`,
`node-launcher.ts`, etc.) and route handlers. The event log is append-only and
SQLite-backed, and it holds stored events only. `view.changed`, which the view
event bridge (`view-event-bridge.ts`) produces for each queue, inbox, proof or project-classification change,
is transient: `eventBus.publishTransient(...)` delivers it to the live
subscribers without a row, without a `seq` of its own and without moving the
notify watermark. It carries `transient: true` and, as its `seq`, the `seq` of
the stored event that caused it.
Upgrade note: `view.changed` rows stored by older versions stay in the log, so
`/api/events` replays and the telemetry export still return them until an events retention
(a separate change) removes them.
SSE delivery surfaces include the following. The daemon has **11**
`streamSSE(` call sites across **10** files in `packages/daemon/src/routes/`
(`git grep -o 'streamSSE(' -- packages/daemon/src/routes | wc -l`;
`git grep -l 'streamSSE(' -- packages/daemon/src/routes | wc -l`).
- `GET /api/events` — global stream of the stored events (`server.ts:730`
`app.route("/api/events", eventsRoute)`); `?rigId=` keeps one rig's.
Transient events (`view.changed`) are not stored, so they appear here
neither live nor in a replay. Each event's SSE `id` is its `seq`. Before going live, a connection replays
stored events, read and written 500 at a time (`routes/events.ts`):
- with a `Last-Event-ID` header (an `EventSource` sends it when it
reconnects), every event after that `seq`, whatever `replay` says;
- otherwise `?replay=<n>`: the newest `n` events (of the rig, with
`rigId`); `replay=0` starts from now; `replay=all`, no `replay`, a
count past the log's size, or any other value, replays the whole log.
Either way the replay ends at the newest event stored as the connection
subscribes; the events stored meanwhile were buffered and follow it, each
once and in order, before the stream goes live.
- `GET /api/views/sse` (alias `/api/views/watch`) and
`GET /api/views/:viewName/sse` — `view.changed`, live only (`routes/views.ts`).
An event's `id` there is the `seq` of the stored event that caused it, so it
repeats across the views one change touches; it is not a replay cursor.
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:194`).
- `GET /api/queue/watch` — queue/inbox coordination events
(`routes/queue.ts:1054`, with a `/sse` alias at `:1055`).
- The chat SSE stream `GET /api/rigs/:rigId/chat/watch` delivers
`chat.message` for one rig (`routes/chat.ts:82`, mounted at `server.ts:782`;
rig-scoped; see compat note 6).
- The other SSE routes are activity events and the watch or `sse` routes for
mission control, projects, views, watchdog and workflow; list them with
`git grep -n 'streamSSE(' -- packages/daemon/src/routes`.
### Telemetry read surface
`/api/telemetry` (`server.ts:822`; `routes/telemetry.ts`) is a read-only,
paged view over the event log and queue history, built by
`readTelemetryPage()` in `packages/daemon/src/domain/finite-telemetry.ts`. It
serves three streams: `GET /api/telemetry/v1/events`, `/v1/queue-transitions`
(active and archived transitions) and `/v1/nodes/:nodeId/tenures` (a node's
occupant tenures). Each page is one read with no writes or subscription,
bounded at 128 rows and 128 KiB (`TELEMETRY_LIMITS`), and resumed with a
cursor. Rows carry metadata only, never event payloads, notes or bodies, and
the response names gaps in the history it could not vouch for (for example a
daemon restart boundary or rows before the retained floor) instead of
presenting a partial history as complete. The page records the source host and
the daemon's boot epoch. Bad input returns `400 telemetry_invalid_request`; a
failed read returns `503 telemetry_read_unavailable`. The same route also
serves the usage series behind `rig usage` (`/usage/series`, `/usage/top`).
`rig telemetry events|transitions|tenures`
(`packages/cli/src/commands/telemetry.ts`) prints one bounded page.
## 3. Remaining compatibility notes
Intentional limits that still describe the shipped system:
1. Remote `agent_ref` imports remain unsupported (see §1 remote import
constraints).
2. Startup actions remain intentionally constrained (`slash_command`,
`send_text`, `startup_proof`).
3. Legacy compatibility seams still ship for pre-reboot data and v1
artifacts.
4. `rig ask` gathers context only — does not call an external LLM — unless
`--wake` is passed (rule 22).
5. `rig ask` transcript search prefers `rg`, falls back to `grep -E`
(`history-query.ts:252`, `:267`); quality/perf varies by backend.
6. Chat is rig-scoped only — no cross-rig channels or DMs.
7. `--verify` on `rig send` reports what the pane shows, not agent
acknowledgement. By default it compares the message (whole for a
header-carrying send, the first 40 characters for a raw one) in captures
before and after the send, leaving out the current input box; with
`transport.verify_input_box` on, Claude Code and Codex sends are verified
only for a seat proven idle before the paste and on input-box evidence; a
busy or unknown seat reads unconfirmed (`transport-and-transcripts.md` §3
step 5).
When it cannot confirm, it reports `rendered-unconfirmed`, not a failure.
8. Terminal node readiness is shell-ready only — no service health probes.
9. Managed-app service surfaces are descriptive only — OpenRig does not
auto-inject service URLs/tokens into agent prompts beyond authored
startup/context files.
10. Specialist delegation is conventional, not automatic — addressed by
session name or normal communication surfaces.
## 4. Cross-references
Under the modular as-built, this module owns the invariants and event shape;
`../codemap.md` is the navigation index for file-by-file structure.
## See also
- `daemon-core.md` — wiring, DB, migrations, startup.
- `coordination-primitive.md` — queue/stream/inbox/outbox events.
- `workflow-runtime.md` — `workflow.*` events.
- `mission-control.md` — `mission_control.*` events.
- Source roots: `packages/daemon/src/domain/types.ts` (RigEvent union),
`packages/daemon/src/domain/event-bus.ts` (emit/persist),
`packages/daemon/src/routes/{stream,queue}.ts` (SSE watch),
`packages/daemon/src/server.ts` (`/api/events`).
@@ -1,191 +0,0 @@
---
kind: as-built
title: Content Surfaces — Files, Progress, Steering, and Health
status: active
topics: [observability, specification-and-bundles]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Tracing allowlisted file access, conflict-checked writes, progress indexing,
steering composition, or the daemon health-summary endpoints.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Content Surfaces — Files, Progress, Steering, and Health
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
The daemon exposes filesystem content through the files, progress, and
steering route families. Health summaries read existing daemon state. These
are separate projections with separate configuration; configuring one root
is not a universal filesystem-access policy.
## Configuration and wiring
[`startup.ts`](../../../packages/daemon/src/startup.ts) constructs
`SettingsStore`, decodes `cfg.filesAllowlistRaw` and `cfg.progressScanRootsRaw`,
and injects `FileWriteService`, `ProgressIndexer`, and `SteeringComposer`.
The write service exists only when the decoded file allowlist is nonempty.
Its audit path is explicitly `${OPENRIG_HOME}/file-edit-audit.jsonl`.
[`SettingsStore`](../../../packages/daemon/src/domain/user-settings/settings-store.ts)
resolves environment overrides, then settings-file values, then defaults.
`files.allowlist` and `progress.scan_roots` both default to
`workspace:<resolved-workspace-root>`. Their environment overrides are
`OPENRIG_FILES_ALLOWLIST` and `OPENRIG_PROGRESS_SCAN_ROOTS`. Thus, an unset
or empty environment variable, or an empty settings-file value, does **not**
imply an empty allowlist in normal daemon startup. A non-empty value is not
validated: the decoders silently skip malformed entries (no `name:` prefix, or
a relative path), so a malformed value can leave no configured roots. Some source comments and route hints still say
otherwise or point only at the environment variable. The standalone `readAllowlistFromEnv` helper has different inputs
and retains its legacy environment fallback.
Roots use comma-separated `name:/absolute/path` pairs. The decoders skip
invalid pairs, canonicalize existing paths, and let the last duplicate name
win. See [`decodeAllowlist`](../../../packages/daemon/src/domain/files/path-safety.ts)
and [`decodeProgressScanRoots`](../../../packages/daemon/src/domain/progress/progress-indexer.ts).
## Path resolution and reads
[`resolveAllowedPath`](../../../packages/daemon/src/domain/files/path-safety.ts)
requires a configured root, rejects `..` path segments and absolute relative
paths, resolves existing symlinks, and checks containment with a path-separator
boundary. An empty relative path denotes the root. If realpath fails, the
helper falls back to the resolved candidate; file/directory helpers then
perform a stat check. These are the implemented checks, not a claim of
race-free isolation against concurrent filesystem changes.
[`readAllowedFile`](../../../packages/daemon/src/domain/files/file-read.ts)
is shared by the HTTP reader and the local TUI reader. It returns content,
absolute and root-relative resolved paths, mtime, full-file SHA-256, size,
binary classification, and truncation metadata. The returned text is capped
at **1,048,576 bytes**, but the full file is read and hashed. The cap limits
the response, not the memory required to read the file. The TUI's local
reader runs as a CLI subprocess without the daemon: it resolves
`files.allowlist` from the CLI configuration, offers four fixed workspace
targets as roots, and hides dot-prefixed entries in listings
(`packages/cli/src/local-reading.ts`).
## Writes and audit
[`FileWriteService.writeAtomic`](../../../packages/daemon/src/domain/files/file-write-service.ts)
reads the existing target and compares **both** the expected mtime and content
hash. A mismatch raises `WriteConflictError` with the current values. It then
writes a temporary file in the same directory, preserves the target's
ordinary permission bits, fsyncs the temporary file, and renames it over the
target. This is optimistic conflict detection; it does not hold a lock over
the read/compare/replace sequence.
`createAtomic` handles new files. It creates and fsyncs a temporary file, then
uses an exclusive hard link to establish the target. If the target already
exists, it returns the `target_exists` error instead of overwriting it.
`createAtomic` is used by the Living Notes freeze export, and `writeAtomic`
also backs the freeze-time `MISSION_BRIEF.md` spine update; both append to the
same audit file.
Both operations append a JSONL audit record containing actor/provenance,
paths, timestamps, hashes, and byte-count delta. An audit append failure
raises `audit_write_failed` **after the content has landed**; the write is not
rolled back. The service appends records without implementing rotation.
The service's standalone default is under the user's `.openrig` directory;
daemon startup overrides it with the active OpenRig home as described above.
## HTTP surface
[`server.ts`](../../../packages/daemon/src/server.ts) mounts these families.
All of them sit behind the daemon's `/api/*` browser boundary
(`middleware/browser-boundary.ts`), which accepts only known host names, and
browser `Origin`s only from the daemon's own UI or `OPENRIG_ALLOWED_ORIGINS`;
the file allowlist is a separate check.
[`filesRoutes`](../../../packages/daemon/src/routes/files.ts) implements:
| Method and path | Result / important behavior |
|---|---|
| `GET /api/files/roots` | Configured roots; an empty list carries a setup hint. |
| `GET /api/files/list?root=…&path=…` | Directory entries with metadata, including dotfiles. |
| `GET /api/files/read?root=…&path=…` | `readAllowedFile` result. |
| `GET /api/files/asset?root=…&path=…` | Raw asset; single byte-range support (`206`, invalid range `416`), five-minute cache. HTML and source files default to plain text and unknown types to `application/octet-stream`; `render=1` opts into HTML rendering. SVG is served as `image/svg+xml`. A `render=1` HTML preview or an SVG is served with no content-security or frame headers, so it can run script in the daemon's origin (`routes/files.ts:153–157`). |
| `POST /api/files/write` | Requires root, path, string content, expected mtime and hash. Stale reads return `409 write_conflict`; an absent write service returns `503`. |
Writes resolve actor/provenance through `resolveActorWithDeferral`. Missing
route dependencies return `503`; unknown roots and invalid paths return
`400`. A failed stat returns `404` on reads, lists and assets; on a write, a
missing or unreadable target returns `500 stat_failed`, because
`POST /api/files/write` only replaces existing files
(`domain/files/file-write-service.ts:120–128`; `routes/files.ts:264–265`). An
audit failure can return `500` even though the file write succeeded, so an
error is not proof of no effect.
## Progress indexing
[`ProgressIndexer.scan`](../../../packages/daemon/src/domain/progress/progress-indexer.ts)
walks configured roots on each request, to a default maximum depth of **6**.
It skips dot-prefixed entries and named build/dependency directories, and
collects regular `PROGRESS.md` and `STEERING.md` files. Unreadable directories
and files are skipped. Consequently, an empty scan does not prove all files
were readable.
The parser skips frontmatter, takes the first H1 as the title, turns
H2–H4 headings into hierarchy rows, and reads checkbox rows as `[x]`/`[X]`
= done, `[~]` = blocked, and `[ ]` = active. Checkbox depth uses two-space
indentation; headings use heading depth. Per-file and aggregate counts cover
checkbox rows. This is a progress-text projection, not proof readiness.
[`GET /api/progress/tree`](../../../packages/daemon/src/routes/progress.ts)
returns the scan. Missing indexer or empty configured roots returns `503`.
## Steering composition
[`steeringOptsFromSettings`](../../../packages/daemon/src/domain/steering/steering-composer.ts)
combines resolved workspace settings with `OPENRIG_STEERING_WORKSPACE`,
`OPENRIG_STEERING_PATH`, `OPENRIG_ROADMAP_PATH`, and
`OPENRIG_DELIVERY_READY_DIR` overrides. `SteeringComposer.compose` reads:
| Field | Source and selection |
|---|---|
| `priorityStack` | Verbatim `STEERING.md`, mtime, and byte count. |
| `roadmapRail` | `roadmap/PROGRESS.md` `[ ]`/`[x]` rows (`[~]` rows are not read); the first unchecked row is marked next. |
| `laneRails` | Every `PROGRESS.md` and `STEERING.md` under `delivery-ready`, using a depth-3 `ProgressIndexer` (lane ID `mode-N` for `mode-N/PROGRESS.md`, else the relative path); first non-done/non-blocked checkbox is next pull. Default top 3 prefers non-done rows, then fills with done rows. |
Missing/unreadable sections become `unavailableSources`. `isReady` means at
least one path resolves; it does not guarantee that every read succeeds.
[`GET /api/steering`](../../../packages/daemon/src/routes/steering.ts)
returns `503` when the composer is absent or no source path resolves. Because
`workspace.steering_path` always has a value (default
`<workspace.root>/STEERING.md`), the daemon's composer is normally ready, and
a missing `STEERING.md` appears as a `priorityStack` read failure in
`unavailableSources` with a `200`, not a `503`.
Queue and health state are fetched through their own endpoints.
## Health summaries
[`healthSummaryRoutes`](../../../packages/daemon/src/routes/health-summary.ts)
exposes four reads under `/api/health-summary`:
| Suffix | Source |
|---|---|
| `/nodes` | `computeNodeHealthSummary`: node inventory aggregated across rigs, grouped by session/lifecycle status, with an attention-required count. |
| `/context` | `computeContextHealthSummary`: `context_usage` rows grouped by urgency and sample freshness. |
| `/version` | `getDaemonVersion`: the build-stamped version, else the daemon package version, else `unknown`; always `200`. |
| `/gateway` | The injected gateway subsystem's `status()`; `503` if unavailable. |
`/nodes` and `/context` return `503 health_summary_unavailable` when the rig
repository is not wired.
The [context aggregation](../../../packages/daemon/src/domain/steering/health-summary.ts)
uses **80%** for critical, **60%** for warning, and **300 seconds** for
freshness. These are constants in this module: they do not follow the
`health.context_pressure.*` settings (defaults 95% and 99%) that the health
detectors use, so the two surfaces can disagree about the same session. A missing usage value is unknown; a missing timestamp has no
freshness sample. The SQL read catches errors and falls back to an empty
sample set, so zero counts alone are not a database-health guarantee.
## Related source
- [Living Notes review](living-notes-review.md) composes proof and scope content.
- [Workspace primitive](workspace-primitive.md) covers workspace identity.
- [File-path tests](../../../packages/daemon/test/files-path-safety.test.ts),
[progress tests](../../../packages/daemon/test/progress-indexer.test.ts), and
[steering tests](../../../packages/daemon/test/steering-composer.test.ts)
exercise these helpers; their presence is not a runtime verification claim.
@@ -1,519 +0,0 @@
---
kind: as-built
title: Coordination Primitive — Stream/Queue/Inbox/Outbox
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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Coordination Primitive — Stream/Queue/Inbox/Outbox
The coordination primitive is the SQLite-canonical durable-work layer the
daemon exposes through `/api/stream` and `/api/queue`. It replaces the POC
filesystem `rigx queue` / `rigx stream` path for daemon-backed work; the POC
filesystem path remains untouched, and the daemon-backed `rig queue` /
`rig stream` commands operate only through the daemon HTTP API, so they write
only to SQLite (`packages/cli/src/commands/queue.ts:19`,
`packages/cli/src/commands/stream.ts:11`).
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. The five host-scoped tables
Five host-scoped tables back the primitive, one per migration `023`–`027` in
`packages/daemon/src/db/migrations/` (**5** tables:
`cat packages/daemon/src/db/migrations/02[3-7]_*.ts | grep -c 'CREATE TABLE'`):
- **`stream_items`** (`023_stream_items.ts`) — L1 append-only intake/audit
root. Columns: `stream_item_id` (TEXT PK; a ULID unless the caller supplies
one, `packages/daemon/src/domain/stream-store.ts:88`), `ts_emitted`,
`stream_sort_key`, `source_session`, `body`, `format` (default `text`),
`hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON),
`interrupt`, `archived_at`, and `identity_provenance` (added by
`067_i3_identity_provenance.ts`). Items immutable after emit (only
`archived_at` may be set, `stream-store.ts:217`).
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue.
Unless `--id` is supplied, the CLI generates the create request's `qitem_id` (TEXT PK) before sending as `qitem-<UTC YYYYMMDDHHMMSS>-<16 hex>` (64 random bits) and prints it to stderr as a request identity, not proof of persistence; reuse that ID with `--id` and the unchanged payload after an unknown outcome, while daemon callers without an ID still use `newQitemId()` and its `qitem-<UTC YYYYMMDDHHMMSS>-<8 hex>` form.
State enum (**8** values, `QUEUE_STATES` at `queue-repository.ts:33`;
`sed -n '/^export const QUEUE_STATES/,/] as const/p' packages/daemon/src/domain/queue-repository.ts | grep -c '^ "'`):
`pending | in-progress | done | blocked | failed | denied | canceled |
handed-off`. Carries `closure_reason`, `closure_target`,
`closure_required_at`, `chain_of_record` (JSON), `blocked_on`,
`handed_off_to`/`handed_off_from`, nudge/heartbeat columns.
- **`queue_transitions`** (`025_queue_transitions.ts`) — L3 append-only
transition log; authoritative audit trail for state evolution. Domain code
never updates its rows. The one delete is the retention runner, which moves
every transition of a terminal item whose last transition is older than the
retention window into `queue_transitions_archive`
(`054_queue_transitions_archive.ts`), insert and delete in one transaction
(`packages/daemon/src/domain/queue-retention.ts:203`). Only `done` and
`handed-off` items are archived; the default window is 30 days, and items
still on a live workflow frontier are skipped (`queue-retention.ts:69`–`75`,
`:158`–`171`).
- **`inbox_entries`** (`026_inbox_entries.ts`) — mailbox-style asynchronous
deposit; idempotent on `inbox_id`. State: `pending | absorbed | denied`
(`INBOX_STATES`, `inbox-handler.ts:5`).
- **`outbox_entries`** (`027_outbox_entries.ts`) — sender-side record;
symmetric to inbox; idempotent on `outbox_id`. Delivery state (**7** values,
`OUTBOX_DELIVERY_STATES` at `outbox-handler.ts:28`;
`grep '^export const OUTBOX_DELIVERY_STATES' packages/daemon/src/domain/outbox-handler.ts | grep -o '"[a-z]*"' | wc -l`):
`pending | sending | delivered | failed | indeterminate | retained |
retired`.
These five are migrations `023`–`027` of the daemon's **96**
(`git ls-files packages/daemon/src/db/migrations | wc -l`), applied in the
order of `ALL_MIGRATIONS` (`packages/daemon/src/db/all-migrations.ts:107`).
Later migrations add columns to these tables; `daemon-core.md` covers the
migration set.
## 2. The six host-scoped services
Six host-scoped domain services in `packages/daemon/src/domain/` implement the
layer (**6** files:
`ls packages/daemon/src/domain/{stream-store,queue-repository,queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts | wc -l`).
Routes import these; services are Hono-free:
- **`stream-store.ts`** — L1 stream: idempotent emit (on `stream_item_id`),
chronological list with cursor pagination plus source, destination, exact-tag,
and inclusive time-window filters, soft archive. `direction=latest` applies
every filter before taking the newest bounded page, then returns that page
chronologically.
- **`queue-repository.ts`** — L3 queue: create (`:1391`), claim/unclaim
(`:2186`, `:2271`), update (general state mutator with hot-potato
strict-rejection on `done`, `:2334`), transactional handoff (close source as
`handed-off` plus create new owned qitem in a single transaction, `:1656`;
`handoffAndComplete`, `:1833`, closes the source as `done` instead),
pod-fallback rerouting (`routeToFallback`, `:3678`), overdue lookup
(`findOverdue`, `:3378`), nudge-result tracking (`recordNudgeAttempt`,
`:3663`). Nothing in the daemon writes the `last_heartbeat` column
(`queue-pickup.ts:16`). Cross-rig validation hook exposed as `validateRig`
constructor option (`queue-repository.ts:717`).
- **`queue-transition-log.ts`** — append-only state-transition log; used by
`queue-repository.ts`, exposed as the read-only property
`QueueRepository.transitionLog` (`queue-repository.ts:666`).
- **`hot-potato-enforcer.ts`** — pure validator for the load-bearing API
contract (see §3).
- **`inbox-handler.ts`** — mailbox handler: drop (idempotent on `inbox_id`,
`:100`), absorb (promotes a pending entry to a `queue_item`, idempotent,
`:155`), deny (records reason, `:215`). The handler has no auth hook
(`inbox-handler.ts:71`); identity comes from the route. `POST /inbox/drop`
takes the sender only from the `X-OpenRig-Session` header through
`requireSenderIdentity` (`routes/queue.ts:1094`) and answers 400
`actor_required` without it. Absorb and deny (`:1121`, `:1135`) use the
header when present; otherwise they accept the body's `receiverSession` and
record it as `claimed:v1` (`routes/require-sender-identity.ts:83`–`92`).
- **`outbox-handler.ts`** — sender-side outbox: idempotent record (`:130`),
delivery-state marks (`markDelivered` `:239`, `markFailed` `:256`,
`markIndeterminate` `:280`), list (`listForSender` `:339`). It also holds the
queue's durable wake intents (ids prefixed `wake-intent-`, `:15`) and the
messages the seat delivery guard retains (`retain` `:181`, `retire` `:217`),
so it is not audit-only. Emits no event-bus events.
Create, handoff and handoff-and-complete return the committed row without
waiting for terminal delivery. Each stages a deterministic
`wake-intent-<qitem>` outbox row in its transaction and delivers it after
commit (`queue-repository.ts:1412`–`1424`, `:1475`–`1483`). The returned
`lastNudgeResult` is therefore normally null; `rig queue show <id>` reads the
wake result later. If create cannot retain the intent, the task is still saved
and the row records `failed:wake not retained: <reason>`. Startup reconciles
and drains intents left pending by a crash once (`startup.ts:2361`–`2362`).
`maybeNudge` remains only as the path for a repository with no outbox.
**Which field answers "did it reach them".** `deliveryOutcome` is the gateway
(human-delivery) ledger, derived at read time
(`QueueRepository.deliveryOutcomeFor`): `posted` or `transport-failed` from the
row's Slack receipt notes, or `never-posted` when a gateway-routed row still
has no receipt once the post window has passed. A task delivered only to an
agent's pane has no such receipt, so it reads `null`. A task that also has a
gateway or human-notification side can carry a value, and the CLI then reports
that gateway side too. For the pane side, read:
- `lastNudgeWireResult`: the wake send's result as stored (for example
`verified`, `delivered-ack-pending`, `indeterminate:…`, `retained:…`,
`failed:…`). A claim never rewrites it; a later wake replaces it.
- `lastNudgeResult`: the result as presented. It is the stored result, except
that a claimed row whose stored result is `delivered-ack-pending` or
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
the stored result again.
- `claimedAt`: when the destination claimed the row. Closing doesn't clear it.
- `pickup`: the derived pickup receipt (`unclaimed`, `working`, `needs-input`,
`stalled-after-claim`, `parked`, `terminal`), computed at read time.
A claimed item whose owner's activity reports outstanding input reads
`needs-input`, with the reason in `pickup.evidence`. That covers an item in
progress and a `pending` item that kept its claim, after an update back to
`pending` or a reopen. A Codex permission request the daemon couldn't confirm on
screen reads `owner may need input (not verified: permission_request_unverified)`.
The input signal can outlast the prompt it reported, so it never pauses
recovery. When the claim would otherwise read `stalled-after-claim`, the receipt
keeps that evidence in `pickup.stall`, and the stuck sweep's finding, its
backstop and manual dispositions behave exactly as for `stalled-after-claim`.
Queue reads, the pickup view and the execution view share this receipt
(`queue-pickup.ts`). Unclaimed, blocked and terminal items keep their receipts.
A claim isn't proof that the body was read or that the terminal render was
verified, and closing a task isn't a new delivery receipt: a claimed task that
is later closed still reads `claimed` when its stored result was unconfirmed
(`delivered-ack-pending` or `indeterminate:…`); a `verified` or `failed:…`
result reads as stored. For such an unconfirmed, claimed item,
`rig queue create --verify` says "Terminal receipt unconfirmed, but the seat
has claimed the item (claimed)."; for any claimed item it adds "A claim shows
the seat took the item; it does not prove the body was read."
(`terminalDeliveryResult`, `packages/cli/src/commands/queue.ts`).
The standing detector in `queue-stuck-sweep.ts` creates findings through the
queue repository, with `evidenceRef: rig queue show <source-qitem-id>` pointing
to the underlying durable work row. Unclaimed and undelivered findings go to
the destination's orchestrator when one is derivable, otherwise to the
destination itself, and that seat may be dead. Overdue claims go to their
destination. Repeated detections reuse the existing finding without changing
its route; when the source condition resolves, the sweep closes its own finding.
Previously, falling out of a reporting query could itself close a delivery or
unclaimed finding. An unconfirmed retry, a fresh human-notification post window,
an unreadable human registry or a larger reporting-age setting could therefore
look like successful recovery. These findings now require source evidence:
closure, pickup or parking of the work, a verified/accepted wake, or a posted
notification for the current human recipient and episode, as appropriate to the
finding. `delivered-ack-pending`, `indeterminate:…`, missing results and unrelated
legacy human receipts do not prove resolution. Uncertainty keeps an existing
finding open without itself sending another wake or creating another finding.
A verified wake resolves a wake failure, not an unclaimed obligation: delivery
does not prove pickup.
A legacy human notice with no owner-notification history can still resolve from
the existing item-scoped posted ledger when its recipient is confirmed human.
This covers older pending asks and parks without inventing an episode that was
never recorded. A registered alias needs a readable registry to establish its
human recipient; reserved human references remain known during an outage.
Legacy receipts carry no recipient key, so a destination reroute requires a new
matching notification episode. A legacy park is accepted only when no earlier
notice or reroute predates it, and later history contains only receipts or
asking-agent reroutes within that park. A re-park, an exit from the park or an
ambiguous append cannot establish the same recipient without owner history;
the finding waits for a new matching episode or other source resolution.
Rerouting only the asking agent within an otherwise unchanged park preserves
its human recipient (`blockedOn`).
Modern notifications still require their current episode, and an unrelated
legacy human receipt cannot resolve an agent wake.
A known hold still leaves recovery with the existing mechanism: an ask on
Steering without an alert, a wake held by the seat typing guard, or a live
quiet-release job. Its closure note names that disposition rather than claiming
delivery or pickup. An unreadable quiet hold is not positive closure evidence.
Claimed work in either `pending` or `in-progress` uses the same pickup derivation;
returning a row to `pending` while retaining its claim no longer hides a later
stall or closes its existing stalled-claim finding. A retained failed wake cannot
override that pickup evidence. The agent's claim still does not establish delivery
of a separate notification to a person.
That automatic closure records `stuck-sweep@system` as the actor in both the
transition and the `queue.updated` audit event, with the source evidence in the
delivery and unclaimed-obligation closure notes. Previously it credited the finding's source agent
with the detector's action. The finding's source,
destination and claim remain its existing ownership records; the underlying work
is not reassigned, and existing transition history is not rewritten.
`queue-stuck-sweep-resolution.test.ts` checks unconfirmed retries, retained
claims, current human-notification episodes and registry outages with migrated
SQLite, including positive recovery controls and no repeated send on an
unresolved retry. `quiet-hold-custody.test.ts` checks existing findings with
readable and unreadable quiet custody. `queue-stuck-sweep.test.ts` checks that
pending and claimed findings close once, name the sweep in their transition and
event, and keep their ownership. The posted-human-notice case also checks the actor while leaving
the person's original ask open. These checks exercise the detector and queue
repository, not a live agent or connector.
The sweep reports six finding kinds. Its custody check covers closures that
name a successor qitem or a seat (`closure_reason = handed_off_to`) in the last
24 hours, plus older closures with open findings. A seat target is satisfied by
a local successor carrying this row's lineage, and any target by a
`custody-verified: <target> …` note on the closed row. A new custody finding for
a human destination goes to the agent that closed the item, using the closing
transition from active or archived history. Later notes and notification
receipts do not change that owner. If the closing actor is missing, human, system
machinery or not admitted by this host's queue, the asking agent receives the
finding if its address is admitted. Human identity includes reserved references
and registered aliases, independent of terminal bindings. Admission uses the
queue's existing address check and does not require a live seat. An agent
destination keeps its existing route; if neither closer nor source is an admitted
agent, the human destination remains the durable fallback. This routing does not
change custody verification or suppress unresolved findings.
If the human registry is unavailable when a custody candidate's owner needs
classification, that candidate keeps its original destination, and one log line
per candidate names it and the registry error. This doesn't infer an agent
identity from an unreadable registry, which may not exist before the first human
is registered. In a pass with no finding to save for a `<name>@external` person,
which includes an install with no registry yet, work bound for agents keeps
routing and repaired findings still close. Such a finding still needs the
registry to admit it when it's saved, so during an outage a pass that must save
one fails at that finding, as it did before this routing. Findings not yet
created in that pass, and the closing of repaired findings, wait for the next
pass that completes. Once the registry can be read again, the next pass creates
the finding and routes it to the closer. Registry recovery restores owner refinement
for new findings; existing findings keep their established route. Reserved human
references and legacy agent names keep their known classifications without a
registry read.
## 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. `validateClosure` (`hot-potato-enforcer.ts:59`) checks a transition
against `CLOSURE_REASONS` (`:18`; **7** values:
`sed -n '/^export const CLOSURE_REASONS/,/] as const/p' packages/daemon/src/domain/hot-potato-enforcer.ts | grep -c '^ "'`):
`state=done` requires `closure_reason ∈ {handed_off_to, denied,
canceled, no-follow-on, escalation, superseded}`. The reasons
`handed_off_to | escalation` additionally require
`closure_target` (`:86`):
- `handed_off_to` — work continues with a different seat (`closure_target` =
new owner).
- `blocked_on` — an active `state=blocked` park record, rejected with `state=done`.
To wait for a qitem, use `rig queue block <id> --on <child>` or
`rig queue update <id> --state blocked --blocked-on <child>`; the item returns
to pending when its blocker completes.
- `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).
- `superseded` — the row was replaced by cancel-and-replace
(`closure_target` = the successor qitem). The update path records it on
`state=canceled` and refuses it there without a `closure_target`
(`queue-repository.ts:2629`).
Tier→SLA mapping for `closure_required_at` also lives in
`hot-potato-enforcer.ts` (`TIER_SLA_SECONDS`, `:115`). This validator is
invoked by `QueueRepository.update()` (and `updateWithinTransaction()`), both
through the call at `queue-repository.ts:2531`, so closure is enforced at the
daemon transaction boundary — the workflow runtime *projects* on closure but
does not otherwise gate it (see `workflow-runtime.md`). The one
workflow-aware check is in the queue: `update()` refuses a terminal close of a
live workflow frontier packet from a non-workflow verb
(`workflow_frontier_packet`, `queue-repository.ts:2557`), through a predicate
startup injects. "Terminal" there is the queue's terminal set, `done` and
`handed-off` (`queue-repository.ts:52`).
Closure fields are stored only on `state=done`, the park record (`blocked`
with `blocked_on`), the handoff close (`handed-off` with `handed_off_to`) and
the superseded cancel; any other transition carrying them is refused with
`closure_fields_not_admitted`, and `blocked_on` outside `blocked` with
`blocked_on_not_admitted`. A `done`, `canceled` or `handed-off` row accepts a
note without a state change as an append-only transition; moving it to
another state requires an explicit reopen with a note. A `qitem-` blocker must
exist and be live on this daemon; when a blocker leaves the active states,
each row parked on it follows the blocker's handoff successor or returns to
`pending` with a durable wake (`queue-repository.ts:2407`–`2529`,
`:2612`–`2656`, `:2931`–`3008`). Claiming a parked row clears its
`blocked_on`; the claim transition keeps the former gate as a `closure_target`
audit pointer with no closure reason, which a re-park without `--blocked-on`
reuses (`queue-repository.ts:2233`–`2254`, `:2571`–`2577`).
`human-route-enforcer.ts` is a second pure validator at the same boundary: a
`human-gate` row, a row addressed to a human seat, or a park on one must carry
`summary` and `evidence_ref`.
## 3b. Cross-host queue routing
Three queue write routes are host-aware — `POST /create`,
`POST /:qitemId/handoff` and `POST /:qitemId/handoff-and-complete` (**3**:
`grep -c '!resolvesToLocalHost(body.hostId, getSelfHostId())' packages/daemon/src/routes/queue.ts`):
a write body may carry an out-of-band `hostId` envelope. The destination
session stays `member@rig`; the 3-part `agent@rig@host` form is CLI input
sugar that `resolveQueueHostDestination`
(`packages/cli/src/commands/queue.ts:394`) splits into the destination and
`hostId` before the request leaves the CLI. The same split applies to
`--host <id>`; queue verbs never follow `rig host select`, and naming two
different hosts is refused with `host_qualifier_conflict`. The mechanism
follows the forward-then-strip shape of mission-control's remote action
(`routes/mission-control.ts:352`): one shared route-layer helper
(`forwardQueueWrite`, `routes/queue.ts:214`) resolves the host registry
daemon-side (bearers never reach the caller), rejects ssh-declared hosts
(`unsupported-transport` — the daemon→daemon path is http-only), and forwards
the whole body, with `hostId` stripped, via `remoteJsonRequest` (`:248`) under
a named write-class deadline (`QUEUE_FORWARD_TIMEOUT_MS`, `:41`). The origin's
response returns verbatim; failures map to a structured host-named error
(`remote_queue_write_failed`, HTTP 502, `:234`) whose `failureClass` is one of
registry / unknown-host / unsupported-transport / unreachable / auth-failed /
remote-error. No local row is ever written on the cross-host path.
**The model: origin-owns-the-record, at-least-once + idempotent,
message-passing closure (never 2PC).**
- **Origin-owns-the-record.** The qitem lives in the TARGET host's DB; that
row is THE record. The target daemon wakes the destination on ITS local
tmux: its own `create` stages a durable wake intent and delivers it after
commit (§2; the forwarded body carries the `nudge` flag) — the sending
daemon never reaches across a host boundary. Before forwarding, the
forwarding daemon stamps its own host id, when it has one, onto the source
session (`stampSelfHostSuffix`, `queue-repository.ts:548`; called at
`routes/queue.ts:378` and `:509`), so the target row records the sender as
`member@rig@<forwarding host>`.
- **Idempotency.** On create, the forwarding daemon mints the `qitemId` before
the forward unless the caller supplied one (`routes/queue.ts:501`). The
forward is a single request, so a retry dedups only when it carries the same
id (a caller re-sending `--id`, or a handoff re-drive, whose successor id is
derived). Dedup rides the existing `qitem_id TEXT PRIMARY KEY`. On PK
conflict the origin returns the stored row when destination and source
match (idempotent absorb) and a structured `qitem_id_reuse` error (409) when
they differ (`QueueRepository.create()` catch path,
`queue-repository.ts:1431`–`1472`, with `isQitemPrimaryKeyConflict`,
`:461`). When destination and source match but the body differs, the stored
row is returned unchanged with `createWarning: qitem_body_not_saved`; the
supplied body is not saved (`:1450`–`1454`).
- **Cross-host handoff choreography.** The local atomic close+create cannot
span two DBs, so the route-layer choreography (`crossHostHandoff`,
`routes/queue.ts:316`) runs: successor-create on the target host FIRST (via
the one forward helper, `:397`), local source-close SECOND
(`QueueRepository.closeCrossHostHandoffSource`, `queue-repository.ts:2031`)
— never the reverse. A crash between the two leaves a live duplicate that
the idempotent re-drive converges; the reverse order would leave a closed
source pointing at a successor that does not exist (a dropped potato — the
one forbidden outcome). The successor id is DERIVED, not minted:
`deriveCrossHostSuccessorId(source, destination, host)`
(`queue-repository.ts:489`) → `qitem-xh-<sha256[:16]>` — a pure stateless
function, so a re-drive re-derives the same id across daemon restarts and
absorbs on the target PK. *(Residual, inherent to at-least-once delivery
without 2PC: a re-drive naming a DIFFERENT destination derives a different
id and cannot absorb the earlier successor — that orphan stays visible via
the chain + provenance tags; the source-close conflict check surfaces the
disagreement.)*
- **Closure across the boundary.** The source closes with
`closure_reason=handed_off_to` and
`closure_target=<successor qitem id>@<host>` (`routes/queue.ts:351`); a
source already closed with the older `member@rig@<host>` target keeps it,
and a re-drive matches against it (`:352`–`356`). `closure_target` is OPAQUE
audit metadata, never parsed for routing; the standing stuck sweep reads it
only to check successor custody (`queue-stuck-sweep.ts:519`–`554`). The
host-qualified key appears only in the `closure_target` column on
`queue_items` and its verbatim mirror on `queue_transitions`: the minted
cross-host close note names the 2-part `toSession` only
(`queue-repository.ts:2087`), and `handed_off_to` and the
`queue.handed_off` event stay 2-part.
Re-drive semantics: already-terminal + MATCHING `closure_target` = absorb;
MISMATCH = structured `cross_host_close_conflict` (409), checked in the route
before any forward (`routes/queue.ts:359`) and again in the repository. The
successor carries `chain_of_record = [...source.chain, source.qitemId]`
(`routes/queue.ts:384`) — A-side ids are opaque lineage identifiers on B
(they do not dereference in B's DB) — plus provenance tags `cross-host` +
`from-host:<self-declared name>` (the forwarding daemon's OS hostname,
`routes/queue.ts:54`; honest best-effort, not authenticated identity).
- **Boundary discipline.** Claim, update and inbox routes take no `hostId` and
stay local (after a cross-host handoff the successor lives where its worker
lives). Without a `hostId`, with `local`, or with this daemon's own resolved
host id (an exact, case-sensitive match), create and handoff take the
local path; a self-addressed handoff whose deterministic cross-host
successor an earlier self-forward already created runs that cross-host
create and close in process. The
hot-potato validation contract (§3) is unweakened across the boundary: the
cross-host close always records `handed_off_to` with a target.
## 4. Coordination events
The `RigEvent` union (`packages/daemon/src/domain/types.ts:108`) has **98**
members
(`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`);
this module covers only the coordination families below.
Coordination events emitted by these services: `stream.emitted`
(`StreamStore.emit`, declared at `types.ts:245`); `queue.created` /
`queue.handed_off` / `queue.claimed` / `queue.unclaimed` / `queue.updated` /
`qitem.fallback_routed` / `qitem.closure_overdue` (QueueRepository); `inbox.absorbed` (`types.ts:255`) /
`inbox.denied` (InboxHandler). `qitem.closure_overdue` (`types.ts:254`) is
emitted by `QueueRepository.recordClosureOverdue` when swept by `queue-stuck-sweep`
and passed through the queue watch filter (**2** files contain its literal:
`git grep -l -F 'type: "qitem.closure_overdue"' -- packages/daemon/src | wc -l`).
The `stream|queue|inbox|qitem` families declare **10** event types (`stream` 1
+ `queue` 5 + `inbox` 2 + `qitem` 2;
`sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -oE '"(stream|queue|inbox|qitem)\.[a-z_]+"' | sort -u | wc -l`).
Two SSE surfaces stream coordination events (**2**:
`cat packages/daemon/src/routes/stream.ts packages/daemon/src/routes/queue.ts | grep -c 'return streamSSE('`):
`/api/stream/watch` (aliased `/api/stream/sse`, `routes/stream.ts:194`–`195`),
which first replays up to 50 unarchived items in chronological order (the
oldest, `:174`–`181`) and then streams new items, and `/api/queue/watch`
(aliased `/api/queue/sse`, `routes/queue.ts:1054`–`1055`) for queue/inbox
events — every coordination type except `stream.emitted` and `queue.updated`
(filter at `routes/queue.ts:1031`–`1038`). The event log remains append-only and
SQLite-backed. `rig stream watch` is a thin, single-connection consumer of
`/api/stream/sse` (`packages/cli/src/commands/stream.ts:191`); it does not add
a daemon route or reconnect policy.
## 5. Route surface
The TUI FEED reads `/api/attention` for human requests and current explicit
human-addressed updates. Its independent update query filters open states and
strict human destinations before the 1001-row sentinel, serves at most 1000
updates with critical then urgent rows first, and requires no transport receipt. These local
FYIs create no approval obligation. `/api/queue/human-updates` remains confirmed
delivery history; the TUI suppresses a history card when the same queue item is
in the current update window. Current-update coverage is independent of the
receipt window, so an unavailable current reader cannot retain closed delivery
history beyond a successful history read. Both reads leave queue state intact.
Clients that need only current FYIs can use `GET /api/queue/open-human-updates`.
It returns `{items, limit, truncated}` with open human-addressed updates, ordered
critical then urgent then routine, without requiring a delivery receipt. `limit`
defaults to 20 and accepts integers from 1 to 100. Optional `view` follows the
same person perspective as `/api/attention`; a perspective read scans at most
1000 rows and signals `truncated` when the window may omit matching items.
A finished item's result file is its `evidenceRef`, read through the daemon's
shared roots with `rig_artifact_read` (MCP) or `rig queue artifact <qitemId>
--json` (CLI). For a code result, the assignee writes a JSON file of kind
`openrig.code-result/v1`, with `repo` (an absolute path or a URL), `base` and
`revision` (full commit ids, SHA-1 or SHA-256), and optionally `paths`, `branch`
and `fetch: {remote, ref}`. Both reads then add `codeResult`, the exact pair a
client can diff. A manifest whose base or revision isn't a full commit id gets
`codeResultIssue` instead. `branch` and `fetch` are hints for finding the commits,
never the revision. Other files read as before.
- `/api/stream` (`server.ts:783`) — `POST /emit` (`routes/stream.ts:56`),
`GET /list` (`:91`, including `sourceSession`, `hintDestination`, `hintTag`,
`since`, `until` and `direction` filters), `GET /watch` + `/sse` SSE
(`:194`), `GET /:streamItemId` (`:198`), `POST /:streamItemId/archive`
(`:207`).
- `/api/queue` (`server.ts:784`) — `POST /create` (`routes/queue.ts:450`),
`POST /:qitemId/claim` (`:545`), `POST /:qitemId/unclaim` (`:561`),
`POST /:qitemId/update` (`:600`), plus handoff (`:650`, `:732`),
`POST /:qitemId/fallback` (`:808`), list (`:902`) and watch (`:1054`)
surfaces; reads `GET /whoami`, `/attention-aggregate`, `/human-updates`,
`/overdue`, `/undelivered`, `/recent-transitions`, `/:qitemId/transitions`
and `/:qitemId` (`:822`–`1067`); inbox routes `POST /inbox/drop`,
`/inbox/:id/absorb`, `/inbox/:id/deny` and `GET /inbox/pending`,
`/inbox/list` (`:1076`–`1152`); outbox routes `POST /outbox/record` and
`GET /outbox/list` (`:1161`, `:1190`).
Every write route derives its actor through `requireSenderIdentity`
(`routes/require-sender-identity.ts:76`): the `X-OpenRig-Session` header wins
over a body actor. On a direct request from a known origin it is recorded as
`transport:v1`; `resolveRecordedProvenance` (`:199`) instead records
`origin-unknown:v1` when the origin-unknown marker is set or a relayed request
carried `origin-unknown:v1` (`:207`), `relay:v1` for a relayed request that
carried `transport:v1`, and `claimed:v1` for other relayed requests. `transport:v1`
says the name came from that header, which the CLI sets from the seat's
`OPENRIG_SESSION_NAME`; it isn't an authentication. Without the
header, the
body actor is recorded as `claimed:v1`; with neither (or, for inbox drop,
without the header) the route answers 400 `actor_required`. The stamp lands in
`identity_provenance` on transitions, inbox, outbox and stream rows
(migrations `067`, `082`).
## See also
- `daemon-core.md` — daemon wiring, the migration set, route surface.
- `workflow-runtime.md` — the workflow runtime that projects on closure.
- `mission-control.md` — queue observability over `queue_items`.
- Source roots: `packages/daemon/src/domain/{stream-store,queue-repository,
queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`,
`packages/daemon/src/routes/{stream,queue}.ts`.
-242
View File
@@ -1,242 +0,0 @@
---
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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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`), the terminal UI (`@openrig/tui`), the web UI (`@openrig/ui`)
and the MCP server all sit on top of.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. System overview
For what OpenRig is and how its packages fit together, read `ARCHITECTURE.md` at
the repository root. This module covers the daemon's own wiring.
### Source footprint at `e8f0ab340db773392ec8be75b072d1c0f3068a50`
The footprint counts use non-test TypeScript files under each package's `src/`
(tests live in separate `packages/*/test/` directories):
`find <dir> -type f \( -name '*.ts' -o -name '*.tsx' \) ! -name '*.test.*' | wc -l`.
| Metric | Count | Directory or command |
|---|---|---|
| All packages | **1205** | `packages/*/src` |
| Daemon | **660** (**431** under `domain/`, **29** under `adapters/`) | `packages/daemon/src`, `…/src/domain`, `…/src/adapters` |
| CLI | **174** | `packages/cli/src` |
| Web UI | **304** | `packages/ui/src` |
| TUI | **67** | `packages/tui/src` |
| Database migrations | **96** (`001_core_schema.ts` … `097_thread_part_map.ts`; no `093`) | `git ls-files packages/daemon/src/db/migrations \| wc -l` |
| Files in `routes/` | **68**, of which **65** create a Hono router | `git ls-files packages/daemon/src/routes \| wc -l`; `git grep -l 'new Hono' -- packages/daemon/src/routes \| wc -l` |
| `app.route(...)` mounts in `server.ts` | **69** | `grep -c 'app.route(' packages/daemon/src/server.ts` |
| Top-level `rig` commands | **87** | `grep -c 'program.addCommand(' packages/cli/src/index.ts` |
| MCP tools | **18**, all named `rig_*` | `grep -c 'server.tool(' packages/cli/src/mcp-server.ts` |
| Runtime adapter classes | **5**: Claude Code, Codex, Pi, Stub, Terminal | `git grep -l 'implements RuntimeAdapter' packages/daemon/src \| wc -l` |
`OmpRuntimeAdapter` (Oh My Pi) extends `PiRuntimeAdapter`, so the daemon wires
six runtime keys — `claude-code`, `codex`, `pi`, `omp`, `stub`, `terminal`
(`startup.ts:989`).
### The stack
```text
CLI (87 top-level commands) / TUI / web UI / MCP (24 tools)
|
v
Hono daemon routes (69 app.route() mounts + direct handlers for /healthz,
spec export, unknown /api paths and the static UI)
|
+-- rigspec routes (pod-aware and legacy spec formats)
+-- env routes (status / logs / down)
+-- transport routes (send / capture / broadcast)
+-- transcript routes (tail / grep)
+-- ask routes (context evidence)
+-- chat routes (durable rig messaging + SSE)
+-- spec review / spec library routes
+-- whoami identity route
+-- coordination routes (stream / queue / workflow / mission control)
|
v
Framework-free domain services (431 files under packages/daemon/src/domain)
|
+-- SQLite state (96 migrations)
+-- tmux / cmux / resume adapters
+-- runtime adapters (Claude Code / Codex / Pi / Oh My Pi / Stub / Terminal)
+-- rig environment services (compose adapter, service readiness, orchestrator)
+-- transport / transcript / chat / ask layers
+-- whoami identity service (including context usage)
```
The core product loop: `down (auto-snapshot) → up <rig-name> (auto-restore) →
handoff → inspect/attach → work → repeat`.
## 2. Database schema
The migrations live in `packages/daemon/src/db/migrations/` (96 files,
`001_core_schema.ts` … `097_thread_part_map.ts`, with no `093`).
`ALL_MIGRATIONS` (`packages/daemon/src/db/all-migrations.ts:107`) lists all 96, and `createDaemon`
applies them with `migrate(db, ALL_MIGRATIONS)` (`startup.ts:298`). `migrate.ts`
sorts them by name (`:29`) and records each applied name in `schema_migrations`
(`:15`).
### Core state tables
`rigs` (topology container, `001_core_schema.ts:7`), `nodes` (logical node
identity, `:17`) and `edges` (logical topology relationships, `:30`); then
`bindings` (physical tmux/cmux surface attachment) and `sessions` (live
execution state) in `002_bindings_sessions.ts`, `events` (append-only event log)
in `003_events.ts`, `snapshots` (serialized rig state) in `004_snapshots.ts`, and
`checkpoints` (per-node recovery state) in `005_checkpoints.ts`.
### Pod-aware schema
- `014_agentspec_reboot.ts` — the pod-aware schema; adds the `pods` and
`continuity_state` tables and pod-aware columns on `nodes`, `sessions` and
`checkpoints`.
- `015_startup_context.ts` — persisted startup replay context for restore.
- `016_chat_messages.ts` — durable rig-scoped chat (SQLite-backed; transcripts
remain filesystem-backed, written by bounded periodic `capture-pane` snapshots;
see `transport-and-transcripts.md`).
- `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, workflow, mission control and workspace migrations
- `023_stream_items.ts` … `027_outbox_entries.ts` — the coordination tables
(stream, queue, queue transitions, inbox, outbox; detail in
`coordination-primitive.md`).
- `028_project_classifications.ts`, `029_classifier_leases.ts`,
`030_views_custom.ts` — classifier and view tables.
- `031_watchdog_jobs.ts`, `032_watchdog_history.ts` — watchdog tables.
- `033_workflow_specs.ts`, `034_workflow_instances.ts`,
`035_workflow_step_trails.ts` — workflow runtime tables (detail in
`workflow-runtime.md`). `036_watchdog_policy_enum_extension.ts` runs no DDL (its
SQL is a no-op `SELECT 1 WHERE 0`); it documents an enum extension.
- `037_mission_control_actions.ts` — the mission control audit table
(`037_mission_control_actions.ts:58`; detail in `mission-control.md`).
- `038_workspace_primitive.ts`, `039_queue_target_repo.ts` — the typed workspace
primitive.
- `040_workflow_specs_diagnostic.ts` — `ALTER TABLE workflow_specs ADD COLUMN`
for parser/validator diagnostics (no new table).
Migrations `041`–`092` and `094`–`097` continue in the same directory; list them with
`git ls-files packages/daemon/src/db/migrations`. `095_rig_non_interruptive.ts` adds
`rigs.non_interruptive` (0 or 1, default 0), the saved per-rig choice behind
`rig up --non-interruptive`; `096_rig_install_root.ts` adds `rigs.install_root`, the rig's
bundle materialization folder (`rig-repository.ts:169`). The newest, `097_thread_part_map.ts`,
adds `thread_part_map`, which maps each message OpenRig posts into an ask's Slack thread to
that ask and its seat.
The package, bootstrap and discovery tables remain: `packages`
(`008_packages.ts`), `package_installs` and `install_journal`
(`009_install_journal.ts`), `bootstrap_runs`, `bootstrap_actions` and
`runtime_verifications` (`011_bootstrap.ts`), and `discovered_sessions`
(`012_discovery.ts`).
## 3. Route-mount surface
`createApp(deps)` (`packages/daemon/src/server.ts:442`) mounts **69**
`app.route()` route groups (`server.ts:723`–`839`) plus direct handlers:
`GET /healthz` (`:664`), `GET /api/rigs/:rigId/spec` (`handleExportYaml`,
`:735`), `GET /api/rigs/:rigId/spec.json` (`handleExportJson`, `:736`), a JSON
`404 not_found` for any other `/api/*` path (`:844`), and the static/deep-link
`app.get("*")` catch-all (`:853`).
The 69 is the count of `app.route(` lines in `server.ts`; `packages/daemon/src/routes/`
has 68 files, 65 of which create a Hono router.
Mount families include the rig, session and spec routes plus the coordination
routes (`/api/stream` `server.ts:783`, `/api/queue` `:784`, `/api/workflow`
`:789`, `missionControlRoutes(...)` `:792`), `/api/seat` (`:781`, the seat
lifecycle verbs such as handover, launch, continue, stop and the typing guard),
`/api/startup` (`:747`), `/api/terminal` (`:809`), `/api/telemetry` (`:822`),
`/api/health-summary` (`:829`), `/api/rigs/:rigId/env` (`:835`) and
`/api/restore-check` (`:836`). List every mount with
`grep -n 'app.route(' packages/daemon/src/server.ts`.
Before the routes, `createApp` installs middleware in this order: the request-phase
observer (`:497`), JSON body-parse error tracking, which turns a malformed body into
`400 invalid_json` (`:499`), dependency injection, route timing, slow-operation
recording, the browser boundary on `/api/*` (`:647`, Host and Origin checks; see
`OPENRIG_ALLOWED_HOSTS` and `OPENRIG_ALLOWED_ORIGINS`), and host read-through.
`createAppWithWebSocket(deps)` (`server.ts:893`) sets `enableNodeWebSocket` and
calls `createApp`; the terminal WebSocket is registered only when the web UI is
also enabled (`server.ts:756`). Otherwise only its bearer-auth handler is
registered (`registerTerminalAuthOnly`, `:762`).
## 4. Startup sequence (`createDaemon`)
`createDaemon(opts?)` is `packages/daemon/src/startup.ts:287` (async, returns
`DaemonResult`). It returns `{ app, db, deps, contextMonitor, eventLoopMonitor,
injectWebSocket }` (`startup.ts:2506`). In source order, it:
1. Opens SQLite and applies all 96 migrations (`migrate(db, ALL_MIGRATIONS)`,
`startup.ts:298`).
2. Constructs the coordination stores early: `StreamStore` (`:330`),
`QueueRepository` (`:428`) and `OutboxHandler` (`:443`).
3. Constructs `TranscriptStore` (`:555`) and the rig environment services
`ComposeServicesAdapter` and `ServiceOrchestrator` (`:644`–`645`).
4. Constructs `StartupOrchestrator` (`:776`) and the runtime adapters:
`ClaudeCodeAdapter` (`:778`), `CodexRuntimeAdapter` (`:779`),
`PiRuntimeAdapter` (`:782`), `OmpRuntimeAdapter` (`:783`) and
`StubRuntimeAdapter` (`:788`); the terminal adapter is created inline in the
runtime adapter maps (`:989`, `:1259`).
5. Constructs `PodRigInstantiator` (`:985`), `PodBundleSourceResolver` (`:1014`)
and `BootstrapOrchestrator` (`:1016`).
6. Constructs `ContextUsageStore` (`:1095`), `ResumeMetadataRefresher`
(`:1129`), `SpecReviewService` (`:1154`) and `WhoamiService` (`:1157`).
7. Builds `AppDeps` (`const deps: AppDeps`, `:1191`). `SessionTransport`
(`:1262`), `ChatRepository` (`:1296`), `InboxHandler` (`:1300`), `AskService`
(`:1349`) and `SpecLibraryService` (`:1387`) are constructed inside that
object literal. `InboxHandler` receives the same queue repository instance
(`queueRepoInstance`) that serves `/api/queue`, so absorbed inbox items and the
queue route write to one store.
8. Constructs `ContextMonitor` (`:2407`), then assigns later fields onto `deps`
(among them the terminal service, the request-phase observer and the gateway
subsystem).
9. Calls `createAppWithWebSocket(deps)` (`startup.ts:2504`) to mount the full
route tree.
Node inventory is a set of functions (`getNodeInventory` and friends in
`domain/node-inventory.ts`), imported where needed rather than constructed at
startup.
The daemon entrypoint `packages/daemon/src/index.ts:306` calls
`createDaemon({ dbPath, bearerToken, terminalBearerToken, … })`.
## 5. Test files
A static count of tracked test files at `e8f0ab340db773392ec8be75b072d1c0f3068a50` (no pass counts are claimed
here; CI runs the suites in `.github/workflows/tests.yml`): daemon **927**,
CLI **261**, web UI **198**, TUI **105**
(`git ls-files 'packages/<package>/**/*.test.ts' 'packages/<package>/**/*.test.tsx' | wc -l`).
## See also
- `ARCHITECTURE.md` (repository root) — what OpenRig is and where to add things.
- `coordination-primitive.md` — the stream/queue/inbox/outbox coordination layer.
- `agent-spec-and-startup.md` — spec parsing/resolution/startup contract.
- `lifecycle-snapshot-restore.md` — snapshot/restore/continuity.
- Source roots: `packages/daemon/src/{startup.ts,server.ts,index.ts}`,
`packages/daemon/src/db/{all-migrations.ts,migrate.ts}`,
`packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts`.
@@ -1,437 +0,0 @@
---
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-primed vs stop-and-ask), enforces restore honesty, consults
live continuity state, or how the daemon-side restore-check readiness probe
and the CLI restore-packet command work.
siblings: [daemon-core.md, agent-spec-and-startup.md, transport-and-transcripts.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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 main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. Snapshot / restore / continuity types
All in `packages/daemon/src/domain/types.ts`. The spec/projection types live in
`agent-spec-and-startup.md`; the snapshot/restore types live here.
- **NodeRestoreOutcome** (`types.ts:500`) — the per-node restore outcome
projected into `rig ps`. **9** values
(`grep '^export type NodeRestoreOutcome' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
`resumed`, `rebuilt`, `fresh`, `fresh-primed`, `awaiting-decision`,
`failed`, `attention_required`, `operator_recovered`, `n-a`. The restore
result itself, `RestoreNodeResult.status` (`types.ts:475`), has the same
values without `n-a`, plus `left_attached`: **9**
(`grep -E '^ status: "resumed"' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`).
`left_attached` isn't a `NodeRestoreOutcome`; the inventory projects it as `n-a`.
Where `restore-orchestrator.ts` sets each one:
- `resumed` — `baseStatus = "resumed"` after a legacy resume (`:1030`); the
status is returned only by `finishJoinedResume` (defined `:1320`, called
`:1244` and `:1311`, returning `resumed` at `:1380`) after it has rebound
and verified the pane; otherwise that becomes `attention_required`
(`:1352`, `:1376`).
- `rebuilt` — a checkpoint file was written (`:1090`).
- `fresh-primed` — the default for a deliberate non-resume launch
(`:987`).
- `fresh` — only the skip path for a pod node whose live continuity state
is already `restoring` (`:785`).
- `awaiting-decision` — the seat could not resume and nothing is running:
before launch (`:820`), or after launch once the blank session is rolled
back (`:1026`, `:1048`, `:1061`, `:1077`, `:1236`).
- `attention_required` — a live session waiting on a runtime prompt
(`:1039`, `:1262`) or a harness that never started (`:1125`).
- `failed` — occupant ambiguity (`:772`, `:969`), launch failure (`:911`),
a checkpoint that cannot be delivered (`:1086`, `:1092`), a missing
required startup file (`:1161`), or a startup error (`:1271`, `:1282`,
`:1292`, `:1300`).
- `operator_recovered` — only from later reconciliation,
`reconcileNodeRuntimeTruth` (`:1590`, event at `:1727`).
- `left_attached` — a seat joined with `rig attach` as an external CLI,
returned first in `restoreNodeWithCompensation`: nothing is launched and
its binding and claim stay as they were. It doesn't count against the
rollup, and its forecast in `restore-plan-preview.ts` is `leave-attached`.
- **RestoreRigResult** (`types.ts:444`) — the rig-level rollup, **4** values
(`grep '^export type RestoreRigResult' packages/daemon/src/domain/types.ts | grep -o '"[^"]*"' | wc -l`):
`fully_restored`, `partially_restored`, `failed`, `not_attempted`.
`rollupRestoreRigResult` (`restore-orchestrator.ts:81`) produces only the
first three; `not_attempted` is set when pre-restore validation fails
(`:281`).
- **SnapshotData** (`types.ts:351`) — the serialized snapshot payload. It has
**8** optional fields, kept optional so older snapshots still load
(`sed -n '/^export interface SnapshotData/,/^}/p' packages/daemon/src/domain/types.ts | grep -c '?:'`):
`activeSessionIdByNode?`, `activeOccupantsByNode?`, `periodicResumeSourcesByNode?`,
`topologyRoster?`, `pods?`, `continuityStates?`, `nodeStartupContext?`, `envReceipt?`.
Periodic snapshots retain a down agent's whole authoritative resume-source
session in `periodicResumeSourcesByNode`, without marking it running. A named
single-agent launch using automatic periodic selection can resume that exact
carried source; an explicit snapshot or whole-rig restore still asks about an
absent agent with resumable history. Tokenless and non-resume-policy agents
keep their existing fresh path. Only the node's newest session with a token
and `resume_if_possible` policy is carried; a current fresh-successor relation
must agree. Named launch rechecks the live newest session, resume policy and
fresh-successor relation under its lifecycle lease, before changing any seat
state; a source retired after capture stops and asks without launching.
Idle-shell carry advice uses automatic selection again, not an
explicit snapshot that disables carry. The selected source is shared by preflight and post-launch
restore; explicit reviewed-history selection keeps its existing checks.
Ranking, periodic cadence and `--fresh` are unchanged. Retention is best-effort
if the earlier-source lookup fails: capture still records current crash
insurance and logs that the resume sources were unavailable.
- **NodeStartupSnapshot** (`types.ts:344`) — persisted restore replay input:
classification-free projection entries, resolved startup files, startup
actions, runtime.
- **PersistedProjectionEntry** (`types.ts:332`) — the classification-free
restore replay seam: persists only entry identity + source metadata, NOT
stale `classification`, `conflicts`, or `noOps` (Architecture Rule 10).
Restore rebuilds each entry as `safe_projection` with empty `conflicts` and
`noOps` (`restore-orchestrator.ts:1177`, `:1182`, `:1183`).
## 2. Snapshot, restore, continuity domain services
All under `packages/daemon/src/domain/`:
- `checkpoint-store.ts` — checkpoint persistence with pod/continuity context.
- `snapshot-capture.ts` — captures pods, continuity state, startup replay
context, the latest env receipt, the active-occupant maps and the intended
topology roster. `captureSnapshot` (`:60`) derives the occupant maps
(`:73–79`), resolves the roster (`:81–90`), then reads `pods` (`:96`),
`continuity_state` (`:100`) and `node_startup_context` (`:106`) rows per rig
and the env receipt (`:116–123`).
- `snapshot-repository.ts` — snapshot CRUD and restore-source selection:
`findLatestRestoreUsable` (`:72`) and `selectRestoreUsable` (`:94`), which
accept only snapshots that pass `isRestoreUsableSnapshotData` (`:250`).
- `restore-orchestrator.ts` — resume, checkpoint delivery, startup replay,
live continuity consultation, topology ordering (`computeRestorePlan`,
`:676`, ordering only `delegates_to` and `spawned_by` edges, `:71`, among
the roster's intended nodes, `:677`), and service boot gating before agent
restore (`serviceOrchestrator.boot`, `:331`).
- `restore-preconditions.ts` — `validatePreRestore` (`:7`), the one
pre-restore validation shared by restore and restore-check (§3, §5.1).
- `restore-topology.ts` — `resolveSnapshotRestoreTopology` (`:12`), which
splits a snapshot's nodes into intended and excluded (§3).
- `restore-attempt-receipt.ts` — `deriveRestoreAttemptReceipt` (`:42`), the
read-only per-attempt receipt (§4).
- `rehydrate-eligibility.ts` — `snapshotMatchesCurrentOccupants` (`:20`) and
`assessCurrentStateRehydrateEligibility` (`:69`), shared by `routes/up.ts`
and `routes/restore-check.ts`.
- `active-occupant.ts` — the shared rule for which snapshot session row is a
node's occupant (`resolveActiveSnapshotSession`, `:73`).
## 3. Restore flow and restore honesty
Restore behavior, each point checked in `restore-orchestrator.ts`:
- Selects the snapshot with `selectRestoreUsable` even when a snapshot id is
given (`:251–252`), so wrong-rig and unusable snapshots are refused before
any mutation. The selection (`RestoreSnapshotSelection`, `types.ts:396`)
records whether it was explicit or automatic, why, and any newer usable
alternative; it is carried on the result and on `restore.started`.
- Restores only the nodes the snapshot's topology roster intends
(`SnapshotData.topologyRoster`, `types.ts:370`). The roster comes from the
latest `topology.roster_recorded` event (`snapshot-capture.ts:171`), from
`rig snapshot <rigId> --intended-seats` (`packages/cli/src/commands/snapshot.ts:20`),
or, for older snapshots, all current nodes. `resolveSnapshotRestoreTopology`
(`restore-topology.ts:12`) reports every other node as excluded with reason
`historical_not_in_intended_roster` (`:27`); the result carries
`intendedRoster` and `excludedNodes` (`types.ts:440–441`).
- Resolves each node's occupant from the snapshot's recorded active-occupant
maps, not by picking the newest session row
(`resolveActiveSnapshotSession`, `active-occupant.ts:74`, called at
`restore-orchestrator.ts:774` and `:971`). An ambiguous occupant fails the
node loudly (`activeOccupantAmbiguityError`, `active-occupant.ts:97`).
- Consults live `continuity_state`; preserves state when a node is already
`restoring` (`:777–791` — `SELECT status FROM continuity_state …` at
`:780`; if `status === "restoring"` the node is skipped with a warning and
reported as `fresh`, `:783–785`; `degraded` only adds a warning).
- Reuses the seat's durably bound tmux session name when the prior binding
has a usable one, and otherwise falls back to the derived name. A missing
binding after the latest session's recorded intentional exit is quiet;
unclean/legacy missing bindings and incomplete or invalid binding rows still warn.
- 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** (`:1161` — status `"failed"`, error
"Missing required startup files: …"). Before any mutation,
`validatePreRestore` (`restore-preconditions.ts:7`, called from
`RestoreOrchestrator.restore`) finds a node that will consume replay and is
missing a required startup file (`required_startup_file_missing`, or
`startup_owner_root_missing`). Restore holds only that node: it is not
launched and is reported `failed` ("Not launched: …" with the blocker's
message and remediation), and the rest of the rig restores. When every node
the restore would launch is held, or for any other blocker, the whole restore
stops before any mutation (outcome `pre_restore_validation_failed`). Its other
blockers are `invalid_snapshot_data` (`restore-preconditions.ts:27`),
`invalid_topology_roster` (`:78`), `checkpoint_missing_node_cwd` (`:91`),
`service_rig_root_missing` (`:187`) and `service_compose_file_missing`
(`:197`). The `/api/up` route returns
that outcome as HTTP 409 with `status: "not_attempted"`
(`routes/up.ts:162`). A node that resumes its exact native session replays
no startup files, actions, guidance or skills (`replayContained`,
`restore-orchestrator.ts:1114`); its saved Claude activity-hook selection
is reapplied before native resume. A skipped hook reapply adds a warning
without changing the restore outcome.
- Writes a **transcript boundary marker before re-launch**
(`writeBoundaryMarker`, `:900`, called before `launchNode` at `:911`) when
the transcript store is enabled.
- Refuses to restore over live sessions (`:259–262` — `rig_not_stopped`: "Rig …
has live sessions. Stop the rig with 'rig down' before restoring, or use the
latest auto-pre-down snapshot."). A tmux probe that fails also blocks it
(`classifyRunningSessions`, `:612`).
- Checks that the harness actually resumed. The resume adapters judge the
pane with `assessNativeResumeProbe` (`domain/native-resume-probe.ts:84`). A
pod-aware resume counts as resumed only when startup reports `resumed`
continuity or the launched session row carries the snapshot's resume type
and token (`launchedSessionMatchesSnapshotResume`,
`restore-orchestrator.ts:1390`); a reported `fresh` continuity without that
proof rolls back to `awaiting-decision` (`:1228–1245`).
- Saves a `rig up --non-interruptive` or `--no-non-interruptive` choice on
the rig only after the live-session check and pre-restore validation pass
(`:301`); see [adapters-and-runtimes.md](adapters-and-runtimes.md) for the
launch flags.
`launchNodeSubset` (`:392`) and `launchSingleNode` restore chosen seats rather
than the whole rig; unlike a full restore, a failed tmux probe there proceeds
with a `liveness_probe_unknown` warning (`:517`).
**Restore-honesty rules** (texts in `architecture-rules-and-event-system.md`;
cited here by number with the code that carries them):
- Rule 7 — `RESTORE_POLICY_LEVEL`
(`packages/daemon/src/domain/profile-resolver.ts:106`) orders the three
policies, and `resolveRestorePolicy` (`profile-resolver.ts:589`) rejects a
profile or member value that broadens it.
- Rule 14 — the outcome set is `RestoreNodeResult.status` (`types.ts:475`,
§1); `rebuilt` is set only when a checkpoint is written
(`restore-orchestrator.ts:1097`).
- Rule 15 — in `restore-orchestrator.ts`, a `resume_if_possible` seat with a
session but no token stops as `awaiting-decision` before launch unless
`--fresh` names it (`:812–831`); a failed resume kills the blank session
(`rollbackToZeroSession`, `:940`) and returns `awaiting-decision`
(`:1055`); pod-aware resume passes
`allowFreshFallback: !(isPodAware && resumeRequested)` (`:1224`). Fresh
launch is explicit: `rig up --existing <rig> --fresh <seats...>`
(`packages/cli/src/commands/up.ts:99`). On a terminal, `rig up` asks
yes or no for each `awaiting-decision` seat and re-posts with the accepted
seats as fresh (`up.ts:589`).
- Rule 16 — `rig down` prints the snapshot id and a restore command
(`packages/cli/src/commands/down.ts:293–303`); `rig up` on an existing rig
prints per-node statuses and the attach command
(`packages/cli/src/commands/up.ts:554–587`).
(The full rule list lives in `architecture-rules-and-event-system.md`.)
## 4. Auto-snapshot and existing-rig power-on
- `rig down <rig>` (name or id, `packages/cli/src/commands/down.ts:144`)
first prints the rig's recorded agent sessions and their activity, best
effort and only without `--json` (`showAgentsBeforeDown`, `down.ts:28`), then
auto-captures an `auto-pre-down` snapshot before teardown when the rig has
live sessions (`packages/daemon/src/domain/rig-teardown.ts:117`, capture
at `:128`, after refreshing resume metadata at `:119`; an already-stopped
rig returns at `:93–115` without one).
- `rig up <rig-name>`: a source with no `/` and no
`.yaml`/`.yml`/`.rigbundle`/`.rigtopology` extension is a rig name
(`packages/daemon/src/domain/up-command-router.ts:49–50`). The CLI refuses
a name that matches only archived rigs, with `rig unarchive` guidance
(`packages/cli/src/commands/up.ts:256`). It then checks the spec library
unless `--existing` is given, and refuses a name that matches both
(`up.ts:306–325`); when `first-project` resolves to the built-in `starter`
spec, the archived and existing-rig checks also apply to `starter`
(`up.ts:314–317`). The daemon finds the rig by name, preferring unarchived
rigs (`packages/daemon/src/routes/up.ts:223–226`), and restores from
`selectRestoreUsable` (`routes/up.ts:106`), which prefers the newest
`auto-pre-down` or `auto-periodic` snapshot, then the newest other usable
one (`snapshot-repository.ts:75`).
- `rig up <rig-name> --plan` is a read-only restore preview that never
captures a snapshot (`routes/up.ts:127`).
- If no usable snapshot exists, or the chosen one names an older occupant
(`snapshotMatchesCurrentOccupants`, `routes/up.ts:110`), `rig up` captures
an `auto-rehydrate` snapshot of current DB state when that state is
eligible (`routes/up.ts:137–146`). Otherwise it errors with code
`no_snapshot` and guidance ("… current DB state is insufficient for
rehydrate. Start fresh with: rig up <spec-path>", `routes/up.ts:118–123`).
- Post-command handoff: `down` output includes the snapshot ID and
`To restore: rig up <name>` (or `rig restore <snapshotId> --rig <rigId>`
when the name is not unique); `up` output includes node statuses and an
`Attach:` command (anchors under Rule 16 above).
- `rig restore status <attemptId> --rig <rigId>`
(`packages/cli/src/commands/restore.ts:120`) reads
`GET /api/rigs/:rigId/restore/status/:attemptId`
(`packages/daemon/src/routes/snapshots.ts:76`). `deriveRestoreAttemptReceipt`
(`restore-attempt-receipt.ts:42`) folds that attempt's events into a
per-node view without writing anything.
### Single-seat recovery and handover
- **`rig seat continue <seat>`** — when a fresh launch stops at a native
prompt before its startup context is delivered, the startup orchestrator
fails it as `attention_required` and adds "run: rig seat continue
<session>" (`startup-orchestrator.ts:822`). The command
(`packages/cli/src/commands/seat.ts:816`) calls
`POST /api/seat/continue/:seatRef` (`routes/seat.ts:367`), which runs
`continueStartup` (`seat-lifecycle-service.ts:723`): it re-checks the
binding, pane and runtime, then delivers the pending context to the same
conversation without relaunching. In a restore, a pod-aware seat in this
state reports `attention_required` (`restore-orchestrator.ts:1265`). See
[agent-spec-and-startup.md](agent-spec-and-startup.md) for the startup side.
- **Per-seat startup decision** — `POST /api/startup/:rigId/:logicalId`
(`routes/startup.ts:142`) takes `resume`, `start`, `fresh` or `continue`
for one seat. `resume` captures an eligibility-checked `auto-rehydrate`
snapshot when none exists and goes through `launchSingleNode`.
- **Seat handover** — `rig seat handover <seat> --source
fresh|rebuild|fork:<seat>|discovered:<id> --reason <reason> [--dry-run]`
(the reason is required, dry run included; without it the command exits 2
before contacting the daemon)
(`packages/cli/src/commands/seat.ts:364`) calls
`POST /api/seat/handover/:seatRef` (`routes/seat.ts:94`), which runs
`SeatHandoverService.handover` (`seat-handover-service.ts:266`).
`SuccessorSessionLauncher` (`successor-session-launcher.ts:92`) respawns
the same pane in place, so the session name stays the same. The fresh
source delivers a daemon-built restore packet (`buildRestorePacket`,
`seat-handover-service.ts:1151`), which is separate from the CLI
`rig restore-packet` below. The rebuild source primes from the seat's
durable chain (`buildRebuildPrimingChain`, `rebuild-priming-chain.ts:22`).
## 5. Daemon-side restore-check and CLI restore-packet
### 5.1 `rig restore-check` — readiness probe
`GET /api/restore-check?rig=<name>&noQueue=true&noHooks=true&compact=1&ready=1`
(`routes/restore-check.ts:220–221`). The route calls
`createRestoreCheckService` (`:151`), which assembles a framework-free
`RestoreCheckDeps` (`:155–216`) over existing daemon projections —
`listRigs` from `rigRepo` (`:175–178`), `getNodeInventory` (joining
`node_id` by `logical_id`, `:26–31`, `:179–185`), `getStartupContext` (reads
`node_startup_context`, parses `projection_entries_json` /
`resolved_files_json` / `startup_actions_json`, `:49–143`), `hasSnapshot`
/ `getLatestSnapshot` from `snapshotRepo` (`:189–195`), `getRestoreInputs`
(`:196–207`), `probeQueueStore` (`:158–170`), `getClaudeActivityHookEvents`
(`:171–174`), and `probeDaemonHealth` (self-evident: "We're inside the
daemon — if this route is responding, daemon is healthy", `:208–211`). It
then runs `service.check(...)` (`:231–232`).
`RestoreCheckService.check()` (`restore-check-service.ts:259`) layers:
1. **Host checks** — `checkDaemonReachable` (probe-throw → `verdict:
unknown`, NOT `not_restorable`, `:270–280`), `checkStateDirWritable`
(`:282`, impl `:498`), `checkHostInfraDeclaration` (`:283`, impl `:515`).
2. **Rig enumeration** — `listRigs()` throw → `buildUnknown` (`:286–295`);
`--rig` filter; unknown rig → red `rig.<name>.exists` (`:297–305`).
3. **Per-rig checks** — `checkSnapshot` (`:311`, impl `:784`),
`checkSpecPresent` (`:316`, impl `:1161`), and
`rig.<name>.restore-preconditions` (`checkRestorePreconditions`, `:338`,
impl `:422`). The last runs the same `validatePreRestore` that restore
runs (`:446`) on the inputs from `getRestoreInputs`: red on a blocker,
yellow on a warning, green otherwise. Without a usable snapshot it is
yellow when current-state rehydrate is eligible and red when it isn't;
when the inputs can't be read it is yellow and the rig's rollup becomes
`unknown` (see Verdict). The rig-status route sets
`recoveryOnly` (`routes/rigs.ts:272`), which skips this check when every
seat is ready (`restore-check-service.ts:337`).
4. **Per-seat checks** — `checkSeatReadiness` (`:346`), `checkStartupContext`
(`:360`, impl `:886`; `unknownChecks` → `buildUnknown`, `:361–366`),
`checkTranscript` (`:382`), `checkResumePath` (`:386`), and unless opted
out: the daemon SQLite `queue_items` availability probe `checkQueueStore`
(`:390–394`, gated by `--no-queue`; impl `:1007`) and `checkHooks`
(`:395–399`, gated by `--no-hooks`; impl `:1030`). Queue continuity is
represented by the shared daemon store; an empty queue is valid
(`routes/restore-check.ts:160–163`). Claude hook readiness follows the
persisted `claude_activity_hooks` runtime-resource selection
(`restore-check-service.ts:1053–1060`) and the adapter's projection: the
hook settings in the seat CWD and the activity relay under the OpenRig
home's `state/claude-activity-hooks/` (`restore-check-service.ts:1071–1072`). A seat
that deliberately omits that resource is not applicable. In compact mode
without `ready=1`, a seat whose readiness check is green gets only the
startup-context check (`restore-check-service.ts:358`, `:368–380`); that
check still counts toward the verdict.
5. **Verdict** — `buildResult` (`:1205`) aggregates: any red →
`not_restorable`; else any rig rollup with status `unknown` → `unknown`
(`:1223–1224`); else any yellow → `restorable_with_caveats`; else
`restorable` (`:1220–1229`). A rig rollup is `unknown` when its restore
inputs could not be read (`:1549–1551`). A probe that can't be inspected
at all also gives `unknown` (`buildUnknown`, `:1238`). Plus a
`RecoveryPlan` (`buildRecovery`, `:1342`) and a `RepairStep[]` packet
(`buildRepairPacket`, `:1581`; `null` when fully restorable, `:1582`).
The result shape is `RestoreCheckResult` (`restore-check-service.ts:148–160`):
`verdict`, `readiness`, `continuity`, `rigs[]`, `hostInfra`, `recovery`,
`counts {red,yellow,green}`, `classCounts`, `checks[]`, `repairPacket`. With
`compact=1` the route returns a reduced body (`routes/restore-check.ts:234–261`).
**Honest-error design:** a daemon-probe *exception* produces
`verdict: unknown` (uninspectable state), distinct from a daemon
definitely-down state which is `red` / `not_restorable`
(`restore-check-service.ts:270–280`, `checkDaemonReachable` `:476–496`). The
route's catch-all returns the same `unknown`-shaped body with HTTP 500 + a
`probe.error` red check (`routes/restore-check.ts:264–312`).
`CheckEntry.remediationSafe` defaults to `false` (conservative —
unclassified remediations are NOT auto-execution-safe,
`restore-check-service.ts:23–28`, applied at `:1596`).
CLI surface (`../cli-reference.md` `### restore-check`; options in
`packages/cli/src/commands/restore-check.ts:222–227`):
`rig restore-check [--rig <name>] [--full] [--ready] [--no-queue] [--no-hooks] [--json]`.
Output is compact unless `--full` (`:313`). Exit codes: `0` restorable (or
with caveats), `1` not restorable (red), `2` unknown / probe error
(`:214–217`). When the daemon is down, the CLI builds a local
`not_restorable` result and exits 1 (`:269`).
### 5.2 `rig restore-packet` — cross-runtime restore packet
CLI-side, no restore-packet daemon route. `commands/restore-packet.ts`
(**541** lines, `wc -l < packages/cli/src/commands/restore-packet.ts`)
implements **3** subcommands
(`grep -c 'cmd.command(' packages/cli/src/commands/restore-packet.ts`;
`../cli-reference.md` `### restore-packet`): `write [options]` (`:218`;
generate a packet directory from a source session or JSONL file, with
`omitted-records` accounting; `--source-session` reads the transcript from the
daemon's `/api/transcripts/<session>/full`, `:156`), `read <packet-dir> [--json]`
(`:373`; render contents; non-mutating), `validate <packet-dir> [--json]`
(`:452`; validate against the v0 schema; non-mutating). Packet shape is the
cross-runtime v0 standard — Claude Code and Codex transcripts both supported
via runtime parsers + redaction (`packages/cli/src/restore-packet/`).
### Shared history inspection
`GET /api/startup/:rigId/nodes/:nodeId/history` lists captured histories for one durable node in the current, policy-ranked restore snapshot. `POST` to the same path plus `/validate` with exactly `{ "snapshotId": "…", "historySessionId": "…" }` rechecks one listed choice. Both routes use startup's terminal bearer authentication. The CLI exposes the same server decisions with `rig seat history --rig <rig-id> --node-id <node-id> --json`; pass both `--snapshot-id` and `--history-session-id` to validate. These calls perform no snapshot capture, token refresh, launch, or database write.
The list includes captured Claude and Codex rows that the current restore policy and native resume adapter can structurally attempt. Codex requires an explicit `codex_id` and token; implicit `codex_last` cannot name a particular history. Claude accepts `claude_id` and `claude_name` with tokens. The server checks exact rig and node membership, the snapshot's intended roster and runtime, and the node's current occupant compatibility. A change to a sibling node does not retarget or invalidate this node's list. A changed or pruned snapshot, removed history row, same-name replacement node, ambiguous current occupant, or incompatible source is refused; validation never substitutes another history.
`tokenState` (`present`, `stale`, or `unverified` for listed rows) describes the saved verification record and its age. Eligibility means a future launch may **attempt** native resume. A stored row can share a native token with another row, and neither listing nor validation proves that a native conversation still exists.
### Selected history startup
`GET /api/startup/:rigId/nodes/:nodeId?snapshotId=<listed-id>&historySessionId=<listed-id>` returns a read-only review for one listed choice: the selected row's forecast, current terminal observation, consumed launch setting sources (including a member permission envelope or Claude rules), and an opaque revision. The permission view uses restore's pure policy resolution: a readable custom policy's current launch posture can supersede its persisted cache, and a changed resolved decision requires re-review. It refuses a captured row whose native type/token, provenance, restore policy, or harness no longer agrees with the live registry row. The response contains no native token. `POST /api/startup/:rigId/nodes/:nodeId` accepts exactly `{ "action": "resume", "snapshotId": "…", "historySessionId": "…", "revision": "…" }`. Both routes require startup's terminal bearer token. The selected route accepts no fresh, continue, arbitrary token, or alternate target. Review does not capture a snapshot or prepare a provider. `rig seat history` also uses a strictly read-only daemon status check, so inspection does not remove stale daemon state.
Apply revalidates the same current compatible source, durable node and saved launch settings after prerequisite and presence checks. The existing narrow restore engine carries that exact historical row through legacy and pod-aware native resume, with a second identity check at native dispatch after preparation. Before the new registry row is committed, selected startup also checks that no other attachment or running occupant claimed the node during terminal creation or pane observation; a collision leaves that claimant intact and reports the terminal outcome for inspection. An occupied or uncertain terminal blocks the attempt. No alternate history or fresh conversation is selected. The response projects exact typed references and outcome fields; only diagnostic text is redacted, so even a short native name cannot corrupt an ID. It names the selected source row and the newly registered result row when one exists; only the engine's joined native continuity proof produces success. If a change occurs after shell creation, the result reports a partial or uncertain attempt for inspection. The existing engine handles native prerequisites, compensation and proof. This surface adds no separate per-agent prelaunch probe; native checks remain at execution. Physical tmux cleanup still addresses sessions by name, so it cannot prove ownership against an external same-name replacement outside the selected registry guard.
## 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 **69** route mounts
in `server.ts` (`grep -c 'app.route(' packages/daemon/src/server.ts`),
mounted at `server.ts:836`.
- `transport-and-transcripts.md` — transcript boundary markers written on
restore.
- Source roots: `packages/daemon/src/domain/{restore-orchestrator,
restore-preconditions,restore-topology,restore-attempt-receipt,
rehydrate-eligibility,snapshot-capture,snapshot-repository,checkpoint-store,
active-occupant,restore-check-service}.ts`,
`packages/daemon/src/routes/{restore-check,up,snapshots}.ts`,
`packages/cli/src/commands/{restore-packet,restore-check,restore}.ts`.
@@ -1,205 +0,0 @@
---
kind: as-built
title: Living Notes — Composed Review and Frozen Exports
status: active
topics: [observability, knowledge-and-context]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Tracing slice, mission, rig, or fleet review data back to scope documents,
proof artifacts, judgments, queue state, or frozen exports.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Living Notes — Composed Review and Frozen Exports
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
Living Notes builds review payloads from existing documents and recorded
state. The composition layer does not make an agent judgment or contact an
agent to obtain one. Its output distinguishes intent, plan, delivered proof,
attention, agents, and source lineage.
## Read and compose boundaries
[`ReviewGatherer`](../../../packages/daemon/src/domain/review/gather.ts)
performs filesystem, database, telemetry, and optional Git reads.
[`compose.ts`](../../../packages/daemon/src/domain/review/compose.ts) turns
those inputs into the contracts defined in
[`types.ts`](../../../packages/daemon/src/domain/review/types.ts), including
`ComposedSliceReview`, `ComposedMissionReview`, and `ComposedRigAgents`.
`gatherSlice` uses `resolveNodeFileVia` to select the scope node document,
reads `IMPLEMENTATION-PRD.md` and `PROOF.md`, and loads the `proof/` artifacts.
It also gathers approval stamps, locked artifacts, tagged queue attention,
agents, workflow exceptions, and `readSliceReadiness`. A current `SPEC.md`
is carried distinctly from a legacy `README.md`; the composer does not
blindly treat every file as an interchangeable source.
[`readProofArtifacts`](../../../packages/daemon/src/domain/review/proof-io.ts)
reads sorted top-level `.md` files in `proof/`, parses their headers with
`parseC1Header`, and uses file mtime as `droppedAt`. Missing directories and
unreadable entries yield no artifact for those entries. No claim that all
possible evidence was discovered follows from that read.
`composeMission` batches slice-index membership reads, gathers each slice,
and composes a mission board, completion ledger, attention union, agents,
and brief spine. `composeRig` reads queue/activity state and reports current
holders, recent holders, overdue work, settled work, and workflow exceptions.
"Recent" is the current UTC day; overdue is in-progress slice-tagged work past
`closure_required_at`; settled is today's `handed_off_to` transitions; and
attention is any active item that is `human-gate`, addressed to a `human…`
session, or blocked on one (`gather.ts`). Of the three review reads, the TUI
calls only `/fleet`, from its broad hydration (`packages/tui/src/hydrate.ts`);
its client also defines methods for `/agents` and `/rig`, which nothing in the
TUI calls.
## Slice source selection and phase
In [`composeSliceReview`](../../../packages/daemon/src/domain/review/compose.ts),
intent comes from the node document's `Intent` section. Placeholder-only
intent is treated as absent. Mini-requirements use current `SPEC.md` when
present; legacy selection can fall back from a pristine scaffold PRD section
to authored README material. Proof-contract selection delegates to
`selectProofContractBody` through `extractProofContractSelected`.
The phase is derived by `derivePhase`, in this precedence order:
| Phase | Signal |
|---|---|
| `locked` | Delivery approval stamp. |
| `review` | Recorded verdict or claimed proof pass. |
| `building` | Real proof artifacts, or active queue work paired with an authored/locked spec. |
| `spec` | Authored structure or a spec approval stamp. |
| `intent` | None of the above. |
File presence alone is insufficient: a scaffolded `PROOF.md` is not build
evidence, and a tracking queue item alone does not promote an unplanned
slice. Phase and proof readiness are separate output fields.
## Delivered items and current judgments
`extractProofContract` reads logical checkbox items, retaining authored raw
text for identity. `proofItemIdentity` uses an explicit `proof-item` marker
when supplied, otherwise an ID derived from the item's text. In the legacy
artifact join, `evidences` references match a promised item's exact display
text or its one-based index.
`composeDelivered` pairs each promise with curated media and a verification
state:
- With configured [proof readiness](../../../packages/daemon/src/domain/proof/judgments.ts),
the matching current item must be `accepted`, and readiness must have no
issues, to render `verified`. The note reports the current judgment state
or why it is unavailable.
- Without configured readiness, a winning covering `qa` or `adjudication`
artifact with a non-null `self_check` and a passing verdict supplies legacy
verification. The note explicitly labels it **item revision unbound**.
- Other covering artifacts are `unverified`; no covering evidence/judgment
is `missing`. Unmapped artifact media appears separately as `extraProof`.
This distinction matters after a proof contract changes: a legacy PASS
header is not automatically a current revision-bound judgment. Approval
stamps are also not a substitute for the delivered-item join.
## Approval, attention, and lineage
`ReviewGatherer.gatherApproval` reads `approved-spec-by`/`approved-spec-at`
and `approved-by`/`approved-at`, alongside matching `mission_control_actions`
approval audit records. The resulting locks preserve who/when/audit facts.
A frontmatter stamp with no matching `approve` audit row still locks the
phase, but carries `auditVerified: false`, and the frozen export shows it as
an UNVERIFIED stamp. The approval writer is the separate
[`scope-approve` route](../../../packages/daemon/src/routes/scope-approve.ts).
`composeNeedsYou` combines recorded queue attention with derived exceptions;
`deriveWorkflowExceptions` adds workflow-specific observations. When
readiness is not configured, a slice whose `PROOF.md` claims PASS without a
recorded green verdict also gets a `confirm-faithful` needs-you item. Mission
composition deduplicates attention identities across slice and mission views.
Media references outside the slice's relative-path contract produce defect
entries instead of disappearing silently.
Git facts are optional. [`startup.ts`](../../../packages/daemon/src/startup.ts)
passes `OPENRIG_REVIEW_GIT_REPO` to the gatherer. The gatherer reads that
repository's `HEAD`, searches merge messages keyed by scope ID, and computes
ancestry/behind facts when possible. Each git read has a 4-second timeout; the
merge search looks for `Merge <id>` in `HEAD` history and needs the scope's
frontmatter `id`; the behind-tip count is computed only for unmerged slices
with a candidate SHA. Unavailable facts remain unknown; this is not a fresh
remote GitHub merge-status query.
`composeMissionReview` derives `cutComplete` from a nonempty ledger in which
every slice is green, has a merge SHA, and has no needs-human items. Configured
slice readiness supplies the green predicate; otherwise recorded verdicts
do. With configured readiness, a slice's board cell also shows the proof
readiness state and revision. That computed field is not itself a
publication or higher-level outcome decision.
## HTTP routes
[`reviewRoutes`](../../../packages/daemon/src/routes/review.ts), mounted at
`/api/review` by [`server.ts`](../../../packages/daemon/src/server.ts):
| Method and suffix | Payload / behavior |
|---|---|
| `GET /slice/:name` | Composed slice plus proof-source observation. |
| `GET /mission/:name` | Composed mission plus proof-source observation. |
| `GET /agents?scope=…` | Agents band; scope is `slice:<id>`, `mission:<id>`, or `rig`. |
| `GET /rig` | Local rig review root. |
| `GET /fleet` | Local and registered remote host review union. |
| `POST /freeze` | Compose and export an approved slice; body requires `scope: slice` and `name`. |
Missing gatherer returns `503`; invalid agents scope returns `400`; unknown
slice/mission returns `404`. Fleet is a sibling aggregate, not another value
of the agents-scope parameter.
## Freeze and media
Nothing invokes the freeze automatically: `POST /api/scope/approve` writes
the stamp and audit row and always returns `freezeFired: false`
(`domain/scope/scope-approve.ts:326`), and the route comment that says the
approve flow invokes the freeze describes wiring that does not exist. A
frozen export exists only after something calls `POST /api/review/freeze`.
[`freezeSliceExport`](../../../packages/daemon/src/domain/review/freeze.ts)
requires a delivery lock and an allowlisted slice directory. It writes
`REVIEW-<scope-id-or-name>-<approval-date>.html` through
`FileWriteService.createAtomic`. An existing target is treated as already
frozen; it is not rewritten. A failed export does not undo approval. The
route needs the file write service (a non-empty `files.allowlist`; otherwise
503) and returns 409 `stamp_missing` without a delivery stamp, 403
`allowlist_missing` outside every allowlist root, and 500 otherwise; success
returns `{ ok, path, alreadyFrozen, briefWrite }` (`routes/review.ts`).
The export embeds contained images as data URIs and links videos with an
optional poster. `containedMediaPath` checks both resolved paths and existing
symlink targets against the slice directory. Unavailable/outside media gets
an explicit rendered message.
After a new freeze, the route may update the generated spine in a conforming
`MISSION_BRIEF.md` through the conflict-checked file writer. A malformed brief
is skipped, and brief-write failures are reported without undoing the export.
The [`slices` routes](../../../packages/daemon/src/routes/slices.ts) separately
serve proof assets; [`files` asset serving](content-surfaces.md) has its own
allowlist and range behavior.
## Fleet composition
[`composeFleet`](../../../packages/daemon/src/domain/review/fleet-compose.ts)
composes local data in-process and reads registered hosts' composed rig
payloads. Its default fan-out concurrency is **4**, with a shared
**4-second** read budget. Synchronous local work cannot be preempted by that
timer; elapsed local work reduces the remaining remote budget.
Each remote host is read through its own `GET /api/review/rig`: SSH-only
registry entries report `unsupported-transport`, auth refusals report
`auth-failed`, and timeouts, malformed payloads and other errors report
`unreachable`.
`unionFleet` keys attention by host plus item identity and retains host
status. Unavailable hosts have absent counts, not synthetic zeros. A missing
registry produces a local-only result; an unreadable existing registry is
reported. Host-derived time/status facts are carried through, not recomputed
as if every host shared one clock.
@@ -1,272 +0,0 @@
---
kind: as-built
title: Mission Control — Queue Observability + Verb Contract
status: active
topics: [coordination, observability]
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
applies-when: |
Need to know how the daemon-backed Mission Control surface works — the
seven views, the nine write verbs, the action audit table, the
bearer-token middleware, or how queue observability maps to the
coordination primitive's sources.
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
prerequisite-reads: [../README.md, coordination-primitive.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Mission Control — Queue Observability + Verb Contract
Mission Control is the daemon's queue-observability and operator-action
surface, mounted at `/api/mission-control` over the coordination primitive's
`queue_items` and `stream_items` (see `coordination-primitive.md`). It has
seven views, nine verbs, a recent-ships cap of 10
(`mission-control-read-layer.ts:107`), and a daemon-backed action audit
table. In the web UI the old `/mission-control` page now redirects to
`/for-you`, and the For You feed carries the verb actions (§6).
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. A bare file name such as
> `mission-control-read-layer.ts:31` is in
> `packages/daemon/src/domain/mission-control/`; `domain/…`, `routes/…`,
> `middleware/…`, `db/…`, `index.ts`, `server.ts` and `startup.ts` are under
> `packages/daemon/src/`. Each count sits beside the command that produces
> it; run the command from the repository root to refresh it.
## 1. The seven views
`MISSION_CONTROL_VIEWS` is the canonical view list
(`mission-control-read-layer.ts:31–39`): `my-queue`, `human-gate`, `fleet`,
`active-work`, `recent-ships`, `recently-active`, `recent-observations`.
Views: **7** — `sed -n '/^export const MISSION_CONTROL_VIEWS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-read-layer.ts | grep -c '^ "'`
All seven return rows in the 9-field phone-friendly content model,
`CompactStatusRow` (`mission-control-read-layer.ts:60`): rig/mission name,
current phase, active|idle|attention|blocked|degraded, next-action,
pending-human-decision, read-cost, last-update timestamp,
confidence/freshness, evidence link. The type's doc comment
(`mission-control-read-layer.ts:43–59`) makes the model non-negotiable across
all seven views: UI may render compact, JSON preserves all nine. Four further
optional fields carry the queue item's id and context.
Required row fields: **9** — `sed -n '/^export interface CompactStatusRow {/,/^}/p' packages/daemon/src/domain/mission-control/mission-control-read-layer.ts | grep -c -E '^ [a-zA-Z]+:'`
`MissionControlReadLayer.readView` (`mission-control-read-layer.ts:143`) maps
each view to its source:
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query
`queue_items` via `QueueRepository`. `my-queue` uses the `operatorSession`
query parameter when given, else `workspace.operator_seat_name` (default
empty), else the only registered human; when none is found, or more than
one, it returns no rows and a `degradedFields` note rather than widening the
view (`mission-control-read-layer.ts:137–141`, `:166–168`;
`routes/mission-control.ts:521`).
- `fleet` calls `MissionControlFleetCliCapability.rollupFleet()`
(`mission-control-read-layer.ts:197`;
`mission-control-fleet-cli-capability.ts:109`), which walks the rig
registry (`:110`), summarises each rig's `queue_items` (`:135`), and runs a
per-rig CLI capability probe (`makeLocalCliCapabilityProbe`, `:77`, wired
at `startup.ts:1597`).
- `recently-active` delegates to the view projector's built-in
`ViewProjector.show("recently-active")` (`mission-control-read-layer.ts:246`).
- `recent-observations` reads the latest 50 `stream_items` via `StreamStore`
with `direction: "latest"` (`mission-control-read-layer.ts:265–268`).
There is no filesystem fallback. When no `StreamStore` is wired,
`recent-observations` returns no rows with
`sourceFallback: "stream-store-not-wired"`
(`mission-control-read-layer.ts:258–262`), and `fleet` always reports
`sourceFallback: "daemon-internal-projection"`
(`mission-control-fleet-cli-capability.ts:156`).
> Scope note: this module describes the daemon's verb vocabulary. It does
> NOT enumerate which verbs the For You cards offer; that surface is
> described in `../ui/project-and-for-you.md`. The terminal UI opened by bare
> `rig` or `rig tui` is called the "mission-control TUI" in source
> (`packages/tui/README.md:1`), but it does not call these routes.
## 2. The nine verbs (the write contract)
`MISSION_CONTROL_VERBS` (`mission-control-action-log.ts:14–29`) is the verb
list. The verbs execute through `MissionControlWriteContract.act`
(`mission-control-write-contract.ts:131`); the closure mapping is
`verbToClosure` (`:509`).
Verbs: **9** — `sed -n '/^export const MISSION_CONTROL_VERBS = \[/,/\] as const;/p' packages/daemon/src/domain/mission-control/mission-control-action-log.ts | grep -c '^ "'`
| Verb | Effect |
|---|---|
| `approve` | `state="done"`, `closure_reason="no-follow-on"` |
| `deny` | `state="done"`, `closure_reason="denied"` |
| `route` | `state="handed-off"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates a new queue item at the route target (1-hop) |
| `annotate` | no queue mutation; audit record only |
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
| `drop` | `state="done"`, `closure_reason="canceled"` |
| `handoff` | same closure and new destination item as `route`; the 4-step shape (see below) |
| `resolve` | for a `blocked` item parked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`): moves it to `in-progress` with required decision/satisfaction evidence in the transition note; typed waits are recorded as blocker resolutions, not human decisions. Qitem dependencies, malformed gates and non-blocked items are refused; no closure and no new item (`:369`, `:386`, `:410–411`). After commit it sends a best-effort nudge with the decision text to the item's owner unless `notify: false` (`:450–467`); the CLI wrapper is `rig queue resolve <qitemId> --decision <text>` (`packages/cli/src/commands/queue.ts:828`) |
| `answer` | only for a `pending` ask addressed to the acting person: a choice (`questionId` + `optionId`) through `QueueRepository.recordHumanAnswer`, or their words (`decision`) through `resolveDirectHumanReply`, each with its existing meaning (a partial choice set stays pending; words fill the first unanswered question). Once answered, one reply row goes to the asking seat (`human-answer` tag, `replyTo` the ask, an id derived from the ask) and then the ask closes once with `human-decision-resolved`, the order Slack's answer continuation uses. With neither a choice nor words, a complete question set whose return didn't finish is sent again with exactly its recorded answers. A closed ask answers 409 `qitem_already_terminal` and records nothing; a refused answer is 400 `answer_not_recorded`; 409 `answer_not_closed` means the ask stayed open: its return failed, it changed before its close, or a retry came in other words than the reply already returned (that retry isn't sent). The TUI's Feed sends it |
`annotate` requires `annotation`, `hold` and `drop` require `reason`, `route`
and `handoff` require `destinationSession`, and `resolve` requires a non-empty
`decision`. A missing `annotation`, `destinationSession` or `decision` returns
400. A `drop` without `reason` passes the queue closure check and is refused by
the audit record instead; the wrapped `reason_required` error has no status
mapping, so it returns 500 (`mission-control-action-log.ts:128–133`,
`mission-control-write-contract.ts:245–248`). Items already `done` or
`handed-off` are refused with 409 `qitem_already_terminal`, except under
`resolve`, which answers 409 `qitem_not_leg1_parked` for any item that is not
blocked on a human seat or a well-formed typed gate (`external:`, `fold:`, `auth:`). An unknown item returns 404 `qitem_not_found`
(`routes/mission-control.ts:240–251`, `:330–341`).
Each verb except `answer` is one atomic daemon transaction (`answer`'s queue writes are the repository's own, in the order above, then its audit row and event): the queue mutation via
`QueueRepository.updateWithinTransaction()` (`domain/queue-repository.ts:2371`,
which keeps the hot-potato closure validation — see
`coordination-primitive.md` §3), an audit row in `mission_control_actions`
(`mission-control-write-contract.ts:207`), and a persisted
`mission_control.action_executed` event (`:225`), all in one
`db.transaction`. For every verb except `annotate`, that transaction is
opened by `EventBus.withNotifyEnvelope` (`:157`; `domain/event-bus.ts:112`,
`:126`); `annotate` opens `db.transaction` itself
(`mission-control-write-contract.ts:308`).
The 4-step `handoff` shape (source-update + destination-create + best-effort
notify + audit-record append) is shared by `route`. The destination item is
created in the same transaction (`createWithinTransaction`, `:173`), along
with the successor's wake intent (`stageWakeIntent`, `:195`, checked by
`assertTerminalClosureHasIntent`, `:242`). The wake itself runs after commit
(`deliverWakeForSuccessor`, `:264`). It is on unless the caller passes
`notify: false` (`domain/queue-repository.ts:1182`), and a notify failure
does NOT roll back durable mutations (`mission-control-write-contract.ts:272–275`).
A failed destination create rolls back the source closure, the audit row and
the new item together
(`packages/daemon/test/mission-control-write-contract.test.ts:220`).
## 3. The action audit table
`mission_control_actions` (`db/migrations/037_mission_control_actions.ts:58`
`CREATE TABLE IF NOT EXISTS mission_control_actions`) is append-only at the
API surface: `record()` is the only writer `MissionControlActionLog` exposes
(`mission-control-action-log.ts:113`). It records every operator action
through Mission Control with before/after queue item snapshots for forensic
reconstruction. Columns include `action_verb` (TEXT, app-layer enum
enforcement, `037_mission_control_actions.ts:60`) and `acted_at` (TEXT NOT
NULL ISO timestamp, `037_mission_control_actions.ts:63`);
indexes `(acted_at DESC, action_verb)`, `(qitem_id, acted_at DESC)`,
`(actor_session, acted_at DESC)` (`037_mission_control_actions.ts:72–77`).
One later migration adds a nullable `identity_provenance` column
(`db/migrations/065_identity_provenance.ts:18`). The acting session comes
from the `X-OpenRig-Session` header when present (the CLI sends it); a
headerless browser request falls back to the body `actorSession`. The row
records how the actor was established: `transport:v1`, `relay:v1` for a
forwarded request, `claimed:v1` for a headerless body actor, or
`origin-unknown:v1` (`routes/mission-control.ts:347–351`;
`routes/require-sender-identity.ts:120`).
Scope approvals (`POST /api/scope/approve`) also append `approve` rows through
the same `MissionControlActionLog`, with `qitem_id` null and the scope target
in `audit_notes_json` (`domain/scope/scope-approve.ts:292`). The audit browse
(§4) reads only this table.
## 4. Bearer middleware, notifications, audit browse
- **Bearer-token middleware** — `middleware/auth-bearer-token.ts`.
Constant-time comparison via Node `crypto.timingSafeEqual`
(`constantTimeEqual`, `:36`, `:50`). `authBearerTokenMiddleware` (`:93`)
passes every request through when no token is configured (`:98`). The
token comes from `OPENRIG_AUTH_BEARER_TOKEN` (`index.ts:288`). The daemon
refuses to start when the bind host is set explicitly, is neither loopback
nor Tailscale (a hostname is resolved first), and no bearer token is set
(`assertBindAuthInvariant`, `auth-bearer-token.ts:240`, called at
`index.ts:296`). Bearer enforced on the write routes:
`app.post("/action", requireAuth)` (`routes/mission-control.ts:307`) and
`app.post("/notifications/test", requireAuth)` (`:308`). Reads are not
bearer-gated. It is one static token; no OAuth/SSO/per-user model
(`auth-bearer-token.ts:11–14`). Every Mission Control request, read or
write, first passes the daemon's `/api/*` browser boundary
(`server.ts:647`; `middleware/browser-boundary.ts`): only known target host
names, and, when an `Origin` header is present, only the daemon's own UI
origin or an `OPENRIG_ALLOWED_ORIGINS` entry. A valid bearer token waives
only the host-name check.
- **Notification dispatcher** — two adapters
(`notification-adapter-ntfy.ts`, `notification-adapter-webhook.ts`) plus
`notification-dispatcher.ts`. `OPENRIG_NOTIFICATIONS_MECHANISM` selects
`ntfy`, `webhook` or `none`, and the default is `none`
(`startup.ts:1642`). A dispatcher starts only when
`OPENRIG_NOTIFICATIONS_TARGET` is also set (`:1643`, `:1650`) and the
target URL passes validation (`:1661`); an unrecognized mechanism with a
target set throws during daemon startup (`:1656–1659`). The ntfy adapter
posts to a topic URL that the ntfy phone app subscribes to
(`notification-adapter-ntfy.ts:1–6`). Notifications fire when a
`human-gate` queue item is created, and also on each completed verb when
`OPENRIG_NOTIFICATIONS_INCLUDE_VERB_COMPLETION=1` (`startup.ts:1648–1649`);
each item, trigger and mechanism is sent at most once per daemon run
(`notification-dispatcher.ts:119–134`, `:156–158`). `POST
/notifications/test` returns 503 `notifications_unconfigured` when no
dispatcher is running (`routes/mission-control.ts:481–492`).
Adapters: **2** — `git grep -l 'implements NotificationAdapter' -- packages/daemon/src | wc -l`
- **Read-only audit-history browse** — `MissionControlAuditBrowse.query`
(`audit-browse.ts:78`) over `mission_control_actions`, exposed at
`GET /api/mission-control/audit` (`routes/mission-control.ts:441`) with
filters (including `scope_tier`, `scope_id`, `scope_path` and
`approval_scope` for scope-approval rows) and `(limit, before_id)`
pagination cursored on SQLite `rowid` (`audit-browse.ts:134`); the page size
defaults to 50 and is capped at 200 (`audit-browse.ts:61–62`).
## 5. Mission Control events
The `RigEvent` union starts at `domain/types.ts:108`; its four
`mission_control.*` members are at `domain/types.ts:308–313`.
`RigEvent` members: **98** — `sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "'`
`mission_control.*` members: **4** — `sed -n '/^export type RigEvent =/,/^export type PersistedEvent/p' packages/daemon/src/domain/types.ts | grep -c 'type: "mission_control\.'`
- `mission_control.action_executed` — written by the write contract
(`mission-control-write-contract.ts:225`).
- `mission_control.cli_drift_detected` — emitted by the fleet capability
probe (`mission-control-fleet-cli-capability.ts:171`).
- `mission_control.notification_sent` / `mission_control.notification_failed`
— emitted by the notification dispatcher (`notification-dispatcher.ts:174`,
`:184`).
- *(retired)* `mission_control.view_refreshed` — retired per #490; views are
read-on-demand queries without background refresh caching.
## 6. Route surface
`missionControlRoutes({ bearerToken })` is mounted at `/api/mission-control`
(`server.ts:790–793`). Routes (`routes/mission-control.ts`): `GET /views`
(`:259`), `GET /cli-capabilities` (`:264`), `GET /destinations` (`:276`),
`GET /sse` and its alias `GET /watch` (`:303–304`; they forward
`action_executed` and `cli_drift_detected` events,
`:289–291`), `POST /action` (`:314`, auth-gated), `GET /audit` (`:441`),
`POST /notifications/test` (`:481`, auth-gated), and `GET /views/:view-name`
(`:509`).
`POST /action` accepts an optional `hostId`: for a non-local host it forwards
the same verb to that host's daemon (http-transport host entries only; 10 s
timeout). The origin host writes the audit row and nothing is written
locally; a forwarding failure returns 502 `remote_action_failed` with a
`failureClass` (`routes/mission-control.ts:13`, `:352–414`).
In the web UI (`packages/ui/src/routes.tsx`), `/mission-control` is a
redirect to `/for-you` (`:495–500`). `/for-you` renders the For You `Feed`
(`:130–134`), whose cards call `POST /action` through `VerbActions`
(`packages/ui/src/components/for-you/FeedCard.tsx:13`), and `/search` renders
`AuditHistoryView` over `GET /audit` (`routes.tsx:304–308`).
`MissionControlSurface`
(`packages/ui/src/components/mission-control/MissionControlSurface.tsx:9`)
still exists and renders the same `Feed`, but nothing imports it. UI detail
lands in `../ui/project-and-for-you.md`.
## See also
- `coordination-primitive.md` — the `queue_items`/`stream_items` sources
Mission Control reads, and the hot-potato closure contract verbs honor.
- `workflow-runtime.md` — the workflow transactional-scribe runtime.
- `../ui/project-and-for-you.md` — the For You feed that carries the verb
actions in the web UI.
- Source roots: `packages/daemon/src/domain/mission-control/`,
`packages/daemon/src/middleware/auth-bearer-token.ts`,
`packages/daemon/src/routes/mission-control.ts`,
`packages/daemon/src/db/migrations/037_mission_control_actions.ts`.
@@ -1,314 +0,0 @@
---
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: |
Locate npm package assembly, topology-bundle creation and validation,
source routing, bootstrap plan/apply, or the retained package install engine.
siblings: [agent-spec-and-startup.md, plugin-agent-image-context-pack.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Packaging, bootstrap and bundles
This module describes source at main commit
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. Source paths below are repository-relative.
An npm CLI artifact and a `.rigbundle` have different builders and consumers; neither
source verification nor archive integrity establishes that a daemon has adopted an artifact.
## npm CLI assembly
`scripts/build-package.sh` builds the daemon, web UI, TUI and CLI, then assembles their
outputs under `packages/cli`. The publishable file list and binary declarations live in
`packages/cli/package.json`.
The assembler writes generated `BUILD_INFO` modules into the daemon and CLI outputs with
the package version, Git commit, dirty flag and build time. The dirty check covers tracked
and untracked inputs under `packages/` and `scripts/`; it is not a whole-worktree status
claim.
The same script:
- Generates shipped context packs through `scripts/generate-context-packs.mjs`.
- Copies daemon output, assets, specs, context packs, policies and `docs/reference/`.
- Checks the specs staging input with `scripts/check-internal-leak-guard.mjs` and writes
the staged substance-root inventory into the package.
- Copies the web UI and TUI outputs.
- Runs `scripts/rewrite-daemon-imports.mjs` so staged CLI/TUI JavaScript resolves the
shipped daemon output rather than an unpublished workspace package.
This is the build layout. Native dependency installation and consumer runtime behavior need
their own checks; this document does not infer them from a successful assembly.
## Topology-bundle creation
`packages/daemon/src/routes/bundles.ts` mounts create, inspect, install and history handlers
under `/api/bundles` (the mount is in `packages/daemon/src/server.ts`).
The create handler validates the source spec before comparing it with recorded live topology.
A divergent spec is refused unless the request explicitly allows drift; an allowed divergence
is recorded in the bundle's provenance notes. The exported spec is not silently rewritten
from live state.
| Source shape | Builder and output |
|---|---|
| Pod-aware rig spec | `packages/daemon/src/domain/pod-bundle-assembler.ts`: `PodBundleAssembler.assemble()` produces schema-version-2 metadata, vendors resolved agent trees, and rewrites agent references to local paths. |
| Legacy rig spec | `packages/daemon/src/domain/bundle-assembler.ts`: `LegacyBundleAssembler` (imported as `BundleAssembler` by the route) collects the referenced legacy packages and produces the legacy manifest shape. |
The pod builder collects culture, docs and startup material, preserves file bytes, and skips
vendoring the terminal sentinel as an agent. A copied file keeps its source mode with owner
read and write added. Required document collection failures stop assembly; missing declared
skills can instead produce warnings, which are also written into the provenance notes.
Inspect the builder's collection helpers when changing these distinctions.
Both route branches can consume a source-root `bundle.yaml` to carry declared skills,
plugins, workflow specs, context packs, agent images and preconditions into staging before
computing integrity. Manifest types, validation and serialization are in
`packages/daemon/src/domain/bundle-types.ts`. Provenance and compatibility are metadata;
provenance is not an author signature.
Preconditions are carried as data. Commands containing shell operators produce a warning,
inspect reports the block, and the behaviour view lists it under needs. The install and
bootstrap code do not reference preconditions.
Two more inputs are accepted only for a pod-aware spec; the legacy branch refuses them with
a 400:
- `--context-pack <dir>` (`vendorContextPackDir()` in
`packages/daemon/src/domain/bundle-carried-context-pack.ts`) carries a pack's
`manifest.yaml` and its declared files to `context-packs/<manifest name>/`. The pack may
sit outside the rig folder.
- `--project-dir <dir>` (`vendorProjectDir()` in
`packages/daemon/src/domain/bundle-carried-project.ts`) carries a folder with a
`project.yaml` to `project/`.
Create also refuses content it must not ship. `computeIntegrity()` in
`packages/daemon/src/domain/bundle-integrity.ts` throws on sensitive paths (`.env` files,
`*.pem`, `*.key`, `*.p12`, `credentials.*`, `tokens.*`, `.git/` and `node_modules/`).
`assertShippableSubstance()` in `packages/daemon/src/domain/agent-resolver.ts` runs on
vendored content and on the final staging tree, and refuses text containing
`substrate/shared-docs/` and files declaring `taxonomy: lore`.
### Configurations and package identity
A bundle folder may declare alternate team configurations in `configurations.yaml` beside
`rig.yaml` (`packages/cli/src/lib/bundle-configuration.ts`). `rig bundle configurations
<spec>` lists them. `rig bundle create --preset <name>` or `--seat <member=runtime>` resolves
one and stages a copy of the rig folder in a temporary directory with the chosen runtimes and
profiles, so the author's folder is never changed. With a GitHub folder link, `rig up`,
`rig bundle create`, `inspect` and `install` take the same two options and stage a copy of the
whole checkout (`importGitHubBundle()` in `packages/cli/src/lib/bundle-source.ts`).
`packages/daemon/src/domain/bundle-identity.ts` defines the two identity values:
- `configurationId()`: every member's `pod.member=runtime`, sorted and joined with `,`.
Create computes it from the normalized spec and returns a 400 if the client sent a
different one.
- `packageDigest()`: SHA-256 over the sorted `integrity.files` entries (coverage label
`openrig.package-digest/v1`). It covers every packaged file's bytes; it does not cover
`bundle.yaml`, the junk files the integrity walk skips, or file modes.
Create and inspect responses carry the source, configuration ID, package digest, archive
hash and assembler version.
### Archive checks
`packages/daemon/src/domain/bundle-archive.ts` owns `pack()` and `unpack()`;
`packages/daemon/src/domain/bundle-integrity.ts` owns the per-file integrity map.
Packing requires a `.rigbundle` output path, sorts entries, uses portable tar metadata, a
fixed tar timestamp and gzip level 9, and writes a sibling SHA-256 file. Unpacking checks that
digest, rejects archive links and unsafe entry paths before extraction, requires
`bundle.yaml` and its integrity section, and verifies the extracted file inventory. Hashes
establish consistency with the supplied manifest and digest; they do not authenticate
whoever supplied both.
The inspect handler does not call `unpack()`, because it must report a broken bundle rather
than refuse it. It reports a missing or mismatched digest as `digestValid: false`, applies the
same unsafe-entry pre-scan (`collectUnsafeArchiveEntries()`) and extraction filter, and runs
`verifyIntegrity()` to report an `integrityResult`. A syntactically valid manifest alone is
not an inspected archive.
Inspect also returns a behaviour view, `describeBundleBehaviour()` in
`packages/daemon/src/domain/bundle-behaviour.ts` (schema `openrig.bundle-behaviour/v1`). It
reads the extracted archive only, through `bundle-behaviour-inspect.ts` (regular UTF-8 files,
at most 1 MiB each and 8 MiB in total, opened without following links). It lists the team,
each seat's permission posture, the files seats are told to read, what else runs, where
content is written, literal outside addresses, needs, and what can't be known before launch.
A schema-1 archive gets `state: not_generated`. Posture comes from the member's or rig's
`permission_policy`: `builtin:yolo` is full bypass and `builtin:auto` is auto, both by launch
flag; `builtin:locked`, `builtin:standard` and `builtin:open` are configuration; a policy file
is read from the archive or marked unresolved; no policy means the product default. A rig-level
`non_interruptive` boolean is reported on each full-bypass Claude Code or Codex seat as
`nonInterruptiveDefault`, and `true` drops the Claude bypass first-run warning. The CLI prints
the view before `rig bundle install` and `rig up` as diagnostics only; it never changes the
request or the exit code.
## Source routing and bootstrap
`packages/daemon/src/domain/up-command-router.ts` classifies a source as
`rig_spec`, `rig_bundle`, `rig_name` or `topology`. It recognizes named rigs,
spec paths, `.rigbundle`, `.rigtopology`, and YAML with a top-level rigs list;
extensionless paths also have content detection.
`packages/daemon/src/routes/up.ts` then selects the execution path:
| Input | Consumer |
|---|---|
| Existing rig name | Existing-rig restore path; unarchived rigs of that name are preferred. |
| Single rig spec or bundle | `BootstrapOrchestrator.bootstrap()` in `packages/daemon/src/domain/bootstrap-orchestrator.ts`. |
| Topology manifest | `MultiRigLauncher`, with a single-rig bootstrap or remote-up leaf per entry. |
Topology plan mode is rejected. Placement belongs on the individual topology entries; a
top-level host flag is rejected. The selected topology parser/route also rejects nested
topologies, existing-rig-name entries and `.rigbundle` entries: topology entries are spec
paths only. This is narrower than the single-rig `up` surface.
Bootstrap distinguishes pod-aware and legacy specs, and inspects a bundle manifest to choose
`PodBundleSourceResolver` or `LegacyBundleSourceResolver` from
`packages/daemon/src/domain/bundle-source-resolver.ts`. Thus the router's
`rig_bundle` kind does not imply a legacy manifest.
For pod-aware specs, plan mode validates and runs preflight probes; apply delegates to
`PodRigInstantiator`. Plan mode still records a bootstrap run/result: it is not a pure
file read. Apply reports partial completion when nodes fail or require attention, preserving
the created rig identity rather than claiming every member launched. Apply's non-interruptive
choice is the request's, else the spec's top-level `non_interruptive`, else the
`launch.non_interruptive` setting.
### Durable install target
Bundle apply requires an explicit target root at the HTTP boundary. The CLI supplies the
current directory as the default for `rig up <file>.rigbundle` and for a GitHub link through
`rig up` or `rig bundle install`. `rig bundle install` with a local `.rigbundle` sends no
default, so apply without `--target` gets a 400. For a pod bundle,
`materializePodBundle()` copies the verified extraction into that durable root before
instantiation, so local agent references and relative working directories survive removal
of the extraction directory. It checks destination conflicts before copying, preserves
identical files, and refuses differing files or incompatible destination types.
When an unarchived or running team already has the bundle's rig name (`bundleInstallContext()`
in `packages/daemon/src/domain/bundle-install-context.ts`), pod-bundle apply refuses before
writing if any of them is running. If the target is that team's own install folder (its
`bundle.yaml` names the offered schema-2 bundle, or `my-bundle` or `github-bundle`, the default
names before 0.6.7, and it is the folder recorded in `rigs.install_root` when any of those teams
recorded one; `isExistingBundleTarget()`), bootstrap first
confirms the old sessions are stopped; materialization then copies each conflicting path to a
`bundle-backups/reinstall-*` folder under the OpenRig home, with a `RESTORE.json`, before
replacing it, and leaves unrelated files in place. Any other target keeps the conflict refusal.
The instantiator archives the stopped earlier generation. Pod-bundle apply records the
target's real path as the new rig's `install_root` (migration `096`).
Pod bundles containing service definitions are refused by bootstrap; their service path
requires a stable spec directory. Direct spec bootstrap has separate service prelaunch
handling. See [agent-spec-and-startup.md](agent-spec-and-startup.md) for instantiation.
## GitHub folder links and the bundle check
`packages/cli/src/lib/bundle-source.ts` lets `rig up`, `rig bundle create`, `inspect` and
`install` take a GitHub folder link (`isGitHubBundleLink()`). Only a credential-free
`https://github.com/owner/repo[/tree/<ref>/<folder>]` link is accepted. The ref is resolved to
the longest matching branch or tag, or taken as a full commit hash. The import:
- needs a healthy local daemon whose `selfHostId` matches the local origin;
- runs Git with credential helpers, hooks and non-HTTPS protocols disabled;
- shallow-fetches the exact commit into `bundle-imports/` under the OpenRig home, requires a
`rig.yaml` in the folder, and refuses symlinks or agent and package references that resolve
outside the checkout;
- posts the folder to `/api/bundles/create` with the source recorded in provenance, refuses a
response without a package digest or with a different source commit, and writes
`source.json` and `build.json` in the import folder.
`rig bundle check <folder>` (`checkBundleFolder()` in `packages/cli/src/lib/bundle-check.ts`)
is an advisory, file-read-only check against `openrig.bundle-standard/v1`; it makes no daemon,
launch or provider call. Its rules are `pod_aware_rig`, `readme_in_docs`, `referenced_files`,
`portable_agents`, `minimum_versions`, `configurations` and `credential_paths` (a filename scan
bounded at 10,000 entries that skips `.git`, `node_modules` and symlinks). `readme_coverage`
and `embedded_secrets` are always reported as not checked, and host-resolved resources are
reported as checked at launch (`host_resources`). Any finding sets exit code 1.
## Install checks and content routing
The install handler in `packages/daemon/src/routes/bundles.ts` obtains a source-path
lock, reads validated metadata, checks compatibility, and checks a declared rig name
against same-name rigs with running sessions before bootstrap; a stopped team of that name
doesn't block it. A conflict returns a 400 with `status: "not_attempted"`, the installed team
and the offered bundle, and three choices: use the existing team, stop it and retry to replace
it, or cancel. The compatibility override `skipVersionCheck` and name-conflict override `force`
are explicit request fields; neither skips the metadata pass, which always validates the
manifest.
`packages/daemon/src/domain/bundle-conflict-detector.ts` implements the rig-name check.
It is not a complete agent, port or filesystem collision audit; a missing rig name supplies
no name comparison. Target-file conflicts are handled separately by materialization.
Skipping these prechecks does not skip archive validation in bootstrap.
Declared content is routed by `routeBundleContents()` in
`packages/daemon/src/domain/bundle-content-routing.ts`, which unpacks the bundle once and
routes each kind independently. When it runs depends on the bundle schema:
- **Pod-aware (schema 2):** bootstrap runs it as `PodRigInstantiator`'s prelaunch hook, after
the rig record exists and before any seat launches, so every seat's first turn can see the
content.
- **Legacy (schema 1):** there is no hook, so the install route routes after bootstrap, and
only when the status is `completed`.
| Manifest content | Destination |
|---|---|
| `skills` | The OpenRig `packages` cache, stripping the legacy `packages/` prefix; this does not import a complete harness skill into the managed skill catalog. |
| `plugins` | The OpenRig `plugins` root, through local plugin references. |
| `workflow_specs` | `workflows/` beneath the resolved `workspaceSpecsRoot`. If that setting is empty, this kind is recorded as a routing failure. |
| `context_packs` | The configured `context.root`, matching context-library discovery. |
| `agent_images` | The OpenRig `agent-images` root; declarations address image directories containing a manifest. |
| `project` | Registered in the workspace catalog (`workspace.catalog_path`) with the rig associated, and its files placed under `workspace.projects_root/<id>` (`registerBundleProject()` in `packages/daemon/src/domain/workspace/project-registration.ts`). A conflict changes nothing and is a routing failure. |
Context packs are never merged into an installed pack of the same name: an identical one is
reported as `already_installed`, a different one is kept unchanged as `kept_existing`. After
routing, the live context library rescans, and a routed pack it can't load becomes a routing
failure.
The per-kind implementations are the `bundle-skills-router.ts`, `bundle-plugins-router.ts`,
`bundle-workflow-specs-router.ts`, `bundle-context-packs-router.ts` and
`bundle-agent-images-router.ts` files under `packages/daemon/src/domain/`. Routing is best
effort in both cases: the hook always lets the launch continue, and a failure never rolls back
or fails the install. Failures appear in the result's `routingFailures` and `warnings`, in a
`route_bundle_contents` stage for the hook, and in the install audit record.
Install audit records are appended best effort to `bundle-audit.jsonl` under the OpenRig
home; the history endpoint reads that audit. Bootstrap run state and action journals are
separate, repository-backed records.
## Legacy package install path
The legacy path remains executable, not just an archive parser.
`packages/daemon/src/routes/packages.ts` is mounted at `/api/packages`, and legacy
bootstrap calls `PackageInstallService` in
`packages/daemon/src/domain/package-install-service.ts`.
The source chain is package resolution/manifest validation, planning and conflict policy,
then apply/verification with repository records:
- `resolvePackage()` in `packages/daemon/src/domain/package-resolve-helper.ts`, which reads
and validates through `package-manifest.ts`. `package-resolver.ts` supplies the types; its
`PackageResolver` class has no production caller.
- `packages/daemon/src/domain/install-planner.ts`, `conflict-detector.ts` and
`install-policy.ts`. The planner refuses any planned destination outside the install
target.
- `packages/daemon/src/domain/install-engine.ts`, `install-verifier.ts`,
`install-repository.ts` and `package-repository.ts`.
Legacy bootstrap also probes requirements and records staged outcomes before rig
instantiation. A failed package installation prevents that path from importing the rig.
This compatibility implementation does not imply that pod-aware bundles use the same
package-install stages.
## Related maps
- [plugin-agent-image-context-pack.md](plugin-agent-image-context-pack.md): content library
consumers and their state boundaries.
- [daemon-core.md](daemon-core.md): bootstrap construction and HTTP wiring.
- [agent-spec-and-startup.md](agent-spec-and-startup.md): spec resolution, projection and launch.
@@ -1,339 +0,0 @@
---
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: |
Locate plugin discovery and vendoring, context-pack addressing/composition,
agent-image capture/fork/protection, or Claude guided-compaction behavior.
siblings: [packaging-bootstrap-bundles.md, agent-spec-and-startup.md]
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Content libraries and compaction
This module describes source at main commit
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. Source paths below are repository-relative.
Context packs, plugins and agent images have filesystem-backed content and daemon-side
discovery. Their consumers can read database identity, mutate files, deliver messages or
launch sessions; the whole layer is not a read-only catalog.
## Context packs
`packages/daemon/src/domain/context-packs/context-pack-library-service.ts` implements
`ContextPackLibraryService`. A pack contains `manifest.yaml` and declared members.
Its path-like ref is the lookup identity, independently of the manifest name/version;
the entry ID is `context-pack:<ref>`.
### Discovery and mutation
Startup in `packages/daemon/src/startup.ts` configures the shipped context-pack root (when
it exists), the resolved `context.root` and its `system` root, followed by the
workspace-local `.openrig/context-packs` root. The workspace root is registered even before
it exists, so a folder created later is found by the next sync; it is left out only when it
is the same path as one of the other two. The library scans recursively, stops descending
when it finds a pack manifest, skips `.tmp-add-*` staging folders, and does not follow
directory symlinks.
The first configured root to encounter a physical pack owns that directory. For distinct
physical packs with the same ref, the later root wins. This distinction prevents overlapping
roots from giving the same physical pack a second address. Scan replaces the in-memory ref
index and reports malformed packs instead of indexing them.
`composeFromFiles()` creates a durable pack after validating its ref, destination and
sources. `removeByRef()` removes the selected pack directory and rescans, but refuses
shipped builtin packs. Library mutation and lookup share ref validation.
`packages/daemon/src/domain/context-packs/manifest-parser.ts` parses manifests.
Its supported file suffixes include Markdown/YAML/text and `.sh`, `.ts`, `.mjs`,
`.py` helper assets. Those assets are served as text; pack assembly does not execute them.
The parser also validates atom metadata and named profiles.
### Assembly, addresses and profiles
These are separate projections:
| Operation | Source and behavior |
|---|---|
| Framed pack preview | `packages/daemon/src/domain/context-packs/bundle-assembler.ts`, `assembleBundle()`: pack/file headers, file metadata, token estimates and `missingFiles`. Missing members are reported and skipped; a failed read of a present member raises an error. |
| Plain file composition | The same file's `assemblePlainFiles()`: present files in declared order, separated by two newlines, without framing or trimming their bytes. |
| Addressed read | `packages/daemon/src/routes/context-packs.ts`, `/library/resolve-address`: longest matching pack-ref prefix, a declared file, then optional H2/H3 heading selection through `packages/daemon/src/domain/markdown-address.ts`. |
| Situation profile | `packages/daemon/src/domain/context-packs/profile-composer.ts`, `composeProfile()`: select atoms, close dependencies, order and resolve each piece. |
| Named profile | The same file's `composeNamedProfile()`: declared phases select atoms or supplied work/seat context, preserving phase IDs in the result. |
Situation composition selects fresh atoms for `fresh`, fresh plus handover atoms for
`handover`, and post-compaction plus handover atoms for `post-compaction`. Runtime
selection is Claude or Codex, with `any` atoms applicable to either. Required atoms join
the selection; missing dependencies or runtime-incompatible dependencies fail composition.
The output is ordered by atom order, then ID. Profile-only atoms are excluded from ordinary
situation selection, but the dependency closure doesn't check that flag, so one can still join
as a dependency of a selected atom. Post-compaction composition skips an absent
`seat:RECAP.md` and reports it as skipped instead of failing.
The profile route resolves configured tree sources through
`packages/daemon/src/domain/context-packs/profile-source-resolver.ts`.
Seat context requires explicit rig and seat selectors. Mission/slice selectors and named
profile context requirements are validated before reading those trees. The default
project/mission/slice walk is bounded to the conventional single-project workspace layout
and fresh situation; it is not an arbitrary project-catalog resolver.
Each resolved piece names its address/source and reports estimated tokens. A token budget
reports overage and drop candidates without silently truncating the profile. Atom
`regions` are metadata; the profile endpoint has no region-subset selector.
### HTTP and CLI boundaries
`packages/daemon/src/server.ts` mounts `contextPacksRoutes()` at
`/api/context-packs`. Its library handlers cover list, sync, compose, read/delete by ref,
preview, pieces, addressed reads and profile composition.
`packages/cli/src/commands/context.ts` implements `rig context`, including `list`, `show`,
`preview`, `get`, `profile`, `compose`, `add`, `rm` and `sync`, plus `work-install`, `trace`,
`source inspect|update` and `recap-write`. The library read/compose routes do not themselves
send a pack to a running seat. Several verbs write: `add` installs into `context.root` (from
a folder, a URL, or Git with `--git`), `source update` fetches and merges a Git-installed pack
and resyncs, `work-install --apply-skills` reconciles skill projections, and `recap-write`
writes a seat recap. Inspect the selected CLI verb before treating a context command as
read-only. `rig context work-install` resolves a project position and its declared context
without the daemon; [workspace-primitive.md](workspace-primitive.md) covers project
resolution.
On a profile read, the route adds each piece's `sha256` and provenance, with a warning when a
piece's bytes come from outside the granted folder. Rig and seat selectors grant the seat's
folder as a source root and supply a default `seat:LEARNED.md` atom to named profiles whose
phases request seat context. An ordinary situation profile includes that file only when the
pack authors a seat atom; the selectors alone don't add it.
### System World, seat recaps and skill loadouts
`packages/daemon/src/domain/system-world.ts` reads the System World manifest (schema
`openrig.system-world/v0alpha1`): an id, a version, the context packs every seat starts from,
and system skills. The `context.system_world` setting selects the default manifest at
`system/system-world.yaml` under `context.root`, disables it, or names another manifest file.
The default (`openrig-default`) names the `onboarding-width` and `world-public` packs and no
skills; it is seeded when missing.
`rig context recap-write --rig <rig> --seat <seat> --file <file>` writes the seat's
`RECAP.md` through `packages/daemon/src/domain/context-packs/seat-recap-store.ts`. The previous
recap is kept byte-for-byte under `recap-superseded/`. A recap that isn't addressable Markdown
is refused; the other content checks are advisory.
`packages/daemon/src/domain/skill-catalog.ts` resolves a seat's skill loadout from the
catalog at `skills.root` (`catalog.yaml`, schema `openrig.skill-catalog/v1`) and the system,
topology and project selections. For a working directory it projects skills into
`<cwd>/.claude/skills` or `<cwd>/.agents/skills` and tracks what it owns in
`<cwd>/.openrig/skill-loadouts/<runtime>.json`. For a Claude or Codex team seat it takes a
`location` instead: the seat's own storage outside the project, with its `skill-loadout.json`
there, and for Claude the `openrig:<id>` names its generated plugin gives. Each projection
reports `current`, `missing`, `shadowed`, `stale` or `conflicting`.
`rig skill loadout --runtime <runtime>` inspects the composed projection for a working
directory, `rig skill loadout --seat <session|self>` does the same for one seat's own skills,
and both reconcile only with `--apply`; `rig skill audit` is a separate
read-only provenance and freshness audit. Launch applies the selected loadout per seat.
For a Claude Code or Codex seat, the profile resolver also adds each selected plugin's skills to
that loadout (`packages/daemon/src/domain/profile-resolver.ts:309–330`, with
`resolvePluginSkills()` at `skill-catalog.ts:460`). Neither runtime reads skills from the plugin
folder projected into the working directory. The skills are projected under their plain names,
and a skill the profile already selects keeps its source. A kept plugin copy (an edited or
user-owned one, or OpenRig's own copy whose plugin source changed or moved after the loadout
was resolved, `skill-catalog.ts:1060–1068`) gives a `plugin_skill_kept` warning, and an unreadable one gives
`plugin_skill_skipped`. If plugin skills can't be projected at all, the seat starts without them
and gets a `plugin_skills_not_projected` warning (`rigspec-instantiator.ts:2045`).
## Agent images and forks
`packages/daemon/src/domain/agent-images/agent-image-library-service.ts` implements
`AgentImageLibraryService`. Startup registers the OpenRig `agent-images` root and,
when present, the workspace-local `.openrig/agent-images` root. Entries are keyed by
`agent-image:<name>:<version>`, with later discovery roots replacing identical IDs.
Content is a manifest plus optional files; `stats.json` holds usage data and `.pinned`
marks protection from ordinary deletion/pruning.
Capture and consumption depend on live identity as well as library files:
- `packages/daemon/src/domain/agent-images/resume-token-discovery.ts`:
`discoverResumeToken()` reads session/node/binding state. It supports Claude Code and
Codex. Claude prefers matching context-usage identity; Codex prefers the persisted resume
token, then an external-CLI binding. Absent native identity remains absent.
- `packages/daemon/src/domain/agent-images/snapshot-capturer.ts`:
`SnapshotCapturer.capture()` refuses unsupported or missing native identity, writes
an image manifest/files and rescans. It records the source working directory when known.
- `packages/daemon/src/routes/agent-images.ts`:
`POST /fork` composes native identity discovery with an add-member operation. The
default path makes no image. The keep-image path captures and pins one before launch,
and reports that retained image even if launch fails.
Library/snapshot responses redact `sourceResumeToken`; the fork composer builds its
native-ID session source inside the daemon rather than returning that field to the caller.
This describes these response projections, not secrecy of every field in an image manifest.
`recordConsumption()` accepts a separate fork-count flag: consumption intent can update
last-use time without incrementing successful forks. Inspect the instantiation caller as
well as library statistics when assessing whether a launch succeeded.
`packages/daemon/src/domain/agent-images/evidence-guard.ts` implements
`evaluateProtection()`: pins, parsed spec references and descendants of protected images
contribute protection reasons. The route applies the guard to deletion/pruning unless
`force` is explicit. Prune defaults to dry-run. Its protection view depends on the supplied
spec roots; it is not an inventory of every reference anywhere on the host.
The guard keys its result by image name, not version: every version of one name gets the
status computed for the last-listed version, carrying that version's image ID. The delete
route looks up by image ID, so on delete only that last-listed version of a name is matched,
and its other versions are deleted without `force` even when pinned. `rig agent-image prune`
sends a dry run unless `--force` is given, and `--force` also overrides the guard, so a
guarded real prune is reachable only through the HTTP route.
The routes mount at `/api/agent-images` and expose list/sync, snapshot/fork,
entry/preview, pin/unpin, delete and prune.
`packages/cli/src/commands/agent-image.ts` owns the image library commands. The top-level
`rig fork` verb (`packages/cli/src/commands/fork.ts`) drives `POST /fork`; `--keep-image`
also captures a pinned image. Forking a Claude Code seat needs Claude Code 2.0.73 or later,
because the fork is launched with its own `--session-id`.
## Plugins
### Discovery and inspection
`packages/daemon/src/domain/plugin-discovery-service.ts` discovers plugins from:
| Source kind | Directory shape |
|---|---|
| `vendored` | OpenRig plugins root, one plugin directory per entry. |
| `claude-cache` | Claude cache, grouped by marketplace/plugin/version. |
| `codex-cache` | Codex cache, grouped by marketplace/plugin/version. |
| `rig-cwd` | `.claude/plugins` and `.codex/plugins` beneath supplied working-directory roots. |
Detection uses the Claude/Codex plugin manifests. `listPlugins()` returns runtime/source
metadata; `getPlugin()` adds manifests, skills, hooks and MCP detail.
`findUsedBy()` scans agent YAML in configured spec-library roots for plugin declarations
and profile references. These references describe authored specs, not proof of a running
harness loading a plugin.
The service constructor is not read-only: it retires obsolete refocus hook registrations
from the older bundled plugin and rejects duplicate vendored hook registrations across
providers. Cached plugin versions are excluded from that duplicate-provider check. Startup
constructs the service while building the daemon's dependencies and doesn't catch that
rejection, so a duplicate vendored hook stops daemon creation.
`packages/daemon/src/routes/plugins.ts` exposes GET-only inspection at `/api/plugins`:
list, detail, used-by and file list/read. The file routes use the discovered plugin root as
their path allowlist. `?cwd=` supplies rig-cwd discovery roots.
At this pin, the route's source filter accepts vendored, Claude-cache and Codex-cache only;
`source=rig-cwd` is not applied as a filter, even though the service supports that kind.
### Vendoring and projection
`packages/daemon/src/domain/plugin-vendor-service.ts` owns `PluginVendorService`.
`ensureVendored()` seeds absent plugins or advances an older numeric manifest version.
Equal/newer installed versions preserve installed bytes; equal versions can reconcile file
modes on byte-identical files. Missing target version authority is preserved rather than
overwritten; invalid or conflicting manifest versions are errors. Advancing a version writes
changed files but never deletes files the new version dropped.
`ensureSkillGlobally()` projects a named plugin skill using a vendor-version marker.
An existing unversioned global skill remains externally owned. Startup wires this service
and its filesystem implementation; inspect that wiring when changing projection roots. At
startup only `openrig-core` is vendored, and its `openrig-skills` and `refocusing` skills are
projected globally into `~/.claude/skills` and `~/.agents/skills`
(`packages/daemon/src/startup.ts:877–886`). The packaged `rigs` skill
(`packages/daemon/assets/skills/rigs`) is not a plugin skill: `ensureSkillDirGlobally()`
projects it into the same roots under the daemon version, so each upgrade refreshes it
(`startup.ts:891–899`). A failed global projection is logged and does not stop startup.
`ensureLatest()` performs local vendoring before `attemptAutoFetch()`. The latter
requests a release asset with a bounded timeout and logs failures; even its successful
response path does not extract/install the fetched archive at this pin. This does not
establish whether an upstream release asset currently exists.
`packages/cli/src/commands/plugin.ts` implements `rig plugin
list|show|used-by|validate`. Discovery, vendoring and a runtime's projected plugin tree
are distinct steps; see [agent-spec-and-startup.md](agent-spec-and-startup.md) for launch.
## Claude guided compaction
`packages/daemon/src/domain/claude-compaction-enforcer.ts` implements
`ClaudeCompactionEnforcer`. Startup shares one instance between `ContextMonitor`
and the manual-compaction route consumers.
`packages/daemon/src/domain/user-settings/settings-store.ts` resolves the
`policies.claude_compaction.*` settings; automatic compaction defaults to disabled.
`maybeAutoCompact()` applies the delivery guard when one exists and the runtime is Claude
Code. Its implementation requires Claude Code, observed usage and a valid integer threshold
(default 80). Above-threshold automatic work additionally checks enablement, the
post-restore cooldown (10 minutes), the deduplication window (60 seconds) and the
already-triggered latch; an active manual attempt also blocks it.
While the policy is enabled with a valid threshold (`managedCompactionActive` in
`packages/daemon/src/adapters/kernel-authority.ts`, using the enforcer's exported `isValidThresholdPercent`),
`operationalLaunchArgs` merges `idleCompaction: false` into the Claude launch's one `--settings` value, adding one
when there is none. Fresh launch, fork, resume (`claude-code-adapter.ts`) and restore (`claude-resume.ts`) all build
their Claude arguments there. The policy is read at each launch; a running session keeps the settings it was launched
with. Claude Code before 2.1.290 ignores the key, Claude's managed settings take precedence over `--settings`, and
`autoCompactEnabled` is not set.
`/compact` is held behind a preparation attempt:
1. The first above-threshold tick starts an attempt with its own ID, the seat's occupant
generation, a restore-map path and a completion marker, then sends only the prep prompt.
An unknown occupant generation stops the attempt at once.
2. The agent writes `RESTORE-MAP.md.tmp` with its file-edit tool, completion marker last.
Later ticks return `preparation_pending` until that exact map is complete. The map path is under the seat's working directory
(`.openrig/compaction/preparation/<session>/<attempt>/`, with a `.gitignore` of `*`) when
that is writable, otherwise under `compaction/preparation/` in the OpenRig home. An explicit
`policies.claude_compaction.preparation_root` instead places maps at `<root>/<session>/<attempt>/`
for both manual and automatic attempts; failure to prepare that root stops the attempt without fallback.
3. The daemon atomically renames the completed temp file to `RESTORE-MAP.md` in its existing
final send checks, after rechecking the attempt and occupant. An already-complete final map
remains compatible. Only then is `/compact` sent, with the final map path appended to the
compact instruction. Publication failure cannot authorize `/compact`.
4. The continuation then runs below the threshold: turn boundary, restore prompt, then a
read-depth audit prompt (each item marked `FULL`, `PARTIAL` or `NOT_READ`), one stage per
poll tick. A seat refreshes its usage sample only when it takes a turn, so when the sample is
stale, unknown or has no percentage and a stage is pending, the monitor drains that stage
without one (`context-monitor.ts:175`, `claude-compaction-enforcer.ts:436`). Only a fresh,
known sample can start a compaction. The sample-less drain reads readiness from the pane alone
and counts a timer-less live status row, such as `✻ Compacting conversation…`, as work
(`session-transport.ts:153-161`); a recent hook showing work or a waiting person still
refuses. It records no width receipt, because the only figure would be the pre-compact one.
An automatic attempt has a 25-minute ceiling that starts once the prep is delivered. A stale
occupant generation, the policy becoming disabled, or the deadline passing stops it. A prep
that clearly wasn't sent is retried at most three times; an uncertain prep or `/compact` is
never replayed.
The restore prompt names a restore-pending marker at
`compaction/restore-pending/<session>.json` in the OpenRig home, which the `openrig-core`
PreCompact hook writes. A per-seat `compaction/post-compact-extra/<session>.md` is preferred
over the global extra file, and an extra file that declares a different seat is refused.
`rig compact <session>` (`packages/cli/src/commands/compact.ts`) runs the same sequence
manually through `/api/compaction/trigger`, `cancel` and `state`, which use the terminal
bearer-token middleware; it passes every request through when no token is configured.
`--skip-map` skips the map requirement once, `--cancel` ends the preparation, and `--state`
shows the attempt, its expected map and its deadline. Ordinary approval prompts during preparation can be answered within that same deadline; OpenRig waits without answering them. A manual attempt has 180 seconds for
preparation and the idle wait.
The below-threshold continuation checks policy enablement too, except for an explicitly
operator-initiated manual sequence. A known occupant-generation mismatch invalidates the
queued stages; an unknown generation does not prove a match. Failed or retained sends
do not advance the stage. The manual trigger shares this continuation while having its
own initiation checks.
Stage, deduplication, cooldown and preparation-attempt maps are in memory; the restore maps
are files. Startup supplies live generation
resolution and a post-restore callback that records a managed-width receipt, so the wider
operation also has persistent effects. Do not infer durable lifecycle completion from
a library read or one successfully queued message.
## Related maps
- [packaging-bootstrap-bundles.md](packaging-bootstrap-bundles.md): bundle export and
routing into content libraries.
- [agent-spec-and-startup.md](agent-spec-and-startup.md): context/resource projection
and image consumption during launch.
- [lifecycle-snapshot-restore.md](lifecycle-snapshot-restore.md): broader continuity flows.
@@ -1,578 +0,0 @@
---
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 transcript capture
(periodic tmux capture-pane) and rg/grep search behave, how durable rig chat
(SQLite + SSE) is modeled, what rig ask gathers, or the exact MCP-tool-name
vs tmux-metadata-key naming distinction.
siblings: [daemon-core.md, lifecycle-snapshot-restore.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Transport, Transcripts, Chat, Ask
The communication-and-history layer: tmux is transport, not truth. Send/
capture/broadcast wrap tmux with honest errors; transcripts are bounded
`tmux capture-pane` snapshots written to files; chat is daemon-backed SQLite;
the daemon's `rig ask` service gathers evidence and never calls an LLM.
> Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count below sits beside the
> command that produces it; run the command from the repository root to refresh
> it.
## 1. The naming axes (read this first)
Two independent naming axes meet in this layer. They must not be conflated or
blanket-replaced: `rigged` → `rig` is a per-occurrence question, not a global
substitution.
**Axis 1 — MCP tool names are `rig_*`.** `packages/cli/src/mcp-server.ts`
registers **24** tools, all named `rig_*`
(`grep -c 'server.tool(' packages/cli/src/mcp-server.ts`), including
`rig_rig_nodes` (`mcp-server.ts:290`), `rig_send` (`:307`),
`rig_capture` (`:341`), `rig_chatroom_send` (`:366`) and `rig_chatroom_watch`
(`:402`), the five queue tools `rig_queue_create`, `_show`, `_list`, `_claim`
and `_update`, and `rig_telemetry_events`. No `rigged_*` name other than the tmux keys below remains in product
source: **0** hits
(`git grep -n 'rigged_' -- ':(glob)packages/*/src/**' | grep -v -c '@rigged_'`).
**Axis 2 — tmux metadata keys are `@rigged_*`, and are correct as-is.** The
tmux metadata keys written at claim/bind time are a separate thing from MCP
tool names and were not renamed. `setRiggedMetadata` in
`packages/daemon/src/domain/claim-service.ts:182`–`186` writes exactly **5**
keys (`grep -c '@rigged_' packages/daemon/src/domain/claim-service.ts`):
`@rigged_node_id`, `@rigged_session_name`, `@rigged_rig_id`,
`@rigged_rig_name`, `@rigged_logical_id`. Do not blanket-sed `rigged` → `rig`.
The metadata-key axis is detailed in `agent-spec-and-startup.md` (identity:
whoami, bind, adopt).
## 2. Transport and communication domain services
All under `packages/daemon/src/domain/`:
- `session-transport.ts` — communication primitives: send/capture/broadcast
with session resolution, send-readiness classification (interactive-prompt
and mid-work detection), honest error reporting, and list/pod/rig/global
targeting.
- `transcript-store.ts` — transcript file management: path convention, ANSI
stripping for tail and grep reads, boundary markers, `readTail`, `readFull`, `grep`.
Filesystem-backed, NOT SQLite. The capture that writes the files is
`transcript-rotation.ts` (§4).
- `history-query.ts` — transcript + chat search. Prefers `rg`, falls back to
`grep -E`, surfaces which backend was used: `history-query.ts:8`
`backend: "rg" | "grep" | "none"`; `:252` runs
`rg -i --no-filename -e <pattern>` over the rig's transcript directory;
`:267` falls back to `grep -E -i -h -e <pattern>`.
- `ask-service.ts` — context-engineering evidence pack: gathers rig summary
plus transcript excerpts, chat excerpts, insufficiency state, guidance.
Does NOT call an external LLM.
- `chat-repository.ts` — durable rig-scoped chat over the `chat_messages`
table (`send`, `sendTopic`, `history`, `latest`, `searchChat`, `clear`). The
repository emits no events: `routes/chat.ts` emits `chat.message` on the
event bus after each send or topic, and `/watch` streams those as SSE.
Routes: `packages/daemon/src/routes/{transport,transcripts,ask,chat,whoami}.ts`
— all present.
## 3. Communication flow
`rig send <session> "message"` → CLI → `POST /api/transport/send` →
`SessionTransport.send()`:
1. Resolve the session name (`resolveBySessionName`,
`session-transport.ts:963`): not found → 404; the same name in more than
one rig → 409. A tmux probe of the session can also refuse it as missing or
tmux as unavailable (`:1317`–`1334`). Pod, rig, global and `--to` list
targets go through `POST /api/transport/broadcast`, which resolves them
with `resolveSessions` and sends to each recipient in turn.
2. Take the seat's delivery lease. Every write runs under a per-seat,
serialized lease from the seat delivery guard, installed at daemon start
(`send`, `:1155`). With the seat's typing guard on, the message is held
instead of typed: the result is `outcome: "retained"` with HTTP 200
(`routes/transport.ts:115`); operators use `rig seat set-typing-guard` and
`rig seat held-messages`. This same typing guard has `off` (default),
`draft-aware`, and `hold` modes. The legacy enabled boolean selects hold/off.
`DraftAwareDelivery` makes one attempt for opted-in seats and immediately returns
the exact refusal reason and original held IDs when input is unavailable.
Migration `100_typing_guard_modes` extends existing guard/audit rows and adds
non-executable receipt metadata to the original outbox; it creates no tables
or retry scheduler. Combined wakes preserve every original ID/body.
Legacy storage bits fail safe to hold on rollback; newer legacy audit writes
supersede older saved modes on re-upgrade, including same-value writes.
3. Classify send readiness. Only a positive interactive-prompt reading
(`needs_input`) refuses, with `target_needs_input`
(`session-transport.ts:1482`), unless the caller passes
`--dangerously-interact --reason`; that override persists an audit record
before sending and refuses the send if it can't
(`prompt_override_audit_unavailable`, `:1471`). A pane that reads `unknown` while the seat's
latest runtime hook says it waits on a person reads as `needs_input` (`session-transport.ts:1878–1889`). A mid-work reading (`running`, from a
fresh runtime hook or the pane's mid-work patterns,
`findPatternEvidence(recentLines, [...MID_WORK_PATTERNS, CLAUDE_LIVE_STATUS_PATTERN])`,
`:311–315`) or an `unknown` one proceeds with an advisory `warning` (`:1495`,
`:1503`). `--force` has no effect on this path, and combining it with
`--wait-for-idle` is refused with 400 (`routes/transport.ts:66`–`73`).
4. Two-step tmux send: a unique file/buffer pasted with `paste-buffer -d -r -p`
(`packages/daemon/src/adapters/tmux.ts:625`) → ~200ms delay
(`session-transport.ts:1558`) → separate named `Enter` (`session-transport.ts:1573–1578`). The paste
targets the seat's registered pane ID, not the session name, and is refused
with no input written if the session no longer holds exactly that pane.
Bracketed paste preserves multiline input in supporting TUIs; the payload
never enters a shell argument. A `--dangerously-interact` answer is pasted
without `-p` (`session-transport.ts:1541`) and Enter is pressed only if the
whole answer is still staged (`:1562`–`1570`). A successful paste proves
transport execution, not runtime consumption.
The transport's prompt reader uses the shared `composer-prompts.ts` matchers
from Mr-Neutr0n's #635. Its cursor-aware extension preserves capture styling
and excludes a wholly dim autocomplete suffix after the cursor. The daemon
directly depends on `string-width` to compare Unicode terminal cell widths
with cursor positions and exact staged text; a dependency hoisted from the
TUI would not declare this daemon runtime requirement. Default seats retain
their existing advisory recognition and staged-text verification behavior.
For draft-aware seats, `TmuxAdapter` checks the current composer after buffer
preparation and again immediately before keys. The delivery lease records
the pasted text and any observed collapsed-paste label; Enter requires that
owned input. Late human input, changed recipients or stopped queue wakes refuse
further input or hold the message; ambiguous writes remain indeterminate. Nothing
is retried automatically. Unicode cell widths use the daemon's direct
`string-width` dependency. See [per-seat terminal delivery](../../reference/seat-delivery.md)
for activation, persistence, inspection and lifecycle tradeoffs.
5. Optional `--verify`: capture the last 30 pane lines before the paste and,
500 ms after Enter, again, leaving out the current input box (the last
prompt line and its continuation, `verificationTranscript`). A send
carrying OpenRig's From/To header is matched as the whole message with
whitespace normalized (`countEnvelopeOccurrences`); a raw send is matched
by its first 40 characters (`verificationSnippet`). More occurrences after
the send report `outcome: "delivered"`; otherwise the outcome is
`rendered-unconfirmed`: text and Enter landed but the capture could not
re-confirm the render, which is not a failure. The outcome values are
`delivered`, `rendered-unconfirmed`, `failed` and `retained`.
With `transport.verify_input_box` on (off by default), a Claude Code or
Codex send is verified only when the seat was proven idle before the paste
and the input box shows the message, and is unconfirmed otherwise:
- **Seat idle before the paste** (`readSeatBeforePaste`). The `--verify`
capture taken before the paste (the whole screen) must match an idle
allow-list (`SEAT_IDLE_SHAPES`) built from real Claude Code 2.1.296 and
Codex 0.160.0 frames and, where no frame was captured, from the Claude
Code 2.1.296 bundle, the Codex 0.160.0 source and OpenRig's own launch
code. Claude Code: the empty box or its `Try "…"` suggestion between two
rules; the opening rule plain, or carrying this seat's own session name
the way Claude Code draws `--name` (`─…─ <session> ─`, or `──` as in
earlier versions, as wide as the closing rule; OpenRig launches every
Claude seat with `--name`); the footer row of the seat's permission
posture, one for each mode OpenRig can launch: bypass
(`⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents`), accept
edits (OpenRig's default), auto, don't ask, plan, and default
(`⏸ manual mode on · ← for agents`); between the closing rule and the
footer, either no row (OpenRig's statusLine collector prints nothing) or
the rows a person's own statusLine prints, with the indicator Claude
draws at the right of the first one (`● high · /effort`), trusted only by
position, as the composer reader trusts them (#2703, #2798): the closing
rule is the first rule above the footer, the frame above it must check
out, and no row between them may be blank or look like a menu or dialog
(`❯`, `›` or `»` anywhere in the row, a row that starts with a numbered
choice such as `1. ` or `2) `, with or without a border or gutter before
it, a box corner, or a row framed by side borders); so a statusLine that
uses `›`, `»` or `❯` as a separator, prints a blank row or starts a row
with a number and a dot reads `unknown`; a blank, effort or tmux-notice
row above the box; and above
that, in the status slot, the completion row of the seat's last turn
(`✻ <verb> for <duration> · done <time>` at column 0, matched by the
Claude Code 2.1.296 bundle's grammar: one of its eight past-tense verbs,
its duration format, and the time of a turn finished today on a 12- or
24-hour en-US clock). A blank slot counts only under that completion
row, with only blank rows between them (as in the fullscreen renderer),
or, before the first turn, under the last row of the Claude Code v2.1.296
welcome banner. OpenRig launches Claude in the classic renderer, where the
box follows the transcript, so an idle seat that has finished a turn
shows that turn's completion row in the slot; real frames of idle seats
OpenRig launched show it. A configured statusLine, which OpenRig installs
on every Claude seat, makes Claude Code drop the footer's hints, so a
running turn draws the same footer row as an idle one, without
`esc to interrupt`: the status slot is what tells them apart. The 2.1.296
bundle draws the live status row (the spinner) there only while no
streamed text with a complete line is on screen
(`isLoading && (!hasDisplayedStreamingText || tailBlank || …)`); while
streamed text is shown it draws nothing in its place, and the last
streamed row moves into the slot. That row never matches the completion
row, because Claude Code draws a message behind its `●` column. The
bundle does not show that the last streamed row is never blank, so a
blank slot under transcript text reads `unknown`: that case is left
unconfirmed rather than read as idle. Codex: the empty box over its
status line and a `? for shortcuts` hint row, with the completion row or
the fresh session's greeting as the last row above the box. The
completion row is matched by the grammar Codex 0.160.0 prints it with
(`Worked for <elapsed> • <time>`, a 12- or 24-hour clock, a date before
the time on another day, runtime metrics after it). No row anywhere may
be a running, streaming, queued, pending-input or paste-hint row (a live
status row, `esc to interrupt`, `paste again to expand`, a queued label,
Codex's pending-input headers or `↳` rows, a spinner). Any other frame,
including every running, streaming, steer-preview, paste-hint or unknown
layout, another seat's name, a second rule drawn under the box, a blank,
prompt-, menu- or dialog-shaped row between the closing rule and the
footer (so a fullscreen renderer that reserves a blank row for the
statusLine reads as before), and a background shell in the footer, reads
`seat: not-idle` or `unknown`, and the send reads unconfirmed. This
holds when a running turn hides its signal (Codex hides its `• Working`
row while it streams; Claude Code shows only `paste again to expand` in
its footer for 8 s after a paste, and no `esc to interrupt` under a
statusLine). So a send to
a busy or unknown seat always reads unconfirmed.
**Versions.** The layouts were recorded from Claude Code 2.1.296 and
Codex 0.160.0 (the versioned frames under
`packages/daemon/test/fixtures/input-box/`, with their limits in that
folder's README). A frame that visibly names another client version reads
`unknown` before the paste and `unrecognized` in the box: Claude Code's
welcome banner (`Claude Code v2.1.297`), Codex's session header
(`>_ OpenAI Codex (v0.161.0)`) or its update notice, whose first version
is the running one (`Update available! 0.159.0 -> …`). Most frames
show no version (the banner and header scroll away), and there a matching
layout is evidence about the layout only, not proof of the client
version: another version that draws the same rows reads the same. There is
no version discovery and no extra read for it.
Which Claude seats OpenRig launches can verify: an idle Claude Code
seat in any posture OpenRig launches, after any number of turns, whose
status slot holds its last turn's completion row as above, or a fresh
seat whose slot is blank under the welcome banner. It reads `unknown`
when that row is anything else: a blank row under transcript text, a
completion row with more after the time (a still-running note, hidden
messages, a budget), the weekday or date of a turn that finished on an
earlier day, another locale's or a custom time format, an interrupted
turn, or a row printed after the turn. And only a text Claude Code shows
inline in its box can verify (see the box reading below): a multi-line
text, which includes every `rig send` envelope and every queue wake,
collapses to a paste label and stays unconfirmed.
- **The box held exactly this message before Enter.** One extra capture
between the paste and Enter must show exactly the sent text in the input
box with its word boundaries kept (`readInputBox`, `boxHoldsText`): runs
of whitespace, including Unicode spaces, compare as one space, so a box
holding `nowhere` is not the text `now here`. A row break counts as a
space only where it must be whitespace of the box's text. Claude Code
2.1.296 wraps its input at spaces only (wrap-ansi). Codex 0.160.0 wraps
it with textwrap (FirstFit, UAX #14 break opportunities, the hyphen
splitter), which also breaks with no space drawn after a hyphen or a
slash and between ideographs. So a break counts where the next row's
first word would have fit on the row before it (both wrappers fill a row
first, so the text has a line break there), or where it falls between
two ASCII letters or digits, or before a hyphen, a slash, a closing
bracket or `.,:;!?`, and the two words it separates, joined, fit in the
box's widest row, but only after a row whose text starts at its first
cell with a printable ASCII character and whose last word is ASCII
letters, digits, `'` and `.,:;` only. Codex keeps a word that fits in a
row whole only where the word starts on a row that already holds text:
a row holding only indentation absorbs the next word and splits it
where the row fills, and a word after a hyphen or a slash can be split
the same way in a row that took the next textwrap line. So on Codex a
split between two letters is not always the text's space; after an
indented row, or after a last word with a break opportunity inside it,
only the first rule reads the break. The first rule has one known
residual on Codex, found in a model of the rust-v0.160.0 source, not in
a live capture: once an indented row has absorbed and split a word, the
re-pass can end a later row early at a hyphen, so a break with no
whitespace can pass the first rule. Every such box in that model also
holds the earlier split after the indented row, which reads `ambiguous`,
so the box never reads `exact`. The widest row is counted in
printable ASCII cells only, so combining marks, joiners and zero-width
characters never widen it. Any other break, and a break inside a word of
the text, reads `ambiguous` and the send stays unconfirmed. A collapsed paste label, Claude's
queued-messages label or `Try "…"` suggestion, or another draft is never
the message. The default `--verify` matcher above is unchanged. Claude
Code's box ends at the last rule on the screen; the rows under it up to
the footer are a person's statusLine, read by position with the same
rule as the seat check above (#2798), so a box with statusLine rows
under it reads as one without them, and a blank, prompt-, menu- or
dialog-shaped row there leaves it `unrecognized`. The startup Enter
guard reads the same region.
- **The box is empty after Enter on the same pane process.** The capture
after Enter must show the box empty, with the pane process
(`#{pane_pid}`) and the seat's binding unchanged.
These readings stay internal to the send; the result carries only the
verdict (`verified`, `outcome`). An empty box reads `queued` instead of
`empty` where the frames say the message may wait behind a turn: Codex's
pending-input rows or a row under its running-turn status, or a running
turn in the frame read before Enter (Codex's `• Working` row; for Claude
Code, `esc to interrupt` or a live status row above the box, including
above a box of several rows). The verdict uses these readings: verified
needs `empty`, so for a seat that was idle before the paste they are the
only check that rejects a message queued behind a turn that started after
the pre-paste capture. An audited prompt answer (step 4) reads no box: with
the setting on it stays unconfirmed, whatever the default matcher would
say.
The readings decide the verification verdict, not whether Enter is sent:
the seat is read from the capture `--verify` already takes, and none of
the extra reads decides whether Enter is sent. Each has
a 500 ms deadline, which is a budget, not a guarantee: the pre-Enter read
comes after the existing 200 ms wait and can add about 500 ms before
Enter, and the post-Enter process read can add about another 500 ms
before the reply. A read that misses its deadline reads `unavailable`, the
send stays unconfirmed, and Enter is still sent once. With a deliberately
slowed screen read, review measured about 718 to 721 ms from paste to
Enter, against about 216 to 219 ms on main under the same slowdown.
Limits: the seat is read once, before the paste, and Enter
follows about 200 ms later. A turn can start in that window, from a person
typing in the pane or from the agent itself (Claude Code and Codex start
turns on their own when a background task finishes, a scheduled wake-up
fires or a teammate's message arrives). Such a turn is caught only when
the frame read before Enter shows it; a Codex turn already streaming
there hides its `• Working` row and is not seen. An empty box after an
exact one means the box was cleared, normally by our Enter; a clear by
something else at that moment (a person's C-c) looks the same, and so
does a text the TUI runs as a command (a Claude Code `/command`, a Codex
`!command`), which clears the box without the model reading a message.
`#{pane_pid}` is the pane's root process (the shell for most launches), so
an agent restarted under the same shell is not caught by the pane process
check, and the same pane and binding are not proof of the same native
conversation.
An empty box means the TUI took the text, not that the model read it.
Consumers see only the report. With the setting on, a queue wake that
reads unconfirmed records the existing `delivered-ack-pending` state, as
an unconfirmed wake does today. Every queue wake to Claude Code is
unconfirmed under the setting, because its multi-line envelope collapses
to a paste label, and so is a wake to a busy or not-proven-idle Claude
Code or Codex seat. A pending, unclaimed handoff with no pickup evidence
can then enter the wake ladder's existing escalation path after
`queue.wake_unconfirmed_window_minutes` (default 30). It is not blindly
re-sent: the ladder's unconfirmed path skips its retry rung
(`queue-wake-ladder.ts`), and nothing in the send path retries or
re-sends.
6. CLI `--verify` goes further (`packages/cli/src/commands/send.ts:596`): it
looks for the text still unsubmitted at the prompt, makes one guarded
Enter-only retry through the daemon's `submitOnly` path
(`session-transport.ts:1344`, which refuses with `staged_mismatch` unless the
pane shows the expected staged text), and ends `staged-not-consumed` with
exit code 1 if it is still there.
7. Honest result with reason on failure. Reasons missing from the send
route's status map (among them the runtime-identity refusals) are returned
with HTTP 500 (`routes/transport.ts:118`–`140`).
The sender comes from the `X-OpenRig-Session` header; an unsigned send gets an
unknown-sender notice. The CLI adds a From/To envelope, and the daemon appends
a delivered-latency note for delays of 10 s or more.
Architecture rule 18 (`architecture-rules-and-event-system.md`): tmux is
transport, not truth — `send/capture/broadcast` wrap tmux reliably with honest
errors.
The daemon runs tmux without a shell: argv straight to the tmux binary found on
`PATH`. `OPENRIG_TMUX_SHELL=1` at daemon start runs every tmux call through
`/bin/sh` instead (Linux and macOS; Windows always uses argv). A binary that
can't be started at all (a script without `#!`, a missing interpreter) runs
through `/bin/sh -c 'exec "$0" "$@"' tmux …`, which finds tmux as the shell
path does; nothing ran before, so no command runs twice. The resolved path is
cached, so a different tmux installed earlier on `PATH` takes effect after a
daemon restart.
### 3b. Cross-host coordination verbs
`rig send/capture/transcript/broadcast --host <id>` (and the `agent@rig@host`
target sugar on the session-target verbs) cross the host boundary through the
remote daemon's ordinary local routes; the cross-host logic is in the CLI.
- **Sidedness and caller pick the seam.** One-sided remote operations from a
caller that can resolve the registry bearer locally (the CLI) go
**CLI-direct** through `runRemoteHttpOp`
(`packages/cli/src/remote-host-ops.ts:25`) — one hop, no local-daemon
involvement, as `rig ps --all-hosts` does. Two-sided operations (a
cross-host queue handoff closes the local source and creates the remote
successor, so the local daemon owns half the transaction) and callers
without the bearer (the browser: Mission Control actions and the `?host=`
read-through) go through the local daemon, which forwards to the remote.
These four verbs are one-sided remote operations from the CLI.
- **The host entry dictates the transport (ssh or http), never a per-call
choice.** ssh hosts shell out over ssh for send/capture
(`packages/cli/src/cross-host-executor.ts`); http hosts (the kind
`rig host pair` registers) go CLI-direct to
`POST /api/transport/send|capture|broadcast` / `GET /api/transcripts/*` with
the same bodies and paths the local CLI uses. transcript and broadcast are
http-only (no ssh path exists for them); on an ssh host they fail with a
structured error naming the transport requirement
(`remote-host-ops.ts:47`), never a fallback.
- **Terminal-bearer posture (`/api/transport/*` only).** The remote's
transport routes (send/capture/broadcast) gate on its terminal bearer
(`routes/transport.ts:26`; `OPENRIG_TERMINAL_BEARER_TOKEN`,
`packages/daemon/src/index.ts:291`). With no token configured the
middleware passes every request through
(`packages/daemon/src/middleware/auth-bearer-token.ts:99`) — the tailnet is
the auth boundary by design — except that a daemon bound to an explicit
address that is neither local nor tailnet falls back to
`OPENRIG_AUTH_BEARER_TOKEN`
(`index.ts:297`). The CLI presents the registry bearer from `hosts.yaml`
when one is configured; for a URL-only anonymous host the `Authorization`
header is omitted. A remote enforcing a different terminal bearer answers
401, which surfaces as the structured `permission-gate` step
(`packages/cli/src/host-registry.ts:53`) — never a hang, never silent.
Remedy: set the remote terminal bearer equal to the paired registry bearer,
or rely on the tailnet boundary. **The transcript read is deliberately
outside this class:** `/api/transcripts/*` mounts with no bearer middleware
(`packages/daemon/src/server.ts:748`, beside the gated `/api/transport` at
`:749`), and credential-shaped text is redacted on the `/full` route only
(`routes/transcripts.ts:292`). So a wrong terminal bearer that
permission-gates `send --host` does NOT gate `transcript --host` — the read
keeps succeeding for a Host name the remote's browser boundary accepts
(`server.ts:647`; it applies to every `/api/*` route), and the auth-failure
class is not uniform across the four verbs. The route's own comment (`routes/transcripts.ts:250`) leaves a
coherent transcript-read auth policy across tail/grep/full for later.
- **The 3-part form is CLI-edge sugar only.** `resolveCrossHostTarget`
(`packages/cli/src/cross-host-target.ts:58`) strips the suffix only when it
resolves to a registered host, or equals this host's own id, which routes
home (`:95`; only `rig send` passes the self id,
`packages/cli/src/commands/send.ts:398`, so `capture` and `transcript` treat
their own id as unknown); otherwise the string passes through unchanged with a loud
host hint. Every session string that reaches any daemon stays
`member@rig`; the host travels out-of-band. Durable cross-host
coordination stays the queue's cross-host routing
(`coordination-primitive.md`); these four verbs add no queue surface.
## 4. Transcript flow
1. `NodeLauncher` starts transcript rotation right after tmux session
creation, before the harness boots
(`NodeLauncher`, `startTranscriptRotation`).
2. Each rotation tick captures the trailing lines and atomically overwrites
(temp file + rename in `startTranscriptRotation`)
`transcripts/{rig-name}/{session-name}.log` under the OpenRig home (or
the `transcripts.path` setting). It skips the write when the captured bytes
are unchanged. Defaults: 1000 trailing lines every 2 s
(`DEFAULT_TRANSCRIPT_LINES`, `DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS`); an idle seat is captured less often,
within the 10-second freshness window. Both are tunable with
`OPENRIG_TRANSCRIPTS_LINES` and `OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS`
or the matching `transcripts.*` settings. This bounded capture replaced the
earlier `tmux pipe-pane` stream (see the header comment of `transcript-rotation.ts`), so the file
holds a trailing window, not the whole session. Sessions on one adapter
tick on one shared timer, and the captures due in a tick share
`capturePanesContent`: one chained call per chunk of 24, with a
`list-sessions` started beside it on every batch, whose result is consulted
only when the chain fails, each chunk on its own (`captureBatch` in
`transcript-rotation.ts` → `capturePanesContent` in `adapters/tmux.ts`).
A session the batch leaves out, a lone due session, or a pane or window
target reads per target with `capturePaneContent` (`adapters/tmux.ts`).
3. `TranscriptStore` owns path convention, ANSI stripping for `readTail` and
`grep`, boundary
markers, `readTail`, `readFull`, `grep`. `readFull` returns the raw bytes
(`TranscriptStore.readFull`); the `/full` route redacts them. The transcript
routes check capture health first and can start capture lazily for a seat
that has none.
4. `rig transcript <session> --tail N / --grep "pattern"` provides
agent-facing access.
5. On restore: a `--- SESSION BOUNDARY: … ---` marker is written before
re-launch (`RestoreOrchestrator`); each rotation tick keeps each
distinct boundary line once, as a header above the fresh capture
(`startTranscriptRotation`, boundary header). (Restore-side detail in
`lifecycle-snapshot-restore.md`.)
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts,
insufficiency state, and guidance.
Architecture rule 19, as the code implements it: transcripts are a bounded
`capture-pane` snapshot (not pipe-pane), ANSI-stripped on tail and grep reads; `rg`
preferred, `grep -E` fallback. Rule 22: the daemon's `rig ask` is context
engineering — it gathers evidence and does NOT call an external LLM; the
agent IS the LLM. The one exception is the explicit CLI flag `rig ask --wake`
(`packages/cli/src/commands/ask.ts:88`), which runs a headless resume of a
Claude or Codex session (`claude -p --resume` / `codex exec resume`,
`packages/cli/src/ask-wake.ts:58`–`60`) to answer one question.
### Current-generation records for `rig walk`
`rig walk` checks a delivered piece against the current conversation through
`GET /api/sessions/:sessionName/generation-record` (`routes/sessions.ts`).
Without `sinceBytes`, the route returns record identity and size; with it, the
route also reads a bounded suffix from the same open file. This terminal-class
endpoint uses the terminal bearer gate.
For Codex, the current occupant must have a verified identity for its bound
pane. The route compares tmux's current root PID with the identity's
`observedPanePid`, falling back to `observedPid` for older verdicts that only
recorded one PID. Previously it always compared the root with the native Codex
PID, refusing a verified Codex running beneath a shell or launcher. Recording
both PIDs lets that wrapper pass without accepting a changed pane root or
changing the identity schema.
For dual-PID verdicts, the selected thread must also be the thread resolved for
`observedPid`. All candidate threads still participate in the ambiguity check;
another process's resolvable thread cannot stand in for a missing or
unresolvable verified native process. This reuses the same process observation
and thread lookups, without an additional census.
The existing thread lookup still requires a Codex descendant with one
resolvable thread, a thread-table record path and a matching rollout header.
The route rechecks occupant generation, pane binding and root PID after the
thread lookup. These are bounded observations, not an atomic process snapshot.
An unresolved identity still returns 409; `rig walk` reports consumption as
unverified instead of claiming it read the record. Route regressions in
`packages/daemon/test/walk-consumption-primitives.test.ts` cover shell and
launcher wrappers, legacy verdicts, root changes, replaced or unresolved native
processes, and changes during thread resolution with synthetic process
observations and actual SQLite/JSONL
fixtures; they do not exercise a live Codex conversation.
## 5. Chat flow
1. `rig chatroom send <rig> "message"` → `POST
/api/rigs/:rigId/chat/send` → `ChatRepository.send()`
(`routes/chat.ts:43`); the route then emits `chat.message` (`:45`).
2. SSE: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages.
3. History: `GET /api/rigs/:rigId/chat/history` returns channel history in id
order, 100 messages unless `limit` says otherwise
(`chat-repository.ts:68`), filterable by `topic`, `after`, `since` and
`sender` (a `since` SQLite cannot parse is refused with 400,
`routes/chat.ts:73`); `POST /api/rigs/:rigId/chat/topic` persists topic markers.
4. UI: a `RigChatPanel` component exists
(`packages/ui/src/components/RigChatPanel.tsx`), but no UI view mounts it:
**1** file under `packages/ui/src` names it, its own
(`git grep -l 'RigChatPanel' -- packages/ui/src | wc -l`).
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (`mcp-server.ts:366`,
`:402`).
6. Source of truth: daemon-backed SQLite (`chat_messages` table), NOT tmux
scrollback.
**ChatMessage** type (`chat-repository.ts:6`): durable rig-scoped message —
`id`, `rigId`, `sender`, `kind`, `body`, `topic`, `createdAt`.
## 6. Compatibility notes
The intentional limits in this layer (numbered as in
`architecture-rules-and-event-system.md`):
- Note 4 — the daemon's `rig ask` gathers context only; it does not call an
external LLM. The agent reasons about the gathered evidence. Only the
explicit `--wake` flag executes a runtime (§4).
- Note 5 — transcript search prefers `rg` but falls back to `grep -E`;
search quality/performance varies by backend.
- Note 6 — chat is rig-scoped only: no cross-rig channels or DMs.
- Note 7 — `--verify` on `rig send` reports what the pane shows, not agent
acknowledgement. By default it compares the message (whole for a
header-carrying send, the first 40 characters for a raw one) in captures
before and after the send, leaving out the current input box; with
`transport.verify_input_box` on, Claude Code and Codex sends are verified
only for a seat proven idle before the paste and on input-box evidence; a
busy or unknown seat reads unconfirmed (§3 step 5). When it cannot confirm,
it reports `rendered-unconfirmed`, not a failure.
(The full compatibility-notes list lives in
`architecture-rules-and-event-system.md`.)
## See also
- `daemon-core.md` — the route-mount surface these routes sit in, and the MCP
tool count.
- `agent-spec-and-startup.md` — the tmux `@rigged_*` metadata-key axis detail
(whoami/adopt).
- `lifecycle-snapshot-restore.md` — restore-side transcript boundary
markers.
- `coordination-primitive.md` — cross-host queue routing (the two-sided
daemon-forward twin of §3b's one-sided CLI-direct verbs).
- `../cli-reference.md` — each verb's registered `--host <id>` option; run
`rig <verb> --help` for its cross-host behaviour.
- Source roots: `packages/daemon/src/domain/{session-transport,transcript-store,transcript-rotation,history-query,ask-service,chat-repository}.ts`,
`packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`,
`packages/cli/src/mcp-server.ts`,
`packages/cli/src/{remote-host-ops,cross-host-target,cross-host-executor,ask-wake}.ts`.
@@ -1,326 +0,0 @@
---
kind: as-built
title: Workflow Runtime — Specs, Packets, and Lifecycle Graphs
status: active
topics: [orchestration, coordination]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Following workflow validation, instantiation, projection, routing, failure
recovery, lifecycle graph revisions, or the corresponding CLI/API.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Workflow Runtime — Specs, Packets, and Lifecycle Graphs
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
A workflow binds a specification to durable queue packets. The daemon
records the instance, its current frontier, and the step trail; agents supply
step outcomes and evidence. Serial workflows and dependency graphs share
this runtime. The engine checks declared fields and recorded state; it does
not judge the substance of a referenced proof document.
## Source map
All paths in this table are under `packages/daemon/src/`.
| File / symbol | Responsibility |
|---|---|
| [`domain/workflow-types.ts`](../../../packages/daemon/src/domain/workflow-types.ts) | Spec, instance, trail, frontier, and failure-occurrence contracts. |
| [`WorkflowSpecCache`](../../../packages/daemon/src/domain/workflow-spec-cache.ts) | YAML parsing, source-hash cache, stored specs, diagnostics and retention. |
| [`WorkflowValidator`](../../../packages/daemon/src/domain/workflow-validator.ts) | Roles, exits, routing/dependency edges, pins, cycles, and advisories. |
| [`WorkflowRuntime`](../../../packages/daemon/src/domain/workflow-runtime.ts) | Instantiation, inspection, routing, resume, abort, and lifecycle facade. |
| [`WorkflowProjector`](../../../packages/daemon/src/domain/workflow-projector.ts) | Close the current step and update its successor/frontier state. |
| [`WorkflowInstanceStore`](../../../packages/daemon/src/domain/workflow-instance-store.ts) | Instances, guarded updates, frontier bindings, and failure occurrences. |
| [`WorkflowStepTrailLog`](../../../packages/daemon/src/domain/workflow-step-trail-log.ts) | Append/read step history. |
| [`workflow-reconciliation.ts`](../../../packages/daemon/src/domain/workflow-reconciliation.ts) | Inspect graph changes, apply explicit revisions, recover operation receipts. |
| [`workflow-keepalive.ts`](../../../packages/daemon/src/domain/policies/workflow-keepalive.ts) | Watchdog evaluation and reminders. |
## Specification and cache
`parseWorkflowSpec` parses the `workflow` document and checks known key sets.
`WorkflowValidator.validate` then checks relationships between roles, steps,
exits, and targets. The exits are `handoff`, `waiting`, `done`, and `failed`.
An exit-specific `next_hop.on` mapping takes precedence over structural
routing; without a mapping, `resolveNextStep` considers `forbid`, suggested
roles, `require`, and then the next array element.
The language includes `harness`, `host`, singular `gate`, `depends_on`, typed
`acceptance`, and waiting re-presentation settings. At this pin,
`WORKFLOW_AGENT_HARNESSES` contains only `claude-code` and `codex`; support for
another runtime elsewhere in OpenRig does not add it to this pin's workflow
harness grammar. Nonlocal host pins can validate as registered names but
`instantiate` still rejects execution with `host_pin_remote_unsupported`.
This is a workflow-step restriction, not a claim that all queue traffic is
local-only.
A step whose `allowed_exits` can neither finish it nor route it on is refused as
`step_cannot_finish` (`workflow-validator.ts:223-236`): its exits leave out `done`, none is
mapped in `next_hop.on`, and it allows no `handoff` that routes on. In a dependency graph,
allowing `handoff` is enough, because handoff completes a sink. `instantiate` refuses any spec
with a validation error.
`allowed_exits` and `max_hops` affect validation/projection. In contrast,
`continuation_required`, `preserve_lineage`, `closure_required`, closure
messages, `skill_refs`, and `spawn_budget` produce
`declared_not_enforced_v1` advisories. Legacy `gates: []` and
`next_hop.mode: prefer` are rejected by the parser rather than silently
accepted. Prerequisite cycles are rejected; a routing cycle requires a
`max_hops` declaration and is distinct from a prerequisite cycle.
`re_present_after_seconds` compiles to the queue's park timer on an unrouted
`waiting` exit, and `re_present_max_seconds` turns it into a repeating
reminder with backoff (`workflow-projector.ts:534`–`549`); a step that cannot
reach `waiting`, or that maps `waiting` to a branch, fails validation
(`waiting_re_presentation_unreachable`).
`WorkflowSpecCache.readThrough` parses the source file, hashes it, and
updates the `(name, version)` cache row. Full `spec_json` preserves fields
that the older scalar columns cannot represent. A same-name/same-version
source edit can update that cache row; the version label alone is not an
immutable content pin. `getByNameVersion` is the runtime lookup.
Diagnostic-only rows, moved sources, and versions retained for unfinished
work have separate paths in `readThrough`, `writeDiagnostic`, and
`isPinnedByUnfinishedWork`.
Only instantiate (and `rig workflow run`, which posts to the same route) and
the workflows-folder scan register specs through `readThrough`.
`WorkflowRuntime.validate` (`rig workflow validate`, `POST /validate`) reads
the file with `WorkflowSpecCache.readFromDisk`: the same file and parse
checks, in memory, with no `workflow_specs` write. A spec that was only
validated from outside the workflows folder is not listed by
`rig workflow specs` and is not found by its bare name; its path still
instantiates (#2726).
## Durable state
The migration files record the storage evolution; they are not a count of
all database migrations:
| Migration | Workflow state introduced |
|---|---|
| [`033`](../../../packages/daemon/src/db/migrations/033_workflow_specs.ts), [`034`](../../../packages/daemon/src/db/migrations/034_workflow_instances.ts), [`035`](../../../packages/daemon/src/db/migrations/035_workflow_step_trails.ts) | Spec cache, instances/frontier/current step, and append-only trail API. |
| [`040`](../../../packages/daemon/src/db/migrations/040_workflow_specs_diagnostic.ts), [`050`](../../../packages/daemon/src/db/migrations/050_workflow_spec_json.ts) | Cache diagnostics and complete parsed spec JSON. |
| [`049`](../../../packages/daemon/src/db/migrations/049_workflow_instance_version.ts), [`051`](../../../packages/daemon/src/db/migrations/051_workflow_resume.ts), [`052`](../../../packages/daemon/src/db/migrations/052_workflow_instance_bound_rig.ts) | Optimistic version guard, resume/hop baseline, and bound rig. |
| [`079`](../../../packages/daemon/src/db/migrations/079_workflow_lifecycle_parallel.ts) | Lifecycle operation key/digest/binding, packet-to-step frontier bindings, and failure occurrences. |
Current instance states include `active`, `waiting`, `completed`, `failed`,
and `aborted`. A multi-packet frontier needs packet-to-step bindings; a
single `current_step_id` cannot describe all parallel work.
## Instantiation and owner selection
`WorkflowRuntime.instantiate` resolves a cached spec name or explicit path,
validates it, resolves the rig and the entry owner, then creates the instance
and its single entry packet (for `steps[0]`) through the transactional
event/queue path (`workflow-runtime.ts:433`, `:757`). The entry packet gets a
best-effort post-commit nudge with no staged wake intent (`:843`–`845`); its
keepalive, armed in the same transaction, and the boot sweep cover a lost
entry nudge. An explicit
unknown `targetRig` fails; an unknown spec-default rig produces an advisory
and falls back to unbound operation. A bound rig without a required declared
role is a different error.
`resolveDefaultOwner` uses declared `preferred_targets` first, applying a
harness match when pinned. With no preferred targets and a bound rig, it uses
[`selectRoleSeat`](../../../packages/daemon/src/domain/workflow-role-resolver.ts):
a running managed agent with the declared role and required runtime, ordered
by pending-only backlog and then canonical coordinate. Infrastructure and
unresolvable/adopted coordinates do not become implicit replacement agents.
Explicit owner overrides are reconciled with harness pins. Declared
`preferred_targets` are not checked for liveness; validate and instantiate
only warn (`role_no_live_preferred_target`) when none of a role's targets has
a running session. A structured gate compiles by target kind (`compileGate`):
for a human-seat target, the packet goes to the gated step's own owner and is
parked `blocked_on` that human seat in the same transaction (summary and
`evidence_ref` required), and the instance waits until the park is resolved;
for a declared role, an ordinary packet goes to that handler role's seat
(`workflow-projector.ts:1659`–`1778`).
A `preferred_targets` entry may end in `@{rig}`, for example `watcher@{rig}`.
When the instance is bound to a rig (`--rig`, or the spec's `target.rig`
default), the trailing `{rig}` becomes that rig, so one reusable spec addresses
that rig's member, including a `builtin:terminal` member. Only a trailing
`@{rig}` is the form: braces in the member part or a partial rig name
(`ops-{rig}@rig-a`, `ops-watcher@prefix-{rig}`) stay literal. Literal
`member@rig` entries are unchanged. With no bound rig the entry stays literal
and is handled like any other destination, at the step that selects it: a
selected `member@{rig}` is refused as an unknown destination rig
(`unknown_destination_rig`), and `rig workflow validate` reports such an
exception target as `unregistered`. A `{rig}` in a role that is never selected,
or in a preferred target that is not selected, does not stop an instantiate.
The cached spec keeps the literal `{rig}`; the expansion is computed from the
instance's stored `bound_rig` each time its spec is read, so later hand-offs,
resume and exception routing use the same target
(`workflow-rig-placeholder.ts`, `WorkflowSpecCache.getForInstance`). A
terminal member cannot act on a nudge, so it must poll its own queue for the
packet.
Instantiation also emits advisories for missing members in otherwise
registered target rigs. That member check is advisory and does not imply
all preferred targets received a live-readiness test.
## Projection, waiting, and concurrency
`WorkflowProjector.project` checks active/waiting status, frontier membership,
packet-to-step identity, allowed exits, and applicable evidence requirements.
For a successful advance, queue closure, successor creation, trail entries,
frontier/version updates, durable events, and wake intent staging share the
transactional notification envelope. Terminal delivery happens after the
commit, and the runtime awaits it, so project, route, resume and exception
responses return after the delivery attempt; a terminal close without its
successor's staged intent rolls back. A committed packet or staged wake is
not evidence that its recipient consumed the message.
Each exit closes the packet through the queue's own closure path
(`updateWithinTransaction`, hot-potato rules intact): `handoff` records
`handed-off` with `handed_off_to` the next owner; `waiting` records `blocked`
with `blocked_on` the supplied blocker, or `external-gate` when none is given;
`done` records `done` with `no-follow-on`; and `failed` records `done` with
`denied` and the result note as target (`workflow-projector.ts:1571`–`1631`).
A dependency-graph handoff that fans out to more than one successor, or to
none, closes the packet `done` with `no-follow-on`.
Both executors absorb an identical waiting replay by matching the stored
closure intent; changed wait data is a new decision. A consumed terminal
packet is no longer on the frontier and is refused. On the serial path a
routed close moves the frontier to the new packet and leaves the instance
`active`, or `waiting` when the target step is gated; an unrouted `waiting`
keeps the closed packet on the frontier so the keepalive can find its owner;
`failed` sets `failed`, and `done` with nothing left sets `completed`. A
dependency graph is `waiting` while every frontier packet is blocked, and
`failed` when the frontier empties with an unresolved occurrence.
`max_hops` constrains routing relative to the current drive's baseline: a
route that would exceed it becomes a `failed` exit with a `max_hops_exceeded`
note and evidence, the packet closes and no successor is created
(`workflow-projector.ts:419`–`443`); resume establishes a new bounded drive
rather than erasing history.
Presence of `depends_on` selects `projectDependencyGraph`. It uses durable
packet bindings to advance eligible successors and record per-occurrence
failures without treating an unrelated live branch as completed. A step's
completion counts only while it is later, in trail append order, than its
prerequisites' latest completions, so a prerequisite re-run through a routed
exit sends its dependents round again (`workflow-projector.ts:1004`–`1019`).
Inspection reports unknown bindings rather than inventing a step from trail order.
The injected [`createWorkflowFrontierPredicate`](../../../packages/daemon/src/domain/workflow-frontier-guard.ts)
lets the queue refuse ordinary terminal closure of live workflow packets.
Workflow mutations use the explicit workflow path; the queue does not need
to import the workflow domain to evaluate that predicate.
## Recovery operations
`WorkflowRuntime` distinguishes these operations:
| Operation | Meaning |
|---|---|
| `route` | Replace a current packet's owner while retaining the step. This is not step advancement. Multiple frontier packets require an explicit packet selection. |
| `resume` | Redrive failed work to a newly resolved owner, recording the optional `--decision` text. A serial instance must be `failed` (`instance_not_failed`). Resume re-resolves the owner rather than copying it, re-bases `hops_baseline`, increments `resume_count`, and closes that occurrence's open exception items. Dependency failures use occurrence identity; multiple unresolved failures require selection. Identical occurrence/decision replay returns the recorded redrive, while changed decision bytes conflict. |
| `abort` | Explicitly stop unfinished work with an actor and reason: cancels every frontier packet, records a `failed` trail row per packet, sets `aborted`, and emits `workflow.failed` with reason `aborted: <reason>`. Refused only for `completed` or `aborted` instances. |
| `continue` | Read-only inspection of instance, trail, guidance, reconciliation, frontier, failures, unknowns, and boundary obligations. It does not mechanically advance a step. |
## Deadlines and exceptions
[`evaluateStepDeadline`](../../../packages/daemon/src/domain/workflow-deadline.ts)
derives overdue state from frontier queue rows. Claimed work uses its closure
deadline, otherwise its claim time plus **4 hours** (creation time if the
claim time is absent). Pending work uses creation time plus **4 hours**.
Blocked waits are not overdue under this evaluator. This is a diagnostic,
not a substitute for packet existence or binding checks.
Instantiate and every routed projection arm a `workflow-keepalive` watchdog
job in the same transaction (one per live packet for dependency graphs, one
per instance for serial specs), evaluated every 15 minutes and quiet until the
step is overdue (`workflow-keepalive-arming.ts:38`). At each daemon start the
boot sweep re-arms keepalives for live instances, re-nudges pending frontier
packets that were never nudged, and surfaces overdue instances
(`workflow-boot-sweep.ts`). Neither path advances a step.
There are three exception classes: `unmapped_failed`, `stuck_overdue` and
`human_gate_trip` (`workflow-exception.ts:34`–`38`). An unrouted `failed`
exit creates one urgent `workflow-exception` queue item in the same
transaction, with evidence `rig workflow trace <instance>` and a body naming
the `rig workflow resume` command; a human gate's own parked packet carries the
`human_gate_trip` identity, so no second item is created.
[`resolveExceptionRoute`](../../../packages/daemon/src/domain/workflow-exception-router.ts)
selects per-class configuration, then workflow default, host default, and
finally orchestrator-first behavior. An unresolved orchestrator route falls
back to registered-human selection. A human gate is intrinsically human
routed. Agent-routed exceptions use the ordinary workflow tier; human-routed
ones use `human-gate`.
[`workflow-human-destination.ts`](../../../packages/daemon/src/domain/workflow-human-destination.ts)
handles human selection: the registered human named by
`workspace.operator_seat_name`, or the only registered human when it is unset;
any other case fails with `workflow_human_destination_unavailable` (409). The
host default dial is the setting `workflow.exception_routing`
(`orchestrator` or `human_only`), read per exception. A valid agent route need
not have a human fallback configured. If fallback is needed, missing/ambiguous selection is an explicit
error, not an invented destination. Failure admission is transactional;
overdue detection is handled by boot/keepalive paths. Runtime reconciliation
closes recovered overdue occurrences against their own packets, preserving
unresolved siblings and unknown provenance.
## Project lifecycle graphs
`compileLifecycle` delegates to
[`compileProjectLifecycle`](../../../packages/daemon/src/domain/project-lifecycle-compiler.ts).
`instantiateLifecycle` requires an operation key and compiled input digest.
An exact replay retrieves the recorded instance/entry packet; changed input
under the same key conflicts. Compilation and an eligible result do not by
themselves launch work.
`inspectGraph`, `reviseGraph`, and `recoverGraphOperation` in
[`workflow-reconciliation.ts`](../../../packages/daemon/src/domain/workflow-reconciliation.ts)
separate inspection from explicit graph revision and operation recovery.
Inspection reports `current`, `source-only`, `compatible`, `incompatible`,
`unavailable` or `unbound`. Only a live, manifest-bound instance whose specs
are explicit dependency graphs without `next_hop.on` can be revised; a
revision may add steps and change unstarted ones, but may not remove steps,
touch a step with completed, live or failed work, drop a required obligation
or its order, change workflow-wide fields other than `exception_routing`, or
add an unstarted step with no outstanding prerequisite. A changed
`exception_routing` applies to future occurrences only; existing obligations
keep their owners. Applying needs the inspected version and digest, an
operation key, an actor and a reason; the receipt is kept in the instance's
`revisionHistory`, and replaying the same key with a different decision is
refused with `lifecycle_revision_conflict` (`workflow-reconciliation.ts:12`,
`:113`–`223`).
`guidance` reads context and current packet information without interpreting
an agent's substantive judgment.
For lifecycle steps that require a receipt, a `done`/`handoff` projection
requires an `evidence_ref`. Typed acceptance gates additionally compare the
submitted candidate, allowed verdict, and evidence reference against the
step declaration. These are mechanical identity/presence checks; the
projector does not read the referenced prose to decide whether the work is
good or ready to publish.
## CLI and HTTP surface
The [CLI inventory](../cli-reference.md#workflow) lists every registered
`rig workflow` subcommand and option. The implementation is
[`packages/cli/src/commands/workflow.ts`](../../../packages/cli/src/commands/workflow.ts).
The [`workflow` routes](../../../packages/daemon/src/routes/workflow.ts)
are mounted under `/api/workflow`:
| Method | Suffixes |
|---|---|
| `POST` | `/validate`, `/compile`, `/instantiate`, `/instantiate-lifecycle`, `/project` |
| `GET` | `/list`, `/specs`, `/operations/:key`, `/:instance_id`, `/:instance_id/trace`, `/:instance_id/guidance`, `/:instance_id/revision` |
| `POST` | `/:instance_id/route`, `/:instance_id/resume`, `/:instance_id/abort`, `/:instance_id/continue`, `/:instance_id/revision` |
| `GET` | `/sse`, `/watch` (same live event stream handler) |
The stream carries the 8 `workflow.*` events (`instantiated`, `step_closed`,
`next_qitem_projected`, `completed`, `failed`, `resumed`,
`routing_table_changed`, `revised`); it is not a general queue stream or a
stored-history replay. Route error mapping distinguishes missing
records, authoring errors, state/version conflicts, and internal failures.
See the source mapping before assigning a meaning to a nonzero CLI result.
For authoring examples and project-level boundaries, use
[product journey SDLC](../../reference/product-journey-sdlc.md) and
[release boundaries](../../reference/release-boundary.md).
@@ -1,606 +0,0 @@
---
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.
siblings: [content-surfaces.md, daemon-core.md, ../ui/project-and-for-you.md]
prerequisite-reads: [../README.md, daemon-core.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
The **workspace primitive** is the typed declaration that lets a rig name
*where its work lives* — a workspace root, a set of named repos with kinds, an
optional default repo, and an optional knowledge root — and have that surface
through `whoami` / node-inventory and gate per-item repo scope. Alongside it, a
**file-backed missions/slices tree** is indexed read-only and projected into
the Project UI's mission / slice surfaces.
> Paths are relative to `packages/daemon/src/` unless prefixed `packages/` or
> `docs/`. Verified against source at main `e8f0ab340db773392ec8be75b072d1c0f3068a50`. Each count sits beside
> the command that produces it; run the commands from the repository root.
> The files / markdown / progress / steering surfaces are in the sibling
> `content-surfaces.md`.
## 0. Release attribution
Checked with `git cat-file -e <tag>:packages/daemon/src/<path>`. The table
records when each file first appeared; most have changed since.
| Subsystem | First release | Check |
|---|---|---|
| `domain/workspace/{workspace-resolver,frontmatter-validator,default-workspace-scaffold}.ts` | **≤0.3.0** | present at `v0.3.0` |
| migrations `038_workspace_primitive.ts`, `039_queue_target_repo.ts` | **≤0.3.0** | present at `v0.3.0` |
| `domain/slices/{slice-indexer,slice-detail-projector}.ts` | **≤0.3.0** | present at `v0.3.0` |
| `routes/{workspace,slices,projects}.ts` | **≤0.3.0** | present at `v0.3.0` |
| `domain/workspace/getting-started-narrative.ts` | **0.3.1** | absent at `v0.3.0`; present at `v0.3.1` |
| `routes/missions.ts` | **0.3.1** | absent at `v0.3.0`; present at `v0.3.1` |
| `domain/workspace/workspace-doctor.ts` | **0.3.2** | absent at `v0.3.1`; present at `v0.3.2` |
The workspace primitive itself (declaration, resolver, validator, migrations
038/039) is a **0.3.0** feature — do not back-attribute it to 0.3.1. The
`missions` route and the getting-started narrative are **0.3.1** additions;
the workspace doctor is **0.3.2**.
`getting-started-narrative.ts` is still in the tree, but nothing imports it:
**0** files reference it (`git grep -l 'workspace/getting-started-narrative' -- packages | wc -l`).
Workspace initialization no longer seeds a getting-started mission (§3).
## 1. The typed workspace declaration — 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:1163–1170` (`WorkspaceSpec`), `:1153–1159`
> (`WorkspaceRepoSpec`), `:1188` (`RigSpec.workspace?`).
| Field | Shape | Notes |
|---|---|---|
| `workspaceRoot` | string | Verbatim from spec |
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time (`domain/rigspec-schema.ts:361–363`); authors may declare it relative to `workspaceRoot` in YAML |
| `defaultRepo?` | string | active repo when no env override / cwd match |
| `knowledgeRoot?` | string | treated as `kind=knowledge` when surfaced |
`WorkspaceKind` is a closed union of **5** members
(`grep '^export const WORKSPACE_KINDS' packages/daemon/src/domain/types.ts | grep -o '"[a-z]*"' | wc -l`):
`user`, `project`, `knowledge`, `lab`, `delivery`.
> Source: `domain/types.ts:1149–1150` (`WORKSPACE_KINDS` / `WorkspaceKind`).
In YAML the block is spelled `workspace: { workspace_root, repos: [{ name,
path, kind }], default_repo?, knowledge_root? }`. Validation requires a
non-empty `workspace_root`, a `repos` array whose names are unique and whose
`kind` is one of the five kinds, a `default_repo` that names a declared repo,
and a non-empty `knowledge_root` when present.
> Source: `domain/rigspec-schema.ts:288–347`.
### 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` at `:19`); doc comment
> `:3–15`.
`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:184–189` (setter; column probe `:185`),
> `:192–202` (getter; probe `:193`; parse failure → null `:199–201`);
> `hasRigColumn` `:347`.
The block is written only when declared. **3** call sites write it
(`git grep -n 'setRigWorkspace(' -- packages/daemon/src | grep -v 'rig-repository.ts' | wc -l`):
the instantiator's two rig-persist paths, `materializeValidatedSpec` and
`instantiateOnce`, each guarded by `if (rigSpec.workspace)`; and the
workspace-only import route `POST /api/rigs/import/workspace`
(`rig import --workspace-only --target-rig <rigId>`), which validates and
normalizes the block and writes it only when it differs from the stored one.
> Source: `domain/rigspec-instantiator.ts:619` (`materializeValidatedSpec`),
> `:655–657`; `:1260` (`instantiateOnce`), `:1349–1351`;
> `routes/rigspec.ts:134–173` (validation `:158`; compare-and-write
> `:168–170`), mounted `server.ts:734`; CLI
> `packages/cli/src/commands/import.ts:82`.
### 1.2 Runtime resolution — workspace-resolver
`resolveWorkspaceContext({ spec, cwd, envOverride })` (consumed by
`whoami-service`) returns the `WhoamiWorkspaceBlock` or `null` when no spec.
`activeRepo` resolution: a non-blank `envOverride` wins **verbatim** — even an
unknown repo name is honored, because operators set `OPENRIG_TARGET_REPO`
consciously (the comment at `workspace-resolver.ts:35–36`); otherwise `defaultRepo` is used **only
if it names a declared repo**. `knowledgeKind` is `"knowledge"` when
`knowledgeRoot` is declared, else `null`. The resolver accepts `cwd` but does
not use it (`workspace-resolver.ts:31`), so `rig whoami` reports the env
override or the declared `defaultRepo`, never the repo containing the seat's
working directory; node-inventory's per-node `activeRepo` (below) is the one
that follows cwd.
> Source: `domain/workspace/workspace-resolver.ts:26–52` (resolver; null
> without a spec `:32`; env override `:39–40`; declared `defaultRepo`
> `:41–42`; `knowledgeKind` `:50`).
`whoami-service` reads the persisted spec and resolves with the query's
`targetRepoOverride`, falling back to the daemon's own
`process.env["OPENRIG_TARGET_REPO"]`. `rig whoami` forwards the caller's
`OPENRIG_TARGET_REPO` as `?targetRepo=`, which the route passes on as
`targetRepoOverride`.
> Source: `domain/whoami-service.ts:333–338` (`getRigWorkspace` +
> `resolveWorkspaceContext`, env fallback `:337`); `whoami` returns
> `workspace` in its payload `:350`; `routes/whoami.ts:29`;
> `packages/cli/src/commands/whoami.ts:281–282`.
`resolveNodeWorkspace({ spec, cwd })` (consumed by `node-inventory`) derives a
per-node `NodeWorkspaceInfo` by picking the **longest declared repo path that
contains** the node's `cwd`. When no repo contains it, `kind` falls back to
`knowledge` if cwd is under `knowledgeRoot`; `activeRepo` then falls back to
the rig's `defaultRepo` (taking that repo's kind if `kind` is still unset).
Containment uses a `path.relative` boundary check (not a string `startsWith`)
so `/foo/bar` does not match `/foo/bar-other`. Node-inventory reads
`workspace_json` with its own queries rather than through `RigRepository`.
> Source: `domain/workspace/workspace-resolver.ts:57–95` (longest match
> `:67–73`; knowledge fallback `:77–79`; default-repo fallback `:82–88`),
> `isInside` `:97–103`; `domain/node-inventory.ts:705`
> (`workspace: resolveNodeWorkspace(...)`), readers `:790–799`
> (`readRigWorkspaceJson`) and `:716–726` (`readAllRigWorkspaceJson`,
> batched); `NodeWorkspaceInfo` `domain/types.ts:795–799`.
### 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`. Per the migration's comment it carries the
per-item typed repo scope when an operator passes `--target-repo <name>`; it
is NULL when the item is unambiguous against the rig's `default_repo` or no
workspace is declared, and Mission Control views surface the field for
cross-rig handoff clarity. At M nothing in the daemon's Mission Control code
reads it; the UI shows it as "Target repo" in the queue item viewer's full
detail (`packages/ui/src/components/drawer-viewers/QueueItemViewer.tsx:157`).
> Source: `db/migrations/039_queue_target_repo.ts:16–22`
> (`ALTER TABLE queue_items ADD COLUMN target_repo TEXT` at `:19`;
> `CREATE INDEX … idx_queue_items_target_repo` at `039_queue_target_repo.ts:20`); doc comment
> `:3–15`; CLI flag `packages/cli/src/commands/queue.ts:461`.
`validateTargetRepo` checks an explicit `targetRepo` against the **source
rig's** `RigSpec.workspace.repos[]`: it parses the source session with
`parseSessionName`, looks the rig up by name, reads `getRigWorkspace`, and
rejects an unknown repo with 400 `unknown_target_repo` plus the `knownRepos`
list. It **fails open** (`{ ok: true }`) when there is no rigRepo, the session
is not a canonical `<member>@<rig>` name, the rig is unknown, or no workspace
is declared — validation only bites when a workspace actively declares repos.
It runs on **3** routes (`grep -c 'validateTargetRepo(c,' packages/daemon/src/routes/queue.ts`):
`POST /create` (through the shared `createInputRefusal` check, which also
requires `destinationSession` and `body`), `POST /:qitemId/handoff` and
`POST /:qitemId/handoff-and-complete`, each before any cross-host forwarding,
because the source rig's workspace lives on the source host.
`createInputRefusal` also runs in the in-process create that finishes a
handoff an earlier self-forward started (a `hostId` equal to this daemon's own
id). A handoff that inherits the source row's `targetRepo` without an override
is not re-validated.
`--target-repo` is accepted by `rig queue create`, `handoff` and
`handoff-and-complete`. `rig queue list --target-repo <name>`
(`GET /api/queue/list?targetRepo=`) filters rows by exact `target_repo`
match; the filter is skipped when the column is absent.
> Source: `routes/queue.ts:96–123` (fail-open guards `:102`, `:106`, `:109`,
> `:112`; `getRigWorkspace` `:111`; `unknown_target_repo` + `knownRepos`
> `:114–120`); `createInputRefusal` `:128–141` (call `:136`), used by
> `/create` `:490` and the self-forward create `:436`; handoff calls `:679`,
> `:758` in handlers `:650`, `:732` (`/create` handler `:450`); ordering note
> `:482–489`; inherited value `:676–677`, `domain/queue-repository.ts:1691`,
> `:1868`; list filter `routes/queue.ts:906`,
> `domain/queue-repository.ts:3236–3238`, CLI
> `packages/cli/src/commands/queue.ts:1143`; mounted `server.ts:784`.
## 2. Workspace HTTP route — frontmatter validator and doctor
`workspaceRoutes()` (mounted `server.ts:785` as `/api/workspace`) exposes **2**
endpoints (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/workspace.ts`).
**Neither mutates the filesystem.**
- `POST /api/workspace/validate` — body `{ root, workspaceKind?, recursive?,
requireFrontmatter?, maxFiles? }`; returns a `FrontmatterValidationReport`.
`root` is required (400 `root_required`); an out-of-vocabulary
`workspaceKind` is rejected 400 `invalid_workspace_kind`; validator throws →
500 `validate_failed`.
- `POST /api/workspace/doctor` — runs `runWorkspaceDoctor` against the
daemon's resolved `workspace.root`, or a `workspaceRoot` given in the body,
and returns its report. 503 `settings_unavailable` without a settings
store; 500 `doctor_failed` when it throws. The doctor runs **8** checks in
order (root reachable, missions folder, file allowlist, daemon points at
this workspace, config changed since start, optional slice docs, mission
notes, SDLC convention sections), each `ok`/`warn`/`fail` with a fix hint,
plus a summary count. With a body `workspaceRoot`, missions are checked at
`<workspaceRoot>/missions`. (The orchestrator's own doc comment still says
7.)
> Source: `routes/workspace.ts:16` (no mutation), `:37–74` (validate;
> root-required `:46–48`; kind check `:50–58`; 500 `:72`), `:85–160`
> (doctor; 503 `:87`; missions root from the body `:115–118`;
> `runWorkspaceDoctor` call `:145`; 500 `:158`);
> `domain/workspace/workspace-doctor.ts:643–685` (the 8 checks; comment
> `:636`); mounted `server.ts:785`.
`validateWorkspaceFrontmatter()` walks a root, parses each `.md` file's YAML
frontmatter (delimited `---` on the first line), and emits a structured gap
report — **advisory only, never modifies files**. There are **4** gap kinds
(`sed -n '/^export type FrontmatterGapKind/,/;$/p' packages/daemon/src/domain/workspace/frontmatter-validator.ts | grep -c '^ | '`):
`missing-required-field`, `unrecognized-status-value`, `parse-error`,
`missing-frontmatter`. Per-kind required fields (checked only when a kind is
given): `user`/`project` → `["doc"]`; `knowledge`/`lab`/`delivery` →
`["doc","status","created","owner"]`. The `status` enum has **4** values
(`grep '^const VALID_STATUS_VALUES' packages/daemon/src/domain/workspace/frontmatter-validator.ts | grep -o '"[a-z]*"' | wc -l`):
`active|draft|archived|superseded`. Default behavior skips files without
`---` silently (informal notes) unless `requireFrontmatter`; recurses by
default; skips `node_modules` / `.git` / `.worktrees` / `dist` / `build`;
hard cap `maxFiles` default 10000.
> Source: `domain/workspace/frontmatter-validator.ts:12` (advisory), `:23–27`
> (gap kinds), `:53` (status enum), `:56–62` (per-kind required), `:81–83`
> (defaults), `:90–121` (walk; skip-dirs `:105–111`), `:160–172`
> (missing-frontmatter behavior), `:201–226` (required + status check), `:239`
> (opening delimiter).
CLI surface: `rig workspace validate [root]` and `rig workspace doctor`.
> Source: `packages/cli/src/commands/workspace.ts:86` (`validate`), `:146`
> (`doctor`); `docs/as-built/cli-reference.md#workspace`.
## 3. Default project-workspace scaffold
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the repo-ready
default workspace (`~/.openrig/workspace/`, the default `workspace.root`, or
`--root`). It emits exactly **2** directories
(`grep '^const WORKSPACE_DIRS' packages/daemon/src/domain/workspace/default-workspace-scaffold.ts | grep -o '"[a-z]*"' | wc -l`):
`missions/`, `exhaust/`; and **4** files
(`sed -n '/^export function workspaceScaffoldFiles/,/^}/p' packages/daemon/src/domain/workspace/default-workspace-scaffold.ts | grep -c 'relPath: "'`):
`SPEC.md`, `project.yaml`, `workspace.yaml`, `.gitignore`. The catalog
(`workspace.yaml`) points its single `default` project at `.`; `project.yaml`
names `SPEC.md` as project intent, roots mission discovery at `missions`, and
exposes empty `install.context` / `install.skills` selectors. The ignore file
excludes `exhaust/` and local `.openrig/` projection state while keeping
authored project context versionable.
`ensureDefaultWorkspace()` applies the scaffold additively: it creates only
missing paths and never deletes or overwrites an existing file. It checks
every managed path first; if one exists with the wrong kind (a file where a
directory belongs, or the reverse) it writes nothing and returns `ok: false`
with the conflicts. Its callers are the shared instance initializer
`ensureOpenRigInstance()`, which both CLI daemon start and direct daemon start
run (so the scaffold is reconciled on every start); `rig config
init-workspace`; and `POST /api/config/init-workspace` (409
`init_workspace_conflict`). The retained `--force` spelling is a
compatibility no-op. The CLI command re-exports the daemon's owner from
`@openrig/daemon/instance-initialization` rather than keeping its own copy,
and a parity test pins the CLI and daemon layouts as byte-identical. Mission
and slice content is created explicitly through `rig scope`, not seeded by
workspace initialization. Instance context, System World, skill source,
runtime state, and the retired `artifacts/`, `evidence/`, `progress/`,
`field-notes/`, `dogfood-evidence/`, `README.md`, and `STEERING.md` entries
are not emitted.
> Source: `domain/workspace/default-workspace-scaffold.ts:7` (dirs),
> `:20–30` (`project.yaml`), `:32–36` (`workspace.yaml`), `:38–41`
> (`.gitignore`), `:43–54` (`workspaceScaffoldDirs`,
> `workspaceScaffoldFiles`), `:100–153` (`ensureDefaultWorkspace`; conflicts
> checked before writes `:117–135`; write-if-missing `:140`);
> `domain/instance-initialization.ts:39` (`ensureOpenRigInstance`; default
> workspace root `:44`; dry-run plan `:83`; apply `:103`); default
> `workspace.root` `domain/user-settings/settings-store.ts:28–31`; daemon
> `index.ts:250`; CLI `packages/cli/src/daemon-lifecycle.ts:658`;
> `packages/cli/src/commands/config-init-workspace.ts:10` (import), `:13`
> (re-export), `:28` (`--force`), `:56–67` (`runInitWorkspace`);
> `routes/config.ts:56–65`, mounted `server.ts:772`; `rig scope`
> `packages/cli/src/commands/scope.ts:2071`; parity
> `packages/daemon/test/getting-started-narrative-parity.test.ts:15–25`,
> retired entries `:43–57`.
### 3.1 The workspace catalog and project selection
`workspace.yaml` at `workspace.catalog_path` (default
`<workspace.root>/workspace.yaml`) lists `projects[]`, each with a string `id`,
a `root` relative to the catalog file, and an optional `rigs` list naming the
rigs that work in that project. With one entry, that project is selected
implicitly. With several, `rig context work-install` and the daemon's
operating posture use an explicit `--project` first, then the calling rig's
`rigs` association, then the unique deepest project root that contains the
working directory, then the only entry no rig claims; otherwise they stop with
`project_required` and the candidate ids. Duplicate ids are refused
(`project_identity_ambiguous`), and a root that does not exist gives
`project_root_missing`.
Installing a bundle that carries a project places it at
`<workspace.projects_root>/<id>/` (default `<workspace.root>/projects`) and
records it in the catalog before the rig's seats launch: a new entry is
appended as text, or the rig is added to an entry's `rigs` list. Existing
catalog bytes are never rewritten; a conflict writes nothing for that part and
reports the exact line to add.
Daemon reads accept `?project=<id>`, resolved by `selectedProject()`: the id
must be in the catalog (or be the single uncatalogued workspace project, whose
id comes from its `project.yaml` or defaults to `workspace`), the project's
`project.yaml` `missions.root` must stay inside the project, and a supplied
`?projectRoot` must still match. Every failure answers 409 with its code.
`GET /api/scopes/projects` lists the projects with their missions roots.
> Source: `domain/workspace/project-catalog.ts:16–25` (`readProjectCatalog`),
> `:26–37` (`selectCatalogProject`), `:66–79` (`inferCatalogProject`, steps
> 3–5); `domain/user-settings/settings-store.ts:489–490`
> (`workspace.projects_root`, `workspace.catalog_path`);
> `domain/workspace/project-registration.ts:6–29` (rules), `:36–37`
> (projects root), `:181` (`registerBundleProject`), called from
> `domain/bundle-content-routing.ts:184`; `domain/workspace/project-read.ts:44–60`
> (`projectEntry`), `:61–69` (`listProjects`), `:70–81` (`selectedProject`),
> `:83–84` (409); `routes/scopes.ts:34`, mounted `server.ts:820`.
## 4. File-backed missions / slices tree
### 4.1 SliceIndexer — 0.3.0
`SliceIndexer` reads slice folders from configured filesystem roots. The
**default workspace contract** is `workspace/missions/<mission>/slices/<slice>`;
flat roots (`workspace/slices/<slice>`) remain supported for compatibility.
A nested `slices/` child folder makes the parent a mission
(`missionId = <mission-folder>`); a bare folder is a flat slice
(`missionId = null`). Startup builds the indexer on `workspace.slices_root`
(default `<workspace.root>/missions`) and adds `<workspace.root>/missions` and
`<workspace.root>/slices` as extra roots. NO new SQLite migration, NO new event
type: read-only projection over existing tables (`queue_items`,
`queue_transitions`, `mission_control_actions`) + dogfood-evidence
directories. Listing, detail and mission-status caches are time-bounded (60 s
default); `invalidate()` drops all three plus the queue-membership index.
Besides the TTL, every unscoped list or detail read first compares a basis
built from each mission's `mission.yaml` text and `slices/` listing and drops
the caches when it changes. When the indexer is ready, startup also watches the
workspace and invalidates the indexer when proof sources change, emitting
`proof.sources_changed`.
`isReady()` is true when any configured slice root exists on disk. A set
`OPENRIG_SLICES_ROOT` still takes precedence over `workspace.slices_root`.
Slices are keyed by folder name alone: when two missions (or two roots) hold a
slice with the same name, only the first one walked is indexed, and
`/api/slices/:name` and `/project/slice/$sliceId` address it by that bare
name. The project-scoped read (`?project=…&mission=…`, §4.3) is the one path
that selects by mission. A slice's `qitemIds` come first from rows tagged
`slice:<id>`, then from the legacy match on the rail item or slice name; in a
project-scoped indexer only rows tagged `project:<id>` count, so untagged
historical rows are not attributed to a selected project.
> Source: `domain/slices/slice-indexer.ts:2–15` (contract + no new state),
> `:238` (TTL), `:297–299` (`isReady`), `:331–336` (`invalidate`),
> `:457–472` (`reconcileComposition`), `:476–521` (`readSliceLocations`;
> first name wins `:478–483`; nested-vs-flat `:495–517`), `:61–88`
> (`SliceRecord`: `missionId` `:65`, `slicePath` `:67`, `qitemIds` `:77`,
> `proofPacket` `:81`), `:90–111` (`SliceListEntry`, which carries
> `qitemCount` / `hasProofPacket` instead), `:865–875` and `:936–946` (queue
> membership; project filter `:870`, `:942`); `domain/workspace/project-catalog.ts:132–138`
> (`belongsToProject`); startup `startup.ts:1705`, `:1718` (`OPENRIG_SLICES_ROOT`
> first), `:1725–1730`, `:1743–1748`, proof-source watch `:1762–1765`
> (`domain/proof/source-watch.ts:16`); default
> `domain/user-settings/settings-store.ts:486`.
### 4.2 SliceDetailProjector — 0.3.0
Given a `SliceRecord`, the projector assembles the full per-slice payload
across six tabs (Story, Acceptance, Decisions, Docs, Tests/Verification,
Topology) — **6** tab keys in `SliceDetailPayload`
(`sed -n '/^export interface SliceDetailPayload/,/^}/p' packages/daemon/src/domain/slices/slice-detail-projector.ts | grep -c -E '^ (story|acceptance|decisions|docs|tests|topology):'`),
plus a `readiness` field. Read-only; composes already-shipped tables +
`workflow_specs`/`workflow_instances`/`workflow_step_trails` + slice docs on
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase taxonomy**
(`discovery`/`product-lab`/`delivery`/…): `StoryEvent.phase` is now an
open-ended string-or-null — the spec-defined `step.id` when bound to a
`workflow_instance`, else `null` (UI groups under "Untagged"). When no
workflow runtime is constructed the projector silently degrades to v0
behavior (`workflowBinding`, `specGraph`, `phaseDefinitions` and
`currentStep` all `null`).
> Source: `domain/slices/slice-detail-projector.ts:1–20` (six-tab contract +
> v1 enrichment), `:16–20` (v0 phase taxonomy removed), `:64`
> (`StoryEvent.phase`), `:177–203` (`SliceDetailPayload`), `:205–213`
> (optional `workflowSpecCache`); startup degrade path
> `startup.ts:1749–1760` (comment `:1749–1755`;
> `workflowSpecCache: workflowRuntime?.specCache` `:1759`).
### 4.3 Slices routes — 0.3.0
`slicesRoutes()` (mounted `server.ts:803` as `/api/slices`) registers **5**
handlers (`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/slices.ts`):
- `GET /` — filter `all|active|done|blocked` (default `all`; anything else is
400 `filter_invalid`); `?refresh=1` (or `true`) invalidates first; optional
`?boundToWorkflow=<name>:<version>` lens narrows to slices bound to a
workflow instance (400 `boundToWorkflow_invalid` without the colon). Each
slice row carries `readiness`, and a `missions` sidecar carries authored
mission status and readiness. Rows sort by last activity, newest first.
- `POST /refresh` — drops the indexer caches, no daemon restart.
- `GET /:name/proof-asset/*` — serves a proof asset from the slice's matched
dogfood-evidence directory; rejects `..` (400); `Cache-Control: public,
max-age=86400`; byte ranges (206 / 416).
- `GET /:name/doc/*` — markdown for the Docs tab; rejects `..`.
- `GET /:name` — full per-tab payload. With `?project=<id>` it also requires
`?mission=<id>` (400 `exact_mission_and_slice_required`) and reads through
an indexer scoped to that project's missions root.
503 `slices_indexer_unavailable` when unwired; on `GET /`, 503
`slices_root_not_configured` + setup hint when not ready. **Route-order
discipline:** literal `/` / `/refresh` / `/:name/proof-asset/*` /
`/:name/doc/*` are registered BEFORE the dynamic `/:name` so they are not
shadowed.
> Source: `routes/slices.ts:33–198` (handlers; route-order comments
> `:36–37`, `:118`, `:126–127`, `:175`; 503s `:40–46`; filters `:31`,
> `:47–53`; refresh `:54–57`; `boundToWorkflow` `:67–93`; readiness +
> `missions` sidecar `:99–110`; `/refresh` `:119–124`; proof asset
> `:131–154`, cache header `:235`, ranges `:237–266`; doc `:158–173`;
> project scope `:180–190`); mounted `server.ts:803`.
### 4.4 Missions route — 0.3.1
`missionsRoutes()` (mounted `server.ts:814` as `/api/missions`) is the
mission scope data layer, with **2** handlers
(`grep -c -E 'app\.(get|post)\(' packages/daemon/src/routes/missions.ts`).
`GET /:missionId` returns
`{ missionId, missionPath, readiness, slices, workflow_spec, topology, status }`:
- `slices` — filtered from the SliceIndexer by `missionId`, each with its
`readiness`;
- `missionPath` — two levels up from any slice's `slicePath`;
- `workflow_spec` — parsed from the frontmatter of the mission's node file
(`SPEC.md`, else legacy `README.md`) with the same `parseWorkflowSpecRef`
helper the slice-indexer exports;
- `topology.specGraph` — projected via `projectSpecGraph(spec, null)` when the
spec is cached; `{ specGraph: null }` when declared but not cached (or no
cache is wired); `null` when nothing is declared;
- `readiness` — the mission's proof readiness;
- `status` — `metadata.status` from `mission.yaml` when present, else the
node file's `status` frontmatter.
`POST /:missionId/complete` sets the mission status to `complete`:
`metadata.status` in `mission.yaml` when the manifest exists (other fields and
comments kept), otherwise the `status` line in the node file's frontmatter
(creating `SPEC.md` when the mission has no node file). It is idempotent.
After a successful write it drops the indexer's mission-status cache so the
next `/api/slices` read sees the new status. The daemon is the audit-trail surface; the UI keeps an
optimistic localStorage mirror. 404 `mission_not_found` when no slices match;
500 `mission_complete_write_failed` on a write error.
> Source: `routes/missions.ts:45–88` (GET; 503s `:46–64`; 404 `:68–70`;
> readiness + status `:77–78`; response `:79–87`), `:95–125` (complete; 500
> `:110–117`; cache drop `:123`), `:132–163` (`writeMissionStatusComplete`;
> manifest `:133–141`; node file `:145–162`), `:169–171`
> (`computeMissionPath` up two levels), `:179–186` (`readMissionStatus`),
> `:192–198` (`readMissionWorkflowSpec`), `:226–236`
> (`computeMissionTopology`); node-file precedence
> `domain/scope/node-file.ts:17`; readiness
> `domain/proof/judgments.ts:288–295`; UI mirror
> `packages/ui/src/hooks/useCompletedMissions.ts:15`; mounted `server.ts:814`;
> §0: `routes/missions.ts` absent at `v0.3.0` ⇒ **0.3.1**.
### 4.5 Projects route — stream classifier — 0.3.0
`projectsRoutes()` (mounted `server.ts:786` as `/api/projects`) backs the
`rig project` CLI verb — the **stream-item classifier** (lease lifecycle +
idempotent classify + operator-verb reclaim + attempt ledger + SSE), NOT the
Project *workspace UI* (that is the slices/missions surface above; the naming
overlap is a documented seam). Endpoints:
- `GET /worker-sources` — requires `?project=<id>`, a workspace catalog
project;
- `GET /shadow`, `POST /shadow/drain`, `POST /shadow/stop` (the last two take
an optional body `{ actor }`);
- `POST /lease/acquire` (optional `evaluateDeadnessFirst` clears stale/dead
leases first), `POST /lease/heartbeat`, `POST /reclaim-classifier`;
- `POST /project` (idempotent on `stream_item_id`);
- `POST /attempts/begin`, `POST /attempts/:attemptId/abstain`,
`POST /attempts/:attemptId/fail`, `GET /eligible`;
- `GET /lease`, `GET /list`, `GET /sse` + `GET /watch` (SSE);
- `GET /:projectId`.
**Route-order discipline:** every literal path is registered BEFORE the bare
`/:projectId` catchall, which comes last.
> Source: `routes/projects.ts:27–375` (handlers; route-order notes `:25`,
> `:240`, `:309`, `:322`, `:339–340`, `:366`); worker sources `:98–109`;
> shadow `:111–127`; lease / reclaim / project `:129–237`
> (`evaluateDeadnessFirst` `:142–144`); attempts and eligible `:239–306`;
> lease and list `:308–336`; SSE `:338–364`; `/:projectId` `:366–372`;
> error-code → HTTP mapping `:58–95`; CLI
> `packages/cli/src/commands/project.ts:46`; mounted `server.ts:786`.
## 5. UI route reality — source-verified at routes.tsx
`packages/ui/src/routes.tsx` is **605** lines (`wc -l < packages/ui/src/routes.tsx`)
and uses TanStack Router (`createRoute({ path, component })` objects), NOT JSX
`<Route>` (**0** matches: `grep -c '<Route' packages/ui/src/routes.tsx`). The
mission / project / slice destinations as they exist:
| Path | routes.tsx reality | routes.tsx:NN |
|---|---|---|
| `/project` (workspace) | **REAL route** — `WorkspaceScopePage` | `:136–140` |
| `/project/mission/$missionId` | **REAL route** — `MissionScopePage` | `:142–146` |
| `/project/slice/$sliceId` | **REAL route** — `SliceScopePage` | `:148–152` |
| `/files` | **REAL route** — `FilesWorkspace` (see content-surfaces.md) | `:248–252` |
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (the Mission Control *system* lives in the daemon at `/api/mission-control`, not as a UI destination) | `:496–500` |
| `/slices` | **`<Navigate to="/project" />`** redirect-stub | `:503–507` |
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId" />`** redirect-stub | `:509–516` |
| `/progress` | **`<Navigate to="/project" />`** redirect-stub (folds into Project tabs) | `:519–523` |
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into the Project workspace overview tab) | `:526–530` |
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | absent: **0** (`grep -c 'path: "/missions"' packages/ui/src/routes.tsx`) |
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md) | absent: **0** (`grep -c 'path: "/markdown"' packages/ui/src/routes.tsx`) |
> Source: `packages/ui/src/routes.tsx:136–152` (project routes), `:248–252`
> (`/files`), `:484–530` (redirect block, which also redirects `/context` to
> `/topology`), `:539–597` (route tree); `/api/mission-control` mounted
> `packages/daemon/src/server.ts:790–793`.
Net: the daemon `/api/missions` + `/api/slices` + `/api/projects` routes are
real. The **operator-facing UI** consumes the missions and slices routes
through the `/project*` destinations; it does not call `/api/projects`
(**0** files: `git grep -l '/api/projects' -- packages/ui/src | wc -l`).
`/mission-control`, `/slices`, `/progress`, `/steering` are redirect-stubs
(the systems exist at the daemon layer; the URLs were collapsed into
`/project` / `/for-you`).
> Source: `packages/ui/src/hooks/useMission.ts:51` (`/api/missions`),
> `packages/ui/src/hooks/useSlices.ts:84` (`/api/slices`).
## 6. Cross-cutting properties
- **Optional + valid-without:** rigs without a `workspace` block stay valid;
whoami / node-inventory return null and queue target-repo validation fails
open (`workspace-resolver.ts:32`, `:62`; `rig-repository.ts:185`, `:193`;
`routes/queue.ts:112`).
- **Defensive column access:** `RigRepository` probes for `workspace_json`
(`rig-repository.ts:185`, `:193`), node-inventory's own reads fall back to
null on error (`node-inventory.ts:724`, `:796–798`), and the queue
repository detects `target_repo` (`queue-repository.ts:758`), so partial
test fixtures don't crash. Migrations 038/039 ship separately so a fixture
can apply only the half it needs (`038_workspace_primitive.ts:12–14`).
- **Read-only projection, no new state for the tree:** SliceIndexer /
SliceDetailProjector add NO migration / event type; only the workspace
declaration (038) + per-item scope (039) touch SQLite
(`slice-indexer.ts:14–15`).
- **Advisory, never deletes:** the frontmatter validator never modifies files
(`frontmatter-validator.ts:12`), and the workspace scaffold never deletes or
overwrites operator content (`default-workspace-scaffold.ts:140`;
`packages/cli/src/commands/config-init-workspace.ts:18`).
## Open items
- `resolveWorkspaceContext` honors an `envOverride` **verbatim even when it
names no declared repo** (`workspace-resolver.ts:39–40`), whereas the queue
route **rejects** an unknown `target_repo` against the same `repos[]`
(`routes/queue.ts:114–120`). These two surfaces apply opposite policies to
an unknown repo name (whoami trusts the operator; queue validates). The
resolver's comment at `workspace-resolver.ts:35–36` states the verbatim behavior is intended, but
the comment line before it (`workspace-resolver.ts:34`) and `whoami-service.ts:331–332` describe
a repo-name check that the code does not perform. Stated as-is; the
divergence is not reconciled in source.
- The `/api/projects` *classifier* and the `/project*` *workspace UI*
(slices/missions) share the word "project" but are different subsystems.
Both accept a workspace catalog project as `?project=<id>`
(`routes/projects.ts:105`; `routes/slices.ts:181`). Documented as a naming
seam; no source defect.
-118
View File
@@ -1,118 +0,0 @@
---
kind: as-built
title: Arteries — where a small change has a large effect
status: active
applies-when: |
You are about to change, or review a change to, an area that many other paths depend on:
message delivery, launch and resume, the queue, rig identity, skill projection, native
process observation, migrations, or restore. Read this to see what depends on the area and
what broke there before, then go and read the code.
siblings: [README.md, test-layers.md]
prerequisite-reads: [../../ARCHITECTURE.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# Arteries: where a small change has a large effect
## Read this first: this map is not the territory
- **This page is a zoomed-out view, not a description of OpenRig.** It names a few areas where
small changes caused large regressions before. OpenRig is much larger than this page. Code,
behaviour and risk that are not listed here exist, and some of it is riskier than anything below.
- **It is incomplete by construction.** A pull request is not safe because its files are missing
from this list.
- **It may be stale.** It was checked against commit `e8f0ab34`. Files and dependencies move. If
this page and the code disagree, the code is right; fix the page.
- **Use it to change altitude, then go to the ground:** read the code, run the commands, check the
behaviour. When you are lost, go and look rather than leaning on this page.
## The arteries
Paths are relative to `packages/`. The key files are entry points, not the full extent of each
area.
| Artery | Key files | What depends on it | Past regressions and lessons |
|---|---|---|---|
| **Message delivery to seats** | `daemon/src/domain/session-transport.ts`, `daemon/src/domain/seat-delivery-guard.ts`, `daemon/src/adapters/tmux.ts` | Every `rig send`, queue wake, nudge, watchdog wake and handoff | #150 (the fix for #142) stopped typing into a bare shell. It then refused managed Codex seats (fixed by #171 before release) and every Claude seat launched with an explicit permission mode, which shipped in 0.6.2 as #197 (fixed by #220). The delivery guard's rule that a name must resolve to exactly one target, added in #109 (0.6.0), was involved in #141 (fixed by #151), the delivery half of #174 (fixed by #181) and #188 (fixed by #189). Since #310/#327, a Claude seat whose runtime can't be observed still receives ordinary messages with a warning; only a plain idle shell or the wrong recipient is refused. |
| **Launch, relaunch, resume** | `daemon/src/adapters/claude-code-adapter.ts`, `daemon/src/domain/claude-managed-launch.ts`, `daemon/src/adapters/codex-runtime-adapter.ts`, `daemon/src/domain/codex-daemon-support.ts`, `daemon/src/adapters/pi-runtime-adapter.ts`, `daemon/src/domain/seat-launch-environment.ts`, `daemon/src/domain/native-resume-probe.ts`, `daemon/src/domain/startup-orchestrator.ts` | Every seat start, restore and permission mode, plus the non-interruptive, `permission_policy`, effort, kernel operational-authority and team permission-default settings that each launch and resume command carries | Always setting `CLAUDE_CONFIG_DIR` gave explicit-mode Claude seats a blank config (#154). #225 fixed which config home is selected, and #565 writes the onboarding and trust bootstrap into that home. Codex seats launch with `--no-daemon` only when `codex --help` shows support (#69, #77); when support is unknown, OpenRig refuses to launch, so a failing or slow `codex --help` blocks the seat. The readiness window defaults to 30 seconds and can time out under load (#182); since #643 it is configurable as `runtime.readiness_timeout_seconds` (env `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`, 1–600). A git worktree's `.git` file broke the Codex sandbox (#121; see #126). #618 moved non-managed Claude, Codex and Pi launches behind the seat launch environment, and #628, #638 and #666 followed within days. Pi launch and resume carry `--thinking` from the seat's resolved effort (OMP does not). |
| **Queue claim, pickup, wakes** | `daemon/src/domain/queue-repository.ts`, `daemon/src/domain/queue-pickup.ts`, `daemon/src/domain/queue-wake-ladder.ts`, `daemon/src/routes/require-sender-identity.ts`, `cli/src/client.ts` | All durable work between seats | After a slow `/healthz`, the CLI host-qualified a local sender and claims failed (#131, fixed by #135). Done-and-closed items read as stalled forever (#164, fixed by #176). |
| **Rig identity: same names, archived and deleted rigs** | `daemon/src/domain/rig-repository.ts`, `daemon/src/domain/rig-lifecycle-service.ts`, `daemon/src/domain/rig-teardown.ts`, `daemon/src/domain/seat-handover-service.ts`, `daemon/src/domain/running-name-guard.ts` | Seat resolution, `rig remove`, restore, importing a rig from YAML | Archived rigs still matched seat references, and `rig remove` killed another rig's live seat with the same name (#174, fixed by #181). Replacing stopped same-name rig generations on YAML import followed in #196. |
| **Projection of skills and files** | `daemon/src/domain/rigspec-instantiator.ts`, `daemon/src/domain/projection-planner.ts`, and each runtime adapter's `project()` (Claude Code, Codex, Pi, OMP, stub) | What every agent is given at start | Codex seats skipped skills that a Claude sibling had already projected (#159, fixed by #162). File-shaped entries failed with ENOTDIR (fixed by #185). |
| **Native process observation** | `daemon/src/domain/native-process-lineage.ts` (parses `ps -Ao pid,ppid,pgid,tpgid,ucomm,lstart,command`) | Liveness, identity verdicts, delivery checks | Processes whose command name contains a space were dropped (#134, fixed by #82). Non-English locales broke the date parsing (fixed by #239, which runs `ps` with `LC_ALL=C`). #220 also changed this file to recognise managed Claude seats behind launch wrappers. Claude identity proof (wrappers, launcher shims, conversation rotation, effort, version-named binaries, launch `--settings`) changed ten more times after 0.6.3. |
| **Schema and migrations** | `daemon/src/db/migrations/`, `daemon/src/db/all-migrations.ts`, `daemon/src/db/migrate.ts` | Every install, and every upgrade of a long-lived install | Parallel pull requests pick the same next number: #112 adds `086_rig_kernel_variant` while `main` already has `086_classification_fields_and_attempts`, and #155 and #195 landed as 090 and 091. `migrate.ts` applies by migration name in name order, so a duplicate number is not skipped, but its order relative to the other migration changes; 093 is unused at `e8f0ab34`. A long-lived install upgrading across several releases applies many migrations in one start, against real data. |
| **Restore and recovery** | `daemon/src/domain/restore-orchestrator.ts`, `daemon/src/adapters/{claude,codex,pi,omp}-resume.ts`, `daemon/src/domain/reconciler.ts`, `daemon/src/domain/restore-check-service.ts`, `daemon/src/domain/startup-orchestrator.ts`, `daemon/src/adapters/tmux.ts` | Reboots, power loss, daemon restarts | Detached seats kept stale tmux pane ids after a reboot (#141, fixed by #151 in `tmux.ts`). `restore-check` read queue files and Claude hooks from a fixed shared-docs path (#130); #139 made it use the configured root, and #328 replaced the queue-file check with a probe of the daemon's queue store and checks the selected Claude hooks. After a fresh relaunch, a stale Codex restore attention flag never clears (#116). Full-restore recovery of OMP seats is open (#41). |
## How reviews use this page
- **Find where the effect lands.** A reviewer notes which artery a pull request touches, if any,
and what depends on it.
- **At an artery, review the effect, not only the diff.** Whatever the diff size, the reviewer
states the downstream effects and checks behaviour with a scratch reproduction or a test.
- **Away from the arteries,** small pull requests keep a normal review. Scrutiny goes where the
effect is.
## The scenario rule
**A pull request that touches an artery adds or extends a stub-agent scenario, or says in its
description why it cannot.** Reviewers check for one or the other. Scenarios live in
`packages/test-system/scenarios/`; [ARCHITECTURE.md](../../ARCHITECTURE.md#a-scenario) says how to
write and run one, and [test-layers.md](test-layers.md) says what each test layer can and cannot
catch.
The goal is that every artery has at least one scenario whose seeded regression fails, so a small
change to an artery meets a real end-to-end check before it merges, not only a reviewer's reading.
### Scenario coverage today
At commit `e8f0ab34`. For why the stub cannot reach some of these yet, see "What a stub can and
cannot prove" in [test-layers.md](test-layers.md).
- **Queue durability across a daemon restart: covered.** CI runs
`queue-baton-survives-restart.yaml` healthy, then with a seeded `baton-drop` fault that must
fail, then healthy again (#243). It runs `rig queue claim` and checks the destination after the
restart, but pickup, wakes and claim-identity edge cases (such as the #131 sender case) have no
scenario yet.
- **Delivery (the #197 class): not covered.** The stub runtime has no input behaviour, so it cannot
exercise delivery to a managed wrapper; `send-verify-means-rendered.yaml` is not admitted to CI
for that reason (`packages/test-system/ci/README.md`).
- **Addressing and rendered delivery: partial.** CI runs the transcript and capture stub scenarios
in passing form, and the capture scenario catches a seat resolved by member-name prefix.
`send-renders-in-addressed-pane.yaml` and `down-stops-every-seat.yaml` run with the local runner
only. `stub-cli-journeys.integration.test.ts` checks that a verified send lands in the addressed
pane and not its sibling. None of these proves a seat consumed a message.
- **Restore (the #141 class): not covered.** The scenario runner does not bind the `restore` verb
yet; it fails with `UnboundActionError`.
- **Rig identity (the #174 class): not covered.** No library scenario sets up a same-name archived
or removed rig.
- **Projection (the #159 class): not coverable with `runtime: stub`,** which projects skills into its
own `.openrig/stub/skills/` directory. Use adapter-level unit tests.
- **Native process observation: unit tests only** (for example
`packages/daemon/test/codex-process-observation.test.ts`, `native-ps-locale.test.ts` and
`send-runtime-not-running.test.ts`).
- **Migrations: unit guards only** (`migration-fixture-parity.test.ts`,
`startup-migrations-mirror.test.ts`). Every scenario starts from an empty database, so upgrading
an existing database is not covered.
- **Launch:** every scenario's `up` goes through the real `rig up`, instantiation and startup
orchestration with stub seats. The stub adapter types its runner directly; Claude Code, Codex
and Pi seats launch through the seat launch environment (`seat-launch-environment.ts`), which
no scenario reaches. Real Codex launch and handover run only in two opt-in daemon tests
(`OPENRIG_E2E_REAL_CODEX=1`, which needs tmux, `codex` and a Codex login). The CI containers
have no provider CLIs or credentials.
## Regression log
When a regression reaches a release, add one line: date, issue, artery, and which layer missed it
(the layers are named in [test-layers.md](test-layers.md)). Each line should end up as a scenario.
- 2026-09-30, #197 (0.6.2), delivery: review saw a small diff in isolation; CI had no managed
explicit-mode Claude seat; the project's own day-to-day seats use the default launch path.
- 2026-09-29/30, #141, #174, #188 (after 0.6.0), delivery and rig identity: reviews of #109 did not
see the downstream effect of the exactly-one-target rule.
- 2026-10-19, #1483, managed compaction: a helper-only command-composition check did not bind the submitted command to the precompact effect. `managed-compaction-effect.test.ts` now drives the real enforcer command into a version-bound stub input model and checks the shipped precompact marker; its 851-character pasted-command control lacks that effect despite a successful submit receipt. `rig compact` rejects stub runtimes, so this is an adapter/helper check, not an installed CLI or native compaction proof. The model uses the recorded Claude Code 2.1.282 distinction; other versions and actual context compaction remain unverified here.
- 2026-10-19, #484, refocus orientation: script tests supplied basis strings without composing parked typed queue state through `queue whoami` and the hook. `refocus-parked-baton.yaml` plus the active-baton contrast now assert the shipped hook's own emitted context; installed-scenario runs healthy/seeded/healthy; its scratch trace-child fault moves the parked row from held work into the work candidates as current work, and the hook's emitted context must then fail with that row's slice shown as `WORK FROM QUEUE EVIDENCE`. This proves the composed diagnostic effect, not native hook scheduling or an agent reading and acting on it. With the work packet's bounds and per-runtime default, the parked scenario runs with the packet on and asserts the row as held work. `refocus-parked-baton-packet-off.yaml` keeps the labelled fallback as the packet-off control.
- 2026-10-23, F1 (0.7.0 candidate `2fec7382`), launch (a fresh Codex kernel): a fresh Codex kernel stopped at Codex's hook review. Caught at the cut, before publication, by native first-user and daily runs. Missed by the stub layer, which has no real Codex binary. Fixed in #1824.
- 2026-10-24, B6 (0.7.1 candidate `7badb557`), launch and resume (Codex hook trust): after an upgrade from 0.7.0, a Codex seat stopped at "Hooks need review: 6", because Codex 0.164 asks to approve a plugin's whole content again. Caught at the cut, before publication, by a native upgrade run with a real Codex. Missed by source tests and stub scenarios (a stub can't see the real Codex binary) and by the installed-upgrade check (no real Codex). Fixed in #1948. Scenario owed: a cheap real-agent Codex upgrade with approval renewal.
- 2026-10-24, pane-only attention (#1876 on the 0.7.1 candidate), native observation: a Codex seat waiting at a prompt that only its pane showed read as working, with no attention signal. Caught at the cut, before publication, by real work on a test machine. Missed by fixtures that all carried hook signals. Fixed in #1946. Scenario owed: a stub pane showing a selector with no hook event must read as needing attention.
- 2026-10-25, #1955, delivery (send readiness): approval-prompt text that a working Claude seat quoted in its own output read as a waiting prompt, so sends and queue wakes to that seat were refused. This one was present since 0.7.0, so it did reach a release; it was found during the 0.7.1 cut by real work on a test machine. Missed by classifier fixtures that had no working pane quoting prompt text. Fixed in #1955 for 0.7.2.
File diff suppressed because it is too large. Load diff
+407 -92
View File
@@ -1,110 +1,425 @@
---
kind: as-built
title: OpenRig Codemap — Modules and Source Entry Points
status: active
topics: [knowledge-and-context, observability]
domains: [engineering-advisor, operating-advisor]
applies-when: |
You know the behavior you need to inspect but not its owning source files.
Follow the module and source entry points below, then read the implementation.
siblings: [README.md, arteries.md, test-layers.md, cli-reference.md]
prerequisite-reads: [README.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# OpenRig Source Codemap
# OpenRig codemap
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
The paths and named entry points below were checked at main
`e8f0ab340db773392ec8be75b072d1c0f3068a50`. This is a navigation map, not a replacement for
reading the implementation. Linked documents retain their individual verification stamps.
[README.md](README.md) lists the complete document tree and commands to derive its inventory.
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
## Package entry points
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.
Paths in backticks are relative to the repository root.
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/*`
| Surface | Start here |
|---|---|
| Daemon process | `packages/daemon/src/index.ts`; `createDaemon()` in `packages/daemon/src/startup.ts`. |
| HTTP application | `createApp()` in `packages/daemon/src/server.ts`; route modules under `packages/daemon/src/routes/`. |
| State and migrations | `packages/daemon/src/db/connection.ts`, `packages/daemon/src/db/migrate.ts`, `packages/daemon/src/db/all-migrations.ts`. |
| CLI | `createProgram()` in `packages/cli/src/index.ts`; commands under `packages/cli/src/commands/`, HTTP in `packages/cli/src/client.ts`. |
| MCP | `createMcpServer()` in `packages/cli/src/mcp-server.ts`; stdio command in `packages/cli/src/commands/mcp.ts`. |
| Terminal UI | `packages/tui/src/main.ts`, `packages/tui/src/state.ts`, `packages/tui/src/render.ts`; HTTP in `packages/tui/src/daemon-client.ts`. |
| Web UI | `packages/ui/src/main.tsx`, `packages/ui/src/routes.tsx`. |
| npm assembly | `scripts/build-package.sh`, `scripts/rewrite-daemon-imports.mjs`, `packages/cli/package.json`. |
## Daemon
The daemon's route handlers obtain domain services from request context. `startup.ts` constructs
those services, repositories and runtime adapters; `server.ts` wires them into the application.
CLI and TUI clients use the daemon's HTTP projections. Package boundaries and the development
versus bundled paths are described in [ARCHITECTURE.md](../../ARCHITECTURE.md).
### 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.
## Runtime and state modules
### 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.
Each row names an existing module and a small set of source starting points. Directories group
related code; they are not an exhaustive dependency list.
### 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.
| Question | Module | Source entry points |
|---|---|---|
| How are the daemon, database and routes assembled? | [Daemon core](architecture/daemon-core.md) | `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/db/`. |
| How do runtime launch, projection and resume work? | [Adapters and runtimes](architecture/adapters-and-runtimes.md) | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/`, `packages/daemon/src/domain/native-resume-probe.ts`. |
| How do authored specs become launched agents with identity? | [Agent spec and startup](architecture/agent-spec-and-startup.md) | `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/startup-orchestrator.ts`, `packages/daemon/src/domain/whoami-service.ts`. |
| Where are durable work, delivery and handoff handled? | [Coordination](architecture/coordination-primitive.md) | `packages/daemon/src/domain/queue-repository.ts`, `packages/daemon/src/domain/stream-store.ts`, `packages/daemon/src/domain/inbox-handler.ts`, `packages/daemon/src/domain/outbox-handler.ts`. |
| How does a workflow retain progress and resume? | [Workflow runtime](architecture/workflow-runtime.md) | `packages/daemon/src/domain/workflow-runtime.ts`, `packages/daemon/src/domain/workflow-projector.ts`, `packages/daemon/src/domain/policies/`, `packages/daemon/src/routes/workflow.ts`. |
| How are queue observations and actions exposed? | [Mission control](architecture/mission-control.md) | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/routes/mission-control.ts`. |
| What is captured and checked during restore? | [Lifecycle, snapshot and restore](architecture/lifecycle-snapshot-restore.md) | `packages/daemon/src/domain/snapshot-capture.ts`, `packages/daemon/src/domain/restore-orchestrator.ts`, `packages/daemon/src/routes/restore-check.ts`. |
| How do messages and transcript reads reach sessions? | [Transport and transcripts](architecture/transport-and-transcripts.md) | `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/routes/transport.ts`. |
| Where do workspaces, projects and repository scope resolve? | [Workspace](architecture/workspace-primitive.md) | `packages/daemon/src/domain/workspace/`, `packages/daemon/src/domain/current-work.ts`, `packages/daemon/src/routes/projects.ts`. |
| How are files, progress and steering read or written? | [Content surfaces](architecture/content-surfaces.md) | `packages/daemon/src/domain/files/`, `packages/daemon/src/domain/progress/`, `packages/daemon/src/domain/steering/`. |
| How are review inputs, proof and approvals composed? | [Living notes and review](architecture/living-notes-review.md) | `packages/daemon/src/domain/review/`, `packages/daemon/src/domain/scope/`, `packages/daemon/src/routes/review.ts`. |
| Where are plugins, agent images and context packs discovered? | [Plugins, images and context packs](architecture/plugin-agent-image-context-pack.md) | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/agent-images/`, `packages/daemon/src/domain/context-packs/`. |
| How are reusable bundles assembled and installed? | [Packaging, bootstrap and bundles](architecture/packaging-bootstrap-bundles.md) | `packages/daemon/src/domain/pod-bundle-assembler.ts`, `packages/daemon/src/domain/bootstrap-orchestrator.ts`, `packages/daemon/src/routes/bundles.ts`, `packages/daemon/src/routes/up.ts`. |
| Where are event types, publication and SSE handled? | [Architecture rules and events](architecture/architecture-rules-and-event-system.md) | `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/event-bus.ts`, `packages/daemon/src/routes/events.ts`. |
### 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`.
For an execution projection, also read `packages/daemon/src/domain/execution-view.ts` and
`packages/daemon/src/domain/view-projector.ts`. The terminal-side consumer starts in
`packages/tui/src/execution/`. These sit alongside the queue and workflow sources above.
### 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`.
### Newer entry points
### 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}`.
These features were added after the module pages above were last verified. Each module's own
stamp says whether it already covers them; until then, start from the source named here.
### 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`.
| Question | Source entry points | Nearest module |
|---|---|---|
| How does a GitHub folder link become a bundle source? | `isGitHubBundleLink()` and the bundle source in `packages/cli/src/lib/bundle-source.ts`, used by `packages/cli/src/commands/up.ts` and `packages/cli/src/commands/bundle.ts`. | [Packaging and bundles](architecture/packaging-bootstrap-bundles.md) |
| What does `rig bundle check` read? | `checkBundleFolder()` in `packages/cli/src/lib/bundle-check.ts`: advisory file reads against `openrig.bundle-standard/v1`, with no daemon, launch or provider call. | [Packaging and bundles](architecture/packaging-bootstrap-bundles.md) |
| How does a member's `permission_policy` become a launch posture, including `builtin:auto`? | `builtinLaunchPosture()` and `resolvePermissionPolicyAttachment()` in `packages/daemon/src/domain/permission-policy/policy-ref.ts` (`builtin:yolo` is full bypass, `builtin:auto` is auto, the other built-ins are the floor), bound per seat in `packages/daemon/src/domain/rigspec-instantiator.ts`. `packages/daemon/src/domain/bundle-behaviour.ts` is the read-only preview of the same mapping for an unopened bundle. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What does a non-interruptive launch add? | `nonInterruptiveArgs()` in `packages/daemon/src/adapters/non-interruptive.ts` (per-launch arguments only; it never changes permissions or native settings files). The separate instance-wide YOLO setting, off by default, is in `packages/daemon/src/adapters/yolo-mode.ts`. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What launch arguments does a kernel seat get? | `operationalLaunchArgs()` and `KERNEL_CLAUDE_ALLOW` in `packages/daemon/src/adapters/kernel-authority.ts`; `NativePermissionStore.launchOverride()` in `packages/daemon/src/domain/native-permission-store.ts` decides it per launch (only the rig named `kernel`, with no explicit seat choice, permission policy or Codex profile). | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| What launch default does a seat in any other rig get? | Under the same conditions `launchOverride()` sets `teamPermissionDefault`: a Claude seat at the floor posture gets `TEAM_CLAUDE_ALLOW` and `TEAM_CLAUDE_ASK` from `operationalLaunchArgs()` in `packages/daemon/src/adapters/kernel-authority.ts`, whose `claudeCommandSettings()` hands the kernel or team lists to the session-only Bash hook `packages/daemon/assets/claude-team-permissions.cjs`, which returns the allow or ask decision; a workspace-write Codex seat gets `--add-dir` roots from `prepareCodexTeamWorkspace()` in `packages/daemon/src/domain/codex-team-workspace.ts`. | [Adapters and runtimes](architecture/adapters-and-runtimes.md) |
| How does `rig seat continue` finish a fresh or Codex-fork startup that stopped before its context was delivered? | `continueStartup()` in `packages/daemon/src/domain/seat-lifecycle-service.ts`, `canContinueStartup()` and `readPendingStartupContext()` in `packages/daemon/src/domain/startup-orchestrator.ts`, `captureForkIdentity()` in `packages/daemon/src/adapters/codex-runtime-adapter.ts`, the route in `packages/daemon/src/routes/seat.ts`, and the command in `packages/cli/src/commands/seat.ts`. | [Agent spec and startup](architecture/agent-spec-and-startup.md) |
| How does `rig context work-install` choose a project and position? | `resolveWorkPosition()` in `packages/cli/src/lib/work-install.ts`, `packages/cli/src/commands/context.ts`, and `packages/daemon/src/domain/workspace/project-catalog.ts`. | [Workspace](architecture/workspace-primitive.md) |
| Where do rosters come from? | `packages/cli/src/commands/roster.ts`: `rig roster list`, `show` and `find` read JSON roster files from `<workspace.root>/rosters` (or `--folder`) and are read-only. | [Agent spec and startup](architecture/agent-spec-and-startup.md), "Specialist rosters" |
| What telemetry can be read? | `packages/daemon/src/domain/finite-telemetry.ts` (the `events`, `queue-transitions` and `tenures` streams), `/api/telemetry` in `packages/daemon/src/routes/telemetry.ts`, and `packages/cli/src/commands/telemetry.ts`. | [Architecture rules and events](architecture/architecture-rules-and-event-system.md), "Telemetry read surface" |
### 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`.
## Change and verification guides
### 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`.
| Need | Read |
|---|---|
| Contributor setup and a small change recipe | [CONTRIBUTING.md](../../CONTRIBUTING.md), [ARCHITECTURE.md](../../ARCHITECTURE.md). |
| What depends on a sensitive source path | [arteries.md](arteries.md). |
| Unit, route, scenario, package and CI coverage | [test-layers.md](test-layers.md), `packages/test-system/README.md`, `.github/workflows/tests.yml`. |
| Exact CLI syntax | [cli-reference.md](cli-reference.md), then `packages/cli/src/index.ts`, its command module and current command help. |
| As-built metadata | [frontmatter-schema.md](frontmatter-schema.md). |
| An older architecture link | [architecture.md](architecture.md), which redirects into this module tree. |
| Visual specification | [DESIGN.md](../DESIGN.md). |
### 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`.
## Existing web UI references
### 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`.
These historical documents are still indexed; this pass checks their locations, not their
current implementation claims. Consult each page's stamp.
### 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`.
| Reference | Source area |
|---|---|
| [UI compatibility pointer](ui.md) | `packages/ui/src/`. |
| [Shell and routing](ui/shell-and-routing.md) | `packages/ui/src/routes.tsx`, `packages/ui/src/components/AppShell.tsx`. |
| [Topology](ui/topology.md) | `packages/ui/src/components/topology/`. |
| [Project and For You](ui/project-and-for-you.md) | `packages/ui/src/routes.tsx`. |
| [Library/specs and design system](ui/library-specs-and-design-system.md) | `packages/ui/src/components/specs/`. |
### 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`.
### 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.
### 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).
### 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).
### 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`.
## CLI
### 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`.
### 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`.
### 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.
-73
View File
@@ -1,73 +0,0 @@
---
kind: as-built
title: As-Built Frontmatter
status: active
topics: [knowledge-and-context, frontmatter]
domains: [engineering-advisor, operating-advisor]
applies-when: |
Writing or refreshing a source-grounded document under docs/as-built/.
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# As-Built Frontmatter
Source snapshot: `e8f0ab340db773392ec8be75b072d1c0f3068a50`. This describes the source at that commit;
it does not establish the version or behavior of a running daemon.
Frontmatter makes an as-built page discoverable and records the source it
was checked against. This page describes the convention visible in this
repository. It does not require an unpublished schema or an internal
workspace to interpret the fields.
## Fields
The corpus uses these fields; see the [module index](README.md) and
[CLI reference](cli-reference.md) for examples.
| Field | Meaning |
|---|---|
| `kind: as-built` | Identifies a description of implemented behavior. |
| `title` | Human-readable subject of the page. |
| `status` | Document status, such as `active`; not a daemon or feature status. |
| `applies-when` | Short retrieval cue explaining when to open the page. |
| `topics`, `domains` | Lists of indexing labels used in this corpus. They do not select permissions or runtime behavior. |
| `siblings`, `prerequisite-reads` | Related pages and reading order, when useful. Paths are relative to the containing page. |
| `last-verified-against-source` | A resolvable Git commit whose source was used to check the page. |
| `last-updated` | Date of the document update, in `YYYY-MM-DD` form. |
## Verification stamp
Use a commit ID, preferably full length, rather than a branch name or a
moving `HEAD` label. A source stamp identifies what was read; it is not proof
that an installed package or a running system uses that source. Keep runtime
observations separately qualified.
```yaml
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
```
Check that the commit resolves with `git cat-file -t <commit>` in a checkout
containing that history. Verify the paths, symbols, and numeric claims cited
by the page at that commit before changing its stamp. A newer date alone
does not refresh the content. Missing history in a shallow checkout and an
invalid commit ID are different conditions.
Use repository links and named symbols for source references. For numeric
inventories, state what is counted and how it was obtained; for example, the
CLI reference distinguishes registered command objects from aliases and
Commander-generated help.
## What the repository guard checks
[`scripts/check-docs-guard.mjs`](../../scripts/check-docs-guard.mjs)
checks allowed tracked documentation paths. Its `findBlockedDocsPaths`
function does **not** validate this frontmatter or prove the prose matches
source. Passing that guard should not be reported as a schema or factual
accuracy check.
[As-built pages](README.md) and [operator reference pages](../reference/help.md)
serve different purposes. `docs/reference/` is staged by
[`scripts/build-package.sh`](../../scripts/build-package.sh); placing content
there changes what is distributed in the package.
-628
View File
@@ -1,628 +0,0 @@
---
kind: as-built
title: Test layers — what to run before you push, and what each layer proves
status: active
applies-when: |
You changed OpenRig and want to know which checks to run before opening a pull
request, what each check actually proves, and what CI will run for you. Also
read it before claiming a stub-agent scenario covers a behaviour.
siblings: [arteries.md, README.md, codemap.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-11-09
---
# Test layers: what to run before you push, and what each layer proves
> **This page is a map, not the territory.** It was checked against source at `e8f0ab34`
> and it will drift. It is also incomplete on purpose. Before you rely on a command or a
> claim here, read the script it names or run it. If a behaviour is missing from this page
> (or from [arteries.md](arteries.md)), that tells you nothing about whether a change to it
> is safe.
## Short version
For every change, run what the pull-request template asks for:
```sh
npm run build
npm run lint # typecheck only; there is no formatter or style linter
npm test # = npm run test:repo && npm run test:workspaces
```
Then add the layers your change calls for, using the ladder below. CI also runs a
network-denied macOS test pass and a containerised installed-package scenario on every PR
to `main`. In the PR description, say which layers you ran and which you could not run.
## The ladder
| Layer | Command | What it proves | What it cannot prove | When to run it |
|---|---|---|---|---|
| Typecheck | `npm run lint` | Builds the daemon, then `tsc --noEmit` passes for daemon, ui, cli and tui | Any runtime behaviour. Formatting. | Every change |
| Build | `npm run build` | All four workspaces compile (the UI also runs `vite build`) | That the *published* package is complete (see Packaging) | Every change |
| Repo checks | `npm run test:repo` | Builds the daemon, then runs `node --test --test-concurrency=1 scripts/*.test.mjs`, `scripts/check-first-run-references.mjs`, `scripts/check-docs-guard.mjs`, `mirror-skills --check` (skill homes, generated copies, layout and digests) and `generate-context-packs --check` | Package behaviour | Every change. It matters most when you touch `scripts/`, `docs/` or skills. |
| Package suites | `npm run test:workspaces`. One package: `npm run test -w packages/cli`. One file, from the repo root: `npx vitest run packages/cli/test/<file>.test.ts` | Vitest unit and integration tests for daemon, cli and tui. Some of them start real scenario daemons, tmux sessions and stub runners. | The installed package. Real Claude Code or Codex. CI's sandbox conditions (see CI). | Every change (`npm test` covers it) |
| Web UI suite | `npm run test:ui` | jsdom unit tests for `packages/ui` | Real browser rendering | When you touch `packages/ui`, or a daemon API the UI reads. The web UI is in maintenance mode, so this suite is advisory locally and is not part of `npm test`. |
| Generated files | `npm run mirror-skills:check`, `npm run generate-context-packs:check` (both part of `npm test`) | Shipped skill homes are publishable, generated copies match their homes, all edges match their recorded hashes, and every static context pack passes the daemon's own manifest parser and the leak scan | Whether the skill content is any good | Edit a shipped skill's one in-repo home, run `npm run mirror-skills` to regenerate its other copies and refresh digests, then run the checks and commit the result. The apply reads nothing outside the repository. See "A shipped skill" in [ARCHITECTURE.md](../../ARCHITECTURE.md#a-shipped-skill). |
| Local gate | `npm run gate` | Lint plus `npm test` on a clean tree, tied to `HEAD`, one run per machine at a time, with a JSON receipt | Anything that lint and `npm test` don't already prove | Optional. Useful when several agents share one machine, or when you want a receipt tied to a commit. |
| Packaging | `npm run build:package`, `npm run test:tui-package`, `bash scripts/smoke-fresh-install.sh` | The publishable `@openrig/cli` assembles, the packed TUI launches, and a fresh install starts a daemon | Behaviour beyond startup | When you touch dependencies, bin entries, build scripts, or files a package ships |
| Stub scenarios, host mode | `node --import tsx packages/daemon/scripts/run-scenarios.mjs [scenario.yaml …]` | A scenario's steps and assertions pass against a scratch daemon, real tmux and `runtime: stub` seats | The installed package. Seeded-regression pairing. Everything under [What a stub can and cannot prove](#what-a-stub-can-and-cannot-prove). | While you write or debug a scenario |
| Stub scenarios, installed package in a container | CI job `installed-scenario`. Before pushing: `DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh --remote …` | A clean Linux install starts. A claimed queue item survives a daemon restart. A planted fault is caught at the intended assertion. Two stub seats' scripted output reads back through `rig transcript` and `rig capture` (passing form only). | Native runtimes. Most command families (see [Help wanted](#help-wanted-command-families-without-a-behavioural-scenario)). | Changes that can affect daemon startup, packaging, queue durability or the scenario harness |
| Live-model evals | `npm run eval -w packages/daemon -- --provider rig …` | Whether a real Claude Code seat runs the expected `rig context get <entry>` for natural prompts | Determinism. Codex. Anything without a case. | Changes to shipped skills or context entries meant to steer agents. **Uses provider credits.** |
| Testbed runbooks | `docker/testbed/runbooks/` (manual) | The testbed image itself works as a test environment | Product behaviour | Only when you change the testbed image or the container plumbing |
Some package tests are gated. `OPENRIG_E2E_REAL_CODEX=1` runs two daemon e2e tests against a
real Codex (they also need tmux, `codex` and `~/.codex/auth.json`, and they use provider
credits). `OPENRIG_REAL_CLAUDE_INTEGRATION=1` enables only a placeholder: it requires
`OPENRIG_PARENT_NATIVE_ID` and checks its format, and no real-Claude fork test exists yet.
Without tmux, some tmux tests skip and others (such as `transcript-seat-env-native.test.ts`)
fail. The CLI's release-tag tests skip without local tags unless `CI=true`, so a green local
run without tags has skipped them. CI installs tmux and fetches full history.
The TUI has its own vitest suite (`npm run test -w packages/tui`, which builds the TUI first)
and runs in the `tui` leg of `package-tests`; `npm run test:tui-package` launches the packed
TUI in demo mode. The `tui_socket` scenario surface exists, but the only scenarios that use it
are not yet runnable checks.
### Gotchas that cost people time
- **Running inside a seat.** The root `vitest.config.ts` makes a root-level file run use its
package's config. The cli, daemon and TUI configs all run the shared root
`test/hermetic-env.setup.ts` before any test file. It clears the inherited `OPENRIG_*` and `RIGGED_*` instance selectors (connection,
database, workspace and topology roots, seat identity, bearer tokens), forces a fixture
`OPENRIG_HOME` and fails if the home is not fixture-scoped. It also wraps `fetch`, so a real
request to anything other than a loopback ephemeral-port fixture or a registered target fails.
It points `HOME` and the XDG directories at a fixture user home and clears `CODEX_HOME` and
`CLAUDE_CONFIG_DIR`. `OPENRIG_E2E_REAL_CODEX=1` keeps native homes and binaries only for
`seat-handover-model-fidelity-e2e.test.ts` and `seat-lifecycle-set-model-resume-e2e.test.ts`;
all other files remain isolated. The scenario scaffold always uses fixture homes and runtime stubs.
The two native files rely on Vitest's default per-file isolation; runs without it are unsupported.
Scenario scaffolds scrub inherited provider roots but preserve those passed in an explicit `baseEnv`.
A test can still set its own
overrides after setup. The UI suite (jsdom) uses its own `packages/ui/test/setup.ts`.
- **Stale vendored daemon.** `npm run build:package` leaves an assembled copy of the daemon at
`packages/cli/daemon/` (gitignored). If you change daemon source afterwards,
`scripts/check-cli-daemon-freshness.test.mjs` inside `test:repo` can fail on the stale copy.
Re-run `npm run build:package` or delete `packages/cli/daemon/`. `npm run gate` deletes it for you.
- **Local passes, CI fails.** CI runs the package suites with a scratch `HOME`, no
credentials, and outbound network blocked except to localhost (see below). A test that reads
your real home directory, a running daemon, the network or a credential can pass on your
machine and fail in CI.
- **Mixed fixture clocks.** `compaction-command-submission.test.ts` uses one clock for the
enforcer, the transport's injected `now` and sleeps, and its `Date.now` idle deadline. Previously,
real-time transport waits could exhaust the fixture's 20 ms budget during a scheduler pause
even though the modeled seat was idle. Restore the `Date.now` spy after each test; keep real
deadline behavior covered by the transport tests. This changes the fixture, not production timeouts.
- **Docs location.** `check-docs-guard` fails if a tracked file under `docs/` sits outside
`docs/as-built/`, `docs/reference/` or `docs/releases/`. `docs/DESIGN.md` is the one allowed
exception.
## What CI runs on your pull request
`.github/workflows/tests.yml` runs on pull requests to `main` and on manual dispatch, all on
Node 22. Each suite is its own job, so one failure doesn't hide the others. No provider
credentials are supplied.
| Job | Runner | What it does |
|---|---|---|
| `build-and-package` | ubuntu | `npm ci` and `npm run build`. Then `npm pack --dry-run` for cli, daemon and tui, with each file list uploaded as the `pack-lists` artifact. The job only records the lists; reviewers read them. |
| `typecheck` | ubuntu | `npm run lint` |
| `repo-checks` | ubuntu | `npm run test:repo` |
| `package-tests` | macOS, matrix `daemon`, `cli`, `tui`, `ui` (fail-fast off) | Installs tmux, runs `npm ci` and `npm run build`. Then runs `npm run test -w packages/<pkg>` under `env -i` with a scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR` and a `sandbox-exec` profile that blocks outbound network except localhost and the job's own socket directory. `scripts/ci-macos-process-probe.mjs` runs first. |
| `linux-seat-environment` | ubuntu | Builds all packages, then runs `seat-env-parent-session.integration.test.ts` with real CLI, daemon, tmux and stub seats. A network namespace has loopback only; tests run as the unprivileged runner user under `env -i` with scratch `HOME`/`TMPDIR`/`TMUX_TMPDIR`. Runs on pull requests and manual dispatch, like `package-tests`. |
| `installed-scenario` | ubuntu | `bash scripts/run-pr-scenarios.sh` (fifteen container runs, described below). Uploads `dist/pr-scenarios/` as `installed-scenario-evidence`, including on failure. |
The Linux seat-environment job covers a gap in the macOS matrix: the integration test
reads `/proc/<pid>/environ`, so macOS skips it. It checks both a new tmux server and an
existing server carrying a parent's session identity. It verifies the scoped environment,
the runtime directory and disabled session-bus default, and tmux's terminal identity in
the launched panes; the existing owner session stays unchanged. This adds one focused
Linux job to the existing suite. Stub seats prove the environment passed to the pane;
native Claude image paste and Shift+Enter handling still need separate verification
(see [#2041](https://github.com/mvschwarz/openrig/issues/2041)).
A second workflow, `.github/workflows/portability-report.yml` (job `portability-report`),
lists lines your PR adds that contain machine-specific values: credentials, home or temp
paths, network addresses, email addresses. It never fails because of a finding. To see the
matched text locally (credentials show only their first characters), run
`node scripts/portability-report.mjs`. By default it compares the
merge-base with `origin/main` against `HEAD`; pass `--staged` to check staged changes instead.
The UI suite is a CI matrix leg even though it is advisory locally. Check your PR's status to
see whether a red UI leg is treated as blocking. Branch-protection settings are not in the
repository.
## The local gate: `npm run gate`
`scripts/gate-lane.mjs` runs two legs: `npm run lint` and `npm run test`. It does not run
`test:ui`, the scenarios or the packaging checks. On top of those legs it:
- holds a machine-wide lock on localhost port 40404 (override with `OPENRIG_GATE_LANE_PORT`).
A second gate exits 2 straight away instead of waiting.
- refuses a dirty worktree. Refuses a `node_modules` that is a symlink, or `@openrig/*`
workspace links that resolve outside this worktree. Both are common with shared git worktrees.
- deletes the stale vendored daemon bundle at `packages/cli/daemon/` before the legs run.
- writes `gate-lane-verdict.json` at the repo root, or at `OPENRIG_GATE_VERDICT`, which must
point inside the current worktree (a path outside it exits 3 before the legs run). The file holds the `HEAD` SHA, per-leg results and durations, and a note of other
`node`/`vitest`/`tsc` processes running at the time. It fails if `HEAD` changes during the
run. The file is not gitignored, so don't commit it.
Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
`OPENRIG_GATE_LANE_SMOKE=1` skips the legs to test the wiring only, and the verdict records
`smoke: true`.
## Stub-agent scenarios
### What's in the box
| Piece | Where |
|---|---|
| `runtime: stub` adapter | `packages/daemon/src/adapters/stub-runtime-adapter.ts`, with `stub-runner.ts` (the process that runs in the pane), `stub-script.ts` (the launch-script format) and `stub-compaction.ts` / `stub-restore.ts` |
| Scenario format and runner | `packages/daemon/test/helpers/scenario-*.ts`. Step verbs: `up`, `down`, `send`, `restart`, `daemon`, `seed_regression` are bound. `restore`, `mutate`, `policy` are rejected at load time (`UNBOUND_STEP_VERB`) because the runner has no shipped binding for them; `emit` parses but only `refocus` is bound. Assertion surfaces: `ps`, `queue`, `stream`, `scope`, `pane`, `transcript`, `tui_socket`, `policy_provenance`. |
| Scenario library | `packages/test-system/scenarios/`: 20 scenarios, 7 stub topologies, `agents/`, `culture.md`, per-seat stub scripts in `scripts/` |
| Runner fixtures | `packages/daemon/test/fixtures/scenarios/` (`scenario-01-per-seat-scripts`, `scenario-02-baton`, `scenario-10-one-view-state`) |
| Host runner | `packages/daemon/scripts/run-scenarios.mjs` |
| In-container entry and result check | `packages/test-system/ci/run.mjs`, `packages/test-system/ci/result.mjs` |
| PR orchestrator | `scripts/run-pr-scenarios.sh` |
| Image | `docker/testbed/Dockerfile`, built by `scripts/build-testbed-image.sh`; `docker/testbed/Dockerfile.scenarios` is layered on top by `scripts/run-pr-scenarios.sh` |
### Host mode: while you're writing a scenario
The tmux server selection regression also uses these scaffold and lifecycle helpers
in `packages/daemon/test/tmux-server-isolation-daemon.test.ts`. It runs a real stub
seat through config changes, daemon restarts, capture, self-attachment and cleanup,
while native socket observations verify that a personal namesake survives. The
test also removes the effective-server record and verifies that `destroy --all`
leaves the daemon, database and both servers' sessions intact before restoring
the record and checking normal cleanup. The
YAML grammar has no config-write or socket-inventory surface, so these assertions
live in the Vitest integration test. It does not exercise a real provider model,
SSH connection or graphical terminal window.
`packages/daemon/test/tmux-server-two-instances.test.ts` adds two real daemons
sharing a scratch OS home and inherited personal tmux server, with separate
OpenRig homes, databases and ports. Both enable isolation and control mode. It
checks same-named seats through inventory, capture and verified writes, observes
the marked control session on each selected socket, then stops one instance and
checks that the other instance's pane identity, control session and delivery still
work. The personal namesake remains untouched. This test requires a tmux version
that supports control mode and the built CLI and daemon. Like CI's package suites,
it should run with scratch homes and outbound network restricted to localhost;
scripted seats prove terminal routing and rendered delivery, not model receipt.
```sh
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
node --import tsx packages/daemon/scripts/run-scenarios.mjs packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml
```
- The runner creates a scratch `HOME`/`OPENRIG_HOME`, its own tmux server and its own
daemon. It builds the scenario environment from `HOME`, `PATH` and `TERM` only, so your
shell's daemon-target variables (`OPENRIG_URL`, `OPENRIG_PORT` and similar), `TMUX` and
`OPENRIG_TEST_CLOCK_NOW` are dropped rather than inherited. It does not touch the daemon or
tmux server your own session uses.
- It accepts YAML paths only. Any flag, including `--container`, is refused before anything runs.
- It supplies no fault controller, so any scenario with a `seed_regression` step fails loudly
at that step. That includes the library's `queue-baton-survives-restart`. Use the container
path to run the seeded pair.
- `scenario-10-one-view-state` declares its own normaliser for its cross-surface `equals`
check. Whether it passes in host mode has not been verified.
### Container mode: the scenario pack CI runs
In CI the `installed-scenario` job runs `scripts/run-pr-scenarios.sh` with no arguments:
both baton cases (`fixture` = `scenario-02-baton.yaml`, `library` = `queue-baton-survives-restart.yaml`),
each run three times as healthy, then lost-baton, then healthy (six containers); the
`refocus-parked` effect case runs healthy, seeded, then healthy (three containers); and the
six passing-only cases (`transcript` = `transcript-reads-addressed-seat.yaml`, `capture` =
`capture-returns-addressed-seat.yaml`, `daemon-restart`, `refocus-active`,
`refocus-parked-off`, `prompt-state`) run once each, healthy only (six containers). That is
fifteen fresh containers.
To run a case before you push, point `DOCKER_HOST` at a Docker engine you control over SSH:
```sh
npm ci
DOCKER_HOST=ssh://<your-host> bash scripts/run-pr-scenarios.sh \
--remote --case library --mode healthy --out dist/scenario-check
```
- Options: `--case fixture|library|transcript|capture|daemon-restart|refocus-parked|
refocus-active|refocus-parked-off|prompt-state` (default: all nine), `--mode
healthy|lost-baton|seeded` (default: healthy, lost-baton, healthy) and `--out <dir>` (default
`dist/pr-scenarios`). `--mode` applies to the baton cases (`fixture` and `library`) and to
the `refocus-parked` effect case; the six passing-only cases (`transcript`, `capture`,
`daemon-restart`, `refocus-active`, `refocus-parked-off`, `prompt-state`) always run once
each, healthy. `--mode lost-baton` leaves them out of a full run, and
`--case transcript|capture --mode lost-baton` is refused with exit 2. Use a fresh `--out`
directory for each run you want to keep.
- `--remote` accepts only `ssh://` values for `DOCKER_HOST`, and it refuses if
`DOCKER_CONTEXT` is set. The engine must be Linux on amd64 or arm64. The script reads the
*server's* platform, so a macOS/arm64 laptop driving a Linux/amd64 engine works.
- Without `--remote`, the script refuses to run anywhere except GitHub Actions on Linux.
- The source build and pack run on your machine. The image build and the containers run on
the engine. Building the image needs network access for the pinned base image, Node and
dependencies. The scenario containers have no network.
- The image is built from your working tree but tagged with `git rev-parse HEAD`. Commit
first, so the tag names what you actually tested.
- It never pushes images. It removes only the containers it named. If the SSH connection
drops, it prints the name of any container it could not remove.
What happens, in order:
1. `scripts/build-testbed-image.sh` packs `@openrig/cli` from your tree. It installs the
package into a digest-pinned Debian image with Node, checks `rig --version`, and checks
that the installed daemon loads. The result is tagged `openrig-testbed:<sha>`.
2. `docker/testbed/Dockerfile.scenarios` adds a bundled copy of
`packages/test-system/ci/run.mjs` plus the fixture and library scenarios.
3. Each run uses a fresh container: `--network none`, read-only root plus a 512 MB `/tmp`
tmpfs, `--cap-drop ALL`, `no-new-privileges`, non-root, 2 CPUs, 2 GiB, 256 PIDs and a
300-second deadline.
4. In lost-baton mode, the runner stops the daemon, changes exactly one queue row in the
scratch database from `in-progress` to `pending`, and restarts the daemon.
`packages/test-system/ci/result.mjs` then accepts only one outcome: a failure at the
post-restart queue assertion, with `baton-1` observed as `pending` at the expected
destination. A startup error, a timeout or any other failure does not count as a caught
regression. Healthy runs must pass with no fault armed.
5. The `transcript` and `capture` runs are passing form only: `result.mjs` accepts only a
clean PASS of the named scenario, with no seed or fault. Their planted failures are
product-code mutations, so they stay per-PR evidence and are not CI runs. Stub scripts
work here because the runner executes the pipeline in host mode inside the image.
A single healthy run proves that one leg only. The paired fault run is what shows the
assertion can catch the failure.
Adding a new case to this pack today means changing three files:
`scripts/run-pr-scenarios.sh` (the `--case` list), `CASES` in
`packages/test-system/ci/result.mjs`, and the fault controller in
`packages/test-system/ci/run.mjs`, which is currently specific to the baton row. A
passing-only case needs no fault controller: add it to `PASSING_CASES` and the `--case` list
in `scripts/run-pr-scenarios.sh`, and to `PASSING_CASES` in `result.mjs`.
### What a green stub scenario proves
- The packaged CLI installs into a clean Linux container and starts a daemon.
- `rig up` and `rig down` of a stub topology go through the real daemon, tmux, SQLite and CLI.
- Queue state, read through the shipped `rig queue list` path, survives a daemon restart.
- With the paired seeded run: the assertion fails when that state is broken.
### What a stub can and cannot prove
The stub does not fabricate product outputs. It runs the real daemon, tmux and CLI. But it is
not Claude Code or Codex, and it differs from them in ways that decide what a stub green
means:
| Area | What the stub does at `e8f0ab34` | What that means for your test |
|---|---|---|
| Consuming a message | `stub-runner.ts` runs its launch script once and then idles. It has no stdin reader, socket or other input channel. The default script prints `[stub] scripted reply: acknowledged` at boot, before anything has been sent. | A pane showing a reply, or your echoed text, does not prove the message was consumed. No stub scenario can currently prove "delivered and answered". Separately, `rig send --verify` means "appeared in the pane" (with transport.verify_input_box on, a Claude Code or Codex send means "the seat was idle before the paste and its input box took the exact text"), not acknowledgement (see `rig send --help`), and the scenario `send` step doesn't pass `--verify` at all. |
| Launch path | `StubRuntimeAdapter` types `node <stub-runner> …` into the pane (`tmux.sendText`, then Enter). Claude Code with an explicit permission mode launches through a managed launch (`ClaudeManagedLaunch.prepare`, `tmux.sendShellCommand`). Otherwise Claude Code, Codex and Pi launch through `SeatLaunchEnvironment.command` and `tmux.sendShellCommand`; the stub adapter does not get that environment. The shell-foreground check in `session-transport.ts` (`unverifiedShellForeground`) runs for every runtime except `terminal` and `claude-code`, and only `codex` has a native-process proof; Claude Code ordinary delivery applies its own uncertainty policy at the input boundary. | A stub seat doesn't exercise the seat launch environment, managed launch, wrappers or native-process identity (the class behind #197). Wrapping the stub in a shell wouldn't change that. |
| Permissions | `validateNativePermissionSelection` accepts only `codex` and `claude-code`. The stub carries the resolved `floor` / `full_bypass` posture, including one set by a declarative `permission_policy`, into its runner's `--posture` argument and READY line. It ignores non-interruptive launches, and it doesn't exercise Claude or Codex native permission behaviour. | Testing Claude permission modes or per-seat permission selection needs a real runtime. |
| Queue pickup and wake | No stub worker reacts to a nudge by claiming an item, working on it and handing it back. | The baton scenarios prove the claim *survives a restart*. They don't prove delivery, pickup or wake. |
| Reboot / tmux reset | `daemon: {op: restart}` restarts only the scenario daemon, and the tmux server keeps running. No step resets tmux. | Recycled pane IDs and stale bindings after a real reboot (the class behind #141) are not covered by a daemon restart. |
| Multi-rig and same-name identities | The `up` step always uses the scenario's top-level `topology` and ignores a per-step override. No step archives or removes a rig. | Cross-rig scope, same-name generations and archive/remove routing (the class behind #174) need runner work before they can be tested. |
| Step-time restore, compaction, mutation, policy | `restore`, `mutate` and `policy` steps are refused at load time (`UNBOUND_STEP_VERB`); `emit` parses but a non-`refocus` behavior throws `UnboundActionError` when the runner reaches it. Behaviours run only from a per-seat launch script (`env.stub_scripts`). A seat `restart` step maps to `rig launch`, which is not native resume or fork. | Step-time compaction and restore, and native resume and fork, are not covered. |
| Skill projection | The stub projects skills into `<cwd>/.openrig/stub/skills/`. Claude Code projects a team seat's skills into the seat's generated plugin in OpenRig's state, and into `<cwd>/.claude/skills/` only for a seat without one. | A stub green says nothing about provider-specific projection (the class behind #159). |
| Model and provider | No model, no tokens. Stub scripts refuse `usage_limit` because it only exists on real runtimes. | Model behaviour, provider usage limits and real context usage need live evals or a real runtime. |
| Platform | CI's scenario job runs in a Linux container. | macOS process behaviour is covered by the macOS `package-tests` jobs, not by scenarios. |
The most useful next pieces of harness work, roughly in dependency order:
1. A stub that consumes input and answers a fresh nonce.
2. A model-free test entry point shaped like a provider, that goes through the real native
adapter and managed launch. Package tests such as `claude-interactive-launch.test.ts` and
the `*-native.test.ts` files already drive the real adapters through tmux against an
offline recorder; they prove command resolution and arguments.
`claude-executable-foreground.test.ts` holds that recorder open in private Bash, zsh, dash and ksh panes
to check parent PID, process-group ownership, tmux's pane command and parent-shell
restoration for unmanaged fresh, fork and resume launches. These fixtures do not prove
native readiness, history or answers.
3. Lifecycle and identity steps (tmux reset, archive/remove).
4. Then pickup, wake and restore scenarios.
### Library status
Seven library scenarios run in CI: `queue-baton-survives-restart` (since #243) and
`refocus-parked-baton` with their seeded pairs, and `transcript-reads-addressed-seat`,
`capture-returns-addressed-seat`, `daemon-restart-preserves-team-identity`,
`refocus-active-baton` and `refocus-parked-baton-packet-off` in passing form only. The
`fixture` case runs the `scenario-02-baton` test fixture through the same seeded pair.
Three more, `down-stops-every-seat`, `send-renders-in-addressed-pane` and
`up-relaunch-restores-a-two-seat-team`, run with the local runner but not in CI, and
`compaction-restore-resumes-role` runs the compaction round-trip's success path the same
way. Those ten later scenarios are not in this table; "Status today" under
[Help wanted](#help-wanted-command-families-without-a-behavioural-scenario) covers seven
of them. The other nine scenarios are authored but not yet runnable as meaningful checks:
| Scenario | Why it doesn't run as a check yet | Smallest useful next step |
|---|---|---|
| `queue-baton-survives-restart` | Runs in CI: healthy / seeded `baton-drop` / healthy | Extend it to handoff, block, resolve and unclaim |
| `clean-lifecycle-no-residue` | The post-`down` "nothing left over" assertion is commented out, because the format has no way to say "empty" | Add a real absence assertion |
| `ps-scope-honesty` | The second `up` reuses the first topology, and the expected match tolerates the other rig | Make `up` honour the per-step topology, then assert the exclusion |
| `send-verify-means-rendered` | The `send` step drops `verify`, and the stub has no input loop | Build the input-consuming stub and assert a response derived from the nonce |
| `home-divergence-preseed-visible` | The pipeline ignores `env.pre_existing_tmux`, and the default script never prints the expected `trust reached` | Recreate the real HOME split and whatever consumes it |
| `stream-emit-durable-replay-live` | Step-time `emit` is unbound | Assert the actual stream contract |
| `policy-posture-survives-structural-edit` | The validator refuses its `policy` and `mutate` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Rewrite around current per-seat permission selection and an observed launch |
| `locked-restore-never-permissive` | The validator refuses its `policy` and `restore` steps at load time (`UNBOUND_STEP_VERB` — no shipped runner binding) | Keep the invariant (the selection survives a restore) and use current modes |
| `one-view-state-after-mutation-storm` | The validator rejects its bare-list `equals` (`EQUALS_NOT_DECLARATIVE`) and refuses its `mutate` steps at load time (`UNBOUND_STEP_VERB`) | Declare the comparable fields and real mutations |
| `kill-daemon-mid-handoff` | A pane `send` doesn't create the queue item the next assertion expects, and later assertions use a shape the queue reader doesn't return | Rebuild the setup before treating a red result as meaningful |
In most of these files, `seed_regression` steps that come *after* the assertions are
historical markers, not executable fault injections.
### Ordinary CLI journeys with stub seats
`packages/daemon/test/stub-cli-journeys.integration.test.ts` reuses the scenario
helpers with one private daemon and two stub rigs. It checks three contracts
through the real CLI: verified send appears in the addressed pane but not its
sibling; current-rig and explicit rig listings contain exactly the expected seats;
and a filtered stream item survives daemon restart and an idempotent emit retry.
After building, run the focused file with:
```sh
npm test -w @openrig/daemon -- test/stub-cli-journeys.integration.test.ts
```
The file also runs in the daemon package-test job. It covers these command
contracts without extending the YAML grammar. The corresponding library scenarios
above remain pending: pane rendering does not prove input consumption, durable
stream replay does not prove live subscription, and this test does not exercise
native providers, restore or policy changes.
`packages/daemon/test/rig-edge-cli.integration.test.ts` uses the same private
scenario daemon and tmux helpers for edge repair. It removes a stub seat, re-adds
its logical ID with the terminal runtime, then restores its cross-pod edges with one
`rig edge add` per edge. It checks repeat IDs and edge counts, refusal without a
write, unchanged sibling node/binding identities and pane PIDs, export,
snapshot capture and exact rollback. It models the cross-runtime replacement
workaround without invoking Claude Code or Codex. The YAML runner has no bound
edge/remove/add verbs, so this journey drives the real CLI directly:
```sh
npm test -w @openrig/daemon -- test/rig-edge-cli.integration.test.ts
```
## If your change touches an artery, add or extend a scenario (or say why not)
[arteries.md](arteries.md) lists the behaviours that everything else depends on. If your
diff can affect one, your PR description should do one of these:
- link the scenario that covers it, and say which layer you ran it in;
- add or extend a scenario. For a regression scenario, show that the fixed code passes and
that the planted old defect fails *at the intended assertion*. A setup crash or a timeout
doesn't count;
- or say precisely why the stub can't exercise it. Use the table above, which covers
message consumption, native launch and permissions, tmux reset and so on. Then say what
you ran instead, for example a manual run with the real runtime.
This is part of normal review, not an extra approval step. It also isn't a claim that
changes outside the arteries map are safe.
## Live-model evals
`packages/test-system/evals/` is a separate check from scenarios. A real seat receives a
natural prompt and decides what to do. A case passes when the seat's transcript shows the
expected `rig context get <entry>` command, in the expected order. Cases live in
`cases/selection.yaml` (10) and `cases/loading.yaml` (4).
```sh
# Harness check: a fake provider fed a JSON map of prompt -> transcript.
# Any prompt without a transcript counts as an error, never as a pass.
npm run eval -w packages/daemon -- --transcripts <transcripts.json> --out <result.json>
# Live run: drives a real Claude Code seat
npm run eval -w packages/daemon -- --provider rig --seat <session> --out <result.json>
npm run eval -w packages/daemon -- --provider rig --seat-spec "$PWD/<one-seat-rig.yaml>" --out <result.json>
```
- **Live runs use provider credits, and the results vary from run to run.** CI never runs them.
- `--provider rig` needs `OPENRIG_HOME` set, and it supports Claude Code seats only. It
refuses a Codex seat.
- `--seat-spec` runs `rig up` of a scratch rig against **your current daemon**, then
`rig down` at the end. `--seat` attaches to an existing seat and leaves it running.
- `evals/rig-seat/example-scratch-rig.yaml` sets `permission_policy: builtin:yolo` and
`cwd: "."`. It also references `local:agents/blank`, and no such agent directory ships
beside it. Read the file and expect to adapt it before you use it.
- Before any run, the runner builds the production context package in a temp directory and
refuses if any case's entry doesn't resolve in it.
- `npm test` covers the harness without a model: the case schema, the grader, the fixtures
and production entry resolution (`packages/daemon/test/eval-*.test.ts`).
## Docker testbed runbooks
`docker/testbed/runbooks/` holds manual procedures, L0 to L6, that you run on a machine with
Docker. They check the testbed image itself:
- L0: resolve the pinned base image and the stub asset list
- L1: PTY allocation
- L2: tmux server lifecycle
- L3: the daemon inside a container, settling a stub rig
- L4: the scenario environment still refuses a foreign daemon target inside a container
- L5: several containers acting as named hosts on one Docker network
- L6: the scenario runner driving a scenario through a container
Use them when you change `docker/testbed/`, `scripts/build-testbed-image.sh` or the
container staging helpers. Ordinary PRs don't need them. They were written before the CI
scenario job and contain internal planning references, so treat them as procedures to adapt,
not as current status.
## Help wanted: command families without a behavioural scenario
> This list may be stale. The tables below cover the families that have a proposed first
> check: at `b29a70f14`, the Commander tree returned by `createProgram()`
> (`packages/cli/src/index.ts`) registers 92 top-level families, 87 of which have a row
> below (7 of them marked partial); `roster` and `telemetry` now have rows too. These 5
> families still have no row: `acp`, `community-harness`, `edge`, `environment`, `exec`.
> Before picking one up, check `packages/test-system/scenarios/`, the cases in
> `scripts/run-pr-scenarios.sh`, and the current `rig --help`.
> One scenario doesn't cover a family's every option, sequence or platform. A `--help`
> check only proves the command is discoverable. Existing unit tests are not counted here.
> They exist, but this list is about behavioural scenarios.
Status today: `queue` has partial coverage, namely create, claim and list, plus the claim
surviving a restart. `down` has partial coverage in
`packages/test-system/scenarios/down-stops-every-seat.yaml`: after `rig down`, `rig ps` reports
every seat stopped and the rig still listed. `send` has partial coverage in
`send-renders-in-addressed-pane.yaml` beside it: the envelope and the message body render in
the addressed seat's pane. `transcript` has partial coverage in
`transcript-reads-addressed-seat.yaml`: each of two seats' scripted output is readable through
that seat's `rig transcript --tail 200 --json`. `capture` has partial coverage in
`capture-returns-addressed-seat.yaml`: in a rig whose member names overlap by prefix (`impl`,
`impl2`; `dev-impl`, `dev-impl2` with the pod), `rig capture <seat>` returns each seat's own
pane. The scenario catches a capture that resolves the seat by a prefix match on its member
name (so `dev-impl` also matches `dev-impl2`) and takes the first hit, or any resolver that
returns the same pane for both seats; it does not exercise a prefix match on the full seat
name. All six run with the local runner (`run-scenarios.mjs`). The transcript, capture and daemon
scenarios also run in the CI job, in passing form only; the down, send and up scenarios don't.
They do not prove that panes are gone or that a seat consumed a message. The transcript
case does not establish ordering, uniqueness, sibling-token absence, tail limits or restart
persistence. `up` has partial coverage in `up-relaunch-restores-a-two-seat-team.yaml`: a
two-seat stub team comes up, goes down, and the same spec starts a new team in place of the
stopped one with the same name, exactly two seats, both running, not archived, so a
relaunch that re-records the old seats into the team's row fails on the final `ps` counts.
It does not prove that conversations survive the round trip (that is the `--existing`
restore path), that a seat consumed a message, or that the panes are gone after `down`.
`daemon` has partial coverage in `daemon-restart-preserves-team-identity.yaml`: a two-seat
stub team is up, the runner restarts the scratch daemon, and `rig ps --json` must report the
same name still running with exactly two seats, both running, not archived, so a restart
that loses the team's row, or changes its seat counts or status, fails on the post-restart `ps`;
`rig down` then records it stopped, so the restart left a governable team. It does not prove
that queue items survive the restart (that is `queue-baton-survives-restart.yaml`), that a
kill inside a handoff window is reconciled (that is `kill-daemon-mid-handoff.yaml`), the
port-and-process half of the row, or that the panes are gone after `down`.
`compaction-restore-resumes-role` covers the compaction round-trip's success path: the
seat's launch script (delivered through `env.stub_scripts`) fires the real precompact and
restore seams in order, and impl's own pane carries the injected directive. It runs with
the local runner, not in CI (container mode refuses `env.stub_scripts` loud and named), and
its seeded wrong-seat-restore negative case is not armed yet.
Before you start, two honest constraints:
- Today's step verbs can't run arbitrary `rig` commands. Many rows below need a runner
binding first. One proposal (not implemented) is a single structured-argv step that runs
the installed CLI through the existing `rigBin`, instead of one new verb per command.
- Rows that need a seat to *answer* (anything with "nonce") depend on the input-consuming
stub described above.
- `expect` has no negative, absence or count form. `match` is a structural subset and
`contains` is a substring, so "the sibling received nothing", "the panes are gone" or "no
duplicates" can't be asserted yet. An exact field value, such as `runningCount: 0` on the
`ps` surface, is the closest available form.
- The `restart` step runs `rig launch <rig> <node> --json` and passes no other flag; the node
is the logical ID (for example `dev.qa`). A stub seat has no resume token. `rig launch` has
no `--fresh` option: a deliberate fresh start of a stub seat after `down` goes through
`rig up --existing <rig> --fresh <seat>` or `rig seat launch <seat> --fresh --reason "<why>"`, so the
`launch` row needs a runner binding to one of those first.
| Group | Family | Proposed first check (observable result) |
|---|---|---|
| lifecycle | `start` | Restore two stopped stub seats. Both answer distinct fresh nonces, and no second daemon starts. |
| lifecycle | `daemon` (partial) | Restart-preserving-the-persisted-team-row is covered by `daemon-restart-preserves-team-identity.yaml`. Next: port and process agreement through public commands, and `stop`, which needs a runner binding. |
| lifecycle | `bootstrap` | Bootstrap a minimal stub spec. Seats come up ready and the selected spec is the one used. |
| lifecycle | `up` (partial) | Relaunch-not-duplicating-identities is covered by `up-relaunch-restores-a-two-seat-team.yaml`. Next: each seat consumes its own send, which needs the input-consuming stub. |
| lifecycle | `down` | (partial) Next: its panes are gone, a sibling rig still answers, retained data follows the docs. |
| lifecycle | `create` | Create a one-seat rig through the public path. It appears and answers without a hand-written topology. |
| lifecycle | `launch` | Fresh, relaunch, resume and fork on provider-shaped fixtures. Correct restored token, one live process. |
| reads | `status` | Two rigs in different states. Status matches their exact identities and transitions. |
| reads | `ps` | Two rigs with overlapping seat names. Rig and node filters return exactly the intended members. |
| reads | `usage` | Deterministic stub telemetry. top and series respect seat and time bounds. Missing data isn't reported as zero. |
| reads | `roster` | A scratch workspace with two rigs and a rosters folder: `rig roster find` returns the expected specialists, and an empty folder reports none without an error. |
| reads | `telemetry` | Deterministic retained-metadata fixtures: one bounded page returns exactly the recorded rows, and a history gap is named rather than skipped silently. |
| reads | `health` | Known fresh, stale and cleared findings. Scope, explain and diagnosis values match the fixture. |
| continuity | `snapshot` | Snapshot two seats. Rig, membership and snapshot bytes survive a daemon restart. |
| continuity | `restore` | Restore after a daemon and tmux reset. Seat identity and nonce answers survive. |
| continuity | `agent-image` | create/show/pin/unpin/delete for a provider-shaped fixture. References and identity match. |
| continuity | `fork` | Fork at a known history token. The child keeps the prefix with its own identity, and the parent still answers. |
| continuity | `restore-check` | One restorable and one missing transcript. Readiness tells them apart without relaunching. |
| continuity | `restore-packet` | write/read/validate round trip. A malformed packet fails clearly. |
| continuity | `compact-plan` | Scripted history and usage fixtures. Candidates and exclusions match thresholds, and nothing is compacted. |
| continuity | `compact` | Input-driven compact and restore keep the role and token. Messages before and after reach the same seat. |
| continuity | `handover` | Scripted predecessor and successor. Context is delivered before the rebind, and the successor answers. |
| recovery | `crash-cart` | Stop only the scratch daemon. The output names paths and state, and the command changes nothing. |
| recovery | `preflight` | Ready and missing-dependency fixtures produce named checks without launching seats. |
| recovery | `doctor` | Healthy and damaged scratch installs. Exact diagnosis, no automatic repair. |
| recovery | `destroy` | In a disposable scenario HOME only. Removes the intended state and keeps explicitly retained markers. |
| registry | `gateway` | Add, show, update and remove a synthetic human. Reads match exact fields, and removal sticks. |
| registry | `host` | add/list/select/rename isolated entries that resolve to fixture endpoints. Pairing needs two container daemons. |
| coordination | `parked` | An owed item with a stopped worker is reported. A held worker with a live wake is distinguished. |
| coordination | `stream` | emit/list/show/archive an exact item. A restart and a bounded watch keep the order with no duplicates. |
| coordination | `queue` | (partial) handoff → successor owned by `--to` and source asserted closed (`handed-off`), block → parked blocked, resolve → unpark to in-progress (owner nudge suppressed), unclaim → pending with destination kept — each with exact items and destinations asserted via `expect queue` (`rig queue list --json --full --all`) in `queue-transitions-reach-exact-states.yaml`; baton restart coverage stays in `queue-baton-survives-restart.yaml`. |
| coordination | `project` | Acquire a lease, classify one item twice, read one projection. The wrong holder cannot act. |
| coordination | `view` | Register a view over known items. list/show return the exact filtered result after a mutation. |
| coordination | `watchdog` | Register one short wake, see one response, stop it, prove no further deliveries. |
| coordination | `workflow` | validate/compile/instantiate a tiny flow. It transitions and resumes after a restart with no duplicate queue work. |
| coordination | `chatroom` | send/history/topic/clear in a scratch room. A bounded watcher sees exactly the ordered messages. |
| coordination | `heartbeat` | Known execution-proof and missing-closure fixtures report the right status. Empty output isn't completion. |
| authoring | `export` | Export a stub rig, then validate and re-import it. Membership and configuration are equivalent. |
| authoring | `import` | Import a minimal spec, and the export matches it. Malformed input leaves state unchanged. |
| authoring | `package` | validate/plan/install/rollback in scratch. Installed files and prior state match. |
| authoring | `requirements` | Complete and missing capability fixtures produce the right named requirements. |
| authoring | `bundle` | create/inspect/install a local bundle and round-trip it. A missing asset gives a named failure. |
| authoring | `agent` | Validate a good and a malformed stub AgentSpec. The diagnostic names the bad field. |
| authoring | `spec` | validate/audit/preflight/show a minimal spec, with predictable diagnostics for a malformed field. |
| authoring | `specs` | add/show/preview/rename/remove a library fixture. A missing reference fails explicitly. |
| authoring | `workspace` | validate/doctor known roots plus one missing-frontmatter case. Reports the exact affected path. |
| authoring | `config` | set/get/reset a scratch key and init a workspace. Readback and files match the requested root. |
| presentation | `ui` | Open the URL through a fake browser executable and read a local UI route. Rendering is out of scope. |
| presentation | `tui` | Drive the TUI command bar and socket over two stub rigs. select, mutate and back show exact identities. |
| presentation | `terminal` | open/status/views against a fake terminal-app endpoint. Correct target and layout arguments. |
| adoption | `discover` | One unmanaged and one owned scratch tmux session. Only the intended candidate is discovered. |
| adoption | `attach` | Attach to the intended seat. `whoami` reports it without duplicating the process. |
| adoption | `bind` | Bind one discovered pane. A different, already-occupied identity is refused. |
| adoption | `adopt` | Adopt two scratch sessions. PIDs and history are preserved, and the identities become addressable. |
| adoption | `reconcile-session` | Rebind a hand-resumed provider-shaped fixture without starting or typing into another process. |
| adoption | `unclaim` | Release an adopted seat. The tmux process and its history stay alive. |
| adoption | `release` | Release one rig's claimed sessions. Processes survive, and a sibling rig is unchanged. |
| identity | `archive` | Archive an old same-name generation. The current one stays routable, and the old data is kept. |
| identity | `unarchive` | Unarchive a retained generation without taking over another live seat. |
| identity | `remove` | Remove an archived generation or one member. Only that identity goes, and a reused name still routes. |
| identity | `whoami` | Inside each stub seat, it returns that seat, not a sibling or the caller. |
| identity | `seat` | Status, held-message guard, stop/launch and resume token keep the right process and permissions. |
| integration | `mcp` | Serve against a scratch daemon. One listed tool's value matches CLI state. |
| integration | `slack` | Manifest and config round trip, plus deterministic inbound and outbound fixture requests. No real Slack account. |
| integration | `ask` | Search fixture transcripts via a fake model endpoint. Citations resolve to the stored lines. |
| integration | `file` | Copy a fixture payload through isolated transport endpoints. Verify bytes and paths. |
| integration | `env` | A fixture service-backed rig. status/logs/down reflect that service and stop only it. |
| delivery | `transcript` (partial) | Own scripted output from two addressed seats is covered by `transcript-reads-addressed-seat.yaml`. Remaining: ordering, uniqueness, no other seat's token, tail limits and restart persistence; absence/count assertions need a runner binding. |
| delivery | `send` | (partial) Next: send a fresh nonce and require an answer derived from it. Boot text or echo can't pass. |
| delivery | `capture` | (partial) Each seat's own token, under prefix-overlapping member names. Next: the not-found, tail (`--lines`), sibling-absence and `--rig`/`--pod` cases, which need an error form, a tail option, an absence form and a multi-target surface in the runner. |
| delivery | `broadcast` | Broadcast a nonce to a rig or pod. Each intended seat answers once, and an excluded seat gets nothing. |
| delivery | `walk` | Walk two pieces through an input-consuming stub. Order is kept, and a bad piece isn't reported as delivered. |
| context | `context` | add/get/compose/trace/profile over a fixture project. Output bytes, order and roots match. |
| context | `plugin` | list/show/used-by/validate a fixture plugin. A missing resource is not the same as a valid empty selection. |
| context | `skill` | Resolve and audit a fixture skill, then inspect the projection the consumer actually gets. |
| context | `mode` | set/effective/cite/unset at nested scopes. Precedence holds, and the inherited value returns after unset. |
| context | `startup-proof` | Submit a proof from a real stub identity. Check the stored attribution and the duplicate behaviour. |
| permissions | `policy` | Apply and read an explicit permission selection using a provider-shaped fixture. It survives a restore, and the default is unchanged. |
| accounts | `auth` | Synthetic auth files in scratch: save, list, validate, switch. No provider contact, no real credentials. |
| accounts | `provider` | Synthetic account, binding and signal state. The precheck refuses where it should, and a switch keeps seat history. |
| topology | `expand` | Add a pod to a live stub rig. Old seats survive, and new members answer nonces. |
| topology | `add` | Add one seat. Exact binding, siblings preserved, and the duplicate-name behaviour is explicit. |
| topology | `grow` | Grow two seats. Identities are unique, config is inherited, and existing history is kept. |
| topology | `shrink` | Remove a pod. Unrelated seats still answer, and the removed membership is gone. |
| onboarding | `setup` | Scripted newcomer answers against synthetic providers. No install or login for an unused provider. |
| work | `scope` | Create a mission and a slice. move/progress/notes/graph readback keep identity and paths. |
| work | `proof` | add/judge/show evidence in a fixture scope. Readiness updates without overwriting earlier proof. |
How to make a case useful:
- Assert actual state or content, not just exit code 0. Where ordinary use includes bad
input, add an invalid or missing input case too.
- Reuse the existing runner and the installed CLI. Don't add a second scenario engine.
- If a scenario exposes an existing product bug, keep the failing case and open an issue.
Include the exact source revision, the shortest failing sequence, and the intended versus
observed result. Don't weaken the assertion to make it pass. Keep "the harness setup
failed" separate from "the product misbehaved".
- A check that only covers the success path is still useful. Just don't call it a
regression detector until you have seen its negative case fail.
## Existing docs in this area that are out of date
Read these with care. This page notes them and proposes no changes.
- The headers of `transcript-reads-addressed-seat.yaml` and `capture-returns-addressed-seat.yaml`
say "host mode only". Both run in the CI container job, which executes the host-mode pipeline
inside the image.
- `packages/test-system/scripts/README.md` and `packages/test-system/ROUTING.md` are
dependency-routing notes from when the scenarios were first written. Several of the items
they list have since landed. Treat them as history.
- `docker/testbed/runbooks/`: see above. They are manual procedures that predate the CI job.
+230 -36
View File
@@ -1,44 +1,238 @@
---
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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# OpenRig UI As-Built
# ui.md was reorganized into the modular as-built tree
This document describes the current UI architecture and component system. The
brand/design rules live in `DESIGN.md`; this file focuses on how the shipped UI
is assembled.
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.
## Package Shape
**Start here:**
UI package: `packages/ui`
- [`./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.
Primary implementation areas:
## Where the major content went
- `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
The UI content is now the **4 modules under `ui/`**:
## Shell Model
| 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`. |
`AppShell` wraps every route with:
The brand/design rules still live at `docs/DESIGN.md` (repo `docs/` root, by
design — see `ui/library-specs-and-design-system.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.
@@ -1,151 +0,0 @@
---
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 flows that feed it, and
how seat details open — 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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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 `e8f0ab34`; package version 0.6.7. Component and
> primitive names below are reconciled against `packages/ui/src/components/`,
> not taken on trust from `DESIGN.md`'s lists. The web UI is in maintenance
> mode (`docs/reference/developing.md`).
## 1. Library / specs surfaces
Components in `packages/ui/src/components/specs/`:
- `SpecsLibraryPage.tsx` — the `/specs` Library page (also mounted at
`/specs/applications`): a sectioned grid of Rig Specs, Workspace Specs
(placeholder), Workflow Specs, Context Packs, Agent Specs, Agent Images
and Applications, then full-width Plugins and Skills sections.
Service-backed rigs (`hasServices`) appear under Applications, not Rig
Specs.
- `SpecsTreeView.tsx` — the Explorer tree. (`SpecsTable.tsx` is still in
the folder but nothing imports it.)
- `SkillsIndexPage.tsx`, `SkillDetailPage.tsx` — skills. The Explorer
groups skills into category folders; each skill is a single row, not
expandable into files. Opening a skill goes to a detail route that
defaults to `SKILL.md` (case-insensitive) and renders its own docs
browser: a file tree with breadcrumbs beside a viewer, `MarkdownViewer`
for `.md`/`.mdx`, `SyntaxHighlight` for other text, preformatted text
otherwise. Files come from `/api/skills/:id/files/list` and `/files/read`
(`hooks/useSkillFiles.ts`).
- `PluginsIndexPage.tsx`, `PluginDetailPage.tsx`, `AgentPluginsList.tsx` —
the plugin surfaces.
Library routes: `/specs`, `/specs/applications`, `/specs/skills`,
`/specs/skills/$skillToken`, `/specs/skills/$skillToken/file/$fileToken`,
`/specs/plugins`, `/plugins/$pluginId`, `/specs/library/$entryId` (see
[`shell-and-routing.md`](shell-and-routing.md) §3).
> The plugin primitive is a **0.3.1** feature (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.
## 2. Spec review / library flows and seat details
These daemon-backed flows feed the Library UI:
- **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 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`. Each list request also
merges workflow entries from the workflow-spec cache.
`/api/specs/library` serves list, get, review and sync, plus
`DELETE /:id`, `POST /:id/rename` and GET/POST/DELETE `/active-lens`.
On a non-local host, list and review reads carry `?host=<id>`; the
active lens stays local.
- **CLI mirror:** `rig specs ls/show/preview/add/sync/remove/rename`;
`rig specs add` installs a single YAML spec or a full spec directory;
`rig up` / `rig bootstrap` resolve name-shaped sources through the
library.
- **Seat details:** graph, tree and table clicks navigate to
`/topology/seat/$rigId/$logicalId`, which renders `LiveNodeDetails`
(Overview and Details tabs). The shared drawer no longer has a
seat-detail kind; its kinds are system, log, status, discovery, qitem,
file and sub-spec. The legacy `/rigs/$rigId/nodes/$logicalId` route also
renders `LiveNodeDetails`, but nothing in the UI links to it.
> Source: `packages/daemon/src/routes/spec-library.ts`;
> `packages/ui/src/hooks/useSpecLibrary.ts`; `packages/cli/src/commands/specs.ts`
> (`remove`, `rename`); `packages/ui/src/components/SharedDetailDrawer.tsx`
> (viewer kinds; seat-detail retired).
## 3. UI primitive inventory (reconciled against source)
- **Vellum/base** (`components/ui/`): `VellumCard`, `VellumSheet`,
`RegistrationMarks`, `StatusPip`, `SectionHeader`, `EmptyState`,
`Button`, `Tabs`, `Table`, form controls, plus `rig-stamp.tsx`
(`RigStamp`).
- **Graphics** (`components/graphics/RuntimeMark.tsx`): `RuntimeMark`,
`RuntimeBadge`, `ToolMark`, `ToolBadge`, `ActorMark`,
`OperatorMoodMark`; normalization in `lib/runtime-brand.ts` +
`lib/tool-brand.ts`. Runtime brands are Claude Code, Codex, Pi ("Pi"),
OMP ("Oh My Pi", short "OMP"), terminal and unknown; `ActorMark` reuses
the runtime marks for recognized runtimes.
- **Project metadata** (`components/project/ProjectMetaPrimitives.tsx`):
`ProjectPill` (`:199`), `EventBadge` (`:227`), `QueueStateBadge` (`:231`),
`TagPill` (`:277`), `ActorChip` (`:286`), `DateChip` (`:306`),
`FlowChips` (`:318`), `ProofThumbnailGrid` (`:337`), `ProofPacketHeader`
(`:383`) — plus `QueueCountIcon` / `StatusDot`.
- **Viewers**: `FileViewer` (`components/drawer-viewers/FileViewer.tsx`),
`MarkdownViewer` (`components/markdown/MarkdownViewer.tsx`),
`ProofImageViewer` and `Lightbox` (`components/project/`),
`SessionPreviewPane` (`components/preview/`).
- **Themes** (`lib/theme.ts`): Vellum Light, Vellum Dark and System
(default `system`, following the OS colour scheme), stored under the
`openrig.theme` localStorage key. `applyTheme` toggles a `.dark` class
on `<html>`, which switches to the `.dark` token block in `globals.css`.
`ThemeProvider` is mounted in `main.tsx`, `ThemeSelector` in the
AppShell top bar, and a pre-paint script in `index.html` mirrors the
storage key.
## 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 (the as-built reorganization left it 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: every primitive DESIGN.md names (Vellum, Graphics,
> Project-metadata, Topology, Preview) resolves to a real export at the
> stamped commit. 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}*`.
-380
View File
@@ -1,380 +0,0 @@
---
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, attention merge, verb
actions), the Project workspace/mission/slice scope pages (tabbed
rollups), and the Dashboard launcher. The web UI is in maintenance mode;
claims are sourced to file and symbol (and line where verified) at the
stamped commit.
siblings: [shell-and-routing.md, ../architecture/mission-control.md]
prerequisite-reads: [../README.md, shell-and-routing.md]
last-verified-against-source: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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 launcher). All read live
daemon state. For You persists two dismissal sets in localStorage; shared
shell preferences include the theme choice (see
[`library-specs-and-design-system.md`](library-specs-and-design-system.md))
and the maintenance-notice dismissal (see
[`shell-and-routing.md`](shell-and-routing.md)).
> Paths are relative to `packages/ui/src/` unless prefixed `docs/` or
> `packages/`. Verified at `e8f0ab34` (package version 0.6.7). The web UI
> is in maintenance mode (`docs/reference/developing.md`); this page
> records what ships, not a roadmap.
## 0. Release attribution (forensic seam)
Checked via `git cat-file -e <tag>:<path>`:
| Layer | Release | Forensic proof |
|---|---|---|
| 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` |
| Vellum **brand-identity system** (`dashboard/vellum/*`: CornerBracket, VellumDestinationCard, marks, graphics, barrel) | **0.3.1, NOT 0.3.0** | `git cat-file -e v0.3.0:packages/ui/src/components/dashboard/vellum/index.ts` fails; `v0.3.1:` resolves |
Vellum surface *primitives* (`components/ui/vellum-*.tsx`) shipped 0.3.0;
the brand system under `dashboard/vellum/` is 0.3.1. Do not back-attribute
the brand system to 0.3.0.
## 1. Route reality
Verified at `routes.tsx` (605 lines):
| Path | Reality | Component | Source |
|---|---|---|---|
| `/` (index) | destination route | `Dashboard` | `routes.tsx:96-100` |
| `/for-you` | destination route | `Feed` | `routes.tsx:130-134` |
| `/project` | destination route | `WorkspaceScopePage` | `routes.tsx:136-140` |
| `/project/mission/$missionId` | destination route | `MissionScopePage` | `routes.tsx:142-146` |
| `/project/slice/$sliceId` | destination route | `SliceScopePage` | `routes.tsx:148-152` |
| `/mission-control` | redirect stub to `/for-you` | `() => <Navigate to="/for-you">` | `routes.tsx:496-500` |
| `/slices` | redirect stub to `/project` | `() => <Navigate to="/project">` | `routes.tsx:503-507` |
| `/slices/$name` | redirect stub to `/project/slice/$sliceId` | `useParams` → `<Navigate>` | `routes.tsx:509-516` |
| `/progress` | redirect stub to `/project` | `() => <Navigate to="/project">` | `routes.tsx:519-523` |
| `/steering` | redirect stub to `/project` | `() => <Navigate to="/project">` | `routes.tsx:526-530` |
| `/markdown` | **not a route** — `MarkdownViewer` is a component | (no route) | no `path: "/markdown"` in `routes.tsx` |
> Source: component imports `routes.tsx:42-43` (`Dashboard`, `Feed`),
> `:61-65` (`WorkspaceScopePage`/`MissionScopePage`/`SliceScopePage` from
> `components/project/ScopePages.js`).
The review altitudes `/agents` (`RigAgentsPage`) and `/fleet` (`FleetPage`)
are listed in [`shell-and-routing.md`](shell-and-routing.md).
> **Route-reality contract:** the **Mission Control *system*** lives in the
> daemon (see `../architecture/mission-control.md`); `/mission-control` is
> a redirect stub, not a UI destination. The 7-verb *action vocabulary*
> surfaces here via `VerbActions` in For-You cards. `/progress`, `/slices`
> and `/steering` fold into `/project` tabs.
## 2. For You — the attention feed (`/for-you` → `Feed`)
`Feed` is the operator's attention surface: decision cards first, then the
rest newest-first, subscription- and lens-filtered and soft-dismissable.
Header reads `Attention` / `For You`; max width 720px. A plain-language
`LevelControl` (All activity / Highlights / Needs you) sits at the top of
the feed over the same subscription toggles.
> Source: `components/for-you/Feed.tsx` (`data-testid="for-you-feed"`,
> max-w-720 header; `LevelControl` in `feed-level-control`; design note
> `:3-12`); `lib/feed-levels.ts` `FEED_LEVEL_LABELS`.
### 2.1 The 5-card classifier (0.3.0 spine)
`classifyFeed(events)` maps every `ActivityEvent` to one of five
`FeedCardKind` values — `action-required`, `approval`, `shipped`,
`progress`, `observation` — sorted by `receivedAt` descending. **Nothing is
silently dropped**: an unmatched event type falls through to
`observation`. Queue events are sub-classified by
`queueKind(type, state, tier)`: `*.closed` → shipped;
`qitem.closure_overdue` / `inbox.denied` → action-required; tier
`human-gate` → **approval**; state `pending-approval` → action-required;
`closeout-pending-ratify` → approval; `done|closed|completed|shipped` →
shipped; otherwise progress. A human-seat destination forces
`action-required` unless the card is already an approval.
> Source: `lib/feed-classifier.ts` — `FeedCardKind` `:10-15`, `queueKind`
> `:105-129`, `isHumanSeat`, `classifyEvent` (approval-preserving
> human-seat override; default-observation fallthrough), `classifyFeed`,
> `sortFeedByDecisionBand`.
### 2.2 The feed pipeline
1. **Event cards:** `classifyFeed(events)` capped at `HISTORY_LIMIT = 50`.
2. **Attention merge:** daemon attention rows (`useAttentionItems(50, …)`:
`GET /api/queue/list?attention=1…`, or `GET /api/queue/attention-aggregate`
when a remote host subscription is on) and needs-input seats
(`useNeedsInputSeats`) become cards, and `mergeAttentionIntoFeed` lets a
queue-derived card supersede a matching event-derived **action-required
or approval** card; shipped, progress and observation event cards for the
same qitem remain.
3. **Decision-band sort:** `sortFeedByDecisionBand` lifts every
action-required and approval card above the rest, newest-first within
each band.
4. **Hydration:** `hydratedCardKind` re-reads each card against the live
queue item and the action audit: a recorded outcome on an
action/approval card → `approval`; tier `human-gate` → `approval`; a
`done|closed|completed` item → `shipped`; a human-seat destination →
`action-required`.
5. **Filters, in order:** subscription (`isCardKindSubscribed`;
`action-required` always visible, `observation` only with the audit
subscription), the transient lens chips (All / Action req / Approvals /
Shipped / Progress / Audit), a per-host filter, then dismissal.
Event cards dismiss by event seq (`forYou.dismissedSeqs`); queue-derived
cards dismiss by card id (`forYou.dismissedCardIds`); both are
localStorage-backed.
> Source: `components/for-you/Feed.tsx` — `LENS_CHIPS` `:60-67`,
> `HISTORY_LIMIT` `:69`, `hydratedCardKind` `:167-186`, attention merge and
> sort `:244-276`, dismiss hooks `:285`/`:294`, filter pipeline `:334-356`;
> `hooks/useAttentionItems.ts`, `hooks/useNeedsInputSeats.ts`,
> `lib/attention-feed.ts` (`mergeAttentionIntoFeed`, `attentionKindFor`),
> `hooks/useDismissedSeqs.ts`, `hooks/useDismissedCardIds.ts`.
With one or more remote host subscriptions enabled, the feed shows host
chips and status rows, cards carry a host chip, and verbs forward the
card's `hostId` (`Feed.tsx` host filter; `hooks/useFeedSubscriptions.ts`
key `feed.subscriptions.<hostId>.enabled`; `VerbActions.tsx`).
### 2.3 Queue-item hydration + proof previews
For cards carrying a `qitemId`, `useQueueItemMap` hydrates the queue item;
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`.
> Source: `components/for-you/Feed.tsx` (`useQueueItemMap`,
> `sliceForCard`, `proofPreviewForSlice`); `FeedCard.tsx` proof block and
> `<ProofImageViewer>`.
### 2.4 Verb actions on action/approval cards
The **canonical Mission Control action vocabulary is the 7-verb system** —
`MISSION_CONTROL_VERBS = [approve, deny, route, annotate, hold, drop,
handoff]`. `VerbActions` defaults to all seven
(`enabledVerbs = [...MISSION_CONTROL_VERBS]`); route/handoff need a
destination, annotate an annotation, hold/drop a reason.
> Source: `components/mission-control/hooks/useMissionControlAction.ts:5-15`;
> `components/mission-control/components/VerbActions.tsx` (`enabledVerbs`
> default `:108`).
**The For-You actionable-card surface is bare one-tap APPROVE + CHAT.**
`FeedCard` passes `bare`, `enabledVerbs={["approve"]}` and
`oneClickVerbs={["approve"]}`, beside a CHAT button that opens the shared
`ProgressiveTerminal` seeded via `buildChatPreamble` (human-action cards
chat with the sender). Deny/route are not offered here, and the
action-required empty state reads "one-tap approve and chat with the
owning agent". The 7-verb vocabulary remains the Mission Control
system-level vocabulary.
> Source: `components/for-you/FeedCard.tsx:558-570` (`bare` `:561`,
> `enabledVerbs` `:563`, `oneClickVerbs` `:564`),
> `resolveCardTerminalSession`; `Feed.tsx:76-82` (`EMPTY_COPY`);
> `review/chat.ts:21` (`buildChatPreamble`); test
> `test/foryou-bare-approve-chat.test.tsx`.
On mutation success `VerbActions` fires `onOptimisticOutcome`; `Feed`
keeps an optimistic-outcome map keyed by `qitemId` so the
`ActionOutcomePanel` receipt renders before the audit re-fetch. A terminal
qitem with no recorded outcome derives a fallback receipt from its closure
reason. Card footers also carry a "show context" queue-item trigger, a
terminal drill, the evidence reference, and a "review →" link to the
slice's Review tab.
> Source: `Feed.tsx` (optimistic map; optimistic-first lookup);
> `FeedCard.tsx` (`isActionableCard`, `fallbackOutcomeFromQueueItem`,
> `ActionOutcomePanel`, `QueueItemTrigger`, `FeedCardTerminalDrill`).
### 2.5 Card surface (0.3.1 brand layer)
`FeedCard` renders a `bg-surface-low/45 backdrop-blur-[10px]` surface with
a 3-stop ambient box-shadow (no border), four `CornerBracket` marks, a
mono-uppercase kind tag with a tone-coloured icon (a dot only when a kind
has no icon), and `ActorMark` runtime marks. Cards dismiss by keyboard
(Backspace/Delete) or swipe, with an `UndoToast`.
> Source: `components/for-you/FeedCard.tsx` — `CARD_SURFACE_CLASS`
> `:108-109`, `CARD_SHADOW_STYLE`, `KIND_TOKEN`, corner brackets
> `:452-455`, `ActorMark` `:265`; `CornerBracket` from
> `dashboard/vellum/index.ts`.
The 0.3.1 storytelling preview band is **gone**: `Feed.tsx:375-380` records
its removal. The `components/feed/cards/storytelling-cards.tsx` primitives
remain for the `/lab/card-previews` gallery. No UI code calls
`POST /api/missions/:id/complete` any more; the daemon route still exists
(`packages/daemon/src/routes/missions.ts`).
## 3. Project — workspace / mission / slice scope pages (0.3.0 spine)
`/project*` mounts three scope pages on a shared `ScopeShell` +
`TabNav` tabbed pattern. Tab sets:
| Page | Tabs | Default |
|---|---|---|
| `WorkspaceScopePage` | `SHARED_TABS`: Overview, Story, Progress, Artifacts, Proof, Queue, Workflow (`topology`) | `overview` |
| `MissionScopePage` | `MISSION_TABS`: Overview, Steering, Review, Story, Progress, Artifacts, Proof, Queue, Workflow | `steering` |
| `SliceScopePage` | `SLICE_TABS`: Review, Story, Overview, Progress, Artifacts, Proof, Queue, Workflow | `review` |
> Source: `components/project/ScopePages.tsx` — `SharedTab`/`SliceTab`
> `:83-84`, `SHARED_TABS` `:86-94`, `MISSION_TABS` `:98-110`, `SLICE_TABS`
> `:112-123`, `TabNav`, `ScopeShell`; defaults `useState` at `:698`,
> `:798`, `:1226`.
Off the local host, the workspace and mission headers show an `ON <host>`
chip (the slice header does not), the Review tab is local-only, and
file-backed sections explain why local files are not shown.
### 3.1 WorkspaceScopePage (`/project`)
Reads the live workspace name; honest empty state (`NO WORKSPACE
CONNECTED` with an Open-settings action) when unset. Tabs: Overview →
`WorkspacePortfolioPanel`; Story/Progress/Artifacts/Proof/Queue → the
scope rollups (§3.4); Workflow → `HostMultiRigGraph`.
(`WorkspaceOverviewPanel`, the older Current Work / Archive grid, is still
defined but not rendered.)
> Source: `ScopePages.tsx` `WorkspaceScopePage` (no-workspace guard;
> `WorkspacePortfolioPanel` `:745`; `HostMultiRigGraph` `:788`).
### 3.2 MissionScopePage (`/project/mission/$missionId`)
Lands on **Steering** (`SteeringTab`). Review → `MissionReviewTab` (§3.5).
Overview renders the mission README (`MarkdownViewer`) above a slice rail.
Progress renders `MissionProgressHeatmap` plus the mission `PROGRESS.md`
(the per-slice rollup cards were removed). Artifacts → `ArtifactsNavigator`;
Proof → `ScopeProofRollup`. Workflow renders the **projected workflow
spec graph** via `TopologyTab` when the mission README declares a
`workflow_spec` that is in the workflow-spec cache (the daemon returns
`topology.specGraph`), otherwise `ScopeTopologyRollup`.
> Source: `ScopePages.tsx` `MissionScopePage` (`SteeringTab` `:834`,
> `MissionReviewTab` `:843`, `MissionProgressHeatmap` `:906`,
> rollup-cards removal note `:979-983`, `ArtifactsNavigator` `:990`,
> spec-graph branch `:1010-1036`).
### 3.3 SliceScopePage (`/project/slice/$sliceId`)
Lands on **Review** (`SliceReviewTab`, §3.5). Overview →
`SliceOverviewTab` (a 4-metric summary — status, progress, qitems, last
activity — then the README). Story → `ScopeStoryRollup` (the queue-lineage
story graph). Progress → `AcceptanceTab`. Artifacts → `ArtifactsNavigator`;
Proof → `SliceProofTab`; Queue → `SliceQueueTab`; Workflow → `TopologyTab`.
Loading and error states are honest (the error names
`rig config get workspace.slices_root` as the likely misconfiguration).
> Source: `ScopePages.tsx` `SliceScopePage` (`SliceReviewTab` `:1307`,
> `ScopeStoryRollup` `:1317`, `SliceOverviewTab` `:1325`, `AcceptanceTab`
> `:1354`, `ArtifactsNavigator` `:1363`, `SliceProofTab` `:1366`,
> `TopologyTab` `:1379`, slices_root hint `:1276`), `SliceOverviewTab`
> summary grid `:1157-1162`.
### 3.4 Shared scope rollups
`ScopeStoryRollup`, `ScopeProgressRollup`, `ScopeArtifactsRollup`,
`ScopeQueueRollup` and `ScopeTopologyRollup` read from one
`useProjectScopeRollup(missionId, loadDetails)` hook so workspace and
mission scopes share rollup behavior; `ScopeProofRollup`
(`project/ProofTab.tsx`) covers Proof. Detail fetching is gated on the
active tab (`active !== "overview"` for the workspace;
`active !== "overview" && active !== "steering"` for a mission).
> Source: `ScopePages.tsx` `useProjectScopeRollup`, rollup components,
> gates `:700`, `:803`.
### 3.5 Review tabs (the Living Notes surface)
The slice tab (`review/SliceReviewTab.tsx`) renders one reviewable
structure per slice — bands in order **NEEDS YOU → AGENTS → INTENT / PLAN
/ DELIVERED → verify-lineage → SETTLED** — from `GET /api/review/slice/:name`
(`hooks/useReview.ts`), with a proof-readiness status line and a defects
box above NEEDS YOU:
- **DELIVERED** pairs each planned deliverable with its curated proof,
media inline and expanding one at a time (`?item=` deep link). Without a
configured readiness policy, `verified` renders as `✓ legacy QA-verified
(item revision unbound)` · `◇ unverified — no PASSING QA comparison` ·
`✗ missing — promised, nothing delivered`; with one, as `✓ accepted under
selected policy` / `◇ current acceptance absent`. A note renders as
"Judgment note:". Visible, never blocking. "See all proof" drills into
`proof/`.
- **Media plays**: video inline with native controls (`?seek`/`?play` deep
links); images open `Lightbox`; evidence and the "full PRD →" door open
`FileViewer` in the shared right-edge drawer.
- **Two locks** render as stamps: plan-lock in PLAN, both stamps in
SETTLED; an unaudited stamp renders as UNVERIFIED.
- Review cards share one vellum recipe (`review/vellum.ts`); DELIVERED rows
stack below the `sm` breakpoint.
- The mission tab (`review/MissionReviewTab.tsx`) is board-first: stage
cells, the completion ledger, cut-complete, and a row expansion reading
the same contract. The rig altitude (`review/RigAgentsPage.tsx`, route
`/agents`) reads `GET /api/review/rig`.
Backend contract: see
[`../architecture/living-notes-review.md`](../architecture/living-notes-review.md).
> Source: `components/review/{SliceReviewTab,MissionReviewTab,
> NeedsYouAccordion,AgentsBandView,VerifyLineageCard,EvidenceOpener,
> RigAgentsPage}.tsx`, `review/vellum.ts`, `hooks/useReview.ts`,
> `SharedDetailDrawer.tsx`; verified labels `SliceReviewTab.tsx:131-135`,
> `:164`, `:194`.
## 4. Dashboard — the launcher (`/` → `Dashboard`)
`Dashboard` is the paper-draft launcher (OPR.0.4.1.14). It renders, in
order: a header with the station online state; `FieldEnvironment` (live
rows: RIGS from `useRigSummary`, AGENTS as the sum of `nodeCount` from
`usePsEntries`, STATION from `window.location.hostname`, OPERATOR ID from
the `agents.operator_session` setting with an `OPERATOR` fallback, and
VERSION from `useDaemonVersion` via `/api/health-summary/version`);
`KernelStatusCard` (from `/api/kernel/status`); `HostConfigCard`; a grid of
six destinations (Topology, Project, For You, Library, Search & Audit,
Settings); and `DashboardFooter`. Its glyphs come from
`dashboard/vellum/fidelity-glyphs.js`.
The kernel card renders `kernel_state: "skipped"` as a neutral **boot not
tracked** state. Built-in boot can be skipped because a kernel rig is already
managed or because automatic boot was disabled; the served reason remains
visible. Ready-agent counts do not promote this state to healthy. An unavailable
tracker or a malformed status envelope remains **unknown**. Recovery still uses
the existing launch-plan and daemon restore guards.
The vellum barrel `dashboard/vellum/index.ts` still exports
`BackLayerContent` and `BackVellumSheet`; `/lab/vellum-lab`
(`lab/VellumLab.tsx`) imports from it.
> Source: `components/dashboard/Dashboard.tsx` (header comment `:1-24`,
> glyph import, `DESTINATIONS`, data wiring, render order);
> `hooks/useDaemonVersion.ts`; `components/dashboard/vellum/index.ts:5-6`;
> `components/KernelStatusCard.tsx`; `components/RigStatusCard.tsx`.
## 5. Cross-cutting properties
- **Live daemon state** — every surface reads daemon hooks. UI-local state
on these surfaces is the two For You dismissal sets (localStorage), the
lens, host filter and optimistic-outcome map (transient), and each scope
page's tab state.
- **Honest empty/error states** — `WorkspaceScopePage` no-workspace and
`SliceScopePage` not-available surface the cause and a remediation
pointer, never a blank screen.
- **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 is the 0.3.1 brand layer and later refreshes.
## OPEN items (carried, not smoothed)
- **OPEN — `/project` redirect-stub consolidation.** `/progress`,
`/slices`, `/steering` are `<Navigate to="/project">` stubs and
`/slices/$name` redirects into `/project/slice/$sliceId` (§1). Whether
the stubs are permanent or transitional is a product decision not
resolvable from source.
-202
View File
@@ -1,202 +0,0 @@
---
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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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 `e8f0ab34`; package version 0.6.7. UI
> footprint is **304** source files (`packages/ui/src`, `.ts`+`.tsx`,
> non-test; 235 at the previous stamp `7eaf524c`). The web UI is in
> maintenance mode (`docs/reference/developing.md`).
## 1. Package shape
UI package: `packages/ui`. Primary implementation areas:
- `src/routes.tsx` — TanStack Router route tree.
- `src/App.tsx`, `src/main.tsx` — app root (`ThemeProvider`, the
maintenance notice, the router).
- `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/review/`, `workflow/` — review altitudes (`/agents`,
`/fleet`) and workflow pages.
- `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
The root route wraps `AppShell` in `QueryClientProvider` and
`DaemonHealthProvider` (`routes.tsx:80-90`). Above the router, `App.tsx`
renders a dismissible `UiMaintenanceNotice` (localStorage key
`openrig.uiMaintenanceNoticeDismissed`); `main.tsx` mounts `ThemeProvider`.
`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 / specs-workspace
contexts. The top bar's right slot holds `HostIndicator` and
`ThemeSelector`. When the shared event connection is down, a "Live updates
unavailable…" status line shows. Switching the selected host clears the
discovery placement (`useClearPlacementOnHostSwitch`). Below the `lg`
breakpoint a 3-slot `MobileBottomNav` (For You / Project / Topology) also
shows.
The rail destinations (`AppShell.tsx` `RAIL_ICONS`) 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
> The route table below is derived from `packages/ui/src/routes.tsx` at
> `e8f0ab34` (605 lines). Each line number is the route's
> `createRoute(` line. The exported route tree is assembled at
> `routes.tsx:539` (`export const routeTree = rootRoute.addChildren([`);
> it holds 54 routes.
>
> `/mission-control` and `/progress` are **redirect stubs**, not
> destination routes, and there is **no `/markdown` route** (markdown is
> rendered by the `MarkdownViewer` component). There is a real `/files`
> route.
### Primary destination routes
| Path | Component | Notes |
|---|---|---|
| `/` | Dashboard (`indexRoute` `routes.tsx:96`) | rail destination |
| `/topology` | host topology (`:106`) | + rig/pod/seat scopes (see `topology.md`) |
| `/topology/rig/$rigId` | rig scope (`:112`) | |
| `/topology/pod/$rigId/$podName` | pod scope (`:118`) | |
| `/topology/seat/$rigId/$logicalId` | seat scope (`:124`) | live node details |
| `/for-you` | attention feed (`:130`) | rail destination |
| `/project` | workspace project scope (`:136`) | rail destination |
| `/project/mission/$missionId` | mission scope (`:142`) | |
| `/project/slice/$sliceId` | slice scope (`:148`) | |
| `/specs` | Library (`:202`) | rail destination |
| `/specs/applications` | applications section (`:208`) | |
| `/specs/skills` | skills index (`:216`) | |
| `/specs/skills/$skillToken` | skill viewer (`:222`) | defaults to `SKILL.md` |
| `/specs/skills/$skillToken/file/$fileToken` | skill file viewer (`:231`) | |
| `/specs/plugins` | plugins index (`:242`) | 0.3.1 (see `library-specs…`) |
| `/plugins/$pluginId` | plugin detail (`:257`) | 0.3.1 |
| `/specs/$specKind/$specName` | generic spec → library redirect (`:269`) | |
| `/files` | Files workspace (`:248`) | |
| `/settings` | settings center (`:278`) | rail destination |
| `/settings/policies` | Policies (`:288`) | Claude-compaction form |
| `/settings/log` | Log (`:293`) | 4-item Settings explorer |
| `/settings/status` | Status (`:298`) | |
| `/search` | audit/history view (`:304`) | |
### Zoom-addressed routes (no rail entry)
Reached from other views and deep links, deliberately not on the rail:
| Path | Component | Notes |
|---|---|---|
| `/agents` | `RigAgentsPage` (`:159`) | rig review altitude; separate from legacy `/agents/validate` |
| `/fleet` | `FleetPage` (`:171`) | fleet attention altitude above host |
| `/workflows` | `WorkflowsPage` (`:181`) | |
| `/workflow/instance/$instanceId` | `WorkflowInstancePage` (`:187`) | optional `?step=<id>` anchor |
### Lab routes (design experiments)
`/lab/project-graphics-preview` (`:310`), `/lab/card-previews` (`:318`),
`/lab/vellum-lab` (`:328`), `/lab/vellum-bg/{a-large,b-small,c-allover}`
(`:339`–`:349`). The vellum-lab + vellum-bg routes are the 0.3.1 vellum
brand-system experiment surface (the `dashboard/vellum/*` system is 0.3.1).
### Legacy / compatibility routes
`/rigs/$rigId` (`:374`), `/rigs/$rigId/nodes/$logicalId` (`:380`),
`/import` (`:389`), `/packages`, `/packages/install`, `/packages/$packageId`
(`:395`–`:407`), `/bootstrap` (`:413`), `/agents/validate` (`:419`),
`/specs/rig` + `/specs/agent` review (`:428`/`:434`),
`/specs/library/$entryId` (`:440`), `/discovery` + `/discovery/inventory`
(`:451`/`:466`), `/bundles/inspect` + `/bundles/install` (`:472`/`:478`).
### Redirect stubs (deleted routes)
`routes.tsx:488`–`:530` mount `<Navigate>` redirects for removed routes —
NOT destination pages: `/context` → `/topology`; `/mission-control` →
`/for-you` (For-You replaces it); `/slices` → `/project`; `/slices/$name` →
`/project/slice/$sliceId` (with `sliceId` set to the name); `/progress` →
`/project`; `/steering` → `/project`. The Mission Control *system* still
exists in the daemon (`../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 (`FileViewer`), subspec preview, and other detail
selections. Drawer triggers carry root/path provenance so the viewer
fetches and renders the correct content; the drawer supports outside-click
dismissal.
Image proof viewing: For-You cards use `ProofImageViewer`
(`components/project/ProofImageViewer.tsx`), which stays inside the shell
layout. The Project Proof tab and the Review surfaces use the full-viewport
`Lightbox` (`components/project/Lightbox.tsx`; mounted in
`project/ProofTab.tsx`, `review/EvidenceOpener.tsx`,
`review/SliceReviewTab.tsx`).
Skill detail (`components/specs/SkillDetailPage.tsx`) renders its own
two-pane docs browser — a file tree beside a viewer — and draws content
with `MarkdownViewer` (`.md`/`.mdx`) or `SyntaxHighlight` (other text). It
does not use `FileViewer`, which only the shared drawer renders.
## 5. Event and activity consumption
UI event consumption is centralized through one shared `/api/events`
connection (`lib/topology-events.ts`) so related surfaces avoid duplicate
SSE connections. Current consumers: For You feed hydration, topology
activity rings + hot-potato movement, the activity feed/system surfaces,
and rig event surfaces. That connection carries stored events only:
`view.changed` is delivered on the live view streams (`/api/views/sse`) and
is not stored, so it no longer appears in the activity feed, as a For You
observation card or in the telemetry export (entries stored by older
versions still replay until an events retention, a separate change, removes them). `useGlobalEvents` reports whether that connection
is up and refetches review, slice and mission data on reconnect and on
`proof.*` events. Reduced-motion preference is honoured for pulse/packet
animation. (Daemon SSE surfaces: `/api/events`, `/api/stream/watch`,
`/api/queue/watch` —
[`../architecture/architecture-rules-and-event-system.md`](../architecture/architecture-rules-and-event-system.md)
§2, "Emission and delivery".)
## 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`.
-144
View File
@@ -1,144 +0,0 @@
---
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 rig/pod graphs, the table/terminal views, the activity-ring /
hot-potato visual language, terminal-preview popovers and launchers, 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: e8f0ab340db773392ec8be75b072d1c0f3068a50
last-updated: 2026-10-08
---
# 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 `e8f0ab34` (package version 0.6.7). The web UI
> is in maintenance mode (`docs/reference/developing.md`). The source file
> that defines the hybrid nodes is `HybridTopologyNodes.tsx` (DESIGN.md's
> implementation reference).
## 1. Topology pieces
> `HybridAgentNode` and `HybridPodGroupNode` are exports inside
> `packages/ui/src/components/topology/HybridTopologyNodes.tsx`, not
> top-level files (`HybridPodGroupNode = memo(...)` at
> `HybridTopologyNodes.tsx:101`; `HybridAgentNode = memo(HybridAgentNodeInner,
> ...)` at `:321`).
Components in `packages/ui/src/components/topology/`:
- `HostMultiRigGraph.tsx` — host-level hybrid React Flow graph (multi-rig
single canvas). Also mounted on the `/project` workspace Workflow tab.
- `HybridTopologyNodes.tsx` — exports `HybridAgentNode` (compact agent cards:
runtime badges, context %, token totals, activity state, terminal preview,
per-card CMUX open via `useCmuxLaunch`) 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 (with
`topology-table-shimmer.css`).
- `TopologyTerminalView.tsx` — pinned-card terminal grid for host, rig or
pod scope; host scope shows 12 cards by default (`SAFE_N`) with a "show
all" toggle.
- `TopologyTreeView.tsx` — the topology tree, rendered in the Explorer
sidebar (`components/Explorer.tsx`), not as a center view mode.
- `TopologyViewModeTabs.tsx` — host tabs Graph / Table / Terminal; rig and
pod tabs add Overview. Tabs switch in place (React state), not separate
routes. (`SEAT_SCOPE_TABS` is defined but unused; the seat scope renders
`LiveNodeDetails`.)
- `TerminalPreviewPopover.tsx` — smoked-glass terminal preview, portaled
above the graph. On the graph and table it is `progressive`: a static
mirror first, click to go live, under a page-wide live-terminal cap.
- `TerminalLauncher.tsx` — the rig-scope tab-bar launcher: choose a
provider (herdr or cmux) and a view (this rig, a pod, a mission's or
slice's agents, a saved view), then open it via
`POST /api/terminal/open`. Shown only when the selected host is local.
- `LaunchCmuxButton.tsx` — the older rig-scope "Launch in CMUX" button.
`TerminalLauncher` replaced it in the tab bar, and no production
component imports it now (tests still do).
- `ScopePages.tsx` — host/rig/pod/seat scope page wrappers.
- `topology-overlay-context.tsx` — `TopologyOverlayProvider`: expanded-rig
state, the rig named in the URL auto-expanded, and the Explorer mode
(`overlay` on the graph tab, `opaque` on the others).
The rig and pod graphs are not the hybrid graph: rig and pod scopes render
`components/RigGraph.tsx` (`RigNode` cards, `graph-layout.ts` layout),
sharing `HotPotatoEdge` and `useTopologyActivity`. Rig scope's Overview tab
renders `RigOverviewTab` (`RigSpecDisplay`); pod scope's is a placeholder.
On narrow viewports the graph tab falls back to the table.
> Source: `topology/TopologyViewModeTabs.tsx` (`HOST_SCOPE_TABS`,
> `RIG_POD_SCOPE_TABS`, `SEAT_SCOPE_TABS`); `topology/ScopePages.tsx`
> (`TerminalLauncher` `:270`, `RigGraph` `:322`/`:437`, `RigOverviewTab`
> `:342`, `useOverlayForActiveTab`); `topology/TerminalPreviewPopover.tsx`
> (`progressive`); `topology/TopologyTerminalView.tsx` (`SAFE_N` `:35`).
> Note (vs `DESIGN.md` "Topology"): DESIGN.md lists the exported brand
> primitives (`HostMultiRigGraph`, `HybridAgentNode`, `HybridPodGroupNode`,
> `RigGroupNode`, `ActivityRing`, `HotPotatoEdge`, `TerminalPreviewPopover`,
> `TopologyTableView`, `TopologyTerminalView`); all are present in source.
> `TopologyTreeView`, `TopologyViewModeTabs`, `TerminalLauncher` and
> `LaunchCmuxButton` exist in source but are not in DESIGN.md's primitive
> list (a scope difference, not drift).
## 2. Topology contracts
Re-confirmed against source:
- Graph / table / tree navigation all resolve to seat detail URLs
(`/topology/seat/$rigId/$logicalId`).
- The host-level graph prefixes node IDs per rig for the multi-rig single
canvas (`lib/hybrid-layout.ts`: `PREFIX_DELIMITER = "::"`,
`prefixedHybridNodeId`).
- Expanded-rig state is owned by `TopologyOverlayProvider`. Rigs default to
**expanded** (`DEFAULT_RIG_EXPANDED = true` in `HostMultiRigGraph.tsx`),
with "Expand all" / "Collapse all" controls; graph data is 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 with non-scaling
strokes; reduced-motion preference removes pulse/travel animation and
keeps static state signals.
- Terminal-preview actions are hover/focus-visible; the
`TerminalPreviewPopover` escapes React Flow stacking contexts (portaled
to `document.body`) and stays within the viewport.
- With a non-local host selected, the host graph fetch carries the host
parameter; terminal-preview and CMUX buttons are hidden on cards and in
the table; and the rig page shows a read-only marker instead of the
launcher.
## 3. Layout and activity helpers
In `src/lib/`: `hybrid-layout.ts` lays out the host graph
(`HostMultiRigGraph`); `graph-layout.ts` (`applyTreeLayout`) lays out the
rig/pod graph (`RigGraph`); `multi-rig-layout.ts` has no production
importer (tests only). `topology-activity.ts` computes ring states and
hot-potato edges, and `activity-visuals.ts` the rollups and activity dots.
Runtime/tool identity on the cards comes from the central
`lib/runtime-brand.ts` / `lib/tool-brand.ts` and
`components/graphics/RuntimeMark.tsx` (runtimes: Claude Code, Codex, Pi,
OMP, terminal, unknown). Do not duplicate brand logic (DESIGN.md "Do
not").
## 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/components/RigGraph.tsx`,
`packages/ui/src/lib/{hybrid,graph}-layout.ts`.
-355
View File
@@ -1,355 +0,0 @@
# Handing work between OpenRig and A2A agents
An OpenRig seat can give a queue row's task to an outside A2A agent. In the other direction, an A2A server can
front a seat's queue and return its answer as an artifact. These are compositions of the
[official Python SDK](https://github.com/a2aproject/a2a-python) and existing `rig queue` commands, not a
`rig a2a` command or a daemon endpoint. The scripts below are examples you run and own.
OpenRig does not configure the outside agent's permissions, model or credentials. Choose a peer and task content
you are authorized to share. The peer's result is its report, not acceptance of the work by your team.
## Before either recipe
Use a working OpenRig installation and an existing seat. `rig whoami --json` identifies your seat;
`rig ps --nodes --rig <rig>` shows the receiving seat's canonical session name. If you need a team first, use
[getting started](getting-started.md). Keep the daemon and provider environment you already use.
In a scratch folder, make a Python environment (Python 3.10 or newer, with `venv` and `pip` available):
```sh
python3 -m venv .venv
.venv/bin/python -m pip install 'a2a-sdk[http-server]==1.1.0' httpx uvicorn
```
The examples pin the SDK version used in the pipe trials; they are not a recommendation to downgrade another
application. The official SDK 1.1.0 paths were exercised in both directions. Other 1.x versions were not tested.
Recheck the API and cancellation behavior when changing versions.
Pydantic AI 2.60.0 with FastA2A 2.1.1 did not interoperate with these SDK 1.1.0 recipes in the tested setup:
FastA2A's inbound typed client returned `result={}` even though the SDK front, with 0.3 compatibility enabled,
sent task, artifact and completed records in its raw SSE stream. Outbound, the official SDK raised a protobuf
parse error at the FastA2A task's `status.timestamp`; the full wire timestamp was not retained. When mixing
frameworks, compare the raw stream's task IDs, artifact and final status with the typed client's output.
An HTTP 200 or an empty typed result is not proof that the task succeeded. Retain the evidence before retrying.
Save this helper as `queue_io.py`:
```python
import asyncio
import json
async def rig(*args):
child = await asyncio.create_subprocess_exec(
'rig', *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
out, err = await child.communicate()
if child.returncode:
raise RuntimeError(f'rig {args[:2]} exited {child.returncode}: {err.decode()} {out.decode()}')
return json.loads(out)
```
It inherits the caller's OpenRig environment. Run queue operations in the intended seat's environment; set
`OPENRIG_URL` only when deliberately selecting another daemon. A timed-out write is uncertain: read its row and
transitions before trying again. Do not silently send the task a second time.
## Outbound: a queue task goes to an A2A peer
You need the peer's base URL and a row assigned to your seat. For a no-model peer, the official
[Hello World sample](https://github.com/a2aproject/a2a-samples/tree/6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3/samples/python/agents/helloworld)
returns an echo artifact. Fetch that revision:
```sh
git clone https://github.com/a2aproject/a2a-samples.git
git -C a2a-samples checkout --detach 6603ba3f2c31a7ef33e70b9d8b5b5f8be42ac9a3
```
Start the peer yourself in a separate terminal, following `a2a-samples/samples/python/agents/helloworld/README.md`.
Its default URL is `http://127.0.0.1:9999`, used below; it is available only while you run the sample.
The sample does not implement the input-required and cancellation controls described later. Use a peer that
supports those operations when testing them.
Save as `a2a_client.py`. This client also works without an OpenRig row, for the inbound recipe below.
Streaming reads have no deadline so quiet worker waits keep their stream; connection, write and pool waits are bounded to 10 seconds.
Stop the local wait with Ctrl-C or a caller-owned overall deadline; this does not confirm cancellation, so keep the task/context IDs and cancel through the peer when it is reachable.
```python
import asyncio
import json
import os
import uuid
from pathlib import Path
import httpx
from a2a.client import A2ACardResolver, ClientConfig, create_client
from a2a.helpers import new_text_message
from a2a.types import Role, SendMessageRequest
from google.protobuf.json_format import MessageToDict
from queue_io import rig
async def main():
row_id = os.environ.get('A2A_ROW')
text = os.environ.get('A2A_TEXT')
if row_id:
row = await rig('queue', 'show', row_id, '--full', '--json')
identity = await rig('whoami', '--json')
if row['destinationSession'] != identity['identity']['sessionName']:
raise RuntimeError('Choose a row assigned to this seat.')
if row['state'] == 'pending':
await rig('queue', 'claim', row_id, '--json')
row = await rig('queue', 'show', row_id, '--full', '--json')
if row['state'] != 'in-progress':
raise RuntimeError('The row is not in progress; inspect it before sending.')
if text is None:
text = row['body']
if text is None:
raise RuntimeError('Set A2A_TEXT or A2A_ROW.')
message = new_text_message(text, role=Role.ROLE_USER)
if os.environ.get('A2A_CONTEXT'):
message.context_id = os.environ['A2A_CONTEXT']
if os.environ.get('A2A_TASK'):
message.task_id = os.environ['A2A_TASK']
log_path = Path(f'a2a-{uuid.uuid4()}.jsonl').resolve()
state, bound = 'UNKNOWN', False
with log_path.open('x') as log:
async with httpx.AsyncClient() as http:
card = await A2ACardResolver(httpx_client=http, base_url=os.environ['A2A_URL']).get_agent_card()
log.write(json.dumps({'card': MessageToDict(card), 'message': MessageToDict(message)}) + '\n')
log.flush()
client = await create_client(agent=card, client_config=ClientConfig(
streaming=True, httpx_client=httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=None))))
try:
async for chunk in client.send_message(SendMessageRequest(message=message)):
event = MessageToDict(chunk) # SDK 1.1.0 yields StreamResponse, not a tuple.
log.write(json.dumps(event) + '\n')
log.flush()
print(json.dumps(event), flush=True)
task = event.get('task')
if task and row_id and not bound:
await rig('queue', 'update', row_id, '--note',
f"A2A peer={card.name} task={task['id']} context={task['contextId']} log={log_path}", '--json')
bound = True
status = (task or event.get('statusUpdate') or {}).get('status', {})
state = status.get('state', state)
finally:
await client.close()
if row_id:
await rig('queue', 'update', row_id, '--note', f'A2A reported {state}; full result/events: {log_path}', '--json')
print(f'Reported state: {state}; saved events: {log_path}')
asyncio.run(main())
```
When `A2A_TEXT` is unset, the row body becomes the peer's message verbatim. Write the body as the peer's input,
or set `A2A_TEXT` to the exact text you want to send. Run it from your seat, replacing the row and peer URL:
```sh
export A2A_URL=http://127.0.0.1:9999
export A2A_ROW=<your-row-id>
.venv/bin/python a2a_client.py
```
Read the saved `artifactUpdate` parts and final status. Put the useful result on the **same** row, using a note
while work continues or a resolution when you deliberately close it. The script leaves the row owned and open:
```sh
rig queue update "$A2A_ROW" --note 'A2A result: <answer, artifact reference, and any limits>' --json
# Only when your work on the row is actually finished:
rig queue update "$A2A_ROW" --state done --closure-reason no-follow-on --resolution '<your assessed outcome>' --json
```
If the stream fails, retain its exception and log. The peer may still be working; inspect the known task before
resending. When the task asks for input, set `A2A_TASK` and `A2A_CONTEXT` to its recorded IDs and `A2A_TEXT` to the
answer, then run the client again. For a new follow-up, unset `A2A_TASK` and keep `A2A_CONTEXT`: a completed task
is not reopened. Unset `A2A_TEXT` when the next outbound task should again use its row body.
## Inbound: an A2A client reaches an OpenRig seat
Run the following server in a custodian seat's environment, with `A2A_SEAT` set to the existing worker seat's
canonical session name. The worker is a separate participant: it watches its queue, claims the row, does the work
and writes a resolution. A deterministic shell worker is enough to test this path; a model is optional.
Save as `a2a_front.py` beside `queue_io.py`:
```python
import asyncio
import json
import os
import uvicorn
from starlette.applications import Starlette
from a2a.helpers import get_message_text, new_task_from_user_message, new_text_message, new_text_part
from a2a.server.agent_execution import AgentExecutor
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
from queue_io import rig
seat = os.environ['A2A_SEAT']
rows, canceling, published = {}, set(), {}
class Front(AgentExecutor):
async def execute(self, context, events):
task = context.current_task or new_task_from_user_message(context.message)
if not context.current_task:
await events.enqueue_event(task)
update = TaskUpdater(events, task.id, task.context_id)
published[task.id] = asyncio.Event()
try:
text = get_message_text(context.message)
if task.id not in rows:
row_id = 'qitem-a2a-' + task.id
body = json.dumps({'taskId': task.id, 'contextId': task.context_id, 'request': text})
await rig('queue', 'create', '--id', row_id, '--destination', seat, '--body', body,
'--summary', 'Inbound A2A task', '--no-nudge', '--full', '--json')
rows[task.id] = row_id
else:
row_id = rows[task.id]
await rig('queue', 'update', row_id, '--note', 'A2A answer: ' + text, '--json')
working = False
while True:
row = await rig('queue', 'show', row_id, '--full', '--json')
if task.id in canceling:
await asyncio.Future() # cancel() owns the terminal event.
if row['state'] == 'done':
if not row.get('resolution'):
await update.failed(new_text_message('Row closed without a result resolution.'))
else:
await update.add_artifact([new_text_part(row['resolution'])], name='seat-result')
await update.complete(new_text_message('Seat closed row ' + row_id))
return
if row['state'] in ('failed', 'denied', 'handed-off'):
await update.failed(new_text_message(row.get('resolution') or ('Row ' + row['state'])))
return
if row['state'] == 'canceled':
await update.cancel(new_text_message('Queue row canceled.'))
return
if row['state'] == 'in-progress' and not working:
await update.start_work(new_text_message('Seat claimed ' + row_id))
working = True
history = await rig('queue', 'transitions', row_id, '--full', '--json')
notes = [t.get('transitionNote') or '' for t in history]
conversation = [n for n in notes if n.startswith(('A2A question: ', 'A2A answer: '))]
if conversation and conversation[-1].startswith('A2A question: '):
await update.requires_input(new_text_message(conversation[-1][14:]))
return
await asyncio.sleep(1)
except asyncio.CancelledError:
if task.id in canceling:
await published[task.id].wait() # See SDK 1.1.0 cancellation note below.
raise
except Exception as error:
await update.failed(new_text_message(str(error)))
async def cancel(self, context, events):
canceling.add(context.task_id)
try:
row_id = rows[context.task_id]
await rig('queue', 'update', row_id, '--state', 'canceled', '--resolution',
'A2A client requested cancellation; worker stop not yet confirmed.', '--json')
await TaskUpdater(events, context.task_id, context.context_id).cancel(
new_text_message('Queue cancellation committed; worker stop is a separate observation.'))
finally:
published[context.task_id].set()
card = AgentCard(name='OpenRig queue: ' + seat, version='example-1',
description='A worker seat returns its queue resolution.',
default_input_modes=['text/plain'], default_output_modes=['text/plain'],
capabilities=AgentCapabilities(streaming=True),
supported_interfaces=[AgentInterface(protocol_binding='JSONRPC', url='http://127.0.0.1:19092', protocol_version='1.0')],
skills=[AgentSkill(id='queue-work', name='Queue work', description='Work assigned to ' + seat, tags=['queue'])])
handler = DefaultRequestHandler(agent_executor=Front(), task_store=InMemoryTaskStore(), agent_card=card)
app = Starlette(routes=[*create_agent_card_routes(card), *create_jsonrpc_routes(handler, '/')])
uvicorn.run(app, host='127.0.0.1', port=19092)
```
Start the front, then use a second terminal for the outside client:
```sh
# Custodian terminal:
export A2A_SEAT=<worker-session-from-rig-ps>
.venv/bin/python a2a_front.py
# Client terminal, in the folder with the example scripts and venv:
unset A2A_ROW A2A_TASK A2A_CONTEXT
export A2A_URL=http://127.0.0.1:19092
export A2A_TEXT='Return exactly these words: "hello from the receiving seat".'
.venv/bin/python a2a_client.py
```
The client waits while the worker does its part. In the **worker's own environment**, inspect and claim the row:
```sh
rig queue list --owned --full --json
export ROW=<the-new-qitem-a2a-id>
rig queue show "$ROW" --full --json
rig queue claim "$ROW" --json
rig queue update "$ROW" --state done --closure-reason no-follow-on --resolution 'hello from the receiving seat' --json
```
The client should receive that exact resolution as an artifact, followed by completed. Compare the artifact with
`rig queue show "$ROW" --full --json`, including the task/context IDs in the row body. The sample uses `--no-nudge`:
the worker must be watching its queue; merely starting the front does not start or wake an agent.
To wake a model worker after the row appears, run `rig send <worker-session> 'Check your queue and process the new A2A task.'`
from the custodian's environment, or remove `--no-nudge` from the front's queue-create call to request the normal queue wake.
For input-required, the worker instead records `--note 'A2A question: <question>'` and leaves the row in progress.
The client reruns with the same `A2A_TASK`, `A2A_CONTEXT` and its answer as `A2A_TEXT`. The worker reads the
`A2A answer: ` transition with `rig queue transitions "$ROW" --full --json`, then finishes that row. These prefixes
are this example's small convention, not built-in OpenRig fields. A new follow-up omits the completed task ID and
keeps the context ID; it produces a new row.
## Cancel and read the outcome honestly
For a protocol-1.0 JSON-RPC peer, a direct control request includes `A2A-Version: 1.0`. For example, after the task
is bound and working (substitute its ID; use `GetTask` with the same parameters to inspect it):
```sh
curl -sS "$A2A_URL" -H 'Content-Type: application/json' -H 'A2A-Version: 1.0' \
--data '{"jsonrpc":"2.0","id":"cancel-1","method":"CancelTask","params":{"id":"<task-id>"}}'
```
Read the JSON body and task state, not just HTTP status. A pipe trial received HTTP 200 carrying JSON-RPC
`-32009 VERSION_NOT_SUPPORTED` when the version header was omitted. That was an error, not a completed task.
The SDK handles versioned calls; raw requests must follow the peer's advertised interface and
[protocol version rules](https://a2a-protocol.org/v1.0.0/specification/).
SDK 1.1.0's default V2 handler cancels the running producer **before** awaiting the executor's `cancel` callback.
If that callback awaits a queue write, the producer can close the event channel first. The inbound trial then
returned stale working state even though the row was canceled. The event in the example keeps that producer
alive until the callback publishes its outcome. This is specific to the exercised ordering; see the
[pinned SDK implementation](https://github.com/a2aproject/a2a-python/blob/v1.1.0/src/a2a/server/agent_execution/active_task.py).
Test it again when changing SDK versions, including an awaited external write rather than an immediate callback.
Keep these facts separate:
| A2A observation | What it means for the queue or worker |
|---|---|
| Submitted | The A2A front accepted a task; a row may not yet be created. |
| Working | This front observed the receiving seat's claim. |
| Input-required | A question needs answering; this example leaves the row in-progress. |
| Completed + artifact | The worker's stored result was returned; your assessment still belongs to you. |
| Canceled | This inbound front committed queue cancellation; an outside peer's cancellation says nothing about your outbound row. Verify separately that the worker noticed and stopped. |
| Failed / JSON-RPC error | Retain the actual error and any row effect; do not report success. |
| Disconnect / timeout | The last known state remains all you know. Inspect before retrying. |
There is no one-to-one A2A/queue state mapping. The example does not follow handoffs to another row, so it reports
that limit instead of pretending the successor's work completed. Stopping the front only detaches clients; it does
not cancel its queue rows or stop their workers. Cancel or hand off outstanding work deliberately before stopping
your server. Acknowledged cancellation does not establish that a model provider stopped spending.
## What was exercised, and what remains open
The initial trials used an OpenRig 0.7.0 candidate and SDK 1.1.0 on Linux. Outbound discovery, streamed output and
artifacts used the official Hello World sample. Deterministic peers covered input-required, cancellation, failure
and concurrency. Inbound used a private daemon and a separate no-model queue worker: five artifacts matched the
stored resolutions, two tasks overlapped without crossing IDs, and completed-task reuse left the old result
unchanged. The successful cancellation check also observed the worker stop, beyond receiving the protocol reply.
A later inbound check used an official SDK 1.1.0 client and front, an OpenRig queue row, and Pi 1.4.0 running
`openai/gpt-4o-mini`. One marker answer reached the row's resolution and the client's completed artifact. A
custodian performed the queue writes; model tools were disabled. That run used a 120-second streaming read
timeout and prompt RPC event draining, changed together. It did not separately test the unbounded read used above.
These results do not establish model quality or provider billing. Other framework versions, model tool use,
public deployment and authentication, stream reconnect, durable bridge restart, and cancellation before row
binding were not exercised. The inbound example holds A2A tasks and their row mapping in memory: keep it running for the conversation;
after a crash, inspect retained OpenRig rows before any replacement run. It binds loopback and supplies no public
authentication layer. Use it on your own machine or a trusted private path, not as an unauthenticated public gateway.
-251
View File
@@ -1,251 +0,0 @@
# Handing work to an ACP agent
`rig acp run` gives one queue row's task to an agent that speaks the
[Agent Client Protocol](https://agentclientprotocol.com) (ACP), such as OpenClaw's `openclaw acp`, and records what
happened on that same row. The seat that runs it is the row's custodian; the agent is recorded as the executor.
```sh
rig acp run <qitemId> --cwd <work folder> -- <the agent's ACP command> [args...]
```
The row's notes get, in order:
- **the binding:** the agent's name and version, the ACP protocol version, whether it can load sessions, and the session;
- **progress,** about once a minute (tool calls so far, output so far); the full transcript is in the run's log file, named on the row;
- **each permission request and its answer** (below);
- **the outcome:** `completed`, `stopped` (for example `max_tokens`), `cancelled`, `failed` or `unknown`, with the
agent's output and the tokens and cost it reported, each labelled with where it came from (unknown when it reported
none);
- **what happened to the agent's processes** after the run ended.
The outcome is the agent's report, not a judgment of the work. The row stays open and yours: read the result, then
close it or hand it on with your own resolution, as you would any other.
## When the agent asks permission
OpenRig holds no permission policy of its own. The agent's own settings decide what it asks, and an owner decides
the answer. Each request becomes a queue row to `--permissions-to` (this seat by default), listing the action, its
target, the options the agent offered and the run, session and request it belongs to. Answer on that row with the
option's id:
```sh
rig queue update <permission row> --state done --closure-reason no-follow-on --resolution allow-once
```
Answer on the permission row itself; don't hand it off, because the run waits on that row. The answer goes back to
that one request, once. The turn waits for it; there's no deadline. An answer that isn't one
of the offered options is noted and the question is asked again. If the turn is cancelled first, the permission row
is closed as stale and nothing is sent. Whoever answers can follow their own written policy; that choice is theirs.
## Cancel, detach and stop
These are different things, and the row keeps them apart:
- **Cancel** (Ctrl-C, or `rig acp cancel <runId>` from another terminal) asks the agent to stop its turn. The row
records when it was requested and when the agent acknowledged it. Whether the model provider stopped work, and what
it cost, stays **unknown** unless you check the provider's own usage.
- **Detach** (a second Ctrl-C, or the run ending normally) closes the agent's connection. That doesn't stop
the agent's runtime.
- **Stop:** when the run ends, it reports whether any process carrying the run's environment marker remains:
`settled` (none), `incomplete` (with their process ids) or `unknown` (the process table couldn't be read). Only
processes that inherited the marker are seen, so one the agent started with a cleared environment, or through a
service manager, isn't. It never touches a process it didn't launch. To stop what's left, use the runtime's
own stop command, through `rig acp stop <runId> -- <that command>` so the result lands on the row.
A gateway the agent connects to but didn't start, such as OpenClaw's Gateway, isn't a process of the run. Leave a
shared one running; stop your own with its own command.
## Reading the outcome
- `completed`: the agent ended its turn with output.
- `unknown`: the turn ended, but nothing shows the work happened. That covers an `end_turn` with no output and no or
zero usage (an agent can end a turn this way after its provider refused the request), an `end_turn` whose text
reports an interruption (OpenClaw's `[OpenClaw interruption]`), and a connection that closed before the turn ended.
Check the agent's own log before you treat it as done.
- `failed`: the agent returned an error. The row keeps its original code, message and data.
- `cancelled`, `stopped`: as above.
## Claude Code and Codex, through their ACP adapters
Both have maintained ACP adapters. What follows was checked on 2026-11-06 with OpenRig 0.7.8: one small file task per
case, with `claude-haiku-5-5` and with `gpt-6.1-sol` at low effort. Other models weren't checked.
Install the adapters once; running them through `npx` adds about 10 seconds a turn:
```sh
npm install --prefix <dir> @agentclientprotocol/claude-agent-acp@0.101.0 @agentclientprotocol/codex-acp@2.5.0
export PATH="<dir>/node_modules/.bin:$PATH"
rig acp run <row> --cwd <work folder> -- env CLAUDE_CODE_EXECUTABLE=<your claude> ANTHROPIC_MODEL=<model> claude-agent-acp
rig acp run <row> --cwd <work folder> -- codex-acp
```
**Which versions run.** The binding note on the row names the adapter and its version.
- `claude-agent-acp` 0.101.0 runs Claude Code through the Claude Agent SDK 0.3.318. With `CLAUDE_CODE_EXECUTABLE` set,
it runs that `claude`, with that `claude`'s own login; the check ran Claude Code 2.1.310 this way. Without it, an npm
install runs the Claude binary from the SDK's platform package (Claude Code 2.1.318, by the SDK's package metadata),
which wasn't checked. Installing with `--omit=optional` leaves that binary out, and then, without
`CLAUDE_CODE_EXECUTABLE`, the adapter refuses to start; it never falls back to a `claude` on PATH. The adapter also
loads your Claude configuration, skills included.
- `codex-acp` 2.5.0 depends on `@openai/codex` `^0.165.0`, and 0.165.0 was the only release in that range when this
was checked. A later install can resolve a newer 0.165 release; `npm ls @openai/codex` in `<dir>` shows yours. It uses
the machine's existing Codex login.
- `@zed-industries/claude-code-acp` and `@zed-industries/codex-acp` are deprecated on npm in favour of these. The zed
`codex-acp` 0.16.0's Codex was too old for the model in the check.
**Permissions.** Each agent keeps its own permission mode, and OpenRig adds no restriction. In the check, neither
asked anything for file writes or shell commands: Claude started in `auto` mode and Codex in `workspace-write`. To
have the agent ask, so that each request becomes a permission row:
- **Claude:** give it a dedicated `CLAUDE_CONFIG_DIR` whose settings set `permissions.defaultMode` to `"default"`.
- **Codex:** set `INITIAL_AGENT_MODE=read-only` in its environment. Approval and sandbox keys in `CODEX_CONFIG` didn't
make it ask.
These are the settings the check used to make the agents ask. Whether to use them is the caller's choice; they're not a
recommended default. Send permission rows to a seat that can be woken: a row to a `terminal` seat sets off the stuck
sweep.
**Answers differ between the two.**
- **Claude** offers `allow-once`, `allow-with-updates` (allow always) and `reject`. `reject` refuses that one action,
and the turn carries on.
- **Codex** offers `allow_once`, `accept_execpolicy_amendment` (allow always) and `cancel`. Its only refusal, `cancel`,
ends the whole turn, and the outcome is `cancelled`.
**Continuing, and a session that doesn't exist.**
- **Continuing:** `--load-session` continued an earlier session on a second row with both adapters.
- **A made-up session id:** both refused it and started nothing, but they report it differently. Claude returns
`-32002 Resource not found`, and Codex returns `-32603 Internal error` with "no rollout found" in its data. The row
shows `failed` with the agent's own error. Neither code by itself means a missing session, so read the message.
**A completed turn isn't finished work.** In the check, Claude was asked to run a command and wait for it. It moved the
command into a background task, said it would report when it finished, and ended the turn `completed`. The task died
with the adapter, and its file never appeared. Check the work itself before you close the row. Beyond this one case,
long and background work weren't checked.
**Cancel.** `rig acp cancel` was acknowledged in 20-30 ms for both, sent while the agent waited on a permission
answer; a cancel mid-generation or mid-tool wasn't timed. Straight after, Claude's run reported `incomplete`
with one process still running, which was gone a few seconds later; Codex's reported `settled`. An acknowledgement
isn't a finished shutdown, and it says nothing about further spend.
**Usage and cost.** Claude's adapter reports token usage and a cost in USD; Codex's reports tokens only. Both are in
the run's log file, not on the row.
**Not checked:**
- long or multi-tool tasks, and background work;
- concurrent runs;
- `rig acp stop` with a real stop command;
- an adapter that crashes mid-turn;
- continuing after a daemon restart;
- MCP servers.
## Notes for specific agents
- **OpenClaw:** `openclaw acp` is a bridge to a running OpenClaw Gateway, so start the Gateway first, then run, for
example, `rig acp run <row> -- openclaw acp --url ws://127.0.0.1:18789 --token-file <file>`. Its permission requests
come from the Gateway's exec approvals.
- **Continuing a session:** `--load-session <id>` continues an earlier session when the agent advertises loading. If it
doesn't, or the load fails, the run reports that and starts nothing new.
## Running it in the background: `rig exec`
`rig exec` runs the same single turn on a named harness, `claude` or `codex`, through that harness's ACP adapter, in a
connector that outlives the command:
```sh
rig exec harnesses # the adapter and native versions installed here, and how each differs
rig exec run <row> --harness claude --plan # what would run, with versions; starts nothing
rig exec run <row> --harness claude [--wait] # start it; --wait waits here for the outcome
rig exec status <runId|row> # state, outcome, usage and cost, and what still runs
rig exec cancel <runId>
```
- **The connector owns the run.** Closing the terminal, or leaving `--wait` with Ctrl-C, doesn't stop it. Its record
in `$OPENRIG_HOME/acp/runs/` holds its process and its state: `requested`, `started`, `finished` or `interrupted`.
The row gets the same notes as with `rig acp run`.
- **One run per row.** Running it again returns the row's run, still going or ended, instead of sending the task
twice. `--again` starts another turn once that run has ended.
- **Interrupted.** A run whose connector died without recording an outcome reads `interrupted`, outcome unknown, with
one note on the row, the next time `status`, `run` or `--wait` looks at it. Whether any of the agent's processes
remain is said there too.
- **Missing.** With `--load-session`, a session reads `missing` only on the adapter's own evidence: for Claude,
`-32002` naming that session; for Codex, its "no rollout found for thread id" error for that session. Any other error
reads `failed`, with the agent's code and message, and no new session is started either way.
- **The outcome is the agent's report.** `finished` with `completed` means the turn ended; accepting the work stays
with you, on the row. `--wait` exits 0 for `completed`, 2 for any other outcome, and 3 when you stop waiting.
Without `--wait`, a run that has already ended exits the same way, and one still going exits 0. Either way the code
follows the row's run, including one another caller started.
- **Permissions:** the agent keeps its own mode, and `rig exec` adds no policy; your environment passes through.
`rig exec harnesses` lists what each harness reads.
`rig acp run` is unchanged: it stays in your terminal for the length of the turn.
## Limits
- One run is one turn for one row.
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
- Spend is whatever the agent's provider charges. OpenRig doesn't measure it; it records only what the agent reports.
## OpenRig as an agent in ACP apps
The other direction: `rig acp serve`. An app that speaks ACP and can launch a custom agent command can offer your OpenRig team as its agent. The app starts
`rig acp serve` and talks to it on stdin and stdout:
```sh
rig acp serve --to <member@rig>
```
Without `--to`, the receiver is this machine's `acp.to` setting: `rig config set acp.to <member@rig>`, set once. An
app that starts the agent from the ACP registry passes no arguments, so that's how it reaches your team.
- **A prompt** becomes a queue row from you to `--to`, the seat that takes your team's new work. The row's body is your
prompt, plus a line saying which app sent it and that its closing resolution is your answer.
- **Progress:** each row the work reaches shows in the app as a step (`Sent to …`, then `Handed to …` at each handoff),
with the row's notes as they're written, each with the seat that wrote it. The step's title also shows its latest
note, for apps that show steps collapsed.
- **The reply** is the closing resolution, with who closed the row and how (`done`, `done (canceled)`, `denied`, …). The
turn ending says only that the turn ended: read the reply for how the work ended.
- **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states,
and the app first shows any earlier row that's still open.
- **A question the team asks you** while a turn is open comes to the app with the ask's own choices, when its row is
tagged for that chat (`acp-session:<id>`; the task's row tells the team how). An app that takes forms (it
advertises ACP elicitation's form mode, as T3 Code does) gets a one-choice form with each choice by its label; any
other app gets a permission request with one option per choice, as Zed shows them. An untagged
question from a seat the work reached is named in the chat with where to answer it, because it could be another
chat's. Your choice is recorded as your answer, as a click in Slack would be, and the seat that asked gets it.
Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack, and no further
question opens after a cancel. A question that ends unanswered here (dismissed, cancelled, closed with the work, or
one the app couldn't show) is named in the chat as still open, as well as on its card. A question that wants an
answer in words is shown with where to answer it.
- **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one
(`session/load`). Reopening replays each prompt, its work, the questions the team asked you during it with your
answers, and the reply, as they stand now. Nothing is asked again on reopening: a question still open comes up in
your next prompt's turn, and a new prompt continues the session.
- **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so.
- **Work handed to a seat on another machine** can't be followed from here; the reply says where it went.
**Who you are:** `--as <address>`, else `OPENRIG_SESSION_NAME`, else this machine's default person: the only
registered person, or the one chosen with `rig gateway default <id>` when there are several (the address is
`<id>@external`). The app's sign-in step, "OpenRig on this machine", checks
that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the
reason and the fix. The agent talks to the same daemon as `rig` on this computer: the local one, so start it first
(`rig daemon start`), unless `OPENRIG_URL` names another.
The team's questions reach you only as a registered person: an `<id>@external` address needs
`rig gateway human add <id> --display-name "<your name>" --delivery-class <A|B|C|D>` first. Until then, the app says
so at your first prompt, and your work is still sent.
**In Zed**, add the agent to `settings.json`:
```json
"agent_servers": {
"OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] }
}
```
Other apps take the same command and arguments wherever they configure a custom ACP agent.
**Limits:**
- One receiver per agent command (or the `acp.to` setting); the app has no team picker.
- Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack).
- An app that shows a permission request's options only as fixed buttons, such as Approve and Decline, and doesn't
take forms, can't show the choices: answer those questions in the TUI or Slack.
- Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted.
- Progress is read every 2 seconds while a turn is open (`--poll-seconds`).
- Sessions are found among the 2,000 newest rows you've sent, and a reopened session's questions among the 2,000 newest
sent to you, so a caller with a lot of traffic, such as a busy seat, can't list or fully reopen its oldest sessions.
+69 -258
View File
@@ -1,8 +1,8 @@
# AgentSpec Reference
Version: 1.0
Last validated against code: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`; changes to its sources and the shipped agent specs since then checked)
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`, `packages/daemon/src/domain/agent-resolver.ts`, `packages/daemon/src/domain/profile-resolver.ts`, `packages/daemon/src/domain/skill-catalog.ts`
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`
This is the canonical reference for the AgentSpec YAML format (`agent.yaml`). Every field, validation rule, and default documented here was traced from the actual parser and validator code.
@@ -20,7 +20,7 @@ profiles:
skills: []
guidance: []
subagents: []
plugins: []
hooks: []
runtime_resources: []
resources: {}
@@ -35,7 +35,7 @@ startup:
```yaml
name: implementer
version: "1.0"
description: Implementation agent — implements and verifies coherent changes
description: Implementation agent — writes code following TDD discipline
defaults:
runtime: claude-code
@@ -46,11 +46,11 @@ imports:
profiles:
default:
uses:
skills: [development-team, systematic-debugging, verification-before-completion]
skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging]
guidance: []
subagents: []
plugins: [shared:openrig-core]
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config, shared:claude-activity-hooks]
hooks: []
runtime_resources: []
resources:
guidance:
@@ -75,10 +75,9 @@ description: Vault specialist agent — manages HashiCorp Vault for this managed
defaults:
runtime: claude-code
model: claude-opus-4-6
effort: high
lifecycle:
execution_mode: interactive_resident
compaction_strategy: default-compaction
compaction_strategy: harness_native
restore_policy: resume_if_possible
imports:
@@ -92,11 +91,11 @@ profiles:
preferences:
runtime: claude-code
uses:
skills: [systematic-debugging, vault-user]
skills: [openrig-user, systematic-debugging, vault-user]
guidance: []
subagents: []
plugins: [shared:openrig-core, vault-tools]
runtime_resources: [claude-settings]
hooks: []
runtime_resources: []
startup:
files:
- path: guidance/profile-specific.md
@@ -112,11 +111,10 @@ resources:
guidance:
- id: role
path: guidance/role.md
plugins:
- id: vault-tools
source:
kind: local
path: plugins/vault-tools
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
@@ -149,12 +147,12 @@ startup:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `name` | string | yes | — | Agent name. Used in spec library identification and validation messages. |
| `version` | string | yes | — | Spec version. Quote it (`"1.0"`): an unquoted number fails as a non-string. An importing spec that pins `imports[].version` must match it exactly. |
| `version` | string | yes | — | Spec version. Informational — not used for compatibility gating. |
| `description` | string | no | — | Human-readable description. Shown in spec library and review surfaces. |
| `defaults` | Defaults | no | — | Default runtime, model, and lifecycle settings. Applied when not overridden by the rig spec or profile. |
| `imports` | Import[] | no | `[]` | Other AgentSpecs to import. Resources from imported specs become available for profile `uses` references. |
| `profiles` | map<string, Profile> | no | `{}` | Named profiles. Each profile selects resources and can override startup/lifecycle. The rig spec member's `profile` field selects which profile to use. |
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, plugins, runtime resources). These are the available pool that profiles select from via `uses`. |
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, hooks, runtime resources). These are the available pool that profiles select from via `uses`. |
| `startup` | StartupBlock | no | `{ files: [], actions: [] }` | Agent-level startup files and actions. Applied to all profiles via the startup layering model. |
---
@@ -165,19 +163,16 @@ startup:
defaults:
runtime: claude-code
model: claude-opus-4-6
effort: high
lifecycle:
execution_mode: interactive_resident
compaction_strategy: default-compaction
compaction_strategy: harness_native
restore_policy: resume_if_possible
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `runtime` | string | no | — | Default runtime for this agent. Precedence: the rig spec member's `runtime`, then the profile's `preferences.runtime`, then this, then `claude-code`. |
| `model` | string | no | — | Default model, with the same precedence. |
| `effort` | string | no | — | Default reasoning effort, with the same precedence. Claude gets `--effort`, Codex `-c model_reasoning_effort=…`, Pi `--thinking <level>` (Pi's levels: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; passed as written). A blank or non-string value is ignored with an advisory. |
| `advisor` | string | no | — | Default Claude Advisor model, with the same precedence. Passed as the session-scoped `--advisor` flag, independently of the primary model; never written to shared Claude settings. Blank or non-string values are ignored with an advisory. Other runtimes ignore it. |
| `runtime` | string | no | — | Default runtime for this agent. Can be overridden by the rig spec member's `runtime` field. |
| `model` | string | no | — | Default model. Can be overridden by the rig spec member's `model` field. |
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
### Lifecycle Defaults
@@ -185,9 +180,8 @@ defaults:
| Field | Type | Default | Allowed Values |
|-------|------|---------|----------------|
| `execution_mode` | string | `interactive_resident` | `interactive_resident` (only value in v1; `wake_on_demand` is explicitly rejected) |
| `compaction_strategy` | string | `default-compaction` | `default-compaction`, `managed-compaction`, `handover`, `apprentice-handover`; deprecated aliases accepted with a validation advisory: `harness_native` → `default-compaction`, `pod_continuity` → `handover` (`custom_prompt` is explicitly rejected in v1). `managed-compaction` and `apprentice-handover` create their jobs only for `claude-code` seats; see [Codex compaction and sizing](agent-startup-guide.md#codex-compaction-and-sizing) |
| `compaction_strategy` | string | `harness_native` | `harness_native`, `pod_continuity` (`custom_prompt` is explicitly rejected in v1) |
| `restore_policy` | string | `resume_if_possible` | `resume_if_possible`, `relaunch_fresh`, `checkpoint_only` |
| `mechanic` | string | — | A canonical `seat@rig` session address; needed by `apprentice-handover` |
---
@@ -203,18 +197,15 @@ imports:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ref` | string | yes | Reference to another AgentSpec directory. Must start with `local:` (relative) or `path:` (absolute). The referenced directory must contain an `agent.yaml`. |
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). When set, the imported spec's `version` must equal it, or resolution fails with `version_mismatch`. |
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). |
### Import Resolution
- `local:` paths resolve relative to the importing spec's directory
- `path:` paths are absolute filesystem paths
- Imported resources become available for `uses` references in profiles
- A qualified reference `namespace:id` (e.g., `shared:openrig-core`) names the imported spec by its `name` field
- An unqualified reference (just `id`) resolves against the spec's own resources first, then against the one import that
declares it. If two imports declare it, the reference is ambiguous and fails: qualify it
- An imported spec may not have imports of its own, and its `name` may not contain `:`. Two imports that resolve to the
same spec name are refused, and import cycles are detected
- When referencing imported resources in `uses`, use qualified `namespace:id` format (e.g., `shared:openrig-user`)
- Unqualified references (just `id`) resolve against the spec's own local resources first
### The Shared Import Pattern
@@ -225,14 +216,7 @@ imports:
- ref: local:../../shared
```
This gives access to the shared pool: skills such as `systematic-debugging`, `verification-before-completion`,
`development-team` and `test-driven-development`; the `openrig-core` plugin, which carries `openrig-user` and OpenRig's
other skills; and runtime resources such as `claude-default-settings`, `claude-default-mcp`, `codex-default-config` and
`claude-activity-hooks`. Agents select the ones they need via profile `uses`, for example
`plugins: [shared:openrig-core]`. For a Claude seat selecting the vendored `openrig-core`, the daemon loads the plugin
itself through `CLAUDE_CODE_PLUGIN_DIRS`, including its skills and hooks, without projecting a second copy of its skills
(see `refocus-channel.md`). Other selected plugin skills follow the projection rules below. A seat that didn't select
`openrig-core` does not get its hooks through this selection.
This gives access to the full pool of shared skills (openrig-user, systematic-debugging, development-team, etc.) and agents select the ones they need via profile `uses`.
---
@@ -246,10 +230,10 @@ profiles:
runtime: claude-code
model: claude-opus-4-6
uses:
skills: [systematic-debugging, vault-user]
skills: [openrig-user, systematic-debugging, vault-user]
guidance: [role]
subagents: []
plugins: [shared:openrig-core]
hooks: []
runtime_resources: []
startup:
files: []
@@ -260,15 +244,13 @@ profiles:
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `summary` | string | no | — | Profile description. Spec review surfaces read a profile `description` instead. |
| `preferences` | object | no | — | Runtime/model/effort/Advisor preferences for this profile. |
| `summary` | string | no | — | Profile description. |
| `preferences` | object | no | — | Runtime/model preferences for this profile. |
| `preferences.runtime` | string | no | — | Preferred runtime. |
| `preferences.model` | string | no | — | Preferred model. |
| `preferences.effort` | string | no | — | Preferred reasoning effort. |
| `preferences.advisor` | string | no | — | Preferred Claude Advisor model; overrides `defaults.advisor` and can be overridden by the member. |
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. `restore_policy` can only narrow (`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`); `compaction_strategy` and `mechanic` take the most specific value (defaults, then profile, then member). |
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. |
### Uses
@@ -276,32 +258,18 @@ The `uses` block selects which resources from the `resources` pool (including im
```yaml
uses:
skills: [systematic-debugging, vault-user]
skills: [openrig-user, systematic-debugging, vault-user]
guidance: [role]
subagents: []
plugins: [shared:openrig-core]
hooks: [pre-commit]
runtime_resources: [claude-settings]
```
Each array contains resource IDs. These can be:
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
- **Qualified** (`shared:systematic-debugging`) — resolves against a specific imported spec's resources
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `plugins`, `runtime_resources`. A `uses.hooks` key, even an
empty list, is refused: hooks ship inside plugins. Any other key selects nothing and gets a validation advisory, shown at
launch and by `rig agent validate`, so a typo such as `skils` doesn't go unnoticed. A single value written in place of a
category's list (`skills: vault-user`) is read as a one-item list, with an advisory suggesting list syntax, and resolves
exactly like that list: when the profile is selected, a resource that isn't available fails that member's resolution with
"not found in resource pool".
**Skills resolve more widely than other resources.** For Claude Code and Codex seats, a skill ID that no declared
resource provides is looked up at launch in the project's runtime skill folder in the seat's cwd (`<cwd>/.claude/skills` or
`<cwd>/.agents/skills`), then the `skills/` folder beside the RigSpec, then the home skill folder, then the managed
skills root. Pi looks only in the `skills/` folder beside the RigSpec. OMP does no filesystem discovery, so its
`uses.skills` references need declared resources. A discovered skill's ID is its `SKILL.md` frontmatter `name`, and a declared resource wins over a discovered skill with the
same ID. When a profile selects a skill from inside the spec's own folder and the managed catalog holds a different
copy, the spec's copy is used and launch warns `skill_bundle_precedence`. Any other differing copy fails launch with
`skill_identity_conflict`.
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
---
@@ -318,12 +286,10 @@ resources:
subagents:
- id: helper
path: subagents/helper
plugins:
- id: vault-tools
source:
kind: local
path: plugins/vault-tools
plugin_type: auto
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
@@ -331,181 +297,50 @@ resources:
type: claude_settings_fragment
```
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`.
The only declared resources delivered are those that the active profile's `uses` block references. Two sources add
skills outside `uses`: the managed skill catalog's system selection and a project's `install.skills`.
`resources.hooks` is refused at validation: hooks now ship inside plugins, declared under `resources.plugins`.
### Plugin paths
A local plugin's `source.path` supports these forms:
| Form | Resolution |
|------|------------|
| `/absolute/plugin` | The exact absolute path. |
| `~/plugins/example` or `~` | The operating-system user home. |
| `openrig-home:plugins/openrig-core` | Relative to the daemon's configured OpenRig home, including a non-default `OPENRIG_HOME`. |
| `plugins/example` or `./plugins/example` | Relative to the agent spec directory. |
For a plugin seeded by OpenRig, use the home-relative form so the spec selects
that daemon's copy:
```yaml
resources:
plugins:
- id: openrig-core
source:
kind: local
path: "openrig-home:plugins/openrig-core"
profiles:
default:
uses:
plugins: [openrig-core]
```
`openrig-home:` is resolved by the daemon from its configured state root. It is
not shell interpolation and does not consult a seat's shell startup files. Its
suffix must be relative. Existing absolute, tilde and spec-relative paths keep
their meaning; `~user` is still a literal relative segment, not a user lookup.
This form needs OpenRig 0.6.5 or later; keep bundles using it pinned to such a
version.
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
### Resource Categories
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path`, `on_restore`* | Skill directories containing a SKILL.md (see "Skill Resources"). Projected by the runtime adapter. Claude and Codex seats get them in the seat's own storage, `<OpenRig home>/state/seats/<rig id>/<logical seat>/<runtime>/`, outside the project: Claude to `plugin/skills/<id>` in the seat's generated plugin, where they show as `openrig:<id>`, and Codex to `skills/<id>` beside the seat's skill catalog. A Claude or Codex seat without that storage gets `<cwd>/.claude/skills/<id>` or `<cwd>/.agents/skills/<id>`. Pi and OMP project to the seat's own agent directory under OpenRig's state. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files, merged as a managed block (see "Guidance Resources"). |
| `subagents` | `id`, `path` | Subagent definitions. Claude copies them to `<cwd>/.claude/agents/`, Codex to `<cwd>/.agents/`; Pi and OMP don't project them. |
| `plugins` | `id`, `source`, `plugin_type`* | Local plugin directories; selected with `uses.plugins`. `source.kind` must be `local`. `plugin_type` is `claude`, `codex` or `auto` (default): Claude projects a plugin to `<cwd>/.claude/plugins/<id>` when it is `claude`, or `auto` with a `.claude-plugin/plugin.json`; Codex projects to `<cwd>/.codex/plugins/<id>` when it is `codex`, or `auto` with a `.codex-plugin/plugin.json`. Pi and OMP don't project plugins. Claude Code and Codex do not read skills from that projected plugin folder, so selected plugin skills are also projected into the seat's skill folder under their plain names: the seat's own storage (see `skills`), or `.claude/skills/<name>` or `.agents/skills/<name>` for a seat without it. The exception is the vendored `openrig-core` selected by a Claude seat: Claude loads that plugin directly, skills included, so its skills are not projected a second time. A folder already there with the same name, ignoring letter case, that OpenRig doesn't own is left alone; unless it already holds the same bytes, launch warns `plugin_skill_kept`. A skill the seat already selects by that name (from its profile, the System World or the project) keeps that source, an edited OpenRig-owned copy is kept with `plugin_skill_kept`, a retained plugin copy whose remembered source can no longer be projected is also kept with `plugin_skill_kept` (a seat selecting that plugin refreshes it at its next launch), and if the plugin skills can't be projected the seat launches without them and warns `plugin_skills_not_projected`. Hooks ship inside plugins. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. An entry whose `runtime` isn't the seat's runtime is skipped. Pi and OMP project none. |
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
| `subagents` | `id`, `path` | Subagent definitions. |
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
*Fields marked with `*` are optional.
Recognized runtime resource types:
- `claude_settings_fragment` — merge a JSON object into `<cwd>/.claude/settings.local.json`.
- `claude_mcp_fragment` — merge a JSON object into `<cwd>/.mcp.json`.
- `claude_activity_hooks` — reconcile OpenRig's activity hooks into `<cwd>/.claude/settings.local.json`.
- `codex_config_fragment` — upsert a TOML fragment into `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`) inside an OpenRig-managed block.
- `codex_config_fragment` — upsert a TOML fragment into `~/.codex/config.toml` inside an OpenRig-managed block.
Unknown runtime resource types are still copied to the Claude or Codex runtime extension directory for agent-visible context.
#### Writing a `codex_config_fragment`
**Begin the fragment with a table header.** A fragment must be a valid TOML
document on its own, and every key it sets must sit under a table it declares.
A fragment that puts keys ahead of its first table header is refused at
projection time, and nothing is written.
The reason is TOML's grammar rather than a policy choice. The managed block is
appended to the end of the user's `config.toml`, and TOML has no syntax for
returning to document root once a table has been opened. So if the user's file
ends inside any table, an appended root-level key does not land at root — it
silently becomes a member of *their* table. Refusal is deterministic and never
inspects the user's file: a fragment author cannot see user state, and a rule
that passed or failed depending on it would be impossible to reproduce.
```toml
# refused — `model` would bind into whatever table the user's file ends inside
model = "gpt-5"
[mcp_servers.exa]
url = "https://mcp.exa.ai/mcp"
```
```toml
# accepted — every key sits under a table this fragment declares
[mcp_servers.exa]
url = "https://mcp.exa.ai/mcp"
```
Where a fragment's table is one the user already declares, the user's table
wins: the managed table is dropped, their values are never merged, rewritten or
overwritten, and the rest of the fragment still applies.
**A fragment can't install a named Codex profile.** Codex versions with named
profiles read each one from its own file, `<CODEX_HOME>/<name>.config.toml`,
with top-level keys, and won't load a profile while `config.toml` still holds a
legacy `[profiles.<name>]` table. A fragment only adds tables to `config.toml`,
so neither form works there. Create the profile file yourself and select it with
the member's `codex_config_profile`; see "Codex: select sandbox and approvals
together" in [getting-started.md](getting-started.md#codex-select-sandbox-and-approvals-together).
Before a launch or restore, OpenRig checks that a selected profile loads, and for
a legacy table it names the file to move the settings into.
Unknown runtime resource types are still copied to the runtime extension directory for agent-visible context.
### Resource Path Rules
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths), except a plugin's
`source.path`, which takes the forms under "Plugin paths"
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths)
- Paths resolve relative to the agent spec's directory
- Resource IDs must be unique within their category
- Resource IDs are the identifiers used in `uses` references
### Skill Resources
```yaml
skills:
- id: house-style
path: skills/house-style
on_restore: follow_source # optional — keep_saved by default
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier, used in `uses.skills`. |
| `path` | string | yes | — | Relative path to the skill's folder, which holds its `SKILL.md`. |
| `on_restore` | string | no | `keep_saved` | What a restore (`rig up <rig>`) does with a Claude or Codex seat's copy of this skill in the seat's own storage: `keep_saved` or `follow_source`. |
An unknown `on_restore` value produces an advisory and uses `keep_saved`; it does not invalidate the spec.
A restore keeps the skills a seat was launched with. With `keep_saved`, it puts back the saved copy and never takes
today's bytes from the source. With `follow_source`, before the seat's session starts, the restore reads the seat's agent
spec as it is now, using the saved launch recipe and rig folder, and:
- replaces the seat's copy with the source folder as it is now, and records it, so `rig down` removes it and the next
restore puts it back as usual;
- copies in a `follow_source` skill the profile now selects that the seat didn't have;
- removes a skill that followed its source and that the profile no longer selects.
A copy edited in the seat (changed bytes or recorded file permissions, or an added file), or a folder with the same id that OpenRig didn't write, is kept as
it is and named in the restore's warnings. If the agent spec can't be read or resolved, the seat keeps its saved skills
and the restore says why; a source folder without a `SKILL.md` keeps that skill's saved copy. A skill changed to
`keep_saved` stops following at the next restore, and its copy stays. Older seat records without file permissions
retain their byte-only ownership checks until a new projection or refresh records permissions.
Source file symlinks are dereferenced, including links to files outside the skill folder, just as during Claude and
Codex launch projection. The seat receives an ordinary file containing the target's bytes. A broken link or a link to
a directory makes the source unreadable and keeps the saved copy; links in the seat's destination remain protected.
Only a restore does this; a running seat isn't changed, and neither is a Codex seat restarted in place. A Claude seat
loads its seat plugin when its session starts, so it sees the new copies. A Codex seat with following skills receives
its rebuilt catalog after its resumed session is ready. The catalog lists the skills actually present, replaces its
earlier OpenRig skill list, and asks it to read current skill files before using them. This includes newly selected
skills and the removal of the last following skill. An edited or linked catalog is kept and not sent, with a warning;
a delivery failure is reported instead of claiming a complete restore. Neither runtime erases skill instructions
already read into the retained conversation.
Following covers the whole selected skill folder, including scripts and references. It does not install external
tools, change MCP configuration, or update bundled plugins. The saved launch recipe supplies the rig directory,
member and profile; today's agent spec supplies the skill selection. Today's rig YAML and other agent settings are
not applied by this refresh.
### Guidance Resources
```yaml
guidance:
- id: role
path: guidance/role.md
merge: managed_block # optional — the only strategy applied
target: CLAUDE.md # optional — where to merge
merge: managed_block # optional — how to merge (default: managed_block)
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier. A `rig-role` guidance resource is never merged; deliver a role with a `send_text` startup file. |
| `id` | string | yes | — | Resource identifier. |
| `path` | string | yes | — | Relative path to the guidance file. |
| `target` | string | no | — | Parsed but not used: the runtime decides the file. Claude merges into the rig's `managed_blocks` file (`CLAUDE.md` by default, or `CLAUDE.local.md`); Codex, Pi and OMP merge into `<cwd>/AGENTS.md`, except that a fresh Pi seat normally uses its own agent folder's `AGENTS.md` and a fresh Codex 0.164.0+ seat normally gets launch developer instructions; the conditions are in [Codex guidance at launch](agent-startup-guide.md#codex-guidance-at-launch) and [Runtime Differences](agent-startup-guide.md#runtime-differences). |
| `merge` | string | no | `managed_block` | Only `managed_block` is applied. Any other value, such as `append`, merges nothing. |
| `target` | string | no | — | Target file for merging (e.g., `CLAUDE.md`). |
| `merge` | string | no | `managed_block` | Merge strategy. One of: `managed_block`, `append`. |
---
@@ -515,17 +350,11 @@ The startup block follows the same format as in the RigSpec (see `docs/reference
Agent-level startup is applied to all profiles. Profile-level startup is applied only when that profile is active. Both merge additively via the startup layering model.
An additional orientation challenge requires an explicit `startup_proof` action
with `value: authenticated` and `idempotent: true`. Omission adds no exercise;
a later applicable `value: none` overrides an earlier selection. See
[startup proof selection](rig-spec.md#startup-proof-selection) for precedence,
restore behavior, and the distinction between readiness and verified proof.
### Delivery Hint Quick Reference
| Hint | When Delivered | Mechanism |
|------|---------------|-----------|
| `auto` | Depends on the file | A seat's own role (a file marked `orientation: role`, or at a `guidance/role.md` path) is `send_text`, so pod-mates never overwrite each other's role in the shared file. A seat launched before this rule keeps the hint saved at its launch, which restore (`rig up <rig>`), `rig seat launch --fresh` and handover replay, until the rig is instantiated from its spec again: `rig down <rig>`, then `rig up <spec file>`, which starts a new generation with fresh conversations. Otherwise a path ending in `SKILL.md`, or content beginning with `# SKILL`, is `skill_install`; a `.md` file is `guidance_merge`; and any other file is `send_text` |
| `auto` | Before harness boot | System chooses based on file type |
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
| `skill_install` | Before harness boot | Installed to runtime skill directory |
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
@@ -539,27 +368,17 @@ restore behavior, and the distinction between readiness and verified proof.
3. Import `ref` must start with `local:` (relative) or `path:` (absolute).
4. Import `version` must be an exact version (no ranges).
5. `profiles` must be a map (object), not an array.
6. Profile `uses` references other than skills must resolve to declared resources (local or imported). Skill
references are checked at launch, not at validation (see "Uses").
6. Profile `uses` references must resolve to declared resources (local or imported).
7. Unqualified `uses` references that don't resolve to local resources require imports to be present.
8. Qualified `uses` references must be in `namespace:id` format.
9. All resource paths must be safe relative paths (a plugin's `source.path` excepted).
10. Resource IDs must be unique within their category, and each `resources.<category>` must be an array.
11. `runtime_resources` entries require `runtime` and `type` fields.
11a. An unknown skill `on_restore` value produces an advisory naming the known values and falls back to `keep_saved`.
12a. `resources.hooks` and `profiles.<name>.uses.hooks` are refused: "removed in plugin-primitive (Phase 3a)". Declare
a plugin instead.
12b. A plugin needs a unique non-empty `id`, a `source` object with `kind: local` and a non-empty `path`, and a
`plugin_type` of `claude`, `codex` or `auto` when given.
12c. Lifecycle `mechanic` must be a canonical `seat@rig` session address.
9. All resource paths must be safe relative paths.
10. Resource IDs must be unique within their category.
11. `runtime_resources` entries require `runtime` field.
12. Lifecycle `execution_mode` must be `interactive_resident`.
13. Lifecycle `compaction_strategy` must be one of `default-compaction`, `managed-compaction`, `handover`, `apprentice-handover` — or a deprecated alias (`harness_native`, `pod_continuity`), which validates with a deprecation advisory and normalizes to its canonical value (OPR.0.5.6.20).
13. Lifecycle `compaction_strategy` must be `harness_native` or `pod_continuity`.
14. Lifecycle `restore_policy` must be `resume_if_possible`, `relaunch_fresh`, or `checkpoint_only`.
15. Startup files and actions follow the same validation rules as in RigSpec.
Unknown keys are ignored, not refused. `rig agent validate <path> [--json]` runs these syntax checks through the running
daemon; it doesn't resolve imports, find skills or check that files exist, so a missing skill shows up only at launch.
---
## File System Layout
@@ -576,8 +395,8 @@ my-agent/
skills/
my-skill/
SKILL.md # skill content
plugins/
my-plugin/ # plugin (skills, hooks) with .claude-plugin/ and/or .codex-plugin/
hooks/
pre-commit.sh # hook script
runtime/
claude-settings.fragment.json # runtime-specific resource
```
@@ -588,20 +407,12 @@ The only required file is `agent.yaml`. Everything else is referenced by paths i
## Shipped Examples
Every agent below except `shared` imports `shared` and selects `plugins: [shared:openrig-core]`, which carries
`openrig-user`. Paths are under `packages/daemon/specs/agents/`; the kernel's own agents are under
`packages/daemon/specs/rigs/launch/kernel/agents/`.
| Agent | Location | Default runtime | Profile skills | Purpose |
|-------|----------|-----------------|----------------|---------|
| `shared` | `shared/` | — | — (resource pool only) | Shared skills, the `openrig-core` plugin and runtime resources |
| `implementer` | `development/implementer/` | claude-code | development-team, systematic-debugging, verification-before-completion | Implementation agent |
| `qa` | `development/qa/` | codex | development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood | Quality assurance agent |
| `orchestrator` | `orchestration/orchestrator/` | claude-code | orchestration-team, systematic-debugging, verification-before-completion | Rig orchestration lead |
| `independent-reviewer` | `review/independent-reviewer/` | claude-code | review-team, systematic-debugging, verification-before-completion | Independent code reviewer |
| `vault-specialist` | `apps/vault-specialist/` | claude-code | systematic-debugging, verification-before-completion, vault-user | Vault domain specialist |
| `product-designer` | `design/product-designer/` | claude-code | development-team, frontend-design, verification-before-completion | Product designer |
| `pm` | `product-management/pm/` | claude-code | office-hours, context-builder, requirements-writer, ui-mockup, plan-review, exec-summary, backlog-capture; `advisor` profile: orchestration-team | Product manager; its `advisor` profile is the factory's advisor |
| `analyst`, `synthesizer` | `research/analyst/`, `research/synthesizer/` | claude-code, codex | — | Research team |
| `conveyor-lead`, `-planner`, `-builder`, `-reviewer` | `conveyor/` | claude-code, codex, claude-code, codex | per role | Kept for the `factory-rsi` world bundle (its planner); no built-in team uses them |
| `factory-rsi-release-manager`, `factory-rsi-dogfood` | `factory-rsi/` | claude-code | per role | Used by the `factory-rsi` world bundle (not a built-in team) |
| Agent | Location | Imports | Profile Skills | Purpose |
|-------|----------|---------|---------------|---------|
| `shared` | `specs/agents/shared/` | none | — (resource pool only) | Shared skill pool for all built-in agents |
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, systematic-debugging, etc. | TDD implementation agent |
| `qa` | `specs/agents/development/qa/` | `shared` | openrig-user, development-team, etc. | Quality assurance agent |
| `orchestrator` | `specs/agents/orchestration/orchestrator/` | `shared` | openrig-user, orchestration-team, etc. | Rig orchestration lead |
| `reviewer` | `specs/agents/review/reviewer/` | `shared` | openrig-user, review-team, etc. | Independent code reviewer |
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, systematic-debugging, vault-user | Vault domain specialist |
| `design` | `specs/agents/design/` | none | — | Product designer |
+39 -628
View File
@@ -1,95 +1,13 @@
# Agent Startup Guide
Last validated: 2026-10-05, against main `9b88b118`
Applies to: OpenRig 0.6.8
Version: 0.2.0
Last validated: 2026-04-11
Applies to: OpenRig 0.1.x
This guide teaches you how to think about what goes into an agent's startup experience — what files to write, where to put them, and how the layering model delivers them. It is an authoring guide, not a schema reference. For field-level details, see `rig-spec.md` and `agent-spec.md`.
---
## Continue after a native consent prompt
If a fresh Claude seat is waiting at its bypass-permissions warning, review and
answer that warning in the seat's native pane. Unless the rig was launched with
`--non-interruptive` (see [non-interruptive mode](non-interruptive-mode.md)),
OpenRig does not accept it for you, and it never sends configured startup context
into the active dialog. The seat reports `attention_required` and retains that
context for the same occupant.
After accepting, run `rig seat continue <seat>` (or press `c` on the seat in the
TUI, shown while its context is pending). This delivers the pending context
without relaunching Claude, and its first prompt reminds the seat that this is a
fresh conversation and how to find its work. It does not replay a delivery that
already started. A timeout, or a refusal because nothing is pending, reports an
unknown outcome; inspect `rig seat status <seat>` before taking another action.
It is also refused while the prompt is still showing (`attention_required`), or
when the seat's binding or runtime has changed since launch.
Runtime readiness, startup delivery, and orientation proof remain separate;
a missing proof does not block this continuation.
Other fresh-launch prerequisites, such as login or workspace trust, use the same
continuation: resolve the prerequisite in the native pane, then run the displayed
command. `rig ps --nodes` shows it under "Startup details (<seat>)", and
`rig up` and `rig bundle install` print it as "Startup attention (<seat>)":
"After resolving it in <session>, run: rig seat continue <session>".
The attention codes that stop startup this way are `trust_gate`,
`hook_trust_gate`, `update_gate`, `login_required`, `mcp_gate`,
`bypass_consent_gate`, `codex_auth_refusal` and `codex_client_incompatible`.
They are recognized from each harness's English prompt text.
## Continue a Codex fork whose child ID arrived late
For a member with `session_source.mode: fork`, OpenRig waits for Codex's new
child thread ID before sending its role and other startup prompts. It saves the
ID and sends the role only for a verified child: the bound pane's foreground
process forks the requested parent, and its own native record, matched to its
start time, supplies the ID. This capture uses `runtime.readiness_timeout_seconds`
(30 seconds by default, configurable from 1 to 600 seconds), as does the later
readiness check. The two waits have separate budgets. A seat handover with
`--source fork:<seat>` is different: its fork carries the conversation itself,
and a missed ID capture leaves the ID pending rather than stopping the handover.
If the ID is still unavailable, the seat reports `attention_required` and keeps
its startup context pending. Wait for the child to finish starting, resolve any
native prompt, then run `rig seat continue <seat>`. The continuation verifies
that the bound pane is running a fork of the requested parent and reads the
child's own native identity before delivering the saved context. It does not
launch another fork or resume the parent. The parent ID is never saved as the
child's resume token.
An unavailable or conflicting identity leaves the context pending. Once prompt
delivery has been attempted, it cannot be replayed by `continue`, including
after a daemon restart: inspect `rig seat status <seat>` and the native pane
before deciding how to recover an uncertain delivery.
## After a restored seat's prompt
A restore that stops at a prompt, such as Claude's resume selection, records the
seat's restore outcome as `attention_required`. `rig seat continue` doesn't change
that record: it only delivers pending startup context. While the record stands, it
alone keeps the seat's lifecycle at `attention_required`, even with startup `ready`:
`rig ps --nodes` shows LIFECYCLE `att`, `needsAttention` is true, and the seat counts
in the rig's `attentionCount` and the TUI's attention totals.
After a full restore of a Claude or Codex seat, answering the prompt is enough. When
the seat's activity next turns to running or idle, or when the whole restore completes
after the seat already reads running or idle, the daemon checks the live seat: its tmux
session is alive, and the pane runs the agent resuming the conversation the seat
records. When the check passes, the restore outcome becomes `operator_recovered`, so
the restore no longer makes the seat need attention. When it fails, the restore outcome
stays and the seat still needs attention, which is true when the pane runs another
conversation. A check that started before a newer restore of the seat doesn't settle
the newer outcome.
`rig seat clear-attention <seat>` runs the same check on demand. When it fails, the
command says which check failed, and `--reason` doesn't bypass it. If
the seat records the wrong conversation and you know the right one, set it with
`rig seat set-resume-token <seat> --token-stdin --reason <text>`, then run
`clear-attention` again. After a restore of only some seats, or a community
harness's assisted startup, `--reason "<what you saw>"` can acknowledge the
outcome instead.
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
@@ -108,14 +26,14 @@ Context loading shapes what the agent knows, believes, and is capable of:
### Category 2: Deterministic Configuration
The rig spec declaratively installs things into the agent's runtime environment:
- Hooks (shipped inside plugins)
- Hooks (git hooks, pre-commit scripts)
- Permissions (`.claude/settings.json` allowlists, approval modes)
- MCPs (Model Context Protocol servers)
- System dependencies (tools, packages)
See the **Current Support Matrix** section at the end of this guide for what is reliable today vs experimental.
**The recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
**The v0.2.0 recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
---
@@ -200,35 +118,30 @@ Startup content is merged additively through layers. Each layer adds to what the
```
1. Agent layer — from the AgentSpec's top-level startup block
2. Profile layer — from the active profile's startup block
3. Culture layer — OpenRig's default culture, then the RigSpec's culture_file
4. Rig layer — from the RigSpec's top-level startup block
3. Rig layer — from the RigSpec's top-level startup block
4. Culture layer — from the RigSpec's culture_file
5. Pod layer — from the pod's startup block
6. Member layer — from the member's startup block in the RigSpec
7. Operator layer — added by OpenRig: openrig-start.md and, on a fresh start, the onboarding pack
7. Operator layer — injected at runtime (openrig-start overlay, context collector, etc.)
```
A member with a `starter_ref` gets that agent starter's files in front of the agent layer. Terminal seats
(`builtin:terminal`) skip the agent and profile layers.
### What Each Layer Is For
| Layer | Authored by | Purpose | Example content |
|-------|-------------|---------|-----------------|
| Agent | Agent spec author | Core identity and capabilities that travel with this agent type | Role guidance, default skills |
| Profile | Agent spec author | Profile-specific variations | Different skill sets for "default" vs "minimal" profiles |
| Culture | OpenRig, then the rig spec author | Team norms | OpenRig's `CULTURE-default.md` for every seat, then the rig's own `CULTURE.md` |
| Rig | Rig spec author | Rig-wide context for all agents | Project documentation |
| Rig | Rig spec author | Rig-wide context for all agents | Team norms, project documentation |
| Culture | Rig spec author | Rig constitution | `CULTURE.md` — communication, quality, operating philosophy |
| Pod | Rig spec author | Pod-specific coordination context | Pod SOP, intra-pod workflow |
| Member | Rig spec author | Individual member overrides | Member-specific instructions, cwd-specific context |
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md` (optional delivery); on a fresh start, `openrig-onboarding-01.md` and `-02.md` unless `onboarding.default_pack.enabled` is false |
After the files, OpenRig sends a session-identity message (rig, pod, member, logical ID) once the seat is ready.
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md`, context collector |
### Practical Guidance
**Most rigs only need three layers:** agent (role.md), culture, and the built-in operator layer. Start simple. Add pod and member layers only when agents in the same pod need different startup content.
**Most rigs only need three layers:** agent (role.md), rig (culture), and operator (openrig-start). Start simple. Add pod and member layers only when agents in the same pod need different startup content.
**The rig's own culture file is high-value and often skipped.** Every seat gets OpenRig's default culture, but that says nothing about how this team communicates and coordinates. Write a `CULTURE.md`. Even a short one dramatically improves team coherence.
**The culture layer is high-value and often skipped.** A rig without a `CULTURE.md` relies on agents to guess how the team communicates and coordinates. Write one. Even a short culture file dramatically improves team coherence.
**Member-level startup is for exceptions, not the rule.** If every member has its own startup block, the layering model is being used as a configuration dump. Refactor shared content up to the pod or rig level.
@@ -240,8 +153,8 @@ After the files, OpenRig sends a session-identity message (rig, pod, member, log
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Depends on the file | Resolved per file: a `SKILL.md` (or content starting `# SKILL`) is `skill_install`, any other `.md` is `guidance_merge`, and any other file is `send_text` | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged as a managed block into the repository `AGENTS.md` (Codex, OMP), a fresh Pi seat's own agent-directory `AGENTS.md` (see [Runtime Differences](#runtime-differences)), or `CLAUDE.md` (Claude; `CLAUDE.local.md` with the rig's `managed_blocks: { claude-code: CLAUDE.local.md }`). A fresh launch of Codex 0.164.0 or later can carry it as launch developer instructions instead (see [Codex guidance at launch](#codex-guidance-at-launch)) | Role guidance, culture, project context |
| `auto` | Before harness boot | System chooses | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged into `CLAUDE.md` / `AGENTS.md` as a managed block | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
@@ -254,183 +167,14 @@ Files delivered via `send_text` happen AFTER the harness is ready. The agent rec
- Instructions to load skills (the files are already projected, the message tells the agent to read them)
- Context that should feel like an operator briefing, not pre-loaded content
On a fresh launch the first message is one turn: the session identity, the first `send_text` file and, when a startup
proof is selected, its challenge (below).
A file's `required` field defaults to `true`: a required file that can't be delivered fails startup, and an optional
one is dropped without a message.
### Readiness, startup proof and failure
**Readiness.** After launch OpenRig polls the pane until the harness is interactive (backing off 1, 2, 4, 8 and 16
seconds), for up to 30 seconds by default. Change the window with
`rig config set runtime.readiness_timeout_seconds <1-600>` (or `OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS`); it
applies to the next launch. A recognized prompt (the attention codes above) ends the wait at once as
`attention_required`. A timeout ends startup as `failed` ("Readiness timeout after Ns — harness did not become
interactive"), and the post-launch files aren't sent. A Codex fork that the rig launches (a member with
`session_source.mode: fork`, or an agent image's fork) is the exception: its timeout ends as `attention_required`,
with its startup context kept for `rig seat continue` (see below). A seat handover's fork has its own readiness check.
**Fresh Claude handovers.** `rig handover --source fresh` keeps the previous agent's scrollback. Before sending the
handover context, OpenRig waits for the latest prompt inside Claude's complete input frame, and confirms that the
process in the pane belongs to the new launch. A shell's `❯` prompt or echoed launch command is not enough, even
with an old Claude header or input frame above it and Claude already running. A header alone or a partially drawn
input frame also keeps the check pending. If the current screen or process cannot be read, or the process identity is unconfirmed, the handover keeps
waiting within the readiness window and fails if that window expires. Old screen text or the `claude` process label
alone cannot complete this check. A healthy agent can therefore remain unconfirmed while those observations are
unavailable; inspect its pane before deciding how to recover.
**Claude submission check.** For each startup message to a Claude seat, OpenRig makes a bounded check that the prompt
was submitted. When the capture can't tell, it records the submission as unverified and carries on. When it sees the
same prompt still sitting in the input, it presses Enter once more; if it's still there, it warns "Startup prompt still
staged in <session>; press Enter in that pane." The seat can still end `ready` with its submission `unverified` or
`staged`, so `ready` doesn't prove the prompt was submitted. After all files and actions are delivered, readiness is
checked once more, so a prompt that appears by then gives `attention_required`.
**Startup proof.** A `startup_proof` action selects `authenticated` or `none` (the default is none; the last applicable
one wins, and it needs `idempotent: true`). With `authenticated`, a fresh launch of an agent seat includes a challenge,
and the seat answers it with `rig startup-proof submit --challenge-id <id> --answer <answer>`. `rig ps --nodes --full`
shows the result in the ORIENTED column: `verified`, `missing`, `rejected` or `n-a`. A missing proof doesn't block startup.
**After a failure.** Delivery, launch and action failures, and readiness timeouts other than a Codex fork's, give
`failed`. Every 30 seconds the context monitor marks a `failed` or `attention_required` seat `ready` once its pane reads
ready, unless its fresh or Codex fork context is still pending for `rig seat continue`. A seat that timed out can therefore read `ready` without having received its
post-launch files. If it needs them, first look at what that occupant has done; then
`rig seat launch <seat> --fresh --reason <text>` starts a blank conversation for the seat. If the occupant is still
live, the command refuses with `session_live` unless you also pass `--stop`, which replaces that occupant deliberately.
**Preview before replacing a seat.** `rig seat launch <seat> --fresh --plan` inspects the selected
seat without starting tmux, stopping or launching a process, projecting startup files, or changing
lifecycle/queue records. Add `--json` for structured output. `--fresh` is still required;
`--reason` is optional for the preview and required when executing. Pass `--stop` to preview the
intended replacement of a live managed occupant. Adopted and unmanaged occupants remain blocked.
The plan identifies the target, current occupant, stop requirements, blockers, startup counts,
and each sibling seat's unchanged effect. Siblings stay attached and untouched. Launch composes
a new command through the runtime adapter using current settings; it does not replay a stored
wrapper command. The seat's launch environment and managed executable selection still apply.
The plan does not construct or display the launch command.
A preview is a snapshot, not a reservation or a native-readiness check. Unreadable tmux produces
`complete: false` and `launchable: null` (unknown), unless a known blocker already proves
`launchable: false`. A successful preview exits 0, including blocked or incomplete plans; scripts
must inspect `launchable === true` before deciding to execute. Request failures still exit nonzero.
Execution always resolves and checks the current state again. An older daemon without the preview
endpoint refuses the request; the CLI never falls back to executing a launch.
### Change an existing seat's working directory
Editing `cwd` in an already applied RigSpec doesn't update an existing seat's saved launch directory. Use `set-cwd` to
save a new one for the seat's next launch:
```sh
rig seat set-cwd planning-lead@project \
--cwd /work/project/seats/planning-lead --reason "isolate planning"
rig seat status planning-lead@project
```
**The path:** it must be absolute **on the daemon's host**. OpenRig creates a missing directory, resolves symlinks to
its real path, and refuses a filesystem root.
**What it saves:** the directory and a `node.cwd_changed` audit event (from, to, reason, operator), together, in one
transaction. The seat's node, canonical address, queue, session history and skill selection stay as they are.
`rig seat status` shows **Working directory for future launches** (`launch_cwd` in JSON). That's saved configuration,
not where the running process is.
**What it writes:** it creates the folder if it's missing, and writes nothing else there. The seat's next fresh launch or
rebuild handover writes its startup files there, as launch does today.
**`set-cwd`'s preflight policy:** before saving, it reads, without writing, the enumerated resource copies (including
nested plugin files) and startup skills that launch would place in the new directory, at their actual destinations,
as they are at that moment. It's a point-in-time check, not a global overwrite guarantee or protection against later
changes; at adoption, launch applies its own policy. (The commit's re-check covers the saved startup configuration,
not the destination's file contents.)
- **Refused, with the protected paths named (`cwd_destination_conflict`):** a file there that an operator edited, an
unrecorded file with different content, or two sources for one destination. That's stricter than launch, which
overwrites an unrecorded differing file with a warning; the refusal is deliberate, while the occupant still runs.
- **What to do:** use a clean destination, or move or merge those files, then retry.
- **Not checked:** managed guidance blocks and native configuration merges keep the adapter's policy at launch, and
launch-owned files such as `.openrig/context-collector.cjs` are written by launch as always.
- **A community harness** takes no startup files, so none are checked for it.
**The running occupant keeps its current directory.** Adopt the new one with:
- `rig seat launch <seat> --fresh --stop --reason <text>`, which starts a blank conversation; or
- `rig handover <seat> --source rebuild --reason <text>`, which carries durable context.
For Codex rebuilds, OpenRig also carries a complete supported native permission policy read from the live
predecessor's own conversation and process, without adding fresh-launch grants. A later recorded seat permission
or policy choice, or a differing declared successor profile, takes precedence. Missing, stale or unsupported observations keep the existing launch
behaviour and are reported as unverified in the handover result; requested modes and profiles alone don't prove
preservation. The result reports what was emitted, not native successor equivalence.
**Adopt it promptly.** The saved directory doesn't move the running process, but background work can use it before
adoption. Saved configuration isn't the live process's directory, and these examples aren't the only consumers. For a
running Claude seat:
- **Collector provisioning** writes `.openrig/context-collector.cjs` and merges `.claude/settings.local.json` in the
saved directory, under its existing preservation rules.
- **Managed compaction,** manual (`POST /api/compaction/trigger`) or automatic, asks the seat to write its restore map
under it. That may be outside the seat's current workspace, so a seat without bypass permissions may meet a write
prompt.
Adopting promptly is guidance. It doesn't protect against a poll or a compaction that runs before your next command.
**Before adopting,** don't `rig down`/`rig up` or hand over by fork. Those resume the existing conversation in the
saved directory, and Claude keeps conversations per project directory, so a resume there may not find it.
Cross-directory resume hasn't been run natively; this is the order to follow. `rig seat restart --keep-conversation`
(Codex) isn't adoption: it respawns in the directory it observes from the running process.
**Failures:** a refusal, missing or malformed saved startup context, or an audit-write failure leaves the prior
directory saved, and changes no seat, custody or startup file (a refusal may leave the new folder created). A concurrent configuration change is reported, not overwritten. A CLI timeout means the outcome is
unknown; check `rig seat status` before retrying. Choosing the same directory again records no second event.
**The old directory:** files there are left as they are, since other seats may still use them. The source RigSpec
declaration isn't changed either; update it separately if a future recreation should use the new directory.
### The `applies_on` Field
Each startup file and action specifies when it applies:
- `fresh_start` — delivered when a new conversation starts: the first launch, `rig seat launch --fresh`,
`rig seat continue`, and seats restored with `--fresh`
- `restore` — delivered when a seat is restored without its earlier conversation: a restore whose resume falls back to a fresh conversation, or a retried start
- `fresh_start` — delivered on first launch only
- `restore` — delivered on restore from snapshot
- Default: `[fresh_start, restore]` (both)
**An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up <rig> --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: the guidance file's managed blocks are brought up to date before the harness starts. A missing block is put back, and a stale one is refreshed (see below).
**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance.
**A block you edited by hand is never overwritten.** OpenRig records what it last wrote in each managed block, so a launch, an exact resume, a handover or a runtime switch can refresh a block whose source file changed since. The record follows the file itself, so two links to one file share it. A block you've edited since OpenRig wrote it is left as you wrote it. The start's result names it (`guidance_block_edited`), and the seat is told in one line once it's ready, or in a handover's packet. A block OpenRig has no record of writing (any block written before these records were kept) that differs from its source works like this:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
---
## What a seat is set to, and what it's running
A seat's settings can disagree with its running agent: what's saved for the next launch, what the running process
actually uses, and what its conversation has already read are three separate things. OpenRig reports each one
separately. Without live evidence it says unknown (or not verified), never the saved value. A saved change reaches the
running seat only through the action in the last column.
| What | Saved for the next launch | What the running seat has | How the running seat gets it |
|---|---|---|---|
| Model | `rig seat set-model` | `rig ps --nodes` shows the configured model beside the last observed one, or `UNKNOWN (<reason>)` | The next managed resume or successor launch |
| Reasoning effort | `rig seat set-effort` | `rig seat effort <seat>`: requested, saved, sent at launch, and what the session recorded, which is not verified until it takes a turn | Its next managed continuation; nothing restarts |
| Permissions | `rig seat set-permissions` (`--mode`, `--rules`, or for Codex `--codex-permissions` or `--codex-profile`), shown by `rig seat status` | What it launched with; a status read doesn't prove the running process enforces the selection | Its next managed launch or resume ([per-seat permission mode](getting-started.md#per-seat-permission-mode)) |
| Working directory | `rig seat set-cwd`, shown by `rig seat status` as **Working directory for future launches** | The directory it started in | `rig seat launch --fresh --stop` (a blank conversation) or `rig handover --source rebuild` (durable context); [details](#change-an-existing-seats-working-directory) |
| Launch wrapper (`launch_wrapper`) | Member `launch_wrapper` in `rig.yaml` (`pi`, `codex` and `claude-code` only; `launch_wrapper: []` clears, omitting retains on existing-seat apply; surfaced as `launchWrapper` in `rig ps --nodes --json` when set; no live `rig seat` edit verb) | The literal wrapper argv that prefixes the resolved Codex entry or Claude executable (preserving TTY stdio) or the resolved Pi child executable inside OpenRig's host runner (preserving RPC pipes and runner lifecycle) when that session launched; a configured prefix is not proof of isolation, tooling, memory or governance | Supported spec create/recreate/apply flow, then the seat's next managed launch, resume, restore or handover (fails closed when wrapper support is unavailable; never falls back to bare child launch; [details](rig-spec.md#launch-wrapper-runtime-pi-and-codex)) |
| Startup files and skills | The spec's startup files, skills and actions | What its conversation read when it started; editing a file afterwards doesn't reach it | A fresh launch or a handover delivers them again; for the running seat, send or walk the change (`rig walk <seat> --through <files>`) |
| Tools (MCP servers, plugins) | Selected fragments, merged into the harness's own config (`.mcp.json` for Claude, `config.toml` for Codex) | Unknown to OpenRig: registered isn't active, Claude asks before it uses a new server, and no OpenRig command reports which tools a running harness loaded | Check in the harness itself (Claude Code's `/mcp`); a running session may need a restart to pick up newly written config ([runtime config disclosure](#runtime-config-disclosure)) |
Per harness: Claude Code's and Codex's permission choices are in the setup reference
([Claude Code](getting-started.md#claude-code-a-different-launch-flag),
[Codex](getting-started.md#codex-select-sandbox-and-approvals-together)), Codex homes are
[below](#separate-codex-homes-for-separate-installations), community harnesses' limits are in
[their presets](community-harness-presets.md), and moving a live seat to the other runtime is in the
[setup reference](getting-started.md#move-a-seat-to-the-other-runtime).
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
---
@@ -440,10 +184,15 @@ The AgentSpec and RigSpec allow declaring deterministic environment configuratio
### What the Spec Supports
**Hooks** ship inside plugins: declare the plugin under `resources.plugins[]`. AgentSpec validation refuses
`resources.hooks`. OpenRig loads one plugin's hooks into a seat itself: the shipped `openrig-core` plugin, for a Claude seat
that selects it (see [the refocus channel](refocus-channel.md#the-mechanism)). The folder a declared plugin is projected into
is not one Claude Code or Codex loads, so a plugin of your own does not get its hooks run by that projection.
**Hooks** (in `resources.hooks`):
```yaml
resources:
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
```
Hooks are scripts copied to the agent's workspace. They can be git hooks, automation scripts, or environment setup.
**Runtime resources** (in `resources.runtime_resources`):
```yaml
@@ -465,31 +214,23 @@ startup:
phase: after_ready
idempotent: true
```
Each value is typed into the harness's input and submitted, so it is a prompt or a slash command, not a shell command.
Types are `send_text`, `slash_command` and `startup_proof` (see "Startup proof" above); `shell` is refused.
`idempotent` is required, and an action that isn't idempotent must not list `restore` in `applies_on`. Both phases
run after the harness is ready: `after_files` (the default) after the post-launch files, then `after_ready`.
Commands sent to the agent's terminal after it's ready. Can install MCPs, run setup commands, etc.
**Don't rename a managed Claude seat.** OpenRig starts every Claude seat with `--name <session>`, on a fresh start and on
every resume, and its own reads of the seat use that name: context usage and managed compaction read the status-line
record Claude saves under it, and the input box is recognized by the name on its border. A startup `/rename` to another
name breaks those reads until the next resume puts the session name back.
### Current Support Matrix
### Current Support Matrix (OpenRig 0.1.x)
| Capability | Status | Notes |
|------------|--------|-------|
| Guidance file projection (`guidance_merge`) | **Supported** | Reliable. Primary delivery mechanism. |
| Skill projection (`skill_install`) | **Supported** | Reliable. Skills are copied to workspace. |
| `send_text` delivery after ready | **Supported** | Reliable. Requires harness to be ready. |
| Hooks | **Through plugins, limited** | `resources.hooks` is refused. OpenRig loads the shipped `openrig-core` plugin's hooks into a Claude seat that selects it (Claude Code v2.1.280 or later); a plugin you declare under `resources.plugins[]` is projected, but its hooks are not loaded. |
| Hook projection | **Experimental** | Files are copied but execution/integration varies by runtime. |
| Runtime resource projection | **Supported for recognized fragments** | `claude_settings_fragment`, `claude_mcp_fragment`, and `codex_config_fragment` are applied to provider config. Unknown types are copied to runtime extension directories. |
| Permission configuration | **Native settings plus managed launch flags** | OpenRig launches Claude with `acceptEdits` and Codex with `workspace-write` unless an explicit supported selection changes them; a seat with no policy, selection or named Codex profile also gets the per-launch [team default](rig-spec.md#team-launch-defaults), and the `kernel` rig's seats get a wider operational default ([rig spec, "At launch"](rig-spec.md)). Neither is written to a settings file. Use [the first-user permission guide](getting-started.md#opt-in-permissive-operation) for opt-in and custom choices. |
| MCP installation | **Supported for Claude fragments** | A selected `claude_mcp_fragment` is merged into the project's `.mcp.json`. Claude asks to approve new servers found there; that prompt stops startup as `mcp_gate` until someone answers it and runs `rig seat continue`. Otherwise use `/mcp` or `claude mcp add`, or describe the servers in startup files for the agent to configure. |
| Permission allowlisting | **Supported via harness config** | Core OpenRig boot adds the minimal Claude `Bash(rig:*)` baseline. Additional policy should be supplied through spec-selected runtime resource fragments or native Codex config profiles, not adapter hard-code. |
| MCP installation | **Experimental** | Claude Code: `/mcp` interactive command or `claude mcp add` from CLI. Can also be described in startup files for agent self-configuration. Reliability depends on runtime TUI state. |
| System dependency installation | **Not deterministic** | Describe in startup files; agent handles via shell commands. |
| Recurring tasks / wake timers | **Runtime-dependent** | Claude Code supports recurring tasks via the `/loop` command. Codex does not have a confirmed equivalent. Orchestrators should include `/loop` instructions in startup for Claude Code agents. |
### The Approach: Describe, Then Let the Agent Handle It
### The v0.2.0 Approach: Describe, Then Let the Agent Handle It
For anything beyond `guidance_merge`, `skill_install`, and `send_text`, the recommended approach is:
@@ -505,117 +246,20 @@ This way, when deterministic support becomes fully reliable, the agent will boot
OpenRig performs best-effort deterministic runtime configuration for managed sessions. Core bootstrap stays minimal; user/custom policy belongs in spec-selected runtime resources. These writes are intentionally invasive and should be disclosed plainly:
- Claude global config: `~/.claude/settings.json`
OpenRig no longer writes a core `Bash(rig:*)` permission allowance here. Older
installations may retain one; existing user settings are not removed.
Purpose: allow core `rig` commands without repeated Claude permission prompts.
- Claude global state: `~/.claude.json`
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
Explicit permission modes use the launch-selected `HOME/.claude.json`, or
`<CLAUDE_CONFIG_DIR>/.claude.json` when that variable is set. Classic startup
retains the daemon-home path.
- Claude project-local config: `.claude/settings.local.json`
Purpose: apply context collector/activity hooks and selected `claude_settings_fragment` resources inside the project without committing them to git.
- Claude project-local MCP config: `.mcp.json`
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
- Codex global config: `$CODEX_HOME/config.toml`, or `~/.codex/config.toml` when unset
- Codex global config: `~/.codex/config.toml`
Purpose: pre-trust managed workspaces and apply selected `codex_config_fragment` resources. Codex currently has no equivalent project-local MCP config path for global profile settings.
- Global skill folders: `~/.claude/skills` and `~/.agents/skills`
Purpose: daemon startup seeds the `openrig-skills` and `refocusing` skills and the person-facing `rigs` skill. Each
upgrade refreshes the `rigs` skill; a copy installed another way is left as it is.
Two important caveats:
- these writes are best-effort and should still be paired with startup guidance so the local agent can verify and repair them if needed
- already-running adopted sessions may need restart before they pick up newly written config
Permission mode and runtime resource projection are separate. A selected fragment
can affect native configuration, but OpenRig's launch flags can override those
values. Recording a config-surface `permission_policy` is not proof that its
rules were translated or applied. See [permission precedence and limits](getting-started.md#custom-settings-and-precedence).
### Separate Codex homes for separate installations
Set an **absolute `CODEX_HOME` in the environment that starts the daemon** when
you want a separate Codex configuration and state root. OpenRig uses that root
for its startup hook setup/removal and trust records, feature flag, workspace
trust and selected config fragments. Startup can write those files even when
the daemon has no seats.
The explicit selection also applies to future managed Codex fresh, resume and
fork launches, their capability/profile/configuration probes, native thread and
context reads, and plugin-cache discovery. Launch commands reassert it after
pane shell startup, so an rc file cannot silently switch that launch to another
Codex home. Executable and PATH selection stay the same. Existing running
sessions are not moved or restarted; a thread in another home is not searched
as a fallback for an explicitly selected home.
**With `CODEX_HOME` unset, a second install still shares `~/.codex`.** Setting only
`OPENRIG_HOME` separates OpenRig state, not provider configuration. The default
launch behavior is unchanged, including existing pane-shell overrides.
OpenRig does not copy or migrate authentication, history, rules, trust or
settings into the selected home. A separate file-backed home needs its intended
authentication provisioned if absent; OS credential stores and other config
layers can behave differently, so a new login is not universally required.
Explicit Codex auth commands resolve their **caller's** `CODEX_HOME`, otherwise
`$HOME/.codex`; contacting a daemon with `OPENRIG_URL` does not retarget auth
save/switch/registry writes to that daemon's selection. Set the intended root
on those commands as well.
This separates provider files, not OS users or accounts. Project-local resources
still use the selected project directory; shared project and system settings
remain shared.
### Codex guidance at launch
On a fresh launch of Codex 0.164.0 or later, OpenRig passes a seat's managed guidance (OpenRig's start file and the
rig's and project's context, culture and SOP files) with the launch, as `-c developer_instructions=…`, instead of
merging it into the repository `AGENTS.md`. Neither the tracked `AGENTS.md` nor the Codex home is written for it, and
both still load as usual. The text opens: "OpenRig seat guidance for this session follows. Where it conflicts with
AGENTS.md guidance in the repository or the Codex home, follow AGENTS.md."
A launch value replaces a configured one, so this happens only when no Codex config layer OpenRig reads sets
`developer_instructions`. OpenRig reads:
- `/etc/codex/config.toml` and `/etc/codex/managed_config.toml`;
- the Codex home's `config.toml`, profiles included, and the selected profile's `<profile>.config.toml`;
- each `.codex/config.toml` from the seat's working directory up.
The Codex home it reads is the seat's own (a `CODEX_HOME` in its `env:`, or a recorded account switch), else the
daemon's `CODEX_HOME`, else `~/.codex`.
The guidance still goes to the repository `AGENTS.md`, as before, when:
- a layer OpenRig reads sets `developer_instructions`, or can't be read. A launch warning names the file;
- the launch resumes or forks a conversation. Developer instructions on a resume haven't been tested, and an exact
resume puts the guidance back in the repository `AGENTS.md`;
- the seat is restored, even when the restore starts a new conversation. A restore keeps the guidance file its launch
recorded, which for Codex is the repository `AGENTS.md`;
- a handover successor starts (fresh, fork or rebuild). Its launch carries no guidance of its own, so the handover
first puts the seat's missing blocks back in the repository `AGENTS.md` from its saved startup context;
- Codex is older than 0.164.0, or its version is unknown.
A seat's role still arrives as startup text, and the seat-skills line, after the person's yes, still goes to
`AGENTS.md`.
A launch that carries developer instructions writes no new blocks to the repository `AGENTS.md`. But blocks an earlier
handover or resume left there are refreshed from their sources, by the same rule as every other managed block. That
matters because the launch text asks Codex to follow `AGENTS.md` where the two differ.
**If your shell sets `CODEX_HOME`.** When neither the seat nor the daemon declares a `CODEX_HOME`, a seat's pane keeps
its own, and OpenRig reads `~/.codex`. A `CODEX_HOME` that only your shell startup sets (in `~/.zshrc`, for example),
with the daemon started without it, isn't seen. A `developer_instructions` set in that home is then replaced by the
launch value for the session. To have it checked, declare that same absolute path as `CODEX_HOME` in the member's or
rig's `env:` (see [Launch environment](rig-spec.md#launch-environment), "A seat's own account"). OpenRig then reads
that home at the seat's next fresh launch, and a `developer_instructions` set there keeps the repository `AGENTS.md`.
The seat keeps the same home and login.
**What it doesn't cover.** OpenRig doesn't read Codex's packaged defaults, or MDM- or enterprise-managed settings. A
`developer_instructions` set there may be replaced for the session. Legacy MDM-managed config ranks above launch flags,
so it can override OpenRig's guidance, and the seat then gets none of it.
**Not established.** The order Codex assembles developer instructions and `AGENTS.md` in, and how it settles every
conflict, aren't established; the opening sentence asks it to follow `AGENTS.md`. The native behaviour was observed on
Codex 0.164.0 alone, on fresh launches without tools.
### Runtime Differences
**Claude Code:**
@@ -630,162 +274,13 @@ Codex 0.164.0 alone, on fresh launches without tools.
- Reads from `AGENTS.md` and `.agents/` directory
- Recurring task support is limited — no confirmed equivalent of Claude Code's `/loop` command
- MCP configuration mechanism differs from Claude Code
- Approval policy and sandbox access are separate controls. OpenRig's default
`-s workspace-write` selects the sandbox; it does not force `-a`. A member's
`codex_config_profile` selects native `-p`, distinct from the AgentSpec `profile`.
- On that plain `-s workspace-write` launch (fresh, resume, fork or restore), a
short-lived `codex app-server` first reads Codex's own configuration with the
seat's executable, home and working directory. OpenRig adds
`-c sandbox_workspace_write.network_access=true` only when no configuration
layer sets network access and no managed requirement could restrict it. A
timeout, error or unrecognized answer adds nothing. Named profiles and full
bypass are not read. The read may write Codex's own state files in
`CODEX_HOME`, read its login and fetch managed policy, as a Codex start does.
- Managed launches pass `-c check_for_update_on_startup=false`, so Codex skips its startup update check; your Codex
config is not changed.
- Approval mode controlled via launch flags (`-a`, `-s`, `--full-auto`)
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
**Pi and OMP:**
- Read `AGENTS.md` in the working directory, added to Pi's own system prompt. Pi also reads the one in its agent
directory: on a fresh launch, a Pi seat's guidance is merged there, under OpenRig's state, not into the
repository's. When that file or its directory is a link, the repository `AGENTS.md` is used instead. A restore keeps
the file the seat launched with. OMP keeps its guidance in the repository `AGENTS.md`
- Selected skills are projected into the seat's own agent directory under OpenRig's state, not into a shared
workspace folder
For Pi tool-selection diagnosis and deliberate in-place recovery, see
[Pi tool-selection recovery](pi-tool-selection-recovery.md). It distinguishes
configured, currently intended, registered and active tools, including the TUI/RPC boundary.
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
### Claude compaction preparation directory
Managed Claude compaction asks the seat to write a restore map before `/compact`. By default,
OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when
that directory cannot be prepared. A filesystem write check cannot establish whether Claude's
file-edit permissions allow the seat to write there.
If the launch workspace is denied by Claude's permissions, select an absolute preparation root:
```sh
rig config set policies.claude_compaction.preparation_root /absolute/path/to/restore-maps
```
Grant the seat file-edit access to that root in its Claude configuration. OpenRig does not change
Claude permissions; choose a directory outside any matching deny rule. The corresponding environment
variable is `OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT`.
New manual and automatic attempts use `<root>/<session>/<attempt-id>/RESTORE-MAP.md`; an in-flight
attempt keeps its original path through the post-compaction restore request. The root gets an
exclusive `.gitignore` containing `*`, and the daemon publishes the marked `.tmp` file with an
atomic rename. Use a dedicated root: a conflicting
ignore file or an unwritable configured directory stops preparation with
`preparation_directory_unavailable`, visible in `rig compact <session> --state`, without choosing
another location. Fix the directory and explicitly retry. `--skip-map` still skips the map once.
Reset with `rig config reset policies.claude_compaction.preparation_root` to restore the default.
The setting applies to new attempts without restarting the daemon.
The managed `/compact` command keeps its 600-character budget. When the selected map path or
the attempt's compaction instruction does not fit, the existing restore request carries it;
it never substitutes a newer preparation root or instruction. An explicitly selected root's
published map is also named in that request when the path fit in `/compact`. The default root
keeps the existing command/restore handoff. Under `--skip-map`, restore still recognizes an
optional map completed at that attempt's path, or says that no map was required.
The compaction preparation and command-submission tests cover manual and automatic attempts,
long paths and instructions, settings changed during preparation, directory failures and
late maps after cancellation or expiry. They use synthetic delivery and filesystem fixtures;
they do not prove a live Claude session's permission choices or compaction behavior.
### Codex compaction and sizing
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
`policies.claude_compaction.*` automatic policy, and the watchdog jobs that the `managed-compaction` and
`apprentice-handover` strategies create. A Codex seat compacts with Codex's own automatic compaction, and OpenRig
leaves that to Codex.
- `rig compact <session>` refuses a Codex seat: "not a Claude (claude-code) seat".
- `rig compact-plan` lists a Codex seat as skipped (`codex_not_managed_by_claude_compact_in_place`).
- The automatic policy never acts on a Codex seat.
- A Codex member with `compaction_strategy: managed-compaction` or `apprentice-handover` gets no watchdog jobs.
**What Codex compacts at.** Codex describes `model_auto_compact_token_limit` as the "token threshold that triggers
automatic history compaction (unset uses model defaults)". Its configuration reference does not say what those
defaults are, and the model catalog's `auto_compact_token_limit` is null for the models we checked in Codex 0.162.1.
Measure a seat's own compactions before relying on an unset limit.
**What window the model has.** `codex debug models` prints the catalog. Each model carries `context_window`,
`max_context_window` and `effective_context_window_percent`. In Codex 0.162.1, `gpt-5.6-sol` has a `context_window`
of 272000 at 95%, which is 258400. That is the `model_context_window` its rollout `token_count` events report.
A limit only matters when it is below that effective window. Leave `model_context_window` alone: don't raise it toward
`max_context_window` to avoid compaction. Set the limit instead.
**Setting the limit for seats only.** Put `model_auto_compact_token_limit` in a Codex profile file,
`$CODEX_HOME/<name>.config.toml` (`~/.codex/<name>.config.toml` when `CODEX_HOME` is unset), and name it on each
Codex member that should use it:
```toml
# ~/.codex/seat-compaction.config.toml
model_auto_compact_token_limit = 150000 # choose your own value
```
```yaml
codex_config_profile: seat-compaction
```
OpenRig passes `-p seat-compaction` on that seat's fresh, fork and resume launches. Codex layers the profile file on
top of the base user config, so only seats that name the profile see the lower limit. A Codex session started without
that profile keeps the base `config.toml` in the same Codex home (`$CODEX_HOME/config.toml`, or `~/.codex/config.toml`
when `CODEX_HOME` is unset). The `150000` is an example, not a recommendation.
With the normal launch mode, `codex_config_profile` replaces OpenRig's explicit workspace-write sandbox flag, so the
profile must set the sandbox and approvals you want. See
[permission precedence and limits](getting-started.md#custom-settings-and-precedence), which also gives Codex's order
of precedence between a profile, project settings and CLI overrides.
**The trade-off.** Every model call carries the whole context so far, so a smaller context makes each call cheaper. The
cost is that Codex compacts sooner and more often, so more of the conversation is carried as a summary instead of
verbatim. A higher limit keeps more detail and makes each call larger.
**Related keys.** These also shape compaction. This guide doesn't recommend values for them. See Codex's
[configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference):
| Key | What Codex says it does |
|---|---|
| `model_auto_compact_token_limit_scope` | `total` (the default) counts the full active context; `body_after_prefix` counts only growth after the carried compaction-window prefix. |
| `compact_prompt` | Inline override for the history compaction prompt. |
| `experimental_compact_prompt_file` | Loads the compaction prompt override from a file (experimental). |
| `tool_output_token_limit` | Token budget for storing individual tool or function outputs in history. |
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
---
## Files OpenRig writes into a project
These are the files OpenRig writes into a seat's working directory, and what `rig down` does with each. Paths are
relative to that directory.
| Path | When OpenRig writes it | What `rig down` does |
| --- | --- | --- |
| Managed blocks in `CLAUDE.md` (or the rig's `managed_blocks` file, such as `CLAUDE.local.md`) or `AGENTS.md` | Each launch merges the seat's guidance blocks. A fresh Codex launch that takes developer instructions puts them in its launch text instead: in `AGENTS.md` it adds only the seat-skills line, after your yes, and refreshes blocks already there. A fresh Pi seat writes its own agent-directory `AGENTS.md` outside the project, except when that folder or file is a link: then its guidance stays in the repository `AGENTS.md`. | Removes OpenRig's blocks, current or legacy, and keeps everything else. When nothing else is left, it deletes the file if the file began with an OpenRig block, or held only blocks in an older format. A block-only file isn't proof that OpenRig created it. The seat-skills line is kept if you edited it. |
| `.openrig/context-collector.cjs` | Claude seats, at launch. | Removes it while its bytes are still the shipped collector's; otherwise keeps it and says so. |
| `.claude/settings.local.json` | Claude seats, at launch: OpenRig's status line and its activity hooks are merged in, separately. Your own status line is never replaced, but the activity hooks are still merged beside it. A file that doesn't parse gets neither. | Removes OpenRig's status line and hooks and keeps the file, even if `{}` is all that's left. |
| `.claude/settings.local.json` fragments and `.mcp.json` | When a profile or spec declares `claude_settings_fragment` or `claude_mcp_fragment`. | Leaves the merged content in place. |
| `.claude/agents/`, `.claude/plugins/<id>/`, `.claude/extensions/<id>/`; `.agents/<id>.yaml`, `.codex/plugins/<id>/`, `.agents/extensions/<id>/` | When the seat's projection includes subagents, plugins or runtime resources (Claude, then Codex). | Leaves them in place. |
| `.claude/skills/<id>/`, `.agents/skills/<id>/` | Only for a seat without seat storage. New Claude and Codex seats keep their skills under `$OPENRIG_HOME/state/seats/`, outside the project. | Leaves them in place. |
| The managed compaction preparation folder described in [Claude compaction preparation directory](#claude-compaction-preparation-directory), with its `.gitignore` of `*` and each attempt's `RESTORE-MAP.md` | Managed Claude compaction, unless a preparation root is configured elsewhere. | Leaves it in place. |
| `.rigged-checkpoint.md` | A restore that didn't resume a seat's native conversation (by choice or not) and had a checkpoint to hand it. | Leaves it in place. |
`rig down` leaves a file alone while another seat still uses it: a running seat of the same rig, or any seat of
another rig that isn't archived. That is decided file by file. A guidance file is kept while another seat's
guidance targets the same file. The Claude settings and collector are kept while another Claude seat works in that
directory. So another rig's Codex seat keeps `AGENTS.md` without keeping `CLAUDE.md` or the Claude settings. It
removes `.openrig/` and `.claude/` only when they are empty.
`rig down --delete` does nothing more to project files. OpenRig doesn't edit your project's own `.gitignore`.
## Patterns and Anti-Patterns
### Good Patterns
@@ -798,89 +293,6 @@ profile → uses.skills: [openrig-user]
```
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
For seats sharing a working directory, keep each role in its own AgentSpec startup entry:
```yaml
startup:
files:
- path: guidance/role.md
orientation: role
delivery_hint: send_text
required: true
```
`rig queue whoami --json` reports only the calling seat's explicitly marked role
bindings, independently of its current work. The `role.state` is `no-record`,
`not-declared`, `missing`, `present`, or `unknown` when identity, storage or the
observation is unavailable. Files retain their original `path`, `absolutePath`
and `ownerRoot` plus the currently resolved `resolvedPath` and
`resolvedOwnerRoot`. Built-in packaged paths follow the running installation;
existing development-checkout paths stay where they were recorded.
`recordedAt` is the startup-context record write/attempt, which happens before
launch succeeds. Exact resume can retain an older record. `present` means the
resolved file currently exists, not that its bytes still match the spec or that
the agent read it. Unmarked startup files and startup actions are not exposed.
Refocus includes the role path and a re-read reminder with topology or both trees,
even if the trace fails. Work-only mode omits it; disabling refocus still disables
the entire hook. Custom refocus prose keeps its existing precedence. The hook
reuses its work lookup, or makes one bounded lookup when the work node is explicit;
a failed or skipped lookup reports `unknown`.
The shipped kernel seats no longer project their role into shared guidance.
Existing user files and legacy role blocks are left intact; this is not a cleanup
of already-installed shared blocks. General user-authored guidance projection is
unchanged.
The other shipped agent specs that start a seat with `guidance/role.md` mark that
file `orientation: role` too, so `rig queue whoami --json` reports their role and
refocus names it. They never projected a role into shared guidance; their startup
delivery is unchanged.
**Removing a leftover shared role block by hand.** A kernel installed before 0.6.5
may still hold one seat's old role in its working directory's `CLAUDE.md` (or the
rig's configured `managed_blocks` file) or `AGENTS.md`. Upgrading doesn't remove it.
`rig down` removes OpenRig's managed blocks from that file when no other running
rig uses it, so it stays until then. To remove it sooner, by hand:
1. Find the exact block: the lines from `<!-- BEGIN OpenRig MANAGED BLOCK: role -->`
through `<!-- END OpenRig MANAGED BLOCK: role -->` (in older files, `RIGGED`
instead of `OpenRig`). Only the block whose id is exactly `role`; leave
`rig-role`, other ids and everything outside the markers alone.
2. Confirm each seat that shares that directory has its per-seat replacement:
`rig queue whoami --json` run in the seat reports `role.state` as `present`.
A seat started before it was upgraded still has the old content in its
conversation; restart it fresh if you want it to start from the per-seat role.
3. Keep a copy of the file, remove only those lines, and compare the result with
the copy: nothing else in the file should differ.
**The kernel's own folder.** A newly created default kernel works in
`<workspace.root>/kernel`, beside your projects (`<workspace.root>/projects` by
default) rather than above them. Claude reads `CLAUDE.md` from a seat's folder
and from every folder above it, so a kernel working in the workspace root put its
guidance into every Claude project seat below it. If that folder can't be made,
the new kernel works in the workspace root, as before.
A kernel that already exists keeps its folders, usually `workspace.root`.
Upgrading moves no seat, changes no restored conversation and removes no file.
To move an existing kernel yourself, if you want to:
1. Save the new folder for each kernel seat with `rig seat set-cwd <seat> --cwd
<workspace.root>/kernel --reason <text>`, using the absolute path. It creates
the folder ([details](#change-an-existing-seats-working-directory)).
2. Choose when each seat adopts it. A seat keeps its current folder until it
starts again, and the new start doesn't continue its current native
conversation: `rig seat launch <seat> --fresh --stop --reason <text>` starts a
blank one, and `rig handover <seat> --source rebuild --reason <text>` carries
durable context into a new one. Nothing does this for you.
3. Tidy the old `<workspace.root>/CLAUDE.md` (or `AGENTS.md`) by hand, once no
running rig's seat still works in that folder: a seat there still reads those
blocks, and `rig down` checks the same before it removes them. Remove only
OpenRig's managed blocks, each from its `<!-- BEGIN OpenRig MANAGED BLOCK: … -->`
line through its `END` line, as in the steps above, and keep everything else,
including your own text. Don't move or delete the whole file.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
@@ -899,8 +311,8 @@ This prompts the agent to actually invoke the skills, not just have them as pass
## System Check
After identity recovery, verify:
1. `rig ps --nodes` shows your rig running (scoped to your session's rig by default; outside a managed session name it explicitly: `rig ps --nodes --rig <name>`)
2. `rig env status <rig>` shows services healthy (if applicable)
1. `rig ps --nodes` shows your rig running
2. `rig env status` shows services healthy (if applicable)
3. Your working directory is correct
4. Required tools are available (node, npm, git, etc.)
@@ -936,12 +348,11 @@ If your rig REQUIRES a hook to function and the hook installation fails silently
When creating a new agent's startup experience:
- [ ] Write a `guidance/role.md` — who is this agent?
- [ ] Reference it in `startup.files` with `orientation: role` and `delivery_hint: send_text`. Avoid projecting distinct seat roles into one shared `AGENTS.md` or `CLAUDE.md`.
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
- [ ] Choose skills from the shared pool via profile `uses`
- [ ] Write a `startup/context.md` if the agent needs environment grounding
- [ ] Include a system check in the startup context
- [ ] Verify the agent spec validates: `rig agent validate agent.yaml`
- [ ] Verify the rig spec validates: `rig spec validate rig.yaml`
- [ ] Run the advisory authoring check: `rig spec audit rig.yaml`
- [ ] Test by launching the rig and checking that the agent received the expected content
-227
View File
@@ -1,227 +0,0 @@
# Agent state taxonomy
The formal activity language OpenRig's surfaces render from — the TUI and the `rig ps`
ACTIVITY column. One oracle computes it; surfaces render it. Two exceptions remain: the web
UI's graph node still derives its own running/needs-input/idle/unknown from the legacy
`agentActivity`, and `rig ps --active` / `--filter agentActivity.state` filter on that legacy
field. Startup, orientation, restore and attention states are separate vocabularies, listed
under "Other state on the same surfaces" below.
**This document is the canonical text for the shipped taxonomy** and adds the
engineering reconciliation; it never forks a second vocabulary. Its typed source of
truth is `packages/daemon/src/domain/activity-taxonomy.ts`.
## The three orthogonal axes
| axis | values | question it answers |
|---|---|---|
| session | `present` · `detached` · `exited` · `absent` | does a process exist |
| activity | `working` · `idle-at-prompt` · `unknown` (+ needs-input as count+reason, below) | what a present agent is doing |
| resumability | `live` · `resumable` · `context-walled` | what a non-present agent can come back as |
Only the activity axis is served today. The session and resumability values are declared in
`activity-taxonomy.ts` but no surface reads them: `rig ps --nodes --full` shows the raw session
STATUS, both node views show a LIFECYCLE of `running`, `detached`, `recoverable` or `attention_required` (`run`, `det`, `rec`,
`att`), and node inventory serves `occupantLifecycle` (`active`, `retiring`, `retired`,
`context_walled`, `compacted`, `crashed`, `unknown`). Node inventory's `sessionStatus` is the
recorded session status, with two observed corrections: a running session whose pane is verified
to have exited (identity reason `pane_exited`) reads `exited`, with `startupStatus:
"attention_required"`, and a running session confirmed missing reads `detached`.
The axes never blend. Reachability is strict; revival optimism is a different field
(omnigent's strict-liveness split). `unknown` is a first-class honest value — the oracle
saying it cannot tell. Unknown beats a confident wrong answer.
### needs-input is a count + reason, never a status value
Blocked-on-input rides as `{ count, reason }` — the number of outstanding prompts plus a
short phrase naming why nothing is moving ("permission prompt", "usage limit",
"classifier hold" — provider chrome and usage limits are explicitly in-axis: they are
the second cause of production-observed parks). Human surfaces may *display* "needs-input"
(the four-value human list) via the one bridge `deriveDisplayActivity`, which
renders needs-input whenever `count > 0`; no store or transition ever holds it as a
state. Attention/alarm values likewise stay in their own machinery (`attentionCount`).
Both herdr and omnigent converged on this exclusion, with warning comments on the
collision in both codebases.
The legacy `agentActivity` field distinguishes a typed provider interruption from
a request for a person: fresh Codex `at_limit` and Claude `StopFailure/rate_limit`
events carry `state: unknown` and `reason: usage_limit`, never a `needs_input`
hook. A permission prompt can independently supply human-input evidence. See
[Claude usage-limit detection](claude-status-line.md#claude-usage-limit-detection)
for the separate provider signal and queue timer.
### Per-seat attention in the node inventory
`rig ps --nodes` carries the daemon's `needsAttention` result, using the same predicate
as the rig's `attentionCount`. It also serves `arbitratedNeedsInput`: `true` for observed
input, `false` for an observed clear, and `null` when no authoritative needs-input source
answered. `needsInputEvidence` retains that source's rung and observation time, or `null`.
A zero prompt count alone is not an observed clear. Absent arbitration evidence leaves
the existing live-hook fallback in effect; lifecycle, startup, held and error signals
can independently raise attention. A false attention flag means no known trigger, not
proof that an unobserved input box is clear.
A handover whose successor started without a required file also raises attention. That's
a projected guidance file or a guidance startup file marked `required` still missing from
the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup
file that's missing. The seat serves `startedWithoutRequired` with each file's path, the
table's reason column names each file, and the handover's own output says it's degraded.
The mark belongs to that successor's session, so the seat's next launch or handover
clears it. Time alone never does. Repairing the file in place doesn't clear it either,
because no route delivers guidance into a running conversation.
A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files,
the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the
successor to read the file; it isn't the pasted text a start sends. A fork successor
carries its parent's conversation and gets no list. `send_text` startup actions, such as
an `after_ready` preload, aren't run for a successor.
The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is
unavailable. Compact JSON retains `needsAttention`; use `--full` or
`--fields needsAttention,arbitratedNeedsInput,needsInputEvidence` with `--json` for the
provenance. The TUI uses the served flag for attention glyphs and totals, and shows the
needs-input evidence in seat detail, including a hook-only prompt. With an older daemon,
attention stays unknown while its existing prompt, startup and error hints remain visible.
## Derived diagnoses — computed at read time, never stored
- **PARKED** — the disease: `(activity = idle-at-prompt OR needs-input pending) ×
(open obligations OR unhealthy HELD rows)`. A dropped baton or an unanswered block. When
activity is `unknown` the verdict is `indeterminate`, never parked or not-parked. Queryable
at rig and seat level with `rig parked [seat] [--rig <name>] [--json]`; the join lives in the
parked query, never inside the oracle (the oracle's non-inference contract: it never reads
queue state).
- **HELD** — the deliberate counterpart: a queue-level hold with a named owner and an
armed wake (`rig view show held`). Stopping on purpose is HELD; stopping by accident is
PARKED. A HELD row is healthy only while its wake is live, and not fired-but-unconsumed
unless the stuck sweep owns its recovery; an unhealthy HELD row on a stopped seat makes
the seat parked.
- **DONE-UNSEEN** — finished work whose completion nobody consumed (herdr derives it as
idle ∧ unseen). Declared as a concept only; no surface computes it yet.
## Reconciliation with prior art
| ours | herdr (`src/detect/mod.rs:11`) | omnigent (`schemas.py:2767`) |
|---|---|---|
| `working` | `Working` | `running` |
| `idle-at-prompt` | `Idle` | `idle` |
| needs-input count+reason | `Blocked` + `visible_blocker` override | `pending_elicitations_count` + `blocked_on` phrase — **not** a status |
| `unknown` | `Unknown` ("plain shell or unrecognized program") | — |
| derived DONE-UNSEEN | derived `Done` = idle ∧ unseen (`src/ui/sidebar.rs:186`) | — |
| session axis | server owns the PTY (presence direct) | `runner_online` × `host_online` × `host_resumable` |
| — (rejected) | — | `waiting` (turn parked on async drain — collides with Claude's dialog `waiting`; both codebases carry warning comments) |
## The evidence ladder (rung inventory and retirement conditions)
State is computed by ONE arbitration point (`SeatActivityService`) from ranked,
time-bounded evidence. Rungs, top down, with each rung's retirement condition:
- **r3 — Claude `sessions/<pid>.json` self-report** (busy/shell/idle/waiting, since
v2.1.139): standing, above hooks for working/idle. Undocumented internal — unreadable
means fall down the ladder, never error.
- **r2 — lifecycle hooks** (Claude SessionStart/UserPromptSubmit/PostToolBatch/Stop/StopFailure/Notification;
Codex SessionStart/UserPromptSubmit/Stop/PermissionRequest): standing for Claude; Codex's
hook rung starts at trial and is promoted after 50 agreeing observations over at least an
hour. Exactly-once turn boundaries; SubagentStop filtered (it
can fire post-turn and must never revive an idle seat). Hook-rung authority is
TIME-BOUNDED: the hook rung is the only rung that does not self-date its evidence, so
persistent cross-rung contradiction degrades it to identity-only, visibly, with a
rung-health event.
- **r4 — visible needs-input chrome**: standing; outranks self-report for the
needs-input signal only (a dialog you can see beats a hook that said working).
- **r1 — window-activity sampling** (today's oracle): the fallback tier. Retires
PER-HARNESS when that harness's hook rung earns trust through a measured agreement
window in production — never by fixture pass alone (symmetric admission: a fixture
pass admits a rung to TRIAL, where it is measured but not consulted; production agreement
promotes it). For Codex, this fallback also accepts a fresh working status adjacent to
the composer, or an advancing counter across two fresh captures when output separates
them. This positive evidence expires after five seconds; unrecognized, frozen, old or
pre-occupant frames add nothing. It uses the existing pane capture. Undeclared and partial-coverage rungs are identity-only, which is also where
a degraded rung goes, with one declared exception: for a pi or omp seat or a community
harness, sampling is positive-only: a changing pane reads working, and a quiet one never reads idle on its
own, since the pane can be quiet mid-turn.
- **pi and omp runner hooks**: OpenRig's runner reads the child's RPC event stream and posts
each turn boundary (`agent_start`, `turn_start`, tool calls, `agent_end`; attention). These
decide working, idle and needs-input. Unlike Claude and Codex hooks, an idle or attention
boundary holds until the next one instead of expiring after 15 s; a working boundary still
expires, so a turn whose end was lost reads unknown, not working forever. Posts are taken
in the runner's own order (time, then its sequence), so an older boundary arriving late
doesn't win. The runner's report that its child
exited reads unknown, not idle. A boundary the runner didn't see (if a runtime ended a turn
without `agent_end`) leaves the seat working up to the 15 s window, then unknown, never idle. After a
daemon restart, a runner seat takes its last recorded boundary from the hook store, for its
current session and occupant generation only, so an idle seat still reads idle before it
posts again, provided that report is fresh: a pi runner's idle boundary states an interval
(below) and lapses to unknown once it is older than two of them.
A pi runner also posts an idle `Stop` (`idle_heartbeat`) every three minutes while it waits, and states that
interval as `livenessSeconds` on every `Stop`: a newly ready runner reads idle at once, and an idle boundary that
carries an interval holds for two of them, then lapses to unknown, so a runner killed without saying so stops reading
idle. Silence never clears a pending attention prompt, and a runner that states no interval (omp, an older pi
runner) keeps the hold-until-the-next-boundary behaviour.
Claude permission notifications raise needs-input immediately. A visible permission can clear
after it has been observed and then disappears. A notification whose dialog was never observed
can also clear when Claude reports `PostToolBatch`: every tool in that actor's batch has resolved,
so its earlier permission requests are no longer pending. The relay carries the native actor
identity; a subagent or in-process teammate finishing its batch cannot clear the main session's
request, and overlapping actors remain separate. An individual `PostToolUse` is insufficient
because another tool in the batch may still be waiting for permission.
Batch completion must come from the current occupant and have a later source timestamp than
the matching notification. Missing actor attribution from an older relay, equal or older
timestamps, and another actor's completion retain attention. An unrecognized or missing screen
does not expire attention by age. `Stop` and `UserPromptSubmit` remain the turn-boundary fallback.
The oracle remembers each actor's latest completed batch for the current occupant, so a strictly
earlier notification arriving late cannot restore the mark, even if the completion arrived first.
A notification with an equal or later timestamp remains eligible; swapping or retiring the
occupant discards its remembered completions.
Completing a batch changes the needs-input signal without inferring that the seat is idle or
overwriting a later elicitation. The hook contract is documented in
[Claude's hooks reference](https://code.claude.com/docs/en/hooks#posttoolbatch).
Partial-coverage honesty: a source with partial lifecycle coverage gets identity-only
trust, unless its inventory declares it positive-only. A positive-only source can report
working and nothing else; its silence reads unknown, never idle. The ladder is also the migration path — new oracles slot in above existing
heuristics, lower rungs retire a release at a time, consumers never see a flag day.
Seat-keyed: state attaches to the durable seat nodeId, never the occupant. A handover or
generation swap is its own visible event — never an activity transition — and triggers
rung-inventory re-declaration: a successor never inherits its predecessor's rung
authority (hooks live in occupant config while the pane is seat-persistent).
## Other state on the same surfaces
These are separate vocabularies, not activity values. `rig ps --nodes --full` shows them in
its STARTUP, ORIENTED, LIFECYCLE and RESTORE columns (the default compact view shows only
LIFECYCLE); see the
[startup guide](agent-startup-guide.md) for how each arises.
- **Startup status:** `pending`, `ready`, `attention_required`, `failed`. `ready` means
startup finished, never that the seat is oriented, and a seat isn't marked `ready` while
its startup context is still pending for `rig seat continue`.
- **Oriented:** `verified`, `missing`, `rejected`, `n-a`, from the startup proof.
- **Attention codes:** `trust_gate`, `hook_trust_gate`, `update_gate`, `login_required`,
`mcp_gate`, `bypass_consent_gate`, `codex_auth_refusal`, `codex_client_incompatible`.
- **Restore outcome:** `resumed`, `fresh-primed`, `awaiting-decision`, `attention_required`,
`failed` (plus `rebuilt`, `operator_recovered` and the legacy `fresh`). A seat joined with
`rig attach` as an external CLI reads `left_attached`: restore leaves it as it is.
An `attention_required` outcome, or `failed` with a running session, makes the seat's
lifecycle `attention_required`, whatever its startup status, so it raises `needsAttention`.
After a full restore, the daemon reconciles it to `operator_recovered` when the seat's
activity next turns to running or idle (or reads so when the restore completes) and the
live seat passes its check, and `rig seat clear-attention <seat>` runs that check on
demand; `rig seat continue` doesn't change it
([startup guide](agent-startup-guide.md#after-a-restored-seats-prompt)).
## Rejected oracles — dated receipts (2026-08-26), so nobody re-derives them expensively
- **transcript-quiescence as an activity oracle: REJECTED.** omnigent's 1-second version
oscillated on mid-turn lulls and idempotently locked out real completions; its
surviving 5-second variant is annotated a stopgap awaiting a hook replacement.
Distinction held: transcript growth remains FINE as the refocus hook's cadence
trigger — a different purpose, and the one permitted consumer.
- **pane-scraping as primary truth: REJECTED.** herdr — whose TOML screen manifests are
the state of that art — subordinates them to hooks wherever hooks cover the lifecycle,
and marks viewer screens `skip_state_update` because scrollback lies. Pane reading
stays fallback-tier only.
-74
View File
@@ -1,74 +0,0 @@
# Integration author contracts — DRAFT, first published with 0.7.6
OpenRig composes agents into a team. An integration author supplies the pieces that let a new harness participate, or let an existing harness run in a new environment. This guide divides that work into two contracts:
1. **[Harness and runner](harness-runner-contract.md):** launch the agent, report its conversation identity and activity, deliver input, handle permission requests and cancellation, and reopen the requested conversation.
2. **[Execution and platform](execution-platform-contract.md):** choose placement, deliver files and credential references, distinguish owned from attached resources, preserve native state, return artifacts and reconcile shutdown.
These are draft author responsibilities. Some use implemented OpenRig interfaces; others describe a proposed integration whose code remains with its author. Including the guide in release documentation does not make every described capability available.
## Release and support status
This draft was published with **OpenRig 0.7.6** (tag [`v0.7.6`](https://github.com/mvschwarz/openrig/tree/v0.7.6), source `c00c6321`). Its implemented-interface baseline is commit [`b6bd4633`](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63). That base includes the runner declaration and `passes_model` changes. The published 0.7.6 adds release repairs and documentation on top of that base. **OpenRig 0.7.8** adds seat activity grants, OpenRig's side of the answered permission path and the maintained OpenShell reference driver (`rig environment`); the rows below that say 0.7.8 describe it. References below use the immutable base instead of a moving branch, except the community preset reference, which is the in-tree copy.
| Area | Status | What the status means |
|---|---|---|
| Community launcher presets, RigSpec members and AgentSpec role/startup declarations | Implemented interfaces | They compose configuration. A launcher must implement its declared effects; successful parsing does not prove delivery or native behavior. |
| `runner: true` with `launch_result: true` | Released in 0.7.6 | The declaration is retained at launch, and Core uses the runner identity/activity path. It does not supply a runner executable. |
| `passes_model: true` | Released in 0.7.6 | Passes the declared model through `OPENRIG_MODEL`. It does not observe or verify the effective native model. Cursor and Hermes examples leave it off pending native qualification. |
| The dsh/ACP input, cancellation and answered permission recipe (a person answers, and the default is permit) | OpenRig's side released in 0.7.8; runner side shown on two runners, not qualified in general | OpenRig asks a person for a runner's permission requests and returns the answer. In 0.7.8's release testing on a Claude Haiku model, dsh-seat 0.3.0 (dsh 0.2.1-rc.2 over ACP) showed input, activity, cancel, exact resume and shutdown, and the dsh example runner showed a person's allow-once and reject-once; see the [runner contract](harness-runner-contract.md)'s status for what wasn't shown. Native reopen capabilities and the tools-off case remain implementer questions. |
| Exact native resume via an author launcher | Existing interface; integration-specific evidence | A requested conversation must reopen exactly or fail honestly. One provider/harness result does not establish portability to another. |
| Environment preparation, projection, artifact export and owned-resource cleanup | OpenShell reference driver released in 0.7.8; generic seam proposed | The platform chapter makes the responsibilities reusable. Core ships one maintained reference driver, OpenShell 0.1.6, as `rig environment`. It doesn't provide a generic platform driver or `DeploySpec` loader. |
| `HarnessSpec`, `DeploySpec`, generic lifecycle fields and wake-on-demand declarations | Proposed vocabulary or unsupported configuration | They are not new accepted YAML keys. Use existing composition and an explicit author implementation. |
| Cross-host native resume, provider-wide cancellation or permission support | Unresolved unless qualified by an integration | Unsupported and untested are distinct from observed failure. Publish the actual boundary and version. |
## How the pieces compose
```mermaid
flowchart TD
R["RigSpec: members, relationships, work arrangement"] --> A["AgentSpec: role, context and startup declarations"]
R --> H["Built-in runtime or community preset"]
H --> N["Harness / runner: native conversation and control"]
H --> P["Author platform integration: placement and resources"]
P --> N
P --> F["Workspace, retained state and returned artifacts"]
R --> S["Optional application services"]
```
**RigSpec remains the composer.** AgentSpec describes reusable agent intent; the runtime or preset selects how the native agent starts. An integration can surround that launch with an environment-specific wrapper. A bundled application's database or Compose service is software the team operates; it is distinct from the environment in which an agent executes.
The terminal provider, native harness protocol and network connection also have different jobs. A terminal provider carries input and output; a runner understands native turn boundaries; a granted MCP or other connection exposes coordination operations. An HTTPS connection does not by itself provision compute, awaken a suspended application or establish native resume. Each integration states which of those effects it implements.
| Owner | Responsibility |
|---|---|
| OpenRig Core | Team and work identity, queue records, existing launcher contracts, activity interpretation and declared configuration. |
| Harness/runner author | Native launch and protocol, exact native reference, prompt framing, truthful activity, permission/cancel behavior and native resume. |
| Platform integration author | Placement binding, destination projection, resource ownership, state transfer, artifact delivery and resource reconciliation. |
| Operator / credential owner | Selected environments, grants, provider credentials and spending authority. |
The queue records who owns work. It does not prove that an old provider process has stopped, that a new runtime consumed its history, or that guest bytes reached the consumer. Those observations belong in the launch, resume and export receipts alongside the work record. This division reuses the queue rather than creating another task system.
## Start from an existing example
For a terminal harness, begin with the [community preset reference](community-harness-presets.md) and the pinned [Cursor](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/cursor) or [Hermes](https://github.com/mvschwarz/openrig/tree/b6bd4633fbd94634a2b4bdef2574a6880f97be63/docs/reference/community-harness-examples/hermes) directory. Their presence is source guidance, not an effective-model or complete integration guarantee.
For a structured runner, the [runner chapter](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch) supplies a minimal preset, launch record and activity message. The executable named by the preset must still be implemented and installed by its author.
For a contained worker, the [platform composition](execution-platform-contract.md#a-minimal-composition-using-existing-surfaces) pairs an ordinary coordinator with a worker preset. It uses existing member and preset fields. The wrapper must prepare or attach to the selected environment explicitly; declaring a runtime does not provision it. Its illustrated member list is a fragment, and its launcher is a skeleton, not a ready-to-run provider package.
A rig bundle can carry declarations, roles and supporting scripts. Do not confuse those files with hydrated image layers, installed binaries, registered presets, credentials or live sessions. State what the recipient must supply.
## Lifetime and recovery
**Persistent** and **Non-persistent** describe execution lifetime. A non-persistent invocation has an explicit completion condition tied to its assigned work. **Durable** describes interruption recovery and is independent of that lifetime. An agent can run briefly and still resume its retained conversation later.
Retaining a workspace and reconstruction material can restore useful competence without restoring the same native conversation. Label that as reconstruction. Native resume additionally requires the correct harness-qualified reference and supported native state. The platform chapter's measured example restored copied history into recreated containers on the same host; it does not establish new-host migration or RAM restoration.
## Questions contributors can help resolve
- At a pinned dsh version, which advertised method reopens a session, and can a useful first case run without permission requests?
- Which additional harness would justify a shared protocol runner rather than a separate wrapper?
- Which provider effects can use the existing projection, native-reference and lifecycle dispatch, and which need a new maintained interface?
- What exact state and ownership must travel when compute is recreated or moved?
These questions remain part of the draft. An unanswered contributor thread does not prevent authors from using the implemented interfaces or reviewing the proposed responsibilities. Compatibility claims should name versions, observed behavior and unsupported operations; they should not imply a support guarantee from a manifest alone.
-49
View File
@@ -1,49 +0,0 @@
## Browser access and allowed addresses
The OpenRig daemon answers its API only when a request is addressed to this machine and, if it
comes from a web page, only when that page is the OpenRig UI or a page you allowed.
### What works with no setup
- The CLI, the TUI and agents on this machine, addressing the daemon by `localhost` or an IP
address.
- Other OpenRig hosts and clients that reach this daemon by its IP address, its own hostname, or
its own Tailscale name (for example `my-machine.your-tailnet.ts.net`, or the short `my-machine`).
The daemon learns its own Tailscale name automatically from Tailscale on this machine.
- The web UI, when it is turned on (`rig config set ui.enabled true`, then restart the daemon) and
opened over `http` on the daemon's own port at any address the daemon accepts, including its
Tailscale name. A UI page served any other way, such as over `https` through your own domain,
also needs its exact origin in `OPENRIG_ALLOWED_ORIGINS`, then a daemon restart.
### When you see a refusal
The daemon answers `403` with a sentence that says what to do. There are two kinds:
- **The address is not recognized** (`untrusted_host`). You reached the daemon by a name it does not
know as its own, such as a custom DNS name, an alias in `/etc/hosts`, or a reverse proxy domain.
Use `localhost`, an IP address or the machine's own name instead, or list the name you use:
`OPENRIG_ALLOWED_HOSTS=rig.example.com` in the daemon's environment, then restart the daemon.
A client that presents the daemon's configured bearer token is not refused for its address
(it still needs whatever access each route requires).
- **The web page is not allowed** (`browser_origin_refused`). A browser page that is not the
OpenRig UI at the daemon's own address called the API. This includes another local app or
development server on a different port. To allow a page, list its exact origin:
`OPENRIG_ALLOWED_ORIGINS=http://localhost:5173` in the daemon's environment, then restart the
daemon. A bearer token does not override this.
`OPENRIG_ALLOWED_HOSTS` lists names you use to reach the daemon. `OPENRIG_ALLOWED_ORIGINS` lists
web pages allowed to call it. They are separate: a UI served through your own domain usually needs
both. Separate several entries with commas. Pages on other origins cannot read the refusal; the
message is for you, the CLI and agents.
### Limits
- If you rename the machine in Tailscale, the daemon keeps accepting its previous name until it
restarts or until it next looks its name up after seeing an unrecognized one.
- If the daemon has not reached Tailscale's resolver since it started, its Tailscale name is
refused until a lookup succeeds. A later failed lookup keeps the name it already learned.
`localhost` and IP addresses keep working.
- This protects against unknown web pages and unknown names. It is not complete browser isolation:
a page on another site can still cause a simple read request to an address the daemon accepts,
although it cannot read the answer. Keep the daemon on loopback or your tailnet.
-209
View File
@@ -1,209 +0,0 @@
# Bundle formats (v1)
The shared data formats for sharing rig bundles: the configurations a bundle offers, the before-install view of what it
will do, run records and the public status file, and registry entries. The schemas are in [`schemas/`](schemas/), with
fixtures that pass and fail them in [`schemas/fixtures/`](schemas/fixtures/). Tools outside this repository read them
at a pinned commit.
**A v1 format only grows:** new optional properties may appear, and consumers ignore properties they don't know. Any
other change is a v2. The schemas are open to new properties, with three closed on purpose: the package digest (an exact
value), a run record's subject (a harness check can never carry a team's identity), and the public status file, where
nothing outside the schema may appear, so a private field can't leak in. A new property in a closed part is a v2. The
status schema also rejects labels that contradict their evidence: a tested label needs records and a package, tested
with help needs its count, tested can't carry a count above zero, and an unreadable listing carries only Status
unavailable.
## Three identities, kept apart
| What | Name | Defined by |
|---|---|---|
| What someone chose | configuration ID | the mapping of every seat to a runtime |
| What was packaged | package digest | the built archive's files |
| What actually ran | execution binding | a run record (resolved resources, settings, environment, unknowns) |
None stands in for another. A package digest doesn't describe what ran, and a configuration ID doesn't describe what was
packaged.
### Source identity
A bundle on GitHub is named by `repository` (`https://github.com/<owner>/<repo>`, no credentials), `folder` (the
repo-relative path of the folder holding `rig.yaml`, or `.` for the repository root) and `resolvedCommit` (40 lowercase
hex).
- `requestedRef` (the branch, tag or commit someone gave, or `HEAD` for a link without `/tree/…`) and `canonicalUrl`
(`…/tree/<resolvedCommit>/<folder>`, with each folder segment percent-encoded and no folder part for the root)
explain it, and aren't part of it.
- A built bundle records its source in its manifest's provenance, which is outside the package digest.
### Configuration ID
Every member's `pod.member=runtime`, sorted by `pod.member` in UTF-16 code-unit order, joined with `,`. There's no
whitespace, and runtimes are spelled as in `rig.yaml`. For example:
```text
dev.build=claude-code,dev.qa=claude-code,dev.review=codex,orch.lead=codex
```
Preset names such as `recommended` or `all-claude` are aliases shown beside the ID, never part of it.
### Package digest
`{ algorithm: "sha256", value, coverage: "openrig.package-digest/v1" }`:
- `value` is SHA-256 over UTF-8 lines `<quoted path>\t<sha256>\n`, one for each entry of the built archive's
`integrity.files`, sorted by path in UTF-16 code-unit order;
- `<quoted path>` is the path as a JSON string, exactly as JavaScript's `JSON.stringify(path)` writes it, so a tab or
newline in a file name can't make two different file lists hash the same. In words: wrapped in `"`; `"` and `\`
escaped with a backslash; U+0008, U+0009, U+000A, U+000C and U+000D written `\b`, `\t`, `\n`, `\f` and `\r`; any
other character below U+0020, and any lone surrogate, written `\uXXXX` in lowercase hex; everything else as itself;
- it's the same when the same folder is rebuilt by the same OpenRig, while the archive's own hash changes with every
build (`createdAt`).
**Coverage, shown wherever the digest is:** the bytes of every packaged file. It doesn't cover:
- `bundle.yaml`, so no manifest field, provenance or `createdAt`;
- the names the integrity walk skips (`.DS_Store`, `Thumbs.db`, `.gitkeep`);
- file modes;
- anything resolved on the installing machine at launch, for example an `openrig-home:` plugin, catalog skills, or a
model or effort nothing declares.
**A build result** carries `source` (`null` unless the bundle was built from a GitHub link), `configurationId`,
`packageDigest`, `archiveHash` and `assembler`. `rig bundle inspect` reports the same fields for an archive, and any the
archive doesn't state are `null`. `assembler` is `{ openrigVersion }`: the format also allows a `commit`, which this
OpenRig doesn't write. `configurationId()` and `packageDigest()` are exported from `@openrig/daemon/bundle-identity`.
Shared test vectors are in `schemas/fixtures/identity-vectors.json`.
## The formats
| Format | Schema | Lives | Written by |
|---|---|---|---|
| Declared configurations | `bundle-configurations.v1` | `configurations.yaml` beside `rig.yaml` | the bundle's author |
| Before-install view | `bundle-behaviour.v1` (`openrig.bundle-behaviour/v1`) | the registry, one file per configuration and assembler version; never inside the archive | the daemon's inspect: `rig bundle inspect`, under `behaviour` with `--json` |
| Run record | `run-record.v1` | beside its receipt, private | Fleet, Dev QA, and maintainers for community reports |
| Public status | `bundle-status.v1` | `openrig-registry` status, generated | the status generator only |
| Registry entry | `registry-entry.v1` | `openrig-registry/registry/<slug>.yaml` | maintainers, through review |
### Declared configurations
`seats` gives a `pod.member` the runtimes it may use, and the profile each runtime uses. A member it doesn't list keeps
the runtime `rig.yaml` gives it. `presets` names full mappings, and `recommended` names one of them, which must be
`rig.yaml` as written. `rig bundle create`, `rig bundle configurations` and `rig bundle check` refuse a file that breaks
these rules.
`--preset` and `--seat` choose a configuration (see the [rig bundle reference](rig-bundle.md)). Any mix of each seat's
declared runtimes can be built, not only the presets; a runtime a seat doesn't declare is refused, and nothing is built.
Without either flag, create builds `rig.yaml` as written. The built archive's manifest records
`configuration: { id, preset }` outside the package digest; `preset` is there only when `--preset` or `--seat` chose a
mapping that matches a declared preset.
### Before-install view
It's derived from the archive's files alone. Nothing is launched, probed or fetched. It's a view, never a gate, and
carries no tested status.
- **`identity`:** source, configuration ID, package digest, the archive's assembler, the generator that made the view,
stated compatibility, stated provenance (not verified), and integrity (self-consistency, not authorship). A source,
configuration ID, package digest or assembler the archive doesn't state is `null`; unstated compatibility and
provenance are empty objects. None of it is filled from the inspecting OpenRig.
- **`team`, `posture`, `toldFiles`, `alsoRuns`, `writes`, `outsideAddresses`, `needs` and `unknownBeforeLaunch`**, in
that order. Facts cite the archive file they come from (`sourceRefs`), except `team` entries and the bundle-wide
unknowns. `toldFiles` and `alsoRuns` also say how each resolves: `archive`, `host_at_launch` or `unresolved`.
- Posture separates what the archive declares from the product's default. Its effect on the host is `unknown`,
because host settings decide it.
Optional `posture[].permissionPrompts` is `off` for declared `full_bypass` (including rig- or member-level
`builtin:yolo`) on any runtime; for Claude/Codex, `on` for a declared flag-surface floor policy or `default` when
no policy is declared (prompts on unless the installing machine's settings turn them off); for Codex, `on` for
declared `auto`. Omit it for Claude under auto (naming auto mode in `selection`), config-surface policies
(`builtin:locked`, `builtin:standard`, `builtin:open` or an archived `surface: config` policy), host-resolved
Codex profiles, `permission_policy: none`, a policy the archive can't resolve, and every runtime other than Claude
and Codex (Pi, terminal) except declared full bypass. `selection` names the declared policy.
An absent field means not stated, never `on`. For `off`, Claude
bypasses permissions, Codex runs with full access and never asks, and Pi gets full resource trust (`--approve`,
not a permission mode). These are archive facts; `nativeEffect` remains `unknown`.
Optional `posture[].firstRunWarnings.claudeBypass: "harness_asks_once"` names the first-run bypass warning for a
declared full-bypass Claude seat, conditional on whether the harness has remembered acceptance.
`posture[].nonInterruptive: "available"` says a full-bypass Claude/Codex seat can use
[non-interruptive launch flags](non-interruptive-mode.md). It does not select the option, read host acceptance
state or prove a prompt-free launch. Both fields are omitted where inapplicable or unresolved; older v1 views
remain valid without them. Optional `posture[].nonInterruptiveDefault` (boolean) carries the rig's authored
`non_interruptive` for those seats; an explicit launch choice overrides it. When it is `true`, `firstRunWarnings`
is omitted. Pi has no additional warning-suppression flag. `nativeEffect` stays `unknown`.
- An empty list means none are known. Something unknown goes in `unknownBeforeLaunch`.
- Per-seat facts carry `seat` (`pod.member`) and human output labels them with that identity. It's required on
`team` and `posture`. Elsewhere it's optional: bundle-wide facts (services, library and project writes, setup and
version needs) omit it, and older generators may not have stated it.
- **`not_generated`:** a combination without a generated view, or an archive the generator can't describe: schema 1, no
readable pod-aware rig spec, a member missing its pod, id or agent reference, a runtime that depends on an AgentSpec
the archive can't read, or an error while reading. It carries a reason and the local command that shows it, and no
sections.
### Declared setup preconditions
The author's `bundle.yaml`, beside `rig.yaml`, may declare setup that the user must do before installing:
```yaml
preconditions:
- name: Run inside an OpenRig source clone with dependencies installed and packages built.
commands:
- git clone https://github.com/mvschwarz/openrig.git
- cd openrig
- npm ci
- npm run build
- name: Sign in to the selected coding runtimes before starting the team.
```
Local-folder creation and GitHub-link imports carry these declarations into the archive manifest. A generated
before-install view lists each as `needs[]` with `kind: precondition`, the author's `name`, optional ordered `commands`,
`status: not_checked`, and a `sourceRefs` entry naming `bundle.yaml` and `preconditions[index]`. Inspect, plan and apply
previews show the same declaration. A legacy schema-1 archive retains it in the manifest, but its view remains
`not_generated`. Missing preconditions mean none were declared, not that no setup is needed.
Each command is one plain command line, without shell operators (`;`, `&&`, pipes, command substitution, backticks or
redirection). Run them in order in **one shell**, then run the install command in that same shell: it inherits the
working directory left by the setup commands. There is no separate `workdir` field. Commands are untrusted author data;
a site building a copyable block must validate every line and bound the number of entries, omitting all setup lines
if any fails its plain-command rule. OpenRig displays these declarations; it does not execute them, verify that setup
was done, or add a prerequisite gate. The archive hash covers the manifest; `packageDigest` excludes `bundle.yaml`
and therefore does not cover these declarations.
At creation, malformed preconditions produce a warning and the whole block is omitted; other author-field validation
is unchanged. Shell operators in otherwise valid command lines produce a warning, but the declarations are retained
as data. An already-built archive with malformed preconditions reports them as unknown before launch.
### Run records and status
**A run record's subject** is either a team (source, configuration ID, package digest, assembler) or a harness check (a
runtime). A harness check never upgrades a team label.
**A run record's `environment.platform` and `arch`** are Node's `process.platform` (`linux`, `darwin` or `win32`) and
`process.arch` (`x64` or `arm64`): the values a status platform key can carry, so every valid record can be labelled.
**Records are private.** Only `outcome.publicNote` (a short public cause) reaches the status file.
**Relations:** `supersedes`, `withdraws` and `resolves`. Reusing earlier evidence for a changed package is a reviewer's
decision, recorded in the registry entry's `evidenceReuse`.
**The status file** is generated from records by rule `openrig.status-rule/v1`:
- **Labels:** `known_problem`, `tested`, `tested_with_help`, `partly_tested`, `not_tested` and `status_unavailable`.
They're shown as Known problem, Tested by OpenRig, Tested with help (N), Partly tested, Not tested by OpenRig
and Status unavailable.
- **A label belongs to one configuration ID.** No configuration is ever shown another configuration's label.
- **Empty `platforms`** means Not tested by OpenRig. So does a custom configuration, one the listing doesn't offer.
- **Status unavailable is only for a listed configuration** whose records can't be read, or that's missing from the
status file; never Not tested by OpenRig. A listing whose records can't be read is `status_unavailable` as a whole.
- **Platform keys** are `<os>-<arch>` (`linux`, `darwin` or `win32`; `x64` or `arm64`).
- **`tested` means no help was needed:** it can't carry an assistance count above zero.
- **An assist** is one action a person or the test runner took to get past a problem during the run, such as restarting a seat or re-running a command; following the bundle's own documented steps doesn't count.
- **The file holds no private paths, host names, row IDs, account names or receipt text,** and regenerating it gives
identical bytes.
- **`bodyDigest`** is SHA-256, lowercase hex, over the RFC 8785 (JCS) canonical JSON, as UTF-8, of an object holding
every other top-level field. The site import recomputes it and rejects a hand-edited file. `harnessChecks` has its own
`state`, so an unreadable harness-check record reads Status unavailable too.
### Registry entries
Each entry records:
- the source at its reviewed commit;
- each offered configuration with its package digest, assembler and behaviour-view file;
- optional `evidenceReuse`;
- the review date, and `listed` or `withdrawn` (with `withdrawnOn`).
v1 has no author field: a listing shows the owner of `source.repository`.
A listing shows "Reviewed for listing on `<date>` at commit `<short>`. Review is not a security audit."
-129
View File
@@ -1,129 +0,0 @@
# The Chain-File Convention (CE-v2)
**This document is the SSOT for chain files**: what they are named, what each
altitude carries, and how the leaf-to-root trace resolves. Both trees' walkers
consume the paths defined here. If a walker and this document disagree, one of
them has a defect — report it; do not fork the convention.
## The one rule
**One filename, identical at every altitude of a tree.** A reader orients by
walking from where they stand toward the root, reading the same-named file at
each level. No pointers to follow, no branching, no per-level names. Inventing
a level-specific name breaks every walker.
Chain files sit on **nodes** (an instance, a rig, a pod, a seat; a mission, a slice)
— never on shelves (`rigs/`, `seats/`, `missions/` are containment, not
nodes, and carry no chain file).
## Two trees, two questions
| tree | path shape | carries | ships? |
|---|---|---|---|
| **Topology** | instance → rig → pod → seat | how work is done here | **yes** — generally applicable defaults ship in source |
| **Project** | project → mission → slice | what is being built | **no** — depends on what the user builds; see below |
### The topology tree
Rooted at the typed config key **`topology.root`** (resolution
`env OPENRIG_TOPOLOGY_ROOT > config file > derived default
$OPENRIG_HOME/topology`). Never hardcode the path — `rig config get
topology.root` is the answer on any machine. The instance altitude **is the
top of the root** (there is no instance directory to find; the root is the
instance):
```
<topology.root>/<NAME>.md # instance altitude
<topology.root>/rigs/<rig>/<NAME>.md # rig altitude
<topology.root>/rigs/<rig>/pods/<pod>/<NAME>.md # pod altitude
<topology.root>/rigs/<rig>/seats/<seat>/<NAME>.md # seat altitude
```
The engine creates the instance root, rig directory, and each declared pod
directory when it materializes topology, even when no authored chain default
exists. Pod context remains optional: use that altitude only for knowledge
shared within one context domain. Cross-pod guidance still belongs at the rig
altitude.
Established chain names on the topology tree:
- `LEARNED.md` — what a POSITION has learned; never shared or shipped as-is.
A seat's LEARNED is identified by its PATH: the same seat name in two rigs
is two different files.
- `CRAFT.md` — how work is done at that altitude (shipped at instance, rig and
seat), with `ORCHESTRATION-CRAFT.md` at rig level for orchestration practice.
- `CULTURE.md` — team norms. Note that startup delivers the rig spec's own
`culture_file` (a path beside `rig.yaml`), not a `CULTURE.md` under
`topology.root`.
### The project tree
Rooted at the workspace keys (`workspace.root`, `workspace.slices_root`).
Chain names there: `SPEC.md` (intent in frontmatter composes up the chain;
specification is a leaf property), `PROOF.md`, `PROGRESS.md`. The walkers also
read `NOTES.md` (or `MISSION_NOTES.md`), and fall back to `README.md` for a node
without a `SPEC.md`.
**Project-tree context cannot ship.** It describes what *you* are building —
mission intent, slice specifications, proof contracts. No vendor can author it
in advance, and shipping a default would install someone else's project as
your context. Real projects author their own; `rig scope mission create` and
`rig scope slice create` start the tree. (Topology-tree context is the opposite: how a team
of agents runs well is largely general, which is why included rigs ship
defaults.)
## Legacy location and the advisory
Before this convention the topology tree lived at `~/.openrig/shared-docs/rigs/`
(design decision 2026-08-14: an arbitrary folder on one machine, not a product
path). That location **stays readable** as a per-level fallback for
`rig context trace`, at the rig and seat levels, so existing rigs migrate instead
of breaking. The refocus trace, `seat:` profile atoms and handover priming read
only `topology.root`, so until migration they report those files as missing. Every
read that resolves to the legacy location emits a
named advisory (`legacy-topology-read: …`) stating the legacy source, the
canonical destination under `topology.root`, and the config key. A silent
legacy read is a defect. The legacy literal lives in exactly one helper per
settings twin (`resolveLegacyTopologyRigsRoot`); walkers import it and carry
no path literal.
## The trace
`rig context trace --rig <rig> [--pod <pod>] [--seat <seat>] --name <NAME>.md`
performs the walk: it prints each selected altitude root-first (general →
specific), marks whether content came from `topology.root`, the legacy tree
(with the advisory on stderr), or is absent, and needs no running daemon —
orientation is exactly when the daemon may be down. Existing rig/seat traces
remain valid; selecting `--pod` adds that context domain between them. `--json`
returns the structured result.
Your reads ARE the walk: a trace assembled from memory is a recitation that
reproduces the drift it was meant to catch. Run it; do not recall it.
## Shipped defaults and the curation path
Included rigs ship sensible-default chain files under a `topology/` folder,
installed under `topology.root` at rig-up. Today only `factory` ships one,
with instance, rig and seat defaults; the installer also supports pod defaults.
A shipped default is a **starting point** the occupying team appends to — it
is never overwritten by a later rig-up (existing files win).
Adding a discovered best practice to the shipped defaults is deliberately
lightweight:
1. **Discovery** — the practice appears where it was earned: a seat's
`LEARNED.md`, a field note, a review observation.
2. **Curation** — someone judges it *generally applicable* (would it hold on a
stranger's machine, in a different project?). If it is project-specific it
stays at the altitude that earned it.
3. **Ship** — add it to the rig's default chain files in source
(`packages/daemon/specs/rigs/…/topology/`), with the motivating incident in
one line. It reaches new installs at the next release; running rigs pick it
up only via explicit delivery (see the refocus channel) — **editing a file
is not delivery to a running seat.**
## What this file does not cover
How chain files reach a RUNNING seat (the refocus channel) is specified in the
refocus documentation; quality bars for the work itself live in the rig's
operating-model skill and culture file.
-22
View File
@@ -1,22 +0,0 @@
# Checked seat stop (HTTP)
Read `GET /api/seat/stop-precondition/:seatRef` immediately before an operator-confirmed stop. A successful response has this shape:
```json
{"contract":"seat-stop.v1","expected":{"nodeId":"<node ID>","sessionId":"<latest session ID or null>"}}
```
Pass `expected` unchanged in the body of the existing `POST /api/seat/stop/:seatRef`, alongside `reason` and optional `operator`. The contract marker lets clients detect support. `expected` is optional; a request without it retains the existing stop behavior. A malformed explicit value returns HTTP 400. A null session ID can describe an empty seat, but stopping it returns `no_session`.
Checked stop requires the delivery guard. It refuses a changed node, latest session, binding or occupant before a terminal effect with HTTP 409 `selection_changed`. A kill failure or a failure to save stop state after a kill reports `stop_uncertain`; inspect the seat before retrying. Success retains the usual `seat`, `sessionName` and `sessionId` fields.
A `selection_changed` or `stop_uncertain` response from a checked stop says how far the stop got in `stopPhase`. A refusal before the stop starts carries neither `stopPhase` nor `partialEffect`, and nothing was sent. The stop detaches the seat's clients, checks the selection again, then kills the session:
| `stopPhase` | What was sent to the seat |
| --- | --- |
| `none` | Nothing. |
| `detach_attempted` | The client detach, which may not have finished. No kill. |
| `detached` | The client detach. No kill. |
| `kill_attempted` | The kill. |
`partialEffect` is `true` whenever `stopPhase` isn't `none`, so on its own it doesn't say whether the kill went out. `selection_changed` with `stopPhase: "kill_attempted"` is a change seen after the kill, when the stop state is saved: the session was killed, so re-read the seat rather than offering the stop again.
-111
View File
@@ -1,111 +0,0 @@
# Custom Claude status line
OpenRig installs a silent context collector as `statusLine.command` in the seat
working directory's `.claude/settings.local.json`. Claude's project-local setting
overrides your user-level status line, so a renderer configured only in
`~/.claude/settings.json` can disappear in that directory. Other Claude sessions
using the same directory also read that setting.
Since 0.6.5, OpenRig preserves a custom, nonempty project-local status line command,
including a wrapper or a command composed with shell operators. It still refreshes
its own collector script. If your command does not run the collector, context and
provider usage stop updating, and Claude resume-token capture at adoption or
handover loses its current session evidence. An earlier sidecar can remain and
contain an older session ID; without one, context and capture report
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
earlier cache remains.
## Feed both commands the same input
On macOS, Linux or WSL2, save a wrapper such as this, replacing the three
placeholder paths and `my-statusline` with your actual commands:
```sh
#!/bin/sh
input=$(cat)
printf '%s' "$input" |
node '/seat/cwd/.openrig/context-collector.cjs' \
'/instance/home/state/context-usage' \
'/instance/home/state/provider-usage' >/dev/null 2>&1
printf '%s' "$input" | my-statusline
```
Take the collector path and both directory arguments from the existing generated
`statusLine.command` in that seat's `.claude/settings.local.json`; the instance
home is the selected `OPENRIG_HOME`. Keep paths quoted, including paths with
spaces. Each command needs the same status line JSON on stdin: simply running
the collector and renderer in sequence against a single stdin lets the first
consume the input.
In that same settings file, merge this field while preserving your other settings:
```json
{
"statusLine": {
"type": "command",
"command": "sh '/absolute/path/to/openrig-status-line.sh'"
}
}
```
Use the renderer command from your user-level configuration in the wrapper. The
collector stays silent and the renderer supplies the visible status line. This
POSIX-shell example applies to WSL2's Linux installation; it does not establish
native Windows support.
## Check after an upgrade or instance change
The collector command's paths and arguments are implementation details. OpenRig
preserves your wrapper, so it cannot refresh those arguments for you. Recheck
them after an upgrade, a seat-directory move or an `OPENRIG_HOME` change.
After Claude next updates its status line, inspect context usage in `rig ps` or
`rig whoami --full` from the seat. Unknown or stale context is a reason to check
the wrapper; it does not prove the collector is running. For a direct check,
inspect the current session's JSON under the configured `state/context-usage`
directory: its timestamp should advance and its `session_id` should match the
current Claude session. A visible custom status line alone does not verify
collection or end-to-end adoption/handover recovery. The wrapper above suppresses
collector errors; remove its stderr redirection temporarily when diagnosing it.
## Claude usage-limit detection
The status line supplies subscription usage windows and their reset timestamps.
OpenRig also registers Claude's [`StopFailure` hook](https://code.claude.com/docs/en/hooks#stopfailure),
available since Claude Code 2.1.78. Its exact `error: "rate_limit"` value reports a
provider interruption even when no status line window is available. This path
requires the updated OpenRig hook configuration and the current seat's runtime
and occupant generation. Unknown identity or an expired event cannot trigger a
usage-limit pause; a hook with a newer source timestamp supersedes the event,
regardless of arrival order. The event expires after five minutes in any case.
A helper Claude started from a seat's shell inherits that seat's environment,
so its rate-limit hook is attributed to that seat too. This is appropriate when
the helper uses the same login, since the quota belongs to the account. A
helper on a different login is attributed the same way, so its rate limit can
hold pending handoffs to every seat in that seat's Claude pool for one retry
interval, even when the seat's own login still has room. This attribution does
not bind the hook to Claude's own native session.
The event carries no usage percentage, account identity or reset timestamp.
It marks the Claude provider pool as limited and preserves a current
`agentActivity.reason` of `usage_limit` over a cached idle reading. It does not
count as a request for human input; a newer permission prompt still takes
precedence in the seat's activity display. Conflicting seat windows retain the
existing `explicit_unknown` host diagnosis.
Pending work handled by the wake ladder uses the existing local Claude pool
(the one-account-per-host assumption), with separate pools for seats that
declare different Claude config directories. If reset windows conflict within
a proposed shared pool, only the seats reporting exhaustion are held; another
seat's pending handoffs can still retry. An exhausted status line window with
a known reset supplies the timer. Otherwise a typed failure uses
`queue.wake_retry_interval_seconds` (default 300) from the event time, plus the
existing wake jitter, and records `config-fallback`. That is a retry time, not a
claim that the provider's quota will reset then. The timer releases the shared
blocker once and retains the existing durable wake delivery behavior.
Authentication, billing and overloaded errors are not classified as usage
limits. Claude limit banners are not scraped, and older runtimes or failures
that do not emit this hook still depend on status line data. Without either
source, provider usage remains unknown.
@@ -1,14 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/cursor.yaml, with openrig-cursor-launch on the seat's PATH.
format: 1
name: cursor
command: openrig-cursor-launch
maintainer: Your name or team
limits:
- resume reads a chat as missing unless its store is in the seat's Cursor config folder, under the seat's working directory
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
launch_result: true
resume: true
startup_assisted: false
first_input: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -1,74 +0,0 @@
#!/bin/sh
# A reference launcher for Cursor's CLI as an OpenRig community harness. Copy it, own it, and change it: OpenRig
# doesn't ship or run it. It follows docs/reference/community-harness-presets.md:
# - a new launch: `cursor-agent create-chat` prints a new chat id; the launcher reports it, then reopens that chat;
# - a resume (OPENRIG_RESUME_REF set): it reopens the chat it was asked for and reports that id, or, when Cursor holds
# no such chat, reports it missing and starts nothing.
# Cursor's chat-id facts come from the probe in OpenRig issue #352, and the chat store's location from Cursor's own CLI
# (2026.10.22).
set -eu
producer="openrig-cursor-launch 2"
cursor="${OPENRIG_CURSOR_AGENT:-cursor-agent}"
# Each seat gets its own Cursor config folder unless CURSOR_CONFIG_DIR is already set. Cursor keeps cli-config.json
# (`--model`, approval settings) and the chats there, so a seat doesn't rewrite the person's own ~/.cursor. Cursor's
# login isn't in it: Cursor keeps that in ~/.config/cursor/auth.json on Linux and in the Keychain on macOS.
CURSOR_CONFIG_DIR="${CURSOR_CONFIG_DIR:-${OPENRIG_HOME:-$HOME/.openrig}/state/cursor/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}}"
export CURSOR_CONFIG_DIR
mkdir -p "$CURSOR_CONFIG_DIR"
# Write this attempt's record, all at once, where OpenRig asked for it (nothing when it didn't ask).
report() {
[ -n "${OPENRIG_LAUNCH_RESULT:-}" ] || return 0
tmp="$OPENRIG_LAUNCH_RESULT.$$.tmp"
printf '%s\n' "$1" > "$tmp"
mv "$tmp" "$OPENRIG_LAUNCH_RESULT"
}
plain_id() { case "$1" in ''|*[!A-Za-z0-9._:-]*) return 1 ;; *) return 0 ;; esac; }
# Where Cursor keeps a chat: <config folder>/chats/<md5 of the working directory>/<chat id>/store.db. Cursor hashes
# its process's working directory, which is the physical path, so this hashes `pwd -P`.
chat_store() {
if command -v md5sum >/dev/null 2>&1; then sum=$(pwd -P | tr -d '\n' | md5sum | cut -d' ' -f1)
else sum=$(pwd -P | tr -d '\n' | md5 -q); fi
printf '%s/chats/%s/%s/store.db' "$CURSOR_CONFIG_DIR" "$sum" "$1"
}
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model) goes to a new chat only. A resumed chat isn't
# passed one: whether Cursor's --resume keeps the chat's earlier model or takes a new one hasn't been checked.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
set -- --model "$OPENRIG_MODEL" "$@"
fi
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input=${first_input%.}
set -- "$@" "$first_input"
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
plain_id "$id" || { echo "openrig-cursor-launch: refusing an unusual chat id" >&2; exit 2; }
# Cursor's `--resume` with an id it doesn't hold exits 0 on a new, empty chat, so check first. Gone: say so, start nothing.
if [ ! -f "$(chat_store "$id")" ]; then
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"error\":\"missing\",\"producer\":\"$producer\"}"
echo "openrig-cursor-launch: Cursor has no chat $id in $CURSOR_CONFIG_DIR for $(pwd -P); starting nothing" >&2
exit 1
fi
else
# create-chat prints the id but doesn't exit (see #352): read the first line, then stop it.
out=$(mktemp)
"$cursor" create-chat > "$out" 2>/dev/null &
pid=$!
i=0
while [ "$i" -lt 100 ] && [ ! -s "$out" ]; do sleep 0.1; i=$((i + 1)); done
kill "$pid" 2>/dev/null || true
id=$(head -n 1 "$out" | tr -d '\r\n\t ')
rm -f "$out"
# No usable id: report nothing, so OpenRig shows the identity as unavailable, and start Cursor on its own chat.
plain_id "$id" || exec "$cursor" --trust "$@"
fi
report "{\"format\":1,\"attempt\":\"${OPENRIG_LAUNCH_ATTEMPT:-}\",\"harness\":\"cursor\",\"nativeRef\":\"$id\",\"producer\":\"$producer\"}"
exec "$cursor" --resume "$id" --trust "$@"
@@ -1,96 +0,0 @@
# An example runner for DeepSeek Harness (dsh)
`openrig-dsh-runner.mjs` is a small runner for the draft [harness and runner contract](../../harness-runner-contract.md).
It runs in the seat's pane, hosts `dsh --profile acp` over ACP on stdio, and does the runner's part of each contract
section:
- **identity:** the launch record, written once the conversation is open;
- **one prompt per block:** each submitted block becomes one `session/prompt`, using bracketed paste in a TTY;
- **turn boundaries:** reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
- **cancel:** `/abort` or Ctrl-C sends `session/cancel`, and a turn reads cancelled only when dsh says so. Type `/abort`
in the seat's pane: one sent with `rig send` arrives inside a From/To envelope, so it's an ordinary prompt;
- **permissions:** each request goes to a person through OpenRig;
- **resume:** it reopens the exact conversation, or says honestly that it couldn't.
It has no dependencies, and it isn't installed by OpenRig. Copy it with [`dsh.yaml`](dsh.yaml) and maintain both as your
own. [bpamiri/dsh-seat](https://github.com/bpamiri/dsh-seat) is a fuller dsh runner by its own maintainer.
## Set it up
1. **Install dsh and Node.** Install Node 22 or later, then `npm i -g @deepseek-ai/[email protected]`. This is the version
it was written for, and it has been checked only against a stand-in agent.
2. **Install the runner and preset.** Make the runner executable and put it on the seat's `PATH`. Copy `dsh.yaml` to
`$OPENRIG_HOME/community-harnesses/`. Then declare a member with `runtime: dsh`.
3. **Give the seat credentials.** Each seat gets its own dsh home, at `$OPENRIG_HOME/state/dsh/<node>/home`, because dsh
rewrites its profile there on every start. Put a `.credentials.yaml` there, or put a provider key in the seat's
`env:`. To use a home you prepared yourself, set `DSH_SEAT_HOME` in the member's `env:`; `DSH_SEAT_BIN` there names
another `dsh` binary. The runner doesn't take dsh's own `DSH_HOME` from its environment, because your shell's startup
files run after the seat's `env:` and could set it. A member's `env:` can't set `OPENRIG_*` names, which OpenRig
reserves, but the runner still reads `OPENRIG_DSH_HOME` and `OPENRIG_DSH` when something exports them to it, such as
a preset `command` wrapper.
The runner's own defaults for dsh:
- **Uploads:** the seat home gets a `cordis.patch.yml` that turns off dsh's session-log and plugin-list uploads to
DeepSeek, and the runner starts dsh with `DSH_TELEMETRY_DISABLED=1`.
- **Permission mode:** `DSH_PERMISSION_MODE` is `workspace-write` unless you set it.
- **The hook token:** OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
- **Its version:** the launch record names dsh's version from `dsh --version`. If that fails or takes over 5 seconds, it
names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
## Permissions
The runner submits each request to OpenRig, which asks one person:
- the default person;
- or the person chosen for the seat with `rig seat set-permissions-to <seat> --to <person> --reason "…"`.
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
**When the person answers in their own words** rather than with a choice, a yes or a no, OpenRig permits by default and
passes their words back as `said`. The runner gives dsh its option as usual, shows the words in the pane, and queues
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
are never the answer.
**When no one can be asked, the request is permitted** with dsh's own allow option: `allow_once` when dsh offers it,
else `allow_always`. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
without this path, an OpenRig it can't reach, or a refusal other than `--deny`.
**`allow_always` creates a standing grant in dsh.** OpenRig doesn't record or revoke it, which is why the runner prefers
`allow_once`. Where dsh keeps the grant, and for how long, isn't checked.
**The runner refuses** only when the seat's operator opted in to refusal, with `rig seat set-permissions-to <seat>
--deny`, or when dsh offered no allow option. A refused request is shown in the pane, and the seat reads needs input
until the next prompt.
**The runner answers cancelled, holding nothing,** when the person says no and dsh offered no reject option, or when
OpenRig says the request isn't the seat's current occupant's (for example, after a relaunch).
## Before you declare resume
The preset leaves `resume` out, so a restore stops and asks, and a fresh launch starts a new conversation.
`rig seat restart --keep-conversation` is for Codex seats only. The runner reopens a conversation by the method dsh
advertises: `session/resume`, or `session/load`. When dsh advertises neither, it tries `session/resume`. It never starts
a new conversation on a resume.
**Telling a missing conversation from one that won't reopen.** dsh `0.2.1-rc.2` answers `-32602` both for a
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
- **Only dsh's own not-found error (`-32002`) counts as missing.** Any other failure leaves the resume unconfirmed.
- **When dsh advertises `session/list`** (it does), the runner first asks the list, following its pages. A conversation
the list names but that won't reopen stays unconfirmed, even on a not-found.
- **An unlisted conversation is still reopened.** dsh's list can leave out a conversation another running dsh holds
unsaved, so it can't show a conversation is gone.
**Limit:** dsh answers `-32602`, not `-32002`, for a conversation it doesn't have, so the runner can't report a missing
dsh conversation as missing. It reads unconfirmed.
Add `resume: true` after a real dsh has reopened a conversation and recalled what was said in it.
## Check it without a model
Point dsh's `DEEPSEEK_BASE_URL` at a local server that speaks DeepSeek's Anthropic-compatible Messages API. Then run the
contract's real check:
- a seat reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
- `/abort` ends a turn as cancelled.
`packages/daemon/test/community-harness-dsh-example.test.ts` runs the same cases against a stand-in agent.
@@ -1,20 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/dsh.yaml, with openrig-dsh-runner.mjs (executable) on the seat's PATH.
format: 1
name: dsh
command: openrig-dsh-runner.mjs
maintainer: Your name or team
runner: true
launch_result: true
first_input: true
limits:
- written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh) over ACP protocol 1, with Node 22 or later; run with a real dsh on two stacks only, dsh's Claude route with a Claude Haiku model in 0.7.8's release testing (with that release's copy of this runner, for an allow-once and a reject-once), and dsh's pi-ai OpenRouter chat-completions profile with the model deepseek/deepseek-v4-flash on OpenRig 0.7.8 (for queue work, activity through a quiet tool call, an allow-once and a reject-once, and cancel); in both, the permission answers came from a test answerer registered as a person, not a live person; dsh's DeepSeek Messages adapter pointed at OpenRouter failed with HTTP 400 in the second, cause not established; no other dsh route or model is checked
- each seat gets its own dsh home under $OPENRIG_HOME/state/dsh/<node>/home, holding a patch that turns off dsh's session-log and plugin-list uploads to DeepSeek; give it credentials (.credentials.yaml there) or a provider key in the seat's env; set DSH_SEAT_HOME in the member's env to use a home you prepared, and DSH_SEAT_BIN for another dsh binary (a member's env can't set OPENRIG_* names, and the runner doesn't read dsh's own DSH_HOME)
- DSH_PERMISSION_MODE defaults to workspace-write; a permission request goes to the default person or the seat's override (rig seat set-permissions-to), is permitted with dsh's allow_once (else allow_always) when no one can be asked or OpenRig can't answer, and is refused and held as needs input only after --deny
- allow_always creates a standing grant in dsh, which OpenRig doesn't record or revoke; the runner prefers allow_once, and where dsh keeps the grant and for how long isn't checked
- resume isn't declared; a real dsh has reopened a conversation after down, snapshot and up and recalled it, with resume added to a copy of this preset, but a missing conversation's refusal hasn't been run; the runner reopens by the method dsh advertises and reports missing only on dsh's own not-found error, not when dsh's session list names the conversation; dsh answers -32602 for a missing conversation (and for one active elsewhere), so a missing dsh conversation reads unconfirmed
- a prompt whose whole text is /abort can't be sent; a Ctrl-C byte inside a paste also aborts; without a TTY, input is read line by line; this isn't a binary-safe channel
- one prompt at a time; a message that arrives mid-turn waits for the next turn
- the seat's model isn't passed; dsh uses its own configured model
- no guidance, skills or startup files are placed; dsh reads its own AGENTS.md and skill folders
- when the pane closes, the runner cancels a running turn and stops dsh; anything dsh's tools started may keep running
- fork unsupported
@@ -1,626 +0,0 @@
#!/usr/bin/env node
// Example OpenRig runner for DeepSeek Harness (dsh), not installed by OpenRig. Checked against a stand-in agent only;
// written for dsh 0.2.1-rc.2 (npm @deepseek-ai/dsh), ACP protocol 1 and Node 22 or later. No dependencies.
//
// It follows docs/reference/harness-runner-contract.md: it hosts `dsh --profile acp` over ACP on stdio, writes the
// launch record once the conversation is open, turns each submitted block into one prompt, reports turn boundaries as
// activity hooks with a working beat through a long, quiet turn, cancels on /abort or Ctrl-C, and sends permission
// requests to OpenRig: a person answers, or OpenRig permits when no one can be asked, and so does the runner when OpenRig
// can't answer (refusing and holding needs input only when the seat opted in to refusal). Copy it and maintain it as your
// own.
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
const RUNNER = "openrig-dsh-runner 1";
const NAME = process.env.OPENRIG_RUNTIME || "dsh"; // the preset's name: OpenRig passes it, and checks the record against it
const LIVENESS_SECONDS = 60;
const BEAT_MS = 30_000;
// The working beat (contract section 4): every 10 s, more often than half its 30 s lifetime.
const WORK_LIVENESS_SECONDS = 30;
const WORK_BEAT_MS = 10_000;
const ACTIVE_EVERY_MS = 15_000;
const POST_TIMEOUT_MS = 2_000;
const WAIT_SECONDS = 25;
const READ_TRIES = 3; // reads in a row that get no answer before the runner permits
// Refusals that say the request certainly isn't the seat's current occupant's live one: cancel it, and hold nothing.
const NOT_LIVE = ["stale_generation", "activity_grant_wrong_seat", "activity_grant_stale"];
// A read's states that end the request, answered cancelled (an `answered` that reaches there names an option dsh didn't
// offer). Any other state, like an unlisted status, permits.
const ENDED = ["refused", "withdrawn", "stale", "answered"];
const CANCELLED = { outcome: { outcome: "cancelled" } };
const ACP_RESOURCE_NOT_FOUND = -32002;
// The most pages of dsh's session list read before a resume; a longer list decides nothing.
const LIST_PAGES = 50;
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
/** Turns terminal bytes into submitted blocks. Everything up to an Enter outside a paste is one block, line breaks
* included; a block that is exactly /abort, or a Ctrl-C byte, is the only control. */
export class InputFramer {
#buffer = "";
#pasting = false;
#partial = "";
push(chunk) {
const events = [];
const data = this.#partial + chunk;
this.#partial = "";
let i = 0;
while (i < data.length) {
if (data[i] === "\x1b") {
const rest = data.slice(i);
if (rest.startsWith(PASTE_START) || rest.startsWith(PASTE_END)) {
this.#pasting = rest.startsWith(PASTE_START);
i += PASTE_START.length;
continue;
}
if (PASTE_START.startsWith(rest) || PASTE_END.startsWith(rest)) { this.#partial = rest; break; }
if (!this.#pasting) { i += escapeLength(rest); continue; } // arrow keys and the like
}
const ch = data[i];
i += 1;
if (ch === "\x03") { this.#buffer = ""; this.#pasting = false; events.push({ type: "abort" }); continue; }
if (this.#pasting) {
if (ch === "\r") { if (data[i] === "\n") i += 1; this.#buffer += "\n"; } else this.#buffer += ch;
continue;
}
if (ch === "\r" || ch === "\n") {
const text = this.#buffer;
this.#buffer = "";
if (text.trim() === "/abort") events.push({ type: "abort" });
else if (text.trim()) events.push({ type: "block", text });
continue;
}
if (ch === "\x7f" || ch === "\b") {
if (this.#buffer) { this.#buffer = this.#buffer.slice(0, -1); events.push({ type: "erase" }); }
continue;
}
if (ch >= " ") { this.#buffer += ch; events.push({ type: "echo", text: ch }); }
}
return events;
}
}
function escapeLength(rest) {
if (rest[1] === "[") {
let j = 2;
while (j < rest.length && !(rest.charCodeAt(j) >= 0x40 && rest.charCodeAt(j) <= 0x7e)) j += 1;
return Math.min(j + 1, rest.length);
}
return rest[1] === "O" ? Math.min(3, rest.length) : 1;
}
/** JSON-RPC 2.0 with the agent, one message per line on its stdio. */
class AgentPeer {
#child;
#next = 1;
#waiting = new Map();
#requests = new Map();
#notifications = new Map();
constructor(child, onNoise) {
this.#child = child;
createInterface({ input: child.stdout }).on("line", (line) => {
let message;
try { message = JSON.parse(line); } catch { message = null; }
if (!message || typeof message !== "object") { if (line.trim()) onNoise(line); return; }
if (message.method === undefined) {
const waiter = this.#waiting.get(message.id);
if (!waiter) return;
this.#waiting.delete(message.id);
if (message.error) waiter.reject(Object.assign(new Error(message.error.message ?? "error"), { code: message.error.code }));
else waiter.resolve(message.result);
return;
}
if (message.id === undefined) { this.#notifications.get(message.method)?.(message.params ?? {}); return; }
const handler = this.#requests.get(message.method);
if (!handler) { this.#write({ id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } }); return; }
Promise.resolve().then(() => handler(message.params ?? {}, message.id)).then(
(result) => this.#write({ id: message.id, result }),
(error) => this.#write({ id: message.id, error: { code: error.code ?? -32603, message: error.message } }));
});
}
request(method, params) {
const id = this.#next++;
return new Promise((resolve, reject) => { this.#waiting.set(id, { resolve, reject }); this.#write({ id, method, params }); });
}
notify(method, params) { this.#write({ method, params }); }
onRequest(method, handler) { this.#requests.set(method, handler); }
onNotification(method, handler) { this.#notifications.set(method, handler); }
failAll(reason) {
for (const waiter of this.#waiting.values()) waiter.reject(new Error(reason));
this.#waiting.clear();
}
#write(message) {
if (this.#child.stdin.writable) this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
}
}
const openrigHome = () => process.env.OPENRIG_HOME || join(homedir(), ".openrig");
/** The daemon's base URL and the seat's token: from the environment, else from activity-endpoint.json. */
function openrigEndpoint() {
const env = process.env;
let base = env.OPENRIG_URL || (env.OPENRIG_PORT ? `http://${env.OPENRIG_HOST || "127.0.0.1"}:${env.OPENRIG_PORT}` : "");
let token = env.OPENRIG_ACTIVITY_HOOK_TOKEN || "";
if (!base || !token) {
try {
const file = JSON.parse(readFileSync(join(openrigHome(), "activity-endpoint.json"), "utf8"));
base ||= file.baseUrl ?? "";
token ||= file.token ?? "";
} catch { /* no daemon has written one yet */ }
}
return base && token ? { base: base.replace(/\/+$/, ""), token } : null;
}
async function callOpenRig(method, path, body, timeoutMs) {
const endpoint = openrigEndpoint();
if (!endpoint) return { status: 0, body: null };
try {
const response = await fetch(`${endpoint.base}${path}`, {
method, signal: AbortSignal.timeout(timeoutMs),
headers: { authorization: `Bearer ${endpoint.token}`, ...(body ? { "content-type": "application/json" } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
let parsed = null;
try { parsed = await response.json(); } catch { /* not JSON */ }
return { status: response.status, body: parsed };
} catch {
return { status: 0, body: null };
}
}
/** Activity posts go one at a time, in order, and never hold up the agent. */
class Activity {
#sequence = 0;
#chain = Promise.resolve();
constructor(identity) { this.identity = identity; }
post(hookEvent, subtype, extra = {}) {
const body = { ...this.identity, sequence: ++this.#sequence, occurredAt: new Date().toISOString(), hookEvent, subtype, ...extra };
this.#chain = this.#chain.then(() => callOpenRig("POST", "/api/activity/hooks", body, POST_TIMEOUT_MS)).then(() => {}, () => {});
}
flushed() { return this.#chain; }
}
const UPLOADS_OFF = [
"# Written by openrig-dsh-runner for this seat; safe to edit, and an existing file is left alone.",
"# Don't attach this seat's session log, or its plugin package names and versions, to DeepSeek API requests.",
"- id: session-log-deepseek",
" config:",
" enabled: false",
"- id: plugin-package-inventory-deepseek",
" config:",
" enabled: false",
"",
].join("\n");
/** Each seat gets its own writable dsh home (dsh rewrites its profile there on every start). DSH_SEAT_HOME, which a rig
* spec's env: can set, or OPENRIG_DSH_HOME names one you prepared yourself, which is used as it is. Not dsh's own
* DSH_HOME: a person's shell startup files can export it, and they run after the seat's env:. */
function seatHome() {
const prepared = process.env.OPENRIG_DSH_HOME || process.env.DSH_SEAT_HOME;
if (prepared) return prepared;
const seat = process.env.OPENRIG_NODE_ID || process.env.OPENRIG_SESSION_NAME || "default";
const home = join(openrigHome(), "state", "dsh", seat, "home");
mkdirSync(home, { recursive: true, mode: 0o700 });
const patch = join(home, "cordis.patch.yml");
if (!existsSync(patch)) writeFileSync(patch, UPLOADS_OFF, { mode: 0o600 });
return home;
}
/** dsh's version as its CLI reports it, or null when it can't say. dsh 0.2.1-rc.2's ACP handshake reports 0.0.1.
* SIGKILL at the timeout, because a synchronous call waits for the child to exit, and a child can ignore SIGTERM. */
function cliVersion(bin, env) {
try {
const out = execFileSync(bin, ["--version"], { encoding: "utf8", env, timeout: 5_000, killSignal: "SIGKILL", stdio: ["ignore", "pipe", "ignore"] });
return out.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)?.[0] ?? null;
} catch {
return null;
}
}
function writeLaunchRecord(fields) {
const path = process.env.OPENRIG_LAUNCH_RESULT;
if (!path) return;
const record = { format: 1, attempt: process.env.OPENRIG_LAUNCH_ATTEMPT, harness: NAME, ...fields };
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, `${JSON.stringify(record)}\n`, { mode: 0o600 });
renameSync(temp, path);
}
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const say = (line) => process.stdout.write(`${line}\n`);
const firstLine = (text) => {
const lines = text.split("\n");
return lines.length > 1 ? `${lines[0]} … (+${lines.length - 1} lines)` : lines[0];
};
async function main() {
const env = process.env;
const cwd = process.cwd();
let firstInput = null;
if (env.OPENRIG_FIRST_INPUT) {
try { firstInput = readFileSync(env.OPENRIG_FIRST_INPUT, "utf8"); } catch (error) {
console.error(`openrig-dsh-runner: the first startup turn couldn't be read (${error.message}); not starting`);
process.exit(2);
}
}
const home = seatHome();
const activity = new Activity({ sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID,
runtime: NAME, generation: env.OPENRIG_OCCUPANT_GENERATION });
const agentEnv = { ...env, DSH_HOME: home };
delete agentEnv.OPENRIG_ACTIVITY_HOOK_TOKEN; // dsh's shell tool inherits this environment
agentEnv.DSH_PERMISSION_MODE ??= "workspace-write";
if (agentEnv.DSH_TELEMETRY_DISABLED === undefined && agentEnv.DSH_TELEMETRY_MODE === undefined) agentEnv.DSH_TELEMETRY_DISABLED = "1";
// DSH_SEAT_BIN, which a rig spec's env: can set, or OPENRIG_DSH names another dsh binary.
const bin = env.OPENRIG_DSH || env.DSH_SEAT_BIN || "dsh";
const version = cliVersion(bin, agentEnv);
const child = spawn(bin, ["--profile", "acp"], { cwd, env: agentEnv, stdio: ["pipe", "pipe", "inherit"] });
child.stdin.on("error", () => {});
const peer = new AgentPeer(child, (line) => say(`[dsh] ${line}`));
const state = { sessionId: null, turn: 0, running: false, cancelRequestedAt: null, holding: false, refused: false, queue: [], asks: new Set(), lastActive: 0, asking: 0, waiting: 0, resolvedAsk: null };
const inflight = new Set();
const track = (promise) => { inflight.add(promise); promise.finally(() => inflight.delete(promise)); return promise; };
let agentExited = false;
let stopping = false;
let heartbeat;
let workBeat;
let tty = false;
// The turn may read working: it runs, nothing holds needs input, no permission request is being submitted or waits for its
// permission_resolved, and dsh is alive. The beat and the agent's own reports both go by it.
const working = () => state.running && !state.holding && state.asking === 0 && !state.resolvedAsk && !agentExited;
const restoreTty = () => {
if (!tty) return;
tty = false;
process.stdout.write("\x1b[?2004l");
try { process.stdin.setRawMode(false); } catch { /* already closed */ }
};
const finish = async (code) => {
clearInterval(heartbeat);
clearInterval(workBeat);
restoreTty();
await Promise.race([Promise.allSettled([...inflight, activity.flushed()]), sleep(2_000)]);
process.exit(code);
};
const fatal = async (message, code) => {
stopping = true;
say(`[openrig-dsh] ${message}`);
if (!agentExited) {
child.kill("SIGTERM");
await Promise.race([new Promise((done) => child.once("exit", done)), sleep(3_000)]);
}
await finish(code);
};
const postIdle = (subtype) => activity.post("Stop", subtype, { livenessSeconds: LIVENESS_SECONDS });
const withdraw = (askId, reason) => track(callOpenRig("POST", `/api/activity/permission-requests/${encodeURIComponent(askId)}/withdraw`, { reason }, POST_TIMEOUT_MS));
child.once("error", (error) => { agentExited = true; void fatal(`dsh couldn't be started (${error.message}); is it on PATH?`, 1); });
child.on("exit", (code, signal) => {
agentExited = true;
peer.failAll("dsh exited");
if (stopping) return;
for (const ask of state.asks) ask.end("exited");
say(`\n[openrig-dsh] dsh exited (code ${code ?? "none"}, signal ${signal ?? "none"})`);
activity.post("Stop", `${NAME}_exited`);
void finish(code === 0 ? 0 : 1);
});
peer.onNotification("session/update", (params) => {
if (params.sessionId !== state.sessionId || !params.update) return;
const update = params.update;
const reporting = working();
if (update.sessionUpdate === "agent_message_chunk") {
if (update.content?.type === "text") process.stdout.write(update.content.text);
if (reporting && Date.now() - state.lastActive >= ACTIVE_EVERY_MS) { state.lastActive = Date.now(); activity.post("active", "agent_message"); }
} else if (update.sessionUpdate === "tool_call") {
say(`\n[tool] ${update.title ?? "tool"} (${update.kind ?? "other"})`);
if (reporting) activity.post("PreToolUse", String(update.kind ?? "other"));
}
});
const refuse = (why) => {
state.holding = true;
state.refused = true; // the seat's own refusal holds needs input until the next prompt
say(`[permission] refused: ${why}. The seat reads needs input until the next prompt.`);
activity.post("Notification", "permission_prompt", { turnId: String(state.turn) });
return CANCELLED;
};
// The answered path: OpenRig opens one ask to the default person (or the seat's override, `rig seat
// set-permissions-to`), and the runner reads the answer back for this same live request. With no one to ask, OpenRig
// permits at once, and so does the runner when OpenRig can't answer. Only the seat's opt-in (`--deny`) refuses; a
// request OpenRig says isn't the current occupant's, or that a person said no to with no reject option, is cancelled.
const askOwner = async (requestId, toolCall, options) => {
const turn = state.turn;
const live = () => state.running && state.turn === turn && !state.cancelRequestedAt && !agentExited;
const submitted = await callOpenRig("POST", "/api/activity/permission-requests", {
sessionName: env.OPENRIG_SESSION_NAME, nodeId: env.OPENRIG_NODE_ID, generation: env.OPENRIG_OCCUPANT_GENERATION,
nativeRef: state.sessionId, turnId: String(turn), requestId: String(requestId), toolCall,
options: options.map((option) => ({ optionId: option.optionId, name: option.name ?? option.optionId, kind: option.kind })),
occurredAt: new Date().toISOString(),
}, 10_000);
const askId = submitted.body?.ok ? submitted.body.askId : undefined;
if (!live()) {
if (askId) withdraw(askId, state.cancelRequestedAt ? "turn_cancelled" : agentExited ? "exited" : "turn_ended");
return CANCELLED;
}
const offered = (optionId) => options.some((option) => option.optionId === optionId);
// No one can be asked: OpenRig's default is permit, with the agent's own allow option.
if (submitted.body?.ok && submitted.body.state === "permitted" && offered(submitted.body.optionId)) {
say(`[permission] permitted: ${submitted.body.reason ?? "no one can be asked"} (${submitted.body.optionId})`);
return { outcome: { outcome: "selected", optionId: submitted.body.optionId } };
}
// When OpenRig can't answer, nobody can be asked either, so the same default applies. allow_once is preferred, so
// dsh doesn't remember a permission nobody chose.
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
const permit = (why) => {
if (!allow) return refuse(`${why}, and the agent offered no allow option`);
state.holding = state.refused || state.waiting > 0; // another ask may still wait on a person
say(`[permission] permitted: ${why} (${allow.optionId})`);
return { outcome: { outcome: "selected", optionId: allow.optionId } };
};
// A person who answered in their own words: OpenRig passes them back as `said`. They join the prompt queue like any
// message, so dsh gets them after this turn and after any message already waiting. They're never the answer.
const passOn = (said) => {
if (typeof said !== "string" || !said.trim()) return;
say(`[permission] the person also wrote: ${firstLine(said)}`);
submit(`The person who answered your permission request (${toolCall.title}) also wrote:\n\n${said}`);
};
if (!askId) {
if (submitted.body?.code === "denied_by_seat") return refuse("the seat's operator chose to refuse its permission requests");
if (NOT_LIVE.includes(submitted.body?.code)) {
say(`[permission] cancelled: OpenRig says this isn't the seat's current request (${submitted.body.code})`);
return CANCELLED;
}
return permit(submitted.status === 404 ? "this OpenRig has no answered path"
: submitted.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't ask anyone (${submitted.body?.code ?? submitted.status})`);
}
state.holding = true; // OpenRig recorded the seat's permission prompt when it opened the ask
state.waiting += 1;
say(`[permission] OpenRig asked a person (${askId}); waiting for their answer`);
// This ask no longer waits on a person; the seat still needs input while another one does, or after its own refusal.
const stopWaiting = () => { state.waiting -= 1; state.holding = state.refused || state.waiting > 0; };
// The agent got an answer or a permit for this ask: once nothing else waits, permission_resolved says the turn goes on.
const resolved = () => { state.resolvedAsk = { turn, askId }; };
return new Promise((resolve) => {
let done = false; // the agent has its answer
let ended = false; // OpenRig was told the request is over
const ask = { end: (reason) => {
if (ended) return;
ended = true;
state.asks.delete(ask);
withdraw(askId, reason);
if (done) return;
done = true;
stopWaiting(); // the turn ended the person's ask
resolve(CANCELLED);
} };
state.asks.add(ask);
void (async () => {
let unanswered = 0;
while (!done) {
const read = await callOpenRig("GET", `/api/activity/permission-requests/${encodeURIComponent(askId)}?waitSeconds=${WAIT_SECONDS}`, null, (WAIT_SECONDS + 10) * 1_000);
if (done) return;
if (read.status === 0 && ++unanswered < READ_TRIES) { await sleep(2_000); continue; }
if (read.status === 200 && read.body?.state === "pending") { unanswered = 0; continue; }
done = true;
const answer = read.body ?? {};
// A person's option, or OpenRig's permit when they closed it without one.
if ((answer.state === "answered" || answer.state === "permitted") && offered(answer.optionId) && live()) {
state.asks.delete(ask);
stopWaiting();
say(`[permission] ${answer.state}: ${answer.optionId}`);
resolve({ outcome: { outcome: "selected", optionId: answer.optionId } });
resolved();
passOn(answer.said);
} else if (live() && (read.status === 200 ? !ENDED.includes(answer.state) : !NOT_LIVE.includes(answer.code))) {
// OpenRig stopped answering, or answered in a way the contract doesn't list: nobody can be asked. The ask stays
// tracked, so the turn's end still withdraws it.
stopWaiting();
resolve(permit(read.status === 0 ? "OpenRig couldn't be reached" : `OpenRig couldn't answer (${answer.code ?? read.status})`));
if (!state.refused) resolved(); // permitted, not refused for want of an allow option
} else {
state.asks.delete(ask);
stopWaiting(); // a person said no, or the request is over
say(`[permission] not answered (${answer.reason ?? answer.state ?? answer.code ?? read.status}); answered cancelled`);
resolve(CANCELLED);
passOn(answer.said);
}
}
})();
});
};
peer.onRequest("session/request_permission", (params, requestId) => {
if (params.sessionId !== state.sessionId) throw Object.assign(new Error(`no turn of conversation ${params.sessionId} runs here`), { code: -32602 });
const toolCall = { title: String(params.toolCall?.title ?? "a tool call"), kind: String(params.toolCall?.kind ?? "other") };
const options = Array.isArray(params.options) ? params.options : [];
say(`\n[permission] dsh asks: ${toolCall.title} (${toolCall.kind}); options ${options.map((option) => option.optionId).join(", ")}`);
if (!state.running || state.cancelRequestedAt) return CANCELLED;
state.asking += 1; // no beat while a permission request is open: it may be waiting on a person
return askOwner(requestId, toolCall, options).finally(() => { state.asking -= 1; setImmediate(settle); });
});
// Once nothing waits on a person or is being submitted, the seat holds no refusal of its own and dsh is alive, say the turn
// goes on. It goes out after the agent has its answer, and before the next beat.
const settle = () => {
const done = state.resolvedAsk;
if (!done || state.holding || state.asking > 0) return;
state.resolvedAsk = null;
if (state.running && state.turn === done.turn && !agentExited) activity.post("active", "permission_resolved", { turnId: String(done.turn), askId: done.askId });
};
const abort = () => {
if (!state.running) { say("[openrig-dsh] no turn is running"); return; }
if (state.cancelRequestedAt) return;
state.cancelRequestedAt = new Date().toISOString();
peer.notify("session/cancel", { sessionId: state.sessionId });
say(`\n[openrig-dsh] cancel requested at ${state.cancelRequestedAt}; the turn ends when dsh says so`);
if (!state.refused) state.holding = false; // the seat's own refusal holds until the next accepted prompt
for (const ask of state.asks) ask.end("turn_cancelled");
};
const runTurn = async (text) => {
state.running = true;
state.turn += 1;
state.holding = false;
state.refused = false;
state.waiting = 0;
state.resolvedAsk = null;
state.cancelRequestedAt = null;
state.lastActive = 0;
const turnId = String(state.turn);
say(`\nyou ▸ ${firstLine(text)}`);
activity.post("UserPromptSubmit", "accepted", { turnId });
let stopReason;
try {
const result = await peer.request("session/prompt", { sessionId: state.sessionId, prompt: [{ type: "text", text }] });
stopReason = String(result?.stopReason ?? "unreported");
} catch (error) {
if (agentExited) return;
say(`\n[openrig-dsh] the turn failed: ${error.message}`);
activity.post("Notification", "runtime_error", { turnId });
stopReason = "error";
}
state.running = false;
for (const ask of state.asks) ask.end("turn_ended");
say(`\n── turn end (${stopReason}) ──`);
if (state.holding) say("[openrig-dsh] a permission request was refused, so the seat reads needs input until the next prompt");
else postIdle(stopReason);
pump();
};
const pump = () => {
if (state.running || agentExited || state.queue.length === 0) return;
void runTurn(state.queue.shift());
};
const submit = (text) => {
if (agentExited) return;
state.queue.push(text);
if (state.running) say(`[queued: ${state.queue.length}] this goes to dsh after the current turn`);
pump();
};
// Handshake, then open or reopen the conversation, then report it. Nothing is read as ready before this.
let init;
try {
init = await peer.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "openrig-dsh-runner", version: "1" } });
} catch (error) {
return fatal(`dsh didn't complete the ACP handshake (${error.message})`, 1);
}
const capabilities = init?.agentCapabilities ?? {};
const reported = init?.agentInfo?.version ? `${init.agentInfo.version} (as its ACP handshake reports it)` : "(version not reported)";
const producer = `${RUNNER}; dsh ${version ?? reported}; ACP ${init?.protocolVersion ?? "?"}`;
const resumeRef = env.OPENRIG_RESUME_REF;
// Whether dsh's own session list names a conversation, following its pages: true, false when every page was read and
// none names it, or undefined when the list fails, answers in another shape, or runs past LIST_PAGES pages. An ACP
// cursor is an opaque string, possibly empty, passed back unchanged; an absent or null one ends the list.
const listsConversation = async (id) => {
let cursor;
for (let page = 0; page < LIST_PAGES; page += 1) {
let listing;
try { listing = await peer.request("session/list", cursor === undefined ? {} : { cursor }); } catch { return undefined; }
if (!Array.isArray(listing?.sessions) || listing.sessions.some((session) => typeof session?.sessionId !== "string")) return undefined;
if (listing.sessions.some((session) => session.sessionId === id)) return true;
if (listing.nextCursor === undefined || listing.nextCursor === null) return false;
if (typeof listing.nextCursor !== "string") return undefined;
cursor = listing.nextCursor;
}
return undefined;
};
if (resumeRef) {
// The method dsh advertises; with neither advertised, session/resume, the method dsh 0.2.1-rc.2 answered in its
// maintainer's checks. dsh 0.2.1-rc.2 answers -32602 both for a conversation it doesn't have and for one active
// elsewhere, so that error can't mean missing. Only dsh's own "not found" counts as missing, and not when dsh's list
// names the conversation. The list can't show a conversation is gone: one another running dsh holds unsaved may be
// left out, so an unlisted conversation is still reopened. Anything else leaves the resume unconfirmed.
const method = capabilities.sessionCapabilities?.resume ? "session/resume" : capabilities.loadSession ? "session/load" : "session/resume";
const listed = capabilities.sessionCapabilities?.list ? await listsConversation(resumeRef) === true : false;
try {
await peer.request(method, { sessionId: resumeRef, cwd, mcpServers: [] });
} catch (error) {
if (error.code === ACP_RESOURCE_NOT_FOUND && !listed) {
writeLaunchRecord({ error: "missing", producer });
return fatal(`dsh says conversation ${resumeRef} doesn't exist (${error.message}); no new one was started`, 3);
}
return fatal(`dsh couldn't reopen conversation ${resumeRef} (${method}: ${error.message}); the resume is unconfirmed and no new conversation was started`, 4);
}
state.sessionId = resumeRef;
} else {
try {
state.sessionId = (await peer.request("session/new", { cwd, mcpServers: [] }))?.sessionId ?? null;
} catch (error) {
return fatal(`dsh couldn't open a conversation (${error.message})`, 1);
}
if (!state.sessionId) return fatal("dsh opened a conversation without naming it", 1);
}
writeLaunchRecord({ nativeRef: state.sessionId, producer });
say(`[openrig-dsh] ready · ${producer} · conversation ${state.sessionId}`);
say(`[openrig-dsh] DSH_HOME ${home} · DSH_PERMISSION_MODE ${agentEnv.DSH_PERMISSION_MODE}`);
say("[openrig-dsh] each submitted message is one prompt; /abort or Ctrl-C cancels the running turn");
heartbeat = setInterval(() => { if (!state.running && !state.holding && !agentExited) postIdle("idle_heartbeat"); }, BEAT_MS);
// While a turn runs and nothing waits on a person: the turn is still outstanding and dsh is alive. Not progress.
workBeat = setInterval(() => {
if (working()) {
activity.post("active", "liveness", { turnId: String(state.turn), livenessSeconds: WORK_LIVENESS_SECONDS });
}
}, WORK_BEAT_MS);
for (const signal of ["SIGHUP", "SIGTERM"]) {
process.on(signal, () => {
if (state.running && !state.cancelRequestedAt) peer.notify("session/cancel", { sessionId: state.sessionId });
for (const ask of state.asks) ask.end("exited");
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 3_000).unref();
});
}
process.on("SIGINT", abort);
const framer = new InputFramer();
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdout.write("\x1b[?2004h");
tty = true;
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
for (const event of framer.push(chunk)) {
if (event.type === "echo") process.stdout.write(event.text);
else if (event.type === "erase") process.stdout.write("\b \b");
else if (event.type === "abort") { process.stdout.write("\n"); abort(); }
else if (event.type === "block") { process.stdout.write("\n"); submit(event.text); }
}
});
} else {
createInterface({ input: process.stdin }).on("line", (line) => {
if (line.trim() === "/abort") abort();
else if (line.trim()) submit(line);
});
}
if (firstInput !== null) submit(firstInput);
else postIdle("idle_heartbeat");
}
const invoked = (() => {
try { return process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null; } catch { return null; }
})();
if (invoked === import.meta.url) {
main().catch((error) => { console.error(`openrig-dsh-runner: ${error.stack ?? error}`); process.exit(1); });
}
@@ -1,19 +0,0 @@
# Merge this into each Hermes seat's config.yaml, in that seat's Hermes home ($OPENRIG_HOME/state/hermes/<node id> with
# the example launcher), before the seat's first start.
#
# Hermes runs every tool command in its gateway (one per Hermes home, so one per seat with the example launcher), with
# the gateway's environment. For each session it swaps
# in the client's own values only for its toolchain names and the names listed here. Without this list, `rig` inside
# Hermes runs as whichever seat started the gateway, or as no seat. With it, each seat runs `rig` as itself.
# These are the seat's own OpenRig variables; none is a secret, and Hermes refuses credential-like names anyway.
terminal:
client_env_passthrough:
- OPENRIG_SESSION_NAME
- OPENRIG_NODE_ID
- OPENRIG_HOME
- OPENRIG_URL
- OPENRIG_HOST
- OPENRIG_PORT
- OPENRIG_DB
# Your own settings go beside it (the guide says which): the model and provider, and agent.max_turns if you want a cap.
@@ -1,13 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/hermes.yaml, with openrig-hermes-launch on the seat's PATH.
format: 1
name: hermes
command: openrig-hermes-launch # no model, provider or --max-turns here: they go in the seat's Hermes config.yaml
maintainer: Your name or team
limits:
- resume not declared until reopening a named session, and a missing one, have been checked with a real Hermes
- closing a seat's pane detaches it; Hermes finishes any accepted turn; whether its gateway exits when idle is unchecked
- creation settings (model, provider, max turns) come from the seat's Hermes config.yaml, not from flags
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
passes_model: false # the launcher maps OPENRIG_MODEL for a new session; set true once checked with the real harness
@@ -1,49 +0,0 @@
#!/bin/sh
# A reference launcher for Hermes Agent's CLI as an OpenRig community harness. Copy it, own it, and change it: OpenRig
# doesn't ship or run it. It follows docs/reference/community-harness-presets.md (`names_conversation`):
# - a new launch (OPENRIG_CONVERSATION set): Hermes creates the session titled with that name;
# - a resume (OPENRIG_RESUME_REF set): Hermes reopens that session, and never creates one. A name that matches nothing
# ends with Hermes's own "No session found" error, so a gone conversation isn't replaced by a new one.
# Put the model, provider and max turns in the seat's Hermes config.yaml, not on the command line. Hermes v0.22.4's CLI
# creates a `-c <name> --create-if-missing` session with no launch flags, then reopens it and refuses any creation flag
# that differs, so such flags fail even on a new name. Other arguments go only to a new session, never to a resume.
set -eu
hermes="${OPENRIG_HERMES:-hermes}"
# Each seat gets its own Hermes home, so seats don't share sessions or a gateway, and a seat's stop concerns only its own.
# Its settings (model, provider, agent.max_turns) come from that home's config.yaml, and its key from the environment
# (an OpenRouter key, say), not from the person's ~/.hermes.
HERMES_HOME="${OPENRIG_HOME:-$HOME/.openrig}/state/hermes/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
export HERMES_HOME
mkdir -p "$HERMES_HOME"
# Hermes's tools see its shared gateway's environment except for the names a profile passes through, so a seat whose
# config.yaml doesn't pass OPENRIG_SESSION_NAME would run `rig` as another seat, or as none (see config.yaml.example).
if ! grep -qs OPENRIG_SESSION_NAME "$HERMES_HOME/config.yaml"; then
echo "openrig-hermes-launch: $HERMES_HOME/config.yaml doesn't pass OPENRIG_SESSION_NAME to Hermes's tools, so rig run from Hermes won't act as this seat; see config.yaml.example" >&2
fi
# The seat's declared model (OPENRIG_MODEL, when the preset sets passes_model): Hermes takes it only from config.yaml, and
# Hermes v0.22.4 fixes a session's launch settings when it creates it. So it's written for a new session only, and only
# into a config.yaml that is absent or empty: a seat's own config is left byte-for-byte as it is, whatever it says. On a
# resume this launcher applies no OpenRig model.
if [ -n "${OPENRIG_MODEL:-}" ] && [ -z "${OPENRIG_RESUME_REF:-}" ]; then
case "$OPENRIG_MODEL" in
''|*[!A-Za-z0-9._:/@+-]*) echo "openrig-hermes-launch: not writing unusual model id \"$OPENRIG_MODEL\" into $HERMES_HOME/config.yaml" >&2 ;;
*) if [ -s "$HERMES_HOME/config.yaml" ]; then
echo "openrig-hermes-launch: the seat has its own $HERMES_HOME/config.yaml; OPENRIG_MODEL was not applied; set the model there" >&2
else
printf 'model: "%s"\n' "$OPENRIG_MODEL" >> "$HERMES_HOME/config.yaml"
fi ;;
esac
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_RESUME_REF"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
exec "$hermes" chat -c "$OPENRIG_CONVERSATION" --create-if-missing "$@"
fi
echo "openrig-hermes-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -1,15 +0,0 @@
# The Gateway must already run. Configure its route and authentication in this seat's OPENCLAW_CONFIG_PATH.
format: 1
name: openclaw-gateway
command: openrig-openclaw-launch --gateway
maintainer: Your name or team
limits:
- connects to an existing Gateway; this launcher never starts or stops that service
- stopping the TUI closes its client; an accepted turn may keep running on the Gateway
- uses only agent main; automatic session resets must stay off
- typing /new changes the native key while the seat keeps the name OpenRig gave it
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
resume: true
first_input: true
@@ -1,16 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/openclaw.yaml, with openrig-openclaw-launch on the seat's PATH.
format: 1
name: openclaw
command: openrig-openclaw-launch
maintainer: Your name or team
limits:
- runs locally in the pane (openclaw tui --local), using only agent main, with no Gateway; Gateway-attached use isn't covered
- Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it.
- local resume may run a native database integrity check; check that the TUI has finished startup before sending work
- automatic session resets (session.reset) must stay off, or a key can start a new conversation
- typing /new in the pane moves the chat to a fresh session key; the seat still holds the name OpenRig gave it
- no guidance, skills or startup files are placed
- needs-input and approval prompts are read from the screen only
names_conversation: true
resume: true
first_input: true
@@ -1,70 +0,0 @@
#!/bin/sh
# Reference launcher for OpenClaw 2026.10.6 (fe06181), not installed by OpenRig.
# Each seat owns its client state and uses the main agent. OpenRig's opaque name maps to its full native key.
# --gateway selects an existing Gateway configured for this client; the default is an embedded local agent.
# Resume checks that exact key at its owner; an unavailable lookup is not a missing conversation.
set -eu
openclaw="${OPENRIG_OPENCLAW:-openclaw}"
mode=local
if [ "${1:-}" = --gateway ]; then mode=gateway; shift; fi
OPENCLAW_STATE_DIR="${OPENRIG_HOME:-$HOME/.openrig}/state/openclaw/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
export OPENCLAW_STATE_DIR
mkdir -p "$OPENCLAW_STATE_DIR"
name="${OPENRIG_RESUME_REF:-${OPENRIG_CONVERSATION:-}}"
if [ -z "$name" ]; then
echo "openrig-openclaw-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
fi
case "$name" in
agent:main:*) key="$name" ;;
agent:*) echo "openrig-openclaw-launch: this example supports only the main OpenClaw agent" >&2; exit 2 ;;
*) key="agent:main:$name" ;;
esac
# Preserve the file's exact bytes, including trailing newlines. A read error stops before the harness starts.
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input=${first_input%.}
set -- "$@" --message "$first_input"
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
if [ "$mode" = gateway ]; then
params=$(KEY="$key" node -e 'process.stdout.write(JSON.stringify({ key: process.env.KEY, agentId: "main" }))')
if listing=$("$openclaw" gateway call sessions.describe --params "$params" --json); then listed=true; else listed=false; fi
else
if listing=$("$openclaw" sessions --agent main --json --limit all); then listed=true; else listed=false; fi
fi
if [ "$listed" = false ]; then
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
exit 4
fi
if printf '%s' "$listing" | KEY="$key" MODE="$mode" node -e '
let raw = ""; process.stdin.on("data", (chunk) => { raw += chunk; });
process.stdin.on("end", () => {
try {
const data = JSON.parse(raw);
if (process.env.MODE === "gateway") {
if (data.session === null) process.exit(3);
process.exit(data.session?.key === process.env.KEY ? 0 : 4);
}
const { sessions } = data;
if (!Array.isArray(sessions)) process.exit(4);
process.exit(sessions.some((s) => s?.agentId === "main" && s.key === process.env.KEY) ? 0 : 3);
} catch { process.exit(4); }
});'; then
:
else
result=$?
if [ "$result" -eq 3 ]; then
echo "openrig-openclaw-launch: this seat's OpenClaw sessions don't list \"$key\"; not starting a new one under that key" >&2
else
echo "openrig-openclaw-launch: this seat's OpenClaw sessions couldn't be listed; not starting" >&2
fi
exit "$result"
fi
fi
if [ "$mode" = gateway ]; then exec "$openclaw" tui --session "$key" "$@"; fi
exec "$openclaw" tui --local --session "$key" "$@"
@@ -1,5 +0,0 @@
# OpenShell reference driver
Run `rig environment setup --help` to install the maintained OpenShell 0.1.6 / Claude Code reference from the packaged daemon assets. Its README, image recipe, endpoint-specific policy, example RigSpec/AgentSpecs and versioned driver schema are installed together. Setup prepares files and an ordinary scoped work grant; it does not create compute or launch a provider.
This first cut supports explicit owned create or exact attached binding, required destination projection and verified JSON artifact return through authenticated MCP work tools. Explicit owned lifecycle operations retain separate observed receipts. Exact same-host recreate/restore requires verified private history and the selected checkpoint; attached native, storage and compute are preserved. A native process receipt is not a successful model-work claim. Existing unbound host runtimes retain their launch/projection paths.
@@ -1,100 +0,0 @@
#!/bin/sh
# A reference launcher for Pi's own terminal UI (interactive `pi`) as an OpenRig community harness. Copy it, own it, and
# change it: OpenRig doesn't ship or run it. It's written for Pi 1.4.0 from its source and not yet checked with a real Pi.
# It follows docs/reference/community-harness-presets.md (`names_conversation`, `first_input`):
# - a new launch (OPENRIG_CONVERSATION set): Pi creates the session whose id is that name, in the seat's own session
# folder, and takes the first input as its first prompt;
# - a resume (OPENRIG_RESUME_REF set): the launcher opens the one session file whose id is that name, and starts nothing
# when there isn't exactly one. Pi's --session-id creates a new session for an id it doesn't find, and --session with
# an id can open another session whose id starts the same way, so neither is used to resume.
set -eu
pi="${OPENRIG_PI:-pi}"
state="${OPENRIG_HOME:-$HOME/.openrig}/state/pi-tui/${OPENRIG_NODE_ID:-${OPENRIG_SESSION_NAME:-default}}"
sessions="$state/sessions"
# Each seat gets its own Pi agent folder (settings, logins, skills) unless PI_CODING_AGENT_DIR is already set, so seats
# don't share settings and a person's ~/.pi/agent isn't touched. Give the seat its provider key in its environment, or its
# login and settings in this folder, before its first start.
PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$state/agent}"
# Pi expands a leading ~ in this folder; do the same, so the skills land where Pi looks.
case "$PI_CODING_AGENT_DIR" in
"~") PI_CODING_AGENT_DIR="$HOME" ;;
"~/"*) PI_CODING_AGENT_DIR="$HOME/${PI_CODING_AGENT_DIR#"~/"}" ;;
esac
export PI_CODING_AGENT_DIR
mkdir -p "$PI_CODING_AGENT_DIR/skills" "$sessions"
# Copy the installed OpenRig's coordination skills in at each launch, so an upgrade reaches the seat. Pi lists the skills
# in <agent folder>/skills/ by itself; the seat's first input should still name queue-handoff (see the recipe).
skills_from="${OPENRIG_PI_SKILLS_FROM:-${OPENRIG_HOME:-$HOME/.openrig}/plugins/openrig-core/skills}"
set -f
for skill in ${OPENRIG_PI_SKILLS:-queue-handoff}; do
case "$skill" in
*[!a-z0-9-]*|-*) echo "openrig-pi-tui-launch: skipping '$skill': not a skill name" >&2; continue ;;
esac
if [ -d "$skills_from/$skill" ]; then
rm -rf "$PI_CODING_AGENT_DIR/skills/$skill"
cp -Rp "$skills_from/$skill" "$PI_CODING_AGENT_DIR/skills/"
else
echo "openrig-pi-tui-launch: no $skill skill in $skills_from; starting without it" >&2
fi
done
set +f
# Pi's session ids: letters, digits, '.', '_' and '-', starting and ending with a letter or digit.
valid_id() {
case "$1" in
"" | *[!A-Za-z0-9._-]* | [!A-Za-z0-9]* | *[!A-Za-z0-9]) return 1 ;;
esac
}
# The first input goes over as Pi's first prompt, byte for byte, after `--`. OpenRig's starts with its identity-context
# line; Pi would read one starting with @ as a file even after `--`, and may run one starting with / as a command.
set --
if [ -n "${OPENRIG_FIRST_INPUT:-}" ]; then
first_input=$(cat "$OPENRIG_FIRST_INPUT" && printf '.')
first_input="${first_input%.}"
if [ -n "$first_input" ]; then set -- -- "$first_input"; fi
fi
if [ -n "${OPENRIG_RESUME_REF:-}" ]; then
id="$OPENRIG_RESUME_REF"
if ! valid_id "$id"; then
echo "openrig-pi-tui-launch: '$id' isn't a Pi session id; starting nothing" >&2
exit 2
fi
# Pi names a session file <time>_<id>.jsonl, but the file's first line, its header, is what says which session it is:
# its top-level type and id, read as JSON as Pi reads it. Pi writes the file only once the conversation has a message, so
# a session that never got one can't be reopened. Pi runs on Node, so node reads the header.
node="${OPENRIG_PI_NODE:-node}"
if ! command -v "$node" >/dev/null 2>&1; then
echo "openrig-pi-tui-launch: $node isn't on PATH to read Pi's session headers; starting nothing" >&2
exit 1
fi
found="" count=0
for file in "$sessions"/*_"$id".jsonl; do
[ -s "$file" ] || continue
if head -n 1 "$file" | "$node" -e '
let text = ""; process.stdin.on("data", c => { text += c; }).on("end", () => {
try { const h = JSON.parse(text); process.exit(h && h.type === "session" && h.id === process.argv[1] ? 0 : 1); }
catch { process.exit(1); }
});' "$id"; then
found="$file" count=$((count + 1))
fi
done
if [ "$count" -ne 1 ]; then
echo "openrig-pi-tui-launch: expected one Pi session $id in $sessions, found $count; starting nothing" >&2
exit 1
fi
exec "$pi" --session-dir "$sessions" --session "$found" "$@"
fi
if [ -n "${OPENRIG_CONVERSATION:-}" ]; then
if ! valid_id "$OPENRIG_CONVERSATION"; then
echo "openrig-pi-tui-launch: '$OPENRIG_CONVERSATION' isn't a Pi session id; starting nothing" >&2
exit 2
fi
exec "$pi" --session-dir "$sessions" --session-id "$OPENRIG_CONVERSATION" "$@"
fi
echo "openrig-pi-tui-launch: OpenRig passed no conversation name; is names_conversation set in the preset?" >&2
exit 2
@@ -1,15 +0,0 @@
# Install as $OPENRIG_HOME/community-harnesses/pi-tui.yaml, with openrig-pi-tui-launch on the seat's PATH.
# "pi" itself is the built-in managed Pi runtime, so this preset for Pi's own terminal UI takes another name.
format: 1
name: pi-tui
command: openrig-pi-tui-launch # no model or provider here: they go in the seat's Pi settings or environment
maintainer: Your name or team
limits:
- checked once with Pi 1.4.0 and OpenRig 0.7.4 (one run, one model); other Pi versions aren't checked
- resume not declared until reopening a named session, and a missing one, have been checked with a real Pi
- with the default typing guard, a wake submits a person's half-typed draft in Pi's editor together with the wake; run the seat unattended, not where a person also types
- a send while a Pi dialog OpenRig doesn't recognize is open, such as /settings, types into the dialog
- how Pi takes a wake that arrives while it's busy isn't shown
- only the listed openrig-core skills are copied into the seat's Pi agent folder; no guidance or startup files are placed
names_conversation: true
first_input: true
-455
View File
@@ -1,455 +0,0 @@
# Community harness presets
A community harness is a coding harness an outside contributor maintains. You install it as one small data file, and a
rig member runs it by naming it as its `runtime`. OpenRig doesn't test these harnesses. It shows them as community
harnesses, and it reads their activity only from what the screen shows.
For what an integration author owns end to end, including a runner's duties and running a harness somewhere else, see
the draft [integration author contracts](author-contracts.md).
## What a community harness gets
What OpenRig does for a community-harness seat, capability by capability. **Partly** and **no** mean OpenRig shows that it doesn't know;
it never reads an unsupported capability as idle, working, ready or verified.
| Capability | Supported | How it works, or what you see instead |
|---|---|---|
| Launch | yes | OpenRig types the preset's `command` into the seat's pane, with its variables passed through `/usr/bin/env`. The seat reads as ready once something other than a shell runs in the pane; that's a process check, not proof of which conversation it runs. With `startup_assisted`, the seat reports attention required until a person checks it. |
| Delivery | yes | `rig send` and queue wakes paste the text into the pane and press Enter, as for any seat. A pane that's back at a shell is refused (`target_runtime_unverified`) and nothing is typed. OpenRig doesn't confirm the harness took the message; `rig send --verify` checks the pane afterwards. |
| Identity | when declared | With `launch_result`, the conversation is shown as reported by its launcher; with `names_conversation`, as named by OpenRig. Neither is observed by OpenRig. Without either, the identity is unavailable. |
| Resume | when declared | `resume: true` with one of the two above (see [Reopening a conversation](#reopening-a-conversation-resume-true)). Without it, a resume says `Resume is unsupported: community harness "<name>" is launch-only`, and a restore stops and asks. |
| Activity | partly | Read from the screen only; there are no activity hooks. Work and attention the screen shows are kept, but a screen that looks idle reads `unknown`, never idle, because a quiet prompt can be an approval or a pause. A [runner preset](#runner-presets-runner-true)'s own reports decide it instead. |
| Attention | partly | Read from the screen: a permission question or numbered choice that OpenRig recognizes reads as needing attention, and `rig send` won't type into it. A prompt it doesn't recognize reads `unknown`. |
| Approval | no | Approvals stay in the harness's own pane. OpenRig relays none, so a person answers them there. A [runner preset](#runner-presets-runner-true) is the exception: its runner submits the agent's permission requests to OpenRig, which asks a person, and the default is permit (section 7 of the [harness and runner contract](harness-runner-contract.md)). |
| Cancel | no | OpenRig has no verb that cancels a running turn. `rig seat stop` and `rig down` end the seat's tmux session; anything the harness runs outside its pane, such as a gateway, is its own, and the preset's `limits` should say what keeps running. Restarting in place and switching harness are refused. |
| Transport | yes | The seat is a tmux pane like any other, so `rig capture`, `rig send` and `--host` work as they do for any seat. |
**Not placed:** guidance, skills and startup files (a launch warns that they weren't delivered), and the model: set it in the harness's own
configuration, or let a launcher that opts in take the seat's declared model (`passes_model`, below).
**Check that the harness answers on its own first.** Before writing a preset, start the harness in a terminal outside OpenRig, with the
same configuration and credentials your launcher will give it, and send it one prompt. Use the folder your launcher gives the seat, not
the harness's default one: the example launchers keep one per seat under `$OPENRIG_HOME/state/<harness>/` (`HERMES_HOME`,
`OPENCLAW_STATE_DIR` or `CURSOR_CONFIG_DIR`), so a check with your own defaults can pass while the seat's setup fails. If no answer comes
back, the problem is in the harness's own setup, such as its model, provider or login, and relaunching an OpenRig seat won't fix it. Fix
it in the harness, then connect it.
## The preset
One YAML file per harness: `$OPENRIG_HOME/community-harnesses/<name>.yaml`. `rig community-harness list` shows what's
installed.
```yaml
format: 1
name: example-harness # must match the file name: lowercase letters, digits and hyphens
command: example-launch # one program and its arguments, typed into the seat's pane
maintainer: Example Maintainer
limits: # what the maintainer says doesn't work
- fork unsupported
launch_result: true # optional: the launcher reports the conversation it created (below)
# names_conversation: true # optional, instead of launch_result: OpenRig names the conversation (below)
resume: true # optional, needs one of those two; declare it only once your harness's resume is checked
startup_assisted: true # optional: startup submission needs a person's check (below)
first_input: true # optional: launcher takes the first turn from OPENRIG_FIRST_INPUT (below)
# runner: true # optional, needs launch_result: the launcher reports the seat's activity (below)
# passes_model: true # optional: the launcher takes the seat's declared model from OPENRIG_MODEL (below)
# launch_wrapper: true # optional: a runner applies OPENRIG_LAUNCH_WRAPPER to its native child (below)
```
- **`command`** is a single program and its arguments. Shell syntax such as `|`, `&&`, `;`, redirections or `$(…)`
is refused: put it in a script and name the script.
- **Built-in runtime names are reserved.** A preset can't replace `claude-code`, `codex` or the other built-ins.
- **Unknown fields are refused,** so a preset written for a newer OpenRig says so instead of half-working.
- **Fork isn't supported, and resume only as described below.** Neither is placing skills, guidance or startup files. Except
for assisted startup below, the seat is called ready while something other than a shell runs in its pane, which says
nothing about which conversation it is.
### Assisted startup
Set `startup_assisted: true` when startup submission needs a person's check. The seat may start its first turn
automatically, including from input passed at launch with `first_input: true`. This declaration requires a check after
the submission attempt; it does not hold the agent back. Without `first_input`, OpenRig pastes the startup text and
presses Enter once.
After attempting startup input, OpenRig reports **attention required**: check the pane and submit any text still in the
composer. OpenRig does not resend the identity prompt. Once the seat responds, use the reported
`rig seat clear-attention <session> --reason "Startup submission checked; seat responded"` command to acknowledge the
check. A launch with no startup text needs no such check. Other presets keep their existing behavior.
## Reporting the conversation a launch created (`launch_result: true`)
Some harnesses create their own conversation id before they start. Cursor's `cursor-agent create-chat` is one. With
`launch_result: true`, the harness's launcher tells OpenRig that id for each launch.
1. **OpenRig names one file per launch attempt.** It starts `command` with two variables:
- `OPENRIG_LAUNCH_RESULT`, a new file path inside `$OPENRIG_HOME/state/launch-results/`;
- `OPENRIG_LAUNCH_ATTEMPT`, this attempt's id.
2. **The launcher writes one JSON record to that path,** then starts the harness:
```json
{ "format": 1, "attempt": "<OPENRIG_LAUNCH_ATTEMPT>", "harness": "cursor", "nativeRef": "<the conversation id>", "producer": "openrig-cursor-launch 1.0" }
```
3. **OpenRig reads that attempt's record once,** waiting up to 20 seconds for it. It records `nativeRef` on the session,
and the seat shows its identity as **reported by its launcher, not observed**. That's what the launcher says, not
proof that the harness used it.
The seat still starts, with its identity **unavailable** and the reason named, when the record:
- wasn't written;
- belongs to another attempt or another harness;
- isn't JSON, isn't format 1, or is larger than 64 KiB;
- has an unusable `nativeRef` (empty, too long, or containing spaces or control characters);
- names no producer;
- or is reached through a link.
None of these means the conversation is gone.
A reported id doesn't make the harness resumable on its own. Without `resume: true`, nothing resumes it: a restore
stops and asks, as it did before (re-run it with `--fresh <seat>` only if you want a new conversation), and elsewhere a
resume says it's unsupported, and a new conversation starts only where the caller allows a fresh start.
**Good to know:**
- **The variables are passed with `/usr/bin/env`,** so `launch_result` needs a POSIX system.
- **A launcher that never writes its record** adds up to 20 seconds to each launch, while OpenRig waits for it.
- **Records are kept,** one small file per attempt, under `$OPENRIG_HOME/state/launch-results/<session>/`. A record
larger than 64 KiB isn't read.
## Letting OpenRig name the conversation (`names_conversation: true`)
Some harnesses take a conversation name from the caller and create that conversation, or reopen it. Hermes's
`hermes chat -c <name> --create-if-missing` is one. With `names_conversation: true`, OpenRig names each new conversation
and the harness uses that name, so no record is needed.
- **A new launch:** OpenRig starts `command` with `OPENRIG_CONVERSATION` set to a new name, `openrig-<seat>-<8 hex digits>`
(at most 100 characters). Each launch gets its own name, so two seats never share a conversation.
- **What the seat shows:** OpenRig records that name on the session, and the seat shows its identity as **named by
OpenRig, not observed**. That's the name OpenRig passed, not proof that the harness used it.
- **Not with `launch_result`:** a preset declares one or the other. Either the launcher reports the conversation, or
OpenRig names it.
## Reopening a conversation (`resume: true`)
**A named conversation:** with `names_conversation`, a resume starts `command` with `OPENRIG_RESUME_REF` set to the saved
name. The harness reopens it and must never create one under that name. Nothing reports back, so:
- **readiness is process-only:** the seat reads as ready while something other than a shell runs in its pane. That
doesn't show the named conversation exists: a harness (or a launcher's own check) that hasn't yet found it gone reads
as ready, and the saved name stays unverified until something observes it;
- **never creating one is the launcher's job:** a resume must not create a conversation under the saved name. When it's
gone, the harness or launcher ends with its error and the pane is back at a shell; the seat keeps its saved name, and
OpenRig starts nothing new, though the seat may already have read as ready;
- **a former runtime's id** isn't passed to the harness, as below.
**A reported conversation:** with `launch_result`, a resume (a restore included) starts the launcher with `OPENRIG_RESUME_REF` set to the
conversation to reopen. The launcher reports what happened in the same attempt's file:
- **Reopened:** the usual record, with that conversation as `nativeRef`.
- **Gone:** `{ "format": 1, "attempt": "…", "harness": "cursor", "error": "missing", "producer": "…" }`. The launcher
starts nothing. OpenRig reports the conversation missing and starts nothing either: it never replaces it with a new one.
For a reported conversation, OpenRig also says so plainly when:
- **no usable record is written:** the resume isn't confirmed. The seat needs attention and keeps the conversation it
asked for; nothing is restarted;
- **the record names a different conversation:** the seat needs attention and keeps the conversation it asked for.
OpenRig doesn't switch it to the reported one;
- **the saved conversation isn't this harness's own** (a former runtime's id, say): it isn't passed to the launcher. A restore stops and asks, and elsewhere the resume says it isn't possible;
- **the record's folder can't be made:** the launcher couldn't be told what to reopen, so nothing starts.
## Runner presets (`runner: true`)
A plain preset's seat reads its activity from the screen. A **runner** preset is for a launcher that hosts the agent itself
and reports what it's doing, as OpenRig's own Pi runner does. The seat's activity then follows those reports.
- **It needs `launch_result: true`.** A runner reports the conversation it opened in its launch record, so `runner: true`
without `launch_result` is refused.
- **The declaration is fixed at launch.** Each launch, a restore's included, records on the session what the preset
declares at that moment. Editing the preset changes the next launch, not a running seat. A session recorded before this
field existed reads as plain.
- **No record means attention.** When a runner's launch record doesn't arrive within the wait, or its folder can't be
made, the seat needs attention instead of reading ready. A plain `launch_result` preset still starts, with its identity
unavailable.
- **Input is as for any seat.** A send pastes the text and presses Enter, so a runner should submit everything up to that
Enter as one prompt.
- **`launch_wrapper: true` declares wrapper support and requires `runner: true`.** A member with a wrapper and a runner preset without this flag is refused at preflight and launch (not schema validation):
`launch_wrapper unsupported: the community preset "<runtime>" runs a runner without launch_wrapper: true; no agent started. Add launch_wrapper: true to the preset if its runner applies OPENRIG_LAUNCH_WRAPPER, or remove launch_wrapper from this member.` See the
[harness and runner contract](harness-runner-contract.md#2-declare-it-what-the-preset-says-fixed-at-launch)
for the environment handoff, native-child execution and refusal requirements.
**What the runner posts.** The runner posts each event to `POST /api/activity/hooks` with the local activity hook token,
one at a time and in order, without making the agent wait. It finds the address and token in `OPENRIG_URL` (or
`OPENRIG_PORT`) and `OPENRIG_ACTIVITY_HOOK_TOKEN`, or else in `$OPENRIG_HOME/activity-endpoint.json`. Every post names:
- the seat, as `sessionName` and `nodeId`;
- `runtime`, the preset's name;
- `generation`, from `OPENRIG_OCCUPANT_GENERATION`;
- an increasing `sequence`, and `occurredAt`;
- `hookEvent` and `subtype`, as in the table.
| The runner posts | When | The seat reads |
|---|---|---|
| `UserPromptSubmit` | it gave the agent a prompt | working |
| `active` or `PreToolUse` | the agent is working | working |
| `Notification` / `permission_prompt` | the agent asked for a permission | needs input |
| `Stop` / the turn's stop reason | the turn ended | idle |
| `Stop` / `<preset name>_exited` | the agent's process ended | unknown, never idle |
- **The screen still counts,** but only as evidence of work: on its own it never reads the seat idle.
- **The generation attributes a post; it isn't authentication.** A post with an earlier occupant's generation doesn't
count for the live seat. The hook token is one local token, so any process that holds it can post for any seat.
- **A runner's full duties** (liveness, input, cancellation and permissions) are in the draft
[harness and runner contract](harness-runner-contract.md).
## An example: Hermes
[`community-harness-examples/hermes/`](community-harness-examples/hermes/) has a preset and a small launcher for Hermes
Agent's CLI.
- **The launcher:** it creates the session OpenRig named (`-c <name> --create-if-missing`), and on a resume reopens it
without `--create-if-missing`, with its own Hermes home per seat. A name that matches nothing ends with Hermes's own
"No session found" error.
- **Settings go in the seat's Hermes `config.yaml`, not in flags:** Hermes's CLI creates a named session with no launch
flags and then reopens it, refusing any creation flag (model, provider, `--max-turns`) that differs. So the preset's
command carries none, and each seat's `config.yaml`, in its Hermes home, sets them before the seat's first start:
the model and provider, and `agent.max_turns` if you want a cap (nothing else sets it once the flag is gone).
- **Let Hermes run `rig` as the seat:** Hermes runs every tool command in its gateway, one per Hermes home, so with
this launcher one per seat (a two-seat run showed separate gateways, homes and sockets, and stopping one seat left
the other's running). It runs them with that gateway's
environment, and swaps in a session's own values only for its toolchain names and the names its profile lists in
`terminal.client_env_passthrough`. So put the seat's OpenRig variables in that list, in each seat's `config.yaml`
([`config.yaml.example`](community-harness-examples/hermes/config.yaml.example)). Without it, `rig` inside Hermes
runs as whichever seat started the gateway, or as no seat; with it, each seat runs `rig` as itself. To check, ask the
seat's Hermes to run `rig whoami --json`. The launcher warns when a seat's `config.yaml` doesn't pass
`OPENRIG_SESSION_NAME`. This covers Hermes's CLI and TUI, whose clients pass their environment; its ACP clients pass
none.
- **The preset:** it declares `names_conversation` but not `resume`, until reopening a named session, and a missing one,
have been checked with a real Hermes.
- **Stopping:** Hermes's CLI is a client of a gateway, so closing a seat's pane detaches it. Hermes finishes any turn it
accepted. Whether the seat's gateway then exits once idle hasn't been checked: check with `ps` that a stopped seat's
gateway has gone before relying on it. The preset says so in its limits.
Copy them and maintain them as your own.
## An example: OpenClaw
[`community-harness-examples/openclaw/`](community-harness-examples/openclaw/) has a preset and a small launcher for
OpenClaw's CLI.
- **Choose the mode:** `openclaw.yaml` runs an embedded agent with `tui --local`. `openclaw-gateway.yaml` runs a TUI client of an existing Gateway. Configure that client's Gateway route and authentication through `OPENCLAW_CONFIG_PATH`; the launcher never starts or stops the Gateway.
- **The conversation:** both examples use only agent `main`, mapping OpenRig's name to the full native key `agent:main:<OpenRig name>`. Each seat has its own client state folder.
- **Its resume check:** local mode checks `sessions --agent main --json --limit all`; Gateway mode calls the selected Gateway's `sessions.describe`. Both require the exact canonical key. An unavailable lookup stops the launch; a confirmed missing key is never recreated. A client's local cache cannot authorize Gateway resume.
- **Stopping:** a Gateway client's pane closing detaches it. The Gateway and an accepted turn can keep running; stopping that service is separate. Local mode runs its embedded agent in the pane.
- **Unattended seats:** prefer Gateway mode within the capabilities qualified for your installed version. Local interrupted-turn recovery is not supported by these launchers.
- **Local interruption:** Finish the current local turn before stopping; an interrupted local turn may retain native recovery work and refuse the next request. This launcher does not repair or retry it. OpenRig recognizes the recorded local “changed while starting work” error frame as needing attention: “OpenClaw turn not recovered; inspect the pane before retrying”. Use `rig capture <session>` to inspect the native conversation. An ordinary send still submits a new turn, with an advisory that the last local turn was not recovered; it is not an automatic replay of that turn. It does not clear native recovery records, repeat the request or start a fresh conversation; a successful process launch is not proof that the turn recovered.
- **Local startup after resume:** OpenClaw may run its database integrity check during startup. A reported resumed process does not confirm readiness for the next request; check that the TUI has finished starting before sending work.
- **Resets must stay off:** automatic session resets can create a new conversation under the same key. Typing `/new` also moves to a fresh key while the seat keeps the name OpenRig gave it.
- **The presets:** both declare `names_conversation`, `resume` and `first_input`. The launcher passes the generated first startup text through OpenClaw's `--message` option. OpenRig labels the saved name as passed, not observed by its adapter.
Copy the launcher and the preset for your mode, and maintain them as your own.
**Stopping a community seat stops its pane.** `rig down` names those seats ("Pane stopped: …"), since a harness's own
processes, a gateway say, may keep running after it. OpenRig doesn't stop them, and whether they survive isn't exercised
by OpenRig's tests, so check the harness's own processes after `rig down`. Each preset's limits say what to expect.
**Not supported for community harnesses:** restarting a seat in place (`rig seat restart` is for Codex seats) and
switching harness on a running seat. Each says so before anything changes.
## An example: Cursor
[`community-harness-examples/cursor/`](community-harness-examples/cursor/) has a preset and a small launcher for Cursor's
CLI.
- **The launcher:** it reports the chat `cursor-agent create-chat` makes, then reopens it. Each seat gets its own Cursor
config folder unless `CURSOR_CONFIG_DIR` is already set. Cursor keeps its chats and `cli-config.json` there, and its
login elsewhere.
- **The preset:** it declares `launch_result` and `resume`. Cursor's `--resume` with an id it doesn't hold starts a new,
empty chat without saying so, so on a resume the launcher first checks that the chat's store exists. When it doesn't, the
launcher reports `error: "missing"` and starts nothing.
- **First input at launch:** the preset declares `first_input: true` and `startup_assisted: false`. The launcher passes
the generated startup text as Cursor's positional first prompt. This route and same-chat resume were checked with
Cursor Agent 2026.10.22-27dcf5f. Later tool approvals use Cursor's native prompts; startup submission does not answer them.
- **Sender names come from the sending session.** A command run from an unattached operator shell can arrive as
`<unknown sender>`; that doesn't mean the receiving Cursor seat lost its identity.
Copy both and maintain them as your own.
## An example: Pi's terminal UI
[`community-harness-examples/pi-tui/`](community-harness-examples/pi-tui/) has a preset and a small launcher that run Pi's
own terminal UI as a community harness. `pi` is the built-in managed Pi runtime, so the preset is named `pi-tui`. OpenRig
delivers to this seat as to any community seat (the Delivery row above), not as to a plain `runtime: terminal` seat, which
queue wakes don't reach. Checked once, on Pi 1.4.0 with OpenRig 0.7.4 and one model: a parked row's wake reached Pi in
the same session (the same session file) and Pi resumed the work, and a handoff reached a second `pi-tui` seat, which
picked it up unattended. The first seat's task named the commands to use, so this shows a wake reaching Pi and Pi acting
on it, not Pi choosing by itself to park or hand work on.
- **The launcher:** each seat gets its own Pi agent folder (`$OPENRIG_HOME/state/pi-tui/<node>/agent`, unless
`PI_CODING_AGENT_DIR` is set) and its own session folder. At each launch it copies the installed `openrig-core` skills
it's given (`queue-handoff` by default) into that agent folder. A new launch starts `pi --session-id <the name OpenRig
gave>` and passes the first input as Pi's first prompt, byte for byte.
- **Resume isn't declared.** The launcher can reopen a named session: it opens the one session file whose header has that
id, and starts nothing when there isn't exactly one, because Pi's own `--session-id` creates a session for an id it
doesn't find. Declare `resume: true` only after a real Pi has reopened a named session and refused a missing one.
- **Its limits:** run a `pi-tui` seat unattended, not where a person also types into it.
- With the seat's [typing guard](seat-delivery.md) at its default, a wake submits a person's half-typed draft in Pi's
editor together with the wake. The guard's draft-aware mode isn't a known fix for this.
- A send while a Pi dialog OpenRig doesn't recognize is open, such as `/settings`, types into the dialog.
- Not shown: how Pi takes a wake that arrives while it's busy (a message sent during a busy turn was answered after
it), and other Pi versions.
- **Give the seat a model** before its first start: its provider key in its environment, or its login and settings in its
agent folder.
Copy both and maintain them as your own.
## An example runner: dsh
[`community-harness-examples/dsh/`](community-harness-examples/dsh/) has a runner preset (`runner: true`) and a small runner
for DeepSeek Harness, which speaks ACP on stdio and has no terminal UI of its own. It's written for dsh 0.2.1-rc.2 and
checked only against a stand-in agent.
- **The runner:**
- it opens the conversation and writes the launch record;
- it turns each submitted block into one prompt;
- it reports turn boundaries as activity hooks;
- it cancels on `/abort` or Ctrl-C, as the [runner contract](harness-runner-contract.md) describes.
- **Permissions:** it sends each request to OpenRig, which asks the default person or the seat's override.
- **When no one can be asked, or OpenRig can't answer,** it permits with dsh's own allow option (`allow_once`, else
`allow_always`), which is OpenRig's default. `allow_always` creates a standing grant in dsh.
- **It refuses and holds needs input** only after `--deny`. A person's no when dsh offered no reject option, or a
request OpenRig says isn't the current occupant's, is answered cancelled.
- **Each seat gets its own dsh home,** with dsh's session-log and plugin-list uploads to DeepSeek turned off. Give that
home credentials, or put a provider key in the seat's environment.
- **Resume isn't declared.** The runner reopens a conversation by the method dsh advertises, and reports one missing only
on dsh's own not-found error. Declare `resume: true` only after a real dsh has reopened a conversation and refused a
missing one.
Its [README](community-harness-examples/dsh/README.md) has the setup and a check you can run without a model. Copy both
and maintain them as your own.
The launcher is the contributor's code, in the contributor's package. OpenRig runs no code from a preset and loads no
module into its daemon. A preset that sets `launch_result`, `names_conversation`, `startup_assisted`, `first_input`, `runner` or `passes_model` is refused by an older
OpenRig that doesn't know the field.
## Pass the first startup input at launch
A format-1 preset may set `first_input: true` (a boolean, default false). OpenRig composes the same initial identity turn it would otherwise paste, including the first merged text file, durable-obligation guidance and any selected orientation challenge. Before launch it writes that text to a per-attempt 0600 file under its own `state/first-input/` folder and passes the path as `OPENRIG_FIRST_INPUT`. The typed launch command carries the path, not the text; an argument-based launcher still puts the text in the native harness’s argv.
The contributor-owned launcher reads the file and uses its harness’s supported initial-prompt route: the Cursor example passes one positional prompt; OpenClaw uses `--message`. Core knows neither flag. If no initial identity turn is due (an ordinary resume, for example), the variable is absent and the launcher starts without a first message. Later startup files and actions use their existing delivery paths; they are not folded into the launch file.
OpenRig records `node.startup_first_input_passed` with the path and `submission: unconfirmed`. This says the input was passed at launch, not that the native harness submitted or read it. An uncertain launch is not permission to replay it. Readiness remains process-only. `startup_assisted` is independent: when declared, startup still requires a person’s acknowledgement, including when the first input was passed at launch.
Presets without `first_input` and built-in runtimes keep their existing behavior. Older OpenRig versions reject this new field as unknown. The private input file remains for diagnostics; it can contain private startup context. A launcher that turns its contents into one argument is subject to the platform’s argument-size limit; the launcher must report a launch error rather than silently truncate the turn. The examples’ argument transport is covered with stand-ins; native unattended startup still requires qualification with the installed harness.
## Pass the seat's declared model at launch
A format-1 preset may set `passes_model: true` (a boolean, default false). Then a model declared for the seat (in the rig
spec, or set later with `rig seat set-model`) reaches its launcher as `OPENRIG_MODEL`, on new and resumed launches alike. A seat that declares no model gets none,
and an inherited `OPENRIG_MODEL` is cleared. The launcher maps it into its harness's own setting; Core knows no harness's
model flag or config key.
OpenRig keeps three things apart:
- **requested:** the declared model, as `rig ps --nodes` shows it ("configured", or "not-declared");
- **passed:** the launcher was handed the model. OpenRig records `node.startup_model_passed` with the model and whether the
launch was a resume only when the launch command's send succeeded, and a later failure of the launch doesn't undo that.
It never says passed from the declaration alone or for a launch that failed before anything was typed. When typing
began and the send then failed, the model's dispatch is uncertain: it isn't recorded as passed, and it isn't retyped;
- **effective:** the model that actually runs. OpenRig doesn't observe it for a community harness, so it reads UNKNOWN.
Passing a model never makes OpenRig pick a default, substitute another model, or retry with one. A model the harness
refuses is the harness's own error.
The examples apply the request for a new session only; on a resume, the example launcher doesn't apply it, and which
model a resumed session runs stays unknown unless the harness's own evidence says otherwise:
- **Cursor:** `--model "$OPENRIG_MODEL"` for a new chat. What a resumed chat runs hasn't been checked: the launcher leaving
out `--model` doesn't show that it keeps its earlier model.
- **Hermes:** Hermes takes its model only from the seat's `config.yaml`, so for a new session the launcher writes
`model: "<id>"` there only when that file is absent or empty. A seat that has its own `config.yaml` keeps it
byte-for-byte, and the launcher says the model wasn't applied: set it in that file. A seat set up from
`config.yaml.example` already has a config, so set its model there. Hermes v0.22.4 fixes a session's
launch settings when it creates it (its own source), so a resumed Hermes session runs with what it was created with.
Both example presets ship with `passes_model: false` until that mapping has been checked with the real harness. Presets
without the field and built-in runtimes are unchanged, and older OpenRig versions reject it as unknown.
## Interactive Pi in a terminal seat: the coordination skills
This is for a seat that runs interactive `pi` from a `runtime: terminal` member's startup, or from a community preset's
launcher. It isn't for a managed `runtime: pi` seat, which already copies its selected skill directories into the seat's own
Pi agent directory (`$OPENRIG_HOME/state/pi/<session>/agent/skills/`), or for Workshop, whose Pi profiles carry pinned skill
copies (openrig-world's `PI-SKILLS.md`). The [`pi-tui` example](#an-example-pis-terminal-ui) above does step 1 for its
seats in its launcher.
OpenRig installs no skills for a terminal or community seat: it skips their resources and startup files, and `rig skill
loadout` covers Claude Code and Codex only. Without the coordination skills, the agent there doesn't know the queue's habits,
such as parking a row that waits on CI instead of leaving it in progress, or handing work on so the next row names its
source. The seat's setup does two things.
1. **Make the released skills readable.** The installed OpenRig's own plugin carries them, matching the installed release:
`rig plugin list` shows `openrig-core` and its path (usually `$OPENRIG_HOME/plugins/openrig-core`), and the skills are in
its `skills/` folder. Copy whole skill directories, keeping their helper files and modes, into the `skills/` folder of the
Pi agent directory that seat uses (`PI_CODING_AGENT_DIR`, or `~/.pi/agent` by default). At least `queue-handoff`:
```bash
CORE="$(rig plugin list | awk '$1 == "openrig-core" {print $NF}')"
PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
mkdir -p "$PI_AGENT_DIR/skills"
cp -Rp "$CORE/skills/queue-handoff" "$PI_AGENT_DIR/skills/"
```
Copy again after each OpenRig upgrade: a copy doesn't follow the plugin.
2. **Point the agent at it when it starts.** Don't count on Pi finding the skill by itself; name it in the seat's first turn.
For example:
> Before you work your queue, read `<Pi agent directory>/skills/queue-handoff/SKILL.md` in full: it's how this team hands
> work on, parks it and waits. When you wait on CI or a long job, park the row with `rig queue block <id> --on
> external:<what> --wake-after <duration> --continuation "<what resumes>"`.
- A community preset with `first_input: true` carries it in the seat's first input when the file is declared with
`delivery_hint: send_text` (see "Pass the first startup input at launch" above). A `.md` file left at the `auto` hint
counts as guidance, which a community seat doesn't take.
- A plain terminal seat can pass it as Pi's initial message in the command its startup runs. Check `pi --help` for that
argument in your Pi version.
- `first_input` carries text, not files: it doesn't install the skills.
**In a plain terminal seat, a queue wake doesn't reach Pi.** OpenRig doesn't treat a `runtime: terminal` seat as an agent
seat. When a parked row's wake fires, OpenRig doesn't type it into Pi, and a handoff to that seat records a failed wake
(`failed:terminal-seat`). Parking still records the wait, but `--wake-after` won't bring Pi back. Check the seat's parked
rows yourself (`rig queue whoami --session <seat>`, or `rig queue show <id>`), or tell Pi to continue the named row.
**What this shows, and what it doesn't.** A copied folder and a startup line put the text in front of the agent. They don't
show that Pi discovered the skill by itself, or that the agent follows it. Check those separately: that the agent read the
whole file (its tool trace), and what its rows record, such as a CI wait parked on `external:` with a wake, and a handoff
made with `handoff-and-complete`, whose next row names it as its source.
## Write your own
Start from the example closest to your harness, and change it. First check that the harness answers a prompt on its own
([above](#what-a-community-harness-gets)).
1. **Choose how the conversation is named.**
- **Your harness creates an id you can read before it starts,** as Cursor's `create-chat` does: declare `launch_result: true`, and the
launcher writes the record.
- **Your harness takes a name from its caller and creates or reopens it,** as Hermes's `-c <name>` and OpenClaw's session key do: declare
`names_conversation: true`, and the launcher passes `OPENRIG_CONVERSATION` on.
- **Neither:** leave both out. The seat starts and works, its identity shows as unavailable, and it can't be resumed.
2. **Write the launcher:** a small script your package installs on the seat's `PATH`. It reads OpenRig's variables, starts the harness with
`exec`, and does nothing else.
- `OPENRIG_CONVERSATION` with `names_conversation`, or `OPENRIG_LAUNCH_RESULT` and `OPENRIG_LAUNCH_ATTEMPT` with `launch_result`.
- **On a resume,** `OPENRIG_RESUME_REF`: reopen exactly that conversation. Check that it exists first, and when it doesn't, end with an
error (or report `error: "missing"`) instead of creating one.
- **With `first_input: true`,** `OPENRIG_FIRST_INPUT`: pass the file's exact bytes through your harness's own first-prompt route.
- **Keep each seat's harness state apart** (Cursor's config folder, Hermes's home, OpenClaw's state folder), so two seats never share a
conversation.
3. **Write the preset** at `$OPENRIG_HOME/community-harnesses/<name>.yaml`, and check it with `rig community-harness list`.
- Declare `resume` only after checking, with the real harness, that a reopen works and that a missing conversation is refused.
- List what doesn't work under `limits`.
4. **Try it in a rig:** give a member `runtime: <name>`, then go through the checks in the promotion test below.
5. **Publish the preset and the launcher in your own package,** and maintain them. OpenRig runs no code from a preset, so the launcher is
yours.
## The promotion test
A community harness stays a community harness: you maintain it, and OpenRig shows it as one. **Promotion** means OpenRig takes a harness on
as a built-in runtime that it maintains and tests itself. **Promotion is decided by the OpenRig maintainers, on evidence from OpenRig's own
testing.** Passing these checks isn't automatic promotion, and it isn't a promise of upstream support.
What the maintainers look at:
1. **Everything the preset declares works on a real install,** end to end through OpenRig:
- a seat starts and takes its first turn (`rig up`);
- a message arrives (`rig send`), and the reply can be read (`rig capture`);
- the seat stops (`rig down`) without leaving work running that the person can't see, or the preset's `limits` say what keeps running;
- with `resume`, `rig up <rig> --existing` reopens the same conversation, and a missing one is refused, never replaced.
2. **What it doesn't support reads as not known,** never as idle, working, ready or verified.
3. **Its `limits` are true,** and name anything a person would otherwise be surprised by.
4. **The built-in runtimes are unchanged.**
5. **It's maintained:** a named maintainer, and a launcher that keeps working with the harness's current release.
There's no score or threshold. The maintainers weigh the evidence.
-210
View File
@@ -1,210 +0,0 @@
# Writing a daemon client
What a program that talks to the OpenRig daemon over HTTP needs and can't read from `rig --help`: how to find the
daemon, its health route, which bearer token each route family checks, the event streams, and some route
details. It describes the daemon as it is. It adds no promise beyond what each section says, and route shapes not
listed here can change.
## Finding the daemon
`rig status --json` is the discovery surface. Its `daemon` object gives `state` and `reachable`, and `pid` and
`port` when they are known: a stopped daemon's response has no `port`, and a running daemon found through
`OPENRIG_URL` has no `pid`. `reachable` is `true` when the daemon answered its health probe, `false` when it is
positively down, and `null` when that isn't confirmed (see `rig status` in
[the command reference](../as-built/cli-reference.md#status)). It reads the recorded state and probes; it never
starts a daemon.
The CLI itself finds the daemon in these orders:
- **Data commands** (everything that sends a request): `OPENRIG_URL` (or the legacy `RIGGED_URL`); else
`$OPENRIG_HOME/daemon.json` when it holds a valid `pid` and `port`; else the config keys `daemon.host` and
`daemon.port` (environment `OPENRIG_HOST` and `OPENRIG_PORT`), which default to `127.0.0.1:7433`.
- **Status** (`rig status`, `rig daemon status`): `OPENRIG_URL` (or `RIGGED_URL`) first, probed directly; else
the state file below.
- **Start and stop** (`rig daemon start`, `stop`): the state file, `$OPENRIG_HOME/daemon.json`, then the legacy
`~/.rigged/daemon.json`.
`daemon.json` holds `pid`, `port`, `host`, `db` and `startedAt`. `rig daemon start` writes a new daemon's record
only after its health check passes, so while a start is pending, the file holds no record of the new daemon: it
may be missing, or hold an earlier daemon's stale record. It is the CLI's own state file, not a published
contract: prefer `rig status --json`.
## `GET /healthz`
The liveness and identity read. It always returns `status: "ok"` and `pid`. Every other field is present only
in the case named:
| Field | When it is present |
| --- | --- |
| `euid` | On platforms with effective user ids. A self-report for the CLI's owner check, not authentication. |
| `databaseWarnings` | When the daemon opened its database with warnings. |
| `semver`, `commit`, `dirty`, `builtAt` | On a stamped (released) build; a development run has none. |
| `selfHostId`, `selfHostIdSource` | After the boot reconcile has set this host's id. |
| `eventLoop` | When the event-loop monitor runs: `lagMeanMs`, `lagP99Ms`, `utilization` (0 to 1), `lastTickAgeMs` and `healthy`. |
| `routeTimings` | With `eventLoop`. |
| `slowOperations` | When the slow-operation recorder runs. |
| `bind` | The bind plan: `mode` (`explicit` or `default`), `hosts` (the addresses bound), `tailscaleDetected`, and `ignoredRoutingHost` when a routing value was ignored. |
| `stuckSweep`, `wakeLadder` | The heartbeats of the stuck sweep and the wake ladder, when they run. |
## Bearer tokens
The daemon reads two tokens from its environment at start:
- **`OPENRIG_AUTH_BEARER_TOKEN`** checks Mission Control's write routes (`POST /api/mission-control/action` and
`/notifications/test`), the `/api/hosts` writes (`add`, `update`, `remove`, `pair`), and the `/api/rig-mode`
binding writes. `rig host pair` hands this token to the paired machine, so a daemon that will be paired with
needs it set (see `rig host pair` in [the command reference](../as-built/cli-reference.md)).
- **`OPENRIG_TERMINAL_BEARER_TOKEN`** checks every `/api/transport` and `/api/compaction` route, `POST
/api/terminal/follow` and `/viewer/ensure`, and every `GET /api/terminal/<name>` route, the terminal websocket
included. When the daemon is bound to an explicit address (`rig daemon start --host`) that isn't loopback or a
tailnet address, and this token isn't set, these routes check `OPENRIG_AUTH_BEARER_TOKEN` instead.
A route whose token isn't set lets every request through. Send a token as `Authorization: Bearer <token>`; the
`GET /api/terminal/<name>` routes, the websocket included, also take `?token=<token>`. The CLI sends the terminal token from `OPENRIG_TERMINAL_BEARER_TOKEN`,
or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; the daemon doesn't create it.
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
[Browser access](browser-access.md).
## Event streams
All of them are server-sent events (`text/event-stream`):
- **`GET /api/events`**: the daemon's event log, stored events only. `?rigId=` limits it to one rig. Each event
carries `id: <seq>`. `view.changed` is not stored, so it does not appear here, live or replayed; `view.changed`
rows stored by older versions still replay until an events retention (a separate change) removes them.
Without `Last-Event-ID`, `?replay=<n>` first sends the newest `n` stored events (`0`: none, live events only);
`?replay=all`, no `replay`, or any other value sends the whole log first. A `Last-Event-ID` header (what
`EventSource` sends when it reconnects) sends the events after that id instead, whatever `replay` says.
Live events that arrive during the replay follow it, without duplicates.
- **`GET /api/activity/events`**: live only, no replay or ids. Each message is named after its event type:
`seat.activity_changed`, `seat.rung_health`, `proof.judged` or `proof.sources_changed`. It tells a view to read
again; it carries no derived state.
- **`GET /api/views/sse`** (alias `/api/views/watch`) and **`GET /api/views/<viewName>/sse`**: `view.changed`, live
only. Its `id:` is the `seq` of the stored event that caused it, so it can repeat across views and is not a replay
cursor.
- **`GET /api/queue/sse`** (alias `/api/queue/watch`): live only, with `id: <seq>`, for `queue.created`,
`queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue`,
`inbox.absorbed` and `inbox.denied`.
## People, asks and updates without Slack
`GET /api/gateway/participants` returns the same JSON projection as `rig gateway participants --json`:
`{ ok, participants, default, organizations, groups, warnings, participantFile }`. Pick a person by `kind: "person"`
and use their `id` for a Feed `view`, or their `address` for the queue's `destinationSession`. The effective
`default` is `{ id, source }`, or `{ id: null, reason }`; it does not establish who is using the client.
Participant records omit connector credential pointers. Check `warnings` and `participantFile.error` for a partial
registry read; an unreadable human registry returns HTTP 503. See [Gateway participants](gateway-participants.md).
| Read | Meaning |
| --- | --- |
| `GET /api/queue/list?destinationSession=<address>&state=pending,in-progress,blocked&limit=100` | Open rows for exactly that address. `humanIntent: "update"` identifies an FYI; other values are asks. Each row has `qitemId`, `state`, `summary` and `body`. |
| `GET /api/queue/open-human-updates?view=<person-id>&limit=20` | Open human FYIs, even when no Slack delivery receipt exists. Returns `{ items, limit, truncated, perspective? }`. |
| `GET /api/queue/<qitemId>` and `/<qitemId>/transitions` | The current row and its history, including closed state and recorded outcome. Closing or acknowledging a row is different from delivering it. |
| `GET /api/queue/human-updates?view=<person-id>&limit=20` | Existing delivered-update history: explicit FYIs with a recorded Slack posting receipt, including closed rows. Items add `deliveredAt` and `deliveryReceipt`. It is not the open-update inbox. |
Both update lists accept integer `limit` values from 1 to 100 (default 20); invalid values return HTTP 400.
Open FYIs are ordered by priority, then newest first. `view=all` (also the default) shows all perspectives;
`view=default` selects the effective default person, or all when no default is available. A named view that cannot
resolve a person has `perspective.basis: "unknown"` and no items when a registry is available. With no registered
people there is no perspective filter: inspect `perspective` rather than assuming the requested view was applied.
A person's view also includes instance or unassigned items when that person is the default. These are views on a
trusted daemon, not per-person authentication or access control; existing daemon and browser access rules still apply.
Always display `truncated: true` as incomplete, even if fewer than `limit` items arrive. A person-view scan examines
at most 1,001 open updates (101 for delivered history) before filtering; older matching items may be outside that
window. These endpoints have no pagination cursor. Reading an open update never posts it to Slack, acknowledges it,
or manufactures a delivery receipt.
For example, this Node client takes a daemon URL, a registered person id and a rig name. It uses no Slack API and
makes only reads; use the daemon discovery and access rules above. In an ES module, `process.argv.slice(1)` is the
argument list for `node --input-type=module -e '<code>' <url> <person-id> <rig>`:
```js
// client-people-example
const [base, personId, rig] = process.argv.slice(1);
async function get(path) {
const response = await fetch(new URL(path, base));
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
const registry = await get("/api/gateway/participants");
const person = registry.participants.find(p => p.kind === "person" && p.id === personId);
if (!person) throw new Error("Choose a registered person id");
const rows = await get(`/api/queue/list?destinationSession=${encodeURIComponent(person.address)}&state=pending,in-progress,blocked&limit=100`);
const updates = await get(`/api/queue/open-human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const receipts = await get(`/api/queue/human-updates?view=${encodeURIComponent(person.id)}&limit=20`);
const rigHistory = await get(`/api/queue/list?rig=${encodeURIComponent(rig)}&limit=100`);
const messages = await get(`/api/queue/outbox/list?rig=${encodeURIComponent(rig)}&limit=100`);
console.log(JSON.stringify({ participants: registry.participants, default: registry.default,
warnings: registry.warnings, participantFile: registry.participantFile,
asks: rows.filter(row => row.humanIntent !== "update"), updates, receipts, rigHistory, messages }, null, 2));
```
The queue and outbox lists are bounded snapshots, not subscriptions. A full queue list may need a larger `limit`.
An outbox entry's `deliveryState` reports the send attempt; only `delivered` confirms delivery. A queue transition,
an open FYI, or a closed ask is not that receipt. The rig-wide reads and their limits are described below; the existing
`outbox/list?senderSession=<session>` read remains available.
## Route details
- **A rig's queue history:** `GET /api/queue/list?rig=<rig>&updatedSince=<ISO time>&sort=updated` returns the
rig's queue items (any whose source or destination seat is in the rig) updated at or after that time: open items
(`pending`, `in-progress`, `blocked`) first, then the rest by latest update. `limit` defaults to 100 and has no
upper cap. The list is cut after `limit` rows in that order, so when it comes back full, raise `limit` to read
them all. Each item's own history is `GET /api/queue/<qitemId>/transitions`. This read scans the queue table, so
read it occasionally rather than polling it. For live changes, treat `GET /api/queue/sse` events as hints to read
`queue/list?rig=` again. Not every event names both seats (`queue.claimed` and `queue.unclaimed` carry only the
destination), and not every state change is on that stream, so the events alone don't track a rig's items.
- **A rig's messages:** `GET /api/queue/outbox/list?rig=<rig>` returns the messages sent from or to the rig's
seats, newest first; `?senderSession=<session>` still returns one sender's. One of the two is required; with
both, `senderSession` is used and `rig` is ignored. `limit` defaults to 100. No event is sent when a message is delivered, so read this list to see delivery
states.
- **`GET /api/queue/recent-transitions`** returns at most 20 rows. A positive `limit` above 20 is capped at 20;
a missing, zero, negative or unparseable `limit` means 20.
- **`POST /api/transport/send`** takes `urgent: true`, the same as `rig send --urgent`. The daemon records who
marked it from the sender identity, not from the body.
- **`POST /api/scope/approve`** is what `rig scope slice approve` and `rig scope mission approve` call. The body
takes `scopeTier` (`slice` or `mission`), `scopePath`, and optionally `approvalScope` (`spec` or `delivery`, the
default), `missionsRoot`, `onBehalfOf`, `reApprove`, `reason` and `lockedArtifacts`. The approver is the
request's sender identity (`actorSession` in the body is recorded beside it, and a different value is
superseded). Success is 201 with `scopeTier`, `scopeId`, `scopePath`, `approvalScope`, `approvedBy`,
`approvedAt`, `onBehalfOf`, `actionId`, `freezeFired` and `reApproved`. Refusals include 400 for an invalid
`scopeTier`, `scopePath` or `approvalScope`, or a missing actor (`actor_required`); 503 `action_log_unavailable`;
and, from the approval itself, 404 `scope_not_found`, 409 `already_approved` or `nothing_to_reapprove`, 503
`workspace_not_configured`, 500 `audit_write_failed`, and 400 for its other refusals. An unexpected error is 500
`internal`.
- **`POST /api/proof/judge`** is what `rig proof judge` calls. The body takes `scope`, `item`, `verdict`
(`accept`, `reject` or `withdraw`), `reason`, `expectedRevision` and `expectedPrevious` (a judgment id or
`null`), and optionally `evidence`, `expectedEvidence`, `subject`, `operationId`, `replace`, `project` and
`projectRoot`. The judge is the request's sender identity. The response is `judgment`, the slice's `readiness`,
`replayed` and `notification`: 201 for a new judgment, 200 when an `operationId` replays an earlier one.
- **`POST /api/workflow/instantiate`** requires `specPath`, `rootObjective` and `createdBySession`, and takes
`entryOwnerSession`, `targetRig` and `scope` (see [Project workspace](project-workspace.md) for `scope`).
Success is 201 with `instance`, `spec`, `entryQitemId` and `entryOwnerSession`, any `advisories`, and
`scopeIgnored` when a scope wasn't applied. The instance keeps `rootObjective`, which every instance read returns
(`/api/workflow/list`, `/api/workflow/<id>` and its `trace`). An instance started before it was kept reads `null`.
- **`GET /api/provider/usage`** returns `{ hostUsage }`, this host's usage rows (`host: "local"`, a `provider`
and a `state`). A Claude row's `windows` are the seats' status-line readings, one entry per reading, each with
its `window`, `usedPercent`, `resetsAt`, `asOf` and `seatSession`. A Codex row has `windows: []`. Treating the
readings as one account rests on the deployment assumption the row's `provenance` names (one account per
host), not on a measured identity. Readings whose reset times disagree are reported as `anomalies`, and the
row's `state` can then be `explicit_unknown`. Show the row's `state`, `anomalies` and each reading's seat and
`asOf` as reported, rather than merging readings that happen to match.
- **Slack channels and people:** `GET /api/gateway/slack/channels` and `GET /api/gateway/slack/users` each return
one page from Slack, read with the daemon's configured bot token, which never leaves the daemon. Channels are
public and unarchived, as `{ id, name, isMember }`; people are `{ id, name, realName, displayName, isBot,
deleted }`, with no email. Pass `cursor` (the previous page's `nextCursor`, `null` on the last page) and
`limit` (default 100, at most 200). Inbound delivery needn't be enabled. Failures: 503 `slack_not_configured`
when no bot token resolves, which is different from an empty list; 403 `slack_missing_scope` with the scope
Slack names in `needed` (an older app may lack `users:read`); 429 `slack_rate_limited` with Slack's
`Retry-After`; otherwise 502. Each request is a live Slack call, so page through a list when someone opens a
chooser rather than polling it. Private channels and direct messages aren't listed.
- **Slices are found by folder name alone.** `GET /api/slices/:name`, `/:name/doc/*`, `/:name/proof-asset/*` and
`GET /api/review/slice/:name` take only the name; the detail route reads `?mission` only together with
`?project`. When two missions have a slice folder of the same name, the daemon keeps the first one it finds:
that one is listed and served, and the other doesn't appear in `GET /api/slices` at all. Keep slice names unique
across missions, as [Project workspace](project-workspace.md) says.
-54
View File
@@ -1,54 +0,0 @@
# Developing OpenRig — gates and lanes
This is the contributor-facing statement of which checks BLOCK a change and which are
advisory. Every pull request to `main` runs `.github/workflows/tests.yml` on a clean GitHub
runner: build and packaging, typecheck, the repository scripts (`npm run test:repo`), each
workspace's test suite (daemon, cli, tui and ui) on macOS without ambient credentials or
external network, and an installed-package scenario (`scripts/run-pr-scenarios.sh`). The root
`package.json` scripts below run the same build, typecheck, repository and test checks locally,
with one difference: `npm test` does not include the UI suite, which CI runs as
`package-tests (ui)`. Run `npm run test:ui` for it.
**In a fresh clone or worktree, run `npm ci`, then `npm run build`,** as CI's build-and-package and
package-test jobs do (typecheck and repo-checks build only the daemon, through their scripts; see
`CONTRIBUTING.md` and [worktree builds](worktree-builds.md)).
Locally, `npm test` builds only the daemon, and `test:workspaces` and `test:ui` build nothing.
## Blocking gates (must pass before a candidate moves)
| Gate | Command | Covers |
|---|---|---|
| Typecheck | `npm run lint` | daemon + **ui** + cli + tui tsconfigs — UI typecheck STAYS blocking |
| Build | `npm run build` | all workspaces — the UI dist ships in the package, so its build STAYS blocking |
| Repo scripts | `npm run test:repo` | daemon build, script self-tests, first-run reference check, docs guard, skill mirror check, context-pack generation check |
| Unit tests | `npm run test:workspaces` | `packages/daemon` + `packages/cli` + `packages/tui` |
| UI unit tests | `npm run test:ui` | `packages/ui` vitest. NOT part of `npm test`, but every pull request runs it as `package-tests (ui)` (the `package-tests` matrix in `.github/workflows/tests.yml`) |
| Installed-package scenario | CI only: `scripts/run-pr-scenarios.sh` | the installed package, a daemon restart and a seeded durability failure. The script refuses to run outside GitHub Actions unless given `--remote` with `DOCKER_HOST=ssh://…` |
| Package file lists | CI only | records each package's published file list (`npm pack --dry-run`) |
`npm test` runs `test:repo` and `test:workspaces`; `npm run test:ui` is the separate UI
suite. Both sets are readable in the root `package.json` scripts.
## Advisory lane
| Lane | Command | Meaning |
|---|---|---|
| Portability report | `node scripts/portability-report.mjs` | lists added lines with machine-specific values; findings never fail it (`.github/workflows/portability-report.yml`) |
## The norm (web-UI freeze at 0.5.0)
Daemon API changes no longer require browser or interaction verification of the UI, and
the contract mirrors under `packages/ui/src/hooks/` are no longer proactively maintained. A
new `test:ui` failure usually signals a moved API contract. Because pull-request CI runs the
UI suite as `package-tests (ui)`, the change that moves the contract has to update the affected
UI test or mirror to pass that check.
Browser/interaction testing of the web UI is not a contributor gate. (The packaged
starter-rig agent skills that exercise the UI are product content for user rigs, not
part of this repo's gates.)
## Wording rule
The web UI is **experimental**, in **maintenance mode**, supported **best-effort**;
**the CLI is primary**. There is no scheduled removal and PRs are welcome. Do not
describe the UI with stronger end-of-life language than this section uses.
+16 -53
View File
@@ -1,8 +1,8 @@
# Edge Types Reference
Historical whole-document validation: 2026-10-07, at the 0.6.6 cut `2620dea8` (whole document at `fcaf1f8e`).
The fallback escalation section and deferred-target exceptions below describe #605.
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`, `packages/daemon/src/domain/queue-owner.ts`, `packages/daemon/src/domain/escalation-owner.ts`
Version: 0.2.0
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`
---
@@ -17,83 +17,46 @@ See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by both the pod-aware and legacy flat-node validators:
Five edge kinds are accepted by the validator:
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
| `delegates_to` | yes | **yes — launch order and queue escalation** | Source delegates work to target |
| `spawned_by` | yes | **yes — affects launch order** | Source was spawned by target |
| `delegates_to` | yes | **yes — affects launch order** | Source delegates work to target |
| `spawned_by` | yes | **yes — affects launch order** | Target was spawned by source |
| `can_observe` | yes | no | Source can observe target's output |
| `collaborates_with` | yes | no | Peer collaboration |
| `escalates_to` | yes | **yes — fallback queue escalation** | Source escalates to target |
| `escalates_to` | yes | no | Source escalates to target |
## What Edges Actually Do Today
## What Edges Actually Do Today (OpenRig 0.1.x)
### Launch ordering
Only `delegates_to` and `spawned_by` constrain the order in which nodes are launched:
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails with `cycle_error` before the rig is created. A `delegates_to` or `spawned_by` edge from a member to itself counts as a cycle. Restore uses the same sort but has no cycle check of its own.
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails.
All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT constrain launch order.
### Queue escalation
`delegates_to` also names a seat's orchestrator for the queue: the source of the `delegates_to` edge into a seat. The
wake ladder escalates an unacknowledged row to that orchestrator, and stuck-row findings and recovery go to it. A seat
with no incoming `delegates_to` edge has no default orchestrator. `spawned_by` plays no part here.
### Fallback escalation across rigs
A seat with **no declared `delegates_to` parent** may declare one `escalates_to` destination. The wake ladder and
stuck sweep use it for new recovery rows; they do not move the original obligation or reassign an already-open recovery. A stopped or unbound declared
parent still takes precedence, preserving the existing parent route and its fallback.
```yaml
edges:
- kind: escalates_to
from: work.lead
to: review-advisor@coord
```
The target is a canonical same-daemon seat address (`pod-member@rig`), or an existing local member using the usual
edge syntax. Named targets are stored without requiring the other rig to exist and resolved when needed. A matching
physical session name takes precedence over a derived canonical address. Deferred declarations survive export and
snapshots; they don't constrain launch order or make the rigs depend on each other's lifecycle.
Delivery is one hop. Recovery rows carry `escalation-route:one-hop`, so a destination's own `escalates_to` declaration
does not forward the recovery again. A missing or stopped destination, multiple matches or declarations, self route,
or declared cycle preserves the existing fallback: the wake ladder proceeds to its operator rung; the sweep retains
its existing owner fallback. The recovery body/transition explains the result. Cycle inspection includes stopped
seats and delegation parents; an unresolved downstream endpoint is reported as unknown, never as proof of no cycle.
Queue address admission is unchanged. Cross-host and synthetic destinations are not supported.
On an existing rig, use `rig edge add <rig> work.lead review-advisor@coord --kind escalates_to`; `--plan` writes nothing.
The receipt's target node ID is null for a deferred address. Remove it with the exact edge ID returned by add.
A rig-local graph omits deferred targets, since those seats are outside that graph; the raw topology and export retain them.
### Graph visualization
Local-node edges are rendered in the UI topology graph; deferred cross-rig targets are omitted. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
Local-node edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) doesn't use edges. It names the first
node, in creation order, whose session is running, or else the first node with a session name.
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) uses edges to prefer the orchestrator as the default attach target. The heuristic looks for the first node with `delegates_to` outgoing edges.
## What Edges Do NOT Do Today
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges (queue escalation, above, is the one
daemon path that follows `delegates_to` or fallback `escalates_to`)
- **Route messages** — `rig send` can target any session, regardless of edges
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
@@ -157,7 +120,7 @@ If none fit, `can_observe` is the safest default — it documents the relationsh
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both endpoints must exist in the same pod, except a named `escalates_to` target as above.
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members, except a named `escalates_to` target as above.
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
@@ -1,138 +0,0 @@
# Execution and platform integration contract — draft
This chapter describes the placement and resource responsibilities of an integration author. The harness/runner chapter owns native protocol, model input, activity, attention, permission exchange and turn cancellation. This chapter owns where that harness runs, which files and credentials it can reach, how native state survives an environment change, and how results and resources return to their owners.
**Status:** a design contract for authors, with an existing-compatible composition and one measured private implementation. Since 0.7.8, OpenRig also ships one maintained reference driver, OpenShell 0.1.6, as `rig environment` (see the [OpenShell example](community-harness-examples/openshell/README.md)). The measured baseline used OpenRig 0.7.4, OpenShell 0.1.6 and Claude Code 2.1.310. 0.7.6 published this draft; neither it nor the reference driver implements a generic environment driver, a `DeploySpec` loader or a cross-host native-resume guarantee. Proposed operations below are not commands or accepted configuration keys. Capability support must be stated for the actual integration and version.
## Keep the identities and responsibilities separate
RigSpec composes members, their AgentSpecs/profiles, runtimes, working directories and relationships. AgentSpec supplies reusable role, startup and context declarations. A built-in runtime or registered community preset supplies a launch path. An environment integration supplies placement and provider-specific resource handling around that path. Existing declarations do not automatically provision a remote filesystem, credentials or compute.
| Identity or handle | Meaning and responsibility |
|---|---|
| Stable seat and work identity | The member's canonical address/node and owned work records. OpenRig retains this identity across executions; an environment IP or display name does not replace it. |
| Native session identity | The harness's conversation token/UUID, qualified by harness and resume-reference type. The harness owns its format and resume semantics. |
| Environment binding | The integration's durable record linking placement, immutable provider resource IDs, native-state location, projection and ownership. It is separate from the native session token. |
| Execution generation | A particular launch/process instance, with observable process/start identity when available. A new process can resume an old conversation. |
| Viewer/client | A terminal attachment or connection to an execution. Closing it need not stop either the native process or compute. |
An application service bundled with a rig is a separate responsibility. An artifact browser or database in `RigSpec.services` serves the team's application; its presence does not place an agent inside that service. Bundling service definitions also does not bundle image bytes, native binaries, authenticated homes or live runtime state.
## A minimal composition using existing surfaces
The following is a **fragment of one pod** (`pods[].members`), using existing RigSpec fields, not a complete standalone RigSpec:
```yaml
pods:
- id: dev
label: Development
members:
- id: coordinator
agent_ref: local:agents/coordinator
profile: default
runtime: claude-code
- id: worker
agent_ref: local:agents/worker
profile: default
runtime: example-contained-claude
```
The worker runtime names an author-installed community preset. This preset shape uses existing fields exercised on the 0.7.4 baseline:
```yaml
format: 1
name: example-contained-claude
maintainer: Integration author
command: ./scripts/launch-contained-worker
launch_result: true
resume: true
startup_assisted: true
limits:
- Requires an explicitly prepared environment binding and guest projection.
- Native resume is qualified only for the documented retained-state recipe.
- Viewer closure does not implement turn cancellation or resource deletion.
```
This is an integration skeleton, not an installed or qualified preset. The author must supply the executable, resolve its path against the actual launch working directory, register the preset in the existing OpenRig home, and prepare the member's host working directory. The launcher implements the preset's declared launch-result and typed-resume behavior; the runner chapter defines those mechanics. `startup_assisted` requires a startup-submission check; it does not hold back submission or establish successful context delivery or ingestion. A member's model setting is not automatically honored by a custom launcher: its resolved native model/effort must be explicit in that launcher's supported configuration. With `passes_model: true`, Core passes the declared model through `OPENRIG_MODEL`; the launcher must map and apply it. The effective model remains unknown to Core.
For a contained worker, the launcher may resolve a private binding, create or attach to the selected resource, project explicit inputs, then connect to the native process inside it. In the measured recipe, OpenRig ran on the host, the plain coordinator used the built-in native runtime, and the worker used a private community launcher. The wrapper performed projection, provider-reference delivery, resume validation and artifact export explicitly. Those effects were not supplied by the preset declarations themselves. The integration must not assume host guidance/skills, hook paths or environment variables automatically reach a community runtime's guest.
A downstream author can implement this composition today around a qualified provider API and native harness. It remains an author-maintained integration until maintained platform support is separately implemented and documented. Validate against the target OpenRig schema and effective launch configuration; a parsed field or successful process start alone does not establish its effect.
## Placement and launch receipts
Before mutation, resolve the selected host/provider, workspace, image/native versions, reachability, credential references, resource request and ownership. Record existing resources and the selected resource scope. Host headroom includes supervisors, gateway and other team members; a workload memory request is not proof of their combined cap. Report requested, admitted and observed resources separately. Do not infer enforced CPU/memory or isolation policy from accepted flags.
Persist a launch intent before a create operation. Use an attempt/retry identity where the provider supports it; otherwise reconcile the original operation before another create. A public binding receipt carries only nonsecret values. If the harness's native resume reference works like a bearer credential (anyone holding it can reopen the conversation), keep it in secret-held receipt data, never in the public receipt. A useful public receipt identifies:
- canonical seat, harness version and native resume-reference type;
- provider/host/workspace, immutable environment ID and execution generation;
- image/policy versions, requested resources and observed readiness;
- inner working directory, private native-state location and projection manifest;
- a public-safe native reference (never a bearer-like resume token), creator or attached-consumer disposition, and exact owned resource handles;
- lifecycle operations supported, completion observations, pending effects and errors.
A resource name or IP can be reused. Reconcile immutable ID, workspace and any available generation/fingerprint before acting. A lost response is **unconfirmed**, not success or absence. Record provider failures without exposing credentials. Readiness should identify what is ready: provider reachable, filesystem projected, native process started, team identity authenticated, or task completed.
## Project files and credential references deliberately
Stage only declared inputs: role/startup material, selected skills, current work context, schemas and required native settings. Preserve unmanaged files and report conflicts. Review every path-bearing setting for its destination: host-only hook scripts, interpreters, status collectors and loopback URLs may not exist or be reachable in the guest. Missing activity or permission transport must remain unsupported rather than being inferred from the base harness name.
Distinguish three observations: host staging, destination delivery and native ingestion. A destination manifest can record relative path, byte count and hash; a useful task using the current context revision/nonce can show consumption. Neither observation substitutes for the other. Current working input may change between tasks while an earlier artifact stays immutable.
Retained native history is a different data class from ordinary project context. Preserve its exact bytes through the harness's supported resume path; do not edit records or synthesize a conversation. Preserve a complete checkpoint prefix with byte length/hash if the harness may append after the checkpoint. Record which native-state files are required and which remain unavailable. Never substitute a fresh conversation seeded with a report while calling it native resume.
Keep secret values out of RigSpec, preset YAML, bindings, argv and public receipts. The provider/credential owner supplies scoped references or native-supported credential helpers and controls revocation. A successful login, provider registration or model-list request is not evidence of usable inference credit or an authorized spend envelope. Do not infer subscription portability from a provider name or copy an authenticated owner home into a guest.
Authenticate guest coordination through the actual granted member route. A shared source IP is not a seat identity. Record the grant-bound principal and scope separately from resource placement; distinct principals do not imply per-seat row ACLs or isolation between co-located secrets.
## Own only the effects assigned to the integration
Record **created** versus **attached** for each launch receipt. The creator may stop/delete the exact resource it created under the selected policy. An attached consumer normally closes only its own client and any separately identified process it owns; it must preserve the external environment, native session, workspace and shared services. Attachment must not silently transfer provider custody. An explicit owner-authorized transfer is a separate recorded action.
Describe these effects independently:
| Operation | Required distinction |
|---|---|
| Cancel a turn | Ask the runner to stop current work and observe its result. Unsupported cancellation must not be replaced silently by killing compute. |
| Close a viewer | Release that client/connection; report whether native execution continues or is unknown. |
| Stop native execution | End the identified native process and observe its exit. This is separate from task success. |
| Stop compute | Release the documented provider allocation; identify retained storage and actual provider/process state. |
| Delete an owned environment | Remove the exact creator-owned resource and reconcile final absence, including provider-managed dependents. |
Use graceful shutdown first where supported, then only explicitly owned, identified handles for stronger termination. Never equate pane disappearance with process or resource release. Persistent/non-persistent lifetime describes when execution should stop; durability describes recovery. An explicit non-persistent completion condition must name its invocation/work scope. An unrelated queue closure or an empty queue is not permission to terminate a member, and no new lifecycle YAML syntax is specified here.
## Resume exactly, or return an honest disposition
A resume request identifies the seat, harness-qualified native reference, prior binding, expected environment boundary and retained state. Validate those before launching the native harness. Reject mismatched reference types, missing/changed history, wrong immutable resources, unavailable required files and duplicate active executions. Never use “latest” as a replacement for an exact requested conversation or silently start fresh on failure.
Separate the outcomes: same native conversation resumed; new conversation rebuilt from artifacts; fresh launch; unsupported; refused; or unconfirmed. Environment continuity has its own outcome: same retained environment, recreated compute with copied state, different host, or unknown. A matching UUID alone does not show that native history was consumed.
If a provider stops exposing file transfer after canonical-process exit, capture artifacts/history and publish any retained resume descriptor **while transfer is still available**. Verify destination bytes before shutdown. After completion, use host/provider observations instead of assuming guest exec still works. For recreated compute, explicitly transfer the retained checkpoint and required artifact into the successor environment and validate them before publishing its resume descriptor. A new environment ID is expected and must be recorded as a successor, not disguised as the old environment.
The measured private recipe demonstrated this latter boundary: the original sandbox was deleted; copied native history and task bytes were restored into new containers on the same physical host; the native harness launched with `--resume` and the retained UUID. Its first new reply recalled a private conversation-only marker before tools, with no marker in the new input and no native-history tool read. It then verified artifact 1, used changed task context, produced artifact 2 and returned it for independent coordinator verification. This establishes one bounded native-return/work result. It does **not** establish new-host migration, RAM restoration, same-container stop/start, cgroup enforcement, general portability or shipped platform support.
## Export results before teardown
A guest path/hash note means bytes exist in a guest; it is not delivery to the consumer. Identify the actor holding the provider transfer handle. In the measured recipe that actor was the host wrapper, not the guest worker.
The worker publishes its report once and records the task/path/hash on its existing child. The wrapper validates the binding, task, allowed path, file type, size and expected hash; transfers to a fresh host staging path; checks the returned bytes/schema/context; and publishes without overwriting an existing result. It records an actual consumer-accessible destination, hash, byte count and actor through the authenticated work route. The worker closes its child after the matching export receipt; the coordinator independently reads/verifies those bytes before closing its parent. An ambiguous transfer is reconciled by existing bytes/hash, not a new report or duplicate task.
Complete exports before resource deletion. If output cannot be returned, preserve the exact resource/owner and failure state. Cleanup then reconciles the exact owned workload, supervisor, volumes and provider records to the intended stopped/absent state. Keep reused engines, caches, authenticated environments and attached resources. Revoke scoped credentials/grants through their owners and record remaining state. “Delete requested” and “absence verified” are different receipts.
## Proposed generic platform seam — not implemented
The existing composition need not acquire another YAML format to describe these responsibilities. If a maintained platform seam is selected, the following **design pseudocode** names the smallest useful operations; it is not a loader or API available in this release:
```text
prepare(placement, ownership, resourceRequest, credentialRefs) -> bindingReceipt
project(binding, explicitFiles, nativeCheckpoint?) -> deliveryReceipt
launch(binding, harnessLaunch, exactResumeRequest?) -> executionReceipt
observe(binding, execution) -> observedState | unsupported | unconfirmed
export(binding, artifactRequest) -> publicationReceipt
shutdown(binding, execution, selectedEffect) -> effectReceipt
```
The provider implementation interprets its image/network/compute configuration. The runner interprets native protocol and control. Core would retain typed provenance and route the selected effects; it must not reinterpret arbitrary provider keys, leak secrets or collapse ownership into a stop boolean. Each mutating operation needs an attempt identity and reconciliation route; partial receipts remain useful after failure. Capability reporting must expose unsupported projection, cancellation, resume or cleanup rather than letting method names imply support.
The most useful author evidence follows the real work path: exact resolved placement and native launch; delivered current context; authenticated work ownership; returned artifact bytes; exact resume boundary and consumed retained state; and final resource disposition. A parser pass, launcher success, viewer reconnect or same display name leaves the corresponding effect unconfirmed. These distinctions let an author implement and diagnose an integration; they do not create a new release gate or require another runtime campaign.
Loaded 100 of 4302 files, more files were not shown because too many files have changed in this diff. Show more