Compare 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
882 changed files with 137936 additions and 7155 deletions

No files matched your search

+10 -1
View File
@@ -3,9 +3,14 @@ dist/
.DS_Store
*.log
# All docs are internal except as-built
# All docs are internal except as-built and reference
docs/*
!docs/as-built/
!docs/reference/
!docs/releases/
!docs/releases/**
notes/*
# Local environment
.env
@@ -16,6 +21,10 @@ docs/*
*.sqlite-wal
*.sqlite.pre-reboot-backup
.openrig/
.claude
.agents
.openrig
.worktrees/
# Local demo proof artifacts
demo/proof/
+297
View File
@@ -0,0 +1,297 @@
# OpenRig Changelog
Agent-readable release notes for coding agents and human operators installing,
upgrading, or operating OpenRig.
Versioning: pre-1.0 minor releases may include contract additions,
deprecations, and behavioral changes. Breaking changes are called out explicitly.
---
## [0.3.0] - 2026-05-10
**Status**: release candidate for public publish. This entry documents the
changes since `0.2.0`.
### Summary For Installing Agents
- **Package version**: package metadata is still `0.2.0` until the release
manager performs the final version bump. The release contents documented here
are the intended `0.3.0` payload.
- **Migrations**: fresh databases apply migrations through
`039_queue_target_repo`. Existing databases migrate by running
`rig daemon start`.
- **Node engines**: the published CLI accepts Node `>=20`. The root package and
private daemon package remain constrained to active even-numbered Node lines.
- **Specs and primitives**: workflow specs, context packs, agent images,
workspace scaffolds, file browsing, queue observability, context usage, and
runtime skill discovery are all first-class product surfaces.
- **UI shell**: the operator UI has been rebuilt around the V1 shell:
destination rail, explorer, center workspace, detail drawer, vellum surfaces,
topology graph/table/terminal modes, and focused project observability.
- **Starter content**: `0.3.0` ships generic starter workflows and starter rigs.
Project-specific automation recipes are intentionally not shipped in product
source.
- **No schema-breaking release change**: existing CLI argument shapes, daemon
route paths, RigSpec/AgentSpec schemas, and persisted settings remain
backward compatible unless noted below.
### Quick Verification Commands
```bash
# Confirm CLI version after the release-manager version bump
rig --version
# Confirm daemon starts and migrations apply
OPENRIG_DB=/tmp/openrig-030-verify.sqlite rig daemon start
# Confirm settings are readable
rig config list --with-source
# Confirm starter specs are visible
rig specs ls --json
# Confirm runtime identity and loaded context
rig whoami --json
```
If any verification fails, see "Failure Modes And Remediation" below.
---
### V1 Shell And Operator UI
The `0.3.0` UI moves from prototype surfaces to a coherent operator shell:
- Two desktop chrome regions: destination rail and explorer.
- Center workspace for full pages.
- Default-closed detail drawer for previews and referenced content.
- Topology graph/table/terminal modes at a single topology URL.
- Settings rendered as a center workspace page, not as a sidebar panel.
- Vellum surface primitives, 1px region borders, and black-glass terminal
preview styling.
- Shared runtime graphics marks for agent/runtime/tool identity.
- Retired legacy surfaces: old sidebar shell, legacy dashboard page, and
rig-detail drawer patterns.
### Starter Rigs And Workflows
OpenRig now ships starter content designed to be useful on a fresh install
without exposing project-specific automation recipes.
- `product-team` is the primary human-directed starter rig.
- `conveyor` is the primary workflow-oriented starter rig.
- `conveyor` includes two generic workflow specs:
- **Conveyor**: each stage can process queued work independently, so multiple
packets can be in flight at once and natural queue backpressure handles slow
stages.
- **Basic loop**: one packet advances hop-by-hop around a small loop, useful
when the operator wants a slower, easier-to-watch workflow.
- Generic starter workflows use the workflow runtime and queue primitives. They
are examples and building blocks, not a hidden project workflow.
- Project-specific workflow specs can still be installed from a user workspace
or private spec directory; they do not need to be committed to product source.
### Mission-Shaped Workspace Defaults
Fresh installs now have a coherent default workspace path and scaffold:
- `rig config init-workspace` creates the default workspace structure.
- Workspace defaults include missions, slices, specs, proof/evidence locations,
steering files, and user-editable docs.
- Existing installs are rebased at read time so newer defaults become available
without destructive rewrites.
- Read-only mission/slice indexing supports nested mission-shaped workspaces
while preserving compatibility with earlier flat-root layouts.
### Project Observability
Project and queue work is now easier to inspect from the UI:
- For You cards classify queue lifecycle events, shipped work, progress,
observations, and approvals.
- Queue cards hydrate qitem bodies and proof previews.
- Story tabs emphasize qitem body content and paginate long activity streams.
- Queue rows preview bodies and open the detail drawer with full source,
destination, state, tags, and created-time metadata.
- Tests tabs show diagnostics and proof assets when no proof is available.
- Workspace and mission rollup pages include scoped Progress, Artifacts, Queue,
and Topology tabs.
- Current/archive grouping separates active project work from old seed or
completed work.
### Mission Control And Queue Actions
Mission Control remains the operator surface for queue observability and
qitem actions:
- Views include personal queue, human gate, fleet, active work, recent ships,
recent activity, and recent observations.
- Actions include approve, deny, route, annotate, hold, drop, and handoff.
- Mission Control audit outcomes are reflected back into For You cards so
terminal or already-actioned items show evidence instead of stale controls.
- Audit browsing and action history are read-only inspection surfaces.
- Existing daemon endpoints are reused; no extra workflow-specific endpoint is
required for the public starter content.
### Files, Markdown, Progress, And Proofs
The file and proof surfaces were expanded:
- File browser supports allowlisted roots, safe reads, asset reads, and
conflict-checked writes.
- Markdown rendering supports frontmatter, code blocks, tables, images, and
raw/rendered toggles.
- Progress views render status pills, hierarchy, and next-work markers.
- Proof screenshots open in an in-page viewer.
- File drawer headers, proof rows, queue related refs, and story rows use
shared graphics marks for faster scanning.
### Workflow Runtime And Spec Library
Workflow primitives are available as general infrastructure:
- Workflow specs are cached from markdown/YAML sources.
- Workflow instances and step trails are persisted.
- Specs library includes workflow entries and graph preview.
- Slice and project story surfaces can render spec-aware topology when a slice
is bound to a workflow instance.
- Cycle-aware traversal prevents graph rendering from hanging on looping specs.
### Context Packs And Agent Images
OpenRig now has additional reusable primitives for context and session state:
- `context_packs` package related context files into a coherent sendable bundle.
- `agent_images` capture reusable starter state from productive sessions.
- Library review surfaces can inspect these primitives and show safe summaries.
- AgentSpec startup can reference context packs and agent images.
- Resume-token data is redacted at route boundaries.
### Topology, Activity, And Context Usage
Topology is now a working operational surface rather than a static diagram:
- Graph/table/terminal views are available at the topology route.
- Host graphs can show multiple rigs on one canvas.
- Rig groups can expand/collapse, persist that state, and auto-expand for
current rig/pod/seat URLs.
- Agent activity, queue handoffs, token telemetry, and context usage are visible
in topology views.
- Codex context telemetry is read from local session state and reflected in
topology table and terminal views when available.
- Detached Codex sessions can still expose the last readable context sample;
Claude context remains running-session based.
### Terminal Preview
Terminal preview matured across several passes:
- Preview panes use existing session capture primitives.
- Compact terminal popovers are portal-mounted, clamp to viewport edges, and
resize/reposition on scroll or viewport changes.
- The compact view strips unnecessary chrome and uses a black-glass visual
language.
- The proof viewer and terminal preview share consistent drawer/popover
behavior.
### Runtime Skill Discovery
Runtime skill discovery is now part of profile resolution:
- Rig-local skill references still win first.
- Skills can be discovered from runtime-specific and shared user skill roots.
- Structurally invalid `SKILL.md` files are rejected with precise reasons.
- Profile resolution surfaces rejected-skill reasons instead of treating every
broken skill as merely missing.
- The behavior is intentionally strict: a skill must have frontmatter, non-empty
`name`, non-empty `description`, and non-empty body content.
### CLI And Daemon Release Hardening
Several release-blocking polish items landed in the CLI/daemon:
- Transcript capture now uses bounded `tmux capture-pane` polling instead of an
unbounded pipe file.
- `rig send` wraps delivered messages with sender/recipient context and a reply
hint.
- Generated setup markers use OpenRig naming.
- Original runtime environment aliases keep deprecated `RIGGED_*` fallbacks for
compatibility; newer typed settings use `OPENRIG_*` only.
- ConfigStore and SettingsStore remain lockstep for typed settings.
- No new plugin loader ships in `0.3.0`; plugin support is planned for a later
release.
### Removed Or Not Included
- Project-specific workflow recipes are not included in product source.
- The old `demo` rig is no longer the recommended starter; public docs point to
`product-team` and `conveyor`.
- The legacy `mental-model-ha` skill is removed from starter guidance.
- Root runtime projections such as `CLAUDE.md` are intentionally not tracked.
- Package-local `pnpm-lock.yaml` files are intentionally not restored; this repo
uses npm workspaces and the root `package-lock.json` for release installs.
- Internal release artifacts and local dogfood packets are not part of the
public release notes.
---
### Failure Modes And Remediation
**`rig daemon start` fails with an ABI mismatch**
- Cause: native dependencies were compiled against a different Node version.
- Remediation: use an active even-numbered Node release, or rebuild native
dependencies from the installed package directory.
**Files browser or Progress view appears empty on a fresh install**
- Cause: workspace scaffold has not been initialized or the configured root is
not where the operator expects.
- Remediation: run `rig config init-workspace`, then inspect
`rig config list --with-source`.
**Mission Control views are empty**
- Expected on a rig with no queue items.
- Remediation: create or hand off a queue item, then refresh. If still empty,
confirm daemon status and DB path with `rig daemon status` and
`rig config get db.path`.
**A skill reference resolves as rejected**
- Cause: the target `SKILL.md` exists but failed structural validation.
- Remediation: fix the skill frontmatter and body. It must include delimited
YAML frontmatter with non-empty `name` and `description`, followed by non-empty
markdown body content.
**A public starter workflow is too simple for a specialized loop**
- Expected. `0.3.0` ships reusable primitives and generic starter workflows.
Specialized workflow specs should live in user workspace or private rig
packages.
---
## [0.2.0] - 2026-04-22
Baseline for this changelog. Key shipped capabilities at `0.2.0`:
- Pod-aware multi-agent runtime with RigSpec, AgentSpec, pods, and seats.
- Rig-scoped environment management.
- Filesystem-backed spec library for built-in and user roots.
- Daemon-backed stream, queue, project, view, watchdog, and workflow primitives.
- Cross-runtime restore packets for Claude Code and Codex sessions.
- Communication primitives: `rig send`, `rig capture`, `rig broadcast`, durable
rig chat, and transcript inspection.
- Identity and lifecycle commands: `rig whoami`, adoption/bind/materialize
flows, snapshot/restore, and post-command handoff.
- Operator surfaces: `rig ps`, `rig ps --nodes`, queue inspection, and daemon
status.
- 32 SQLite migrations.
---
*This changelog is written for agents and humans. It should describe public
release behavior without depending on local workspace paths or private project
packets.*
+346
View File
@@ -0,0 +1,346 @@
# OpenRig Design
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.
## Essence
OpenRig is a local control plane for multi-agent work. The interface should feel
like an operator's tactical dossier: precise, technical, calm, and legible under
pressure. It is not a consumer SaaS dashboard and not a decorative terminal skin.
It is a paper-and-glass workspace for watching work move through rigs, agents,
queues, projects, files, and proof packets.
The brand should communicate:
- Operator confidence: the human can see what is happening and what needs action.
- Composable systems: workspace, mission, slice, rig, pod, seat, queue, file, and
proof surfaces share primitives instead of bespoke one-off cards.
- Live work: activity, flow, context usage, proofs, and terminal state are visible
without turning the UI into a log dump.
- Local-first control: the app looks like a serious local cockpit, not a cloud
marketing product.
## Design Language
OpenRig currently uses two deliberate surface languages.
### Vellum Paper
Vellum is the default light surface language. It appears on navigation chrome,
dashboard cards, topology cards, project cards, drawers, file viewers, and
metadata containers.
Use vellum for persistent product surfaces:
- Explorer sidebars
- Dashboard cards
- Topology rig, pod, and agent cards
- Project and For You cards
- Drawer sheets and document viewers
- File and markdown surfaces
Core vellum traits:
- Paper-grid background visible behind and sometimes through surfaces
- White translucent fills, usually `bg-white/25` to `bg-white/40`
- `backdrop-blur-[8px]` for layered paper
- 1px outline or ghost border
- Hard shadow only on paper cards, not on floating glass
- Registration marks for framed tactical sheets
- Square corners everywhere except tiny status dots
Vellum should never become fully transparent. If the paper-grid makes text hard
to read, increase opacity before adding heavy borders.
### Black Glass
Black glass is the floating dark surface language. It is currently used for the
topology terminal preview and screenshot/proof overlays.
Use black glass for temporary preview surfaces:
- Terminal popovers
- Image proof viewers
- Other quick-look overlays where the content should float above the canvas
Core black-glass traits:
- Transparent dark fill, currently around `bg-stone-950/65`
- Light text, usually `text-stone-50`
- Optional subtle backdrop blur
- No hard shadow
- No 1px vellum border
- Chrome stripped to the minimum needed for comprehension
Black glass should feel like a smoked sheet above the canvas, not a framed
terminal emulator.
## Color Tokens
The canonical tokens live in `packages/ui/src/globals.css` and are surfaced to
Tailwind in `packages/ui/tailwind.config.ts`.
Paper:
- `--background: 47 20% 97%` - base paper, `#faf9f5`
- `--surface-container-lowest: 0 0% 100%` - card body white
- `--surface-container-low: 80 10% 95%`
- `--surface-container: 80 10% 92%`
- `--surface-container-high: 100 10% 89%`
- `--surface-container-highest: 100 10% 87%`
Ink:
- `--on-surface: 120 8% 19%` - primary text, `#2e342e`
- `--on-surface-variant: 108 5% 36%` - secondary text, `#5b615a`
- `--inverse-surface: 120 10% 4%` - dark strip fills, near black
Technical linework:
- `--secondary: 213 15% 39%` - connectors, handles, registration marks
- `--secondary-container: 220 39% 91%`
Status:
- `--success: 145 63% 35%`
- `--warning: 40 90% 50%`
- `--tertiary: 1 63% 42%` - stamped red / critical
- `--error: 0 43% 44%`
Borders:
- `--outline: 108 4% 47%`
- `--outline-variant: 108 4% 68%`
Usage rules:
- Use `outline-variant` for most structural borders.
- Reserve stone-900 borders for important card edges, active tabs, and hard
tactical emphasis.
- Do not create one-off color systems for each feature. Classify the state first,
then map to the shared neutral, info, success, warning, or danger tones.
## Typography
Tailwind font families:
- `font-body`: Inter for readable body text
- `font-headline`: Space Grotesk for display and stamped headings
- `font-mono`: JetBrains Mono for labels, IDs, queue state, technical metadata,
terminal previews, tabs, chips, and counters
General typography rules:
- Product labels use uppercase mono with letter spacing around `tracking-[0.10em]`.
- Body copy should be sentence case when it is meant for humans to read.
- Code identifiers should be transformed into human-readable labels where
possible, not displayed raw.
- Terminal and transcript content stays mono but should not force the rest of the
product to read like a log.
## Layout
The shell is route-first and explorer-first:
- 48px icon rail on desktop
- Explorer sidebar for route-specific tree context
- Center workspace for destination content
- Right drawer for transient detail and file views
- Preview stack for pinned quick-look panes
Primary destinations:
- `/` Dashboard
- `/topology` host topology
- `/for-you` For You feed
- `/project` workspace/project observability
- `/specs` Library
- `/settings` Settings
Topology and project both use hierarchical scopes:
- Topology: host -> rig -> pod -> seat
- Project: workspace -> mission -> slice
Each lower scope narrows the filter. Each higher scope widens the context.
## Core Primitives
Use these primitives before adding new component styles.
Vellum:
- `VellumCard`
- `VellumSheet`
- `RegistrationMarks`
- `StatusPip`
- `SectionHeader`
- `EmptyState`
Graphics:
- `RuntimeMark`
- `RuntimeBadge`
- `ToolMark`
- `ToolBadge`
- `ActorMark`
- `OperatorMoodMark`
- `runtime-brand.ts`
- `tool-brand.ts`
Project metadata:
- `ProjectPill`
- `EventBadge`
- `QueueStateBadge`
- `TagPill`
- `ActorChip`
- `DateChip`
- `FlowChips`
- `ProofThumbnailGrid`
- `ProofPacketHeader`
Topology:
- `HostMultiRigGraph`
- `HybridAgentNode`
- `HybridPodGroupNode`
- `RigGroupNode`
- `ActivityRing`
- `HotPotatoEdge`
- `TerminalPreviewPopover`
- `TopologyTableView`
- `TopologyTerminalView`
Preview and documents:
- `SessionPreviewPane`
- `ProofImageViewer`
- `FileViewer`
- `MarkdownViewer`
## Graphics System
OpenRig uses a shared graphics package rather than scattering literal icon logic
through product surfaces.
Runtime marks:
- Claude: pixel-style Claude mark, label `Claude`
- Codex: Codex CLI terminal-circle mark, label `Codex`
- Terminal: compact terminal glyph, label `TTY` or `Terminal`
- Unknown: neutral fallback
Tool marks:
- CMUX: blue chevron mark
- tmux: pane-grid mark with green base
- VS Code: blue code mark
- Terminal, file, markdown, config, code, screenshot, proof, transcript, commit,
folder, skill, video, and trace each have a central brand entry
Actor marks:
- Human/operator uses the monochrome climber mask
- Agent/runtime actors reuse runtime marks
Graphics placement rules:
- Use runtime marks where the runtime is the important scan dimension.
- Do not duplicate the same runtime mark in adjacent columns on the same row.
- Use tool marks for actions, not runtime marks.
- Prefer inline marks and open metadata rows over stacking more rectangular pill
badges when a card is already visually dense.
- Keep marks small enough to support scanning. They are landmarks, not mascots.
## Interaction Patterns
Hover-only actions:
- Dense topology cards show CMUX and terminal actions on hover/focus.
- Keyboard focus must reveal the same actions as mouse hover.
- Action clicks must stop propagation so row or node navigation does not also
fire.
Drawers and overlays:
- Drawers close with their close button and by clicking outside the sheet.
- Image viewers and terminal popovers are quick-look surfaces.
- Topology terminal popovers are portaled above React Flow node stacking contexts.
Queue and project:
- Queue is operational: what is pending, actionable, routed, or closed.
- Story is narrative: what happened over time, with body text primary and
metadata secondary.
- For You is attention routing: your turn, approvals, shipped proof, progress,
and observations.
## Motion
Motion should explain state, not decorate.
Current motion vocabulary:
- Activity card pulse for active, needs-input, and blocked states
- Source and target flash for handoff events
- Hot-potato packet animation on topology graph edges
- Rig frame pulse for aggregate activity
- Subtle route and node enter fades
Rules:
- Prefer slow pulses to blinking.
- Make directional work movement obvious enough to see at graph zoom levels.
- Honor `prefers-reduced-motion`: remove pulse/travel animation and keep static
state signals.
## Accessibility
- Icon-only actions need labels or tooltips.
- Decorative marks should use `aria-hidden`.
- Semantic status should use `StatusPip`, not activity animation classes.
- Keyboard focus must expose hover-only controls.
- Do not rely on color alone for action outcomes.
- Keep dense cards readable at the expected topology zoom range.
## Do And Do Not
Do:
- Use vellum and black-glass as distinct surface paradigms.
- Keep square geometry, 1px lines, and hard paper shadows.
- Centralize graphics through brand helpers.
- Translate raw event/state codes into human-readable labels.
- Make proof and terminal previews available where thumbnails or terminal marks
appear.
- Keep hierarchy visible: workspace -> mission -> slice, host -> rig -> pod ->
seat.
Do not:
- Add new one-off card styles when a primitive can do the job.
- Use emojis as product graphics.
- Turn metadata into a wall of rectangular pills.
- Make terminal/log aesthetics dominate human decision surfaces.
- Add decorative gradient blobs or generic SaaS hero styling.
- Add black borders or hard shadows to black-glass popovers.
- Duplicate runtime/tool brand logic outside `runtime-brand.ts`,
`tool-brand.ts`, and `RuntimeMark.tsx`.
## Implementation References
- Tokens: `packages/ui/src/globals.css`
- Tailwind theme: `packages/ui/tailwind.config.ts`
- Shell: `packages/ui/src/components/AppShell.tsx`
- Routes: `packages/ui/src/routes.tsx`
- Vellum primitives: `packages/ui/src/components/ui/`
- Graphics primitives: `packages/ui/src/components/graphics/RuntimeMark.tsx`
- Runtime brands: `packages/ui/src/lib/runtime-brand.ts`
- Tool brands: `packages/ui/src/lib/tool-brand.ts`
- Project metadata primitives: `packages/ui/src/components/project/ProjectMetaPrimitives.tsx`
- Topology graph nodes: `packages/ui/src/components/topology/HybridTopologyNodes.tsx`
- Terminal popover: `packages/ui/src/components/topology/TerminalPreviewPopover.tsx`
- Session preview: `packages/ui/src/components/preview/SessionPreviewPane.tsx`
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Mike Schwarz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+173
View File
@@ -0,0 +1,173 @@
# OpenRig
Open source multi-agent harness for coding teams.
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
![OpenRig UI](https://openrig.dev/screenshots/remotion/hero-ui-workspace.png)
## Quick Start
```bash
# Install
npm install -g @openrig/cli
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
rig setup
# Boot the conveyor starter rig (4 seats, mixed runtimes)
rig up conveyor
# Open the UI
rig ui open
```
After the conveyor rig boots, open the UI and click **intake.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
## Launch Walkthrough
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.
### 1. Open the conveyor rig
When the UI opens, the explorer is already visible on the left. Click the `conveyor` rig to load its live topology.
### 2. Click `CMUX` on `intake.lead`
Once the topology loads, go to the `intake.lead` node and click its `CMUX` button. That opens the intake terminal directly.
> From here, give the intake agent a packet of work and watch it move through planning, build, review, and closeout.
## What It Does
OpenRig is a multi-agent harness — it manages the system that coding agents form when you run them together. Not the agents themselves, but the team they create: which sessions are running, how they relate, how to recover after a reboot, and how to stop it from becoming terminal sprawl.
- **Define** topologies in YAML (RigSpec) with pods, edges, and continuity policies
- **Boot** everything with `rig up` — tmux sessions, harnesses, startup files, readiness checks
- **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 full topology on `rig down`, restore by name with `rig up <name>`
- **Communicate** across agents with `rig send`, `rig broadcast`, and `rig chatroom`
- **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.
## Starter Rigs
OpenRig ships with a conveyor starter rig for learning workflow handoff:
```bash
rig specs preview conveyor --kind rig
```
```
conveyor (rig, pod_aware)
Starter workflow rig: a station pipeline that can move multiple
work packets at once, with queue depth acting as natural backpressure.
Pod: intake (1 member)
lead — claude-code
Pod: plan (1 member)
planner — codex
Pod: build (1 member)
builder — claude-code
Pod: review (1 member)
reviewer — codex
```
For a larger human-operated product-development topology, inspect `product-team`:
```bash
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 + MCP server + React UI, built on tmux.
```
CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services (52)
|
SQLite + tmux + runtime adapters
```
- **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
- **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.
- **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, 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
A rig can package actual software alongside the agents that manage it. The shipped example is `secrets-manager`: a HashiCorp Vault instance operated by a specialist agent.
```bash
rig up secrets-manager
rig env status secrets-manager
rig send vault-specialist@secrets-manager "Check Vault health and report status." --verify
```
Requires Docker for service-backed rigs.
## Requirements
- Node.js 20, 22, or 24 (even-numbered LTS releases; odd releases like 25 lack native addon prebuilds)
- tmux
Optional:
- cmux for `Open CMUX` node surface controls
- Docker for service-backed rigs and managed apps
## Setup and Troubleshooting
- `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.
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:** 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
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)
- **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)
- **Twitter**: [@_feralmachine](https://twitter.com/_feralmachine)
## License
Apache 2.0
@@ -0,0 +1,24 @@
# Launch Happy-Path Screenshot Set
Source capture:
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
- host-forwarded UI: `http://127.0.0.1:17439`
- guest daemon: `192.168.64.9:7433`
Purpose:
- README walkthrough
- website/docs reuse
- launch gate evidence bundle
Annotated README walkthrough:
- `guide-step-01-open-demo.png` — click the `demo` rig in the explorer
- `guide-step-02-open-cmux.png` — click the `CMUX` button on `orch1.lead` in the topology
Archived proof/source bundle:
- raw proof captures and guide source were moved out of the repo to:
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
Notes:
- The README should use the annotated walkthrough files.
- The raw captures are still the fidelity record for proof and future reuse, just not inside the product repo.
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+1 -1
View File
@@ -62,7 +62,7 @@ pods:
files: []
actions:
- type: send_text
value: "rigged status"
value: "rig status"
phase: after_ready
idempotent: true
applies_on: [fresh_start]
+7 -7
View File
@@ -10,11 +10,11 @@ echo ""
# 0. Start or reuse daemon
echo "Step 0: Ensure daemon is running..."
if rigged daemon start; then
if rig daemon start; then
sleep 2
else
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
if rigged ps --json >/dev/null 2>&1; then
if rig ps --json >/dev/null 2>&1; then
echo "Reusing existing healthy daemon."
else
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
@@ -25,7 +25,7 @@ echo ""
# 1. Boot
echo "Step 1: Boot demo topology..."
UP_OUTPUT=$(rigged up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
UP_OUTPUT=$(rig up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
if [ -z "$RIG_ID" ]; then
echo "ERROR: No rig ID found in boot output. Proof failed."
@@ -35,7 +35,7 @@ echo ""
# 2. Node status
echo "Step 2: Node status after boot..."
rigged ps --nodes 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
@@ -74,7 +74,7 @@ echo "Rig ID: $RIG_ID"
# 7. Tear down
echo ""
echo "Step 7: Tear down..."
DOWN_OUTPUT=$(rigged down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
DOWN_OUTPUT=$(rig down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
SNAPSHOT_ID=$(printf '%s\n' "$DOWN_OUTPUT" | sed -n 's/^Snapshot: //p' | tail -n1)
if [ -z "$SNAPSHOT_ID" ]; then
echo "ERROR: No snapshot ID found in teardown output. Proof failed."
@@ -90,12 +90,12 @@ echo ""
# 9. Restore via explicit snapshot + rig ID
echo "Step 9: Restore via explicit snapshot + rig ID..."
rigged restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
rig restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
echo ""
# 10. Node status after restore
echo "Step 10: Node status after restore..."
rigged ps --nodes 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 ==="
+8 -8
View File
@@ -6,11 +6,11 @@ echo ""
# Start daemon
echo "Starting daemon..."
if rigged daemon start; then
if rig daemon start; then
sleep 2
else
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
if rigged ps --json >/dev/null 2>&1; then
if rig ps --json >/dev/null 2>&1; then
echo "Reusing existing healthy daemon."
else
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
@@ -21,18 +21,18 @@ fi
# Boot the demo topology
echo ""
echo "Booting demo topology..."
UP_OUTPUT=$(rigged up demo/rig.yaml)
UP_OUTPUT=$(rig up demo/rig.yaml)
printf '%s\n' "$UP_OUTPUT"
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
if [ -z "$RIG_ID" ]; then
echo "ERROR: failed to determine rig ID from 'rigged up' output." >&2
echo "ERROR: failed to determine rig ID from 'rig up' output." >&2
exit 1
fi
# Show node status
echo ""
echo "Node status:"
rigged ps --nodes
rig ps --nodes
# Establish a restore-safe baseline for the demo rig.
echo ""
@@ -55,6 +55,6 @@ echo "=== Demo topology is running ==="
echo "Dashboard: http://localhost:5173"
echo ""
echo "Next steps:"
echo " rigged ps --nodes # Check node status"
echo " rigged down $RIG_ID # Tear down (auto-snapshots)"
echo " rigged restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
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"
+6 -6
View File
@@ -59,12 +59,12 @@ export function runCommand(command: string, args: string[], cwd = repoRoot()): s
}).trim();
}
export function runRigged(args: string[]): string {
return runCommand("rigged", args);
export function runRig(args: string[]): string {
return runCommand("rig", args);
}
export function runRiggedJson<T>(args: string[]): T {
const output = runRigged(args);
export function runRigJson<T>(args: string[]): T {
const output = runRig(args);
return JSON.parse(output) as T;
}
@@ -77,7 +77,7 @@ export function sleep(ms: number): Promise<void> {
}
export function listRigSummaries(): DemoRigSummary[] {
return runRiggedJson<DemoRigSummary[]>(["ps", "--json"]);
return runRigJson<DemoRigSummary[]>(["ps", "--json"]);
}
export function getCurrentRigSummary(rigNameOrId: string): DemoRigSummary | null {
@@ -95,7 +95,7 @@ export function listRigNodes(rigNameOrId: string): DemoNodeEntry[] {
return [];
}
return filterNodesForRigId(
runRiggedJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
runRigJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
summary.rigId
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ export async function probeNodeResume(
const pollMs = opts.pollMs ?? 500;
const maxWaitMs = opts.maxWaitMs ?? 6_000;
const attempts = Math.max(1, Math.floor(maxWaitMs / pollMs));
const sessionName = `rigged-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
const sessionName = `rig-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
try {
runTmux(["new-session", "-d", "-s", sessionName, "-c", resolveNodeCwd(node.cwd ?? ".")]);
+1 -1
View File
@@ -100,7 +100,7 @@ function sendWarmup(sessionName: string | null, text: string): void {
}
execFileSync(
"rigged",
"rig",
["send", sessionName, text, "--verify", "--force", "--json"],
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
);
+404 -105
View File
@@ -1,16 +1,18 @@
# OpenRig — As-Built Architecture
## Final Stretch 1 Snapshot (as of 2026-04-05)
## Current Architecture
Status:
- Four implementation rounds have landed: AgentSpec reboot (16 tasks), North Star (16 tasks), Post-North-Star (11 tasks + dogfood fixes + UI polish), and Final Stretch 1 (spec library, `whoami`, adopted-session parity, specs/discovery drawer workflows, reusable spec display surfaces, live-node full-details route, graph hover/runtime hints).
- Shipped source footprint: `253` source files across the three packages.
- Daemon footprint: `133` source files total, including `79` domain files, `21` route files, `10` adapters, and `17` migrations.
- CLI footprint: `37` source files.
- UI footprint: `83` source files.
- Current full-suite verification during this refresh:
- daemon: `112/112` files, `1461/1461` passing
- CLI: `29/29` files, `275/275` passing
- UI: `35` files, `361/364` passing (`3` known design/foundation failures)
- OpenRig currently ships a pod-aware multi-agent runtime, rig-scoped environment management, a filesystem-backed spec library, managed-app browsing/review surfaces, and the canonical `secrets-manager` + `vault.specialist` agent-managed app example.
- OpenRig v0.2.0 also ships daemon-backed coordination primitives: stream, queue, project, view, watchdog, and workflow runtime.
- Shipped source footprint: `376` source files across the three packages.
- Daemon footprint: `211` source files total, including `126` domain files, `31` route files, `11` adapters, and `36` migrations.
- CLI footprint: `78` source files.
- UI footprint: `87` source files.
- Current v0.2.0 release verification:
- root build and package build passing
- daemon focused suite: `171/171` files, `2561/2561` passing
- CLI sanitized suite: `56/56` files, `794/794` passing
- Mode 3 real-agent dual-auth dogfood covered Claude-only, Codex-only, and mixed Claude-to-Codex paths
Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
@@ -18,42 +20,47 @@ Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
## 1. System Overview
OpenRig is a local control plane for multi-agent coding topologies. The system currently has four architectural layers:
OpenRig is a local control plane for multi-agent coding topologies. The system currently has six architectural layers:
1. **AgentSpec / pod-aware core**: spec parsing, resolution, precedence, startup orchestration, snapshot/restore, bundles.
2. **North Star operator layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
3. **Post-North-Star transport/history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
4. **Final Stretch 1 authoring/identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
2. **Operator and topology layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
3. **Communication and history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
4. **Authoring and identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
5. **Rig environment layer**: rig-scoped services records, Compose-backed service orchestration, readiness gates, env snapshot/restore integration, runtime env status/logs/down surfaces.
6. **Agent-managed software layer**: managed-app classification in the library, app-focused browse/review/runtime UI, managed-app-aware CLI/help surfaces, and the canonical `secrets-manager` + `vault.specialist` example.
Legacy flat-node/package flows remain for backward compatibility.
The stack is:
```text
CLI (31 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
CLI (53 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
v
Hono daemon routes (21 route groups + dedicated health/export/static handlers)
Hono daemon routes (31 route groups + dedicated health/export/static handlers)
|
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
+-- env routes (status / logs / down)
+-- transport routes (send/capture/broadcast)
+-- transcript routes (tail/grep)
+-- ask routes (context evidence packs)
+-- chat routes (durable rig messaging + SSE)
+-- spec review/library routes
+-- whoami identity route
+-- spec review/library routes (managed-app enrichment + compose preview)
+-- whoami identity + context-usage route
|
v
Framework-free domain services
|
+-- SQLite state (17 migrations)
+-- SQLite state (36 migrations)
+-- tmux / cmux / resume adapters
+-- runtime adapters (Claude Code / Codex / Terminal)
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
+-- transport layer (SessionTransport)
+-- transcript store (pipe-pane backed)
+-- chat repository (SQLite backed)
+-- spec review service
+-- spec library service
+-- spec review service (services metadata + compose preview seam)
+-- spec library service (`hasServices` classification)
+-- context-usage store / monitor
+-- whoami identity service
The core product loop:
@@ -66,7 +73,7 @@ The core product loop:
### HTTP routes
`createApp()` now mounts 21 route groups plus:
`createApp()` now mounts 22 route groups plus:
- `GET /healthz`
- `GET /api/rigs/:rigId/spec`
- `GET /api/rigs/:rigId/spec.json`
@@ -74,7 +81,7 @@ The core product loop:
In addition to the reboot-era routes:
**North Star additions:**
**Operator and topology routes:**
- `GET /api/rigs/:rigId/nodes` — canonical shared node-inventory projection
- `GET /api/rigs/:rigId/nodes/:logicalId` — rich node-detail payload for the drawer
- `POST /api/rigs/:rigId/nodes/:logicalId/launch` — manual node launch
@@ -82,7 +89,7 @@ In addition to the reboot-era routes:
- `POST /api/rigs/:id/up` — existing-rig restore by rig ID
- `POST /api/discovery/draft-rig` — generate candidate rig spec from discovered sessions
**Final Stretch 1 additions:**
**Authoring and identity routes:**
- `POST /api/rigs/import/materialize` — create pod-aware rig topology without launching
- `POST /api/discovery/:id/bind` — bind discovered session to an existing logical node
- `POST /api/discovery/:id/adopt` — UI-friendly composite adopt route (`bind` or `create_and_bind`)
@@ -90,7 +97,12 @@ In addition to the reboot-era routes:
- `/api/specs/library` — list/get/review/sync filesystem-backed library entries
- `/api/whoami` — daemon-backed runtime identity projection by `nodeId` or `sessionName`
**Post-North-Star additions:**
**Rig environment routes:**
- `GET /api/rigs/:rigId/env` — live rig-environment status with best-effort fresh receipt capture
- `GET /api/rigs/:rigId/env/logs` — service logs from the Compose adapter
- `POST /api/rigs/:rigId/env/down` — explicit env teardown for service-backed rigs
**Communication and history routes:**
- `/api/transport` — `POST /send`, `/capture`, `/broadcast` — communication primitives
- `/api/transcripts` — `GET /:session/tail`, `/:session/grep` — transcript access
- `/api/ask` — `POST /` — context evidence pack over rig summary + transcripts + chat
@@ -103,30 +115,44 @@ Important route behaviors:
- `/api/transport/send` classifies target activity before sending and can refuse unless forced
- `/api/ask` returns context/evidence, not LLM-synthesized answers
- `/api/rigs/:rigId/chat` streams via SSE for real-time delivery
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`)
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`), managed-app services metadata, and best-effort compose preview
- `/api/whoami` fails honestly on missing/ambiguous identity instead of fabricating “healthy” state
- `/api/rigs/:rigId/env` returns `{ ok: true, hasServices: false }` honestly when a rig has no service substrate
- `/api/rigs/:rigId/env/down` currently honors stored down policy; explicit volume-removal override is not fully wired through yet
### CLI commands
`index.ts` currently mounts 31 command groups. Reboot-era commands plus:
`index.ts` currently mounts 53 command groups. Reboot-era commands plus:
**North Star additions:**
**Operator and topology commands:**
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
- `rig down` — auto-snapshots before teardown, handoff includes restore command
- `rig discover --draft` — generate candidate rig spec from discovered sessions
- Changed: `rig ps --nodes` shows per-node detail with session names, status, startup status
**Final Stretch 1 additions:**
**Authoring and identity commands:**
- `rig specs ls/show/preview/add/sync` — agent-facing spec library browse/review workflow
- `rig whoami --json` — daemon-backed runtime identity with peers, edges, transcript helpers, and command hints
- `rig bind <discoveredId> <rigId> <logicalId>` — bind discovered session to an existing node
- `rig adopt <file> --bind logicalId=session` — materialize then bind live sessions into a rig
- Changed: `rig up <source>` and `rig bootstrap <source>` now resolve spec-library names and fail loudly on rig-name vs library-name ambiguity
**Post-North-Star additions (8 new command groups):**
- `rig send <session> "message" [--verify] [--force]` — send to agent terminal
- `rig capture <session> [--lines N] [--json]` — capture agent pane content
**Rig environment commands:**
- `rig env status <rig>` — inspect rig-scoped environment status for service-backed rigs and managed apps
- `rig env logs <rig> [service]` — fetch service logs
- `rig env down <rig>` — tear down the rig environment
**Managed-app-aware help and copy:**
- `rig up` help/examples explicitly include library-backed managed apps like `secrets-manager`
- `rig specs` help/list/preview text now distinguishes rigs, agents, and managed apps
- `openrig-user` now teaches the shipped managed-app model (`software + specialist`) and cross-rig specialist interaction
**Communication and history commands:**
- `rig send <session> "message" [--verify] [--force] [--host <id>]` — send to agent terminal; `--host` routes to a host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). SSH success is NOT verify success — the remote rig's verify result is surfaced verbatim.
- `rig capture <session> [--lines N] [--json] [--host <id>]` — capture agent pane content; `--host` same shape as `rig send --host`.
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
**Session source declaration:** Member YAML may carry `session_source` to control how a new managed seat derives its starting context. v1 supports two modes: `fork` (start from a prior native runtime conversation; captures and persists a NEW post-fork token, never the parent — see cli-reference.md "Session source declaration"); and `rebuild` (fresh-launch seeded with operator-declared artifacts in trust-precedence order; the seat's `continuityOutcome` is `rebuilt` and no `resumeToken` is persisted). Both modes flow through the existing rig spec and `rig expand` member-input shapes; no new top-level command was introduced for either.
- `rig transcript <session> --tail N / --grep "pattern" [--json]` — transcript access
- `rig config [get <key> / set <key> <value> / reset <key>] [--json]` — configuration surface
- `rig preflight [--json]` — system readiness check (Node, tmux, writable dirs, port)
@@ -139,7 +165,7 @@ The CLI-hosted MCP server exposes 17 tools:
1-12. Reboot-era: `rigged_up`, `rigged_down`, `rigged_ps`, `rigged_status`, `rigged_snapshot_create`, `rigged_snapshot_list`, `rigged_restore`, `rigged_discover`, `rigged_claim`, `rigged_bundle_inspect`, `rigged_agent_validate`, `rigged_rig_validate`
13-17. Post-North-Star additions:
13-17. Communication and operator additions:
- `rigged_rig_nodes` — node inventory for a rig (agents can look up infrastructure sessions)
- `rigged_send` — send message to agent terminal
- `rigged_capture` — capture agent pane content
@@ -148,34 +174,73 @@ The CLI-hosted MCP server exposes 17 tools:
### UI architecture
The UI is now explorer-first, with a shared drawer plus center-workspace route model:
The current UI is shell-first, route-first, and primitive-driven. See
`DESIGN.md` for the brand/design spec and `docs/as-built/ui.md` for the detailed
as-built UI map.
- `/` renders `WorkspaceHome` (landing page when no rig selected)
- `AppShell` composes: `Explorer` sidebar + shared drawer selection context + `SharedDetailDrawer` + specs workspace context + activity/system surfaces
- Selecting a rig in the explorer loads its topology graph in the main area
- Clicking a node (in explorer or graph) opens the shared detail drawer
- The shared drawer has five primary surfaces:
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
- **Specs drawer:** library list, draft/spec review routing, and spec authoring entry points
- **Discovery drawer:** placement-first adopt/bind/materialize flow
- **System drawer:** global event/activity surface replacing the old status-bar role
- Pod selection opens the rig drawer with pod section expanded (no separate pod drawer)
- Human-readable IDs are UI-only: pod labels instead of ULIDs in explorer, short ULID tails for glanceability, full IDs in detail views
- Infrastructure/terminal nodes have distinct visual treatment
- Graph shows pod grouping via React Flow group nodes, status colors (green=ready, amber=launching, red=failed, gray=stopped), and lightweight hover/runtime hints
- Explicit center-workspace routes now exist for:
- `/specs`
- `/specs/rig`
- `/specs/agent`
- `/specs/library/$entryId`
- `/rigs/$rigId/nodes/$logicalId`
High-level shape:
- `AppShell` wraps every route with a desktop rail, route-aware `Explorer`,
center workspace, shared right drawer, preview stack, and topology overlay
provider.
- Primary destinations are Dashboard (`/`), Topology (`/topology`), For You
(`/for-you`), Project (`/project`), Library (`/specs`), and Settings
(`/settings`).
- Topology uses scoped routes for host, rig, pod, and seat views:
`/topology`, `/topology/rig/$rigId`, `/topology/pod/$rigId/$podName`, and
`/topology/seat/$rigId/$logicalId`.
- Project uses scoped routes for workspace, mission, and slice:
`/project`, `/project/mission/$missionId`, and `/project/slice/$sliceId`.
- Library keeps `/specs` as the route but presents the product label
`Library`, including skills at `/specs/skills/$skillToken` and
`/specs/skills/$skillToken/file/$fileToken`.
Current UI primitives:
- Vellum primitives live in `components/ui`: `VellumCard`, `VellumSheet`,
`RegistrationMarks`, `StatusPip`, empty states, tabs, tables, buttons, and
form controls.
- Graphics primitives live in `components/graphics` and `lib`: `RuntimeMark`,
`RuntimeBadge`, `ToolMark`, `ToolBadge`, `ActorMark`, `runtime-brand.ts`, and
`tool-brand.ts`.
- Project metadata primitives live in `components/project/ProjectMetaPrimitives`
and render event badges, queue state badges, tag pills, actor chips, date
chips, flow chips, proof thumbnails, and proof packet headers.
Current topology UI:
- Host topology is a hybrid React Flow graph with soft rig frames, pod frames,
compact agent cards, context percentage, token totals, activity card tint,
activity rings, hot-potato edge animation, hover/focus CMUX actions, and
terminal preview popovers.
- Topology table and terminal views share the same route-scope model and preserve
graph/table/tree navigation parity to seat detail URLs.
- Terminal preview popovers use black-glass styling and are portaled above the
React Flow canvas to avoid node stacking conflicts.
Current project and feed UI:
- Project scopes render Story, Progress, Artifacts, Tests, Queue, and Topology
tabs filtered by workspace, mission, or slice.
- Story is narrative and body-primary; Queue is operational and actionable.
- For You classifies feed items into action-required, approval, shipped,
progress, and observation cards, with body previews, proof thumbnails, action
outcomes, actor flow, and queue actions.
- Proof images use a reusable black-glass viewer.
Current drawer/viewer model:
- `SharedDetailDrawer` remains the shared transient detail surface.
- Drawer triggers carry root/path provenance for file and queue item viewers.
- The drawer supports outside-click dismissal.
- Skill details use Library Explorer navigation plus `FileViewer` in the center
workspace instead of a nested three-column browser.
---
## 3. Database Schema
The daemon now has 17 migrations.
The daemon now has 27 migrations (22 existing plus 5 added by PL-004 Phase A).
### Core state tables
@@ -199,6 +264,9 @@ The daemon now has 17 migrations.
**bindings**
- Physical surface attachment: tmux/cmux coordinates.
- `019_external_cli_attachment.ts` extends this row shape with:
- `attachment_type`
- `external_session_name`
**sessions**
- Live execution state.
@@ -240,7 +308,7 @@ These remain active:
- `label`
- `summary`
- serialized continuity policy
- Final Stretch 1 made `namespace` first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
- `namespace` is first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
**continuity_state** (`014_agentspec_reboot.ts`)
- Live per-`pod_id` / `node_id` operational continuity state.
@@ -263,11 +331,111 @@ These remain active:
- Indexed by `(rig_id, created_at)` for fast rig-scoped queries
- Note: transcript persistence is filesystem-backed (pipe-pane → log files), not SQLite. Chat is SQLite-backed.
**context_usage** (`018_context_usage.ts`)
- Persisted per-node context usage snapshots for runtime/operator surfaces.
- Used by the daemon-owned context monitor and `whoami`/inventory projections.
**rig_services** (`020_rig_services.ts`)
- Persisted rig-scoped environment record for service-backed rigs.
- Stores:
- `rig_id`
- `kind`
- `spec_json`
- `rig_root`
- resolved `compose_file`
- resolved `project_name`
- `latest_receipt_json`
- timestamps
Migration boundary:
- `014_agentspec_reboot.ts` adds the reboot schema shape.
- `015_startup_context.ts` adds persisted startup replay context.
- `016_chat_messages.ts` adds durable rig chat.
- `017_pod_namespace.ts` backfills/persists authored pod namespace for export, adoption, and identity parity.
- `018_context_usage.ts` adds context-usage persistence.
- `019_external_cli_attachment.ts` extends binding rows for external CLI attachment.
- `020_rig_services.ts` adds rig-scoped services persistence.
- `021_seat_handover_observability.ts` adds seat-handover observability fields.
- `022_node_codex_config_profile.ts` adds Codex config-profile field on nodes.
- `023_stream_items.ts` through `027_outbox_entries.ts` add the PL-004 Phase A coordination tables (see § Coordination Layer below).
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Phase D extends the policy enum to four values (Phase C's three plus `workflow-keepalive`); see § Workflow Runtime below.
- `033_workflow_specs.ts`, `034_workflow_instances.ts`, `035_workflow_step_trails.ts` add the PL-004 Phase D Workflow Runtime tables. Migration `036_watchdog_policy_enum_extension.ts` is a documenting no-op that records the Phase D enum extension (Phase C uses application-layer enforcement via `PHASE_D_POLICIES` array, so no DDL is needed).
- `037_mission_control_actions.ts` adds the PL-005 Phase A Mission Control action audit table. Append-only at the API surface; records every operator action through Mission Control with before/after qitem snapshots for forensic reconstruction (see § Mission Control / Queue Observability below).
### Mission Control / Queue Observability (PL-005 Phase A)
Daemon-backed Mission Control surface. Per PRD acceptance criteria: seven views, seven verbs, first-class human seats, recent-ships=10, and a daemon-backed action audit table; no old-dashboard migration/cutover.
**Seven views** (`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`, `recently-active`, `recent-observations`) all return rows in the load-bearing 9-field phone-friendly content model (rig/mission name, current phase, active|idle|attention|blocked|degraded, next-action, pending-human-decision, read-cost, last-update timestamp, confidence/freshness, evidence link). The model is non-negotiable across all 7 views; UI may render compact, JSON preserves all 9.
`MissionControlReadLayer` maps each view to its source-of-truth path:
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004 Phase A `queue_items` via `QueueRepository`.
- `fleet` consumes the per-rig CLI capability cache + queue summary.
- `recently-active` delegates to PL-004 Phase B `ViewProjector.show("recently-active")`.
- `recent-observations` reads PL-004 Phase A `stream_items` via `StreamStore`.
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep) are graceful-degradation aids, NOT the primary path.
**Seven verbs** (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute through the load-bearing `MissionControlWriteContract`. Each verb is one atomic daemon transaction: queue mutation via `QueueRepository.updateWithinTransaction()` (which preserves Phase A hot-potato closure validation) + audit row in `mission_control_actions` + persisted `mission_control.action_executed` event, all in one `db.transaction`. The 4-step `handoff` shape (source-update + destination-create + opt-in best-effort notify + audit-record append) is verified atomic; notify failure does NOT roll back durable mutations (PRD invariant). Failure-injection rolls back source closure + audit row + new qitem together.
**Cross-CLI-version drift** is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance:
1. Per-field availability check (`MissionControlFleetCliCapability.probeRig`).
2. Per-rig capability honesty (each rig surfaces its own capabilities; mixed fleet rendered honestly).
3. Once-per-session-per-rig logging (`MissionControlFleetCliCapability` deduplicates drift events by `(rig, field)` until daemon restart).
4. Fleet-level "rigs running stale CLI" indicator (`FleetView` surfaces `staleCliCount` from `/api/mission-control/cli-capabilities`).
**Human seats are first-class product concepts**: the operator's default `human-operator@kernel` seat is rendered via `HumanSeatCard` showing identity, pending load, blocked count, and verb capabilities. NOT invisible config-layer convention.
**Mission Control is integrated product UI inside the existing shell** — a new top-level route `/mission-control` registered in `packages/ui/src/routes.tsx` mounting `MissionControlSurface`, NOT a new managed app. An earlier prototype that informed Mission Control's view/verb vocabulary was retired in PL-005 Phase B; git history preserves it as evidence.
PL-005 Phase B extends the daemon Mission Control surface with: a tailnet-bind bearer-token middleware (`packages/daemon/src/middleware/auth-bearer-token.ts` — constant-time comparison via `crypto.timingSafeEqual`; daemon refuses non-loopback bind when the bearer is empty; mounted on `POST /api/mission-control/action` and `POST /api/mission-control/notifications/test`); a notification dispatcher with two adapters (`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts` alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env; ntfy.sh recommended for the operator's phone via tailnet); a read-only audit-history browse layer (`audit-browse.ts`) over `mission_control_actions` exposed at `GET /api/mission-control/audit` with filters (`qitem_id`, `action_verb`, `actor_session`, `since`, `until`) and `(limit, before_id)` pagination cursored on SQLite `rowid` (deterministic insertion order; ULID action_id same-ms tail is non-deterministic). Two new RigEvent types (`mission_control.notification_sent`, `mission_control.notification_failed`) cover the notification audit trail. Phase B adds NO migrations — the audit table from Phase A migration `037_mission_control_actions` is the only data source. UI adds an `audit-history` tab to `MissionControlSurface` with filter UI + paginated result table; the existing 7 Phase A views and 7 verbs are unmodified.
PL-005 Phase A extends `RigEvent` with 3 new types: `mission_control.action_executed`, `mission_control.cli_drift_detected`, `mission_control.view_refreshed`. Existing 32 PL-004 events untouched. Localhost-only auth at v0 (matches existing dashboard posture; remote-bind is a separate slice).
### Workflow Runtime (PL-004 Phase D)
Daemon-native Workflow Runtime — declarative workflow specs, live instance state, append-only step trails, and the load-bearing transactional-scribe contract. The PRD § L4 operating model is owner-as-author semantically plus workflow-as-transactional-scribe mechanically.
`workflow_specs` is a read-through cache of human-authored markdown/YAML spec files. Sources are workspace-surface; the daemon caches by `(name, version)` with a content `source_hash` so that valid operator edits to the spec file win at next read (workspace-surface reconciliation). Spec authoring stays markdown-authoritative; `workflow_specs` exists for fast lookup and runtime resolution.
`workflow_instances` carries live state per running workflow: `status` (`active|waiting|completed|failed`), `current_frontier_json` (active qitem ids), `hop_count` (loop-guard counter), and `last_continuation_decision_json` (most recent close/continue decision). Instances survive daemon restart from SQLite — no filesystem reconciliation required.
`workflow_step_trails` is the append-only history of meaningful step transitions. Every closure produces one trail row pairing the prior qitem with the next qitem (null on terminal). Append-only at the API surface: `WorkflowStepTrailLog.record()` is the only writer.
The transactional-scribe contract — the load-bearing Phase D guarantee — is implemented in `WorkflowProjector.project()`. A single `db.transaction` holds: closing the current packet (state mutation on `queue_items`), creating the next-step packet (`QueueRepository.createWithinTransaction()`), recording the trail entry, updating the instance frontier + status, and persisting workflow events. Either everything commits or everything rolls back; lost handoffs are impossible by design. Post-commit, subscribers are notified and the next owner is nudged.
`workflow-keepalive` (the Phase C-deferred watchdog policy) reads `workflow_instances` directly via SQLite — never the markdown source. Eligible only when `status` is `active|waiting`; resolves frontier qitem owners by querying `queue_items`; combines with explicit observer/created_by targets; sends to the first resolved target.
Phase D extends `RigEvent` with: `workflow.instantiated`, `workflow.step_closed`, `workflow.next_qitem_projected`, `workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`. Existing 20 PL-004 events are unchanged.
Phase D scope intentionally excludes multi-hop chaining (graduation per PRD § Risks "Workflow runtime over-engineered"), gate-return-sweep, and the closure-enforcement path (the daemon's transactional state remains the closure authority via Phase A's hot-potato strict-rejection; workflow runtime PROJECTS on closure, does not GATE closure). Phase E (progress-rail) remains markdown-authoritative for v1.
### Coordination tables (PL-004 Phase A)
Five host-scoped tables back the coordination primitive:
**stream_items** (`023_stream_items.ts`)
- L1 — append-only intake/audit root.
- Columns: `stream_item_id` (ULID PK), `ts_emitted`, `stream_sort_key`, `source_session`, `body`, `format` (default `text`), `hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON), `interrupt`, `archived_at`.
- Items immutable after emit (only `archived_at` may be set).
**queue_items** (`024_queue_items.ts`)
- L3 — owned-work queue.
- Columns: `qitem_id` (TEXT PK preserving POC `qitem-YYYYMMDDHHMMSS-<hex>` shape), `ts_created`, `ts_updated`, `source_session`, `destination_session`, `state` (8-value enum), `priority`, `tier`, `tags` (JSON), `blocked_on`, `handed_off_to`, `handed_off_from`, `expires_at`, `chain_of_record` (JSON), `body`, `closure_reason`, `closure_target`, `closure_required_at`, `claimed_at`, `last_nudge_attempt`, `last_nudge_result`, `last_heartbeat`, `resolution`.
- State enum: `pending | in-progress | done | blocked | failed | denied | canceled | handed-off`.
**queue_transitions** (`025_queue_transitions.ts`)
- L3 — append-only transition log. Authoritative audit trail for queue state evolution. No UPDATE / DELETE from domain code.
- Columns: `transition_id` (AUTOINCREMENT PK), `qitem_id`, `ts`, `state`, `transition_note`, `actor_session`, `closure_reason`, `closure_target`.
**inbox_entries** (`026_inbox_entries.ts`)
- Mailbox-style asynchronous deposit; idempotent on `inbox_id`.
- Columns: `inbox_id` (PK), `destination_session`, `sender_session` (authenticated), `body`, `tags` (JSON), `urgency`, `ts_dropped`, `state` (`pending | absorbed | denied`), `absorbed_at`, `absorbed_qitem_id`, `denied_at`, `denied_reason`, `audit_pointer`.
**outbox_entries** (`027_outbox_entries.ts`)
- Sender-side audit; symmetric to inbox; idempotent on `outbox_id`.
- Columns: `outbox_id` (PK), `sender_session`, `destination_session`, `body`, `tags` (JSON), `urgency`, `ts_dispatched`, `delivery_state` (`pending | delivered | failed`), `delivered_at`, `audit_pointer`.
---
@@ -284,6 +452,17 @@ Migration boundary:
- Uses `version: "0.2"` and `pods[]`.
- Owns cross-pod `edges[]`, rig-level startup overlays, and `cultureFile`.
**RigServicesSpec**
- Optional `services` block on a pod-aware `RigSpec`.
- Current shipped service kind is Compose-backed env management with:
- `composeFile`
- `projectName?`
- `profiles?`
- `downPolicy?`
- `waitFor?`
- `surfaces?`
- `checkpoints?`
**RigSpecPod**
- Pod-local bounded context with `members[]`, pod-local `edges[]`, pod startup, and optional continuity policy.
@@ -329,13 +508,21 @@ Migration boundary:
- Pre-launch vs interactive delivery split: `guidance_merge`/`skill_install` happen before harness boot (filesystem); `send_text` happens after harness is ready (TUI).
- Persists replay context including resume token for future restores.
### Operator-layer types (North Star + Post-North-Star)
**EnvReceipt**
- RigEnv capture result persisted for service-backed rigs.
- Carries service status/health plus evaluated wait targets and capture timestamp.
**RigServicesRecord**
- Persisted database representation of a rig-scoped services substrate.
- Holds resolved compose path, persisted spec JSON, project name, and latest receipt JSON.
### Operator, identity, and communication
**NodeInventoryEntry**
- Universal node projection consumed by CLI/UI/MCP: rigId, rigName, logicalId, canonicalSessionName, podId, nodeKind (agent/infrastructure), runtime, sessionStatus, startupStatus, restoreOutcome, tmuxAttachCommand, resumeCommand, latestError.
**NodeDetailEntry**
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus Final Stretch 1 live-identity fields:
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus live-identity fields:
- `peers`
- directional `edges`
- transcript helpers/path
@@ -396,7 +583,7 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
- `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, including topology preview data and provenance state
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data, provenance state, and managed-app services metadata (`waitFor`, `surfaces`, `composePreview`)
### Resolution pipeline
@@ -415,6 +602,15 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
- `rigspec-exporter.ts`: dual-format live rig export back to YAML/JSON
- `pod-repository.ts`: pod CRUD plus live continuity-state CRUD
### Rig environment and agent-managed software
- `compose-services-adapter.ts`: thin Compose-backed adapter for `up`, `ps`, `logs`, and teardown
- `services-readiness.ts`: shared wait-target evaluator for `url`, `tcp`, and `service + condition: healthy`
- `service-orchestrator.ts`: rig-scoped env boot, receipt capture, readiness gating, persisted receipt updates, and teardown
- `rig-repository.ts`: now persists `rig_services` rows and surfaces `hasServices` in rig summaries for runtime/UI gating
- `spec-library-service.ts`: classifies builtin/user specs and marks service-backed rigs with `hasServices`
- `spec-review-service.ts`: lifts services metadata into review models; route layer enriches with compose preview
### Runtime adapters
Three runtime adapters implement the five-method contract: projection, startup delivery, harness launch, readiness, and installed-resource listing.
@@ -429,18 +625,20 @@ Three runtime adapters implement the five-method contract: projection, startup d
- launches via `codex`, resumes via `codex resume <threadId>`
- readiness probe: polls for Codex ready indicator
**TerminalAdapter** (North Star)
**TerminalAdapter**
- no-op project/deliver/launch (shell IS the harness)
- immediate readiness (shell is ready as soon as tmux session exists)
- for infrastructure nodes: servers, log tails, build watchers
### Node inventory and operator surfaces (North Star)
### Node inventory and operator surfaces
- `node-inventory.ts`: universal node-level projection. The single source of truth for node state consumed by CLI (`ps --nodes`), UI (explorer + graph + drawer), and MCP (`rigged_rig_nodes`). Core + extended field tiers.
- `demo-rig-selector.ts`: existing-rig power-on helper — finds the right rig by name from ps summary
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows
- `context-usage-store.ts`: SQLite + state-dir persistence for per-node context usage
- `context-monitor.ts`: daemon-owned monitor that records context usage for runtime/operator surfaces
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows, now with context-usage access
### Transport and communication (Post-North-Star)
### Transport and communication
- `session-transport.ts`: communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection, honest error reporting, pod/rig/global targeting
- `transcript-store.ts`: pipe-pane transcript management — ANSI stripping on read, boundary markers, readTail, grep. Filesystem-backed, not SQLite.
@@ -448,29 +646,40 @@ Three runtime adapters implement the five-method contract: projection, startup d
- `ask-service.ts`: context engineering evidence pack — gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance. Does NOT call an external LLM.
- `chat-repository.ts`: durable rig-scoped chat — CRUD for chat_messages table, SSE-compatible event emission
### Resume honesty (North Star)
### Coordination primitive (PL-004 Phase A)
Six host-scoped services implementing the SQLite-canonical coordination layer. POC `rigx queue` / `rigx stream` filesystem path remains untouched; daemon `rig queue` / `rig stream` writes only to SQLite. Routes import these; services are Hono-free.
- `stream-store.ts`: L1 stream — idempotent emit (on `stream_item_id`), chronological list with cursor pagination, archive (soft).
- `queue-repository.ts`: L3 queue — create, claim/unclaim, update (general state mutator with hot-potato strict-rejection on `done`), transactional handoff (close source as `handed-off` plus create new owned qitem, single transaction), pod-fallback rerouting, overdue lookup, nudge/heartbeat tracking. Cross-rig validation hook exposed as `validateRig` constructor option.
- `queue-transition-log.ts`: append-only state transition log. Used by `queue-repository.ts` and exposed read-only on `queue_repository.transitionLog`.
- `hot-potato-enforcer.ts`: pure validator for the load-bearing API contract — `state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation}`; `handed_off_to | blocked_on | escalation` additionally require `closure_target`. Tier→SLA mapping for `closure_required_at` lives here.
- `inbox-handler.ts`: mailbox handler — authenticated drop (idempotent on `inbox_id`), absorb (promotes pending entry to a queue_item, idempotent), deny (records reason). Auth check is a pluggable constructor hook.
- `outbox-handler.ts`: sender-side outbox; idempotent record, mark delivered/failed, list. No event-bus events emitted (pure audit).
### Resume honesty
- `native-resume-probe.ts`: honest assessment of whether a harness actually resumed vs fresh-launched — probes pane content for runtime-specific indicators
- `resume-metadata-refresher.ts`: post-launch resume token capture — reads Claude conversation IDs from `.claude/` state, Codex thread IDs from SQLite state files
- `codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite database
### Discovery extensions (North Star)
### Discovery extensions
- `draft-rig-generator.ts`: synthesize a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs
- `claim-service.ts`: adopted-session integration service — claim/bind/create-and-bind flows, tmux metadata writes, and post-claim onboarding hint
### Final Stretch 1 authoring and library surfaces
### Authoring, library, and identity surfaces
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, and reusable display components
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review and `hasServices`
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, reusable display components, and managed-app env details
- `whoami-service.ts`: daemon-side identity surface with peers/edges/transcript helpers
### Snapshot, restore, and continuity
- `checkpoint-store.ts`: checkpoint persistence with pod/continuity context
- `snapshot-capture.ts`: captures pods, continuity state, and startup replay context
- `snapshot-capture.ts`: captures pods, continuity state, startup replay context, and latest env receipt
- `snapshot-repository.ts`: snapshot CRUD
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, and topology ordering
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, topology ordering, and RigEnv boot gating before agent restore
### Bundles, bootstrap, and legacy compatibility
@@ -551,14 +760,14 @@ Restore now:
- restore states: `resumed` / `rebuilt` / `fresh` / `failed`
- failed resume is FAILED loudly — no automatic fresh fallback
### Auto-snapshot + existing-rig power-on (North Star)
### Auto-snapshot and existing-rig power-on
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before teardown
- `rig up <rig-name>` (no file extension) searches for existing rig by name, restores from latest auto-pre-down snapshot
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot from a spec or bundle path.")
- Post-command handoff: down output includes snapshot ID + restore command; up output includes node statuses + attach command
### Communication flow (Post-North-Star)
### Communication flow
`rig send <session> "message"` → CLI → `POST /api/transport/send` → `SessionTransport`:
1. Resolve session name (canonical or legacy, by session/rig/pod/global)
@@ -567,7 +776,7 @@ Restore now:
4. Optional `--verify`: capture post-send pane, check message visibility
5. Honest result with reason on failure
### Transcript flow (Post-North-Star)
### Transcript flow
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation (before harness boot)
2. Raw terminal output streams to `~/.openrig/transcripts/{rig-name}/{session-name}.log`
@@ -576,7 +785,7 @@ Restore now:
5. On restore: boundary marker written before re-launch. Pipe-pane reconnects to same file (append).
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance
### Chat flow (Post-North-Star)
### Chat flow
1. `rig chatroom send <rig> "message"` → `POST /api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`
2. SSE stream: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages
@@ -585,19 +794,19 @@ Restore now:
5. MCP: `rigged_chatroom_send` + `rigged_chatroom_watch`
6. Source of truth: daemon-backed SQLite (`chat_messages` table), not tmux scrollback
### Config + preflight flow (Post-North-Star)
### Config and preflight flow
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. The original runtime env keys retain deprecated `RIGGED_*` aliases for upgrade compatibility: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`. New typed config keys use `OPENRIG_*` only.
- Precedence: CLI flag > env var > config file > default
- `rig preflight` checks: Node.js ≥ 20, tmux available, writable home/db/transcript dirs, port available
- Auto-preflight runs on `rig up` and daemon start
- Every preflight error: what failed + why it matters + what to do (3-part pattern)
### Discovery-to-draft-rig flow (North Star)
### Discovery-to-draft-rig flow
`rig discover --draft` → scan tmux sessions → group by CWD → suggest pod structure → generate candidate RigSpec YAML → output to stdout or file
### Spec review and spec library flow (Final Stretch 1)
### Spec review and spec library flow
1. Raw YAML preview:
- UI/CLI posts YAML to `/api/specs/review/rig` or `/api/specs/review/agent`
@@ -606,12 +815,18 @@ Restore now:
2. Filesystem-backed library:
- `SpecLibraryService` scans builtin + user roots (`packages/daemon/specs`, `~/.openrig/specs`) with legacy fallback from `~/.rigged/specs`
- each YAML file is classified via structured review
- service-backed rigs are marked `hasServices`
- `/api/specs/library` serves list/get/review/sync
3. CLI:
- `rig specs ls/show/preview/add/sync`
- `rig specs add` installs either a single YAML spec or a full spec directory containing `rig.yaml` or `agent.yaml`
- `rig up` / `rig bootstrap` resolve library names before falling back to other source kinds
4. UI:
- `Specs` drawer presents a unified library list with `All / Apps / Rigs / Agents`
- service-backed entries render as `APP`
- library review shows specialist identity, setup prompt, and environment details
### Whoami and adopted-session parity flow (Final Stretch 1)
### Whoami and adopted-session parity flow
1. Managed sessions still prefer projected `OPENRIG_NODE_ID` / `OPENRIG_SESSION_NAME`.
2. Adopted sessions use tmux-owned metadata written at claim/bind time:
@@ -628,7 +843,7 @@ Restore now:
- raw tmux session-name fallback
4. The daemon owns the truth surface through `/api/whoami`; tmux metadata is an adopted-session anchor, not sovereign truth.
### Materialize / bind / adopt flow (Final Stretch 1)
### Materialize / bind / adopt flow
1. `POST /api/rigs/import/materialize` creates a pod-aware topology without launching sessions.
2. `POST /api/discovery/:id/bind` attaches a discovered live session to an existing logical node.
@@ -640,7 +855,7 @@ Restore now:
- `rig adopt`
5. Authored pod namespace is preserved through adoption so logical ids stay `${podNamespace}.${memberName}`.
### Live identity / specs UI flow (Final Stretch 1)
### Live identity / specs UI flow
1. Explorer/graph selection opens the shared right drawer.
2. Node drawer stays runtime-first and now includes:
@@ -656,6 +871,48 @@ Restore now:
- live full-details surfaces
5. Graph nodes carry lightweight hover/runtime hints without replacing drawer-first operation.
### Rig environment flow
1. Rig instantiation persists a `rig_services` record when a pod-aware `RigSpec` has a `services` block.
2. `ServiceOrchestrator.boot(...)` runs before agent launch/restore:
- Compose `up`
- evaluate `waitFor`
- persist `EnvReceipt`
- fail honestly on timeout or unhealthy waits
3. `GET /api/rigs/:rigId/env` returns live status:
- `hasServices: false` for non-service rigs
- otherwise kind, compose identity, receipt, and surfaces
4. `rig env status/logs/down` and the rig drawer `Env` tab are thin client surfaces over those routes.
5. Snapshot/restore preserves the latest env receipt and reboots services before agent restore.
6. **Current runtime truth model:** Runtime env truth is provided through boot-time gating (services must be healthy before agents launch) and on-demand inspection via `rig env` / the `Env` tab, which polls with honest `probeStatus` (fresh/stale/no_orchestrator). Background health monitoring and `rig ps` env health summary are deferred.
### Agent-managed app flow
1. A managed app is a service-backed rig plus a specialist agent.
2. The canonical shipped example is:
- rig: `secrets-manager`
- pod: `vault`
- member: `specialist`
- session: `vault-specialist@secrets-manager`
3. Specs/UI flow:
- browse `secrets-manager` under `Apps`
- open library review
- inspect specialist identity, env stack, health gates, and surfaces
- copy the setup prompt
4. Runtime flow:
- launch the rig
- services boot before `vault.specialist`
- the rig drawer `Env` tab exposes state, health, surfaces, logs, and teardown
### Specialist interaction flow
1. Other humans or agents address a specialist by session name, e.g. `vault-specialist@secrets-manager`.
2. Cross-rig communication uses the existing transport seam:
- `rig send vault-specialist@secrets-manager "..." --verify`
3. No new routing primitive exists here:
- delegation is currently conventional/social
- session transport remains the transport truth
---
## 7. Architecture Rules
@@ -727,6 +984,13 @@ Present in the union but not yet emitted by production code:
- `pod.created`, `pod.deleted`
- `continuity.sync`, `continuity.degraded`
PL-004 Phase A adds 9 coordination events emitted by domain services:
- `stream.emitted` (StreamStore.emit)
- `queue.created`, `queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue` (QueueRepository)
- `inbox.absorbed`, `inbox.denied` (InboxHandler)
Two SSE surfaces stream coordination events: `/api/stream/watch` for new stream items, `/api/queue/watch` for queue/inbox events.
The event log remains append-only and SQLite-backed. The global SSE stream (`/api/events`) delivers all events; the chat SSE stream (`/api/rigs/:rigId/chat/watch`) delivers chat messages for one rig.
---
@@ -735,7 +999,7 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
`createDaemon()` now does the following:
1. Open SQLite and run all 17 migrations.
1. Open SQLite and run all 27 migrations (22 existing plus the 5 PL-004 Phase A coordination tables).
2. Construct core repositories and legacy services.
3. Construct package/bootstrap/discovery services.
4. Construct rebooted startup/runtime services:
@@ -743,19 +1007,25 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
- `ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`
- `PodRigInstantiator`
- `PodBundleSourceResolver`
5. Construct North Star + Post-North-Star services:
5. Construct rig environment services:
- `ComposeServicesAdapter`
- `ServiceOrchestrator`
6. Construct operator, transport, and history services:
- `TranscriptStore` (with config from env for transcript path/enabled)
- `SessionTransport`
- `ChatRepository`
- `AskService` (with `HistoryQuery`)
- `ResumeMetadataRefresher`
- `NodeInventory`
6. Construct Final Stretch 1 services:
- `ResumeMetadataRefresher`
- `ContextUsageStore`
- `ContextMonitor`
- `NodeInventory`
7. Construct authoring, identity, and managed-app services:
- `SpecReviewService`
- `SpecLibraryService` (builtin + user roots)
- `WhoamiService`
7. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
8. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including specs review/library and `whoami`.
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
9. Construct PL-004 Phase A coordination services from a shared `QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue` write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`, `OutboxHandler`.
10. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including env, specs review/library, `whoami`, plus the PL-004 coordination routes (`/api/stream`, `/api/queue`).
---
@@ -763,25 +1033,18 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
### Test footprint
- daemon: 112 Vitest files / 1,461 tests
- CLI: 29 Vitest files / 275 tests
- UI: 35 Vitest files / 364 tests
- total: 176 files / 2,100 tests
- daemon: 127 Vitest files / 1,668 tests
- CLI: 37 Vitest files / 366 tests
- UI: 37 Vitest files / 388 tests
- total: 201 files / 2,422 tests
### Verified during this doc refresh
- daemon: `1461/1461` passing
- CLI: `275/275` passing
- UI: `361/364` passing
- daemon: `1668/1668` passing
- CLI: `366/366` passing
- UI: `388/388` passing
Current known UI failures during this refresh:
- `design-compliance.test.tsx`
- non-zero border-radius count still above zero
- stale `StatusBar.tsx` file assumption in the mono/data-display assertion
- `tailwind-foundation.test.tsx`
- missing `.bg-card` assertion in generated CSS
### Post-North-Star test suites
### Communication, identity, and authoring suites
In addition to reboot-era coverage:
@@ -804,9 +1067,23 @@ In addition to reboot-era coverage:
- `adopt.test.ts` — CLI materialize/bind adoption flow
- UI tests for spec review displays, spec library review, specs panel, live node details, and adopted-session node identity
### Rig environment and agent-managed software suites
- `rigspec-services.test.ts` — RigSpec `services` schema and parsing
- `service-orchestrator.test.ts` — Compose-backed env boot, readiness, receipt capture, teardown
- `services-up.test.ts` — end-to-end service-backed rig boot
- `services-snapshot-restore.test.ts` — env receipt capture + restore gating
- `env-routes.test.ts` — runtime env status/logs/down route contracts
- `spec-library-starters.test.ts` — builtin `secrets-manager` + `vault-specialist` presence, topology, and `hasServices`
- `spec-review-service.test.ts` / `spec-library-routes.test.ts` — managed-app services review, wait-target contract, compose preview
- CLI `env.test.ts` — env status/logs/down command surface
- UI `specs-panel.test.tsx` — `All / Apps / Rigs / Agents`, type/stability badges
- UI `library-review.test.tsx` — specialist card, setup prompt, managed-app environment tab
- UI `rig-detail-panel.test.tsx` — runtime `Env` tab and env-state rendering
### Dogfood status
Post-North-Star + Final Stretch 1 dogfood verified:
Core operator, communication, identity, and library flows verified:
- Full product loop: `rig up demo/rig.yaml` → harnesses launch → `rig ps --nodes` → `rig down` → `rig up demo-rig` (restore by name) → agents resume
- Communication: `rig send`, `rig capture`, `rig broadcast` across running rig
@@ -820,6 +1097,25 @@ Post-North-Star + Final Stretch 1 dogfood verified:
- Adoption: materialize + bind/adopt flows from discovery/specs workflows
- MCP: all 17 tools verified
Rig environment and agent-managed software flows additionally verified:
- Managed-app browse flow in the UI:
- `Specs` → `Apps` → `secrets-manager`
- managed-app review with `vault.specialist`
- `secrets-manager` launch as a real service-backed rig with a specialist agent
- Real Vault operations through `vault-specialist@secrets-manager`
- health check
- write secret
- read secret back
- Cross-rig direct messaging to `vault-specialist@secrets-manager`
- Runtime `Env` tab:
- overall state
- health gates
- surfaces
- logs
- explicit teardown
- Failure honesty when Vault is stopped mid-rig
---
## 11. Remaining Compatibility Notes
@@ -834,6 +1130,9 @@ These are the main intentional limits that still describe the shipped system:
6. Chat is rig-scoped only — no cross-rig channels or DMs.
7. `--verify` on `rig send` checks pane content for message visibility but can produce false positives from pre-existing matching content. Known limitation.
8. Terminal node readiness is shell-ready only — no service health probes.
9. `rig env down --volumes` exists in the CLI surface, but the explicit daemon-side override is not fully plumbed through yet.
10. Managed-app service surfaces are descriptive only — OpenRig does not automatically inject service URLs/tokens into agent prompts beyond authored startup/context files.
11. Specialist delegation is conventional, not automatic — other agents/humans address the specialist by session name or other normal communication surfaces.
---
@@ -842,4 +1141,4 @@ These are the main intentional limits that still describe the shipped system:
This document is the architecture-level source of truth.
For file-by-file structure across daemon, CLI, and UI, use:
- [codemap.md](/Users/mschwarz/code/rigged/docs/as-built/codemap.md)
- [codemap.md](./codemap.md)
+462 -21
View File
@@ -1,6 +1,6 @@
# OpenRig CLI Reference
Verified against the shipped CLI on 2026-04-07 using:
Verified against the shipped CLI on 2026-05-10 (v0.3.0) using:
- `packages/cli/src/index.ts`
- `packages/cli/src/commands/*.ts`
- `packages/cli/src/mcp-server.ts`
@@ -11,9 +11,10 @@ This document reflects the current `rig` surface as shipped. Where live help tex
## Overview
- Binary: `rig`
- Top-level command groups: `36`
- Top-level command groups: `56`
- Output mode: human-readable by default; many commands also support `--json`
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
- Legacy surface still shipped: `package`
## Top-Level Commands
@@ -31,11 +32,13 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `bootstrap` | Bootstrap a rig from a spec file |
| `requirements` | Check requirements for a rig spec |
| `discover` | Scan for unmanaged tmux sessions |
| `attach` | Attach the current shell or agent into a rig node |
| `bind` | Bind a discovered session to a rig node |
| `adopt` | Materialize topology and bind discovered live sessions |
| `bundle` | Manage rig bundles |
| `up` | Bootstrap a rig from a spec or bundle |
| `down` | Tear down a rig |
| `env` | Inspect and control rig environment services |
| `ps` | List rigs and their status |
| `mcp` | MCP server for agent integration |
| `agent` | Manage agent specs |
@@ -51,11 +54,29 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `config` | Inspect and change OpenRig configuration |
| `preflight` | Check system readiness for OpenRig |
| `doctor` | Verify OpenRig install health |
| `destroy` | Destroy OpenRig local state for recovery |
| `expand` | Add a pod to a running rig |
| `unclaim` | Release an adopted session without killing tmux |
| `release` | Release claimed sessions from a rig |
| `launch` | Launch or relaunch a node in a running rig |
| `remove` | Remove a node from a running rig |
| `shrink` | Remove an entire pod from a running rig |
| `setup` | Prepare the machine for OpenRig |
| `stream` | Coordination L1 — append-only intake stream |
| `queue` | Coordination L3 — owned-work queue + inbox/outbox |
| `project` | Coordination L2 — agent-backed classifier with daemon-enforced lease + idempotency + reclaim |
| `view` | Coordination L5 — daemon-backed views over coordination state |
| `watchdog` | Coordination Watchdog — daemon-native scheduler |
| `workflow` | Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection |
| `restore-packet` | Generate, read, and validate cross-runtime restore packets |
| `restore-check` | Check restore readiness across running rigs |
| `context` | Show context-usage across running agents |
| `compact-plan` | Plan Claude compact-in-place candidates without compacting anything |
| `heartbeat` | Show workflow execution proof state from queue files |
| `seat` | Inspect OpenRig seat observability state |
| `agent-image` | Browse, snapshot, and manage agent images |
| `context-pack` | Browse, preview, send, and install operator-authored context packs |
| `workspace` | Workspace primitive — typed-kind tooling (frontmatter validation) |
## Core Daemon and System Commands
@@ -92,10 +113,11 @@ Subcommands:
### `rig config`
Usage:
- `rig config [--json]`
- `rig config get <key>`
- `rig config [--json] [--with-source]`
- `rig config get <key> [--show-source]`
- `rig config set <key> <value>`
- `rig config reset`
- `rig config reset [<key>]`
- `rig config init-workspace [--root <path>] [--force] [--dry-run] [--json]`
Supported keys:
- `daemon.port`
@@ -103,6 +125,7 @@ Supported keys:
- `db.path`
- `transcripts.enabled`
- `transcripts.path`
- `workspace.root` (and other workspace-rooted paths used by `init-workspace`)
Precedence:
- CLI flag
@@ -110,6 +133,13 @@ Precedence:
- config file
- default
Notes:
- `--with-source` (top-level) and `--show-source` (`get`) report per-key source/default for honest provenance.
- `init-workspace` scaffolds the default workspace at `~/.openrig/workspace/` (or the `--root` override) with mission/slice folders. `--force` overwrites scaffolded files (does NOT remove directories); `--dry-run` previews without writing. New in v0.3.0.
Legacy env compatibility: the original runtime keys still accept deprecated
`RIGGED_*` aliases. New typed config keys use `OPENRIG_*` only.
### `rig preflight`
Usage: `rig preflight [--json]`
@@ -124,7 +154,24 @@ Usage: `rig doctor [--json]`
Notes:
- Verifies install health for packaged/local CLI usage.
- Checks daemon dist, UI dist, Node version, tmux availability, writable state paths, and daemon port availability.
- Checks daemon dist, UI dist, Node version, `tmux`, optional `cmux` control health, writable state paths, and daemon port availability.
- On macOS, also warns when tmux mouse mode appears disabled, gives the current-server fix (`tmux set -g mouse on`), and points to the persistent fix in `~/.tmux.conf`.
- `cmux` issues are warnings, not hard failures. OpenRig still works without `cmux`; only `Open CMUX` workflows are unavailable.
- `--json` is suitable for agent use and only exits non-zero on real failures, not warnings.
### `rig destroy`
Usage:
- `rig destroy --state [--backup] --yes --confirm destroy-openrig-state`
- `rig destroy --all [--backup] --yes --confirm destroy-openrig-state`
Notes:
- This is the destructive recovery surface for polluted local OpenRig state.
- `--state` stops the daemon, clears the active OpenRig listener on the configured port if needed, rotates or deletes the effective state root, and recreates an empty state root.
- `--all` includes `--state` plus managed tmux session cleanup for sessions that are discoverable from the current OpenRig database.
- `--backup` moves the state root aside to a collision-safe timestamped path such as `~/.openrig.backup-YYYYMMDD-HHMMSS`.
- Managed tmux cleanup is intentionally conservative. It only removes sessions that are present in current DB state; unrelated tmux sessions are left alone.
- Human output prints a compact destroy plan followed by the destroy result.
### `rig mcp`
@@ -159,7 +206,10 @@ Shipped MCP tools:
Usage: `rig bootstrap <spec> [--plan] [--yes] [--json]`
Arguments:
- `spec`: path to a rig spec YAML file
- `spec`: path to a rig spec YAML file or a library name
Notes:
- Bare names resolve through the spec library before falling back to the raw source value.
### `rig requirements`
@@ -168,6 +218,10 @@ Usage: `rig requirements <spec> [--json]`
Arguments:
- `spec`: path to a rig spec YAML file
Notes:
- `rig requirements` is the spec/app-specific dependency surface.
- Use `rig doctor` for host-level install health, then `rig requirements <spec>` for rig-specific requirements.
### `rig up`
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
@@ -187,6 +241,7 @@ Current behavior notes:
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
Success modes:
- fresh boot
@@ -205,19 +260,42 @@ Flags:
Notes:
- When `--snapshot` succeeds, human output includes the restore command.
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
- v0.2.0 known issue: `rig down <name> --delete` can return HTTP 404; use
`rig ps` to find the rig ID and run `rig down <rigId> --delete`.
### `rig env`
Usage:
- `rig env status <rig> [--json]`
- `rig env logs <rig> [service] [--tail <n>]`
- `rig env down <rig> [--volumes]`
Notes:
- This surface is only meaningful for service-backed rigs and managed apps.
- `status` resolves rig names or IDs and returns the env receipt with an honest freshness probe. The response includes `probeStatus` (fresh/stale/no_orchestrator) so operators can distinguish current truth from cached state.
- `logs` proxies compose-backed service logs; `[service]` is optional.
- `down` tears down the rig environment. `--volumes` overrides the stored down policy to force volume removal via `docker compose down --volumes`.
- Note: `rig ps` does not yet surface env health. Runtime env truth is available through `rig env status` and the rig drawer `Env` tab.
### `rig ps`
Usage:
- `rig ps [--json]`
- `rig ps --nodes [--json]`
- `rig ps [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--host <id>]`
Notes:
- `rig ps` lists rig summaries.
- `rig ps --nodes` expands into a cross-rig node inventory including session name, runtime, startup status, restore outcome, attach command, resume command, and latest error.
- Exit codes are defined in help:
- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`.
- `rig ps --nodes` expands into a cross-rig node inventory. Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command.
- JSON output for both rig and node tiers includes a `rigName` alias (equal to `name`) for forward compatibility; agent code should prefer `rigName`. Default `--json` is a bare array (back-compat); the envelope shape `{entries, totalRigs|totalNodes, truncated, hint?}` is only used when `--limit`, `--fields`, `--summary`, or `--filter` is set.
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
- `--filter <key=value>` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do).
- `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
- Exit codes:
- `0` success
- `1` daemon not running
- `1` daemon not running, or invalid `--filter` / `--limit`
- `2` daemon fetch failure
### `rig snapshot`
@@ -240,6 +318,29 @@ Notes:
- Human output prints each restored node and any failed node error.
- Non-zero exit if any restored node fails.
### `rig restore-check`
Usage: `rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`
Notes:
- Checks restore readiness across running rigs (or one rig with `--rig`).
- `--no-queue` skips queue file checks; `--no-hooks` skips hook checks.
- Exit codes: `0` restorable (or restorable with caveats), `1` not restorable (red blockers found), `2` unknown / probe error.
### `rig restore-packet`
Usage: `rig restore-packet <subcommand>`
Subcommands:
- `write [options]` — generate a restore packet from a source session or JSONL file.
- `read <packet-dir> [--json]` — render a restore packet's contents (human or JSON).
- `validate <packet-dir> [--json]` — validate a restore packet against the v0 schema.
Notes:
- Packet shape is the cross-runtime v0 standard (Claude Code and Codex transcripts both supported via runtime parsers + redaction).
- `write` emits a packet directory with the canonical schema files plus `omitted-records` accounting.
- `read` and `validate` operate on existing packet directories and do not mutate them.
### `rig export`
Usage: `rig export <rigId> [-o|--output <path>]`
@@ -312,9 +413,11 @@ Subcommands:
- `rename <name-or-id> <new-name> [--json]`
Notes:
- `specs` is the library surface.
- `specs` is the library surface for rigs, agents, and managed apps.
- `preview` returns structured review data from the daemon.
- `add` validates the file as either RigSpec or AgentSpec before copying it into the user library.
- `add` accepts either a YAML spec file or a full spec directory containing `rig.yaml` or `agent.yaml`.
- Directory adds copy the whole tree into the user library so adjacent agents, guidance, skills, and docs remain available.
- `preview secrets-manager` is the canonical managed-app review example.
## Discovery and Topology Mutation
@@ -326,6 +429,18 @@ Notes:
- Scans unmanaged tmux sessions.
- `--draft` generates a candidate rig spec from the discovery set.
### `rig attach`
Usage:
- `rig attach --self --rig <rigId> --node <logicalId> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
- `rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
Notes:
- `--self` is currently required.
- Node attach and pod-create attach are exclusive modes.
- In tmux-backed shells, the command records tmux attachment metadata; otherwise it records an `external_cli` attachment.
- `--print-env` prints shell exports for `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME`.
### `rig bind`
Usage: `rig bind <discoveredId> --rig <rigId> (--node <logicalId> | --pod <namespace> --member <name>)`
@@ -356,6 +471,52 @@ Usage: `rig expand <rig-id> <pod-fragment-path> [--json] [--rig-root <path>]`
Notes:
- Adds a pod fragment to a running rig.
- `--rig-root` controls agent resolution.
- Member YAML may carry `session_source` (see "Session source declaration" below) to start the new seat from a prior native conversation (`mode: fork`) or from operator-declared artifacts (`mode: rebuild`).
### Session source declaration (`session_source`)
Member YAML in a rig spec or `rig expand` payload may declare a launch-time `session_source` to control how the new managed seat derives its starting context. Two modes are supported in v1:
```yaml
# Fork from a prior native runtime conversation. Captures and persists a NEW
# post-fork token; the parent token is NEVER persisted onto the new seat.
members:
- id: reviewer-2
runtime: claude-code # or "codex"; not valid on terminal
session_source:
mode: fork
ref:
kind: native_id # v1 fork mode supports "native_id" only
value: "0b0165d7-cb4d-4650-90de-15c0a1ede9e6"
```
```yaml
# Rebuild from operator-declared artifacts (CULTURE, role doc, handover packet,
# queue files, session logs). Fresh-launches the harness and seeds the running
# TUI with the artifacts in the operator-declared trust-precedence order.
# The seat's continuityOutcome is `rebuilt` (NEVER `fresh`/`resumed`/`forked`)
# and NO `resumeToken` is persisted.
members:
- id: writer-2
runtime: claude-code # or "codex"; not valid on terminal
session_source:
mode: rebuild
ref:
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
value: # ordered list, highest-trust first
- <substrate-shared-docs>/rigs/<rig>/CULTURE.md
- <substrate-shared-docs>/specs/agents/<role>.md
- /path/to/handover-packet.md
- /path/to/state/<pod>/<member>.queue.md
- /path/to/state/<pod>/shared.session.log
- /path/to/state/<pod>/<member>.session.log
```
Notes:
- `terminal` runtime rejects `session_source` (no native fork primitive; no agent context to rebuild).
- `mode: fork` requires `ref.kind: native_id` and a non-empty `ref.value` string. Other ref kinds (`artifact_path`, `name`, `last`) are reserved shapes for follow-up slices and are refused in v1 fork mode.
- `mode: rebuild` requires `ref.kind: artifact_set` and a non-empty `ref.value` array of paths. Missing paths are recorded as gaps and the launch proceeds with what resolved; if NO declared paths resolve, the launch fails with a clear error.
- The two modes are mutually exclusive on a given member; mixing is a schema error.
### `rig unclaim`
@@ -364,6 +525,15 @@ Usage: `rig unclaim <sessionRef> [--json]`
Notes:
- Releases an adopted session without killing its tmux session.
### `rig release`
Usage: `rig release <rigId> [--delete] [--json]`
Notes:
- Releases all claimed/adopted sessions from a rig without killing their tmux sessions.
- `--delete` removes the rig record after a clean release.
- OpenRig-launched nodes still require `rig down`.
### `rig launch`
Usage: `rig launch <rigId> <nodeRef> [--json]`
@@ -391,7 +561,7 @@ Notes:
### `rig whoami`
Usage: `rig whoami [--node-id <id>] [--session <name>] [--json]`
Usage: `rig whoami [--node-id <id>] [--session <name>] [--host <id>] [--json]`
Identity resolution order:
1. `--node-id`
@@ -406,6 +576,7 @@ Notes:
- If the daemon is unreachable but an identity source can still be resolved, `--json` returns a partial result instead of crashing.
- Human-readable output includes transcript location and context usage when available.
- In Claude Code projects, unattended `rig whoami` on boot may require the local permissions allow list to include `Bash(rig:*)`.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Identity resolution happens on the REMOTE rig (each host has its own daemon + tmux + identity context); local `--node-id`/`--session` flags are forwarded to the remote `rig whoami` invocation. The remote rig's output is verbatim passthrough on success; failure is distinguished into the same `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` enum as `rig ps --host` and `rig send --host`.
### `rig transcript`
@@ -420,23 +591,68 @@ Notes:
### `rig send`
Usage: `rig send <session> <text> [--verify] [--force] [--json]`
Usage: `rig send <session> <text> [--verify] [--force] [--wait-for-idle <s>] [--host <id>] [--json]`
Notes:
- Uses the two-step send pattern automatically: paste text, wait, submit Enter.
- `--verify` requests delivery verification.
- `--force` overrides mid-task safety checks.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below. SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim.
### `rig capture`
Usage:
- `rig capture <session> [--lines <n>] [--json]`
- `rig capture --rig <name> [--lines <n>] [--json]`
- `rig capture --pod <name> --rig <name> [--lines <n>] [--json]`
- `rig capture <session> [--lines <n>] [--host <id>] [--json]`
- `rig capture --rig <name> [--lines <n>] [--host <id>] [--json]`
- `rig capture --pod <name> --rig <name> [--lines <n>] [--host <id>] [--json]`
Default:
- `--lines 20`
Notes:
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below.
### Cross-host execution (`--host <id>`)
`rig send` and `rig capture` accept a `--host <id>` flag that routes the
command to a remote host over single-hop SSH. v0 is CLI-side shell-out
only — the local daemon is not involved in routing, and there is no
daemon-to-daemon networking. The remote host is expected to have its own
managed `rig` available on `$PATH`.
Hosts are declared by the operator in `~/.openrig/hosts.yaml`:
```yaml
hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: wrandom # optional
notes: "Tart VM" # optional
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: wrandom
```
Validation rules:
- `hosts` is required and must be a non-null array.
- Each entry: `id` required (non-empty, unique), `transport` required (v0 supports `ssh` only), `target` required (non-empty — DNS name, SSH config alias, or IP).
- `user` and `notes` are optional.
- The file is operator-managed; v0 does NOT include any sub-command to add, remove, or list hosts (operators edit the YAML directly).
- A missing or invalid file returns a clear error pointing at the canonical path.
The CLI distinguishes four structured failure modes (operators get an
actionable error per mode; JSON output preserves the `failedStep` enum):
- `ssh-unreachable` — SSH itself failed (connection refused, host key mismatch, etc.). Verify SSH access and the registry entry.
- `permission-gate` — SSH hit an auth/permission gate (Permission denied, Keychain). The error includes a hint to the keychain-over-SSH field note.
- `remote-daemon-unreachable` — SSH succeeded but the remote `rig` reported the remote daemon was not reachable. Start it with `ssh <target> rig daemon start`.
- `remote-command-failed` — SSH succeeded but the remote `rig` exited non-zero for some other reason; the remote stderr is surfaced.
Out of scope for v0: non-SSH transports; `--host` on `rig ps` / `rig whoami` / other commands; connection pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
### `rig broadcast`
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--json]`
@@ -481,12 +697,237 @@ Notes:
- `clear` is destructive and rig-scoped. Removes all messages for that rig.
- `watch --tmux` starts a dedicated tmux watcher session.
## Coordination Primitive (PL-004 Phase A)
Two top-level commands back the SQLite-canonical coordination layer. They speak only to the daemon HTTP API; they do NOT touch the POC `rigx-stream-proto` / `rigx-queue-proto` filesystem state. POC and daemon coexist at OPERATOR level only.
### `rig stream`
Usage: `rig stream <subcommand>` — L1 append-only intake stream.
Subcommands:
- `emit --source <session> --body <text> [--hint-destination <session>] [--hint-type <type>] [--hint-urgency <urgency>] [--hint-tags <csv>] [--interrupt] [--id <streamItemId>] [--json]`
- `list [--source <session>] [--hint-destination <session>] [--limit <n>] [--after <sortKey>] [--include-archived] [--json]`
- `show <streamItemId> [--json]`
- `archive <streamItemId> [--json]`
Notes:
- `--id` is for idempotency; same id returns the same row, body of subsequent calls is ignored.
- `archive` is soft — the row remains for audit and is excluded from `list` unless `--include-archived` is passed.
### `rig queue`
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
Subcommands:
- `create --source <session> --destination <session> --body <text> [--priority <p>] [--tier <t>] [--tags <csv>] [--expires-at <iso>] [--id <qitemId>] [--json]`
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--json]` — transactional close-as-handed-off + create-new
- `fallback <qitemId> --destination <session> [--reason <text>] [--json]` — reroute to fallback seat
- `show <qitemId> [--json]`
- `transitions <qitemId> [--json]` — append-only transition log
- `list [--destination <session>] [--source <session>] [--state <csv>] [--limit <n>] [--json]`
- `overdue [--json]` — in-progress qitems past closure_required_at
- `inbox-drop <destinationSession> --sender <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <inboxId>] [--json]`
- `inbox-absorb <inboxId> --receiver <session> [--json]` — promote a pending inbox entry to a queue_item
- `inbox-deny <inboxId> --receiver <session> --reason <text> [--json]`
- `inbox-pending <destinationSession> [--json]`
- `outbox-record --sender <session> --destination <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <outboxId>] [--json]`
- `outbox-list <senderSession> [--limit <n>] [--json]`
Hot-potato strict-rejection (load-bearing API contract):
- `update --state done` REQUIRES `--closure-reason` from one of: `handed_off_to`, `blocked_on`, `denied`, `canceled`, `no-follow-on`, `escalation`. Missing or invalid reason → exit 1 with structured error naming the 6 valid values.
- `closure-reason` of `handed_off_to`, `blocked_on`, or `escalation` additionally requires `--closure-target`.
- All hot-potato enforcement happens at the daemon domain layer; every surface (CLI, future MCP, future UI) inherits the same guarantee.
Closure-reason semantics:
- `handed_off_to` — work continues at a different seat (target = new owner). `handoff` subcommand is the preferred path; `update` accepts it for non-handoff terminal closures.
- `blocked_on` — parked pending another qitem (target = blocker qitem_id).
- `denied` — receiver rejected the work.
- `canceled` — sender or receiver withdrew.
- `no-follow-on` — terminal completion, nothing else needed.
- `escalation` — kicked up to a higher tier (target = escalation target).
## Coordination Project / Classifier and View (PL-004 Phase B)
Two top-level commands extend the coordination layer with L2 (project / classifier) and L5 (views).
### `rig project`
Usage: `rig project <subcommand>` — L2 agent-backed classifier with daemon-enforced lease + idempotency + reclaim.
Subcommands:
- `lease-acquire [options]` — acquire the active classifier lease for the caller.
- `lease-heartbeat [options]` — send a heartbeat for an active classifier lease (extends TTL).
- `lease-show [options]` — show the currently-active classifier lease.
- `reclaim-classifier [options]` — operator verb to reclaim the active classifier lease. Use `--if-dead` to refuse if holder is still alive.
- `classify <streamItemId> [options]` — project a stream item with classification fields. Idempotent on `stream_item_id`; requires an active lease.
- `list [options]` — list project classifications with filters.
- `show <projectId> [options]` — show one project classification.
Notes:
- Lease semantics: only one classifier holds the active lease at a time. Heartbeats extend TTL; lease expiry frees the slot for the next acquirer.
- `classify` enforces L1→L2 foreign-key existence: the referenced `stream_items` row must exist or the call is rejected before any state mutation.
- `reclaim-classifier` is the operator-side verb to recover from a hung classifier; `--if-dead` adds a liveness guard so a still-heartbeating classifier is not stolen from.
### `rig view`
Usage: `rig view <subcommand>` — L5 daemon-backed views over coordination state.
Subcommands:
- `list [options]` — list built-in + custom views.
- `show <viewName> [options]` — run a view (built-in or custom).
- `register [options]` — register or update a custom view.
Built-in views:
- `recently-active`, `founder`, `pod-load`, `escalations`, `held`, `activity`.
Notes:
- Views emit a `view.changed` SSE event on every state mutation that affects them. Queue update mutations bridge to `queue.updated` and then to `view.changed` for all six built-in views (Phase B R2).
- Custom view registration writes to the `views_custom` table; the daemon hot-reloads on registration.
## Coordination Watchdog (PL-004 Phase C)
`rig watchdog` registers, lists, inspects, and stops daemon-native scheduler jobs. The scheduler runs inside the daemon supervision tree and persists state in SQLite (`watchdog_jobs`/`watchdog_history`); jobs survive daemon restarts. Three policies are available at v1: `periodic-reminder`, `artifact-pool-ready`, and `edge-artifact-required`. The fourth POC policy `workflow-keepalive` is rejected with `policy_deferred_to_phase_d` and ships in Phase D.
### `rig watchdog`
- `register --spec <path> --policy <name> --target-session <s> --interval-seconds <n> --registered-by <s>` — register a job from a YAML spec; `--active-wake-interval-seconds` and `--scan-interval-seconds` are pool-ready-specific opt-ins.
- `list` — list all jobs (active + stopped + terminal).
- `show <job_id>` — show one job.
- `status <job_id>` — show job + recent evaluation history (last 20 entries).
- `stop <job_id> [--reason <text>]` — operator stop; scheduler skips the job thereafter.
History records only loud evaluations: `sent` (delivery executed) or `terminal` (policy declared the job done). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded — POC parity so agents are not woken about scheduler polls. Loud `skipped` rows are recorded only if a policy returns a non-quiet reason.
Phase D extends the policy enum with `workflow-keepalive` (the policy deferred from Phase C). It reads `workflow_instances` directly via SQLite, requires `status: active|waiting`, and resolves frontier qitem owners from `queue_items`.
## Workflow Runtime (PL-004 Phase D)
`rig workflow` operates on the daemon-native Workflow Runtime: declarative spec validation, instance creation, step projection (transactional-scribe), trace, and idempotent continue. Workflow specs are markdown/YAML files on disk (workspace-surface); the daemon caches them in SQLite for fast lookup.
### `rig workflow`
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness).
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner.
- `project --instance <id> --current-packet <qitem-id> --exit <handoff|waiting|done|failed> --actor-session <session>` — close the current packet AND project the next-step packet IN THE SAME daemon transaction (transactional-scribe; lost handoffs impossible by design). `--result-note <text>`, `--blocked-on <ref>`, `--next-owner <session>` modify behavior.
- `list [--status <s>]` — list instances; optionally filter by status (`active`/`waiting`/`completed`/`failed`).
- `show <instanceId>` — show one instance.
- `trace <instanceId>` — show the instance + its append-only step trail (audit-only).
- `continue <instanceId>` — idempotent inspector; in v1 returns the current state.
The owner-as-author + workflow-as-transactional-scribe contract is enforced by the daemon. The owner of a packet decides when it closes; the workflow runtime atomically records the closure AND creates/projects the next qitem per the workflow spec, in a single daemon transaction.
## Operational Inspection
Read-only inspection commands for context-window state, compaction planning, workflow heartbeat, and seat handover observability. Default mode is read-only across this section.
### `rig context`
Usage: `rig context [--rig <name>] [--threshold <pct>] [--refresh] [--json]`
Notes:
- Shows context-window usage across running agents.
- `--rig <name>` narrows to a single rig; `--threshold <pct>` filters to seats at or above the percent (plus unknown + stale).
- `--refresh` re-samples context usage before displaying instead of reading the latest cached snapshot.
### `rig compact-plan`
Usage: `rig compact-plan [--rig <name>] [--refresh] [--threshold-tokens <n>] [--threshold-percent <0-100>] [--json]`
Notes:
- Plans Claude compact-in-place candidates without compacting anything (read-only triage).
- `--threshold-tokens <n>` is the estimated used-token threshold; `--threshold-percent <0-100>` is the used-percent threshold when the context window size is missing.
- Output identifies seats that are candidates for compaction by current heuristics; the operator decides what (if anything) to compact.
### `rig heartbeat`
Usage: `rig heartbeat [--rig <name>] [--nudge] [--include-done] [--json]`
Notes:
- Shows workflow execution proof state from queue files.
- Default mode is read-only. `--nudge` sends informational proof instructions to stalled or unproven owners; it does not modify queue files or reroute work.
- `--include-done` includes done/handed-off queue items in the output (excluded by default).
### `rig seat`
Usage: `rig seat <subcommand>`
Subcommands:
- `status <seat> [options]` — show read-only seat handover observability status.
- `handover <seat> [options]` — plan a safe two-phase seat handover.
Notes:
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
## Mission Control / Queue Observability (PL-005 Phase A)
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only."
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture.md` § Mission Control / Queue Observability. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
## Agent Images, Context Packs, and Workspace (v0.3.0)
Three top-level commands shipped in v0.3.0 for operator-authored library content (agent images and context packs) and the workspace primitive.
### `rig agent-image`
Usage: `rig agent-image <subcommand>` — browse, snapshot, and manage agent images (PL-016).
Subcommands:
- `list [options]` — list all agent images in the library.
- `show <name-or-id> [options]` — show image manifest + statistics.
- `preview <name-or-id> [options]` — show manifest + sized supplementary file metadata + starter snippet.
- `create <source-session> [options]` — capture a productive seat's resumable state into a new agent image.
- `delete <name-or-id> [options]` — delete an agent image (subject to evidence-preservation guard).
- `pin <name-or-id> [options]` — pin an image so prune cannot delete it.
- `unpin <name-or-id> [options]` — unpin an image.
- `prune [options]` — delete evictable images (protected by evidence-preservation guard).
- `sync [options]` — re-walk discovery roots and refresh the library index.
Notes:
- Images are an operator-authored library form; deletion is gated by an evidence-preservation guard so productive seat snapshots are not lost accidentally.
- `pin` / `unpin` are the operator levers for explicit retention; `prune` honours them.
### `rig context-pack`
Usage: `rig context-pack <subcommand>` — browse, preview, send, and install operator-authored context packs.
Subcommands:
- `list [options]` — list all context packs in the library.
- `show <name-or-id> [options]` — show pack manifest + per-file metadata.
- `preview <name-or-id> [options]` — show the assembled bundle (the exact text that would be sent).
- `sync [options]` — re-walk discovery roots and refresh the library index.
- `add <source-dir> [options]` — install a context pack from a local directory into `~/.openrig/context-packs/`.
- `send <name-or-id> <destination-session> [options]` — assemble the pack into one paste-ready bundle and send to a seat.
Notes:
- Context packs are operator-authored bundles of context (manifest + files) intended to prime a managed seat with a coherent starting context.
- `preview` is the canonical way to see exactly what `send` will deliver; useful before priming a live seat.
- `send --dry-run` is supported for preview-then-deliver flows.
### `rig workspace`
Usage: `rig workspace <subcommand>` — Workspace Primitive (PL-007), v0 typed-kind tooling.
Subcommands:
- `validate [root]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`.
Notes:
- v0 surface is intentionally narrow (`validate` only) — read-only frontmatter audit.
- Future versions will add typed-kind authoring/refactor tooling on the same root walker.
- See `rig config init-workspace` to scaffold a fresh default workspace.
## Commands Not Present
These are not current top-level `rig` commands:
- `rig claim`
- `rig blame`
- `rig replay`
- `rig env`
If older docs or habits mention them, treat those references as stale.
+108 -24
View File
@@ -3,10 +3,10 @@
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
Current footprint:
- daemon: 133 source files (79 domain, 21 routes, 10 adapters, 17 migrations, 6 other)
- CLI: 37 source files
- UI: 83 source files
- total: 253 source files
- 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
How to use this doc:
- `Exports` lists the public symbols defined by the file.
@@ -16,6 +16,7 @@ How to use this doc:
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`
@@ -23,13 +24,15 @@ Fast lookup:
- 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/*` (31 groups)
- 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/*`
@@ -38,14 +41,15 @@ Fast lookup:
### 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, 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 17 migrations, wires legacy, reboot, North Star, Post-North-Star, and Final Stretch 1 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.
- `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.
### 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`.
@@ -75,19 +79,82 @@ Fast lookup:
- `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`.
### 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`.
### 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}`.
### 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`.
### 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`.
### 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, and edge state. Exports: RigRepository. Related: `packages/daemon/src/domain/types.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/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
- `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`.
### Domain: Specs, Resolution, Startup
@@ -104,8 +171,8 @@ Fast lookup:
- `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, and provenance/source-state for preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, 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, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.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`.
@@ -113,8 +180,8 @@ Fast lookup:
### 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, and topology ordering. 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, and startup context. 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/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`.
### Domain: Packages And Install Engine
@@ -169,6 +236,14 @@ Fast lookup:
- `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`.
@@ -176,6 +251,7 @@ Fast lookup:
- `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`.
@@ -187,8 +263,10 @@ Fast lookup:
- `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`). Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `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`.
@@ -197,7 +275,7 @@ Fast lookup:
### 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 (31 mounted command groups, including 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`, +18 more.
- `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
@@ -209,6 +287,7 @@ Fast lookup:
- `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.
@@ -219,9 +298,9 @@ Fast lookup:
- `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`. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.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. 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/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`.
@@ -230,6 +309,8 @@ Fast lookup:
- `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`.
@@ -273,19 +354,20 @@ Fast lookup:
### 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 (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab. 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/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. Lists library entries, opens review routes, and bridges into specs workspace tasks. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.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. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.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. 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/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`.
@@ -315,16 +397,17 @@ Fast lookup:
- `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. Exports: RigSummary, useRigSummary. 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. 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/*`. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. 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.
@@ -338,4 +421,5 @@ Fast lookup:
### 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.
+238
View File
@@ -0,0 +1,238 @@
# OpenRig UI As-Built
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.
## Package Shape
UI package: `packages/ui`
Primary implementation areas:
- `src/routes.tsx` - TanStack Router route tree
- `src/components/AppShell.tsx` - global shell, rail, Explorer, drawer, preview stack
- `src/components/ui/` - reusable vellum 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
## Shell Model
`AppShell` wraps every route with:
- 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.
+418
View File
@@ -0,0 +1,418 @@
# AgentSpec Reference
Version: 1.0
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.
---
## Minimal Valid Example
```yaml
name: my-agent
version: "1.0"
profiles:
default:
uses:
skills: []
guidance: []
subagents: []
hooks: []
runtime_resources: []
resources: {}
startup:
files: []
actions: []
```
## Practical Example (implementer agent)
```yaml
name: implementer
version: "1.0"
description: Implementation agent — writes code following TDD discipline
defaults:
runtime: claude-code
imports:
- ref: local:../../shared
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging]
guidance: []
subagents: []
hooks: []
runtime_resources: []
resources:
guidance:
- id: role
path: guidance/role.md
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
actions: []
```
## Complete Example (all features)
```yaml
name: vault-specialist
version: "1.0"
description: Vault specialist agent — manages HashiCorp Vault for this managed app
defaults:
runtime: claude-code
model: claude-opus-4-6
lifecycle:
execution_mode: interactive_resident
compaction_strategy: harness_native
restore_policy: resume_if_possible
imports:
- ref: local:../../shared
- ref: local:../common-tools
version: "2.0"
profiles:
default:
summary: Standard Vault operations profile
preferences:
runtime: claude-code
uses:
skills: [openrig-user, systematic-debugging, vault-user]
guidance: []
subagents: []
hooks: []
runtime_resources: []
startup:
files:
- path: guidance/profile-specific.md
delivery_hint: send_text
actions: []
lifecycle:
restore_policy: resume_if_possible
resources:
skills:
- id: vault-user
path: skills/vault-user
guidance:
- id: role
path: guidance/role.md
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
runtime: claude-code
type: claude_settings_fragment
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
- path: startup/context.md
delivery_hint: send_text
required: true
- path: guidance/optional-tips.md
delivery_hint: guidance_merge
required: false
applies_on: [fresh_start]
actions:
- type: send_text
value: "Load vault-user skill and verify Vault health."
phase: after_ready
idempotent: true
```
---
## Top-Level Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `name` | string | yes | — | Agent name. Used in spec library identification and validation messages. |
| `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, 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. |
---
## Defaults
```yaml
defaults:
runtime: claude-code
model: claude-opus-4-6
lifecycle:
execution_mode: interactive_resident
compaction_strategy: harness_native
restore_policy: resume_if_possible
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `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
| 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 | `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` |
---
## Imports
```yaml
imports:
- ref: local:../../shared
- ref: local:../common-tools
version: "2.0"
```
| 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.). |
### 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
- 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
Most built-in agents import the shared builtin spec:
```yaml
imports:
- ref: local:../../shared
```
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`.
---
## Profiles
```yaml
profiles:
default:
summary: Standard operations profile
preferences:
runtime: claude-code
model: claude-opus-4-6
uses:
skills: [openrig-user, systematic-debugging, vault-user]
guidance: [role]
subagents: []
hooks: []
runtime_resources: []
startup:
files: []
actions: []
lifecycle:
restore_policy: resume_if_possible
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `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. |
| `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. |
### Uses
The `uses` block selects which resources from the `resources` pool (including imported resources) are active for this profile.
```yaml
uses:
skills: [openrig-user, systematic-debugging, vault-user]
guidance: [role]
subagents: []
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:openrig-user`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
---
## Resources
```yaml
resources:
skills:
- id: vault-user
path: skills/vault-user
guidance:
- id: role
path: guidance/role.md
subagents:
- id: helper
path: subagents/helper
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
runtime: claude-code
type: claude_settings_fragment
```
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` | 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`.
- `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 runtime extension directory for agent-visible context.
### Resource Path Rules
- 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
### Guidance Resources
```yaml
guidance:
- id: role
path: guidance/role.md
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. |
| `path` | string | yes | — | Relative path to the guidance file. |
| `target` | string | no | — | Target file for merging (e.g., `CLAUDE.md`). |
| `merge` | string | no | `managed_block` | Merge strategy. One of: `managed_block`, `append`. |
---
## Startup Block
The startup block follows the same format as in the RigSpec (see `docs/reference/rig-spec.md` for full details on files and actions).
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.
### Delivery Hint Quick Reference
| Hint | When Delivered | Mechanism |
|------|---------------|-----------|
| `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 |
---
## Validation Rules Summary
1. `name` and `version` are required non-empty strings.
2. `imports` must be an array of objects with `ref` field.
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 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.
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 `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.
---
## File System Layout
An agent spec directory follows this conventional layout:
```
my-agent/
agent.yaml # required — the AgentSpec
guidance/
role.md # role definition
startup/
context.md # boot-time grounding
skills/
my-skill/
SKILL.md # skill content
hooks/
pre-commit.sh # hook script
runtime/
claude-settings.fragment.json # runtime-specific resource
```
The only required file is `agent.yaml`. Everything else is referenced by paths in the spec and must exist at those relative paths.
---
## Shipped Examples
| 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 |
+358
View File
@@ -0,0 +1,358 @@
# Agent Startup Guide
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`.
---
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
### Category 1: Context Loading
The agent reads markdown files into its context window. This is the **primary mechanism** in OpenRig today and the one you should invest most of your authoring effort in.
Context loading shapes what the agent knows, believes, and is capable of:
- Who it is (role, identity, pod membership)
- How the team works (culture, communication norms, coordination protocols)
- What the project is (codebase context, architecture, domain knowledge)
- What it can do (skills, SOPs, operational procedures)
- What its environment looks like (services, access credentials, tools)
### Category 2: Deterministic Configuration
The rig spec declaratively installs things into the agent's runtime environment:
- 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 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.
---
## Context Loading: What Goes Where
### Skills vs Startup Files — The Key Distinction
**Skills** are reusable SOPs. They teach an agent HOW to do something — how to use OpenRig, how to do TDD, how to run a code review, how to operate Vault. Skills transfer across rigs. A skill you write once can be used by any agent in any rig.
**Startup/guidance files** are rig-specific and role-specific. They tell an agent WHO it is, WHAT it's working on, and HOW this particular team operates. They are authored per-rig and often per-pod or per-member.
| Put in a skill when... | Put in a startup/guidance file when... |
|------------------------|---------------------------------------|
| The knowledge is reusable across rigs | The knowledge is specific to this rig or project |
| It teaches a procedure or methodology | It teaches identity, role, or team context |
| It could be useful to any agent of this type | It's only useful to agents in this specific topology |
| Examples: `openrig-user`, `test-driven-development`, `vault-user` | Examples: `role.md`, `CULTURE.md`, `startup/context.md` |
### The Belt-and-Suspenders Pattern for Skills
Skills are delivered to agents through two parallel paths:
1. **Spec declaration:** The agent spec's `resources.skills` + profile `uses.skills` ensures the skill files are projected to the agent's workspace (installed before harness boot)
2. **Startup instruction:** The guidance/startup files tell the agent to actually read and load those skills
Both paths are important. The spec projection ensures the files are physically present. The startup instruction ensures the agent knows to read them. This redundancy is intentional — it handles cases where one path fails.
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, systematic-debugging" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
### The File Types
**Role guidance (`guidance/role.md`)**
Tells the agent who it is and what its responsibilities are:
- Title and primary function
- Specific responsibilities (bulleted list)
- Working rhythm (how the agent operates day-to-day)
- Principles (behavioral guidelines)
- Relationship to other team members
This is the agent's identity document. Every agent should have one.
**Rig culture (`CULTURE.md`)**
The rig-wide constitution. Applied to ALL agents in the rig:
- Communication norms (use `rig send`/chatroom, not raw tmux)
- Coordination protocol (how work flows between pods)
- Quality standards (what "done" means)
- Commit/merge policy
- Escalation rules
Think of this as the team operating manual that every team member reads on day one.
**Startup context (`startup/context.md`)**
Boot-time grounding information specific to this agent's operating environment:
- Identity recovery instructions (`rig whoami --json`)
- Environment details (service URLs, access credentials, ports)
- System check instructions (what should be running, how to verify)
- Role-specific delegation information (who to ask, who delegates to you)
This is especially important for managed-app specialists (e.g., a Vault specialist needs to know the Vault address and dev token).
**Project documentation**
Rig-level startup files that teach the agent about the project:
- Architecture overview
- Key conventions
- Domain vocabulary
- Recent context (what's been happening, what's in progress)
These go in rig-level or pod-level startup blocks and are delivered to relevant agents.
---
## The Layering Model
Startup content is merged additively through layers. Each layer adds to what the previous layers provided. Later layers do NOT replace earlier layers — they append.
### The Layers (in delivery order)
```
1. Agent layer — from the AgentSpec's top-level startup block
2. Profile layer — from the active profile's 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 — injected at runtime (openrig-start overlay, context collector, etc.)
```
### 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 |
| 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`, context collector |
### Practical Guidance
**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 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.
---
## Delivery Mechanisms
### How Files Reach the Agent
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `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 |
### Delivery Timing Matters
Files delivered via `guidance_merge` and `skill_install` happen BEFORE the agent's harness boots. The agent sees them immediately when it starts — they're part of the initial context.
Files delivered via `send_text` happen AFTER the harness is ready. The agent receives them as messages in its terminal. Use this for:
- Identity grounding (agent reads and processes the instructions)
- 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
### The `applies_on` Field
Each startup file and action specifies when it applies:
- `fresh_start` — delivered on first launch only
- `restore` — delivered on restore from snapshot
- Default: `[fresh_start, restore]` (both)
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.
---
## Deterministic Configuration
The AgentSpec and RigSpec allow declaring deterministic environment configuration:
### What the Spec Supports
**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
resources:
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
runtime: claude-code
type: claude_settings_fragment
```
Runtime-specific configuration files projected into the agent's runtime environment.
**Startup actions** (in `startup.actions`):
```yaml
startup:
actions:
- type: send_text
value: "/install-mcp my-server"
phase: after_ready
idempotent: true
```
Commands sent to the agent's terminal after it's ready. Can install MCPs, run setup commands, etc.
### 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. |
| 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 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 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:
1. **Describe the desired end state** in a startup file (e.g., "You need these MCP servers configured, these permissions set, these hooks installed")
2. **Include a system check** in the startup instructions ("Verify your environment: check that X is installed, Y is configured, Z is accessible")
3. **Empower the agent to self-configure** ("If any of these are missing, install/configure them")
4. **Optionally also declare it in the spec** for when deterministic support improves — the spec serves as the blueprint, and the startup file serves as the fallback instruction
This way, when deterministic support becomes fully reliable, the agent will boot up, see that everything is already set up (by the deterministic path), run its system check, confirm everything looks good, and proceed. Until then, the agent reads the instructions and handles the setup itself.
### Runtime Config Disclosure
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`
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.
- Claude project-local config: `.claude/settings.local.json`
Purpose: apply context collector/activity hooks and selected `claude_settings_fragment` resources inside the project without committing them to git.
- Claude project-local MCP config: `.mcp.json`
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
- Codex global config: `~/.codex/config.toml`
Purpose: pre-trust managed workspaces and apply selected `codex_config_fragment` resources. Codex currently has no equivalent project-local MCP config path for global profile settings.
Two important caveats:
- 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
### Runtime Differences
**Claude Code:**
- Reads from `CLAUDE.md` and `.claude/` directory
- Recurring tasks via the `/loop` command (e.g., `/loop 5m "check rig health"`) — this is NOT hooks; hooks are event-driven
- MCP server management via `/mcp` interactive command or `claude mcp add` from CLI
- Event-driven hooks system in `.claude/settings.json` — reacts to events like `PreToolUse`, `PostToolUse`, `SessionStart`, etc. (not time-based)
- Permission allowlisting via `.claude/settings.json` (`permissions.allow`, `permissions.deny`, `permissions.defaultMode`)
- Can self-configure MCP servers, permissions, and hooks from startup instructions
**Codex:**
- 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 mode controlled via launch flags (`-a`, `-s`, `--full-auto`)
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
---
## Patterns and Anti-Patterns
### Good Patterns
**Start with role + culture + one skill**
```
agent.yaml → guidance/role.md
rig.yaml → culture_file: CULTURE.md
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.
**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.
**Tell the agent about its skills explicitly**
In a startup file or guidance, include a line like:
```
You have the following skills loaded: openrig-user, test-driven-development, systematic-debugging. Use them.
```
This prompts the agent to actually invoke the skills, not just have them as passive context.
**Include a system check in startup context**
```
## System Check
After identity recovery, verify:
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.)
If anything is missing, fix it before starting work.
```
### Anti-Patterns
**Dumping everything into one giant CLAUDE.md**
Don't. Use the layering model. Role goes in the agent spec. Culture goes in the rig spec. Project context goes in rig/pod startup files. If everything is in one file, you can't reuse any of it.
**Duplicating skill content in guidance files**
If you find yourself copying text from a skill into a guidance file, stop. Reference the skill instead. Skills are projected; guidance should point to them, not duplicate them.
**Using send_text for content that should be pre-loaded**
If the agent needs to know something BEFORE it starts reasoning, use `guidance_merge` (delivered before boot), not `send_text` (delivered after boot). `send_text` is for instructions the agent should process as a first task, not for foundational context.
**Over-specifying member-level startup**
If every member has a large startup block, the rig spec becomes a configuration dump. Refactor shared content up to the pod level. Member-level startup should be small overrides, not complete agent briefings.
**Relying on deterministic hooks for critical setup**
If your rig REQUIRES a hook to function and the hook installation fails silently, the agent won't know something is wrong. Always pair deterministic config with a startup instruction or system check that verifies the result.
---
## Authoring Checklist
When creating a new agent's startup experience:
- [ ] Write a `guidance/role.md` — who is this agent?
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
- [ ] 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`
- [ ] Test by launching the rig and checking that the agent received the expected content
+126
View File
@@ -0,0 +1,126 @@
# Edge Types Reference
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`
---
## Overview
Edges define relationships between members in a rig topology. They appear in two places:
- **Pod-local edges** — between members in the same pod (use unqualified member IDs)
- **Cross-pod edges** — between members in different pods (use `pod.member` format)
See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by the validator:
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
| `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 | no | Source escalates to target |
## What Edges Actually Do Today (OpenRig 0.1.x)
### Launch ordering
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.
All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT constrain launch order.
### Graph visualization
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
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`) 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
- **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
These are aspirational capabilities. The edge vocabulary is intentionally richer than the current runtime behavior so that the topology captures design intent even before the runtime enforces it.
## Design Intent (Why We Have Five Kinds)
The five kinds represent a taxonomy of how agents relate to each other in a working team:
**`delegates_to`** — the most common edge. An orchestrator delegates to an implementer. A lead delegates to a worker. This is the primary workflow direction. When an orchestrator sends a task, it uses `rig send` to the session it `delegates_to`.
**`spawned_by`** — for hierarchical launch relationships. A subagent spawned by a parent process. Less common in current topologies.
**`can_observe`** — the review/oversight relationship. A reviewer observes an implementer's work. The reviewer can `rig capture` and `rig transcript` the implementation session. This edge communicates intent: "I'm watching your output."
**`collaborates_with`** — peer relationships. Two agents working side-by-side at the same level of the hierarchy. Neither delegates to the other.
**`escalates_to`** — the reverse of delegation. When a worker hits a decision it can't make, it escalates to a lead. This is less common in current topologies but represents a real coordination pattern.
## Choosing Edge Kinds
### Common Patterns
**Orchestrator → workers:**
```yaml
edges:
- kind: delegates_to
from: orch.lead
to: dev.impl
```
**Reviewer → implementer:**
```yaml
edges:
- kind: can_observe
from: rev.r1
to: dev.impl
```
**Implementation pair:**
```yaml
edges:
- kind: delegates_to
from: impl
to: qa
```
### When You're Not Sure
If you're unsure which edge kind to use:
1. Does one agent give work to the other? → `delegates_to`
2. Does one agent watch the other's output? → `can_observe`
3. Are they equals working together? → `collaborates_with`
4. Does one report problems up? → `escalates_to`
5. Did one create the other? → `spawned_by`
If none fit, `can_observe` is the safest default — it documents the relationship without implying a workflow direction and doesn't affect launch ordering.
## 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 `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
+102
View File
@@ -0,0 +1,102 @@
# Project Workspace Contract
OpenRig's Project UI is a file-backed view over a workspace root. The folder
shape is intentionally simple so humans and agents can create or repair it
without daemon-internal knowledge.
## Default Shape
`rig config init-workspace` creates the default workspace at
`~/.openrig/workspace` unless `--root` or `workspace.root` points elsewhere.
```text
workspace/
README.md
STEERING.md
missions/
README.md
idea-ledger/
README.md
PROGRESS.md
slices/
capture-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
triage-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
handoff-loop/
README.md
PROGRESS.md
slices/
route-work-packets/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
verify-loop-evidence/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
progress/
field-notes/
specs/
```
## UI Mapping
- `workspace.root` maps to the Project workspace.
- `workspace.root/missions/<mission-id>` maps to a Project mission.
- `workspace.root/missions/<mission-id>/slices/<slice-id>` maps to a Project slice.
- Mission `PROGRESS.md` frontmatter supplies the mission status badge when the
file root is allowlisted.
- Slice `README.md`, `PROGRESS.md`, and `IMPLEMENTATION-PRD.md` frontmatter
supply display name, lifecycle status, mission id, and queue linkage hints.
Mission and slice ids should be stable kebab-case strings. Keep slice ids
unique inside the workspace so `/project/slice/<slice-id>` resolves without
ambiguity.
## Queue Mapping
Queue items attach to a slice when their body or tags mention one of:
- the slice id;
- the mission id;
- the legacy `rail-item` value in slice frontmatter.
For new work, include both mission and slice ids in the queue item body or
tags. Example:
```text
Mission: idea-ledger
Slice: capture-product-ideas
```
This makes Story, Queue, Tests, and Topology tabs line up with the filesystem
slice without adding a separate project database schema.
## Compatibility
The default discovery root is `workspace.slices_root=<workspace.root>/missions`.
The slice indexer also supports legacy flat roots such as
`workspace.slices_root=<workspace.root>/slices`, where each direct child folder
is a slice. Flat roots remain readable, but the mission-aware shape is the
default setup contract.
## Repair Checklist
If Project shows a mission discovery warning:
1. Run `rig config get workspace.root --show-source`.
2. Run `rig config get workspace.slices_root --show-source`.
3. Confirm `workspace.slices_root` points at a folder containing mission
directories with `slices/` children.
4. Confirm `files.allowlist` includes `workspace:<workspace.root>` so the UI
can read mission `PROGRESS.md`.
5. If the workspace is missing, run `rig config init-workspace` after operator
approval.
No daemon restart is required for most config reads. Restart when changing
startup-time roots such as `files.allowlist` or progress scan roots.
+367
View File
@@ -0,0 +1,367 @@
# RigBundle Reference
Version: 2 (pod-aware)
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/bundle-types.ts`, `packages/daemon/src/domain/bundle-archive.ts`, `packages/daemon/src/domain/pod-bundle-assembler.ts`
A `.rigbundle` is a self-contained distributable archive that packages a rig spec, all referenced agent specs, their resources (skills, guidance, startup files), culture file, documentation, and an integrity manifest into a single file. The recipient can install and launch the rig without needing the original source tree.
---
## Archive Format
A `.rigbundle` file is a gzip-compressed tar archive (`.tar.gz`) with a fixed structure.
### File extension
The archive MUST have the `.rigbundle` extension. The packer rejects output paths that don't end with `.rigbundle`.
### Sibling digest file
Every `.rigbundle` has a sibling `.rigbundle.sha256` file containing the SHA-256 hex digest of the archive. This detects corruption during transfer. The unpacker verifies this digest before extraction.
Example:
```
my-rig.rigbundle — the archive
my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
```
### Determinism
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode normalizes uid/gid/mode
- Maximum gzip compression (level 9)
This means the same inputs always produce the same archive hash.
---
## Archive Layout
```
bundle.yaml — manifest (required)
rig.yaml — the RigSpec (required, may be rewritten)
CULTURE.md — culture file (if declared in rig spec)
SETUP.md — documentation (if declared in rig spec docs field)
agents/
agent-name/
agent.yaml — AgentSpec
guidance/
role.md — guidance files
skills/
skill-name/
SKILL.md — skill files
startup/
context.md — startup files
```
### Key rules
- `bundle.yaml` is the manifest — always present, always at the root
- `rig.yaml` is the rig spec — rewritten during assembly with vendored `agent_ref` paths
- Agent directories are vendored copies of the original agent specs with all their resources
- Import refs are rewritten from the original `local:` or `path:` paths to bundle-relative `local:` paths
- All file paths within the archive are safe relative paths (no `..`, no absolute, no symlinks)
---
## Manifest (`bundle.yaml`)
The manifest is a YAML file at the archive root that describes the bundle contents.
### Schema Version 2 (pod-aware, current)
```yaml
schema_version: 2
name: my-bundle
version: "0.1.0"
created_at: "2026-04-11T22:32:48.570Z"
rig_spec: rig.yaml
agents:
- name: pm-lead
version: "1.0"
path: agents/pm-lead
original_ref: "local:agents/pm-lead"
hash: "ed4cff20..."
import_entries: []
- name: researcher
version: "1.0"
path: agents/researcher
original_ref: "local:agents/researcher"
hash: "11f8a077..."
import_entries:
- name: shared
version: "1.0"
path: agents/shared
original_ref: "local:../../shared"
hash: "abc123..."
culture_file: CULTURE.md
integrity:
algorithm: sha256
files:
rig.yaml: "b80c0674..."
CULTURE.md: "9354361b..."
agents/pm-lead/agent.yaml: "ed4cff20..."
# ... every file in the archive
```
### Manifest Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `schema_version` | number | yes | Must be `2` for pod-aware bundles. |
| `name` | string | yes | Bundle name. |
| `version` | string | yes | Bundle version. |
| `created_at` | string | yes | ISO-8601 timestamp of creation. |
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
| `agents` | AgentEntry[] | yes | Array of vendored agent entries. |
| `culture_file` | string | no | Relative path to the culture file if present. |
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. |
### Agent Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Agent name (from agent.yaml). |
| `version` | string | no | Agent version. |
| `path` | string | yes | Relative path to the vendored agent directory. Safe relative path. |
| `original_ref` | string | yes | The original `agent_ref` before rewriting. |
| `hash` | string | yes | SHA-256 hash of the agent.yaml content. |
| `import_entries` | ImportEntry[] | yes | Vendored imports for this agent (may be empty). |
### Import Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Imported agent name. |
| `version` | string | yes | Imported agent version. |
| `path` | string | yes | Relative path to the vendored import within the archive. |
| `original_ref` | string | yes | The original import ref before rewriting. |
| `hash` | string | yes | SHA-256 hash of the imported agent.yaml. |
### Integrity Section
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `algorithm` | string | yes | Must be `sha256`. |
| `files` | map<string, string> | yes | Map of archive-relative file path → SHA-256 hex hash. Every file in the archive (except `bundle.yaml` itself) should be listed. |
---
## Security Model
Bundle integrity provides **self-consistency verification, not authenticity**.
- The sibling `.sha256` file detects corruption during transfer
- The per-file integrity hashes detect tampering of individual files within the archive
- Neither mechanism authenticates the bundle author
An attacker who can rewrite the full bundle + digest can bypass verification. Users must trust the source they obtained the bundle from. This is the same trust model as unsigned npm packages and Docker images.
Future enhancement: cryptographic signing (Ed25519) for author authentication.
---
## Safety Guarantees
The unpacker enforces these safety rules before extraction:
1. **No symlinks or hardlinks** — `SymbolicLink` and `Link` entries are rejected
2. **No absolute paths** — entries starting with `/` are rejected
3. **No path traversal** — entries containing `..` segments are rejected
4. **Digest verification** — archive SHA-256 must match the sibling `.sha256` file
5. **Content integrity** — after extraction, per-file hashes are verified against the manifest
If any check fails, extraction is aborted and an error is thrown.
---
## CLI Surface
### Create a bundle
```bash
rig bundle create <spec-path> -o <output.rigbundle> [--rig-root <dir>] [--name <name>] [--bundle-version <ver>]
```
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `<spec-path>` | yes | — | Path to the rig spec YAML file. |
| `-o, --output` | yes | — | Output path. Must end with `.rigbundle`. |
| `--rig-root` | no | spec directory | Root directory for resolving `agent_ref` and other relative paths. |
| `--name` | no | `my-bundle` | Bundle name in the manifest. |
| `--bundle-version` | no | `0.1.0` | Bundle version in the manifest. |
The create command:
1. Validates the rig spec
2. Resolves all `agent_ref` paths and their imports
3. Vendors all agent specs, resources, and startup files into a staging directory
4. Rewrites `agent_ref` paths to bundle-relative `local:` refs
5. Collects culture file, docs files, and rig-level startup files
6. Computes per-file integrity hashes
7. Writes the manifest (`bundle.yaml`)
8. Packs into a deterministic `.tar.gz`
9. Writes the sibling `.sha256` digest
### Inspect a bundle
```bash
rig bundle inspect <bundle-path> [--json]
```
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install the bundle.
### Install a bundle
```bash
rig bundle install <bundle-path> [--plan] [--yes] [--target <root>] [--json]
```
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `<bundle-path>` | yes | — | Path to the `.rigbundle` file. |
| `--plan` | no | `false` | Preview the bootstrap plan without installing or launching. |
| `--yes` | no | `false` | Auto-approve trusted actions during apply mode. |
| `--target <root>` | yes in apply mode | — | Target root directory for package installation. Required unless `--plan` is used. |
| `--json` | no | `false` | Emit machine-readable JSON. |
Extracts the bundle, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
### Launch directly
```bash
rig up <bundle-path> [--target <root>] [--cwd <dir>]
```
`rig up` auto-detects `.rigbundle` files and routes them through the bundle bootstrap path.
- `--target <root>` controls where packaged files are installed
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory
- `--cwd <dir>` does **not** change the install target; it only overrides the launched members' working directory for that run
---
## Assembly Process
When `rig bundle create` runs, the `PodBundleAssembler` performs these steps:
1. **Parse and validate** the rig spec
2. **Collect rig-level files:**
- Culture file (if `culture_file` is set)
- Docs files (if `docs` array is set) — **required: missing docs fail assembly**
- Rig-level startup files
- Pod-level and member-level startup files
3. **For each member's `agent_ref`:**
- Resolve the ref to an agent spec directory
- Copy the agent spec and all its resources (skills, guidance, hooks, startup, runtime resources)
- Recursively resolve and copy imports
- Record the agent entry in the manifest with its hash
- Rewrite the ref to a bundle-relative `local:` path
4. **Write the rewritten rig spec** to the staging directory
5. **Compute integrity** — SHA-256 of every file in the staging directory
6. **Write the manifest** (`bundle.yaml`) with all entries and integrity
7. **Pack** the staging directory into a deterministic tar.gz
### Terminal nodes
Members with `agent_ref: "builtin:terminal"` are bundle-native sentinels. They are not vendored — the runtime handles them directly.
### Deduplication
If multiple members reference the same agent spec (same resolved path), the spec is vendored once and all members' refs are rewritten to the same bundle-relative path.
### Import resolution
When an agent spec has `imports`, each import is resolved, vendored into the bundle, and the import refs in the vendored agent.yaml are rewritten to bundle-relative `local:` paths. Import entries are recorded in the manifest's agent entry.
---
## Validation Rules Summary
### Manifest validation (schema version 2)
1. `schema_version` must be `2`
2. `name` is required non-empty string
3. `version` is required non-empty string
4. `created_at` is required non-empty string
5. `rig_spec` is required and must be a safe relative path
6. `agents` must be an array
7. Each agent must have `name`, `path` (safe relative), and `hash`
8. Integrity `algorithm` must be `sha256`
9. Integrity `files` must be a non-empty map of safe-relative-path → 64-char hex hash
### Archive safety (enforced on unpack)
1. No symlinks or hardlinks
2. No absolute paths
3. No `..` path traversal
4. Archive digest must match sibling `.sha256`
5. Per-file content hashes must match integrity section
### Assembly validation
1. Rig spec must validate
2. All `agent_ref` paths must resolve to valid agent specs
3. All declared `docs` files must exist on disk (missing docs fail assembly)
4. Culture file and startup files are collected best-effort (missing = skipped)
---
## Legacy Bundles (Schema Version 1)
Schema version 1 bundles are the pre-reboot format using flat-node rig specs and package-based bundling. They are still supported for backward compatibility but should not be created for new rigs.
Key differences from v2:
- `schema_version: 1` in the manifest
- `packages` array instead of `agents` array
- Package entries have `original_source` instead of `original_ref`
- No `import_entries` in package entries
- Legacy rig spec format (flat nodes, not pods)
---
## Example: Creating and Using a Bundle
### Create
```bash
# From the rig directory
rig bundle create rig.yaml -o my-team.rigbundle --rig-root . --name my-team --bundle-version 1.0.0
```
Output:
```
Bundle created: my-team.rigbundle
Name: my-team v1.0.0
Hash: a1b2c3d4e5f6...
```
### Inspect
```bash
rig bundle inspect my-team.rigbundle
```
Output:
```
Bundle: my-team v1.0.0
Digest valid: true
Integrity: PASS
```
### Install and launch
```bash
cd ~/projects/my-project
rig up /path/to/my-team.rigbundle
```
Equivalent explicit form:
```bash
rig up /path/to/my-team.rigbundle --target ~/projects/my-project
```
The bundle is extracted to a temporary directory, integrity is verified, packaged files are installed into the target root, and the rig is bootstrapped with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
+479
View File
@@ -0,0 +1,479 @@
# RigSpec Reference
Version: 0.2 (pod-aware)
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
---
## Minimal Valid Example
```yaml
version: "0.2"
name: my-rig
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: "."
edges: []
edges: []
```
## Complete Example (all features)
```yaml
version: "0.2"
name: my-product-team
summary: A full product squad with orchestration, development, and review pods.
culture_file: culture/CULTURE.md
docs:
- path: SETUP.md
- path: README.md
startup:
files:
- path: guidance/team-norms.md
delivery_hint: guidance_merge
required: true
actions: []
services:
kind: compose
compose_file: docker-compose.yaml
project_name: my-product
profiles: [core]
down_policy: down
wait_for:
- url: http://127.0.0.1:5432/health
- service: redis
condition: healthy
surfaces:
urls:
- name: App
url: http://127.0.0.1:3000
commands:
- name: psql
command: "psql postgresql://app:[email protected]:5432/app"
checkpoints:
- id: postgres
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
pods:
- id: orch
label: Orchestration
members:
- id: lead
agent_ref: "local:agents/orchestrator"
profile: default
runtime: claude-code
cwd: "."
- id: peer
agent_ref: "local:agents/orchestrator"
profile: default
runtime: codex
cwd: "."
edges: []
- id: dev
label: Development
summary: Implementation and quality assurance pair.
continuity_policy:
enabled: true
sync_triggers: [pre_compaction, pre_shutdown]
artifacts:
session_log: true
restore_brief: true
restore_protocol:
peer_driven: true
verify_via_quiz: false
startup:
files:
- path: guidance/dev-sop.md
delivery_hint: guidance_merge
required: true
actions: []
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: "."
label: "Implementation Lead"
model: claude-opus-4-6
restore_policy: resume_if_possible
startup:
files:
- path: guidance/impl-specific.md
delivery_hint: send_text
required: false
applies_on: [fresh_start]
actions:
- type: send_text
value: "Load the implementation-pair skill and begin."
phase: after_ready
idempotent: true
- id: qa
agent_ref: "local:agents/qa"
profile: default
runtime: codex
cwd: "."
edges:
- kind: delegates_to
from: impl
to: qa
- id: rev
label: Review
members:
- id: r1
agent_ref: "local:agents/reviewer"
profile: default
runtime: claude-code
cwd: "."
- id: r2
agent_ref: "local:agents/reviewer"
profile: default
runtime: codex
cwd: "."
edges: []
edges:
- kind: delegates_to
from: orch.lead
to: dev.impl
- kind: delegates_to
from: orch.peer
to: dev.qa
- kind: can_observe
from: rev.r1
to: dev.impl
- kind: can_observe
from: rev.r2
to: dev.qa
```
---
## Top-Level Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `version` | string | yes | — | Must be `"0.2"` for pod-aware specs. |
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
| `summary` | string | no | — | Human-readable description. Shown in spec library, review surfaces, and `rig specs show`. |
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, services boot before any agent launches. |
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
---
## Pod
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
| `summary` | string | no | — | Pod description. |
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy. Controls compaction recovery, artifact management, and peer-driven restoration. |
| `startup` | StartupBlock | no | — | Pod-level startup files and actions. Applied to all members in this pod via the startup layering model. |
| `members` | Member[] | yes | — | At least one member required (enforced by pods needing content). |
| `edges` | PodLocalEdge[] | no | `[]` | Edges between members within this pod. Must use unqualified member IDs (not `pod.member`). |
### Pod ID Rules
- Must not contain dots (`.`)
- Must be unique across all pods in the rig
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
---
## Member
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `terminal`. |
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### Terminal Nodes
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
```yaml
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
```
All three must be present together. Any partial combination is a validation error.
### agent_ref Rules
- Must start with `local:` or `path:`
- `local:` paths are relative to the rig spec file's directory (the rig root)
- `path:` paths are absolute filesystem paths
- The referenced path must contain an `agent.yaml` file
- Exception: `builtin:terminal` for terminal nodes
### Session Naming
The canonical session name is derived from the pod ID, member ID, and rig name:
```
{podId}-{memberId}@{rigName}
```
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
---
## Edges
### Edge Kinds
| Kind | Meaning | Use When |
|------|---------|----------|
| `delegates_to` | Source delegates work to target. Constrains launch order. | Orchestrator → implementer, lead → worker |
| `spawned_by` | Target was spawned by source. Constrains launch order. | Parent → child in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
### Pod-Local Edges
Edges within a pod use **unqualified member IDs** (just the member `id`, not `pod.member`):
```yaml
pods:
- id: dev
members:
- id: impl
# ...
- id: qa
# ...
edges:
- kind: delegates_to
from: impl # NOT dev.impl
to: qa # NOT dev.qa
```
Both `from` and `to` must reference members that exist in the same pod.
### Cross-Pod Edges
Edges between pods use **fully-qualified `pod.member` IDs**:
```yaml
edges:
- kind: delegates_to
from: orch.lead # pod.member format
to: dev.impl # pod.member format
```
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
---
## Startup Block
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see `docs/reference/startup-layering.md`).
### Files
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
#### Delivery Hints
| Hint | Behavior |
|------|----------|
| `auto` | System chooses based on file type and context. |
| `guidance_merge` | Merged into the runtime's guidance file (`CLAUDE.md` or `AGENTS.md`) as a managed block. Delivered before harness boot. |
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
### Actions
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`. Note: `shell` is explicitly NOT supported in v1. |
| `value` | string | yes | — | The command or text to send. |
| `phase` | string | no | `after_files` | When to execute. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). |
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
---
## Services Block
The services block is optional. When present, services boot before any agent node launches. If service health checks fail, agent launch is blocked.
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `kind` | string | yes | — | Service backend. Only `compose` is supported in v1. |
| `compose_file` | string | yes | — | Relative path to the Docker Compose file. Must be a safe relative path. Resolved relative to rig root. |
| `project_name` | string | no | derived from rig name | Docker Compose project name. Must match `[a-z0-9][a-z0-9_-]*`. If omitted, derived by sanitizing the rig name. |
| `profiles` | string[] | no | — | Compose profiles to activate. |
| `down_policy` | string | no | `down` | What happens on `rig down`. One of: `leave_running`, `down`, `down_and_volumes`. |
| `wait_for` | WaitTarget[] | no | — | Health targets that must pass before agent launch. |
| `surfaces` | Surfaces | no | — | Metadata about accessible URLs and commands. Not executed — informational only. |
| `checkpoints` | CheckpointHook[] | no | — | Shell commands for checkpoint export/import during snapshot/restore. |
### Wait Targets
Each target must define exactly one of `service`, `url`, or `tcp`:
```yaml
wait_for:
# HTTP probe — hits the URL, expects 2xx
- url: http://127.0.0.1:8200/v1/sys/health
# TCP probe — connects to host:port
- tcp: "127.0.0.1:5432"
# Compose health check — requires Docker health to report "healthy"
- service: postgres
condition: healthy
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | one of three | HTTP URL to probe. |
| `tcp` | string | one of three | `host:port` for TCP probe. |
| `service` | string | one of three | Compose service name. Requires `condition: healthy`. |
| `condition` | string | only with `service` | Must be `healthy`. Only valid with `service` targets. |
### Surfaces
```yaml
surfaces:
urls:
- name: Vault UI
url: http://127.0.0.1:8200/ui
commands:
- name: Vault status
command: "vault status -address=http://127.0.0.1:8200"
```
Surfaces are metadata only. They are displayed in the UI and in `rig env status` output but are NOT executed by OpenRig.
### Checkpoint Hooks
```yaml
checkpoints:
- id: postgres
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | yes | Unique identifier for this checkpoint. |
| `export` | string | yes | Shell command to export state. `{{artifacts_dir}}` is replaced with a daemon-managed path. |
| `import` | string | no | Shell command to import state on restore. |
Checkpoint hooks are shell commands run by the daemon. They are best-effort — a failed export does not block snapshot, but continuity is classified as `receipt_only` instead of `checkpointed`.
---
## Continuity Policy
Optional pod-level configuration for compaction recovery behavior.
```yaml
continuity_policy:
enabled: true
sync_triggers: [pre_compaction, pre_shutdown, manual, milestone]
artifacts:
session_log: true
restore_brief: true
quiz: false
restore_protocol:
peer_driven: true
verify_via_quiz: false
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `enabled` | boolean | yes | — | Whether continuity is active for this pod. |
| `sync_triggers` | string[] | no | — | When to sync. Values: `pre_compaction`, `pre_shutdown`, `manual`, `milestone`. |
| `artifacts.session_log` | boolean | no | — | Whether to maintain a session log. |
| `artifacts.restore_brief` | boolean | no | — | Whether to maintain a restore brief. |
| `artifacts.quiz` | boolean | no | — | Whether to use quiz-based verification. |
| `restore_protocol.peer_driven` | boolean | no | — | Whether peers drive the restore process. |
| `restore_protocol.verify_via_quiz` | boolean | no | — | Whether to verify restoration via quiz. |
---
## Validation Rules Summary
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
1. `version` and `name` are required non-empty strings.
2. `pods` must be a non-empty array.
3. Pod IDs must not contain dots and must be unique.
4. Pod labels are required.
5. Member IDs must not contain dots and must be unique within their pod.
6. `agent_ref`, `profile`, `runtime`, and `cwd` are required for every member.
7. Terminal nodes require the exact triple: `runtime: terminal`, `agent_ref: builtin:terminal`, `profile: none`.
8. `agent_ref` must start with `local:` (relative) or `path:` (absolute), except `builtin:terminal`.
9. `local:` refs must be relative paths. `path:` refs must be absolute paths.
10. `restore_policy` must be one of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`.
11. Pod-local edges use unqualified member IDs. Cross-pod edges use `pod.member` format.
12. Cross-pod edges must reference different pods.
13. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`.
14. All file paths (`culture_file`, startup file paths, `compose_file`) must be safe relative paths.
15. `services.kind` must be `compose`.
16. `services.compose_file` is required when services is present.
17. `services.project_name` must match `[a-z0-9][a-z0-9_-]*`.
18. `services.down_policy` must be one of: `leave_running`, `down`, `down_and_volumes`.
19. Each wait target must define exactly one of: `service`, `url`, `tcp`.
20. `condition` is only valid on `service` targets and must be `healthy`.
21. Startup file `delivery_hint` must be one of: `auto`, `guidance_merge`, `skill_install`, `send_text`.
22. Startup action `type` must be one of: `slash_command`, `send_text`. (`shell` is explicitly rejected.)
23. Startup action `phase` must be one of: `after_files`, `after_ready`.
24. Startup action `idempotent` is a required boolean.
25. Non-idempotent actions must not include `restore` in `applies_on`.
26. `applies_on` values must be from: `fresh_start`, `restore`.
---
## Shipped Examples
These are the built-in specs shipped with OpenRig. Read them as worked examples.
| Spec | Location | Pods | Members | Services |
|------|----------|------|---------|----------|
| `product-team` | `packages/daemon/specs/rigs/product-team.yaml` | orch, dev, rev | 7 (lead, peer, impl, qa, design, r1, r2) | no |
| `implementation-pair` | `packages/daemon/specs/rigs/implementation-pair.yaml` | dev | 2 (impl, qa) | no |
| `adversarial-review` | `packages/daemon/specs/rigs/adversarial-review.yaml` | orch, rev | 3 (lead, r1, r2) | no |
| `research-team` | `packages/daemon/specs/rigs/research-team.yaml` | orch, research | 3 (lead, analyst, synthesizer) | no |
| `secrets-manager` | `packages/daemon/specs/rigs/launch/secrets-manager/rig.yaml` | vault | 1 (specialist) | yes (Vault) |
+66
View File
@@ -0,0 +1,66 @@
# Release Notes
This directory is the lightweight release history for OpenRig.
It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.2.0.md`
- `v0.3.0.md`
- and so on
## Why This Exists
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
- npm package release
- optional Git tag
- optional GitHub Release
- short, human-written summary of what is included
This keeps release notes:
- easy to author
- easy to link in GitHub Releases
- easy to paste into announcements
- durable in the repo
## Minimal Process
For each release:
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
3. Create a git tag for the release:
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. Create a GitHub Release using the same file:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
```
5. Publish the npm package if that is part of the release flow.
## Guidance
- Prefer user-facing language over commit-log language.
- Group related fixes into a small number of bullets.
- Be explicit about operator-impacting changes: setup, permissions, startup state, restore, recovery, and environment notes.
- Keep internal refactors out unless they materially change user behavior or operator confidence.
- If verification was limited, say so directly.
## Scope
This directory is a release-note archive, not a full historical changelog taxonomy.
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
+38
View File
@@ -0,0 +1,38 @@
# OpenRig vX.Y.Z
## Summary
One short paragraph describing the release at a human level.
## Included In This Release
- Change or fix cluster 1
- Change or fix cluster 2
- Change or fix cluster 3
## User-Facing Changes
- What users or operators will notice
- What behavior is different now
- Any new classifications, flows, or visible fixes
## Operator / Setup Notes
- Any upgrade, restart, or environment notes
- Any migration or manual action notes
- Any caveats for fresh installs or managed sessions
## Known Limitations
- Honest limitations that still exist after this release
- Keep this short and specific
## Verification Performed
- Local targeted tests
- VM or fresh-install validation
- Manual operator-path verification
## Commits Included
- Optional: list the main commits if useful for maintainers
+58
View File
@@ -0,0 +1,58 @@
# OpenRig v0.1.12
## Summary
This release hardens startup-state truth, improves transcript cleanup, and makes restore/recovery behavior more reliable across Claude and Codex managed sessions.
## Included In This Release
- Normalize user-clearable startup blockers so they do not masquerade as hard failures.
- Clear stale Claude startup/error residue more aggressively.
- Improve Codex restore continuity detection and resume metadata capture.
- Fix transcript redraw and status-noise cleanup.
- Harden Claude managed-session startup around MCP-related blockers.
- Improve operational reliability around tmux health and instantiate timing.
## User-Facing Changes
- Nodes blocked on interactive startup prompts are now normalized as user-clearable startup blockers instead of being left in misleading stale-failure states.
- Claude startup state is cleaned up more aggressively when current runtime evidence proves the old failure residue is stale.
- Codex restore behavior is more robust when resuming wrapped processes and when the TUI has scrolled or rendered extra footer/header noise.
- Transcript cleanup is less noisy and better at removing redraw/status artifacts without leaving the terminal output in a confusing state.
## Operator / Setup Notes
- After upgrading, restart the daemon so new startup and restore behavior is active:
```bash
rig daemon stop
rig daemon start
```
- This release improves classification and recovery behavior, but it does not turn every interrupted runtime into guaranteed native continuity. Operators should still treat restore results honestly and use manual recovery paths when the product says continuity could not be proved.
## Known Limitations
- Lifecycle/state handling is more truthful than before, but OpenRig still does not have a fully explicit infrastructure-grade state machine.
- Restore remains runtime-specific and evidence-driven; some recovery paths still rely on heuristics rather than perfect provider-native primitives.
## Verification Performed
- Targeted daemon and CLI test coverage during the release workstream.
- Focused review and validation of the transcript-cleanup change set.
- Fresh-VM npm-install verification of the published `@openrig/cli` package, including daemon start, health route, doctor checks, `rig ps --nodes`, and UI load.
## Commits Included
- `8b07461` `cli: detect unhealthy tmux control sockets`
- `8ca8e43` `daemon: ignore stale codex update banner once tui is live`
- `a8adfe2` `daemon: capture codex resume ids from wrapped processes`
- `e4619cf` `daemon: recognize resumed codex tui after header scrolls`
- `30cf604` `daemon: accept codex footer with trailing blank lines`
- `c44507f` `harden restore continuity and recovery guidance`
- `105ed7e` `fix Claude MCP startup blockers for managed sessions`
- `848b032` `cli: extend import instantiate timeout budget`
- `43a0ace` `daemon: reject pod-aware node relaunch via legacy route`
- `0d7e6ec` `daemon: normalize stale Claude startup state`
- `37e2d5f` `daemon: normalize user-clearable startup blockers`
- `74cf893` `fix: clean transcript redraw and status noise (#10)`
+276
View File
@@ -0,0 +1,276 @@
# OpenRig v0.2.0
## Summary
OpenRig v0.2.0 is a major step forward. It ships a durable
daemon-backed coordination layer — streams, queues, projects, views,
watchdog supervision, and workflow runtime — together with a redesigned
restore experience, an agent-starter registry, cross-host operations,
and a wide set of lifecycle and recovery hardening fixes. This is the
first public release after a long internal hardening cycle, so the
delta from v0.1.12 is large.
## Included In This Release
- A new daemon-owned coordination layer: `rig stream`, `rig queue`,
`rig project`, `rig view`, `rig watchdog`, and `rig workflow`.
- An agent-starter registry that lets RigSpec members reference named
startup-priming entries (`starter_ref`) instead of inlining them.
- A new restore-packet command surface for capturing agent
conversation packets across runtimes.
- New member-level launch source modes: `session_source: { mode:
rebuild }` and `session_source: { mode: fork }`.
- Cross-host operations: `rig ps`, `rig whoami`, `rig send`, and
`rig capture` accept `--host` for SSH-based shell-out.
- Substantial lifecycle, recovery, and runtime-truth improvements
across Claude Code and Codex managed sessions.
- Sixteen new SQLite migrations (021 → 036) to back the new
coordination, view, watchdog, and workflow tables, plus a few
observability and config-profile additions.
## User-Facing Changes
### New coordination commands
A new family of `rig` commands provides a durable, daemon-backed
coordination surface:
- `rig stream` — emit and query stream items; subscribe via SSE.
- `rig queue` — create, claim, hand off, and close queue items.
Includes `handoff`, `handoff-and-complete`, `update`, `show`,
`list`, `whoami`, `inbox-drop`, `inbox-absorb`, `outbox-drop`,
and `outbox-collect`.
- `rig project` — group classified work items into durable projects
with classifier lease/liveness/reclaim semantics.
- `rig view` — query built-in and custom views over coordination
state; consume change events via SSE.
- `rig watchdog` — schedule periodic and conditional jobs with
daemon-native supervision.
- `rig workflow` — run multi-step workflows with durable trail
records and a workflow-keepalive job.
These commands persist their state in the OpenRig daemon's SQLite
database, are observable through the daemon API, and emit
`view.changed` and related SSE events on update.
### New `starter_ref` member field
`rig up` now accepts `starter_ref` on RigSpec members. The daemon
resolves the named entry from the `agent-starters/` registry at
launch and merges its content into the seat's per-cwd guidance file
(`CLAUDE.md` for Claude, `AGENTS.md` for Codex). Inputs containing
credential markers are refused with a redacted refusal envelope.
`starter_ref` is mutually exclusive with inline `session_source` on
the same member, and is rejected upfront in plan-mode for terminal
runtimes and fork compositions.
### New `session_source` modes
Members may now declare:
- `session_source: { mode: rebuild }` — rebuild a member from a
named artifact set on every materialize.
- `session_source: { mode: fork }` — fork a new member from an
existing source on launch.
### Restore-packet command surface
A new `rig restore-packet` family captures restorable conversation
packets across runtimes. It includes parsers and redaction for
Claude and Codex transcripts, schema validation, packet writing, and
a read/validate flow. There is also a `rig restore-check` command
for restore-readiness checks.
### Cross-host operations
`rig ps`, `rig whoami`, `rig send`, and `rig capture` now accept
`--host <ssh-host>` to run against a remote machine. The CLI
shells out over SSH; no long-lived agent or daemon needs to be on
the remote machine ahead of the call.
### Lifecycle, recovery, and runtime-truth improvements
- New lifecycle classifications: `recoverable`, `degraded`, and
`attention_required`. Codex auth-refusal now surfaces as
`attention_required` end-to-end.
- `rig up <rigName>` falls back to a manual snapshot when no
auto-pre-down snapshot exists, so launches no longer fail
silently in that path.
- Restore now has an explicit attempt id, and runtime-truth
reconciliation prevents stale state from masquerading as live.
- Codex resume now verifies the pane via a native probe; rig expand
recovery guidance points to shrink+re-expand instead of trying to
launch a doomed rig.
- `rig ps --fields` now rejects unknown keys with a per-level
allow-list. `rig ps` also supports `rigName` aliasing and a
bounded output mode.
### Agents launched by the daemon now run plain `rig`
Codex seats launched by the daemon previously required an explicit
`OPENRIG_HOME`/`OPENRIG_PORT` env and a non-default binary path on
PATH. Daemon-launched seats now have plain `rig` available, so
agents and operators can use the standard CLI without per-seat env
gymnastics.
### Watchdog help, queue-actor inference, and other small fixes
- `rig watchdog --help` now lists the workflow-keepalive job kind.
- `rig queue` infers the actor from the managed session when no
explicit actor is provided.
- The CLI bin keeps its executable bit across builds.
- Chatroom wait test fixture is time-stable (no more flaky
`chatroom wait` test under sanitized environments).
## Operator / Setup Notes
After upgrading the published CLI:
```bash
npm install -g @openrig/[email protected]
rig --version # expect 0.2.0
```
You will need to restart the daemon to pick up the new schema and
behavior:
```bash
rig daemon stop
rig daemon start
```
The daemon will apply migrations 021-036 to its SQLite database on
first startup. Migrations are forward-only by convention; if you
need to roll back, restore the daemon DB from a snapshot and switch
the daemon binary back to the previous build before restart. Take a
DB snapshot before the first post-upgrade daemon start if you want a
clean rollback path:
```bash
cp ~/.openrig/openrig.sqlite ~/.openrig/openrig.sqlite.pre-v0.2.0.bak
```
If you maintain a managed control-plane environment with live rigs
mid-flight, plan the daemon restart as an upgrade window. The
upgrade is straightforward, but daemon stop/start interrupts active
SSE consumers and any in-progress `rig up` flow.
## Known Limitations
- The new coordination surfaces (`rig stream`, `rig queue`,
`rig project`, `rig view`, `rig watchdog`, `rig workflow`) are
daemon-backed. The host daemon must be on v0.2.0 to serve them; an
older daemon will not expose these commands and may silently
ignore opt-in fields like `starter_ref` on members.
- `runtime: claude` is not a valid RigSpec runtime spelling; use
`runtime: claude-code` for Claude managed seats. This is harness/
spec syntax, not a product limitation, but it has surprised
operators.
- Watchdog policies and workflow specs are persisted in the daemon
DB. There is currently no first-class CLI for editing custom
watchdog policies in bulk; expect more ergonomics in a future
release.
- The Phase E surfaces (rails, rendering, steering for the
coordination layer) are intentionally deferred to future product
lanes (PL-006/PL-007/PL-008) and are not part of v0.2.0.
- Lifecycle classification is more truthful than before, but
OpenRig still does not have a fully explicit infrastructure-grade
state machine. Some recovery paths still rely on heuristics
rather than perfect provider-native primitives.
## Verification Performed
- Sanitized full CLI suite under Node 22 with OpenRig env stripped:
56 files / 794 tests passing.
- Root `npm run build` and `npm run build:package` passing.
- Real-agent dogfood under Mode 3 isolated VM with VM-local OpenRig
daemon/state and real OpenRig-managed Claude Code and Codex seats:
Claude-only, Codex-only, and mixed Claude→Codex paths all
exercised end-to-end through the new `rig queue`, `rig stream`,
`rig workflow`, and `rig watchdog` surfaces.
- VM-local global install of `@openrig/cli` from source, with
`rig --version` reporting the expected version and managed seats
running plain `rig`.
## Commits Included (highlights)
The full delta from `v0.1.12` to `e40214f` is 117 commits, 277
files changed, 49,209 insertions and 683 deletions. The list below
groups the load-bearing commits by theme:
### Coordination primitive system
- `d8d8ee9 feat(daemon,cli): coordination primitive system — stream/queue/inbox/outbox + hot-potato strict-rejection`
- `fd4fb30 fix(daemon,cli): topology-backed validation + default nudge + handoff-and-complete + whoami + SSE alias + CLI tests`
- `194a1eb fix(daemon): SSE route-order shadowing fix`
- `4ac0d20 feat(daemon,cli): L2 Project (Classifier) + L5 View`
- `9f86df4 fix(daemon,cli): L1→L2 FK + view.changed event bridge`
- `629d061 fix(daemon): queue.updated event + bridge mapping for general state mutations`
- `48c0267 feat(daemon,cli): daemon-native Watchdog supervision tree`
- `dd9820e fix(daemon): watchdog policy POC contract parity + active-wake throttle`
- `1a3b313 fix(daemon): yaml-package frontmatter parser + scan_interval scheduler test`
- `55bdcbb feat(daemon,cli): daemon-native Workflow Runtime + workflow-keepalive`
- `bf28cc2 fix(daemon): workflow projection routes through queue closure primitive + waiting frontier preservation + failed status`
- `1c94719 fix(daemon): durable current-step binding + allowed_exits enforcement at projection`
- `3e32b0a fix(daemon): map exit_not_allowed to HTTP 400 + route regression`
- `05ca19c fix(daemon): workflow_step_trails listForInstance ordered by rowid (deterministic insertion order)`
- `e40214f fix: include workflow keepalive in watchdog help`
### Restore-Packet surface
- `de2dbd4 feat(cli): restore-packet schema + validator + CLI shell + wiring`
- `40d0724 fix(cli): restore-packet schema packaging fix`
- `bdf4cf6 feat(cli): restore-packet runtime parsers + runtime-detect + redaction + omitted-records`
- `5c87145 fix(cli): Claude parser nested tool_use/tool_result handling`
- `e5de3ab feat(cli): packet-writer atomic emission + write action impl`
- `a8401f6 fix(cli): --source-jsonl provenance from parsed session_meta`
- `c7b74fa feat(daemon,cli): full-read transcript route + final M2 regression`
- `a5132da feat(cli): rig restore-packet read + validate actual implementations`
- `ab822ee test(cli): command-registration meta-tests + final regression check`
### Agent Starter v1 vertical
- `eb4acf2 feat(daemon): Agent Starter v1 schema + resolver scaffolding`
- `2673e30 fix(daemon): codec emits starter_ref; expansion preserves it; resolver redacts match-line`
- `2f8f269 feat(daemon): Agent Starter v1 adapter pass-through + rig up integration`
- `6af2754 test(daemon, cli): Agent Starter v1 real-adapter, route end-to-end, CLI plan content, continuityOutcome + SQLite roundtrip`
### session_source rebuild and fork modes
- `c7b6df1 feat(daemon): session_source: { mode: fork } member-level launch input`
- `578bd5c feat(daemon): session_source: { mode: rebuild }`
### Cross-host operations
- `cdce3a6 feat(cli): cross-host rig send/capture via --host (CLI-side ssh shell-out)`
- `6b7043a feat(cli): rig ps and rig whoami accept --host`
### Lifecycle, recovery, and runtime-truth
- `3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile`
- `fad10a9 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection`
- `66785c8 feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation`
- `e70694f fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists`
- `0a9fb43 feat(daemon,cli): add rigName alias, lifecycleState column, and bounded ps output options`
- `4e277d7 docs(cli-reference): bring rig ps section in line with shipped 0a9fb43`
- `315745a feat(cli): rig ps --fields rejects unknown keys with per-level allow-list`
- `ab01331 feat(daemon): codex resume verifies pane via native probe`
- `fcfc7dc fix(cli): rig expand recovery guidance points to shrink+re-expand, not doomed rig launch`
- `63ee206 feat(daemon): codex auth-refusal surfaces as attention_required end-to-end`
- `53e9409 fix(daemon): codex auth-refusal surfaces as attention_required across pod-aware launch path`
### Final fixes during release loop
- `934f3c9 fix: launch codex seats with full access posture`
- `fe5b23e fix: propagate daemon env to launched seats`
- `cff08c7 test: make chatroom wait fixture time-stable`
- `a536df3 fix: preserve cli bin executable bit on build`
- `90b1772 fix: infer queue actor from managed session`
### Recovery markers
- `7a0ae0f chore: mark PL-004 Phase D checkout recovery`
The remaining ~50 commits are smaller fixes, test additions, and
docs updates that compose into the themed groups above. See `git
log v0.1.12..v0.2.0` for the full list.
+119
View File
@@ -0,0 +1,119 @@
# OpenRig v0.3.0
## Summary
OpenRig v0.3.0 turns the 0.2 coordination substrate into a more polished
operator product. It ships the redesigned shell, generic workflow starters,
mission-shaped workspace support, project observability, curated bundled skills,
runtime graphics, context telemetry, and release-hardening work for a cleaner
fresh install.
This release keeps the reusable primitives for queue handoff, workflow runtime,
watchdog supervision, artifact/proof surfaces, and agent coordination, while
the public starter content is intentionally generic.
## Included In This Release
- V1 shell redesign with the vellum visual system, two desktop chrome regions,
topology graph/table/terminal modes, drawer viewers, Project, For You, Queue,
Library, Settings, and proof-oriented detail surfaces.
- Generic starter workflow specs:
- `conveyor` for station-style work where multiple packets can be active
across the topology at once.
- `basic-loop` for a one-packet-at-a-time walkthrough flow.
- `conveyor` starter rig and matching agent specs for intake/planning/building
and review-oriented work.
- Mission-shaped workspace defaults and read-only nested mission/slice indexing.
- Project observability upgrades across For You, Story, Queue, Tests, Project
rollups, proof screenshots, and qitem drawers.
- Runtime/tool graphics package applied across topology, queue, project metadata,
proof rows, terminal previews, and agent identity marks.
- Terminal preview improvements, activity indicators, handoff edge animation,
and context usage telemetry in topology table/terminal views.
- Runtime skill discovery from filesystem skill roots during profile resolution.
- Pre-release CLI/daemon cleanup: bounded transcript rotation, `rig send`
message envelopes, OpenRig install/init marker naming, and public-release
terminology cleanup.
## User-Facing Changes
### Redesigned Shell
The UI now uses the V1 shell model with a narrow rail, destination-specific
Explorer region, center workspace, and named-trigger drawer. Topology pages have
graph, table, and terminal modes at one stable URL. Drawer viewers handle queue
items, files, sub-spec previews, terminal previews, and proof assets without
turning every reference click into a full-page navigation.
### Starter Workflows And Rigs
The release includes generic workflow examples rather than project-specific
automation recipes. `conveyor` demonstrates station-based flow with natural
queue backpressure. `basic-loop` demonstrates a simple one-token-at-a-time loop.
The `conveyor` rig is the public workflow starter. `product-team` remains the
more advanced human-operator-driven starter topology.
### Project And Proof Visibility
Project surfaces now distinguish current and archived work, show mission/slice
hierarchy, route mission labels to mission pages, and expose story/tests/proof
rollups. For You and Queue cards hydrate qitem bodies, proof images, audit
outcomes, and next-action state. Tests proof screenshots open in an in-page
viewer.
### Topology And Runtime Visibility
Topology now has a hybrid multi-rig graph, expand/collapse controls, activity
rings, animated handoff edges, runtime/tool marks, context percentage/token
displays, and a terminal fallback view. Agent detail surfaces now use the
canonical live node details view.
### Skills And Starter Guidance
Bundled skills were curated for starter coherence. Runtime skill discovery now
finds valid `SKILL.md` files in runtime and workspace skill roots, with
rig-local resources taking precedence over broader user libraries.
## Operator / Setup Notes
- Fresh workspace defaults are mission-shaped and derive from
`workspace.root`. Use `rig config init-workspace` to create the default
workspace structure.
- The repo uses npm workspaces and the tracked root `package-lock.json`.
`pnpm-lock.yaml` files are not release artifacts.
- Root-level runtime projection files such as `CLAUDE.md` are intentionally not
tracked in the product repo.
- Deprecated `RIGGED_*` aliases remain only for the original runtime env keys:
`daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, and
`transcripts.path`. New typed config keys use `OPENRIG_*` only.
- Existing local workflow specs can still be installed from workspace/spec
locations; product source only bundles the generic starter workflows.
## Known Limitations
- Plugin support is not part of 0.3.0. Plugin-packaged skills and external
plugin distribution are planned for a later release.
- The public starter workflows are examples, not a complete packaged automation
methodology.
- Some broad CLI tests are sensitive to local daemon port/config state. Focused
release-path tests and build/lint gates were used for the final push gate.
## Verification Performed
- `npm run lint`
- `npm run build`
- `npm run test:repo`
- Focused CLI, daemon, and UI tests for release hygiene, config boundaries,
starter skill discovery, shell/topology behavior, and project observability.
- Source scans for personal paths, internal attribution phrasing, default
fallback assumptions, and unwanted legacy env aliases on new typed keys.
## Commits Included
This release contains the accumulated product work since v0.2.0, including:
- Shell redesign V1 phases and polish slices.
- Project observability phases A-G plus follow-up fixes.
- Graphics package and broadened runtime/tool marks.
- Pre-release UI and CLI/daemon hardening.
- 0.3.0 starter refresh and release hygiene commits.
+59 -6
View File
@@ -1,19 +1,20 @@
{
"name": "openrig",
"version": "0.1.3",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.1.3",
"version": "0.3.0",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
"packages/ui",
"packages/cli"
],
"engines": {
"node": ">=20"
"node": "^20 || ^22 || ^24"
}
},
"node_modules/@adobe/css-tools": {
@@ -2295,6 +2296,26 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.168.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.3.tgz",
@@ -2327,6 +2348,19 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4592,6 +4626,12 @@
"node": ">=6"
}
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -7379,14 +7419,18 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.1.3",
"version": "0.3.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^11.8.0",
"commander": "^13.0.0",
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
@@ -7399,11 +7443,15 @@
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": ">=20"
}
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.1.3",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"better-sqlite3": "^11.8.0",
@@ -7419,11 +7467,15 @@
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": "^20 || ^22 || ^24"
}
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.1.3",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
@@ -7435,6 +7487,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.3",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.10.1",
"autoprefixer": "^10.4.27",
"class-variance-authority": "^0.7.1",
+9 -4
View File
@@ -1,7 +1,8 @@
{
"name": "openrig",
"version": "0.1.3",
"version": "0.3.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"workspaces": [
"packages/daemon",
@@ -10,11 +11,15 @@
],
"scripts": {
"build": "npm run build --workspaces",
"test": "npm run test --workspaces",
"test": "npm run test:repo && npm run test:workspaces",
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check",
"test:workspaces": "npm run test --workspaces",
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
"build:package": "bash scripts/build-package.sh"
"build:package": "bash scripts/build-package.sh",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check"
},
"engines": {
"node": ">=20"
"node": "^20 || ^22 || ^24"
}
}
+11 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.1.3",
"version": "0.3.0",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
@@ -31,10 +31,12 @@
"files": [
"dist",
"daemon",
"ui"
"ui",
"scripts"
],
"scripts": {
"build": "tsc",
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
"test:watch": "vitest"
@@ -42,9 +44,12 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^11.8.0",
"commander": "^13.0.0",
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
@@ -54,5 +59,8 @@
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": ">=20"
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
// Postinstall ABI sanity check for @openrig/cli.
// Fails loudly if better-sqlite3 native binary does not match the active
// Node runtime. Layered on top of the engines.node constraint in
// package.json — engines warns at npm level; this hard-blocks at install
// time with a copy-paste fix.
/**
* @param {{ nodeVersion: string, loadNativeAddon: () => void }} deps
* @returns {{ ok: true } | { ok: false, message: string }}
*/
export function checkAbi({ nodeVersion, loadNativeAddon }) {
// Phase 1: version-range check (fast path).
// Even-numbered Node majors (20, 22, 24) are LTS lines with native addon
// prebuilds. Odd-numbered majors (21, 23, 25) generally lack them.
const match = nodeVersion.match(/^v?(\d+)/);
const major = match ? parseInt(match[1], 10) : 0;
if (major < 20) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
` ║ @openrig/cli requires Node.js 20, 22, or 24 (LTS). ║`,
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
].join("\n"),
};
}
if (major % 2 !== 0) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
` ║ @openrig/cli does not support odd-numbered Node releases. ║`,
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Odd Node versions (21, 23, 25, …) lack native addon ║",
" ║ prebuilds for better-sqlite3. The daemon will fail. ║",
" ║ ║",
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
].join("\n"),
};
}
// Phase 2: ABI load check (catches edge cases like local Node builds
// or prebuild packaging gaps on supported versions).
try {
loadNativeAddon();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
const isAbiMismatch = detail.includes("NODE_MODULE_VERSION");
if (isAbiMismatch) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
" ║ better-sqlite3 native binary does not match this Node. ║",
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: npm rebuild better-sqlite3 ║",
" ║ or: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
` Detail: ${detail}`,
"",
].join("\n"),
};
}
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
" ║ better-sqlite3 native addon failed to load. ║",
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: npm rebuild better-sqlite3 ║",
" ║ If that fails, reinstall with a supported Node version: ║",
" ║ nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
` Detail: ${detail}`,
"",
].join("\n"),
};
}
return { ok: true };
}
// --- Run when executed as postinstall script ---
const isMain =
process.argv[1] &&
(import.meta.url === `file://${process.argv[1]}` ||
process.argv[1].endsWith("check-abi.mjs"));
if (isMain) {
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const result = checkAbi({
nodeVersion: process.version,
loadNativeAddon: () => require("better-sqlite3"),
});
if (!result.ok) {
console.error(result.message);
process.exitCode = 1;
}
}
+50 -1
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env node
import path from "node:path";
import { realpathSync } from "node:fs";
import { existsSync, realpathSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
const packageRoot = path.resolve(path.dirname(wrapperPath), "..");
@@ -19,6 +22,51 @@ export function isDirectRun(argv1 = process.argv[1], moduleUrl = import.meta.url
}
}
export function resolveNodeReexecBinary(
currentExecPath = process.execPath,
invokedPath = process.argv[1],
env: NodeJS.ProcessEnv = process.env,
exists: (candidate: string) => boolean = existsSync,
realpath: (candidate: string) => string = realpathSync,
): string | null {
if (!invokedPath) return null;
if (env[REEXEC_GUARD_ENV] === "1") return null;
try {
const binDir = realpath(path.dirname(invokedPath));
const siblingNode = path.join(binDir, process.platform === "win32" ? "node.exe" : "node");
if (!exists(siblingNode)) return null;
const normalizedSiblingNode = realpath(siblingNode);
const normalizedCurrentExec = realpath(currentExecPath);
if (normalizedSiblingNode === normalizedCurrentExec) return null;
return normalizedSiblingNode;
} catch {
return null;
}
}
function maybeReexecWithSiblingNode(argv = process.argv): void {
const preferredNode = resolveNodeReexecBinary(process.execPath, argv[1], process.env);
if (!preferredNode) return;
const scriptPath = argv[1] ? realpathSync(argv[1]) : fileURLToPath(import.meta.url);
const result = spawnSync(preferredNode, [scriptPath, ...argv.slice(2)], {
stdio: "inherit",
env: {
...process.env,
[REEXEC_GUARD_ENV]: "1",
},
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 0);
}
export async function run(argv = process.argv): Promise<void> {
const normalizedArgv = [...argv];
if (normalizedArgv[1]) {
@@ -31,5 +79,6 @@ export async function run(argv = process.argv): Promise<void> {
}
if (isDirectRun()) {
maybeReexecWithSiblingNode(process.argv);
await run(process.argv);
}
+24 -19
View File
@@ -14,6 +14,10 @@ export interface DaemonResponse<T = unknown> {
data: T;
}
interface DaemonRequestOptions {
timeoutMs?: number;
}
interface DaemonClientOptions {
fetchImpl?: typeof fetch;
timeoutMs?: number;
@@ -42,51 +46,52 @@ export class DaemonClient {
this.timeoutMs = options?.timeoutMs ?? 5_000;
}
async get<T = unknown>(path: string): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "GET" });
async get<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "GET" }, options);
}
async getText(path: string): Promise<DaemonResponse<string>> {
return this.requestText(path, { method: "GET" });
async getText(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
return this.requestText(path, { method: "GET" }, options);
}
async post<T = unknown>(path: string, body?: unknown): Promise<DaemonResponse<T>> {
async post<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}, options);
}
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>): Promise<DaemonResponse<T>> {
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, {
method: "POST",
headers: { "Content-Type": contentType, ...extraHeaders },
body: text,
});
}, options);
}
async postExpectText(path: string, body?: unknown): Promise<DaemonResponse<string>> {
async postExpectText(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
return this.requestText(path, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}, options);
}
async delete<T = unknown>(path: string): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "DELETE" });
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "DELETE" }, options);
}
private async fetch(path: string, init: RequestInit): Promise<Response> {
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
try {
return await fetchWithTimeout(
this.fetchImpl,
`${this.baseUrl}${path}`,
init,
{
timeoutMs: this.timeoutMs,
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${this.timeoutMs}ms`,
timeoutMs,
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${timeoutMs}ms`,
},
);
} catch (err) {
@@ -95,14 +100,14 @@ export class DaemonClient {
}
}
private async requestJson<T>(path: string, init: RequestInit): Promise<DaemonResponse<T>> {
const res = await this.fetch(path, init);
private async requestJson<T>(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
const res = await this.fetch(path, init, options);
const data = (await res.json()) as T;
return { status: res.status, data };
}
private async requestText(path: string, init: RequestInit): Promise<DaemonResponse<string>> {
const res = await this.fetch(path, init);
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
const res = await this.fetch(path, init, options);
const data = await res.text();
return { status: res.status, data };
}
+87
View File
@@ -0,0 +1,87 @@
import path from "node:path";
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
export const CMUX_SETTINGS_DISCLOSURE_PATH = "~/.config/cmux/settings.json";
export const CMUX_DEFAULT_SOCKET_CONTROL_MODE = "cmuxOnly";
const CMUX_INCOMPATIBLE_MODES = new Set(["off", "cmuxOnly"]);
export interface CmuxSettingsReadResult {
mode: string;
source: "settings" | "default";
error?: string;
}
export function resolveCmuxSettingsPath(homeDir = process.env["HOME"] ?? "~"): string {
return path.join(homeDir, ".config", "cmux", "settings.json");
}
export function isCmuxSocketControlCompatible(mode: string | null): boolean {
return typeof mode === "string" && !CMUX_INCOMPATIBLE_MODES.has(mode);
}
export function readCmuxSocketControlModeFromText(text: string | null): CmuxSettingsReadResult {
if (!text || !text.trim()) {
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
const errors: ParseError[] = [];
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
if (errors.length > 0) {
return {
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
source: "settings",
error: formatParseErrors(errors),
};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
source: "settings",
error: "cmux settings must contain a JSON object.",
};
}
const automation = parsed["automation"];
if (!automation || typeof automation !== "object" || Array.isArray(automation)) {
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
const mode = automation["socketControlMode"];
if (typeof mode === "string" && mode.trim()) {
return { mode, source: "settings" };
}
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
export function upsertCmuxSocketControlMode(text: string | null, mode: string): { content: string; changed: boolean } {
const source = text && text.trim() ? text : "{\n}\n";
const edits = modify(source, ["automation", "socketControlMode"], mode, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
eol: "\n",
},
});
if (edits.length === 0) {
return { content: ensureTrailingNewline(source), changed: false };
}
return {
content: ensureTrailingNewline(applyEdits(source, edits)),
changed: true,
};
}
function ensureTrailingNewline(text: string): string {
return text.endsWith("\n") ? text : `${text}\n`;
}
function formatParseErrors(errors: ParseError[]): string {
return errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join("; ");
}
+401
View File
@@ -0,0 +1,401 @@
// Fork Primitive + Starter Agent Images v0 (PL-016) — `rig agent-image`
// CLI verb family. Parallel to `rig context-pack` shipped in PL-014.
//
// Subcommands:
// create <source-session> --name <name> — capture image from a live seat
// list — list all images
// show <name-or-id> — manifest + stats
// preview <name-or-id> — assembled preview + starter snippet
// delete <name-or-id> [--force] — delete (subject to evidence guard)
// pin <name-or-id> — pin from prune
// unpin <name-or-id> — unpin
// prune [--force] [--dry-run] [--json] — bulk delete evictable images
// sync — re-walk discovery roots
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface AgentImageEntryWire {
id: string;
kind: "agent-image";
name: string;
version: string;
runtime: "claude-code" | "codex";
sourceSeat: string;
sourceSessionId: string;
notes: string | null;
createdAt: string;
sourceType: "user_file" | "workspace" | "builtin";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: Array<{
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}>;
sourceResumeToken: string;
stats: {
forkCount: number;
lastUsedAt: string | null;
estimatedSizeBytes: number;
lineage: string[];
};
lineage: string[];
pinned: boolean;
}
interface PreviewWire extends Omit<AgentImageEntryWire, "sourcePath" | "relativePath" | "updatedAt" | "kind" | "sourceResumeToken" | "sourceType"> {
starterSnippet: string;
}
interface PruneWire {
dryRun: boolean;
forced?: boolean;
protected?: Array<{
imageId: string;
imageName: string;
imageVersion: string;
reasons: string[];
references: string[];
}>;
evictable?: Array<{ imageId: string; imageName: string; imageVersion: string }>;
deleted?: string[];
errors?: Array<{ imageId: string; error: string }>;
}
async function resolveImage(client: DaemonClient, nameOrId: string): Promise<AgentImageEntryWire> {
if (nameOrId.startsWith("agent-image:")) {
const res = await client.get<AgentImageEntryWire>(`/api/agent-images/library/${encodeURIComponent(nameOrId)}`);
if (res.status === 200) return res.data;
if (res.status === 404) throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library/${nameOrId}`);
}
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library`);
const matches = (res.data ?? []).filter((e) => e.name === nameOrId);
if (matches.length === 0) {
throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
}
if (matches.length > 1) {
const versions = matches.map((m) => m.version).join(", ");
throw new Error(`Agent image '${nameOrId}' is ambiguous (versions: ${versions}). Use 'agent-image:${nameOrId}:<version>'.`);
}
return matches[0]!;
}
export function agentImageCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("agent-image")
.description("Browse, snapshot, and manage agent images (PL-016)")
.addHelpText("after", `
Examples:
rig agent-image list
rig agent-image show driver-release-primed
rig agent-image create velocity-driver@openrig-velocity --name driver-release-primed --notes "after review"
rig agent-image preview driver-release-primed
rig agent-image pin driver-release-primed
rig agent-image prune --dry-run
rig agent-image delete driver-release-primed --force
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
cmd.command("list")
.description("List all agent images in the library")
.option("--runtime <runtime>", "Filter by runtime (claude-code | codex)")
.option("--json", "JSON output")
.action(async (opts: { runtime?: string; json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
let entries = res.data ?? [];
if (opts.runtime) entries = entries.filter((e) => e.runtime === opts.runtime);
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No agent images. Capture one with: rig agent-image create <source-session> --name <name>");
return;
}
for (const e of entries) {
const pinned = e.pinned ? " 📌" : "";
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${e.runtime.padEnd(12)} forks: ${String(e.stats.forkCount).padStart(3)} ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType}${pinned}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("show")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Show image manifest + statistics")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Runtime: ${entry.runtime}`);
console.log(`Source: ${entry.sourceSeat}`);
console.log(`Created: ${entry.createdAt}`);
console.log(`Path: ${entry.sourcePath}`);
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
console.log(`Pinned: ${entry.pinned}`);
console.log("");
console.log(`Stats:`);
console.log(` fork count: ${entry.stats.forkCount}`);
console.log(` last used: ${entry.stats.lastUsedAt ?? "never"}`);
console.log(` estimated size: ${entry.stats.estimatedSizeBytes} bytes`);
console.log(` lineage: ${entry.lineage.length === 0 ? "(none)" : entry.lineage.join(" → ")}`);
if (entry.notes) {
console.log("");
console.log("Notes:");
console.log(` ${entry.notes.replaceAll("\n", "\n ")}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("preview")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Show manifest + sized supplementary file metadata + starter snippet")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.get<PreviewWire>(`/api/agent-images/library/${encodeURIComponent(entry.id)}/preview`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const preview = res.data;
if (opts.json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
console.log(`# Preview: ${preview.name} v${preview.version} (${preview.runtime})`);
console.log(`# Source seat: ${preview.sourceSeat}`);
console.log(`# Stats: fork=${preview.stats.forkCount}, last-used=${preview.stats.lastUsedAt ?? "never"}, size=${preview.stats.estimatedSizeBytes}B`);
if (preview.lineage.length > 0) console.log(`# Lineage: ${preview.lineage.join(" → ")}`);
console.log("");
console.log("# Starter snippet (paste into agent.yaml):");
console.log(preview.starterSnippet);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("create")
.argument("<source-session>", "Source session canonical name (e.g., velocity-driver@openrig-velocity)")
.description("Capture a productive seat's resumable state into a new agent image")
.requiredOption("--name <name>", "Image name (used as the directory name and library id)")
// Use --image-version instead of --version because Commander.js
// intercepts the global --version flag (prints CLI version + exits
// 0 silently). Per-command name resolves the collision without
// losing the global --version surface.
.option("--image-version <version>", "Image version (default: 1)")
.option("--notes <text>", "Operator-supplied notes preserved in manifest")
.option("--estimated-tokens <n>", "Operator-supplied token estimate")
.option("--lineage <names...>", "Comma-separated parent image names if forking from another image")
.option("--json", "JSON output")
.action(async (sourceSession: string, opts: {
name: string;
imageVersion?: string;
notes?: string;
estimatedTokens?: string;
lineage?: string[];
json?: boolean;
}) => {
try {
const client = await getClient();
const body: Record<string, unknown> = {
sourceSession,
name: opts.name,
};
if (opts.imageVersion) body["version"] = opts.imageVersion;
if (opts.notes) body["notes"] = opts.notes;
if (opts.estimatedTokens) {
const n = Number(opts.estimatedTokens);
if (Number.isFinite(n)) body["estimatedTokens"] = n;
}
if (opts.lineage && opts.lineage.length > 0) body["lineage"] = opts.lineage;
const res = await client.post<{ imageId: string; imagePath: string; manifest: { name: string; version: string; runtime: string } }>(
"/api/agent-images/snapshot",
body,
);
if (res.status !== 200) {
const data = res.data as Partial<{ error: string; message: string; details: unknown }>;
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
}
const r = res.data;
if (opts.json) {
console.log(JSON.stringify(r, null, 2));
return;
}
console.log(`Captured ${r.manifest.name} v${r.manifest.version} (${r.manifest.runtime}) at ${r.imagePath}`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("delete")
.argument("<name-or-id>", "Image name or library id")
.option("--force", "Override evidence-preservation guard (CATASTROPHIC if active references exist)")
.option("--json", "JSON output")
.description("Delete an agent image (subject to evidence-preservation guard)")
.action(async (nameOrId: string, opts: { force?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const url = `/api/agent-images/library/${encodeURIComponent(entry.id)}${opts.force ? "?force=true" : ""}`;
const res = await client.delete<{ ok: boolean; forced: boolean; error?: string; message?: string; reasons?: string[] }>(url);
if (res.status !== 200) {
const data = res.data as Partial<{ error: string; message: string; reasons: string[] }>;
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
}
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
console.log(`Deleted ${entry.id}${opts.force ? " (forced)" : ""}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("pin")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Pin an image so prune cannot delete it")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/pin`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Pinned ${entry.id}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("unpin")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Unpin an image")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/unpin`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Unpinned ${entry.id}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("prune")
.option("--dry-run", "Preview without deleting (default if not --force)")
.option("--force", "Override evidence-preservation guard (CATASTROPHIC: deletes referenced images)")
.option("--json", "JSON output")
.description("Delete evictable images (protected by evidence-preservation guard)")
.action(async (opts: { dryRun?: boolean; force?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const dryRun = opts.dryRun !== false && !opts.force;
const res = await client.post<PruneWire>("/api/agent-images/prune", {
dryRun,
force: !!opts.force,
});
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
const r = res.data;
if (r.dryRun) {
const protectedList = r.protected ?? [];
const evictable = r.evictable ?? [];
console.log(`(dry-run) ${protectedList.length} protected, ${evictable.length} evictable`);
if (protectedList.length > 0) {
console.log("");
console.log("Protected:");
for (const p of protectedList) {
console.log(` ${p.imageName} v${p.imageVersion}: ${p.reasons.join(", ")}`);
for (const ref of p.references) console.log(` ↪ ${ref}`);
}
}
if (evictable.length > 0) {
console.log("");
console.log("Evictable:");
for (const e of evictable) console.log(` ${e.imageName} v${e.imageVersion}`);
}
} else {
console.log(`Deleted ${(r.deleted ?? []).length} image(s).${r.forced ? " (forced)" : ""}`);
for (const err of r.errors ?? []) console.log(` error: ${err.imageId}: ${err.error}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("sync")
.option("--json", "JSON output")
.description("Re-walk discovery roots and refresh the library index")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: AgentImageEntryWire[] }>(
"/api/agent-images/library/sync",
);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
console.log(`Indexed ${res.data.count} agent image(s).`);
for (const e of res.data.errors) console.log(` error: ${e.source}: ${e.error}`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
+3 -1
View File
@@ -5,6 +5,8 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS = 120_000;
function logStageDetailErrors(data: Record<string, unknown>) {
const stages = (data["stages"] as Array<{ stage: string; status: string; detail?: unknown }>) ?? [];
for (const stage of stages) {
@@ -107,7 +109,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
sourceRef,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
autoApprove: opts.yes ?? false,
});
}, { timeoutMs: LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
+77 -4
View File
@@ -3,10 +3,22 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
export function captureCommand(depsOverride?: StatusDeps): Command {
export interface CaptureDeps extends StatusDeps {
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
export function captureCommand(depsOverride?: CaptureDeps): Command {
const cmd = new Command("capture").description("Capture terminal output from agent sessions");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): CaptureDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -16,6 +28,7 @@ export function captureCommand(depsOverride?: StatusDeps): Command {
.option("--rig <name>", "Capture all sessions in a rig")
.option("--pod <name>", "Capture all sessions in a pod")
.option("--lines <n>", "Number of lines to capture (default: 20)", "20")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -24,12 +37,22 @@ Examples:
rig capture --rig my-rig
rig capture --pod dev --rig my-rig
rig capture --rig my-rig --json
rig capture --host vm-claude-test dev-impl@my-rig --lines 50
Supported notes:
- Multi-target capture reports unsupported external_cli nodes as explicit per-target failures.
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; json?: boolean }) => {
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.
- --host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. The remote rig is authoritative on what it can capture.`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; host?: string; json?: boolean }) => {
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostCapture(opts.host, session, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -84,3 +107,53 @@ Supported notes:
return cmd;
}
async function runCrossHostCapture(
hostId: string,
session: string | undefined,
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
deps: CaptureDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv. Order: positional first, then flags.
const argv: string[] = ["rig", "capture"];
if (session) argv.push(session);
if (opts.rig) argv.push("--rig", opts.rig);
if (opts.pod) argv.push("--pod", opts.pod);
if (opts.lines !== undefined) argv.push("--lines", opts.lines);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: host.target },
result,
}));
if (!result.ok) process.exitCode = 1;
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, opts.json);
}
+496
View File
@@ -0,0 +1,496 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface CompactPlanDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
}
interface RigEntry {
rigId: string;
name: string;
}
interface NodeEntry {
rigId: string;
rigName: string;
logicalId: string;
canonicalSessionName: string | null;
runtime: string | null;
sessionStatus: string | null;
startupStatus: string | null;
tmuxAttachCommand: string | null;
resumeCommand: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
source: string | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
}
interface BasePlanEntry {
session: string | null;
rig: string;
logicalId: string;
runtime: string;
usedPercentage: number | null;
contextWindowSize: number | null;
estimatedUsedTokens: number | null;
contextFreshness: "fresh" | "stale" | "unknown";
sessionStatus: string | null;
startupStatus: string | null;
}
interface CompactPlanThresholds {
thresholdTokens: number;
thresholdPercent: number;
}
interface CompactPlanSeatPolicy {
oneSeatAtATime: true;
autoCompactAllowed: false;
explicitAuthorizationRequired: true;
}
interface NotificationPacket {
recipient: string | null;
subject: string;
text: string;
}
interface CandidateEntry extends BasePlanEntry {
status: "candidate_with_caveats";
thresholdReason: string;
reasons: string[];
missingEvidence: string[];
precompactRequirements: string[];
seatPolicy: CompactPlanSeatPolicy;
notificationPacket: NotificationPacket;
nextAction: string;
}
interface BlockedEntry extends BasePlanEntry {
status: "blocked";
thresholdReason: string | null;
reasons: string[];
missingEvidence: string[];
precompactRequirements: string[];
seatPolicy: CompactPlanSeatPolicy;
notificationPacket: NotificationPacket;
nextAction: string;
}
interface SkippedEntry extends BasePlanEntry {
status: "skipped";
reason: string;
}
interface CompactPlanResult {
summary: {
totalSeats: number;
claudeSeats: number;
candidateCount: number;
blockedCount: number;
skippedCount: number;
requiresAuthorization: true;
};
policy: {
mode: "read_only_plan";
defaultThresholdTokens: number;
defaultThresholdPercent: number;
thresholdTokens: number;
thresholdPercent: number;
oneSeatAtATime: true;
autoCompactAllowed: false;
explicitAuthorizationRequired: true;
};
recommendedOrder: string[];
candidates: CandidateEntry[];
blocked: BlockedEntry[];
skipped: SkippedEntry[];
}
const DEFAULT_THRESHOLD_TOKENS = 400_000;
const PERCENT_FALLBACK_THRESHOLD = 80;
const FRESHNESS_THRESHOLD_S = 600;
const PRECOMPACT_REQUIREMENTS = [
"fresh_context_sample",
"checkpoint_or_restore_packet_verification",
"explicit_operator_authorization",
"one_seat_at_a_time_only",
"post_compaction_restore_audit",
];
const SEAT_POLICY: CompactPlanSeatPolicy = {
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
};
function defaultThresholds(): CompactPlanThresholds {
return {
thresholdTokens: DEFAULT_THRESHOLD_TOKENS,
thresholdPercent: PERCENT_FALLBACK_THRESHOLD,
};
}
function estimateUsedTokens(usedPercentage: number | null, contextWindowSize: number | null): number | null {
if (usedPercentage == null || contextWindowSize == null) return null;
return Math.round(contextWindowSize * usedPercentage / 100);
}
function computeFreshness(contextUsage: NodeEntry["contextUsage"]): "fresh" | "stale" | "unknown" {
if (!contextUsage || contextUsage.usedPercentage == null) return "unknown";
if (contextUsage.sampledAt) {
const ageSeconds = (Date.now() - new Date(contextUsage.sampledAt).getTime()) / 1000;
return ageSeconds <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
}
if (contextUsage.fresh) return "fresh";
return "unknown";
}
function baseEntry(node: NodeEntry): BasePlanEntry {
const usedPercentage = node.contextUsage?.usedPercentage ?? null;
const contextWindowSize = node.contextUsage?.contextWindowSize ?? null;
return {
session: node.canonicalSessionName,
rig: node.rigName,
logicalId: node.logicalId,
runtime: node.runtime ?? "unknown",
usedPercentage,
contextWindowSize,
estimatedUsedTokens: estimateUsedTokens(usedPercentage, contextWindowSize),
contextFreshness: computeFreshness(node.contextUsage),
sessionStatus: node.sessionStatus,
startupStatus: node.startupStatus,
};
}
function isClaude(node: NodeEntry): boolean {
return node.runtime === "claude-code";
}
function isSeatRunningReady(node: NodeEntry): boolean {
return node.sessionStatus === "running" && node.startupStatus === "ready";
}
function candidateSortScore(candidate: CandidateEntry): number {
return candidate.estimatedUsedTokens ?? ((candidate.usedPercentage ?? 0) * 1_000);
}
function notificationPacket(node: NodeEntry, status: "candidate_with_caveats" | "blocked", reasons: string[], missingEvidence: string[]): NotificationPacket {
const recipient = node.canonicalSessionName;
const subject = status === "candidate_with_caveats"
? `Compact-plan marshal check for ${node.logicalId}`
: `Compact-plan evidence needed for ${node.logicalId}`;
const session = recipient ?? node.logicalId;
const text = status === "candidate_with_caveats"
? [
`Read-only compact-plan flagged ${session} for one-seat-at-a-time Claude continuity triage.`,
`Reasons: ${reasons.join(", ")}.`,
`Before any compaction: verify checkpoint/restore evidence, get explicit operator authorization, compact only this seat, and audit restore after compact-in-place.`,
`No automatic compaction has been run.`,
].join(" ")
: [
`Read-only compact-plan cannot safely plan ${session} yet.`,
`Blockers: ${reasons.join(", ")}.`,
`Missing evidence: ${missingEvidence.join(", ") || "none"}.`,
`Resolve these before requesting one-seat-at-a-time compaction authorization.`,
].join(" ");
return { recipient, subject, text };
}
function thresholdReason(base: BasePlanEntry, thresholds: CompactPlanThresholds): string | null {
if (base.estimatedUsedTokens != null && base.estimatedUsedTokens >= thresholds.thresholdTokens) {
return "above_token_threshold";
}
if (base.estimatedUsedTokens == null && (base.usedPercentage ?? 0) >= thresholds.thresholdPercent) {
return "above_percent_threshold_missing_window";
}
return null;
}
function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): CandidateEntry | BlockedEntry | SkippedEntry {
const base = baseEntry(node);
if (!isClaude(node)) {
return {
...base,
status: "skipped",
reason: node.runtime === "codex" ? "codex_not_managed_by_claude_compact_in_place" : "non_claude_runtime",
};
}
const blockedReasons: string[] = [];
const blockedMissingEvidence: string[] = [];
if (!node.canonicalSessionName) {
blockedReasons.push("missing_canonical_session");
blockedMissingEvidence.push("canonical_session_name");
}
if (!isSeatRunningReady(node)) {
blockedReasons.push("seat_not_running_or_ready");
}
if (base.usedPercentage == null) {
blockedReasons.push("context_unknown");
blockedMissingEvidence.push("context_usage");
}
if (blockedReasons.length > 0) {
return {
...base,
status: "blocked",
thresholdReason: thresholdReason(base, thresholds),
reasons: blockedReasons,
missingEvidence: blockedMissingEvidence,
precompactRequirements: PRECOMPACT_REQUIREMENTS,
seatPolicy: SEAT_POLICY,
notificationPacket: notificationPacket(node, "blocked", blockedReasons, blockedMissingEvidence),
nextAction: "Resolve blocked seat state and collect fresh context before adding this seat to marshal triage.",
};
}
const reason = thresholdReason(base, thresholds);
if (!reason) {
return {
...base,
status: "skipped",
reason: "below_threshold",
};
}
const reasons = [
reason,
"authorization_required",
];
const missingEvidence = ["checkpoint_or_restore_packet_verification"];
if (base.contextWindowSize == null) {
missingEvidence.push("context_window_size");
}
if (base.contextFreshness === "stale") {
reasons.push("context_stale");
missingEvidence.push("fresh_context_sample");
} else if (base.contextFreshness === "unknown") {
reasons.push("context_freshness_unknown");
missingEvidence.push("fresh_context_sample");
}
if (!node.tmuxAttachCommand && !node.resumeCommand) {
missingEvidence.push("attach_or_resume_evidence");
}
return {
...base,
status: "candidate_with_caveats",
thresholdReason: reason,
reasons,
missingEvidence,
precompactRequirements: PRECOMPACT_REQUIREMENTS,
seatPolicy: SEAT_POLICY,
notificationPacket: notificationPacket(node, "candidate_with_caveats", reasons, missingEvidence),
nextAction: "Verify checkpoint/restore evidence, get explicit authorization, compact one Claude seat, then audit restore using claude-compact-in-place.",
};
}
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): CompactPlanResult {
const candidates: CandidateEntry[] = [];
const blocked: BlockedEntry[] = [];
const skipped: SkippedEntry[] = [];
for (const node of nodes) {
const entry = analyzeNode(node, thresholds);
if (entry.status === "candidate_with_caveats") candidates.push(entry);
else if (entry.status === "blocked") blocked.push(entry);
else skipped.push(entry);
}
candidates.sort((a, b) => {
const score = candidateSortScore(b) - candidateSortScore(a);
if (score !== 0) return score;
return (a.session ?? a.logicalId).localeCompare(b.session ?? b.logicalId);
});
return {
summary: {
totalSeats: nodes.length,
claudeSeats: nodes.filter(isClaude).length,
candidateCount: candidates.length,
blockedCount: blocked.length,
skippedCount: skipped.length,
requiresAuthorization: true,
},
policy: {
mode: "read_only_plan",
defaultThresholdTokens: DEFAULT_THRESHOLD_TOKENS,
defaultThresholdPercent: PERCENT_FALLBACK_THRESHOLD,
thresholdTokens: thresholds.thresholdTokens,
thresholdPercent: thresholds.thresholdPercent,
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
},
recommendedOrder: candidates
.map((candidate) => candidate.session)
.filter((session): session is string => Boolean(session)),
candidates,
blocked,
skipped,
};
}
function printHuman(plan: CompactPlanResult): void {
console.log("READ-ONLY PLAN - does not compact");
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
console.log();
if (plan.recommendedOrder.length > 0) {
console.log("One-seat-at-a-time recommended marshal triage order:");
for (const [index, session] of plan.recommendedOrder.entries()) {
console.log(` ${index + 1}. ${session}`);
}
} else {
console.log("One-seat-at-a-time recommended marshal triage order: none");
}
if (plan.candidates.length > 0) {
console.log();
console.log("Candidates with caveats:");
for (const candidate of plan.candidates) {
const estimate = candidate.estimatedUsedTokens == null ? "unknown tokens" : `${candidate.estimatedUsedTokens} estimated tokens`;
console.log(` - ${candidate.session}: ${estimate}; threshold=${candidate.thresholdReason}; reasons=${candidate.reasons.join(", ")}; missing=${candidate.missingEvidence.join(", ")}`);
console.log(` Notify: ${candidate.notificationPacket.text}`);
}
}
if (plan.blocked.length > 0) {
console.log();
console.log("Blocked / not safely plannable:");
for (const blocked of plan.blocked) {
console.log(` - ${blocked.session ?? blocked.logicalId}: reasons=${blocked.reasons.join(", ")}; missing=${blocked.missingEvidence.join(", ") || "none"}`);
console.log(` Notify: ${blocked.notificationPacket.text}`);
}
}
console.log();
console.log("Next action: verify checkpoint/restore evidence, get explicit authorization, compact one Claude seat only, then audit restore with claude-compact-in-place.");
}
export function compactPlanCommand(depsOverride?: CompactPlanDeps): Command {
const cmd = new Command("compact-plan")
.description("Plan Claude compact-in-place candidates without compacting anything")
.addHelpText("after", `
Examples:
rig compact-plan Show a read-only Claude compaction triage plan
rig compact-plan --rig openrig-pm Plan one rig only
rig compact-plan --refresh Re-sample context before planning
rig compact-plan --json JSON output for agents`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--rig <name>", "Plan one rig only")
.option("--refresh", "Re-sample context usage before planning")
.option("--threshold-tokens <n>", "Estimated used-token threshold for Claude compact-plan candidates")
.option("--threshold-percent <0-100>", "Used-percent threshold when context window size is missing")
.action(async (opts: { json?: boolean; rig?: string; refresh?: boolean; thresholdTokens?: string; thresholdPercent?: string }) => {
const deps = getDepsF();
const thresholds = parseThresholdOptions(opts);
if (!thresholds.ok) {
console.error(thresholds.error);
process.exitCode = 1;
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon is not running. Start it with: rig daemon start");
console.error("Cannot build compact-plan without current read-only rig inventory.");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
try {
const psResult = await client.get<RigEntry[]>("/api/ps");
const rigs = psResult.data ?? [];
const targetRigs = opts.rig ? rigs.filter((rig) => rig.name === opts.rig) : rigs;
if (opts.rig && targetRigs.length === 0) {
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
process.exitCode = 1;
return;
}
if (opts.refresh && targetRigs.length > 0) {
const firstRig = targetRigs[0]!;
try {
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
if (refreshResult.status >= 400) {
console.error("Compact-plan refresh failed. Data may be stale.");
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
} catch (refreshErr) {
console.error("Compact-plan refresh failed. Data may be stale.");
console.error(`Detail: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
}
const allNodes: NodeEntry[] = [];
for (const rig of targetRigs) {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
if (Array.isArray(nodesResult.data)) {
allNodes.push(...nodesResult.data);
}
}
const plan = buildPlan(allNodes, thresholds.value);
if (opts.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
printHuman(plan);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
console.error("Fix: check daemon status with: rig daemon status");
process.exitCode = 2;
}
});
return cmd;
}
function parseThresholdOptions(opts: { thresholdTokens?: string; thresholdPercent?: string }): { ok: true; value: CompactPlanThresholds } | { ok: false; error: string } {
const thresholds = defaultThresholds();
if (opts.thresholdTokens != null) {
const value = Number(opts.thresholdTokens);
if (!Number.isInteger(value) || value <= 0) {
return { ok: false, error: "--threshold-tokens must be a positive integer" };
}
thresholds.thresholdTokens = value;
}
if (opts.thresholdPercent != null) {
const value = Number(opts.thresholdPercent);
if (!Number.isFinite(value) || value < 0 || value > 100) {
return { ok: false, error: "--threshold-percent must be a number from 0 to 100" };
}
thresholds.thresholdPercent = value;
}
return { ok: true, value: thresholds };
}
@@ -0,0 +1,349 @@
// User Settings v0 — `rig config init-workspace` workspace scaffolding.
//
// Creates the mission-aware default workspace at ~/.openrig/workspace/ (or a
// caller-supplied --root). The same scaffold is also used idempotently by
// daemon startup so a fresh install has a browsable Project workspace before
// the user discovers the explicit command.
//
// Behavior:
// - Reads workspace.root setting (default ~/.openrig/workspace/).
// - Creates canonical subdirs: missions/ progress/ field-notes/ specs/ dogfood-evidence/.
// - Seeds two example missions with multiple slices.
// - Drops workspace README.md + STEERING.md.
// - --dry-run: report what would be created without acting.
// - --force: overwrite existing files (NOT directories — never deletes
// operator content).
// - Idempotent without --force: existing-dir + existing-readme is a no-op.
import { Command } from "commander";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
export interface InitWorkspaceOpts {
root?: string;
force?: boolean;
dryRun?: boolean;
json?: boolean;
}
export interface InitWorkspaceResult {
root: string;
/** True when this run would (or did) create the root dir; false if root existed. */
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
/** True when --dry-run; nothing was actually written. */
dryRun: boolean;
}
type DefaultSlice = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
};
type DefaultMission = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
slices: DefaultSlice[];
};
const DEFAULT_MISSIONS: DefaultMission[] = [
{
id: "getting-started",
title: "Getting Started",
status: "active",
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
slices: [
{
id: "first-conveyor-run",
title: "First Conveyor Run",
status: "active",
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
},
{
id: "inspect-project-evidence",
title: "Inspect Project Evidence",
status: "draft",
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
},
],
},
];
const WORKSPACE_DIRS = [
"missions",
"artifacts",
"evidence",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
...DEFAULT_MISSIONS.flatMap((mission) => [
`missions/${mission.id}`,
`missions/${mission.id}/slices`,
...mission.slices.map((slice) => `missions/${mission.id}/slices/${slice.id}`),
]),
] as const;
function subdirReadmeContent(subdir: string): string {
switch (subdir) {
case "missions":
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
case "artifacts":
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
case "evidence":
return "# evidence\n\nHuman-readable verification notes live here. Use this for compact proof summaries that are not tied to a screenshot or video packet.\n";
case "progress":
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory recursively for PROGRESS.md files and renders them as a hierarchical tree.\n";
case "field-notes":
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work. OpenRig surfaces these alongside missions and slices for context.\n";
case "specs":
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context packs / skills). OpenRig's Library browses this directory alongside bundled specs.\n";
case "dogfood-evidence":
return "# dogfood-evidence\n\nProof packets live here. Each proof packet folder is matched to a Project slice by folder-name tokens and may contain markdown, screenshots, videos, traces, and other verification artifacts.\n";
default:
return `# ${subdir}\n`;
}
}
const WORKSPACE_README = `# OpenRig Workspace
This workspace is file-backed. The Project UI mirrors this structure:
- \`missions/<mission-id>\` becomes a Project mission.
- \`missions/<mission-id>/slices/<slice-id>\` becomes a Project slice.
- Queue items should mention or tag the mission id and slice id so Project can attach live work to the right slice.
- \`artifacts/\` is for work products that a slice needs to keep.
- \`evidence/\` is for proof notes and verification summaries.
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
`;
const STEERING_PLACEHOLDER = `---
title: Priority Stack
status: placeholder
---
# OpenRig Priority Stack
This file is a placeholder created by \`rig config init-workspace\`. Edit it
to record your top priorities. OpenRig's Steering surface reads this file
alongside your Project workspace.
## Top 3
1. Run the \`conveyor\` starter rig.
2. Move one packet through \`basic-loop\` or \`conveyor\`.
3. Inspect the Project, Queue, Story, and Tests surfaces.
## In Motion
(Active slices land here as you push them through the priority rail.)
## Loop State
(Health gates + loop diagnostics land here.)
`;
function missionReadme(mission: DefaultMission): string {
return `---
title: ${mission.title}
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title}
${mission.objective}
## Slices
${mission.slices.map((slice) => `- [${slice.title}](slices/${slice.id}/README.md)`).join("\n")}
`;
}
function missionProgress(mission: DefaultMission): string {
return `---
title: ${mission.title} Progress
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title} Progress
- [ ] Keep mission README current.
- [ ] Keep active slices queue-backed with mission and slice ids.
`;
}
function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title}
${slice.objective}
## Queue Mapping
Queue items for this slice should mention or tag:
- mission: \`${mission.id}\`
- slice: \`${slice.id}\`
This lets Project attach queue activity to the slice story, queue, tests, and topology tabs.
`;
}
function sliceProgress(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title} Progress
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Progress
- [ ] Define the next concrete packet.
- [ ] Attach queue work to this slice id.
- [ ] Capture proof or notes before closing.
`;
}
function slicePrd(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title} Implementation Notes
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Implementation Notes
## Goal
${slice.objective}
## Acceptance
- [ ] The work is visible in the Project slice.
- [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`.
- [ ] Any proof artifacts are referenced from the slice before closure.
`;
}
export function workspaceScaffoldDirs(): string[] {
return [...WORKSPACE_DIRS];
}
export function workspaceScaffoldFiles(): Array<{ relPath: string; content: string }> {
const files: Array<{ relPath: string; content: string }> = [
{ relPath: "README.md", content: WORKSPACE_README },
{ relPath: "STEERING.md", content: STEERING_PLACEHOLDER },
{ relPath: "missions/README.md", content: subdirReadmeContent("missions") },
{ relPath: "artifacts/README.md", content: subdirReadmeContent("artifacts") },
{ relPath: "evidence/README.md", content: subdirReadmeContent("evidence") },
{ relPath: "progress/README.md", content: subdirReadmeContent("progress") },
{ relPath: "field-notes/README.md", content: subdirReadmeContent("field-notes") },
{ relPath: "specs/README.md", content: subdirReadmeContent("specs") },
{ relPath: "dogfood-evidence/README.md", content: subdirReadmeContent("dogfood-evidence") },
];
for (const mission of DEFAULT_MISSIONS) {
files.push(
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
);
for (const slice of mission.slices) {
files.push(
{ relPath: `missions/${mission.id}/slices/${slice.id}/README.md`, content: sliceReadme(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/PROGRESS.md`, content: sliceProgress(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/IMPLEMENTATION-PRD.md`, content: slicePrd(mission, slice) },
);
}
}
return files;
}
export function initWorkspaceCommand(configPath?: string): Command {
const cmd = new Command("init-workspace")
.description("Scaffold the default workspace at ~/.openrig/workspace/ with mission/slice folders")
.option("--root <path>", "Override workspace root (default: workspace.root setting)")
.option("--force", "Overwrite existing scaffolded files (does NOT remove directories)")
.option("--dry-run", "Show what would be created without writing")
.option("--json", "JSON output")
.action((opts: InitWorkspaceOpts) => {
try {
const effectiveJson = opts.json ?? Boolean(cmd.optsWithGlobals().json);
const result = runInitWorkspace({ ...opts, json: effectiveJson, configPath });
if (effectiveJson) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.dryRun) console.log(`(dry-run) workspace root: ${result.root}`);
else console.log(`workspace root: ${result.root}`);
for (const sub of result.subdirs) {
console.log(` ${sub.created ? "+" : " "} ${sub.name}/`);
}
for (const f of result.files) {
console.log(` ${f.created ? "+" : " "} ${f.relPath}${f.skipped ? ` (skipped: ${f.skipped})` : ""}`);
}
if (result.dryRun) console.log("(dry-run; no files written.)");
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string }): InitWorkspaceResult {
const store = new ConfigStore(opts.configPath);
const root = opts.root ?? (store.get("workspace.root") as string);
const dryRun = !!opts.dryRun;
const force = !!opts.force;
const rootExists = existsSync(root);
const result: InitWorkspaceResult = {
root,
rootCreated: !rootExists,
subdirs: [],
files: [],
dryRun,
};
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of workspaceScaffoldDirs()) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
}
for (const file of workspaceScaffoldFiles()) {
const absPath = join(root, file.relPath);
const exists = existsSync(absPath);
if (exists && !force) {
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(absPath, file.content, "utf-8");
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
}
return result;
}
+78 -19
View File
@@ -1,33 +1,70 @@
import { Command } from "commander";
import { ConfigStore } from "../config-store.js";
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
import { initWorkspaceCommand } from "./config-init-workspace.js";
function formatRow(key: string, value: unknown): string {
return `${key.padEnd(28)} ${value}`;
}
function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting> {
return store.resolveAllWithSource();
}
export function configCommand(configPath?: string): Command {
const cmd = new Command("config").description("Inspect and change OpenRig configuration");
const store = new ConfigStore(configPath);
cmd
.option("--json", "JSON output for agents")
.option("--json", "JSON output for agents (resolved RiggedConfig)")
.option("--with-source", "Include source/default per key (honest provenance)")
.addHelpText("after", `
Examples:
rig config # show all resolved config
rig config --json # JSON output
rig config get daemon.port # read a single key
rig config set daemon.port 7434 # change a value
rig config reset # delete config file, revert to defaults
rig config # show all resolved config
rig config --json # JSON RiggedConfig (structured)
rig config --json --with-source # JSON per-key with source + default
rig config get daemon.port # read a single key
rig config get workspace.slices_root --show-source
rig config set daemon.port 7434 # change a value
rig config set workspace.slices_root /path # configure a workspace path
rig config reset # delete config file, revert all to defaults
rig config reset workspace.slices_root # clear one key, revert to default
rig config init-workspace # scaffold ~/.openrig/workspace/ with missions + slices
Keys:
daemon.* port, host
db.path
transcripts.* enabled, path
workspace.* root, slices_root, steering_path, field_notes_root, specs_root
files.allowlist name:/abs/path,name:/abs/path
progress.scan_roots name:/abs/path,name:/abs/path
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
Keys: daemon.port, daemon.host, db.path, transcripts.enabled, transcripts.path
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean }) => {
.action((opts: { json?: boolean; withSource?: boolean }) => {
try {
if (opts.withSource) {
const all = summarizeSettings(store);
if (opts.json) {
console.log(JSON.stringify(all, null, 2));
} else {
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
}
return;
}
// Default: structured RiggedConfig output (preserves pre-v0
// bare-action shape so existing scripts / tests keep working).
const config = store.resolve();
if (opts.json) {
console.log(JSON.stringify(config, null, 2));
} else {
console.log(`daemon.port ${config.daemon.port}`);
console.log(`daemon.host ${config.daemon.host}`);
console.log(`db.path ${config.db.path}`);
console.log(`transcripts.enabled ${config.transcripts.enabled}`);
console.log(`transcripts.path ${config.transcripts.path}`);
const all = summarizeSettings(store);
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
}
} catch (err) {
console.error((err as Error).message);
@@ -37,9 +74,20 @@ Precedence: CLI flag > environment variable > config file > default`)
const getCmd = new Command("get")
.argument("<key>", "Config key (e.g. daemon.port)")
.option("--json", "JSON output with value + source + default")
.option("--show-source", "Print value + source on a single line")
.description("Read a single config value")
.action((key: string) => {
.action((key: string, opts: { json?: boolean; showSource?: boolean }) => {
try {
if (opts.json) {
console.log(JSON.stringify(store.resolveWithSource(key), null, 2));
return;
}
if (opts.showSource) {
const r = store.resolveWithSource(key);
console.log(`${r.value}\t(source: ${r.source})`);
return;
}
console.log(String(store.get(key)));
} catch (err) {
console.error((err as Error).message);
@@ -62,15 +110,26 @@ Precedence: CLI flag > environment variable > config file > default`)
});
const resetCmd = new Command("reset")
.description("Delete config file and revert to defaults")
.action(() => {
store.reset();
console.log("Config reset to defaults.");
.argument("[key]", "Optional config key to reset (omit to reset entire file)")
.description("Clear a config override (or delete the entire file when no key given)")
.action((key: string | undefined) => {
try {
store.reset(key);
if (key) {
console.log(`${key} reset to default (${store.get(key)}).`);
} else {
console.log("Config reset to defaults.");
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.addCommand(getCmd);
cmd.addCommand(setCmd);
cmd.addCommand(resetCmd);
cmd.addCommand(initWorkspaceCommand(configPath));
return cmd;
}
+407
View File
@@ -0,0 +1,407 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family.
//
// Six subcommands parallel to `rig specs`:
// list / show / preview / add / sync / send
//
// Each delegates to /api/context-packs/library/* against the daemon.
// The `add` verb installs a pack from a directory at
// ~/.openrig/context-packs/<name>/ — host-symlink-free contract,
// matches `rig specs add` shape (regular files only; no symlinks).
import { Command } from "commander";
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
} from "node:fs";
import { basename, extname, isAbsolute, join } from "node:path";
import { parse as parseYaml } from "yaml";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface ContextPackEntryWire {
id: string;
kind: "context-pack";
name: string;
version: string;
purpose: string | null;
sourceType: "builtin" | "user_file" | "workspace";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: Array<{
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}>;
}
interface PreviewWire {
id: string;
name: string;
version: string;
bundleText: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
}
interface SendWire {
id: string;
name: string;
version: string;
destinationSession: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
dryRun: boolean;
bundleText?: string;
sent?: boolean;
reason?: string;
error?: string;
}
function assertTreeHasNoSymlinks(root: string): void {
const stack = [root];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absPath = join(current, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Context pack directories must not contain symlinks: ${absPath}`);
}
if (entry.isDirectory()) stack.push(absPath);
}
}
}
const ALLOWED_CONTEXT_PACK_SUFFIXES = new Set([".md", ".markdown", ".yaml", ".yml", ".txt"]);
function validateContextPackManifestForInstall(manifestPath: string): void {
let parsed: unknown;
try {
parsed = parseYaml(readFileSync(manifestPath, "utf-8"));
} catch (err) {
throw new Error(`manifest at ${manifestPath} is not valid YAML: ${(err as Error).message}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`manifest at ${manifestPath} must be a YAML object at the root`);
}
const obj = parsed as Record<string, unknown>;
if (typeof obj["name"] !== "string" || obj["name"].length === 0) {
throw new Error(`manifest at ${manifestPath} is missing required field 'name' (string)`);
}
if (obj["version"] === undefined || obj["version"] === null) {
throw new Error(`manifest at ${manifestPath} is missing required field 'version'`);
}
const files = obj["files"];
if (!Array.isArray(files)) {
throw new Error(`manifest at ${manifestPath} must declare 'files: [...]'`);
}
for (let i = 0; i < files.length; i++) {
const entry = files[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`manifest at ${manifestPath} has malformed entry at files[${i}]`);
}
const file = entry as Record<string, unknown>;
const relPath = file["path"];
if (typeof relPath !== "string" || relPath.length === 0) {
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'path' (string)`);
}
if (relPath.includes("..") || isAbsolute(relPath) || relPath.startsWith("\\")) {
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' must be a relative path inside the pack (no '..' segments, no leading '/')`);
}
if (!ALLOWED_CONTEXT_PACK_SUFFIXES.has(extname(relPath))) {
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' has an unsupported suffix; allowed: ${Array.from(ALLOWED_CONTEXT_PACK_SUFFIXES).join(", ")}`);
}
if (typeof file["role"] !== "string" || file["role"].length === 0) {
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'role' (string)`);
}
}
}
async function resolvePack(client: DaemonClient, nameOrId: string): Promise<ContextPackEntryWire> {
// First try as id (context-pack:<name>:<version>); else search by name.
if (nameOrId.startsWith("context-pack:")) {
const res = await client.get<ContextPackEntryWire>(`/api/context-packs/library/${encodeURIComponent(nameOrId)}`);
if (res.status === 200) return res.data;
if (res.status === 404) throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library/${nameOrId}`);
}
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library`);
const entries = res.data ?? [];
const matches = entries.filter((e) => e.name === nameOrId);
if (matches.length === 0) {
throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
}
if (matches.length > 1) {
const versions = matches.map((m) => m.version).join(", ");
throw new Error(`Context pack '${nameOrId}' is ambiguous (versions: ${versions}). Use the full id 'context-pack:${nameOrId}:<version>'.`);
}
return matches[0]!;
}
export function contextPackCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("context-pack")
.description("Browse, preview, send, and install operator-authored context packs")
.addHelpText("after", `
Examples:
rig context-pack list
rig context-pack show pl-005-phase-a-priming
rig context-pack preview pl-005-phase-a-priming
rig context-pack add ./my-pack
rig context-pack sync
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity --dry-run
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
cmd.command("list")
.description("List all context packs in the library")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No context packs in library. Author one at ~/.openrig/context-packs/<name>/ then run: rig context-pack sync");
return;
}
for (const e of entries) {
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${String(e.files.length).padStart(2)} files ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType} ${e.sourcePath}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("show")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show pack manifest + per-file metadata")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Source: ${entry.sourceType} (${entry.sourcePath})`);
console.log(`Files: ${entry.files.length}`);
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
if (entry.purpose) {
console.log("");
console.log("Purpose:");
console.log(` ${entry.purpose.replaceAll("\n", "\n ")}`);
}
console.log("");
for (const f of entry.files) {
const sizeStr = f.bytes === null ? "(missing)" : `${f.bytes}B`;
const tokenStr = f.estimatedTokens === null ? "—" : `~${f.estimatedTokens} tokens`;
console.log(` ${f.path.padEnd(40)} role=${f.role.padEnd(20)} ${sizeStr.padEnd(12)} ${tokenStr}`);
if (f.summary) console.log(` ${f.summary}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("preview")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show the assembled bundle (the exact text that would be sent)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.get<PreviewWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/preview`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const preview = res.data;
if (opts.json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
if (preview.missingFiles.length > 0) {
console.error(`Warning: ${preview.missingFiles.length} file(s) referenced by manifest are missing on disk:`);
for (const m of preview.missingFiles) console.error(` - ${m.path} (role: ${m.role})`);
console.error("");
}
console.log(`# Preview: ${preview.name} v${preview.version}`);
console.log(`# Bundle: ${preview.bundleBytes} bytes (~${preview.estimatedTokens} tokens), ${preview.files.length} files`);
console.log("# ---");
console.log(preview.bundleText);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("sync")
.description("Re-walk discovery roots and refresh the library index")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>(
"/api/context-packs/library/sync",
);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
console.log(`Indexed ${data.count} context pack(s).`);
if (data.errors.length > 0) {
console.log(`Encountered ${data.errors.length} parse error(s):`);
for (const e of data.errors) console.log(` - ${e.source}: ${e.error}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("add")
.argument("<source-dir>", "Directory containing manifest.yaml + included files")
.description("Install a context pack from a local directory into ~/.openrig/context-packs/")
.option("--name <name>", "Override the install name (defaults to source directory basename)")
.option("--json", "JSON output")
.action(async (sourceDir: string, opts: { name?: string; json?: boolean }) => {
try {
if (!existsSync(sourceDir)) throw new Error(`Source directory not found: ${sourceDir}`);
const stat = lstatSync(sourceDir);
if (stat.isSymbolicLink()) throw new Error(`Source must not be a symlink: ${sourceDir}`);
if (!stat.isDirectory()) throw new Error(`Source must be a directory containing manifest.yaml: ${sourceDir}`);
const manifestPath = join(sourceDir, "manifest.yaml");
if (!existsSync(manifestPath)) {
throw new Error(`Source directory must contain manifest.yaml: ${sourceDir}`);
}
validateContextPackManifestForInstall(manifestPath);
// Read the manifest's name as the canonical install name when
// --name not given. Cheap parse: trust the daemon to validate
// on next sync; here we just need the name.
const installName = opts.name ?? (() => {
try {
const raw = readFileSync(manifestPath, "utf-8");
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
return m?.[1]?.trim() || basename(sourceDir);
} catch {
return basename(sourceDir);
}
})();
assertTreeHasNoSymlinks(sourceDir);
const targetRoot = getDefaultOpenRigPath("context-packs");
mkdirSync(targetRoot, { recursive: true });
const targetDir = join(targetRoot, installName);
if (existsSync(targetDir)) {
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
}
cpSync(sourceDir, targetDir, { recursive: true });
// Sync the daemon library so the new pack appears immediately.
const client = await getClient();
const syncRes = await client.post<{ count: number; errors?: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>("/api/context-packs/library/sync");
if (syncRes.status !== 200) {
// Install succeeded; sync failed → still surface install path.
if (opts.json) console.log(JSON.stringify({ installedAt: targetDir, syncError: `HTTP ${syncRes.status}` }, null, 2));
else console.log(`Installed at ${targetDir}; daemon sync failed (HTTP ${syncRes.status}). Run 'rig context-pack sync' manually.`);
return;
}
const syncError = syncRes.data.errors?.find((e) => e.source === targetDir);
if (syncError) {
throw new Error(`Installed at ${targetDir}, but daemon rejected the pack during sync: ${syncError.error}`);
}
if (opts.json) {
console.log(JSON.stringify({ installedAt: targetDir, count: syncRes.data.count }, null, 2));
} else {
console.log(`Installed at ${targetDir}. Library now has ${syncRes.data.count} context pack(s).`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("send")
.argument("<name-or-id>", "Context pack name or library ID")
.argument("<destination-session>", "Destination session name (e.g., velocity-driver@openrig-velocity)")
.description("Assemble the pack into one paste-ready bundle and send to a seat")
.option("--dry-run", "Show what would be sent without invoking SessionTransport")
.option("--json", "JSON output")
.action(async (nameOrId: string, destinationSession: string, opts: { dryRun?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.post<SendWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/send`, {
destinationSession,
dryRun: opts.dryRun ?? false,
});
if (res.status !== 200) {
// 502/503/etc. — surface daemon error verbatim.
const data = res.data as Partial<SendWire> & { error?: string; reason?: string };
throw new Error(data.error ?? data.reason ?? `Daemon returned HTTP ${res.status}`);
}
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (data.dryRun) {
if (data.missingFiles.length > 0) {
console.error(`Warning: ${data.missingFiles.length} manifest file(s) missing on disk.`);
}
console.log(`(dry-run) ${data.name} v${data.version} → ${data.destinationSession}`);
console.log(`Bundle: ${data.bundleBytes} bytes (~${data.estimatedTokens} tokens), ${data.files.length} files`);
if (data.bundleText) {
console.log("# ---");
console.log(data.bundleText);
}
return;
}
console.log(`Sent ${data.name} v${data.version} (${data.bundleBytes} bytes) to ${data.destinationSession}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
+278
View File
@@ -0,0 +1,278 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface ContextDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
}
interface NodeEntry {
rigId: string;
rigName: string;
logicalId: string;
canonicalSessionName: string | null;
runtime: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
source: string | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
[key: string]: unknown;
}
interface Seat {
session: string;
rig: string;
logicalId: string;
runtime: string;
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
urgency: "critical" | "warning" | "low" | "unknown";
freshness: "fresh" | "stale" | "none";
status: string;
displayStatus: string;
source: string | null;
availability: string;
sampledAt: string | null;
staleness: string;
fresh: boolean;
}
const FRESHNESS_THRESHOLD_S = 600; // 10 minutes
function ageLabel(sampledAt: string | null): string {
if (!sampledAt) return "\u2014";
const age = Math.max(0, Math.floor((Date.now() - new Date(sampledAt).getTime()) / 1000));
if (age < 60) return "<1m ago";
const minutes = Math.floor(age / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function computeFreshness(sampledAt: string | null): "fresh" | "stale" | "none" {
if (!sampledAt) return "none";
const age = (Date.now() - new Date(sampledAt).getTime()) / 1000;
return age <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
}
function analyze(node: NodeEntry): Seat {
const ctx = node.contextUsage ?? {} as Partial<NonNullable<NodeEntry["contextUsage"]>>;
const used = ctx.usedPercentage != null ? Math.round(ctx.usedPercentage) : null;
const remaining = ctx.remainingPercentage != null ? Math.round(ctx.remainingPercentage) : null;
const window = ctx.contextWindowSize ?? null;
const sampledAt = ctx.sampledAt ?? null;
const source = ctx.source ?? null;
const availability = ctx.availability ?? (used != null ? "known" : "unknown");
if (used == null) {
return {
session: node.canonicalSessionName ?? "",
rig: node.rigName ?? "",
logicalId: node.logicalId ?? "",
runtime: node.runtime ?? "unknown",
usedPercentage: null, remainingPercentage: null, contextWindowSize: null,
urgency: "unknown", freshness: "none", status: "unknown", displayStatus: "unknown",
source, availability, sampledAt, staleness: "\u2014", fresh: false,
};
}
const urgency: "critical" | "warning" | "low" = used >= 80 ? "critical" : used >= 60 ? "warning" : "low";
const freshness = computeFreshness(sampledAt);
let status: string;
let displayStatus: string;
if (urgency === "critical") {
status = freshness === "fresh" ? "critical" : "critical_stale";
displayStatus = freshness === "fresh" ? "CRITICAL" : "CRITICAL (stale)";
} else if (urgency === "warning") {
status = freshness === "fresh" ? "warning" : "warning_stale";
displayStatus = freshness === "fresh" ? "WARNING" : "WARNING (stale)";
} else if (freshness === "fresh") {
status = "ok";
displayStatus = "ok";
} else {
status = "stale";
displayStatus = "stale";
}
return {
session: node.canonicalSessionName ?? "",
rig: node.rigName ?? "",
logicalId: node.logicalId ?? "",
runtime: node.runtime ?? "unknown",
usedPercentage: used, remainingPercentage: remaining, contextWindowSize: window,
urgency, freshness, status, displayStatus,
source, availability, sampledAt, staleness: ageLabel(sampledAt), fresh: freshness === "fresh",
};
}
const STATUS_RANK: Record<string, number> = {
critical: 0, critical_stale: 0,
warning: 1, warning_stale: 1,
stale: 2, ok: 3, unknown: 4,
};
function sortSeats(seats: Seat[]): Seat[] {
return seats.sort((a, b) => {
const ra = STATUS_RANK[a.status] ?? 9;
const rb = STATUS_RANK[b.status] ?? 9;
if (ra !== rb) return ra - rb;
const pa = a.usedPercentage ?? -1;
const pb = b.usedPercentage ?? -1;
if (pa !== pb) return pb - pa; // higher percentage first
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
return a.session.localeCompare(b.session);
});
}
function isVisible(seat: Seat, threshold: number | null): boolean {
if (threshold == null) return true;
if (seat.status === "unknown") return true;
if (seat.freshness === "stale") return true;
return seat.usedPercentage != null && seat.usedPercentage >= threshold;
}
export function contextCommand(depsOverride?: ContextDeps): Command {
const cmd = new Command("context")
.description("Show context-usage across running agents")
.addHelpText("after", `
Examples:
rig context Show all seats with context usage
rig context --rig openrig-pm Show one rig
rig context --threshold 80 Show seats at or above 80% (plus unknown + stale)
rig context --refresh Re-sample context before displaying
rig context --json JSON output for agents`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--rig <name>", "Show one rig only")
.option("--threshold <pct>", "Show seats at or above this percentage")
.option("--refresh", "Re-sample context usage before displaying")
.action(async (opts: { json?: boolean; rig?: string; threshold?: string; refresh?: boolean }) => {
const deps = getDepsF();
// Strict threshold validation — reject non-integer input
let threshold: number | null = null;
if (opts.threshold != null) {
const parsed = Number(opts.threshold);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
console.error("rig context: --threshold must be an integer percentage (0-100)");
process.exitCode = 2;
return;
}
threshold = parsed;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon is not running. Start it with: rig daemon start");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
try {
// Get rig list to iterate
const psResult = await client.get<Array<{ rigId: string; name: string }>>("/api/ps");
const rigs = psResult.data ?? [];
const targetRigs = opts.rig
? rigs.filter((r) => r.name === opts.rig)
: rigs;
if (opts.rig && targetRigs.length === 0) {
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
process.exitCode = 1;
return;
}
// Refresh once globally if requested (pollOnce samples all active Claude sessions).
// Refresh failure is a hard error — do not silently display stale data as if refreshed.
if (opts.refresh && targetRigs.length > 0) {
const firstRig = targetRigs[0]!;
try {
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
if (refreshResult.status >= 400) {
console.error("Context refresh failed. Data may be stale.");
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
} catch (refreshErr) {
console.error("Context refresh failed. Data may be stale.");
console.error(`Detail: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
}
// Fetch node inventory per rig (without refresh — already done globally)
const allNodes: NodeEntry[] = [];
for (const rig of targetRigs) {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
if (Array.isArray(nodesResult.data)) {
allNodes.push(...nodesResult.data);
}
}
const seats = sortSeats(
allNodes
.map(analyze)
.filter((s) => isVisible(s, threshold))
);
const summary = {
total: seats.length,
known: seats.filter((s) => s.usedPercentage != null).length,
critical: seats.filter((s) => s.urgency === "critical").length,
warning: seats.filter((s) => s.urgency === "warning").length,
ok: seats.filter((s) => s.status === "ok").length,
stale: seats.filter((s) => s.freshness === "stale").length,
unknown: seats.filter((s) => s.status === "unknown").length,
};
if (opts.json) {
console.log(JSON.stringify({ seats, summary }, null, 2));
return;
}
// Human-readable output
const title = opts.rig ? `CONTEXT USAGE - ${opts.rig}` : "CONTEXT USAGE - all rigs";
console.log(title);
console.log(
`${"seat".padEnd(42)} ${"runtime".padEnd(12)} ${"context".padStart(7)} ${"window".padStart(8)} ${"sampled".padStart(10)} status`
);
for (const seat of seats) {
const context = seat.usedPercentage == null ? "?" : `${seat.usedPercentage}%`;
const window = seat.contextWindowSize == null ? "?" : String(seat.contextWindowSize);
console.log(
`${seat.session.slice(0, 42).padEnd(42)} ${seat.runtime.slice(0, 12).padEnd(12)} ${context.padStart(7)} ${window.padStart(8)} ${seat.staleness.padStart(10)} ${seat.displayStatus}`
);
}
console.log();
console.log(
`FLEET SUMMARY: ${summary.known}/${summary.total} known | ` +
`${summary.critical} CRITICAL | ${summary.warning} WARNING | ` +
`${summary.ok} ok | ${summary.stale} stale | ${summary.unknown} unknown`
);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 2;
}
});
return cmd;
}
+11 -2
View File
@@ -112,6 +112,14 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
db: opts.db ?? config.db.path,
transcriptsEnabled: config.transcripts.enabled,
transcriptsPath: config.transcripts.path,
workspaceRoot: config.workspace.root,
// V1 pre-release CLI/daemon Item 1 — project the
// ConfigStore-resolved rotation tunables into the daemon
// process env so file-stored values
// (`rig config set transcripts.lines 500`) actually
// reach the rotation hook.
transcriptsLines: config.transcripts.lines,
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
},
getDeps(),
);
@@ -140,12 +148,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.description("Show daemon status")
.action(async () => {
const status = await getDaemonStatus(getDeps());
const pidSuffix = status.pid !== undefined ? ` (pid ${status.pid})` : "";
switch (status.state) {
case "running":
if (status.healthy === false) {
console.log(`Daemon running on port ${status.port} (pid ${status.pid}) — healthz failed`);
console.log(`Daemon running on port ${status.port}${pidSuffix} — healthz failed`);
} else {
console.log(`Daemon running on port ${status.port} (pid ${status.pid})`);
console.log(`Daemon running on port ${status.port}${pidSuffix}`);
}
break;
case "stopped":
+210
View File
@@ -0,0 +1,210 @@
import { Command } from "commander";
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { realDeps } from "./daemon.js";
import { getDefaultOpenRigPath, getPreferredOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import {
DESTROY_CONFIRM_TOKEN,
buildDestroyPlan,
executeDestroy,
findListeningPidWithLsof,
killTmuxSessionWithCli,
listManagedTmuxSessionsFromDb,
type DestroyDeps,
type ListenerInspection,
type DestroyRuntimeConfig,
type DestroyScope,
} from "../destroy-helpers.js";
import { stopDaemon } from "../daemon-lifecycle.js";
export interface DestroyCommandDeps {
configStore: Pick<ConfigStore, "resolve">;
destroyDeps: DestroyDeps;
}
interface ResolvedDestroyRuntime {
runtimeConfig: DestroyRuntimeConfig;
warnings: string[];
}
function resolveRuntimeConfig(configStore: Pick<ConfigStore, "resolve">): ResolvedDestroyRuntime {
const warnings: string[] = [];
const stateRoot = getPreferredOpenRigHome();
const defaultDbPath = getDefaultOpenRigPath("openrig.sqlite");
const defaultTranscriptsPath = getDefaultOpenRigPath("transcripts");
const effectiveDefaultDbPath = join(stateRoot, "openrig.sqlite");
const effectiveDefaultTranscriptsPath = join(stateRoot, "transcripts");
const overrideUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL")?.trim();
if (overrideUrl) {
warnings.push(`Ignoring ${overrideUrl} from OPENRIG_URL/RIGGED_URL because rig destroy only operates on local state.`);
}
try {
const config = configStore.resolve();
return {
runtimeConfig: {
stateRoot,
dbPath: config.db.path === defaultDbPath ? effectiveDefaultDbPath : config.db.path,
transcriptsPath: config.transcripts.path === defaultTranscriptsPath ? effectiveDefaultTranscriptsPath : config.transcripts.path,
daemonHost: config.daemon.host,
daemonPort: config.daemon.port,
},
warnings,
};
} catch (err) {
warnings.push(`Failed to read config; using compatibility defaults instead: ${err instanceof Error ? err.message : String(err)}`);
return {
runtimeConfig: {
stateRoot,
dbPath: effectiveDefaultDbPath,
transcriptsPath: effectiveDefaultTranscriptsPath,
daemonHost: "127.0.0.1",
daemonPort: 7433,
},
warnings,
};
}
}
function realDestroyDeps(): DestroyDeps {
const lifecycleDeps = realDeps();
return {
stopDaemon: async () => stopDaemon(lifecycleDeps),
inspectListener: async (host: string, port: number): Promise<ListenerInspection> => {
try {
const res = await fetch(`http://${host}:${port}/healthz`);
if (!res.ok) {
return {
kind: "other_http",
healthy: false,
detail: `HTTP ${res.status}`,
};
}
let body: unknown = null;
try {
body = await res.json();
} catch {
return {
kind: "other_http",
healthy: false,
detail: "Response body was not valid OpenRig health JSON.",
};
}
if (body && typeof body === "object" && (body as Record<string, unknown>).status === "ok") {
return { kind: "openrig", healthy: true };
}
return {
kind: "other_http",
healthy: false,
detail: "Response body did not match the OpenRig health contract.",
};
} catch {
return { kind: "unreachable" };
}
},
findListeningPid: findListeningPidWithLsof,
killProcess: (pid: number) => {
process.kill(pid, "SIGTERM");
},
exists: existsSync,
renamePath: (from: string, to: string) => renameSync(from, to),
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
killTmuxSession: killTmuxSessionWithCli,
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => new Date(),
};
}
function printPlan(
scope: DestroyScope,
backup: boolean,
runtimeConfig: DestroyRuntimeConfig,
managedTmuxSessions: string[],
warnings: string[]
): void {
console.log("DESTROY PLAN");
console.log(` scope: ${scope}`);
console.log(` state root: ${runtimeConfig.stateRoot}`);
console.log(` daemon: http://${runtimeConfig.daemonHost}:${runtimeConfig.daemonPort}`);
console.log(` backup: ${backup ? "enabled" : "disabled"}`);
console.log(` tmux cleanup: ${scope === "all" ? `enabled (${managedTmuxSessions.length} managed session${managedTmuxSessions.length === 1 ? "" : "s"})` : "disabled"}`);
for (const warning of warnings) {
console.log(` warning: ${warning}`);
}
console.log("");
}
function printResult(result: Awaited<ReturnType<typeof executeDestroy>>): void {
console.log("DESTROY RESULT");
console.log(` daemon: ${result.daemonStopped ? "stopped" : "still responding"}`);
console.log(` port: ${result.portCleared ? "cleared" : "still occupied"}`);
if (result.scope === "all") {
console.log(` tmux sessions removed: ${result.tmuxKilled}`);
if (result.tmuxMissing > 0) {
console.log(` tmux sessions missing/already gone: ${result.tmuxMissing}`);
}
}
if (result.backupPaths.length > 0) {
for (const backupPath of result.backupPaths) {
console.log(` backup: ${backupPath}`);
}
} else if (result.backup) {
console.log(" backup: nothing to move");
}
console.log(result.stateRecreated ? ` fresh state root: ${result.stateRoot}` : " fresh state root: not created (state left untouched)");
for (const warning of result.warnings) {
console.log(` warning: ${warning}`);
}
}
export function destroyCommand(depsOverride?: DestroyCommandDeps): Command {
const cmd = new Command("destroy").description("Destroy OpenRig local state for recovery");
cmd
.option("--state", "Destroy OpenRig state and recreate an empty state root")
.option("--all", "Destroy OpenRig state and remove managed tmux sessions")
.option("--backup", "Move state aside instead of deleting it")
.option("--yes", "Confirm the destructive operation")
.option("--confirm <token>", `Exact confirmation token: ${DESTROY_CONFIRM_TOKEN}`)
.action(async (opts: { state?: boolean; all?: boolean; backup?: boolean; yes?: boolean; confirm?: string }) => {
const selectedScopes = [opts.state ? "state" : null, opts.all ? "all" : null].filter(Boolean) as DestroyScope[];
if (selectedScopes.length !== 1) {
console.error("Specify exactly one destroy scope: --state or --all");
process.exitCode = 1;
return;
}
if (!opts.yes) {
console.error("Destroy requires --yes");
process.exitCode = 1;
return;
}
if (opts.confirm !== DESTROY_CONFIRM_TOKEN) {
console.error(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
process.exitCode = 1;
return;
}
const deps = depsOverride ?? {
configStore: new ConfigStore(),
destroyDeps: realDestroyDeps(),
};
const { runtimeConfig, warnings } = resolveRuntimeConfig(deps.configStore);
const plan = buildDestroyPlan(selectedScopes[0]!, Boolean(opts.backup), runtimeConfig, deps.destroyDeps);
printPlan(plan.scope, plan.backup, runtimeConfig, plan.managedTmuxSessions, [...warnings, ...plan.warnings]);
plan.warnings = [...warnings, ...plan.warnings];
const result = await executeDestroy(plan, deps.destroyDeps);
printResult(result);
if (!result.portCleared) {
process.exitCode = 1;
}
});
return cmd;
}
+178 -20
View File
@@ -1,15 +1,22 @@
import { Command } from "commander";
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import net from "node:net";
import { execSync } from "node:child_process";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { ConfigStore } from "../config-store.js";
import { buildWritableHomeCheck } from "../system-preflight.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
isCmuxSocketControlCompatible,
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
interface DoctorCheck {
name: string;
status: "pass" | "warn" | "fail";
status: "pass" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fix?: string;
@@ -18,11 +25,14 @@ interface DoctorCheck {
export interface DoctorDeps {
exists: (p: string) => boolean;
baseDir: string;
readFile: (path: string) => string | null;
exec: (cmd: string) => string;
checkPort: (port: number) => Promise<boolean>;
configStore: Pick<ConfigStore, "resolve">;
platform?: NodeJS.Platform;
mkdirp?: (path: string) => void;
checkWritable?: (path: string) => void;
fetch?: (url: string) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
}
const MIN_NODE_MAJOR = 20;
@@ -39,8 +49,9 @@ function defaultCheckPort(port: number): Promise<boolean> {
});
}
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck> } {
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck>; asyncChecks: Promise<DoctorCheck>[] } {
const checks: DoctorCheck[] = [];
const platform = deps.platform ?? process.platform;
// 1. Daemon dist
const daemonPath = resolveDaemonPath(deps.baseDir, deps.exists);
@@ -86,10 +97,8 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
}
// 4. tmux
try {
const ver = deps.exec("tmux -V").trim();
checks.push({ name: "tmux", status: "pass", message: ver });
} catch {
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
if (tmuxProbe.code === "not_installed") {
checks.push({
name: "tmux",
status: "fail",
@@ -97,9 +106,78 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
reason: "OpenRig uses tmux to manage agent sessions.",
fix: "Install tmux: brew install tmux (macOS), apt install tmux (Linux).",
});
} else if (tmuxProbe.available && tmuxProbe.version) {
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
if (platform === "darwin") {
const mouseMode = readTmuxMouseMode(deps);
if (mouseMode === "on") {
checks.push({
name: "tmux_mouse",
status: "pass",
message: "tmux mouse mode enabled.",
});
} else if (mouseMode === "off") {
checks.push({
name: "tmux_mouse",
status: "warn",
message: "tmux mouse mode appears disabled.",
reason: "On macOS, scrolling and text selection inside tmux panes is much smoother with mouse mode enabled.",
fix: "Run `tmux set -g mouse on` for the current tmux server. To keep it enabled, add `set -g mouse on` to `~/.tmux.conf` and reload with `tmux source-file ~/.tmux.conf`.",
});
}
}
} else {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
checks.push({
name: "tmux",
status: "fail",
message: failure.message,
reason: failure.reason,
fix: failure.fix,
});
}
// 5. Writable state paths (shared with preflight)
// 5. cmux shell check (optional but recommended for Open CMUX workflows)
let shellCmuxPassed = false;
try {
deps.exec("cmux capabilities --json");
shellCmuxPassed = true;
checks.push({
name: "cmux_shell",
status: "pass",
message: "cmux shell control available.",
});
} catch (err) {
try {
deps.exec("cmux --help");
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
const modeHint = socketMode?.error
? ` Likely cause on macOS: ${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable (${socketMode.error}).`
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode) && socketMode.source === "default"
? ` Likely cause on macOS: cmux is still using its default automation.socketControlMode '${socketMode.mode}'.`
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode)
? ` Likely cause on macOS: automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}.`
: "";
checks.push({
name: "cmux_shell",
status: "warn",
message: "cmux installed, but control unavailable right now.",
reason: "OpenRig can run without cmux, but Open CMUX actions and cmux-aware node control will be unavailable until cmux control works.",
fix: `Open the cmux app, verify control access/socket sharing is enabled for OpenRig, then rerun 'rig doctor'. If you are running this for someone else, tell the user that cmux is optional but required for Open CMUX. If you do not need cmux features, you can ignore this warning.${modeHint}`,
});
} catch {
checks.push({
name: "cmux_shell",
status: "warn",
message: "cmux not found.",
reason: "OpenRig can run without cmux, but Open CMUX actions and surface control will be unavailable.",
fix: "Install and launch cmux if you want Open CMUX support. If you are running this for someone else, tell the user that cmux is optional and only needed for Open CMUX workflows.",
});
}
void err;
}
// 6. Writable state paths (shared with preflight)
const config = deps.configStore.resolve();
const writableCheck = buildWritableHomeCheck(config, path.dirname(config.db.path), {
mkdirp: deps.mkdirp,
@@ -113,7 +191,7 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
fix: writableCheck.fix,
});
// 6. Port availability (async) — daemon already running on that port counts as OK
// 7. Port availability (async) — daemon already running on that port counts as OK
const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise<DoctorCheck> => {
if (available) {
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` };
@@ -134,7 +212,86 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
};
});
return { checks, portCheck };
// 8. Daemon cmux control (async, only when shell cmux passed)
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
if (shellCmuxPassed) {
const fetchFn = deps.fetch ?? globalThis.fetch;
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
try {
const healthRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
if (!healthRes.ok) {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not running. Skipping daemon cmux check." };
}
} catch {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
}
try {
const cmuxRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/api/adapters/cmux/status`);
if (cmuxRes.ok && cmuxRes.json) {
const data = (await cmuxRes.json()) as { available?: boolean };
if (data.available) {
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
}
return buildCmuxDaemonWarning(deps, platform);
}
} catch { /* fetch failed */ }
return buildCmuxDaemonWarning(deps, platform);
})();
asyncChecks.push(daemonCmuxCheck);
}
return { checks, portCheck, asyncChecks };
}
function readCmuxSocketControlMode(deps: DoctorDeps) {
return readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
}
function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): DoctorCheck {
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
if (platform === "darwin" && socketMode?.error) {
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason: `${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable: ${socketMode.error}`,
fix: `Repair ${CMUX_SETTINGS_DISCLOSURE_PATH} or remove it so cmux can regenerate the template, then rerun \`rig setup\` or \`rig doctor\`.`,
};
}
if (platform === "darwin" && socketMode && !isCmuxSocketControlCompatible(socketMode.mode)) {
const reason = socketMode.source === "default"
? `cmux is still using its default automation.socketControlMode '${socketMode.mode}', so the daemon cannot attach as an external cmux client yet.`
: `automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}, so the daemon cannot attach as an external cmux client yet.`;
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason,
fix: `Run \`rig setup\` to set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then restart the daemon with \`rig daemon start\`.`,
};
}
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason: "The daemon inherited a terminal/session environment that broke cmux adapter initialization.",
fix: "Restart the daemon with `rig daemon start` after setup. If it still fails, inspect the daemon log with `rig daemon logs`.",
};
}
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
try {
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
if (mode === "on" || mode === "off") return mode;
return null;
} catch {
return null;
}
}
export function doctorCommand(depsOverride?: DoctorDeps): Command {
@@ -146,34 +303,35 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
const deps: DoctorDeps = depsOverride ?? {
exists: existsSync,
baseDir: import.meta.dirname,
exec: (c: string) => execSync(c, { encoding: "utf-8" }),
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
checkPort: defaultCheckPort,
configStore: new ConfigStore(),
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
checkWritable: (dirPath: string) => accessSync(dirPath, constants.W_OK),
};
const { checks, portCheck } = runDoctorChecks(deps);
const portResult = await portCheck;
const allChecks = [...checks, portResult];
const allPass = allChecks.every((c) => c.status === "pass");
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolvedAsync = await Promise.all(asyncChecks);
const allChecks = [...checks, ...resolvedAsync];
const healthy = allChecks.every((c) => c.status !== "fail");
if (opts.json) {
console.log(JSON.stringify({ healthy: allPass, checks: allChecks }, null, 2));
if (!allPass) process.exitCode = 1;
console.log(JSON.stringify({ healthy, checks: allChecks }, null, 2));
if (!healthy) process.exitCode = 1;
return;
}
for (const check of allChecks) {
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : "FAIL";
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : check.status === "skipped" ? "SKIP" : "FAIL";
console.log(` [${icon}] ${check.name}: ${check.message}`);
if (check.reason) console.log(` Why: ${check.reason}`);
if (check.fix) console.log(` Fix: ${check.fix}`);
}
console.log("");
console.log(allPass ? "All checks passed." : "Some checks failed.");
if (!allPass) process.exitCode = 1;
console.log(healthy ? "System checks look good." : "Some checks failed.");
if (!healthy) process.exitCode = 1;
});
return cmd;
+3 -1
View File
@@ -14,6 +14,8 @@ interface TeardownResult {
errors: string[];
}
const LONG_RUNNING_TIMEOUT_MS = 45_000;
/**
* `rig down <rigId>` — tear down a rig.
* @param depsOverride - injectable deps for testing
@@ -46,7 +48,7 @@ export function downCommand(depsOverride?: StatusDeps): Command {
delete: opts.delete ?? false,
force: opts.force ?? false,
snapshot: opts.snapshot ?? false,
});
}, { timeoutMs: LONG_RUNNING_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
+142
View File
@@ -0,0 +1,142 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function envCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("env")
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
.addHelpText("after", `
Examples:
rig env status secrets-manager
rig env logs secrets-manager vault
rig env down secrets-manager
`);
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running");
return null;
}
return deps.clientFactory(getDaemonUrl(status));
}
async function resolveRigId(client: DaemonClient, rigRef: string): Promise<string> {
const summaries = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
if (!match) throw new Error(`Rig '${rigRef}' not found`);
return match.id;
}
// rig env status <rig>
cmd
.command("status")
.argument("<rig>", "Rig name or ID")
.option("--json", "JSON output")
.action(async (rig: string, opts: { json?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
if (!res.data["hasServices"]) {
console.log("No services configured for this rig.");
return;
}
const receipt = res.data["receipt"] as Record<string, unknown> | null;
if (!receipt) {
console.log("Services configured but no receipt available yet.");
return;
}
console.log(`Env: ${res.data["kind"]} (${res.data["projectName"]})`);
const services = receipt["services"] as Array<{ name: string; status: string; health?: string | null }>;
if (services) {
for (const svc of services) {
const health = svc.health ? ` (${svc.health})` : "";
console.log(` ${svc.name}: ${svc.status}${health}`);
}
}
});
// rig env logs <rig> [service]
cmd
.command("logs")
.argument("<rig>", "Rig name or ID")
.argument("[service]", "Specific service name")
.option("--tail <n>", "Number of lines", "100")
.action(async (rig: string, service: string | undefined, opts: { tail: string }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const params = new URLSearchParams({ tail: opts.tail });
if (service) params.set("service", service);
const res = await client.get<{ ok: boolean; output?: string; error?: string }>(
`/api/rigs/${encodeURIComponent(rigId)}/env/logs?${params}`,
);
if (res.status >= 400 || !res.data.ok) {
console.error(res.data.error ?? `Failed to get logs (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
console.log(res.data.output ?? "");
});
// rig env down <rig> [--volumes]
cmd
.command("down")
.argument("<rig>", "Rig name or ID")
.option("--volumes", "Also remove volumes")
.action(async (rig: string, opts: { volumes?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const res = await client.post<{ ok: boolean; error?: string }>(
`/api/rigs/${encodeURIComponent(rigId)}/env/down`,
{ volumes: opts.volumes ?? false },
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
);
if (res.status >= 400 || !res.data.ok) {
console.error(res.data.error ?? `Failed to stop services (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
console.log(`Services stopped for ${rig}.`);
});
return cmd;
}
+14 -4
View File
@@ -102,13 +102,23 @@ export function expandCommand(depsOverride?: StatusDeps): Command {
for (const w of data.warnings) console.log(` Warning: ${w}`);
}
// Honest retry guidance for failed nodes
// Honest retry guidance for failed nodes. The pod-aware /launch route
// returns pod_aware_launch_unsupported for any node `expand` creates
// (every expand-created node has podId set), so the recoverable path is
// shrink-the-failed-pod + fix + re-expand, not per-node relaunch.
if (data.retryTargets && data.retryTargets.length > 0) {
console.log("");
console.log(" Failed nodes can be relaunched via the dashboard or CLI:");
for (const target of data.retryTargets) {
console.log(` rig launch ${rigId} ${target}`);
console.log(" Failed nodes cannot be relaunched directly because pod-aware launch");
console.log(" bypasses startup orchestration. Recover by:");
console.log(` 1. Verify other seats are healthy: rig ps --filter rigName=${rigId} --nodes`);
if (data.podNamespace) {
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} ${data.podNamespace}`);
} else {
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} <pod-namespace>`);
}
console.log(` 3. Fix the spec/config issue.`);
console.log(` 4. Re-run: rig expand ${rigId} <pod-fragment-path>`);
console.log(` Failed targets: ${data.retryTargets.join(", ")}`);
}
if (data.status !== "ok") {
+530
View File
@@ -0,0 +1,530 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { Command } from "commander";
export type HeartbeatExecutionState =
| "checked-out"
| "proven-active"
| "stalled"
| "unproven"
| "blocked"
| "parked"
| "done";
export interface HeartbeatProof {
at: string;
ageSeconds: number;
line: string;
path: string | null;
}
export interface HeartbeatItem {
id: string;
rig: string;
owner: string;
session: string;
title: string;
queueState: string;
executionState: HeartbeatExecutionState;
queueFile: string;
checkoutAt: string | null;
checkoutAgeSeconds: number | null;
lastProof: HeartbeatProof | null;
blockedOn: string | null;
}
export interface HeartbeatSummary {
total: number;
checkedOut: number;
provenActive: number;
stalled: number;
unproven: number;
blocked: number;
parked: number;
done: number;
}
export interface HeartbeatResult {
generatedAt: string;
sharedDocsRoot: string;
rigFilter: string | null;
windows: {
firstProofSeconds: number;
heartbeatSeconds: number;
};
summary: HeartbeatSummary;
items: HeartbeatItem[];
nudgeResults?: HeartbeatNudgeResult[];
}
export interface HeartbeatNudgeResult {
id: string;
session: string;
executionState: "stalled" | "unproven";
ok: boolean;
message: string;
}
interface QueueEntry {
id: string;
rig: string;
owner: string;
session: string;
title: string;
queueState: string;
queueFile: string;
meta: Record<string, string>;
body: string;
}
export interface HeartbeatOptions {
rig?: string;
firstProofSeconds?: number;
heartbeatSeconds?: number;
includeDone?: boolean;
now?: Date;
}
export interface HeartbeatDeps {
sharedDocsRoot?: string;
env?: NodeJS.ProcessEnv;
now?: () => Date;
send?: (session: string, text: string) => Promise<{ ok: boolean; message: string }>;
}
const DEFAULT_FIRST_PROOF_SECONDS = 7_200;
const DEFAULT_HEARTBEAT_SECONDS = 7_200;
const TIMESTAMP_PATTERN = String.raw`\d{4}-\d{2}-\d{2}T[\d:~]+(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`;
const STATE_RANK: Record<HeartbeatExecutionState, number> = {
stalled: 0,
unproven: 1,
blocked: 2,
"checked-out": 3,
"proven-active": 4,
parked: 5,
done: 6,
};
export function heartbeatCommand(depsOverride?: HeartbeatDeps): Command {
const cmd = new Command("heartbeat")
.description("Show workflow execution proof state from queue files")
.addHelpText("after", `
Examples:
rig heartbeat --rig openrig-pm
rig heartbeat --rig openrig-pm --json
rig heartbeat --rig openrig-pm --nudge
Default mode is read-only. --nudge only sends informational proof instructions
to stalled/unproven owners; it does not modify queue files or reroute work.`);
cmd
.option("--rig <name>", "Limit to a single rig")
.option("--json", "JSON output for agents")
.option("--nudge", "Send informational nudges to stalled/unproven owners")
.option("--include-done", "Include done/handed-off queue items in output")
.action(async (opts: { rig?: string; json?: boolean; nudge?: boolean; includeDone?: boolean }) => {
const deps = depsOverride ?? {};
const env = deps.env ?? process.env;
const sharedDocsRoot = deps.sharedDocsRoot ?? resolveSharedDocsRoot(env);
if (!sharedDocsRoot) {
console.error("rig heartbeat: cannot resolve shared-docs root. Set RIGX_SHARED_DOCS_ROOT.");
process.exitCode = 1;
return;
}
const firstProofSeconds = readPositiveInt(env["HEARTBEAT_FIRST_PROOF_WINDOW"], DEFAULT_FIRST_PROOF_SECONDS);
const heartbeatSeconds = readPositiveInt(env["HEARTBEAT_HEARTBEAT_CADENCE"], DEFAULT_HEARTBEAT_SECONDS);
const now = deps.now?.() ?? new Date();
const result = analyzeHeartbeat({
sharedDocsRoot,
rig: opts.rig,
firstProofSeconds,
heartbeatSeconds,
includeDone: !!opts.includeDone,
now,
});
if (opts.nudge) {
const send = deps.send ?? defaultSend;
result.nudgeResults = await sendHeartbeatNudges(result.items, send);
}
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
printHumanHeartbeat(result);
});
return cmd;
}
export function analyzeHeartbeat(input: {
sharedDocsRoot: string;
rig?: string;
firstProofSeconds?: number;
heartbeatSeconds?: number;
includeDone?: boolean;
now?: Date;
}): HeartbeatResult {
const firstProofSeconds = input.firstProofSeconds ?? DEFAULT_FIRST_PROOF_SECONDS;
const heartbeatSeconds = input.heartbeatSeconds ?? DEFAULT_HEARTBEAT_SECONDS;
const now = input.now ?? new Date();
const generatedAt = now.toISOString();
const queueFiles = discoverQueueFiles(input.sharedDocsRoot, input.rig);
const entries = queueFiles.flatMap((file) => parseQueueFile(input.sharedDocsRoot, file));
const items = entries
.map((entry) => classifyQueueEntry(entry, { now, firstProofSeconds, heartbeatSeconds }))
.filter((item): item is HeartbeatItem => item !== null)
.filter((item) => input.includeDone || item.executionState !== "done")
.sort(compareHeartbeatItems);
return {
generatedAt,
sharedDocsRoot: input.sharedDocsRoot,
rigFilter: input.rig ?? null,
windows: { firstProofSeconds, heartbeatSeconds },
summary: summarize(items),
items,
};
}
export function parseQueueFile(sharedDocsRoot: string, queueFile: string): QueueEntry[] {
const content = fs.readFileSync(queueFile, "utf8");
const rig = deriveRigName(sharedDocsRoot, queueFile);
const owner = deriveOwner(queueFile);
const session = deriveSessionName(owner, rig);
const entries: QueueEntry[] = [];
const entryPattern = new RegExp(
String.raw`(?:^|\n)---\nid:\s*(\S+)\n([\s\S]*?)\n---\n([\s\S]*?)(?=\n---\nid:\s*\S+\n|$)`,
"g",
);
for (const match of content.matchAll(entryPattern)) {
const id = match[1]!;
const meta = parseMetadata(match[2] ?? "");
meta["id"] = id;
const body = match[3] ?? "";
const queueState = normalizeState(meta["state"] ?? "unknown");
const title = body.match(/^###\s+(.+)$/m)?.[1]?.trim() ?? id;
entries.push({
id,
rig,
owner,
session,
title: title.slice(0, 120),
queueState,
queueFile,
meta,
body,
});
}
return entries;
}
export function classifyQueueEntry(
entry: QueueEntry,
opts: { now: Date; firstProofSeconds: number; heartbeatSeconds: number },
): HeartbeatItem | null {
const blockedOn = cleanNullable(entry.meta["blocked-on"]);
if (entry.queueState === "blocked" || entry.queueState === "blocked-on") {
return baseItem(entry, "blocked", opts.now, null, null, blockedOn);
}
if (entry.queueState === "deferred" || entry.queueState === "parked") {
return baseItem(entry, "parked", opts.now, null, null, blockedOn);
}
if (entry.queueState === "done" || entry.queueState === "handed-off") {
return baseItem(entry, "done", opts.now, null, null, blockedOn);
}
if (entry.queueState !== "in-progress") {
return null;
}
const checkoutAt = firstStateTransitionTimestamp(entry.body, "in-progress")
?? cleanNullable(entry.meta["ts-created"]);
const checkoutAgeSeconds = ageSeconds(checkoutAt, opts.now);
const lastProof = latestProof(entry.body, opts.now);
let executionState: HeartbeatExecutionState;
if (lastProof && lastProof.ageSeconds <= opts.heartbeatSeconds) {
executionState = "proven-active";
} else if (lastProof) {
executionState = "stalled";
} else if (checkoutAgeSeconds != null && checkoutAgeSeconds > opts.firstProofSeconds) {
executionState = "unproven";
} else {
executionState = "checked-out";
}
return baseItem(entry, executionState, opts.now, checkoutAt, lastProof, blockedOn);
}
export async function sendHeartbeatNudges(
items: HeartbeatItem[],
send: (session: string, text: string) => Promise<{ ok: boolean; message: string }>,
): Promise<HeartbeatNudgeResult[]> {
const targets = items.filter((item): item is HeartbeatItem & { executionState: "stalled" | "unproven" } =>
item.executionState === "stalled" || item.executionState === "unproven"
);
const results: HeartbeatNudgeResult[] = [];
for (const item of targets) {
const proofCopy = item.executionState === "stalled" && item.lastProof
? `Last proof was ${formatAge(item.lastProof.ageSeconds)} ago.`
: `No proof-of-work since checkout${item.checkoutAgeSeconds == null ? "." : ` ${formatAge(item.checkoutAgeSeconds)} ago.`}`;
const text = [
`[heartbeat-nudge] Your task \`${item.id}\` is ${item.executionState}. ${proofCopy}`,
"If actively working, add a task-specific proof note to your queue item naming the artifact.",
"If blocked, transition to blocked with reason. If pausing, transition to deferred with reason.",
].join(" ");
const result = await send(item.session, text);
results.push({ id: item.id, session: item.session, executionState: item.executionState, ...result });
}
return results;
}
function resolveSharedDocsRoot(env: NodeJS.ProcessEnv): string | null {
for (const key of ["RIGX_SHARED_DOCS_ROOT", "OPENRIG_SHARED_DOCS_ROOT"]) {
const value = env[key];
if (value && fs.existsSync(nodePath.join(value, "rigs"))) return value;
}
const home = env["HOME"] ?? os.homedir();
const wellKnown = nodePath.join(home, "code", "substrate", "shared-docs");
if (fs.existsSync(nodePath.join(wellKnown, "rigs"))) return wellKnown;
return null;
}
function discoverQueueFiles(sharedDocsRoot: string, rig?: string): string[] {
const base = rig
? nodePath.join(sharedDocsRoot, "rigs", rig, "state")
: nodePath.join(sharedDocsRoot, "rigs");
const files: string[] = [];
walk(base, files);
return files.filter((file) => file.endsWith(".queue.md")).sort();
}
function walk(dir: string, files: string[]): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = nodePath.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath, files);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
function parseMetadata(text: string): Record<string, string> {
const meta: Record<string, string> = {};
for (const line of text.split("\n")) {
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (match) meta[match[1]!.toLowerCase()] = match[2]!.trim();
}
return meta;
}
function normalizeState(state: string): string {
return state.trim().toLowerCase().replace(/_/g, "-");
}
function firstStateTransitionTimestamp(body: string, state: string): string | null {
const pattern = new RegExp(`(${TIMESTAMP_PATTERN})\\s*[-\u2014]\\s*${escapeRegex(state).replace("-", "[- ]")}`, "gi");
const matches = Array.from(body.matchAll(pattern)).map((match) => match[1]!.replace("~", ""));
return matches.length > 0 ? matches[0]! : null;
}
function latestProof(body: string, now: Date): HeartbeatProof | null {
const proofs: HeartbeatProof[] = [];
for (const rawLine of body.split("\n")) {
const line = rawLine.trim();
if (!/\bproof\b/i.test(line)) continue;
const timestamp = line.match(new RegExp(`(${TIMESTAMP_PATTERN})`, "i"))?.[1]?.replace("~", "");
if (!timestamp) continue;
const path = extractEvidencePath(line);
if (!path) continue;
const age = ageSeconds(timestamp, now);
if (age == null) continue;
proofs.push({ at: timestamp, ageSeconds: age, line, path });
}
proofs.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime());
return proofs.at(-1) ?? null;
}
function extractEvidencePath(line: string): string | null {
const backtickPath = Array.from(line.matchAll(/`([^`]+)`/g))
.map((match) => match[1]!)
.find(looksLikePath);
if (backtickPath) return backtickPath;
const tokenPath = line
.split(/\s+/)
.map((token) => token.replace(/[),.;:]+$/g, "").replace(/^["']|["']$/g, ""))
.find(looksLikePath);
return tokenPath ?? null;
}
function looksLikePath(value: string): boolean {
if (value.startsWith("/")) return value.length > 1;
if (value.startsWith("./") || value.startsWith("../")) return true;
if (/^[A-Za-z0-9_.-]+\.(md|txt|ts|tsx|js|jsx|json|jsonl|yaml|yml|toml|sqlite|db)$/i.test(value)) return true;
return /[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+/.test(value);
}
function baseItem(
entry: QueueEntry,
executionState: HeartbeatExecutionState,
now: Date,
checkoutAt: string | null,
lastProof: HeartbeatProof | null,
blockedOn: string | null,
): HeartbeatItem {
return {
id: entry.id,
rig: entry.rig,
owner: entry.owner,
session: entry.session,
title: entry.title,
queueState: entry.queueState,
executionState,
queueFile: entry.queueFile,
checkoutAt,
checkoutAgeSeconds: ageSeconds(checkoutAt, now),
lastProof,
blockedOn,
};
}
function ageSeconds(ts: string | null, now: Date): number | null {
if (!ts) return null;
const parsed = parseTimestamp(ts);
if (!parsed) return null;
return Math.max(0, Math.floor((now.getTime() - parsed.getTime()) / 1000));
}
function parseTimestamp(raw: string): Date | null {
const normalized = raw.trim().replace("~", "");
const iso = /Z$|[+-]\d{2}:?\d{2}$/.test(normalized) ? normalized : `${normalized}Z`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
function summarize(items: HeartbeatItem[]): HeartbeatSummary {
return {
total: items.length,
checkedOut: items.filter((item) => item.executionState === "checked-out").length,
provenActive: items.filter((item) => item.executionState === "proven-active").length,
stalled: items.filter((item) => item.executionState === "stalled").length,
unproven: items.filter((item) => item.executionState === "unproven").length,
blocked: items.filter((item) => item.executionState === "blocked").length,
parked: items.filter((item) => item.executionState === "parked").length,
done: items.filter((item) => item.executionState === "done").length,
};
}
function compareHeartbeatItems(a: HeartbeatItem, b: HeartbeatItem): number {
const rankA = STATE_RANK[a.executionState];
const rankB = STATE_RANK[b.executionState];
if (rankA !== rankB) return rankA - rankB;
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
if (a.owner !== b.owner) return a.owner.localeCompare(b.owner);
return a.id.localeCompare(b.id);
}
function deriveRigName(sharedDocsRoot: string, queueFile: string): string {
const rel = nodePath.relative(nodePath.join(sharedDocsRoot, "rigs"), queueFile);
return rel.split(nodePath.sep)[0] ?? "";
}
function deriveOwner(queueFile: string): string {
const parts = queueFile.split(nodePath.sep);
const stateIndex = parts.lastIndexOf("state");
if (stateIndex < 0 || stateIndex + 2 >= parts.length) return nodePath.basename(queueFile, ".queue.md");
const pod = parts[stateIndex + 1]!;
const member = nodePath.basename(parts[stateIndex + 2]!, ".queue.md");
return `${pod}.${member}`;
}
function deriveSessionName(owner: string, rig: string): string {
const [pod, member] = owner.split(".", 2);
return pod && member ? `${pod}-${member}@${rig}` : `${owner}@${rig}`;
}
function cleanNullable(value: string | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
return trimmed === "" || trimmed.toLowerCase() === "null" ? null : trimmed;
}
function readPositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function formatAge(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3_600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86_400) return `${Math.floor(seconds / 3_600)}h${Math.floor((seconds % 3_600) / 60)}m`;
return `${Math.floor(seconds / 86_400)}d${Math.floor((seconds % 86_400) / 3_600)}h`;
}
function printHumanHeartbeat(result: HeartbeatResult): void {
const title = result.rigFilter
? `WORKFLOW EXECUTION HEARTBEAT - ${result.rigFilter}`
: "WORKFLOW EXECUTION HEARTBEAT - all rigs";
console.log(title);
console.log(
`items: ${result.summary.total} | proven-active: ${result.summary.provenActive} | checked-out: ${result.summary.checkedOut} | stalled: ${result.summary.stalled} | unproven: ${result.summary.unproven} | blocked: ${result.summary.blocked} | parked: ${result.summary.parked} | done: ${result.summary.done}`,
);
if (result.items.length === 0) {
console.log("No workflow queue items found.");
} else {
console.log("");
console.log(`${"execution".padEnd(15)} ${"queue".padEnd(12)} ${"task".padEnd(32)} ${"owner".padEnd(28)} ${"proof".padEnd(12)} age`);
for (const item of result.items) {
const proof = item.lastProof ? `${formatAge(item.lastProof.ageSeconds)} ago` : "-";
const age = item.checkoutAgeSeconds == null ? "-" : formatAge(item.checkoutAgeSeconds);
console.log(
`${item.executionState.padEnd(15)} ${item.queueState.padEnd(12)} ${item.id.slice(0, 31).padEnd(32)} ${(item.rig + "/" + item.owner).padEnd(28)} ${proof.padEnd(12)} ${age}`,
);
}
}
if (result.nudgeResults) {
console.log("");
console.log(`Nudges: ${result.nudgeResults.length}`);
for (const nudge of result.nudgeResults) {
console.log(` ${nudge.session}: ${nudge.ok ? "sent" : "failed"} ${nudge.message}`);
}
}
}
function defaultSend(session: string, text: string): Promise<{ ok: boolean; message: string }> {
return new Promise((resolve) => {
execFile("rig", ["send", session, text], { timeout: 10_000 }, (error, stdout, stderr) => {
if (error) {
resolve({ ok: false, message: (stderr || error.message).trim() });
} else {
resolve({ ok: true, message: stdout.trim() || "sent" });
}
});
});
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+17 -4
View File
@@ -10,6 +10,8 @@ export interface ImportDeps extends StatusDeps {
readFile: (path: string) => string;
}
const LONG_RUNNING_IMPORT_TIMEOUT_MS = 120_000;
export function importCommand(depsOverride?: ImportDeps): Command {
const cmd = new Command("import").description("Import a rig spec from YAML");
const getDeps = (): ImportDeps => depsOverride ?? {
@@ -25,7 +27,8 @@ export function importCommand(depsOverride?: ImportDeps): Command {
.option("--preflight", "Run preflight checks")
.option("--target-rig <rigId>", "Target existing rig for additive materialization")
.option("--rig-root <root>", "Root directory for pod-aware resolution")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string }) => {
.option("--cwd <path>", "Override launch/materialization working directory for all members")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string; cwd?: string }) => {
const deps = getDeps();
// Read local file first (before daemon check — fail fast on missing file)
@@ -57,7 +60,11 @@ export function importCommand(depsOverride?: ImportDeps): Command {
const rigRoot = podAware
? (opts.rigRoot ? nodePath.resolve(opts.rigRoot) : nodePath.dirname(nodePath.resolve(filePath)))
: undefined;
const extraHeaders = rigRoot ? { "X-Rig-Root": rigRoot } : undefined;
const cwdOverride = opts.cwd ? nodePath.resolve(opts.cwd) : undefined;
const extraHeaders = {
...(rigRoot ? { "X-Rig-Root": rigRoot } : {}),
...(cwdOverride ? { "X-Cwd-Override": cwdOverride } : {}),
};
if (opts.instantiate && opts.materializeOnly) {
console.error("Choose either --instantiate or --materialize-only, not both.");
@@ -95,7 +102,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
return;
}
const headers = {
...(extraHeaders ?? {}),
...extraHeaders,
...(opts.targetRig ? { "X-Target-Rig-Id": opts.targetRig } : {}),
};
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import/materialize", yaml, "text/yaml", headers);
@@ -120,7 +127,13 @@ export function importCommand(depsOverride?: ImportDeps): Command {
}
if (opts.instantiate) {
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import", yaml, "text/yaml", extraHeaders);
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>(
"/api/rigs/import",
yaml,
"text/yaml",
extraHeaders,
{ timeoutMs: LONG_RUNNING_IMPORT_TIMEOUT_MS },
);
if (res.status === 409 || res.status === 400) {
const data = res.data as { ok: false; code: string; errors?: string[]; message?: string };
const detail = data.errors?.join("\n ") ?? data.message ?? `status ${res.status}`;
+1 -1
View File
@@ -22,7 +22,7 @@ export function preflightCommand(depsOverride?: PreflightCommandDeps): Command {
const exec = depsOverride?.exec ?? (async (c: string) => {
const { execSync } = await import("node:child_process");
return execSync(c, { encoding: "utf-8" });
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
});
const preflight = new SystemPreflight({
+199
View File
@@ -0,0 +1,199 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig project` — coordination primitive L2 (classifier) commands (PL-004 Phase B).
*
* Backed by `/api/projects`. Operates only via the daemon HTTP API.
*
* Per PRD § L2: classifier judgment stays with the agent; daemon enforces
* the lease + idempotency + reclaim contract.
*/
export interface ProjectDeps extends StatusDeps {}
async function withClient<T>(
deps: ProjectDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function projectCommand(depsOverride?: ProjectDeps): Command {
const cmd = new Command("project").description(
"Coordination L2 — agent-backed classifier with daemon-enforced lease + idempotency + reclaim",
);
const getDeps = (): ProjectDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
// ---- Lease lifecycle ----
cmd
.command("lease-acquire")
.description("Acquire the active classifier lease for the caller")
.requiredOption("--session <session>", "Classifier session name")
.option(
"--evaluate-deadness-first",
"Before acquire, call evaluateDeadness to clear any stale TTL-expired or dead-holder lease (per PRD § L2 deadness-detection)",
)
.option("--json", "JSON output for agents")
.action(async (opts: { session: string; evaluateDeadnessFirst?: boolean; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/lease/acquire", {
classifierSession: opts.session,
evaluateDeadnessFirst: opts.evaluateDeadnessFirst,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("lease-heartbeat")
.description("Send a heartbeat for an active classifier lease (extends TTL)")
.requiredOption("--lease-id <id>", "Lease ID")
.requiredOption("--session <session>", "Classifier session name")
.option("--json", "JSON output for agents")
.action(async (opts: { leaseId: string; session: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/lease/heartbeat", {
leaseId: opts.leaseId,
classifierSession: opts.session,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("lease-show")
.description("Show the currently-active classifier lease")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/projects/lease");
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Operator-verb reclaim ----
cmd
.command("reclaim-classifier")
.description(
"Operator-verb: reclaim the active classifier lease. Use --if-dead to refuse if holder is still alive.",
)
.requiredOption("--session <session>", "Session that will hold the new lease")
.option("--if-dead", "Only reclaim if the current holder is reported dead by the liveness check")
.option("--reason <text>", "Reclaim reason (free-form; recorded in classifier_leases.reclaim_reason)")
.option("--json", "JSON output for agents")
.action(async (opts: { session: string; ifDead?: boolean; reason?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/reclaim-classifier", {
byClassifierSession: opts.session,
ifDead: opts.ifDead,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Project a stream item ----
cmd
.command("classify <streamItemId>")
.description(
"Project a stream item with classification fields (idempotent on stream_item_id; requires active lease)",
)
.requiredOption("--session <session>", "Classifier session name (must hold active lease)")
.option("--type <type>", "Classification type (e.g., idea, bug, feature-request)")
.option("--urgency <urgency>", "Classification urgency (e.g., normal, high, critical)")
.option("--maturity <maturity>", "Classification maturity (e.g., concept, drafted, ratified)")
.option("--confidence <confidence>", "Classification confidence (e.g., low, medium, high)")
.option("--destination <destination>", "Classification destination (downstream slice/seat)")
.option("--action <action>", "Action type (e.g., create, advance)")
.option("--json", "JSON output for agents")
.action(async (streamItemId: string, opts: {
session: string;
type?: string;
urgency?: string;
maturity?: string;
confidence?: string;
destination?: string;
action?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/project", {
streamItemId,
classifierSession: opts.session,
classificationType: opts.type,
classificationUrgency: opts.urgency,
classificationMaturity: opts.maturity,
classificationConfidence: opts.confidence,
classificationDestination: opts.destination,
action: opts.action,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- List + show ----
cmd
.command("list")
.description("List project classifications with filters")
.option("--session <session>", "Filter by classifier session")
.option("--destination <destination>", "Filter by classification destination")
.option("--limit <n>", "Result limit", "100")
.option("--json", "JSON output for agents")
.action(async (opts: { session?: string; destination?: string; limit: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.session) params.set("classifierSession", opts.session);
if (opts.destination) params.set("classificationDestination", opts.destination);
if (opts.limit) params.set("limit", opts.limit);
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/projects/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <projectId>")
.description("Show one project classification")
.option("--json", "JSON output for agents")
.action(async (projectId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/projects/${encodeURIComponent(projectId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+690 -36
View File
@@ -3,13 +3,19 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
interface PsEntry {
rigId: string;
name: string;
/** L3-followup: alias of `name`. Always populated and equal to `name`. */
rigName?: string;
nodeCount: number;
runningCount: number;
status: "running" | "partial" | "stopped";
lifecycleState?: "running" | "recoverable" | "stopped" | "degraded" | "attention_required";
uptime: string | null;
latestSnapshot: string | null;
}
@@ -24,41 +30,451 @@ interface NodeEntry {
nodeKind: "agent" | "infrastructure";
runtime: string | null;
sessionStatus: string | null;
startupStatus: "pending" | "ready" | "failed" | null;
startupStatus: "pending" | "ready" | "attention_required" | "failed" | null;
restoreOutcome: string;
lifecycleState?: "running" | "detached" | "recoverable" | "attention_required";
tmuxAttachCommand: string | null;
resumeCommand: string | null;
latestError: string | null;
agentActivity?: {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
evidenceSource: string;
sampledAt: string;
evidence: string | null;
};
// PL-012: context-usage block surfaced from the daemon's
// /api/rigs/:id/nodes route (already populated). Optional because
// older daemons may not emit it.
contextUsage?: {
availability: "known" | "unknown";
usedPercentage: number | null;
fresh: boolean;
state?: "critical" | "warning" | "low" | "unknown";
sampledAt: string | null;
};
[key: string]: unknown;
}
// L3-followup: human-output budgets bound default terminal output for hosts
// with realistic agent counts. `--full` opts out. JSON output remains
// unbounded by default for back-compat (Decision C hybrid).
const HUMAN_RIG_BUDGET = 50;
const HUMAN_NODE_BUDGET = 100;
// L3-followup: --filter accepts only this allow-list. Unknown keys produce a
// clear error with the supported list (Guard Review Checklist: filter-parser
// security).
//
// PL-019 item 1: extends the allow-list with the nested key `agentActivity.state`.
// This is the only nested filter key v0 supports; the parser checks for the
// dotted form explicitly. Bare `agentActivity` is intentionally NOT a filter
// key — it's an object, not a scalar; project it via --fields if you need it.
const ALLOWED_FILTER_KEYS = new Set([
"status",
"lifecycleState",
"name-prefix",
"name",
"agentActivity.state",
// PL-012: context-usage filters. percent is numeric (>=, >, <=, <, =);
// state is enum (critical / warning / low / unknown — lockstep with
// computeContextHealthSummary tier vocabulary).
"contextUsage.percent",
"contextUsage.state",
]);
// PL-019 item 1: when filter key is `agentActivity.state`, the value is
// validated against the AgentActivityState enum. Invalid values produce a
// three-part error (what failed / what's allowed / what to do).
const ALLOWED_AGENT_ACTIVITY_STATES = new Set(["running", "needs_input", "idle", "unknown"]);
// PL-012: ALLOWED_CONTEXT_USAGE_STATES — must stay lockstep with the
// daemon's computeContextHealthSummary urgency vocab.
const ALLOWED_CONTEXT_USAGE_STATES = new Set(["critical", "warning", "low", "unknown"]);
const NUMERIC_FILTER_KEYS = new Set(["contextUsage.percent"]);
type NumericComparator = ">=" | ">" | "<=" | "<" | "=";
const NUMERIC_OPERATORS: NumericComparator[] = [">=", "<=", ">", "<", "="];
// C9a: --fields accepts a per-level allow-list. Unknown keys produce a clear
// error with the supported list, mirroring the --filter rejection pattern.
// Per-level because rig and node entries have different schemas (PsEntry
// rig-level vs NodeEntry node-level).
//
// Source of truth: PsEntry / NodeEntry interfaces above. `name` exists at
// rig-level only; `rigName` is the alias and exists at both levels (per the
// closed `rig ps trust and scale-safety` slice at openrig 0a9fb43).
const ALLOWED_RIG_FIELDS = new Set([
"rigId",
"name",
"rigName",
"nodeCount",
"runningCount",
"status",
"lifecycleState",
"uptime",
"latestSnapshot",
]);
const ALLOWED_NODE_FIELDS = new Set([
"rigId",
"rigName",
"logicalId",
"podId",
"podNamespace",
"canonicalSessionName",
"nodeKind",
"runtime",
"sessionStatus",
"startupStatus",
"restoreOutcome",
"lifecycleState",
"tmuxAttachCommand",
"resumeCommand",
"latestError",
"agentActivity",
"contextUsage",
]);
interface PsCliOptions {
json?: boolean;
nodes?: boolean;
full?: boolean;
limit?: string;
fields?: string;
summary?: boolean;
filter?: string;
host?: string;
active?: boolean;
}
export interface PsDeps extends StatusDeps {
/**
* Cross-host hooks. Both default to the production loaders/executors; tests
* inject in-package mocks so no real ssh / no real ~/.ssh / no real network
* is touched. Mirrors the SendDeps/CaptureDeps shape from the closed
* cross-host-rig-commands slice (cdce3a6).
*/
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
interface ParsedFilter {
key: string;
value: string;
/** PL-012: numeric comparator for keys in NUMERIC_FILTER_KEYS;
* defaults to "=" (equality) for all other keys. */
op: NumericComparator;
/** PL-012: parsed numeric value when op is a numeric comparator. */
numericValue?: number;
}
function parseFilter(filter: string): ParsedFilter | { error: string } {
// PL-012: pre-detect numeric comparators (>=, <=, >, <, =) so callers
// can write `contextUsage.percent>=80`. Order matters: check >= and
// <= before > and < so the longer prefix wins.
let op: NumericComparator = "=";
let opIdx = -1;
for (const candidate of NUMERIC_OPERATORS) {
const i = filter.indexOf(candidate);
if (i !== -1) {
// Prefer the leftmost match; on tie, prefer the longest comparator.
if (opIdx === -1 || i < opIdx || (i === opIdx && candidate.length > op.length)) {
opIdx = i;
op = candidate;
}
}
}
if (opIdx === -1) {
return { error: `--filter must be key<op>value (op = ${NUMERIC_OPERATORS.join(", ")}); got: '${filter}'` };
}
const key = filter.slice(0, opIdx);
const value = filter.slice(opIdx + op.length);
if (!ALLOWED_FILTER_KEYS.has(key)) {
return {
error: `Unknown --filter key '${key}'. Supported: ${[...ALLOWED_FILTER_KEYS].sort().join(", ")}`,
};
}
if (!value) {
return { error: `--filter value is empty for key '${key}'` };
}
// PL-012: numeric-keyed filters validate the value parses as a finite
// number. Three-part error per the existing convention.
if (NUMERIC_FILTER_KEYS.has(key)) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return {
error: `--filter ${key}${op}'${value}' is not a numeric value. ` +
`Allowed: a finite number (e.g., ${key}>=80). ` +
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
};
}
return { key, value, op, numericValue };
}
// PL-012: contextUsage.state enum guard — same shape as agentActivity.state.
if (key === "contextUsage.state" && !ALLOWED_CONTEXT_USAGE_STATES.has(value)) {
return {
error: `--filter contextUsage.state='${value}' is not a valid context state. ` +
`Allowed: ${[...ALLOWED_CONTEXT_USAGE_STATES].sort().join(", ")}. ` +
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
};
}
// PL-019 item 1: agentActivity.state is enum-valued; reject invalid values
// up-front so operators don't get a silent empty result on a typo. Three-part
// shape per `feedback_smart_agents_no_bureaucracy.md`: what failed / what's
// allowed / what to do.
if (key === "agentActivity.state" && !ALLOWED_AGENT_ACTIVITY_STATES.has(value)) {
return {
error: `--filter agentActivity.state='${value}' is not a valid activity state. ` +
`Allowed: ${[...ALLOWED_AGENT_ACTIVITY_STATES].sort().join(", ")}. ` +
`Use 'rig ps --nodes --fields agentActivity --json' to see what daemon is reporting.`,
};
}
// Non-numeric keys must use op = "=".
if (op !== "=") {
return {
error: `--filter ${key}${op}... uses a numeric comparator on a non-numeric key. ` +
`Allowed numeric keys: ${[...NUMERIC_FILTER_KEYS].sort().join(", ")}. ` +
`Use ${key}=<value> for equality.`,
};
}
return { key, value, op };
}
// PL-012: derive a context-usage tier from a numeric percent. Lockstep
// with daemon-side computeContextHealthSummary thresholds.
function deriveContextUsageState(percent: number | null | undefined): "critical" | "warning" | "low" | "unknown" {
if (typeof percent !== "number") return "unknown";
if (percent >= 80) return "critical";
if (percent >= 60) return "warning";
return "low";
}
// C9a: validate --fields against a per-level allow-list. Mirrors parseFilter's
// shape: rejects with a sorted-supported-list error message including the
// unknown key(s) quoted. The level-aware "Hint" fires when an operator types
// `--fields name` against a node query (the most likely confusion case
// preserved by the rigName/name aliasing at rig-level only).
function parseFields(input: string, allowed: Set<string>, level: "rig" | "nodes"): string[] | { error: string } {
const fields = input.split(",").map((f) => f.trim()).filter((f) => f.length > 0);
if (fields.length === 0) {
return { error: `--fields cannot be empty` };
}
const unknown = fields.filter((f) => !allowed.has(f));
if (unknown.length > 0) {
const sorted = [...allowed].sort().join(", ");
const hint = level === "nodes" && unknown.includes("name")
? ` Hint: 'name' is a rig-level field; use 'rigName' for node entries.`
: "";
const keyWord = unknown.length > 1 ? "keys" : "key";
return {
error: `Unknown --fields ${keyWord} ${unknown.map((u) => `'${u}'`).join(", ")}. Supported: ${sorted}.${hint}`,
};
}
return fields;
}
function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
return entries.filter((e) => {
if (filter.key === "status") return e.status === filter.value;
if (filter.key === "lifecycleState") return e.lifecycleState === filter.value;
if (filter.key === "name-prefix") return (e.rigName ?? e.name).startsWith(filter.value);
if (filter.key === "name") return (e.rigName ?? e.name) === filter.value;
// PL-019 item 1: agentActivity.state is node-level only; at the rig
// level it has no meaning, so the filter passes everything through and
// the user gets all rigs (the operator can `--nodes` to scope it).
if (filter.key === "agentActivity.state") return true;
// PL-012: contextUsage.* filters are node-level only.
if (filter.key === "contextUsage.percent" || filter.key === "contextUsage.state") return true;
return true;
});
}
function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[] {
return entries.filter((n) => {
// status maps to sessionStatus for nodes; lifecycleState applies directly.
if (filter.key === "status") return n.sessionStatus === filter.value;
if (filter.key === "lifecycleState") return n.lifecycleState === filter.value;
if (filter.key === "name-prefix") return n.rigName.startsWith(filter.value);
if (filter.key === "name") return n.rigName === filter.value;
// PL-019 item 1: nested-key traversal for agentActivity.state. Nodes
// without an agentActivity attachment never match a non-unknown filter
// value (the daemon reports `unknown` when it has no signal — explicit).
if (filter.key === "agentActivity.state") return n.agentActivity?.state === filter.value;
// PL-012: contextUsage.percent — numeric comparison. Nodes with no
// sample fail every comparator (operator can filter on
// contextUsage.state=unknown to find them).
if (filter.key === "contextUsage.percent") {
const pct = n.contextUsage?.usedPercentage;
if (typeof pct !== "number" || filter.numericValue === undefined) return false;
switch (filter.op) {
case ">=": return pct >= filter.numericValue;
case ">": return pct > filter.numericValue;
case "<=": return pct <= filter.numericValue;
case "<": return pct < filter.numericValue;
case "=": return pct === filter.numericValue;
}
}
if (filter.key === "contextUsage.state") {
const derived = n.contextUsage?.state ?? deriveContextUsageState(n.contextUsage?.usedPercentage ?? null);
return derived === filter.value;
}
return true;
});
}
function selectFields<T extends Record<string, unknown>>(entries: T[], fields: string[]): Array<Record<string, unknown>> {
return entries.map((e) => {
const out: Record<string, unknown> = {};
for (const f of fields) out[f] = e[f];
return out;
});
}
function summarizeRigs(entries: PsEntry[]): {
totalRigs: number;
totalRunning: number;
byLifecycle: Record<string, number>;
byStatus: Record<string, number>;
} {
const byLifecycle: Record<string, number> = {};
const byStatus: Record<string, number> = {};
let totalRunning = 0;
for (const e of entries) {
const ls = e.lifecycleState ?? "unknown";
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
if (e.status === "running") totalRunning++;
}
return { totalRigs: entries.length, totalRunning, byLifecycle, byStatus };
}
function summarizeNodes(entries: NodeEntry[]): {
totalNodes: number;
byLifecycle: Record<string, number>;
bySessionStatus: Record<string, number>;
} {
const byLifecycle: Record<string, number> = {};
const bySessionStatus: Record<string, number> = {};
for (const n of entries) {
const ls = n.lifecycleState ?? "unknown";
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
const ss = n.sessionStatus ?? "unknown";
bySessionStatus[ss] = (bySessionStatus[ss] ?? 0) + 1;
}
return { totalNodes: entries.length, byLifecycle, bySessionStatus };
}
// Compact rig-level lifecycle codes for the rig table header (3-char fixed width).
function abbrevRigLifecycle(state: PsEntry["lifecycleState"] | undefined): string {
if (!state) return "—";
if (state === "running") return "run";
if (state === "recoverable") return "rec";
if (state === "stopped") return "stp";
if (state === "degraded") return "deg";
if (state === "attention_required") return "att";
return "—";
}
// Compact per-node lifecycle codes for the nodes table.
function abbrevNodeLifecycle(state: NodeEntry["lifecycleState"] | undefined): string {
if (!state) return "—";
if (state === "running") return "run";
if (state === "recoverable") return "rec";
if (state === "detached") return "det";
if (state === "attention_required") return "att";
return "—";
}
/**
* `rig ps` — list running rigs and optionally their nodes.
* @param depsOverride - injectable deps for testing
* @returns Commander command
*
* L3-followup adds CLI-side shaping flags (`--limit`, `--fields`, `--summary`,
* `--filter`, `--full`) for context-window-safe output on large hosts. Default
* `rig ps` (human) truncates to ~50 rigs with an explicit footer naming totals
* and the `--full` opt-out. Default `rig ps --json` keeps the bare-array shape
* for back-compat; the truncation/envelope shape only applies when at least
* one of `--limit`/`--summary`/`--fields`/`--filter` is specified.
*/
export function psCommand(depsOverride?: StatusDeps): Command {
export function psCommand(depsOverride?: PsDeps): Command {
const cmd = new Command("ps")
.description("List rigs and their status")
.addHelpText("after", `
Examples:
rig ps Show all rigs with status
rig ps --json JSON output for piping/parsing
rig ps --nodes Show per-node detail for all rigs
rig ps --nodes --json Node inventory as JSON array
rig ps Show all rigs (human; truncated to ${HUMAN_RIG_BUDGET} with footer)
rig ps --full Disable human truncation
rig ps --json JSON output (bare array; back-compat)
rig ps --json --limit 20 Bounded JSON envelope
rig ps --json --summary Aggregate-only JSON (no per-rig entries)
rig ps --json --fields rigName,status,lifecycleState
Project JSON to named fields
rig ps --filter lifecycleState=attention_required
Show only rigs needing attention
rig ps --filter status=running Show only running rigs
rig ps --filter name-prefix=demo Filter by rig-name prefix
rig ps --nodes Per-node detail (human; truncated to ${HUMAN_NODE_BUDGET})
rig ps --nodes --json --limit 50 Bounded per-node JSON envelope
rig ps --nodes --active Show only nodes whose agentActivity.state == running (PL-019)
rig ps --nodes --filter agentActivity.state=running
Same as --active (the explicit form)
rig ps --host vm-claude-test --nodes --json Run on a remote host via single-hop ssh
JSON output entries include both \`name\` and \`rigName\` (alias) for forward
compatibility; agent code should prefer \`rigName\` (matches per-node JSON).
--filter accepts: status, lifecycleState, name-prefix, name, agentActivity.state.
Other keys are rejected. agentActivity.state is node-level (use with --nodes);
allowed values: running, needs_input, idle, unknown.
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
status, lifecycleState, uptime, latestSnapshot.
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
podNamespace, canonicalSessionName, nodeKind, runtime, sessionStatus,
startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
resumeCommand, latestError, agentActivity. Other keys are rejected.
\`name\` is rig-level only; for node entries use \`rigName\`. Nested-field
projection (e.g. \`agentActivity.state\`) is not supported in this slice; pass
\`agentActivity\` to project the whole object and read the nested value
downstream.
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh (CLI-side shell-out; daemon untouched). The remote rig's
output is what counts and is surfaced verbatim on success; failure is
distinguished into ssh-unreachable / permission-gate / remote-daemon-unreachable
/ remote-command-failed.
Exit codes:
0 Success
1 Daemon not running
1 Daemon not running, or invalid --filter / --limit / --fields
2 Failed to fetch data from daemon`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const getDepsF = (): PsDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--nodes", "Show per-node detail for all rigs")
.action(async (opts: { json?: boolean; nodes?: boolean }) => {
.option("--full", "Disable default human-output truncation")
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
.option("--summary", "Emit aggregate-only output (counts by status/lifecycle)")
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name, agentActivity.state")
.option("--active", "Shortcut for --filter agentActivity.state=running (PL-019)")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.action(async (opts: PsCliOptions) => {
const deps = getDepsF();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostPs(opts.host, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
@@ -66,10 +482,66 @@ Exit codes:
return;
}
// Parse filter once up front so unknown keys/malformed filters surface
// as exit-1 errors before any HTTP call.
//
// PL-019 item 1: --active is sugar for --filter agentActivity.state=running.
// Combining --active with --filter is rejected (composition is
// ambiguous — pick one explicit form). Parity is verified end-to-end
// by the focused test: `--active` and the explicit filter must yield
// identical output on the same fixture.
let effectiveFilter = opts.filter;
if (opts.active) {
if (effectiveFilter) {
console.error(
`--active and --filter cannot be combined. ` +
`--active is sugar for --filter agentActivity.state=running. ` +
`Pick one form, or compose by upgrading to --filter directly.`
);
process.exitCode = 1;
return;
}
effectiveFilter = "agentActivity.state=running";
}
let parsedFilter: ParsedFilter | null = null;
if (effectiveFilter) {
const result = parseFilter(effectiveFilter);
if ("error" in result) {
console.error(result.error);
process.exitCode = 1;
return;
}
parsedFilter = result;
}
const limit = opts.limit !== undefined ? Number(opts.limit) : null;
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) {
console.error(`--limit must be a non-negative integer; got '${opts.limit}'`);
process.exitCode = 1;
return;
}
// C9a: validate --fields against the per-level allow-list before any HTTP
// call, mirroring the --filter validation above. Level is determined by
// --nodes; both branches downstream use the same `fields` variable, so
// one up-front validation covers both paths.
let fields: string[] | null = null;
if (opts.fields !== undefined) {
const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig";
const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS;
const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel);
if ("error" in fieldsResult) {
console.error(fieldsResult.error);
process.exitCode = 1;
return;
}
fields = fieldsResult;
}
const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true;
const client = deps.clientFactory(getDaemonUrl(status));
if (opts.nodes) {
await handleNodes(client, opts.json ?? false);
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope);
return;
}
@@ -81,38 +553,95 @@ Exit codes:
return;
}
const entries = res.data;
const all = res.data;
// Apply CLI-side filter (Amendment A: prefer CLI shaping).
const filtered = parsedFilter ? applyRigFilter(all, parsedFilter) : all;
// Summary mode short-circuits per-entry output.
if (opts.summary) {
const summary = summarizeRigs(filtered);
if (opts.json) {
console.log(JSON.stringify(summary));
} else {
console.log(`totalRigs: ${summary.totalRigs}`);
console.log(`totalRunning: ${summary.totalRunning}`);
console.log(`byStatus: ${JSON.stringify(summary.byStatus)}`);
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
}
return;
}
// Apply --limit on top of filter (CLI side; daemon stays bare-array).
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
const truncated = limit !== null && filtered.length > limit;
// Field projection runs last so requested fields apply to the limited set.
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
if (opts.json) {
console.log(JSON.stringify(entries));
if (useEnvelope) {
// Envelope only on flag use; default JSON stays a bare array for compat.
const envelope: Record<string, unknown> = {
entries: projected,
totalRigs: filtered.length,
truncated,
};
if (truncated) envelope.hint = "rig ps --full --json";
console.log(JSON.stringify(envelope));
} else {
console.log(JSON.stringify(projected));
}
return;
}
if (entries.length === 0) {
console.log("No rigs");
if (limited.length === 0) {
if (parsedFilter) {
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else {
console.log("No rigs");
}
return;
}
// Formatted table
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "UPTIME", "SNAPSHOT");
// Human output: apply default truncation budget unless --full.
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET;
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
console.log(header);
for (const e of entries) {
for (const e of humanList as PsEntry[]) {
console.log(padRigRow(
e.name,
e.rigName ?? e.name,
String(e.nodeCount),
String(e.runningCount),
e.status,
abbrevRigLifecycle(e.lifecycleState),
e.uptime ?? "—",
e.latestSnapshot ?? "—",
));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_RIG_BUDGET;
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_RIG_BUDGET}).`);
console.log("Run 'rig ps --full' to see all, or 'rig ps --filter lifecycleState=attention_required' to narrow.");
} else if (truncated) {
const remaining = filtered.length - (limit ?? 0);
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
}
});
return cmd;
}
async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
// Fetch rig list first
async function handleNodes(
client: DaemonClient,
opts: PsCliOptions,
parsedFilter: ParsedFilter | null,
limit: number | null,
fields: string[] | null,
useEnvelope: boolean,
): Promise<void> {
const rigRes = await client.get<PsEntry[]>("/api/ps");
if (rigRes.status >= 400) {
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
@@ -124,25 +653,60 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
for (const rig of rigRes.data) {
const nodesRes = await client.get<NodeEntry[]>(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`);
if (nodesRes.status >= 400) {
console.error(`Warning: failed to fetch nodes for rig "${rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
console.error(`Warning: failed to fetch nodes for rig "${rig.rigName ?? rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
continue;
}
allNodes.push(...nodesRes.data);
}
if (json) {
console.log(JSON.stringify(allNodes));
const filtered = parsedFilter ? applyNodeFilter(allNodes, parsedFilter) : allNodes;
if (opts.summary) {
const summary = summarizeNodes(filtered);
if (opts.json) {
console.log(JSON.stringify(summary));
} else {
console.log(`totalNodes: ${summary.totalNodes}`);
console.log(`bySessionStatus: ${JSON.stringify(summary.bySessionStatus)}`);
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
}
return;
}
if (allNodes.length === 0) {
console.log("No nodes");
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
const limitTruncated = limit !== null && filtered.length > limit;
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
if (opts.json) {
if (useEnvelope) {
const envelope: Record<string, unknown> = {
entries: projected,
totalNodes: filtered.length,
truncated: limitTruncated,
};
if (limitTruncated) envelope.hint = "rig ps --nodes --full --json";
console.log(JSON.stringify(envelope));
} else {
console.log(JSON.stringify(projected));
}
return;
}
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "RESTORE", "ERROR");
if (limited.length === 0) {
if (parsedFilter) {
console.log(`No nodes match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else {
console.log("No nodes");
}
return;
}
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "CTX", "RESTORE", "ERROR");
console.log(header);
for (const n of allNodes) {
for (const n of humanList as NodeEntry[]) {
const parts = n.logicalId.split(".");
const pod = n.podNamespace ?? (parts.length > 1 ? parts[0]! : "—");
const member = parts.length > 1 ? parts.slice(1).join(".") : n.logicalId;
@@ -155,10 +719,29 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
n.runtime ?? "—",
n.sessionStatus ?? "—",
n.startupStatus ?? "—",
abbrevNodeLifecycle(n.lifecycleState),
formatActivity(n.agentActivity),
formatContextUsage(n.contextUsage),
n.restoreOutcome,
n.latestError ? truncate(n.latestError, 30) : "—",
));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_NODE_BUDGET;
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_NODE_BUDGET}).`);
console.log("Run 'rig ps --nodes --full' to see all, or '--filter lifecycleState=attention_required' to narrow.");
} else if (limitTruncated) {
const remaining = filtered.length - (limit ?? 0);
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
}
}
function formatActivity(activity: NodeEntry["agentActivity"]): string {
if (!activity) return "unknown";
if (activity.state === "running") return "running";
if (activity.state === "needs_input") return "needs_input";
if (activity.state === "idle") return "idle";
return "unknown";
}
function truncate(s: string, max: number): string {
@@ -169,27 +752,98 @@ function fitCell(value: string, width: number): string {
return truncate(value, width).padEnd(width);
}
function padRigRow(rig: string, nodes: string, running: string, status: string, uptime: string, snapshot: string): string {
function padRigRow(rig: string, nodes: string, running: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
return [
fitCell(rig, 24),
fitCell(nodes, 7),
fitCell(running, 9),
fitCell(status, 10),
fitCell(lifecycle, 11),
fitCell(uptime, 11),
snapshot,
].join("");
}
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, restore: string, error: string): string {
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, ctx: string, restore: string, error: string): string {
return [
fitCell(rig, 32),
fitCell(pod, 12),
fitCell(member, 16),
fitCell(session, 40),
fitCell(runtime, 14),
fitCell(rig, 30),
fitCell(pod, 10),
fitCell(member, 14),
fitCell(session, 34),
fitCell(runtime, 12),
fitCell(status, 10),
fitCell(startup, 10),
fitCell(lifecycle, 11),
fitCell(activity, 12),
fitCell(ctx, 6),
fitCell(restore, 10),
error,
].join("");
}
// PL-012: render context-usage as a 5-char cell — "<percent>%" when
// known + fresh, "<percent>%*" when known but stale, "??" when unknown.
// 4-char width keeps the table compact without truncating two-digit
// percentages (e.g., "98%*" or "5%").
function formatContextUsage(ctx: NodeEntry["contextUsage"]): string {
if (!ctx || ctx.availability !== "known" || typeof ctx.usedPercentage !== "number") {
return "??";
}
const stale = ctx.fresh === false ? "*" : "";
return `${ctx.usedPercentage}%${stale}`;
}
async function runCrossHostPs(
hostId: string,
opts: PsCliOptions,
deps: PsDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv. `rig ps` has no positional args; all flags propagate.
const argv: string[] = ["rig", "ps"];
if (opts.nodes) argv.push("--nodes");
if (opts.full) argv.push("--full");
if (opts.limit !== undefined) argv.push("--limit", opts.limit);
if (opts.fields !== undefined) argv.push("--fields", opts.fields);
if (opts.summary) argv.push("--summary");
if (opts.filter !== undefined) argv.push("--filter", opts.filter);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
if (result.ok) {
// Verbatim remote stdout passthrough — the remote `rig ps --json` already
// produced the correct JSON shape (bare array OR envelope per its own
// shaping flags); we do NOT double-wrap.
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, true);
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, false);
}
+505
View File
@@ -0,0 +1,505 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
*
* Backed by `/api/queue`. Operates only via the daemon HTTP API.
* Does NOT touch the POC `rigx-queue-proto` filesystem state.
*
* Hot-potato strict-rejection is enforced at the daemon; `update --state done`
* without `--closure-reason` returns exit 1 with structured error naming the
* 6 valid closure reasons.
*/
export interface QueueDeps extends StatusDeps {}
async function withClient<T>(
deps: QueueDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (session) return session;
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set`);
process.exitCode = 1;
return undefined;
}
export function queueCommand(depsOverride?: QueueDeps): Command {
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
const getDeps = (): QueueDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("create")
.description("Create a new qitem")
.requiredOption("--source <session>", "Source session")
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
.requiredOption("--body <text>", "Qitem body")
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
.option("--json", "JSON output for agents")
.action(async (opts: {
source: string;
destination: string;
body: string;
priority: string;
tier?: string;
tags?: string;
expiresAt?: string;
id?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
qitemId: opts.id,
sourceSession: opts.source,
destinationSession: opts.destination,
body: opts.body,
priority: opts.priority,
tier: opts.tier,
tags,
expiresAt: opts.expiresAt,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("claim <qitemId>")
.description("Claim a qitem (pending → in-progress); computes closure_required_at from tier")
.option("--destination <session>", "Destination session claiming the qitem (defaults to OPENRIG_SESSION_NAME)")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination?: string; json?: boolean }) => {
const destination = resolveCurrentSession(opts.destination, "destination");
if (!destination) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {
destinationSession: destination,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("unclaim <qitemId>")
.description("Release a claimed qitem (in-progress → pending)")
.option("--destination <session>", "Destination session releasing the qitem (defaults to OPENRIG_SESSION_NAME)")
.option("--reason <text>", "Reason for unclaim", "manual")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination?: string; reason: string; json?: boolean }) => {
const destination = resolveCurrentSession(opts.destination, "destination");
if (!destination) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
destinationSession: destination,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("update <qitemId>")
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation)")
.option("--actor <session>", "Actor session performing the transition (defaults to OPENRIG_SESSION_NAME)")
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
.option("--closure-reason <reason>", "Required for state=done")
.option("--closure-target <target>", "Required for handed_off_to, blocked_on, escalation")
.option("--note <text>", "Transition note for the audit log")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
actor?: string;
state: string;
closureReason?: string;
closureTarget?: string;
note?: string;
json?: boolean;
}) => {
const actor = resolveCurrentSession(opts.actor, "actor");
if (!actor) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
actorSession: actor,
state: opts.state,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
transitionNote: opts.note,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("handoff <qitemId>")
.description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to")
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
.option("--body <text>", "New qitem body (defaults to source body)")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
from?: string;
to: string;
body?: string;
note?: string;
priority?: string;
tier?: string;
tags?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff`, {
fromSession: from,
toSession: opts.to,
body: opts.body,
transitionNote: opts.note,
priority: opts.priority,
tier: opts.tier,
tags,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("handoff-and-complete <qitemId>")
.description(
"Atomic close (state=done, closure_reason=handed_off_to) + create new qitem owned by --to. Variant of handoff that fully terminates the source qitem."
)
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
.option("--body <text>", "New qitem body (defaults to source body)")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
from?: string;
to: string;
body?: string;
note?: string;
priority?: string;
tier?: string;
tags?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff-and-complete`, {
fromSession: from,
toSession: opts.to,
body: opts.body,
transitionNote: opts.note,
priority: opts.priority,
tier: opts.tier,
tags,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("whoami")
.description("Show the caller's queue position from the daemon's perspective")
.option("--session <session>", "Caller's session name (defaults to OPENRIG_SESSION_NAME)")
.option("--recent-limit <n>", "How many recent active qitems to include", "25")
.option("--json", "JSON output for agents")
.action(async (opts: { session?: string; recentLimit: string; json?: boolean }) => {
const session = resolveCurrentSession(opts.session, "session");
if (!session) return;
const deps = getDeps();
const params = new URLSearchParams({
session,
recentLimit: opts.recentLimit,
});
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/whoami?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("fallback <qitemId>")
.description("Reroute a qitem to a fallback destination (e.g. unreachable seat)")
.requiredOption("--destination <session>", "Fallback destination seat")
.option("--reason <text>", "Reason for fallback", "manual")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination: string; reason: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/fallback`, {
fallbackDestination: opts.destination,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <qitemId>")
.description("Show one qitem")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("transitions <qitemId>")
.description("Show the append-only transition log for a qitem")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List qitems with filters")
.option("--destination <session>", "Filter by destination session")
.option("--source <session>", "Filter by source session")
.option("--state <state>", "Filter by state (comma-separated for multiple)")
.option("--target-repo <name>", "PL-007: filter qitems by target_repo (exact match)")
.option("--limit <n>", "Result limit", "100")
.option("--json", "JSON output for agents")
.action(async (opts: {
destination?: string;
source?: string;
state?: string;
targetRepo?: string;
limit: string;
json?: boolean;
}) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", opts.limit);
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("overdue")
.description("List in-progress qitems past their closure_required_at deadline")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/queue/overdue");
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Inbox subcommands ----
cmd
.command("inbox-drop <destinationSession>")
.description("Drop a mailbox-style entry into a destination's inbox")
.requiredOption("--sender <session>", "Sender session")
.requiredOption("--body <text>", "Inbox body")
.option("--tags <tags>", "Comma-separated tags")
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
.option("--audit <pointer>", "Audit pointer reference")
.option("--id <inboxId>", "Idempotent inbox_id")
.option("--json", "JSON output for agents")
.action(async (destinationSession: string, opts: {
sender: string;
body: string;
tags?: string;
urgency: string;
audit?: string;
id?: string;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/queue/inbox/drop", {
inboxId: opts.id,
destinationSession,
senderSession: opts.sender,
body: opts.body,
tags,
urgency: opts.urgency,
auditPointer: opts.audit,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("inbox-absorb <inboxId>")
.description("Absorb a pending inbox entry into the receiver's main queue")
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
.option("--json", "JSON output for agents")
.action(async (inboxId: string, opts: { receiver: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/absorb`, {
receiverSession: opts.receiver,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("inbox-deny <inboxId>")
.description("Deny a pending inbox entry with a recorded reason")
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
.requiredOption("--reason <text>", "Reason for denial")
.option("--json", "JSON output for agents")
.action(async (inboxId: string, opts: { receiver: string; reason: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/deny`, {
receiverSession: opts.receiver,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("inbox-pending <destinationSession>")
.description("List pending inbox entries for a destination seat")
.option("--json", "JSON output for agents")
.action(async (destinationSession: string, opts: { json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams({ destinationSession });
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/inbox/pending?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Outbox subcommands ----
cmd
.command("outbox-record")
.description("Record an outbound dispatch in the sender's outbox")
.requiredOption("--sender <session>", "Sender session")
.requiredOption("--destination <session>", "Destination session")
.requiredOption("--body <text>", "Body")
.option("--tags <tags>", "Comma-separated tags")
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
.option("--audit <pointer>", "Audit pointer reference")
.option("--id <outboxId>", "Idempotent outbox_id")
.option("--json", "JSON output for agents")
.action(async (opts: {
sender: string;
destination: string;
body: string;
tags?: string;
urgency: string;
audit?: string;
id?: string;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/queue/outbox/record", {
outboxId: opts.id,
senderSession: opts.sender,
destinationSession: opts.destination,
body: opts.body,
tags,
urgency: opts.urgency,
auditPointer: opts.audit,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("outbox-list <senderSession>")
.description("List outbox entries for a sender seat")
.option("--limit <n>", "Result limit", "100")
.option("--json", "JSON output for agents")
.action(async (senderSession: string, opts: { limit: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams({ senderSession, limit: opts.limit });
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/outbox/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+340
View File
@@ -0,0 +1,340 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface RestoreCheckDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
}
interface CheckEntry {
check: string;
status: "green" | "yellow" | "red";
evidence: string;
remediation: string;
}
interface RepairStep {
step: number;
command: string;
rationale: string;
safe: boolean;
blocking: boolean;
}
interface ReadinessAssertion {
status: "ready" | "ready_with_caveats" | "not_ready" | "unknown";
reason: string;
blockingRigCount: number;
caveatRigCount: number;
unknownRigCount: number;
}
interface ContinuityAssertion {
status: "proven" | "not_proven" | "partial" | "not_applicable";
evidence: string;
provenCapabilities: string[];
unprovenCapabilities: string[];
}
interface RigRestoreRollup {
rigId: string;
rigName: string;
status: "ready" | "ready_with_caveats" | "not_ready" | "unknown";
verdict: "restorable" | "restorable_with_caveats" | "not_restorable" | "unknown";
expectedNodes: number;
runningReadyNodes: number;
blockedNodes: number;
caveatNodes: number;
blockingChecks: CheckEntry[];
caveatChecks: CheckEntry[];
}
interface HostInfraAssertion {
status: "not_inspected" | "not_declared" | "declared" | "unknown";
evidence: string;
}
interface RecoveryAction {
scope: "rig";
rigId: string;
rigName: string;
action: "restore_from_latest_snapshot";
command: string;
reason: string;
safe: boolean;
blocking: boolean;
}
interface RecoveryIssue {
scope: "host" | "rig";
rigId?: string;
rigName?: string;
reason: string;
}
interface RecoveryPlan {
status: "not_needed" | "actionable" | "blocked" | "unknown";
summary: string;
actions: RecoveryAction[];
blocked: RecoveryIssue[];
unknown: RecoveryIssue[];
}
interface RestoreCheckResult {
verdict: "restorable" | "restorable_with_caveats" | "not_restorable" | "unknown";
readiness: ReadinessAssertion;
continuity: ContinuityAssertion;
rigs: RigRestoreRollup[];
hostInfra: HostInfraAssertion;
recovery: RecoveryPlan;
counts: { red: number; yellow: number; green: number };
checks: CheckEntry[];
repairPacket: RepairStep[] | null;
}
const STATUS_SYMBOLS: Record<string, string> = {
green: "✓",
yellow: "⚠",
red: "✗",
};
const STATUS_COLORS: Record<string, string> = {
green: "",
yellow: "",
red: "",
};
export function restoreCheckCommand(depsOverride?: RestoreCheckDeps): Command {
const cmd = new Command("restore-check")
.description("Check restore readiness across running rigs")
.addHelpText("after", `
Examples:
rig restore-check Check all rigs
rig restore-check --rig openrig-pm Check one rig
rig restore-check --no-queue Skip queue file checks
rig restore-check --no-hooks Skip hook checks
rig restore-check --json JSON output for agents
Exit codes:
0 Restorable (or restorable with caveats)
1 Not restorable (red blockers found)
2 Unknown / probe error`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--rig <name>", "Check one rig only")
.option("--no-queue", "Skip queue file checks")
.option("--no-hooks", "Skip hook checks")
.action(async (opts: { json?: boolean; rig?: string; queue?: boolean; hooks?: boolean }) => {
const deps = getDepsF();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
// Daemon down — produce a structured not_restorable result
const result = localRestoreResult({
verdict: "not_restorable",
checks: [{
check: "daemon.reachable",
status: "red",
evidence: "Daemon is not running",
remediation: "Start the daemon with: rig daemon start",
}],
repairPacket: [{
step: 1,
command: "Start the daemon with: rig daemon start",
rationale: "Daemon is not running",
safe: false, // mutating: starts a daemon process
blocking: true,
}],
recovery: {
status: "blocked",
summary: "No exact rig recovery action is known until the daemon is running.",
actions: [],
blocked: [{
scope: "host",
reason: "Daemon is not running; rig state could not be inspected for recovery planning.",
}],
unknown: [],
},
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
try {
const params = new URLSearchParams();
if (opts.rig) params.set("rig", opts.rig);
if (opts.queue === false) params.set("noQueue", "true");
if (opts.hooks === false) params.set("noHooks", "true");
const query = params.toString();
const path = `/api/restore-check${query ? `?${query}` : ""}`;
const response = await client.get<RestoreCheckResult>(path);
if (response.status >= 500) {
const result = localRestoreResult({
verdict: "unknown",
checks: [{
check: "probe.error",
status: "red",
evidence: `Daemon returned HTTP ${response.status}`,
remediation: "Check daemon logs with: rig daemon logs",
}],
repairPacket: [{
step: 1,
command: "Check daemon logs with: rig daemon logs",
rationale: `Daemon returned HTTP ${response.status}`,
safe: true,
blocking: true,
}],
recovery: {
status: "unknown",
summary: `Recovery status could not be inspected because daemon returned HTTP ${response.status}.`,
actions: [],
blocked: [],
unknown: [{
scope: "host",
reason: `Daemon returned HTTP ${response.status}`,
}],
},
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
process.exitCode = 2;
return;
}
const result = response.data;
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
// Exit codes: 0 for restorable/restorable_with_caveats; 1 for not_restorable; 2 for unknown
if (result.verdict === "not_restorable") {
process.exitCode = 1;
} else if (result.verdict === "unknown") {
process.exitCode = 2;
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
console.error("Fix: check daemon status with: rig daemon status");
process.exitCode = 2;
}
});
return cmd;
}
function printHuman(result: RestoreCheckResult): void {
const verdictLabel = result.verdict.replace(/_/g, " ").toUpperCase();
console.log(`RESTORE CHECK — ${verdictLabel}`);
console.log(`${result.counts.green} green | ${result.counts.yellow} yellow | ${result.counts.red} red`);
const readinessLabel = result.readiness.status.replace(/_/g, " ");
console.log(`READINESS: ${readinessLabel} (${result.readiness.reason})`);
console.log(`CONTINUITY: ${result.continuity.status.replace(/_/g, " ")}`);
if (result.hostInfra) {
console.log(`Host bootstrap/autostart: ${result.hostInfra.status} — ${result.hostInfra.evidence}`);
}
console.log(`RECOVERY: ${result.recovery.status.replace(/_/g, " ").toUpperCase()}`);
console.log(result.recovery.summary);
for (const action of result.recovery.actions) {
console.log(` Action: ${action.command}`);
}
for (const issue of result.recovery.blocked) {
console.log(` Blocked: ${issue.reason}`);
}
for (const issue of result.recovery.unknown) {
console.log(` Unknown: ${issue.reason}`);
}
if (result.rigs && result.rigs.length > 0) {
console.log();
console.log("Per-rig summary:");
console.log(`${"rig".padEnd(24)} ${"status".padEnd(16)} ${"ready".padEnd(9)} ${"blocked".padEnd(8)} caveats`);
for (const rig of result.rigs) {
console.log(
`${rig.rigName.slice(0, 24).padEnd(24)} ${rig.status.padEnd(16)} ` +
`${`${rig.runningReadyNodes}/${rig.expectedNodes}`.padEnd(9)} ${String(rig.blockedNodes).padEnd(8)} ${rig.caveatNodes}`
);
}
}
console.log();
for (const check of result.checks) {
const sym = STATUS_SYMBOLS[check.status] ?? "?";
console.log(` ${sym} ${check.check}: ${check.evidence}`);
if (check.remediation) {
console.log(` Fix: ${check.remediation}`);
}
}
if (result.repairPacket && result.repairPacket.length > 0) {
const blockers = result.repairPacket.filter((s) => s.blocking).length;
const caveats = result.repairPacket.filter((s) => !s.blocking).length;
console.log();
console.log(`Repair steps: ${result.repairPacket.length} (${blockers} blocking, ${caveats} caveats)`);
if (blockers > 0) {
console.log("Resolve blocking steps before restoring.");
}
} else if (result.counts.red > 0) {
console.log();
console.log("Blockers found. Resolve red items before restoring.");
} else if (result.counts.yellow > 0) {
console.log();
console.log("Restorable with caveats. Yellow items are non-blocking but worth reviewing.");
}
}
function localRestoreResult(input: {
verdict: RestoreCheckResult["verdict"];
checks: CheckEntry[];
repairPacket: RepairStep[] | null;
recovery: RecoveryPlan;
}): RestoreCheckResult {
const red = input.checks.filter((c) => c.status === "red").length;
const yellow = input.checks.filter((c) => c.status === "yellow").length;
const green = input.checks.filter((c) => c.status === "green").length;
const unknown = input.verdict === "unknown";
return {
verdict: input.verdict,
readiness: {
status: unknown ? "unknown" : "not_ready",
reason: unknown ? "unknown_probe_state" : "blockers_present",
blockingRigCount: 0,
caveatRigCount: 0,
unknownRigCount: 0,
},
continuity: {
status: "not_proven",
evidence: "Strict same-session/provider-context resume is not verified by restore-check v1.",
provenCapabilities: [],
unprovenCapabilities: ["provider_session_resume", "context_window_preservation", "interrupted_work_functional_resume"],
},
rigs: [],
hostInfra: {
status: "unknown",
evidence: "Host bootstrap/autostart source could not be inspected because restore-check did not receive daemon route evidence",
},
counts: { red, yellow, green },
checks: input.checks,
repairPacket: input.repairPacket,
recovery: input.recovery,
};
}
+528
View File
@@ -0,0 +1,528 @@
// restore-packet.ts — `rig restore-packet {write,read,validate}` CLI command.
//
// M2a: command shape + mutual-exclusion + read/validate stubs.
// M2b: source-adapter wiring (codex-jsonl-parser, claude-transcript-parser,
// runtime-detect, redaction, omitted-records).
// M2c-CLI: write action full impl using packet-writer.ts; --source-jsonl
// + --source-session adapter wiring; CLI flag defaults for the
// ~11 contract-required fields not derivable from the parser.
// M2c-Daemon: full-read transcript route + auth + redaction + tests.
// M3: real read + validate implementations replacing the stubs.
import { Command } from "commander";
import { readFileSync, existsSync, statSync } from "node:fs";
import { resolve as resolvePath, join as joinPath } from "node:path";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import { detectRuntime } from "../restore-packet/runtime-detect.js";
import { parseCodexJsonl } from "../restore-packet/codex-jsonl-parser.js";
import { parseClaudeTranscript } from "../restore-packet/claude-transcript-parser.js";
import { writePacket, type WritePacketOptions } from "../restore-packet/packet-writer.js";
import {
validateRestoreSummary,
type ValidationError,
} from "../restore-packet/schema-validator.js";
import type { SourceRuntime, StructuredTranscript } from "../restore-packet/types.js";
// Per M1 contract § 1: 4 required packet files. Used by validate's
// packet-shape check and by read for transcript-presence detection.
const REQUIRED_PACKET_FILES = [
"restore-instructions.md",
"transcript-latest.md",
"touched-files.md",
"restore-summary.json",
] as const;
export interface RestorePacketDeps {
lifecycleDeps?: LifecycleDeps;
clientFactory?: (url: string) => DaemonClient;
}
interface WriteOptions {
sourceSession?: string;
sourceJsonl?: string;
sourceRuntime?: string;
/**
* R2: explicit operator override for source_session_id. Wins over parsed
* session_meta and over the --source-session arg. Required when JSONL
* has no session_meta record.
*/
sourceSessionIdOverride?: string;
/**
* R2: explicit operator override for source_rig. Wins over <seat>@<rig>
* derivation. Required when the canonical source_session_id is bare
* (no @) and the operator has not supplied --source-session-id-override
* with @ shape.
*/
sourceRigOverride?: string;
target: string;
targetRig?: string;
targetRuntime?: string;
targetWorkspaceRoot?: string;
defaultTargetRepo?: string;
rolePointer?: string;
currentWorkSummary?: string;
nextOwner?: string;
caveat?: string[];
authorityBoundaries?: string;
sourceTrustRanking?: string;
generatorVersion?: string;
}
/**
* R2: derive canonical (source_session_id, source_rig) per honest-fallback
* policy. Override flags > parsed session_meta (jsonl) > supplied --source-session.
*
* Returns either the derived pair (with no exception) OR throws an Error
* containing the explicit operator-facing guidance message. The action
* caller catches and routes to reportFailure().
*/
function deriveProvenance(
opts: WriteOptions,
parsedSessionId: string | null,
hasJsonl: boolean,
): { sourceSessionId: string; sourceRig: string } {
// Step 1: source_session_id — override > parsed > --source-session arg.
let sourceSessionId: string;
if (typeof opts.sourceSessionIdOverride === "string" && opts.sourceSessionIdOverride.length > 0) {
sourceSessionId = opts.sourceSessionIdOverride;
} else if (hasJsonl) {
if (!parsedSessionId) {
throw new Error(
"no session_meta record in source JSONL; supply --source-session-id-override <id> AND --source-rig-override <rig> to set provenance explicitly. Silent fallback to file-path-as-session-id is not allowed (M2c-CLI R2 honest-provenance policy).",
);
}
sourceSessionId = parsedSessionId;
} else {
// hasSession path: operator-named session is authoritative.
sourceSessionId = opts.sourceSession!;
}
// Step 2: source_rig — override > <seat>@<rig> split.
let sourceRig: string;
if (typeof opts.sourceRigOverride === "string" && opts.sourceRigOverride.length > 0) {
sourceRig = opts.sourceRigOverride;
} else {
const m = /^[^@]+@([^@]+)$/.exec(sourceSessionId);
const captured = m?.[1];
if (!captured) {
throw new Error(
`could not derive source_rig from session id '${sourceSessionId}' (does not match <seat>@<rig> shape); supply --source-rig-override <rig> explicitly. Silent fallback to source_rig:"unknown" is not allowed (M2c-CLI R2 honest-provenance policy).`,
);
}
sourceRig = captured;
}
return { sourceSessionId, sourceRig };
}
function reportFailure(message: string): void {
console.error(`rig restore-packet write: ${message}`);
process.exitCode = 2;
}
function isValidRuntime(value: string | undefined): value is SourceRuntime {
return value === "codex" || value === "claude-code";
}
async function fetchSourceTranscriptViaDaemon(
session: string,
deps: RestorePacketDeps | undefined,
): Promise<{ content: string; sourceCwd: string | null }> {
const lifecycleDeps = deps?.lifecycleDeps ?? realDeps();
const status = await getDaemonStatus(lifecycleDeps);
if (status.state !== "running" || typeof status.port !== "number") {
throw new Error("daemon not running; start it with: rig daemon start");
}
const url = getDaemonUrl(status);
const client = (deps?.clientFactory ?? ((u: string) => new DaemonClient(u)))(url);
// M2c-Daemon will define this route. M2c-CLI calls it; M2c-CLI tests
// mock the daemon (compact-plan.test.ts:166-176 pattern). The path is
// a draft — M2c-Daemon may pick a different naming convention; if so,
// M2c-Daemon will land that change in restore-packet.ts the same commit
// that adds the daemon route.
const path = `/api/transcripts/${encodeURIComponent(session)}/full`;
const response = await client.get<{ content: string; cwd?: string | null }>(path);
if (response.status >= 400 || !response.data) {
throw new Error(
`daemon transcript fetch failed: HTTP ${response.status}: ${
typeof response.data === "object" && response.data !== null
? JSON.stringify(response.data)
: "(no body)"
}`,
);
}
return {
content: response.data.content ?? "",
sourceCwd: response.data.cwd ?? null,
};
}
function buildWritePacketOptions(
opts: WriteOptions,
structured: StructuredTranscript,
sourceRuntime: SourceRuntime,
sourceCwdFallback: string,
sourceSessionId: string,
sourceRig: string,
): WritePacketOptions {
const trustRankingDefault = "rig_whoami,bounded_latest_transcript";
const trustRankingRaw = opts.sourceTrustRanking ?? trustRankingDefault;
const trustRanking = trustRankingRaw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
return {
targetDir: resolvePath(opts.target),
structured,
sourceRuntime,
targetRig: opts.targetRig ?? "openrig-velocity",
targetRuntime: isValidRuntime(opts.targetRuntime) ? opts.targetRuntime : "claude-code",
targetWorkspaceRoot: opts.targetWorkspaceRoot ?? structured.sessionMeta?.cwd ?? sourceCwdFallback,
defaultTargetRepo: opts.defaultTargetRepo ?? null,
rolePointer: opts.rolePointer ?? `rigs/${opts.targetRig ?? "openrig-velocity"}/state/velocity/role.md`,
currentWorkSummary: opts.currentWorkSummary ?? "Cross-runtime restore packet generated by rig restore-packet write.",
nextOwner: opts.nextOwner ?? "self",
caveats: opts.caveat ?? [],
authorityBoundaries: opts.authorityBoundaries ?? "Restored seat authority per source session role.",
sourceTrustRanking: trustRanking,
sourceSessionId,
sourceRig,
sourceCwd: structured.sessionMeta?.cwd ?? sourceCwdFallback,
generatorVersion: opts.generatorVersion ?? "[email protected]",
includeFullTranscript: structured.messages.length > 0,
};
}
export function restorePacketCommand(depsOverride?: RestorePacketDeps): Command {
const cmd = new Command("restore-packet")
.description(
"Generate, read, and validate cross-runtime restore packets per the v0 standard",
);
cmd.command("write")
.description(
"Generate a restore packet from a source session or JSONL file",
)
.option("--source-session <session>", "Source session name (daemon-backed)")
.option(
"--source-jsonl <path>",
"Source Codex/Claude JSONL transcript file (direct)",
)
.option(
"--source-runtime <runtime>",
"Force source runtime (claude-code | codex); auto-detected if omitted",
)
.requiredOption(
"--target <dir>",
"Target packet directory (must not exist; created atomically)",
)
.option("--target-rig <rig>", "Target rig name for the restored seat")
.option(
"--target-runtime <runtime>",
"Target runtime (claude-code | codex)",
)
.option(
"--target-workspace-root <path>",
"Target workspace root absolute path",
)
.option(
"--default-target-repo <path>",
"Default target repo absolute path or null",
)
.option(
"--role-pointer <path>",
"Role guidance pointer path or URI for the restored seat",
)
.option(
"--current-work-summary <text>",
"One-paragraph summary of the source seat's current work",
)
.option(
"--next-owner <name>",
'Who the restored seat hands off to next (default: "self")',
)
.option(
"--caveat <text>",
"Caveat to include in the packet (repeatable)",
(value: string, prev: string[] = []) => prev.concat([value]),
[] as string[],
)
.option(
"--authority-boundaries <text>",
"Explicit statement of restored-seat authority",
)
.option(
"--source-trust-ranking <csv>",
"Comma-separated source-trust ranking; default: rig_whoami,bounded_latest_transcript",
)
.option(
"--generator-version <version>",
'Generator version identifier (default: "[email protected]")',
)
.option(
"--source-session-id-override <id>",
"Operator override for source_session_id (R2; required when JSONL has no session_meta record)",
)
.option(
"--source-rig-override <rig>",
"Operator override for source_rig (R2; required when canonical session id is bare with no <seat>@<rig> shape)",
)
.action(async (opts: WriteOptions) => {
const hasSession = typeof opts.sourceSession === "string" && opts.sourceSession.length > 0;
const hasJsonl = typeof opts.sourceJsonl === "string" && opts.sourceJsonl.length > 0;
if (hasSession && hasJsonl) {
reportFailure(
"--source-session and --source-jsonl are mutually exclusive; supply exactly one.",
);
return;
}
if (!hasSession && !hasJsonl) {
reportFailure(
"exactly one of --source-session or --source-jsonl is required.",
);
return;
}
try {
let content: string;
let sourceCwdFallback: string;
if (hasJsonl) {
const jsonlPath = resolvePath(opts.sourceJsonl!);
content = readFileSync(jsonlPath, "utf-8");
sourceCwdFallback = "/";
} else {
const session = opts.sourceSession!;
const fetched = await fetchSourceTranscriptViaDaemon(session, depsOverride);
content = fetched.content;
sourceCwdFallback = fetched.sourceCwd ?? "/";
}
let runtime: SourceRuntime | null = null;
if (isValidRuntime(opts.sourceRuntime)) {
runtime = opts.sourceRuntime;
} else if (typeof opts.sourceRuntime === "string" && opts.sourceRuntime.length > 0) {
reportFailure(
`--source-runtime must be 'codex' or 'claude-code'; got '${opts.sourceRuntime}'.`,
);
return;
} else {
runtime = detectRuntime(content);
if (runtime === null) {
reportFailure(
"could not auto-detect source runtime; pass --source-runtime <claude-code|codex> explicitly.",
);
return;
}
}
const structured = runtime === "codex"
? parseCodexJsonl(content)
: parseClaudeTranscript(content);
// R2: derive (source_session_id, source_rig) honestly. Override
// flags > parsed session_meta (jsonl) > supplied --source-session
// arg. Throws an Error with explicit operator guidance when the
// honest-provenance policy can't be satisfied.
let provenance: { sourceSessionId: string; sourceRig: string };
try {
provenance = deriveProvenance(opts, structured.sessionMeta?.sessionId ?? null, hasJsonl);
} catch (err) {
reportFailure((err as Error).message);
return;
}
const writeOpts = buildWritePacketOptions(
opts,
structured,
runtime,
sourceCwdFallback,
provenance.sourceSessionId,
provenance.sourceRig,
);
const result = await writePacket(writeOpts);
// Per Quality Lesson v9: log only metadata, NOT transcript content.
console.log(`packet: ${result.targetDir}`);
console.log(`files: ${result.files.length}`);
for (const f of result.files) {
console.log(` - ${f}`);
}
console.log(`messages: ${structured.messageCount}`);
console.log(`omitted-classes: ${Object.entries(structured.omittedCounts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(",") || "(none)"}`);
} catch (err) {
reportFailure((err as Error).message);
}
});
cmd.command("read")
.description("Render a restore packet contents (human or --json)")
.argument("<packet-dir>", "Packet directory path")
.option("--json", "Emit machine-readable JSON output")
.action(async (packetDir: string, opts: { json?: boolean }) => {
const dir = resolvePath(packetDir);
const summaryPath = joinPath(dir, "restore-summary.json");
if (!existsSync(summaryPath)) {
console.error(
`rig restore-packet read: restore-summary.json not found at ${summaryPath}.`,
);
process.exitCode = 2;
return;
}
let summary: Record<string, unknown>;
try {
summary = JSON.parse(readFileSync(summaryPath, "utf-8")) as Record<string, unknown>;
} catch (err) {
console.error(
`rig restore-packet read: restore-summary.json is not valid JSON: ${(err as Error).message}.`,
);
process.exitCode = 2;
return;
}
if (opts.json) {
// Per IMPL § M3: --json emits raw restore-summary.json content
// verbatim; round-trippable.
console.log(JSON.stringify(summary, null, 2));
return;
}
// Human output: restore-instructions body + summary metadata + transcript digest.
const instructionsPath = joinPath(dir, "restore-instructions.md");
if (existsSync(instructionsPath)) {
console.log(readFileSync(instructionsPath, "utf-8"));
} else {
console.log("(restore-instructions.md absent in packet directory)");
}
console.log("--- Summary metadata ---");
const metadataKeys = [
"source_session_id",
"source_rig",
"source_cwd",
"source_runtime",
"target_rig",
"target_runtime",
"generator_version",
];
for (const k of metadataKeys) {
const v = summary[k];
console.log(`${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
}
const blt = summary.bounded_latest_transcript as { bound?: number; message_count?: number } | undefined;
if (blt) {
console.log(`bounded_latest_transcript.bound: ${blt.bound}`);
console.log(`bounded_latest_transcript.message_count: ${blt.message_count}`);
}
const omittedClasses = summary.omitted_classes;
console.log(`omitted_classes: ${Array.isArray(omittedClasses) ? omittedClasses.join(",") || "(none)" : "(unknown)"}`);
// Transcript digest line (per IMPL § M3 + contract § 1 parity rule).
const transcriptPath = joinPath(dir, "transcript.md");
const hasFullTranscriptKey = Object.prototype.hasOwnProperty.call(summary, "full_transcript");
if (existsSync(transcriptPath)) {
const sz = statSync(transcriptPath).size;
console.log(`transcript.md: ${sz} bytes`);
if (!hasFullTranscriptKey) {
console.log("(parity warning: transcript.md present but full_transcript summary key absent)");
}
} else {
if (hasFullTranscriptKey) {
console.log("(parity warning: full_transcript summary key present but transcript.md absent)");
} else {
console.log("transcript.md absent (full_transcript key absent in summary; valid per parity rule)");
}
}
});
cmd.command("validate")
.description("Validate a restore packet against the v0 schema")
.argument("<packet-dir>", "Packet directory path")
.option("--json", "Emit machine-readable validation report")
.action(async (packetDir: string, opts: { json?: boolean }) => {
const dir = resolvePath(packetDir);
const errors: ValidationError[] = [];
// Packet-shape check: 4 required files per contract § 1.
for (const required of REQUIRED_PACKET_FILES) {
const p = joinPath(dir, required);
if (!existsSync(p)) {
errors.push({
field: required,
value: "<missing>",
rule: `packet-shape: required file missing at ${p}`,
severity: "error",
});
}
}
// Schema validation if restore-summary.json exists.
let summary: Record<string, unknown> | null = null;
const summaryPath = joinPath(dir, "restore-summary.json");
if (existsSync(summaryPath)) {
try {
summary = JSON.parse(readFileSync(summaryPath, "utf-8")) as Record<string, unknown>;
} catch (err) {
errors.push({
field: "restore-summary.json",
value: "<invalid-json>",
rule: `restore-summary.json parse failed: ${(err as Error).message}`,
severity: "error",
});
}
if (summary) {
const schemaResult = validateRestoreSummary(summary);
for (const e of schemaResult.errors) errors.push(e);
}
}
// Parity check (per contract § 1 + § 8): full_transcript summary key
// ↔ transcript.md presence.
if (summary) {
const hasFullTranscriptKey = Object.prototype.hasOwnProperty.call(summary, "full_transcript");
const transcriptExists = existsSync(joinPath(dir, "transcript.md"));
if (hasFullTranscriptKey && !transcriptExists) {
errors.push({
field: "full_transcript",
value: "<key-present-file-absent>",
rule: "parity: full_transcript summary key present but transcript.md absent at packet directory",
severity: "error",
});
}
if (!hasFullTranscriptKey && transcriptExists) {
errors.push({
field: "transcript.md",
value: "<file-present-key-absent>",
rule: "parity: transcript.md present but full_transcript summary key absent",
severity: "error",
});
}
}
const hasErrorSeverity = errors.some((e) => e.severity === "error");
const valid = !hasErrorSeverity;
if (opts.json) {
console.log(JSON.stringify({ valid, errors }, null, 2));
} else {
if (errors.length === 0) {
console.log(`valid: packet at ${dir} passed all checks.`);
} else {
console.log(`${valid ? "valid (with warnings)" : "invalid"}: ${errors.length} issue(s) at ${dir}`);
for (const e of errors) {
const tag = e.severity === "warning" ? "WARNING" : "ERROR";
console.log(` [${tag}] ${e.field}: ${e.rule} (value: ${e.value})`);
}
}
}
// Per contract § 8: required-field violations / parity violations →
// exit nonzero. Optional-field warnings → exit 0.
if (hasErrorSeverity) {
process.exitCode = 2;
}
});
return cmd;
}
+125 -2
View File
@@ -4,6 +4,8 @@ import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lif
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function restoreCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("restore").description("Restore a rig from a snapshot");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -28,29 +30,85 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
const rigId = opts.rig;
const res = await client.post<{ nodes?: Array<{ nodeId: string; logicalId: string; status: string; error?: string }>; attachCommand?: string }>(`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`);
// L3: install a SIGINT/SIGTERM handler that prints an honest message —
// interrupting the CLI client does NOT stop daemon-side restore work.
// Cancellation as a daemon protocol is a separate slice; ship the
// message so operators are not surprised.
const onSignal = () => {
console.error("Client interrupt received; daemon-side restore may continue. Use 'rig ps --nodes' or 'rig restore-check' to follow progress.");
process.exit(1);
};
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
const res = await client.post<{
ok?: boolean;
attemptId?: number;
status?: string;
rigId?: string;
// Pre-restore-started error path keeps the original payload shape.
rigResult?: string;
blockers?: RestoreBlocker[];
nodes?: Array<{
nodeId: string;
logicalId: string;
status: string;
error?: string;
canonicalSessionName?: string | null;
tmuxAttachCommand?: string | null;
resumeCommand?: string | null;
recoveryGuidance?: {
summary: string;
commands: string[];
notes: string[];
} | null;
cwd?: string | null;
}>;
attachCommand?: string;
}>(
`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`,
undefined,
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
);
if (res.status === 404) {
console.error(`Snapshot "${snapshotId}" or rig "${rigId}" not found. List snapshots with: rig snapshot list --rig ${rigId}`);
process.exitCode = 1;
} else if (res.status === 409) {
if ((res.data as { code?: string }).code === "pre_restore_validation_failed") {
printRestoreNotAttempted(res.data);
process.exitCode = 1;
return;
}
console.error(`Restore conflict: ${(res.data as { error?: string }).error ?? "rig may still be running"}. Stop the rig first with: rig down ${rigId}`);
process.exitCode = 1;
} else if (res.status >= 400) {
console.error(`Restore failed: ${(res.data as { error?: string }).error ?? "unknown error"} (HTTP ${res.status}). Check daemon logs or try a different snapshot.`);
process.exitCode = 1;
} else if (res.data.attemptId !== undefined) {
// L3 success path: route returned 202 immediately after restore.started.
console.log(`Restore attempt id: ${res.data.attemptId}`);
console.log(`Status: ${res.data.status ?? "started"}`);
console.log("Daemon is restoring per-node in the background; follow progress with 'rig ps --nodes' or 'rig restore-check'.");
} else {
// Defensive: server responded ok=true but didn't include attemptId.
// Fall back to the legacy summary if it's present (back-compat with
// pre-L3 daemons during rolling upgrades).
console.log("Restore complete:");
if (res.data.rigResult) {
console.log(`Rig result: ${res.data.rigResult}`);
}
const nodes = res.data.nodes ?? [];
for (const node of nodes) {
const label = node.status === "failed" && node.error ? `${node.status} — ${node.error}` : node.status;
console.log(` ${node.logicalId}: ${label}`);
}
printRecoveryGuidance(nodes);
const attachCommand = (res.data as Record<string, unknown>)["attachCommand"] as string | undefined;
if (attachCommand) {
console.log(`Attach: ${attachCommand}`);
}
if (nodes.some((node) => node.status === "failed")) {
if (res.data.rigResult === "partially_restored" || res.data.rigResult === "failed" || res.data.rigResult === "not_attempted" || nodes.some((node) => node.status === "failed")) {
process.exitCode = 1;
}
}
@@ -58,3 +116,68 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
return cmd;
}
interface RestoreBlocker {
code: string;
severity?: string;
logicalId?: string;
nodeId?: string;
target?: string;
path?: string;
message: string;
remediation: string;
}
function printRestoreNotAttempted(data: { rigResult?: string; blockers?: RestoreBlocker[]; error?: string }): void {
console.error(`Restore blocked: ${data.error ?? "pre-restore validation failed"}`);
if (data.rigResult) {
console.error(`Rig result: ${data.rigResult}`);
}
printBlockers(data.blockers ?? []);
}
function printBlockers(blockers: RestoreBlocker[]): void {
for (const blocker of blockers) {
const scope = blocker.logicalId ?? blocker.nodeId ?? blocker.target ?? blocker.code;
console.error(` ${scope}: ${blocker.message}`);
if (blocker.path) console.error(` path: ${blocker.path}`);
console.error(` remediation: ${blocker.remediation}`);
}
}
function printRecoveryGuidance(
nodes: Array<{
logicalId: string;
status: string;
canonicalSessionName?: string | null;
tmuxAttachCommand?: string | null;
recoveryGuidance?: { summary: string; commands: string[]; notes: string[] } | null;
cwd?: string | null;
}>,
): void {
const actionable = nodes.filter((node) =>
(node.status === "fresh" || node.status === "failed") && node.recoveryGuidance
);
if (actionable.length === 0) return;
console.log("\nRecovery guidance:");
for (const node of actionable) {
console.log(` ${node.logicalId}: ${node.recoveryGuidance!.summary}`);
if (node.tmuxAttachCommand) {
console.log(` attach: ${node.tmuxAttachCommand}`);
}
if (node.canonicalSessionName) {
console.log(` session: ${node.canonicalSessionName}`);
}
if (node.cwd) {
console.log(` cwd: ${node.cwd}`);
}
for (const command of node.recoveryGuidance!.commands) {
console.log(` $ ${command}`);
}
for (const note of node.recoveryGuidance!.notes) {
console.log(` note: ${note}`);
}
}
}
+288
View File
@@ -0,0 +1,288 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export type SeatDeps = StatusDeps;
interface SeatStatusResponse {
seat_ref: string;
rig_id: string;
rig_name: string;
logical_id: string;
pod_id: string | null;
pod_namespace: string | null;
runtime: string | null;
current_occupant: string | null;
session_status: string | null;
startup_status: string | null;
occupant_lifecycle: string;
continuity_outcome: string | null;
handover_result: string | null;
previous_occupant: string | null;
handover_at: string | null;
restore_outcome: string;
}
interface SeatStatusError {
ok: false;
code: string;
message?: string;
error?: string;
guidance?: string;
matches?: Array<{ rig_name: string; logical_id: string; current_occupant: string | null }>;
}
interface SeatHandoverPlan {
ok: true;
dryRun: true;
willMutate: false;
seat: {
ref: string;
rigId: string;
rigName: string;
logicalId: string;
podId: string | null;
podNamespace: string | null;
runtime: string | null;
};
source: {
mode: "fresh" | "rebuild" | "fork" | "discovered";
ref: string | null;
raw: string;
defaulted: boolean;
};
reason: string;
operator: string | null;
currentOccupant: string | null;
currentStatus: {
sessionStatus: string | null;
startupStatus: string | null;
occupantLifecycle: string;
continuityOutcome: string | null;
handoverResult: string | null;
previousOccupant: string | null;
handoverAt: string | null;
restoreOutcome: string;
};
phases: Array<{
id: "prepare" | "commit";
title: string;
bindingUnchangedUntilComplete: boolean;
steps: Array<{ id: string; title: string; description: string; willMutate: false }>;
}>;
}
interface SeatHandoverMutationResult {
ok: true;
dryRun: false;
mutated: true;
continuityTransferred: false;
seat: SeatHandoverPlan["seat"];
source: {
mode: "discovered";
ref: string;
raw: string;
defaulted: false;
};
reason: string;
operator: string | null;
previousOccupant: string;
currentOccupant: string;
previousSessionIdsSuperseded: string[];
newSessionId: string;
discovery: {
id: string;
status: "claimed";
tmuxSession: string;
tmuxPane: string | null;
};
currentStatus: SeatHandoverPlan["currentStatus"];
handoverAt: string;
eventSeq: number;
sideEffects: {
departingSessionKilled: false;
startupContextDelivered: false;
provenanceRecordWritten: false;
};
}
function display(value: string | null | undefined, empty = "none"): string {
return value ?? empty;
}
function printHuman(status: SeatStatusResponse): void {
console.log(`Seat ${status.seat_ref}`);
console.log(`Rig: ${status.rig_name}`);
console.log(`Logical ID: ${status.logical_id}`);
console.log(`Current occupant: ${display(status.current_occupant)}`);
console.log(`Session: ${display(status.session_status, "unknown")}`);
console.log(`Startup: ${display(status.startup_status, "unknown")}`);
console.log(`Occupant lifecycle: ${status.occupant_lifecycle}`);
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unknown")}`);
console.log(`Handover result: ${display(status.handover_result)}`);
console.log(`Previous occupant: ${display(status.previous_occupant)}`);
console.log(`Handover at: ${display(status.handover_at)}`);
}
function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
console.log(`Seat handover dry run: ${plan.seat.ref}`);
console.log(`Rig: ${plan.seat.rigName}`);
console.log(`Logical ID: ${plan.seat.logicalId}`);
console.log(`Source: ${plan.source.mode}${plan.source.ref ? `:${plan.source.ref}` : ""}`);
console.log(`Reason: ${plan.reason}`);
console.log(`Operator: ${display(plan.operator)}`);
console.log(`Current occupant: ${display(plan.currentOccupant)}`);
console.log(`Current status: session=${display(plan.currentStatus.sessionStatus, "unknown")} startup=${display(plan.currentStatus.startupStatus, "unknown")} lifecycle=${plan.currentStatus.occupantLifecycle}`);
for (const phase of plan.phases) {
console.log(phase.title);
for (const step of phase.steps) {
console.log(` - ${step.title}`);
}
}
console.log("No changes were made.");
}
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Seat handover complete: ${result.seat.ref}`);
console.log(`Rig: ${result.seat.rigName}`);
console.log(`Logical ID: ${result.seat.logicalId}`);
console.log(`Source: discovered:${result.discovery.id}`);
console.log(`Reason: ${result.reason}`);
console.log(`Operator: ${display(result.operator)}`);
console.log(`Previous occupant: ${result.previousOccupant}`);
console.log(`Current occupant: ${result.currentOccupant}`);
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
console.log("Seat binding and inventory provenance were updated.");
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
}
function printSeatError(error: SeatStatusError, fallback: string): void {
console.error(error.message ?? error.error ?? fallback);
if (error.guidance) {
console.error(error.guidance);
}
if (error.code === "seat_ambiguous" && error.matches?.length) {
for (const match of error.matches) {
console.error(` ${match.logical_id}@${match.rig_name} (${display(match.current_occupant)})`);
}
}
}
export function seatCommand(depsOverride?: SeatDeps): Command {
const cmd = new Command("seat")
.description("Inspect OpenRig seat observability state");
const getDeps = (): SeatDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("status")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--json", "JSON output for agents")
.description("Show read-only seat handover observability status")
.addHelpText("after", `
Examples:
rig seat status spec-writer@openrig-pm
rig seat status spec.writer@openrig-pm --json
rig seat status spec.writer --json`)
.action(async (seat: string, opts: { json?: boolean }) => {
const deps = getDeps();
const daemon = await getDaemonStatus(deps.lifecycleDeps);
if (daemon.state !== "running" || daemon.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(daemon));
const res = await client.get<SeatStatusResponse | SeatStatusError>(`/api/seat/status/${encodeURIComponent(seat)}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
if (res.status >= 400) {
const error = res.data as SeatStatusError;
printSeatError(error, `Seat status failed (HTTP ${res.status})`);
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
printHuman(res.data as SeatStatusResponse);
});
cmd
.command("handover")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--source <source>", "Source: discovered:<id> for live MVP; fresh, rebuild, or fork:<id> for dry-run planning")
.option("--reason <reason>", "Why the handover is happening")
.option("--operator <address>", "Operator initiating the handover")
.option("--dry-run", "Plan the handover without changing topology")
.option("--json", "JSON output for agents")
.description("Plan a safe two-phase seat handover")
.addHelpText("after", `
Examples:
rig seat handover spec-writer@openrig-pm --reason context-wall --dry-run
rig seat handover spec-writer@openrig-pm --source rebuild --reason context-wall --dry-run --json
rig seat handover spec-writer@openrig-pm --source fork:0b0165d7 --reason successor-test --operator orch-lead@openrig-pm --dry-run
rig seat handover spec-writer@openrig-pm --source discovered:01H... --reason mvp-proof --json`)
.action(async (seat: string, opts: { source?: string; reason?: string; operator?: string; dryRun?: boolean; json?: boolean }) => {
if (!opts.reason?.trim()) {
const error: SeatStatusError = {
ok: false,
code: "missing_reason",
message: "Missing required option: --reason <reason>",
guidance: "Provide an explicit handover reason, for example: --reason context-wall",
};
if (opts.json) {
console.log(JSON.stringify(error, null, 2));
} else {
printSeatError(error, "Missing required option: --reason <reason>");
}
process.exitCode = 2;
return;
}
const deps = getDeps();
const daemon = await getDaemonStatus(deps.lifecycleDeps);
if (daemon.state !== "running" || daemon.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(daemon));
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
source: opts.source,
reason: opts.reason,
operator: opts.operator,
dryRun: opts.dryRun === true,
});
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
if (res.status >= 400) {
printSeatError(res.data as SeatStatusError, `Seat handover failed (HTTP ${res.status})`);
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
const data = res.data as SeatHandoverPlan | SeatHandoverMutationResult;
if (data.dryRun) {
printHumanHandoverPlan(data);
} else {
printHumanHandoverResult(data);
}
});
return cmd;
}
+144 -8
View File
@@ -3,10 +3,54 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
import { readOpenRigEnv } from "../openrig-compat.js";
export function sendCommand(depsOverride?: StatusDeps): Command {
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
const SENDER_FALLBACK = "<unknown sender>";
/**
* Wrap a `rig send` body with an email-style envelope so the recipient
* pane has both the sender's identity and a copy-pasteable reply hint.
* Cross-host sends do NOT wrap locally: the remote rig wraps when it
* runs the same command, and double-wrapping would nest envelopes.
*/
export function wrapSendBody(sender: string | undefined, recipient: string, body: string): string {
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
return [
`From: ${senderLabel}`,
`To: ${recipient}`,
"---",
body,
"---",
`↩ Reply: rig send ${senderLabel} "..."`,
].join("\n");
}
function resolveSenderSession(): string | undefined {
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
}
export interface SendDeps extends StatusDeps {
/**
* Cross-host hooks. Both default to the production loaders/executors; tests
* inject in-package mocks so no real ssh / no real ~/.ssh / no real network
* is touched.
*/
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
export function sendCommand(depsOverride?: SendDeps): Command {
const cmd = new Command("send").description("Send a message to an agent's terminal");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): SendDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -14,21 +58,50 @@ export function sendCommand(depsOverride?: StatusDeps): Command {
cmd
.argument("<session>", "Target session name (e.g. dev-impl@my-rig)")
.argument("<text>", "Message text to send")
.option("--verify", "Verify delivery by checking pane content after send")
.option("--verify", "Verify pane only delivery by checking content after send")
.option("--force", "Send even if target pane appears mid-task")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
rig send dev-impl@my-rig "Context update: QA approved. Proceed."
rig send dev-impl@my-rig "message" --verify
rig send dev-impl@my-rig "safe proof prompt" --wait-for-idle 30 --verify
rig send dev-impl@my-rig "Stop and read the spec." --force
rig send dev-impl@my-rig "message" --json
rig send --host vm-claude-test dev-impl@my-rig "remote message" --verify
The two-step send pattern (paste text, wait, submit Enter) is handled
automatically. Use --verify to confirm the message appeared in the pane.
Use --force to override mid-task safety checks.`)
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; json?: boolean }) => {
automatically. Use --wait-for-idle to send only after explicit idle evidence;
it fails closed on attention prompts, unknown activity, or timeout. Use --verify
to confirm the message appeared in the pane only; it is not agent acknowledgement.
Use --force to override mid-task safety checks without wait mode.
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. SSH success is NOT verify success: the remote rig's
'Verified: yes/no' line is what counts and is surfaced verbatim.`)
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; waitForIdle?: string; host?: string; json?: boolean }) => {
const waitForIdleMs = parseWaitForIdleMs(opts.waitForIdle);
if (opts.force && waitForIdleMs !== undefined) {
console.error("--wait-for-idle cannot be combined with --force");
process.exitCode = 1;
return;
}
if (waitForIdleMs === null) {
console.error("--wait-for-idle must be a positive number of seconds");
process.exitCode = 1;
return;
}
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostSend(opts.host, session, text, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -38,9 +111,10 @@ Use --force to override mid-task safety checks.`)
}
const client = deps.clientFactory(getDaemonUrl(status));
const wrappedText = wrapSendBody(resolveSenderSession(), session, text);
const res = await client.post<Record<string, unknown>>("/api/transport/send", {
session, text, verify: opts.verify, force: opts.force,
});
session, text: wrappedText, verify: opts.verify, force: opts.force, waitForIdleMs,
}, waitForIdleRequestOptions(waitForIdleMs));
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -64,3 +138,65 @@ Use --force to override mid-task safety checks.`)
return cmd;
}
async function runCrossHostSend(
hostId: string,
session: string,
text: string,
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; json?: boolean },
deps: SendDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv for the remote `rig send` invocation. Order is positional
// first so the remote Commander parses it the same way local does.
const argv: string[] = ["rig", "send", session, text];
if (opts.verify) argv.push("--verify");
if (opts.force) argv.push("--force");
if (opts.waitForIdle !== undefined) argv.push("--wait-for-idle", opts.waitForIdle);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: host.target },
result,
}));
if (!result.ok) process.exitCode = 1;
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, opts.json);
}
function parseWaitForIdleMs(value: string | undefined): number | undefined | null {
if (value === undefined) return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.ceil(seconds * 1000);
}
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
if (waitForIdleMs === undefined) return undefined;
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
}
+640
View File
@@ -0,0 +1,640 @@
import { Command } from "commander";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
import path from "node:path";
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { ConfigStore } from "../config-store.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
isCmuxSocketControlCompatible,
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
upsertCmuxSocketControlMode,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
export interface SetupStep {
id: string;
status: "pass" | "applied" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fixHint?: string;
}
export interface VerificationCheck {
name: string;
status: "pass" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fix?: string;
}
export interface RuntimeConfigDisclosure {
scope: "global" | "project";
runtime: "claude-code" | "codex" | "cmux";
path: string;
purpose: string;
}
export interface SetupResult {
profile: "core" | "full";
platform: string;
ready: boolean;
steps: SetupStep[];
runtimeConfig: RuntimeConfigDisclosure[];
verification?: {
checks: VerificationCheck[];
};
}
export interface SetupDeps {
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
readFile: (path: string) => string | null;
writeFile: (path: string, content: string) => void;
exists: (path: string) => boolean;
mkdirp?: (path: string) => void;
platform?: NodeJS.Platform;
}
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
const INSTALL_COMMAND_TIMEOUT_MS = 5 * 60_000;
const CMUX_READY_ATTEMPTS = 5;
const CMUX_READY_DELAY_MS = 1_000;
const CORE_STEP_IDS = [
"brew",
"tmux_install",
"cmux_install",
"claude_install",
"claude_auth",
"codex_install",
"codex_auth",
"tmux_config",
"verify",
];
const FULL_EXTRA_STEP_IDS = ["jq_install", "gh_install"];
const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
{
scope: "global",
runtime: "claude-code",
path: "~/.claude/settings.json",
purpose: "Allow OpenRig commands without Claude permission prompts.",
},
{
scope: "global",
runtime: "claude-code",
path: "~/.claude.json",
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
},
{
scope: "project",
runtime: "claude-code",
path: ".claude/settings.local.json",
purpose: "Apply context-collector statusLine config and selected Claude settings runtime-resource fragments.",
},
{
scope: "project",
runtime: "claude-code",
path: ".mcp.json",
purpose: "Apply selected Claude MCP runtime-resource fragments.",
},
{
scope: "global",
runtime: "codex",
path: "~/.codex/config.toml",
purpose: "Pre-trust managed workspaces and apply selected Codex config runtime-resource fragments.",
},
];
const DARWIN_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure = {
scope: "global",
runtime: "cmux",
path: CMUX_SETTINGS_DISCLOSURE_PATH,
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
};
function defaultDeps(): SetupDeps {
return {
exec: (cmd: string, opts?: { timeoutMs?: number }) =>
execSync(cmd, {
encoding: "utf-8",
timeout: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
stdio: ["pipe", "pipe", "pipe"],
}),
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
writeFile: (p: string, c: string) => writeFileSync(p, c, "utf-8"),
exists: (p: string) => existsSync(p),
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
};
}
function installCommand(deps: SetupDeps, cmd: string): string {
return deps.exec(cmd, { timeoutMs: INSTALL_COMMAND_TIMEOUT_MS });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCmuxCapabilities(deps: SetupDeps, attempts = CMUX_READY_ATTEMPTS): Promise<boolean> {
for (let index = 0; index < attempts; index += 1) {
try {
deps.exec("cmux capabilities --json");
return true;
} catch {
if (index < attempts - 1) {
await sleep(CMUX_READY_DELAY_MS);
}
}
}
return false;
}
async function tryEnableCmuxControl(deps: SetupDeps, platform: NodeJS.Platform): Promise<boolean> {
if (platform !== "darwin") return false;
const settingsPath = resolveCmuxSettingsPath();
const settingsText = deps.readFile(settingsPath);
const currentMode = readCmuxSocketControlModeFromText(settingsText);
if (currentMode.error) {
return false;
}
try {
deps.mkdirp?.(path.dirname(settingsPath));
const next = upsertCmuxSocketControlMode(settingsText, "automation");
if (next.changed) {
deps.writeFile(settingsPath, next.content);
}
} catch {
return false;
}
const shellReady = await waitForCmuxCapabilities(deps, 1);
if (shellReady) {
try {
deps.exec("cmux reload-config");
} catch {
// Best effort: if reload fails, the daemon-side verification will surface it honestly.
}
return waitForCmuxCapabilities(deps, 1);
}
try {
deps.exec("open -a /Applications/cmux.app");
} catch {
// Best effort: cmux may already be running or the app open may be blocked; capability probe decides readiness.
}
return waitForCmuxCapabilities(deps);
}
function buildRuntimeConfigDisclosure(platform: NodeJS.Platform): RuntimeConfigDisclosure[] {
return platform === "darwin"
? [...BASE_RUNTIME_CONFIG_DISCLOSURE, DARWIN_RUNTIME_CONFIG_DISCLOSURE]
: [...BASE_RUNTIME_CONFIG_DISCLOSURE];
}
async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"available" | "unavailable" | "skipped"> {
const fetchFn = doctorDeps?.fetch;
if (!fetchFn) return "skipped";
const config = doctorDeps.configStore.resolve();
const host = config.daemon.host;
const port = config.daemon.port;
try {
const healthRes = await fetchFn(`http://${host}:${port}/healthz`);
if (!healthRes.ok) return "skipped";
} catch {
return "skipped";
}
try {
const cmuxRes = await fetchFn(`http://${host}:${port}/api/adapters/cmux/status`);
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
const data = (await cmuxRes.json()) as { available?: boolean };
return data.available ? "available" : "unavailable";
} catch {
return "unavailable";
}
}
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
const profile = opts.full ? "full" : "core";
const platform = deps.platform ?? process.platform;
const runtimeConfig = buildRuntimeConfigDisclosure(platform);
const stepIds = opts.full ? [...CORE_STEP_IDS, ...FULL_EXTRA_STEP_IDS] : [...CORE_STEP_IDS];
const steps: SetupStep[] = [];
if (opts.dryRun) {
for (const id of stepIds) {
steps.push({ id, status: "skipped", message: `Dry run: ${id} would be attempted.` });
}
return { profile, platform, ready: false, steps, runtimeConfig };
}
// Core steps
// 1. Homebrew (macOS-first setup path)
let brewOk = false;
if (platform !== "darwin") {
steps.push({
id: "brew",
status: "skipped",
message: "Skipped: Homebrew setup path is only used on macOS.",
});
} else {
try {
deps.exec("brew --version");
brewOk = true;
steps.push({ id: "brew", status: "pass", message: "Homebrew available." });
} catch {
steps.push({
id: "brew",
status: "fail",
message: "Homebrew not found.",
reason: "Homebrew is required to install tmux and cmux on macOS.",
fixHint: "Install Homebrew: https://brew.sh",
});
}
}
// 2. tmux
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
if (tmuxProbe.code === "not_installed") {
if (!brewOk) {
steps.push({ id: "tmux_install", status: "skipped", message: "Skipped: Homebrew not available.", reason: "tmux install requires Homebrew." });
} else {
try {
installCommand(deps, "brew install tmux");
steps.push({ id: "tmux_install", status: "applied", message: "Installed tmux with Homebrew." });
} catch (err) {
steps.push({ id: "tmux_install", status: "fail", message: `Failed to install tmux: ${(err as Error).message}` });
}
}
} else if (!tmuxProbe.available) {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
steps.push({
id: "tmux_install",
status: "fail",
message: failure.message,
reason: failure.reason,
fixHint: failure.fix,
});
} else {
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
}
// 3. cmux
const daemonCmuxBefore = await probeDaemonCmuxStatus(opts.doctorDeps);
if (await waitForCmuxCapabilities(deps, 1)) {
const socketMode = readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
if (platform === "darwin" && socketMode.error) {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux settings file is unreadable.",
reason: `OpenRig could not parse ${CMUX_SETTINGS_DISCLOSURE_PATH}: ${socketMode.error}`,
fixHint: "Repair or remove the cmux settings file, then rerun `rig setup`.",
});
} else if (platform === "darwin" && !isCmuxSocketControlCompatible(socketMode.mode)) {
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig updated cmux settings, but the running daemon still cannot control cmux.",
reason: "The cmux settings file is now compatible, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Normalized cmux socket control to automation mode in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but OpenRig could not normalize cmux socket control.",
reason: "OpenRig needs a compatible cmux socket control mode so the daemon can open CMUX surfaces reliably.",
fixHint: `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`,
});
}
} else if (daemonCmuxBefore === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but the running daemon still cannot control cmux.",
reason: "Current cmux settings already look compatible, so the remaining blocker is outside the cmux settings file OpenRig can repair automatically.",
fixHint: "Run `rig doctor` for the exact daemon cmux diagnosis, then restart the daemon after clearing the underlying blocker.",
});
} else {
steps.push({ id: "cmux_install", status: "pass", message: "cmux available." });
}
} else {
try {
deps.exec("cmux --help");
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig enabled cmux socket control, but the running daemon still cannot control cmux.",
reason: "The cmux app and settings are now in place, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Enabled cmux socket control in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "cmux installed but control unavailable.",
reason: "Open CMUX workflows need cmux socket control to be enabled.",
fixHint: platform === "darwin"
? `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`
: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch {
if (!brewOk) {
steps.push({ id: "cmux_install", status: "skipped", message: "Skipped: Homebrew not available." });
} else {
try {
installCommand(deps, "brew install --cask cmux");
if (await tryEnableCmuxControl(deps, platform) || await waitForCmuxCapabilities(deps, 1)) {
steps.push({ id: "cmux_install", status: "applied", message: "Installed cmux with Homebrew." });
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "Installed cmux, but control is still unavailable.",
reason: "Open CMUX workflows need the cmux app to expose socket control after installation.",
fixHint: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch (err) {
steps.push({
id: "cmux_install",
status: "fail",
message: `Failed to install cmux: ${(err as Error).message}`,
reason: "Open CMUX workflows stay unavailable until the cmux app and CLI are installed.",
fixHint: "Retry `brew install --cask cmux` after connectivity stabilizes, or install cmux manually.",
});
}
}
}
}
// 4. tmux config
// 4. Claude Code runtime
let claudeInstalled = false;
try {
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "pass", message: "Claude Code available." });
} catch {
try {
installCommand(deps, "npm install -g @anthropic-ai/claude-code");
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "applied", message: "Installed Claude Code with npm." });
} catch (err) {
steps.push({
id: "claude_install",
status: "fail",
message: `Failed to install Claude Code: ${(err as Error).message}`,
reason: "The demo rig launches Claude Code nodes, so the Claude CLI must be installed on this machine.",
fixHint: "Install Claude Code with `npm install -g @anthropic-ai/claude-code`.",
});
}
}
if (claudeInstalled) {
try {
deps.exec("claude auth status");
steps.push({ id: "claude_auth", status: "pass", message: "Claude Code authentication available." });
} catch (err) {
steps.push({
id: "claude_auth",
status: "fail",
message: `Claude Code is installed but not ready to launch: ${(err as Error).message}`,
reason: "The demo rig cannot launch Claude Code nodes until the Claude CLI is logged in and usable.",
fixHint: "Run `claude auth login` or open `claude` once to complete authentication, then rerun `rig setup` or `rig doctor`.",
});
}
} else {
steps.push({
id: "claude_auth",
status: "skipped",
message: "Skipped: Claude Code is not installed.",
reason: "Authentication cannot be checked until the Claude Code CLI is installed.",
});
}
// 5. Codex runtime
let codexInstalled = false;
try {
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "pass", message: "Codex available." });
} catch {
try {
installCommand(deps, "npm install -g @openai/codex");
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "applied", message: "Installed Codex with npm." });
} catch (err) {
steps.push({
id: "codex_install",
status: "fail",
message: `Failed to install Codex: ${(err as Error).message}`,
reason: "The demo rig launches Codex nodes, so the Codex CLI must be installed on this machine.",
fixHint: "Install Codex with `npm install -g @openai/codex`.",
});
}
}
if (codexInstalled) {
try {
deps.exec("codex login status");
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
} catch (err) {
steps.push({
id: "codex_auth",
status: "fail",
message: `Codex is installed but not ready to launch: ${(err as Error).message}`,
reason: "The demo rig cannot launch Codex nodes until the Codex CLI is logged in and usable.",
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
});
}
} else {
steps.push({
id: "codex_auth",
status: "skipped",
message: "Skipped: Codex is not installed.",
reason: "Authentication cannot be checked until the Codex CLI is installed.",
});
}
// 6. tmux config
const TMUX_CONF = `${process.env["HOME"] ?? "~"}/.tmux.conf`;
const MANAGED_MARKER = "# OpenRig managed block";
const MANAGED_BLOCK = [
MANAGED_MARKER,
"set -g mouse on",
"set -g history-limit 50000",
`# End ${MANAGED_MARKER}`,
].join("\n");
try {
const existing = deps.readFile(TMUX_CONF);
if (existing && existing.includes(MANAGED_MARKER)) {
// Replace existing managed block
const replaced = existing.replace(
new RegExp(`${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?# End ${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
MANAGED_BLOCK,
);
deps.writeFile(TMUX_CONF, replaced);
steps.push({ id: "tmux_config", status: "applied", message: "Updated OpenRig managed tmux config block." });
} else if (existing) {
deps.writeFile(TMUX_CONF, existing.trimEnd() + "\n\n" + MANAGED_BLOCK + "\n");
steps.push({ id: "tmux_config", status: "applied", message: "Appended OpenRig managed tmux config block." });
} else {
deps.writeFile(TMUX_CONF, MANAGED_BLOCK + "\n");
steps.push({ id: "tmux_config", status: "applied", message: "Created .tmux.conf with OpenRig managed block." });
}
} catch (err) {
steps.push({ id: "tmux_config", status: "warn", message: `Could not update tmux config: ${(err as Error).message}` });
}
// 7. Verify
const tmuxOk = steps.some((s) => s.id === "tmux_install" && (s.status === "pass" || s.status === "applied"));
const anyFail = steps.some((s) => s.status === "fail");
steps.push({
id: "verify",
status: anyFail ? "warn" : "pass",
message: anyFail ? "Some setup steps failed. Run `rig doctor` for detailed diagnostics." : "Core setup verified.",
});
// Full profile extras
if (opts.full) {
for (const tool of [{ id: "jq_install", cmd: "jq", brew: "jq" }, { id: "gh_install", cmd: "gh", brew: "gh" }]) {
try {
deps.exec(`${tool.cmd} --version`);
steps.push({ id: tool.id, status: "pass", message: `${tool.cmd} available.` });
} catch {
if (!brewOk) {
steps.push({ id: tool.id, status: "skipped", message: `Skipped: Homebrew not available.` });
} else {
try {
installCommand(deps, `brew install ${tool.brew}`);
steps.push({ id: tool.id, status: "applied", message: `Installed ${tool.cmd} with Homebrew.` });
} catch {
steps.push({ id: tool.id, status: "warn", message: `Failed to install ${tool.cmd}.`, fixHint: `Install ${tool.cmd} manually.` });
}
}
}
}
}
// Run doctor-backed verification if not dry-run and doctorDeps available
let verification: SetupResult["verification"];
if (!opts.dryRun && opts.doctorDeps) {
const doctorDeps = opts.doctorDeps;
const doctor = runDoctorChecks(doctorDeps);
const asyncResults = await Promise.all(doctor.asyncChecks);
const allDoctorChecks = [...doctor.checks, ...asyncResults];
verification = {
checks: allDoctorChecks.map((c) => ({
name: c.name,
status: c.status,
message: c.message,
...(c.reason ? { reason: c.reason } : {}),
...(c.fix ? { fix: c.fix } : {}),
})),
};
}
// ready = no fail statuses in steps or verification checks
const stepsFailed = steps.some((s) => s.status === "fail");
const verificationFailed = verification?.checks.some((c) => c.status === "fail") ?? false;
const ready = !stepsFailed && !verificationFailed;
return { profile, platform, ready, steps, runtimeConfig, ...(verification ? { verification } : {}) };
}
function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
const platform = setupDeps.platform ?? process.platform;
const baseDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
return {
exists: setupDeps.exists,
baseDir,
readFile: setupDeps.readFile,
exec: setupDeps.exec,
checkPort: async (port: number) => {
const net = await import("node:net");
return new Promise<boolean>((resolve) => {
const socket = new net.default.Socket();
socket.once("connect", () => { socket.destroy(); resolve(false); });
socket.once("error", () => resolve(true));
socket.connect(port, "127.0.0.1");
});
},
configStore: new ConfigStore(),
platform: platform as NodeJS.Platform,
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
checkWritable: (p: string) => accessSync(p, constants.W_OK),
fetch: globalThis.fetch,
};
}
export function setupCommand(depsOverride?: SetupDeps): Command {
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
cmd
.option("--dry-run", "Show the plan without making changes")
.option("--json", "Machine-readable JSON output")
.option("--full", "Install broader operator workstation tools")
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean }) => {
const deps = depsOverride ?? defaultDeps();
const doctorDeps = opts.dryRun ? undefined : buildDefaultDoctorDeps(deps);
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, doctorDeps });
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
if (!opts.dryRun && !result.ready) process.exitCode = 1;
return;
}
console.log(`\nProfile: ${result.profile}`);
console.log(`Platform: ${result.platform}\n`);
console.log("OpenRig may modify runtime config in these locations:");
for (const item of result.runtimeConfig) {
console.log(` - [${item.scope}] ${item.runtime} ${item.path} — ${item.purpose}`);
}
console.log(" - Note: already-running adopted sessions may need restart to pick up runtime config changes.\n");
for (const step of result.steps) {
const icon = step.status === "pass" ? "OK" : step.status === "applied" ? "APPLIED" : step.status === "warn" ? "WARN" : step.status === "skipped" ? "SKIP" : "FAIL";
console.log(` [${icon}] ${step.id}: ${step.message}`);
if (step.reason) console.log(` Why: ${step.reason}`);
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
console.log(`\n${result.ready ? "Setup complete." : "Some steps need attention. Run `rig doctor` for detailed diagnostics."}`);
if (!opts.dryRun && !result.ready) process.exitCode = 1;
});
return cmd;
}
+157 -31
View File
@@ -1,4 +1,17 @@
import { Command } from "commander";
import {
copyFileSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
} from "node:fs";
import { basename, join } from "node:path";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
@@ -7,7 +20,7 @@ import type { StatusDeps } from "./status.js";
interface LibraryEntry {
id: string;
kind: string;
kind: "rig" | "agent" | "workflow";
name: string;
version: string;
sourceType: string;
@@ -17,6 +30,114 @@ interface LibraryEntry {
summary?: string;
}
interface AddSpecSource {
inputPath: string;
yamlPath: string;
installName: string;
installKind: "file" | "directory";
libraryEntrySuffix: string;
}
function normalizePathForMatch(path: string): string {
return path.replaceAll("\\", "/");
}
function requireRegularFile(path: string, label: string): void {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
throw new Error(`${label} must not be a symlink: ${path}`);
}
if (!stat.isFile()) {
throw new Error(`${label} must be a regular file: ${path}`);
}
}
function assertTreeHasNoSymlinks(root: string): void {
const stack = [root];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absPath = join(current, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Spec directories must not contain symlinks: ${absPath}`);
}
if (entry.isDirectory()) {
stack.push(absPath);
}
}
}
}
function resolveAddSpecSource(inputPath: string): AddSpecSource {
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
}
const stat = lstatSync(inputPath);
if (stat.isSymbolicLink()) {
throw new Error(`Spec path must not be a symlink: ${inputPath}`);
}
if (stat.isFile()) {
return {
inputPath,
yamlPath: inputPath,
installName: basename(inputPath),
installKind: "file",
libraryEntrySuffix: basename(inputPath),
};
}
if (!stat.isDirectory()) {
throw new Error(`Spec path must be a YAML file or spec directory: ${inputPath}`);
}
const rootSpec = ["rig.yaml", "rig.yml", "agent.yaml", "agent.yml"]
.map((candidate) => join(inputPath, candidate))
.find((candidate) => existsSync(candidate));
if (!rootSpec) {
throw new Error(`Spec directory must contain rig.yaml or agent.yaml: ${inputPath}`);
}
requireRegularFile(rootSpec, "Root spec file");
const installName = basename(inputPath);
return {
inputPath,
yamlPath: rootSpec,
installName,
installKind: "directory",
libraryEntrySuffix: normalizePathForMatch(join(installName, basename(rootSpec))),
};
}
function installSpecSource(source: AddSpecSource, userRoot: string): string {
mkdirSync(userRoot, { recursive: true });
const dest = join(userRoot, source.installName);
if (source.installKind === "file") {
requireRegularFile(source.inputPath, "Spec file");
copyFileSync(source.inputPath, dest);
return dest;
}
if (existsSync(dest)) {
throw new Error(`A spec directory already exists at ${dest}. Remove or rename it before adding this spec.`);
}
assertTreeHasNoSymlinks(source.inputPath);
const tempParent = mkdtempSync(join(userRoot, ".spec-add-"));
const tempDest = join(tempParent, source.installName);
try {
cpSync(source.inputPath, tempDest, { recursive: true, errorOnExist: true, force: false });
renameSync(tempDest, dest);
} catch (err) {
rmSync(tempParent, { recursive: true, force: true });
throw err;
}
rmSync(tempParent, { recursive: true, force: true });
return dest;
}
/**
* Shared name resolution for library specs.
* Returns the matching entry, or throws with guidance on ambiguity/not-found.
@@ -24,8 +145,9 @@ interface LibraryEntry {
export async function resolveLibrarySpec(
client: DaemonClient,
nameOrId: string,
opts?: { kind?: LibraryEntry["kind"] },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>("/api/specs/library");
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
const entries = res.data ?? [];
// Try exact ID match first
@@ -37,19 +159,28 @@ export async function resolveLibrarySpec(
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
const candidates = byName.map((e) => ` ${e.id} — ${e.sourcePath}`).join("\n");
const candidates = byName.map((e) => ` ${e.id} (${e.kind}) — ${e.sourcePath}`).join("\n");
const scope = opts?.kind ? ` ${opts.kind}` : "";
throw new Error(
`Spec name '${nameOrId}' is ambiguous — ${byName.length} entries match.\nUse the ID instead:\n${candidates}`
`Spec name '${nameOrId}' is ambiguous — ${byName.length}${scope} entries match.\nUse the ID instead:\n${candidates}`
);
}
const scope = opts?.kind ? ` ${opts.kind}` : "";
throw new Error(
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available specs.`
`Spec '${nameOrId}' not found in${scope} library. Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.`
);
}
export function specsCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("specs").description("Browse, preview, and manage the spec library");
const cmd = new Command("specs")
.description("Browse, preview, and manage the spec library, including managed apps")
.addHelpText("after", `
Examples:
rig specs ls
rig specs preview secrets-manager
rig specs show vault-specialist
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
@@ -66,8 +197,8 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs ls
cmd.command("ls")
.description("List library specs")
.option("--kind <kind>", "Filter by kind (rig or agent)")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
@@ -99,11 +230,12 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
cmd.command("show")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show spec metadata and path")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
@@ -125,12 +257,13 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs preview
cmd.command("preview")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show structured spec review")
.description("Show structured spec review, including managed app details")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
if (opts.json) {
@@ -160,20 +293,13 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs add
cmd.command("add")
.argument("<path>", "Path to YAML spec file")
.description("Add a spec to the user library")
.argument("<path>", "Path to YAML spec file or spec directory")
.description("Add a spec file or full spec directory to the user library")
.option("--json", "JSON output")
.action(async (filePath: string, opts: { json?: boolean }) => {
.action(async (inputPath: string, opts: { json?: boolean }) => {
try {
const { readFileSync, copyFileSync, existsSync } = await import("node:fs");
const { join, basename } = await import("node:path");
const { homedir } = await import("node:os");
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const yaml = readFileSync(filePath, "utf-8");
const source = resolveAddSpecSource(inputPath);
const yaml = readFileSync(source.yamlPath, "utf-8");
const client = await getClient();
// Validate via daemon
@@ -189,16 +315,16 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// Copy to user library
const userRoot = getDefaultOpenRigPath("specs");
const { mkdirSync } = await import("node:fs");
mkdirSync(userRoot, { recursive: true });
const dest = join(userRoot, basename(filePath));
copyFileSync(filePath, dest);
const dest = installSpecSource(source, userRoot);
// Sync and find the new entry
const syncRes = await client.post<LibraryEntry[]>("/api/specs/library/sync");
const entries = syncRes.data ?? [];
const name = (res.data as Record<string, unknown>)["name"] as string ?? basename(filePath);
const newEntry = entries.find((e) => e.name === name && e.sourcePath.endsWith(basename(filePath)));
const name = (res.data as Record<string, unknown>)["name"] as string ?? source.installName;
const newEntry = entries.find((e) => (
e.name === name &&
normalizePathForMatch(e.sourcePath).endsWith(source.libraryEntrySuffix)
));
if (opts.json) {
console.log(JSON.stringify({ name, kind, path: dest, id: newEntry?.id ?? null, entry: newEntry ?? null }));
+141
View File
@@ -0,0 +1,141 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig stream` — coordination primitive L1 commands (PL-004 Phase A).
*
* Backed by `/api/stream`. Operates only via the daemon HTTP API.
* Does NOT touch the POC `rigx-stream-proto` filesystem state.
*/
export interface StreamDeps extends StatusDeps {}
async function withClient<T>(
deps: StreamDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function streamCommand(depsOverride?: StreamDeps): Command {
const cmd = new Command("stream").description("Coordination L1 — append-only intake stream");
const getDeps = (): StreamDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("emit")
.description("Append a stream item")
.requiredOption("--source <session>", "Source session (e.g. velocity-driver@openrig-velocity-claude)")
.requiredOption("--body <text>", "Stream item body")
.option("--hint-destination <session>", "Hint at intended destination seat")
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
.option("--hint-tags <tags>", "Comma-separated hint tags")
.option("--interrupt", "Mark item as interrupting")
.option("--id <streamItemId>", "Idempotent stream_item_id (skip if not provided)")
.option("--json", "JSON output for agents")
.action(async (opts: {
source: string;
body: string;
hintDestination?: string;
hintType?: string;
hintUrgency?: string;
hintTags?: string;
interrupt?: boolean;
id?: string;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.hintTags ? opts.hintTags.split(",").map((s) => s.trim()).filter(Boolean) : null;
await withClient(deps, async (client) => {
const res = await client.post<Record<string, unknown>>("/api/stream/emit", {
streamItemId: opts.id,
sourceSession: opts.source,
body: opts.body,
hintDestination: opts.hintDestination ?? null,
hintType: opts.hintType ?? null,
hintUrgency: opts.hintUrgency ?? null,
hintTags: tags,
interrupt: opts.interrupt ?? false,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List stream items chronologically")
.option("--source <session>", "Filter by source session")
.option("--hint-destination <session>", "Filter by hint destination")
.option("--limit <n>", "Result limit", "100")
.option("--after <sortKey>", "Cursor pagination — return items after this sort key")
.option("--include-archived", "Include archived items")
.option("--json", "JSON output for agents")
.action(async (opts: {
source?: string;
hintDestination?: string;
limit: string;
after?: string;
includeArchived?: boolean;
json?: boolean;
}) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.source) params.set("sourceSession", opts.source);
if (opts.hintDestination) params.set("hintDestination", opts.hintDestination);
if (opts.limit) params.set("limit", opts.limit);
if (opts.after) params.set("afterSortKey", opts.after);
if (opts.includeArchived) params.set("includeArchived", "true");
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/stream/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <streamItemId>")
.description("Fetch one stream item by id")
.option("--json", "JSON output for agents")
.action(async (streamItemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("archive <streamItemId>")
.description("Soft-archive a stream item (audit row preserved)")
.option("--json", "JSON output for agents")
.action(async (streamItemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}/archive`, {});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+118 -13
View File
@@ -5,18 +5,33 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: LifecycleDeps }): Command {
const cmd = new Command("up").description("Bootstrap a rig from a spec or bundle");
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
export function upCommand(
depsOverride?: StatusDeps & {
lifecycleDeps?: LifecycleDeps;
preflightExec?: (cmd: string) => Promise<string>;
},
): Command {
const cmd = new Command("up")
.description("Launch a rig or managed app from a spec, library entry, or bundle")
.addHelpText("after", `
Examples:
rig up secrets-manager
rig up ./rig.yaml
rig up ./demo.rigbundle --target ~/work
`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.argument("<source>", "Path to .yaml rig spec or .rigbundle")
.argument("<source>", "Path to a .yaml rig spec or .rigbundle, or a library name such as secrets-manager")
.option("--plan", "Plan mode — preview without executing")
.option("--yes", "Auto-approve trusted actions")
.option("--cwd <path>", "Override launch working directory for all members for this run only")
.option("--target <root>", "Target root directory for package installation (.rigbundle only; does not change agent cwd)")
.option("--existing", "Treat <source> as an existing rig name; bypass library-spec name resolution")
.option("--json", "JSON output for agents")
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; json?: boolean }) => {
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; json?: boolean }) => {
const deps = getDepsF();
// Run preflight before auto-start
@@ -25,7 +40,8 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
let resolvedConfig: {
daemon: { port: number; host: string };
db: { path: string };
transcripts: { enabled: boolean; path: string };
transcripts: { enabled: boolean; path: string; lines: number; pollIntervalSeconds: number };
workspace: { root: string };
} | null = null;
try {
const { ConfigStore } = await import("../config-store.js");
@@ -34,8 +50,10 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
const configStore = new ConfigStore();
resolvedConfig = configStore.resolve();
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
const preflight = new SystemPreflight({
exec: async (cmd) => execSync(cmd, { encoding: "utf-8" }),
exec: preflightExec,
configStore,
getDaemonStatus: () => getDaemonStatus(deps.lifecycleDeps),
openrigHome: OPENRIG_DIR,
@@ -63,6 +81,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
db: resolvedConfig?.db.path,
transcriptsEnabled: resolvedConfig?.transcripts.enabled,
transcriptsPath: resolvedConfig?.transcripts.path,
transcriptsLines: resolvedConfig?.transcripts.lines,
transcriptsPollIntervalSeconds: resolvedConfig?.transcripts.pollIntervalSeconds,
workspaceRoot: resolvedConfig?.workspace.root,
}, deps.lifecycleDeps);
status = await getDaemonStatus(deps.lifecycleDeps);
} catch (err) {
@@ -85,14 +106,28 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
let sourceRef = isRigName ? source : nodePath.resolve(source);
// If it looks like a name, check for library spec match
if (isRigName) {
let defaultLibraryCwdOverride: string | undefined;
// Cache rig summaries so we can both detect ambiguity AND derive lifecycleState
// for "Recovering ..." vs "Turning on ..." wording (post-L2).
let rigSummariesCache: Array<{ id: string; name: string; lifecycleState?: string }> | null = null;
const fetchRigSummaries = async () => {
if (rigSummariesCache !== null) return rigSummariesCache;
try {
const res = await client.get<Array<{ id: string; name: string; lifecycleState?: string }>>("/api/rigs/summary");
rigSummariesCache = res.data ?? [];
} catch {
rigSummariesCache = [];
}
return rigSummariesCache;
};
if (isRigName && !opts.existing) {
try {
const { resolveLibrarySpec } = await import("./specs.js");
const entry = await resolveLibrarySpec(client, source);
const entry = await resolveLibrarySpec(client, source, { kind: "rig" });
// Library match found — check for existing-rig collision
// Use /api/rigs/summary which mirrors findRigsByName (includes stopped rigs)
const rigRes = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
const rigMatches = (rigRes.data ?? []).filter((r) => r.name === source);
const rigSummaries = await fetchRigSummaries();
const rigMatches = rigSummaries.filter((r) => r.name === source);
if (rigMatches.length > 0) {
console.error(`'${source}' is ambiguous — it matches both an existing rig restore target and a library spec.`);
console.error(` To launch the library spec: rig up ${entry.sourcePath}`);
@@ -102,6 +137,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
return;
}
sourceRef = entry.sourcePath;
if (!opts.cwd && entry.kind === "rig" && entry.sourceType === "builtin") {
defaultLibraryCwdOverride = process.cwd();
}
} catch (resolveErr) {
// Ambiguity within library — surface it
if ((resolveErr as Error).message?.includes("ambiguous")) {
@@ -111,6 +149,29 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
}
// Not found or other error — proceed with existing rig-name behavior
}
// Wording divergence (post-L2): if sourceRef stayed as the rig name we are
// routing through the existing-rig restore path. Print "Recovering ..." or
// "Turning on ..." per the rig's derived lifecycleState. Help text honesty:
// "Recover" describes what `rig up` does; it does not promise success before
// tester L4 VM proof completes.
if (sourceRef === source && !opts.json) {
const summaries = await fetchRigSummaries();
const match = summaries.find((r) => r.name === source);
if (match?.lifecycleState === "recoverable") {
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
} else if (match?.lifecycleState === "stopped") {
console.log(`Turning on rig "${source}"...`);
}
}
} else if (isRigName && opts.existing && !opts.json) {
const summaries = await fetchRigSummaries();
const match = summaries.find((r) => r.name === source);
if (match?.lifecycleState === "recoverable") {
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
} else {
console.log(`Turning on existing rig "${source}"...`);
}
}
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
@@ -120,13 +181,15 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
sourceRef,
plan: opts.plan ?? false,
autoApprove: opts.yes ?? false,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
targetRoot,
});
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
if (res.status >= 400) process.exitCode = res.status === 409 ? 1 : 2;
const rigResult = res.data["rigResult"] as string | undefined;
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted") process.exitCode = 1;
return;
}
@@ -140,6 +203,8 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
} else if (code === "preflight_failed") {
const errors = (res.data["errors"] as string[]) ?? [];
console.error(`Preflight check failed:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: resolve the issues above and retry.`);
} else if (code === "pre_restore_validation_failed") {
printRestoreNotAttempted(res.data as RestoreNotAttemptedData);
} else {
const errorText = String(res.data["error"] ?? "unknown error");
console.error(`Up failed: ${errorText} (HTTP ${res.status}). Check daemon logs or validate your spec with: rig spec validate <path>`);
@@ -163,7 +228,17 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
// Existing-rig power-on handoff
const rigId = res.data["rigId"] as string;
const rigName = res.data["rigName"] as string | undefined;
const rigResult = res.data["rigResult"] as string | undefined;
const snapshotKind = res.data["snapshotKind"] as string | undefined;
// L3b: when the daemon falls back to a non-auto-pre-down snapshot, surface
// the kind so operators see the manual fallback explicitly. The note is
// printed BEFORE the "Rig restored" line so it's visible in the typical
// top-of-output scan.
if (snapshotKind && snapshotKind !== "auto-pre-down") {
console.log(`Restoring from manual snapshot (kind=${snapshotKind}); no auto-pre-down snapshot available.`);
}
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
if (rigResult) console.log(`Result: ${rigResult}`);
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
for (const n of nodes) {
console.log(` ${n.logicalId}: ${n.status}`);
@@ -177,7 +252,7 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
if (attachCommand) {
console.log(`Attach: ${attachCommand}`);
}
if (nodes.some((n) => n.status === "failed")) {
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted" || nodes.some((n) => n.status === "failed")) {
process.exitCode = 1;
}
} else {
@@ -213,3 +288,33 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
return cmd;
}
interface RestoreBlocker {
code: string;
severity?: string;
logicalId?: string;
nodeId?: string;
target?: string;
path?: string;
message: string;
remediation: string;
}
interface RestoreNotAttemptedData {
error?: string;
rigResult?: string;
blockers?: RestoreBlocker[];
}
function printRestoreNotAttempted(data: RestoreNotAttemptedData): void {
console.error(`Restore blocked: ${data.error ?? "pre-restore validation failed"}`);
if (data.rigResult) {
console.error(`Result: ${data.rigResult}`);
}
for (const blocker of data.blockers ?? []) {
const scope = blocker.logicalId ?? blocker.nodeId ?? blocker.target ?? blocker.code;
console.error(` ${scope}: ${blocker.message}`);
if (blocker.path) console.error(` path: ${blocker.path}`);
console.error(` remediation: ${blocker.remediation}`);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig view` — coordination primitive L5 (view) commands (PL-004 Phase B).
*
* Backed by `/api/views`. 6 built-in views (recently-active, founder,
* pod-load, escalations, held, activity) + custom view registration.
*/
export interface ViewDeps extends StatusDeps {}
async function withClient<T>(
deps: ViewDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function viewCommand(depsOverride?: ViewDeps): Command {
const cmd = new Command("view").description(
"Coordination L5 — daemon-backed views over coordination state",
);
const getDeps = (): ViewDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("list")
.description("List built-in + custom views")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/views/list");
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <viewName>")
.description(
"Run a view (built-in or custom). Built-in views: recently-active, founder, pod-load, escalations, held, activity",
)
.option("--rig <rig>", "Filter by rig name (matches destination_session OR source_session @<rig>)")
.option("--limit <n>", "Result row limit", "100")
.option("--json", "JSON output for agents")
.action(async (viewName: string, opts: { rig?: string; limit: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.rig) params.set("rig", opts.rig);
if (opts.limit) params.set("limit", opts.limit);
const query = params.toString();
await withClient(deps, async (client) => {
const path = `/api/views/${encodeURIComponent(viewName)}${query ? `?${query}` : ""}`;
const res = await client.get<unknown>(path);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("register")
.description("Register or update a custom view")
.requiredOption("--name <name>", "Custom view name (must NOT collide with built-in names)")
.requiredOption("--definition <sql>", "SQL definition (operator-supplied; not validated for taxonomy)")
.requiredOption("--session <session>", "Registering operator session")
.option("--json", "JSON output for agents")
.action(async (opts: { name: string; definition: string; session: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/views/custom/register", {
viewName: opts.name,
definition: opts.definition,
registeredBySession: opts.session,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+153
View File
@@ -0,0 +1,153 @@
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig watchdog` — coordination primitive Watchdog (PL-004 Phase C).
*
* Backed by `/api/watchdog`. Operates only via the daemon HTTP API.
*
* Per PRD § Watchdog: scheduler is daemon-native and joins the
* supervision tree. Phase D includes workflow-keepalive alongside the
* Phase C watchdog policies.
*/
export interface WatchdogDeps extends StatusDeps {}
async function withClient<T>(
deps: WatchdogDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
const cmd = new Command("watchdog").description(
"Coordination Watchdog — daemon-native scheduler for periodic-reminder, artifact-pool-ready, edge-artifact-required (PL-004 Phase C; workflow-keepalive ships in Phase D)",
);
const getDeps = (): WatchdogDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("register")
.description("Register a new watchdog job from a YAML spec file")
.requiredOption("--spec <path>", "Path to YAML spec file (policy + target + interval + context)")
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive)")
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
.requiredOption("--registered-by <session>", "Registering session (for audit)")
.option("--active-wake-interval-seconds <n>", "(pool-ready-specific) wake-up cadence when actionable artifacts exist")
.option("--scan-interval-seconds <n>", "(pool-ready-specific) artifact pool scan cadence")
.option("--json", "JSON output for agents")
.action(async (opts: {
spec: string;
policy: string;
targetSession: string;
intervalSeconds: string;
registeredBy: string;
activeWakeIntervalSeconds?: string;
scanIntervalSeconds?: string;
json?: boolean;
}) => {
const deps = getDeps();
let specYaml: string;
try {
specYaml = readFileSync(opts.spec, "utf-8");
} catch (err) {
console.error(`Failed to read spec file ${opts.spec}: ${err instanceof Error ? err.message : err}`);
process.exitCode = 1;
return;
}
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/watchdog/register", {
policy: opts.policy,
specYaml,
targetSession: opts.targetSession,
intervalSeconds: Number.parseInt(opts.intervalSeconds, 10),
activeWakeIntervalSeconds: opts.activeWakeIntervalSeconds
? Number.parseInt(opts.activeWakeIntervalSeconds, 10)
: undefined,
scanIntervalSeconds: opts.scanIntervalSeconds
? Number.parseInt(opts.scanIntervalSeconds, 10)
: undefined,
registeredBySession: opts.registeredBy,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List all watchdog jobs")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/watchdog/list");
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <jobId>")
.description("Show one watchdog job")
.option("--json", "JSON output for agents")
.action(async (jobId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("status <jobId>")
.description("Show one watchdog job + recent evaluation history")
.option("--json", "JSON output for agents")
.action(async (jobId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}/status`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("stop <jobId>")
.description("Stop a watchdog job (operator-stopped; scheduler skips it)")
.option("--reason <text>", "Stop reason (free-form; recorded in terminal_reason)")
.option("--json", "JSON output for agents")
.action(async (jobId: string, opts: { reason?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}/stop`, {
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+91 -9
View File
@@ -5,6 +5,26 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
interface WhoamiCliOptions {
nodeId?: string;
session?: string;
host?: string;
json?: boolean;
}
export interface WhoamiDeps extends StatusDeps {
/** Cross-host hooks; mirrors PsDeps/SendDeps shape. Tests inject mocks. */
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
interface WhoamiIdentity {
rigName: string;
@@ -133,9 +153,9 @@ export function resolveIdentitySource(
return null;
}
export function whoamiCommand(depsOverride?: StatusDeps): Command {
export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
const cmd = new Command("whoami").description("Show current managed identity in an OpenRig topology");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): WhoamiDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -143,16 +163,25 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
cmd
.option("--node-id <id>", "Resolve by node ID")
.option("--session <name>", "Resolve by session name")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.action(async (opts: { nodeId?: string; session?: string; json?: boolean }) => {
.action(async (opts: WhoamiCliOptions) => {
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
// Runs BEFORE local identity-source resolution: the remote rig has its
// own daemon and identity context; we just forward the raw flags.
if (opts.host) {
await runCrossHostWhoami(opts.host, opts, deps);
return;
}
const source = resolveIdentitySource(opts);
if (!source) {
console.error("Cannot determine identity. Run inside an OpenRig-managed session, or use --session or --node-id.");
process.exitCode = 1;
return;
}
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
const partial = buildPartialWhoamiResult(source);
@@ -169,11 +198,13 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
}
const client = deps.clientFactory(getDaemonUrl(status));
const query = source.nodeId
? `nodeId=${encodeURIComponent(source.nodeId)}`
: `sessionName=${encodeURIComponent(source.sessionName!)}`;
const params = new URLSearchParams();
if (source.nodeId) params.set("nodeId", source.nodeId);
else params.set("sessionName", source.sessionName!);
const targetRepo = readOpenRigEnv("OPENRIG_TARGET_REPO", "RIGGED_TARGET_REPO");
if (targetRepo) params.set("targetRepo", targetRepo);
const res = await client.get<Record<string, unknown>>(`/api/whoami?${query}`);
const res = await client.get<Record<string, unknown>>(`/api/whoami?${params.toString()}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
@@ -249,3 +280,54 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
return cmd;
}
async function runCrossHostWhoami(
hostId: string,
opts: WhoamiCliOptions,
deps: WhoamiDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv. `rig whoami` has no positional args; just the resolution
// flags. Identity resolution happens on the REMOTE rig (each host has its
// own daemon + tmux + identity context); we don't pre-resolve locally.
const argv: string[] = ["rig", "whoami"];
if (opts.nodeId !== undefined) argv.push("--node-id", opts.nodeId);
if (opts.session !== undefined) argv.push("--session", opts.session);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
if (result.ok) {
// Verbatim remote stdout passthrough — the remote `rig whoami --json`
// already produced the correct JSON envelope; we do NOT double-wrap.
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, true);
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, false);
}
+221
View File
@@ -0,0 +1,221 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig workflow` — daemon-native Workflow Runtime (PL-004 Phase D).
*
* Backed by `/api/workflow`. Operates only via the daemon HTTP API.
*
* Per PRD § L4 Workflow Runtime: workflow specs are markdown/YAML
* authoritative, daemon caches a read-through copy. Owner-as-author
* + workflow-as-transactional-scribe: owner closure + next-qitem
* projection happen in the SAME daemon transaction. Lost handoffs
* are impossible by design.
*/
export interface WorkflowDeps extends StatusDeps {}
async function withClient<T>(
deps: WorkflowDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
const cmd = new Command("workflow").description(
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
);
const getDeps = (): WorkflowDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("validate <specPath>")
.description("Validate a workflow spec file (returns structured ok/error report)")
.option("--json", "JSON output for agents")
.action(async (specPath: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/validate", { specPath });
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("instantiate <specPath>")
.description("Create a workflow instance + entry-step qitem from a spec")
.requiredOption("--root-objective <text>", "Root objective for the run")
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
.option("--entry-owner <session>", "Override default entry-step owner")
.option("--json", "JSON output for agents")
.action(async (specPath: string, opts: {
rootObjective: string;
createdBy: string;
entryOwner?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/instantiate", {
specPath,
rootObjective: opts.rootObjective,
createdBySession: opts.createdBy,
entryOwnerSession: opts.entryOwner,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("project")
.description("Close a current packet AND project the next-step packet (transactional-scribe; one daemon transaction)")
.requiredOption("--instance <id>", "Workflow instance id")
.requiredOption("--current-packet <qitem-id>", "qitem being closed (must be on instance frontier)")
.requiredOption("--exit <kind>", "Closure exit kind: handoff | waiting | done | failed")
.requiredOption("--actor-session <session>", "Session closing the packet (owner-as-author)")
.option("--result-note <text>", "Closure result note (audit context)")
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
.option("--next-owner <session>", "Override default next-step owner")
.option("--json", "JSON output for agents")
.action(async (opts: {
instance: string;
currentPacket: string;
exit: "handoff" | "waiting" | "done" | "failed";
actorSession: string;
resultNote?: string;
blockedOn?: string;
nextOwner?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/project", {
instanceId: opts.instance,
currentPacketId: opts.currentPacket,
exit: opts.exit,
actorSession: opts.actorSession,
resultNote: opts.resultNote,
blockedOn: opts.blockedOn,
nextOwnerSession: opts.nextOwner,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List workflow instances; optionally filter by status")
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
.option("--json", "JSON output for agents")
.action(async (opts: { status?: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.status) params.set("status", opts.status);
const qs = params.toString();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
// List cached workflow_specs (NOT instances). The existing
// `rig workflow list` lists instances; this is the complementary
// surface for inspecting which specs are registered, including any
// built-in starter(s) shipped at daemon startup.
// Built-in rows display a `(built-in)` indicator in human output and
// an `isBuiltIn: true` field in JSON output.
cmd
.command("specs")
.description("List registered workflow specs; built-in starters tagged with (built-in)")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<{ specs: Array<{
name: string; version: string; purpose: string | null;
targetRig: string | null; coordinationTerminalTurnRule: string;
sourcePath: string; cachedAt: string; isBuiltIn: boolean;
}> }>("/api/workflow/specs");
if (opts.json) {
printResult(true, res.data, res.status);
return;
}
if (res.status >= 400) {
printResult(false, res.data, res.status);
return;
}
const rows = res.data.specs ?? [];
if (rows.length === 0) {
console.log("No workflow specs registered.");
return;
}
// Compact human table — name, version, source, indicator.
for (const row of rows) {
const indicator = row.isBuiltIn ? " (built-in)" : "";
console.log(`${row.name} v${row.version}${indicator}`);
if (row.purpose) console.log(` purpose: ${row.purpose.replace(/\n/g, " ").slice(0, 120)}`);
console.log(` source: ${row.sourcePath}`);
}
});
});
cmd
.command("show <instanceId>")
.description("Show one workflow instance")
.option("--json", "JSON output for agents")
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("trace <instanceId>")
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
.option("--json", "JSON output for agents")
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/trace`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("continue <instanceId>")
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
.option("--json", "JSON output for agents")
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+127
View File
@@ -0,0 +1,127 @@
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
//
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
// parses each .md file's YAML frontmatter, and emits a structured gap
// report. Advisory only — never modifies. curate-steward consumes the
// report.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import * as path from "node:path";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export interface WorkspaceDeps extends StatusDeps {}
interface ValidationGap {
filePath: string;
relativePath: string;
kind: string;
field: string | null;
message: string;
workspaceKind: string | null;
}
interface ValidationReport {
root: string;
workspaceKind: string | null;
totalFiles: number;
filesWithFrontmatter: number;
gapCount: number;
gaps: ValidationGap[];
}
async function withClient<T>(
deps: WorkspaceDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
const cmd = new Command("workspace").description(
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
);
const getDeps = (): WorkspaceDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("validate [root]")
.description(
"Walk a workspace root, parse each .md file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: cwd.",
)
.option("--kind <kind>", "Workspace kind to validate against: user | project | knowledge | lab | delivery")
.option("--no-recursive", "Do not descend into subdirectories")
.option("--require-frontmatter", "Report a gap for every .md file without a frontmatter delimiter")
.option("--max-files <n>", "Hard cap on .md files walked", "10000")
.option("--json", "JSON output for agents")
.action(
async (
rootArg: string | undefined,
opts: {
kind?: string;
recursive?: boolean;
requireFrontmatter?: boolean;
maxFiles: string;
json?: boolean;
},
) => {
const root = path.resolve(rootArg ?? process.cwd());
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<ValidationReport>("/api/workspace/validate", {
root,
workspaceKind: opts.kind,
recursive: opts.recursive !== false,
requireFrontmatter: opts.requireFrontmatter ?? false,
maxFiles: Number.parseInt(opts.maxFiles, 10),
});
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
process.exitCode = 1;
return;
}
const report = res.data;
if (opts.json) {
console.log(JSON.stringify(report));
} else {
renderHumanReport(report);
}
// Exit non-zero when gaps found — operators chain into hygiene fix loops.
if (report.gapCount > 0) process.exitCode = 1;
});
},
);
return cmd;
}
function renderHumanReport(r: ValidationReport): void {
console.log(`workspace root: ${r.root}`);
console.log(`workspace kind: ${r.workspaceKind ?? "(none — kind-agnostic structural check)"}`);
console.log(`files walked: ${r.totalFiles}`);
console.log(`with frontmatter: ${r.filesWithFrontmatter}`);
console.log(`gaps: ${r.gapCount}`);
if (r.gapCount === 0) {
console.log("\n no gaps — canon is clean against v0 contract.");
return;
}
console.log("\n Gaps:");
for (const g of r.gaps) {
const fieldStr = g.field ? ` [${g.field}]` : "";
console.log(` [${g.kind}] ${g.relativePath}${fieldStr}`);
console.log(` ${g.message}`);
}
}
+407 -45
View File
@@ -6,42 +6,237 @@ import {
readOpenRigEnv,
} from "./openrig-compat.js";
// User Settings v0 — extends the existing ConfigStore with new namespaces
// (workspace.*, files.*, progress.*) without changing the existing 5
// daemon/db/transcripts keys' behavior. Storage stays single-source-of-
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
daemon: { port: number; host: string };
db: { path: string };
transcripts: { enabled: boolean; path: string };
transcripts: {
enabled: boolean;
path: string;
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
// SC-29 EXCEPTION #4 declared in pre-release ACK §5: same allowlist
// shape as the Phase 4 / Phase 5 prior exceptions.
lines: number;
pollIntervalSeconds: number;
};
// User Settings v0 — workspace paths.
workspace: {
root: string;
slicesRoot: string;
steeringPath: string;
fieldNotesRoot: string;
specsRoot: string;
dogfoodEvidenceRoot: string;
};
// User Settings v0 — UEP env-var graduation.
// Values are stored as raw named-pair strings ("name:/abs/path,...")
// matching the OPENRIG_FILES_ALLOWLIST / OPENRIG_PROGRESS_SCAN_ROOTS
// formats; decoded helpers (parseNamedPairs) turn them into structured
// arrays.
files: {
allowlist: string;
};
progress: {
scanRoots: string;
};
// Preview Terminal v0 (PL-018) — UI-side preferences for the live
// terminal preview pane.
ui: {
preview: {
refreshIntervalSeconds: number;
maxPins: number;
defaultLines: number;
};
};
recovery: {
autoDriveProviderPrompts: boolean;
providerAuthEnvAllowlist: string;
};
// V1 attempt-3 Phase 4 — Advisor / Operator rail icon V1 placeholders
// per universal-shell.md L82–L84. SC-29 EXCEPTION: allowlist-only
// additions (no schema migrations / new endpoints / event types).
agents: {
advisorSession: string;
operatorSession: string;
};
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles per
// for-you-feed.md L144–L151. SC-29 EXCEPTION declared in Phase 5
// dispatch ACK §5 DRIFT P5-D2: same scope as Phase 4 (allowlist-only;
// no migrations / new endpoints / event types).
feed: {
subscriptions: {
actionRequired: boolean;
approvals: boolean;
shipped: boolean;
progress: boolean;
auditLog: boolean;
};
};
}
const DEFAULTS: RiggedConfig = {
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
const DEFAULTS = {
daemon: { port: 7433, host: "127.0.0.1" },
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts") },
};
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts"), lines: 1000, pollIntervalSeconds: 2 },
workspace: {
root: DEFAULT_WORKSPACE_ROOT,
slicesRoot: "",
steeringPath: "",
fieldNotesRoot: "",
specsRoot: "",
dogfoodEvidenceRoot: "",
},
files: { allowlist: "" },
progress: { scanRoots: "" },
ui: {
preview: {
refreshIntervalSeconds: 3,
maxPins: 4,
defaultLines: 50,
},
},
recovery: {
autoDriveProviderPrompts: false,
providerAuthEnvAllowlist: "",
},
// V1 Phase 4 — Advisor default per universal-shell.md L83;
// Operator default empty per L84 ("not configured").
agents: {
advisorSession: "advisor-lead@openrig-velocity",
operatorSession: "",
},
// V1 Phase 5 P5-3 — feed subscription defaults per for-you-feed.md
// L144–L151. action_required forced ON in UI (load-bearing
// human-gate items per L145; cannot be disabled); approvals/
// shipped/progress default ON; audit_log default OFF (verbose;
// opt-in for triage runs).
feed: {
subscriptions: {
actionRequired: true,
approvals: true,
shipped: true,
progress: true,
auditLog: false,
},
},
} as const;
const VALID_KEYS = [
export const VALID_KEYS = [
"daemon.port",
"daemon.host",
"db.path",
"transcripts.enabled",
"transcripts.path",
// V1 pre-release CLI/daemon Item 1 — SC-29 EXCEPTION #4 allowlist
// sub-piece: transcript rotation tunables (line count + poll interval).
"transcripts.lines",
"transcripts.poll_interval_seconds",
"workspace.root",
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"ui.preview.refresh_interval_seconds",
"ui.preview.max_pins",
"ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 Phase 4 SC-29 exception — allowlist-only additions.
"agents.advisor_session",
"agents.operator_session",
// V1 Phase 5 P5-3 SC-29 exception — allowlist-only additions.
"feed.subscriptions.action_required",
"feed.subscriptions.approvals",
"feed.subscriptions.shipped",
"feed.subscriptions.progress",
"feed.subscriptions.audit_log",
] as const;
type ValidKey = typeof VALID_KEYS[number];
export type ValidKey = typeof VALID_KEYS[number];
const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
// Only the original runtime keys keep RIGGED_* aliases for upgrade
// compatibility. New typed keys use OPENRIG_* only.
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
"transcripts.lines": { primary: "OPENRIG_TRANSCRIPTS_LINES" },
"transcripts.poll_interval_seconds": { primary: "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS" },
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT" },
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT" },
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH" },
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT" },
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT" },
"workspace.dogfood_evidence_root": { primary: "OPENRIG_DOGFOOD_EVIDENCE_ROOT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
// OPENRIG_PROGRESS_SCAN_ROOTS become the env override for the new
// typed keys (no breaking change).
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST" },
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS" },
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION" },
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION" },
"feed.subscriptions.action_required": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED" },
"feed.subscriptions.approvals": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS" },
"feed.subscriptions.shipped": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED" },
"feed.subscriptions.progress": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS" },
"feed.subscriptions.audit_log": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG" },
};
// Maps dotted-string config keys to the camelCase RiggedConfig path.
// Workspace per-subdir keys are stored on disk as `workspace.slices_root`
// (snake) and exposed in RiggedConfig as `workspace.slicesRoot` (camel)
// to match TS conventions.
const KEY_TO_PATH: Record<ValidKey, string[]> = {
"daemon.port": ["daemon", "port"],
"daemon.host": ["daemon", "host"],
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
"transcripts.lines": ["transcripts", "lines"],
"transcripts.poll_interval_seconds": ["transcripts", "pollIntervalSeconds"],
"workspace.root": ["workspace", "root"],
"workspace.slices_root": ["workspace", "slicesRoot"],
"workspace.steering_path": ["workspace", "steeringPath"],
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
"workspace.specs_root": ["workspace", "specsRoot"],
"workspace.dogfood_evidence_root": ["workspace", "dogfoodEvidenceRoot"],
"files.allowlist": ["files", "allowlist"],
"progress.scan_roots": ["progress", "scanRoots"],
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
"agents.advisor_session": ["agents", "advisorSession"],
"agents.operator_session": ["agents", "operatorSession"],
"feed.subscriptions.action_required": ["feed", "subscriptions", "actionRequired"],
"feed.subscriptions.approvals": ["feed", "subscriptions", "approvals"],
"feed.subscriptions.shipped": ["feed", "subscriptions", "shipped"],
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
};
function isValidKey(key: string): key is ValidKey {
return (VALID_KEYS as readonly string[]).includes(key);
}
function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
const parts = key.split(".");
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
let current: unknown = obj;
for (const part of parts) {
if (current == null || typeof current !== "object") return undefined;
@@ -50,8 +245,7 @@ function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
return current;
}
function setNestedValue(obj: Record<string, unknown>, key: string, value: unknown): void {
const parts = key.split(".");
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
@@ -63,12 +257,52 @@ function setNestedValue(obj: Record<string, unknown>, key: string, value: unknow
current[parts[parts.length - 1]!] = value;
}
function getDefaultValue(key: ValidKey): string | number | boolean {
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, key) as string | number | boolean;
// Per-subdir defaults derived from workspace.root. Steering is a FILE path.
// Slice discovery defaults to the mission-aware workspace/missions contract;
// the indexer remains backward-compatible with flat slice roots when an
// operator sets workspace.slices_root explicitly.
// Files and Progress default to the whole workspace so a fresh
// `rig config init-workspace` install is browsable without extra env wiring.
export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): string {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "missions");
case "workspace.steering_path": return join(workspaceRoot, "STEERING.md");
case "workspace.field_notes_root": return join(workspaceRoot, "field-notes");
case "workspace.specs_root": return join(workspaceRoot, "specs");
case "workspace.dogfood_evidence_root": return join(workspaceRoot, "dogfood-evidence");
case "files.allowlist": return `workspace:${workspaceRoot}`;
case "progress.scan_roots": return `workspace:${workspaceRoot}`;
default: return "";
}
}
function coerceValue(key: ValidKey, raw: string): string | number | boolean {
const defaultVal = getDefaultValue(key);
function deriveLegacyWorkspaceDefault(key: ValidKey, workspaceRoot: string): string | null {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "slices");
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
default: return null;
}
}
const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
]);
function getDefaultValue(key: ValidKey, workspaceRoot: string): string | number | boolean {
if (WORKSPACE_DERIVED_KEYS.has(key)) {
return deriveWorkspaceDefault(key, workspaceRoot);
}
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, KEY_TO_PATH[key]) as string | number | boolean;
}
function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
const defaultVal = getDefaultValue(key, workspaceRoot);
if (typeof defaultVal === "number") {
const n = parseInt(raw, 10);
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
@@ -82,6 +316,14 @@ function coerceValue(key: ValidKey, raw: string): string | number | boolean {
return raw;
}
export type SettingSource = "env" | "file" | "default";
export interface ResolvedSetting {
value: string | number | boolean;
source: SettingSource;
defaultValue: string | number | boolean;
}
export class ConfigStore {
readonly configPath: string;
@@ -92,62 +334,154 @@ export class ConfigStore {
resolve(): RiggedConfig {
const fileConfig = this.readConfigFile();
const resolveKey = (key: ValidKey): string | number | boolean => {
// 1. Environment variable
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
return coerceValue(key, envVal);
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, key);
if (fileVal !== undefined && fileVal !== null) {
return fileVal as string | number | boolean;
}
// 3. Default
return getDefaultValue(key);
};
// Workspace root must be resolved first so derived per-subdir
// defaults can use it.
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const v = (key: ValidKey) =>
this.resolveOne(key, fileConfig, workspaceRoot).value;
return {
daemon: {
port: resolveKey("daemon.port") as number,
host: resolveKey("daemon.host") as string,
port: v("daemon.port") as number,
host: v("daemon.host") as string,
},
db: {
path: resolveKey("db.path") as string,
path: v("db.path") as string,
},
transcripts: {
enabled: resolveKey("transcripts.enabled") as boolean,
path: resolveKey("transcripts.path") as string,
enabled: v("transcripts.enabled") as boolean,
path: v("transcripts.path") as string,
lines: v("transcripts.lines") as number,
pollIntervalSeconds: v("transcripts.poll_interval_seconds") as number,
},
workspace: {
root: workspaceRoot,
slicesRoot: v("workspace.slices_root") as string,
steeringPath: v("workspace.steering_path") as string,
fieldNotesRoot: v("workspace.field_notes_root") as string,
specsRoot: v("workspace.specs_root") as string,
dogfoodEvidenceRoot: v("workspace.dogfood_evidence_root") as string,
},
files: {
allowlist: v("files.allowlist") as string,
},
progress: {
scanRoots: v("progress.scan_roots") as string,
},
ui: {
preview: {
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
maxPins: v("ui.preview.max_pins") as number,
defaultLines: v("ui.preview.default_lines") as number,
},
},
recovery: {
autoDriveProviderPrompts: v("recovery.auto_drive_provider_prompts") as boolean,
providerAuthEnvAllowlist: v("recovery.provider_auth_env_allowlist") as string,
},
agents: {
advisorSession: v("agents.advisor_session") as string,
operatorSession: v("agents.operator_session") as string,
},
feed: {
subscriptions: {
actionRequired: v("feed.subscriptions.action_required") as boolean,
approvals: v("feed.subscriptions.approvals") as boolean,
shipped: v("feed.subscriptions.shipped") as boolean,
progress: v("feed.subscriptions.progress") as boolean,
auditLog: v("feed.subscriptions.audit_log") as boolean,
},
},
};
}
get(key: string): string | number | boolean {
/**
* Resolve a single key with its source. Used by the daemon HTTP route
* + UI Settings panel for honest provenance display (env / file /
* default).
*/
resolveWithSource(key: string): ResolvedSetting {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const config = this.resolve();
return getNestedValue(config as unknown as Record<string, unknown>, key) as string | number | boolean;
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
return this.resolveOne(key, fileConfig, workspaceRoot);
}
/** Resolve all valid keys with sources. Convenience for the UI. */
resolveAllWithSource(): Record<ValidKey, ResolvedSetting> {
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const out = {} as Record<ValidKey, ResolvedSetting>;
for (const key of VALID_KEYS) {
out[key] = this.resolveOne(key, fileConfig, workspaceRoot);
}
return out;
}
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
const defaultValue = getDefaultValue(key, workspaceRoot);
// 1. Environment variable
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
return { value: coerceValue(key, envVal, workspaceRoot), source: "env", defaultValue };
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH[key]);
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
const legacyDefault = deriveLegacyWorkspaceDefault(key, workspaceRoot);
if (legacyDefault !== null && fileVal === legacyDefault) {
return { value: defaultValue, source: "default", defaultValue };
}
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
}
// 3. Default
return { value: defaultValue, source: "default", defaultValue };
}
get(key: string): string | number | boolean {
return this.resolveWithSource(key).value;
}
set(key: string, value: string): void {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const coerced = coerceValue(key, value);
// readConfigFile (not Raw) — throws on malformed config so set doesn't silently overwrite
const fileConfig = this.readConfigFile();
setNestedValue(fileConfig, key, coerced);
const workspaceRoot = (getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined)
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|| DEFAULT_WORKSPACE_ROOT;
const coerced = coerceValue(key, value, workspaceRoot);
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
reset(): void {
try {
unlinkSync(this.configPath);
} catch {
// File doesn't exist — that's fine
/**
* Clear an override. Without a key argument, deletes the whole config
* file (revert all to defaults). With a key, removes just that key
* from the config file.
*/
reset(key?: string): void {
if (key === undefined) {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
}
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
if (!existsSync(this.configPath)) return;
const fileConfig = this.readConfigFile();
const parts = KEY_TO_PATH[key];
const parentParts = parts.slice(0, -1);
const leaf = parts[parts.length - 1]!;
const parent = getNestedValue(fileConfig, parentParts) as Record<string, unknown> | undefined;
if (parent && leaf in parent) {
delete parent[leaf];
}
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
private readConfigFile(): Record<string, unknown> {
@@ -161,5 +495,33 @@ export class ConfigStore {
);
}
}
}
// --- User Settings v0: named-pair decoder ---
//
// `files.allowlist` and `progress.scan_roots` are stored as the same
// comma-separated `name:/abs/path` strings UEP introduced via env var.
// This helper decodes the raw string into structured pairs. Invalid
// entries (no colon, empty name/path) are silently skipped per UEP
// convention; duplicate names: last wins.
export interface NamedPair {
name: string;
path: string;
}
export function parseNamedPairs(raw: string): NamedPair[] {
if (!raw || !raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
out.set(name, rawPath);
}
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
}
@@ -0,0 +1,59 @@
import type { CrossHostResult } from "./cross-host-executor.js";
/**
* Shared CLI-side helpers for commands that gate on `--host`. Centralizes
* the structured failure formatting + JSON envelope so `send.ts` and
* `capture.ts` (and any future v1 cross-host command) format identically.
*/
export function emitCrossHostError(hostId: string, code: string, message: string, json?: boolean): void {
if (json) {
console.log(JSON.stringify({ ok: false, cross_host: { host: hostId }, failedStep: code, error: message }));
} else {
console.error(`cross-host (host=${hostId}): ${message}`);
}
process.exitCode = 1;
}
export function emitCrossHostFailure(
hostId: string,
target: string,
result: CrossHostResult,
json?: boolean,
): void {
if (result.ok) return;
const message = formatCrossHostFailure(hostId, target, result);
if (json) {
console.log(JSON.stringify({
ok: false,
cross_host: { host: hostId, target },
failedStep: result.failedStep,
error: message,
...(result.failedStep === "permission-gate" && result.hint ? { hint: result.hint } : {}),
}));
} else {
console.error(message);
}
process.exitCode = 1;
}
export function formatCrossHostFailure(
hostId: string,
target: string,
result: Extract<CrossHostResult, { ok: false }>,
): string {
switch (result.failedStep) {
case "ssh-unreachable":
return `ssh to host=${hostId} (target=${target}) failed: ${oneLine(result.sshStderr)}. Verify SSH access and host registry config.`;
case "permission-gate":
return `ssh to host=${hostId} (target=${target}) hit a permission/auth gate: ${oneLine(result.sshStderr)}. ${result.hint ?? ""}`.trim();
case "remote-daemon-unreachable":
return `remote rig command on host=${hostId} could not reach the remote daemon (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}. Start the daemon on the remote with 'ssh ${target} rig daemon start'.`;
case "remote-command-failed":
return `remote rig command on host=${hostId} failed (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}`;
}
}
function oneLine(s: string): string {
return s.split("\n").map((l) => l.trim()).filter(Boolean).join(" | ").slice(0, 400);
}
+190
View File
@@ -0,0 +1,190 @@
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import type { HostEntry } from "./host-registry.js";
/**
* Structured result of a cross-host command. Distinguishes SSH-layer failures
* (couldn't reach the remote shell) from remote-command failures (reached the
* remote shell, but the command itself returned non-zero). This distinction
* is load-bearing: collapsing both into "failed" hides the operational
* difference between "fix your SSH" and "fix your remote rig daemon."
*/
export type CrossHostResult =
| { ok: true; failedStep: "none"; stdout: string; stderr: string; remoteExitCode: 0 }
| { ok: false; failedStep: "ssh-unreachable"; sshStderr: string }
| { ok: false; failedStep: "permission-gate"; sshStderr: string; hint?: string }
| { ok: false; failedStep: "remote-daemon-unreachable"; stdout: string; stderr: string; remoteExitCode: number }
| { ok: false; failedStep: "remote-command-failed"; stdout: string; stderr: string; remoteExitCode: number };
export type FailedStep = CrossHostResult["failedStep"];
/**
* Pluggable spawn function for testing. The default uses Node's
* `child_process.spawn` directly. Tests inject a mock that returns a
* controllable process-like object.
*/
export type SpawnFn = (
command: string,
args: readonly string[],
) => ChildProcess;
export interface RunCrossHostCommandOpts {
/** Optional stdin to write to the spawned ssh process (forwarded to remote stdin). */
stdin?: string;
/** Inject a non-default spawn function (used in tests). */
spawn?: SpawnFn;
/** Connect timeout in seconds for the underlying ssh invocation. Default: 10. */
connectTimeoutSeconds?: number;
}
/**
* Run a remote rig command via single-hop ssh. Returns a structured result
* that maps each failure mode named in the dossier to a distinct
* `failedStep` value.
*
* Important: this function does NOT collapse remote `--verify` semantics into
* the SSH exit code. Callers that pass `--verify` get the remote command's
* stdout (containing `Verified: yes`/`no`) verbatim; the remote rig is
* authoritative on verification. SSH success (exit 0) just means "we reached
* the remote shell and it ran the command", not "the verify passed."
*/
export async function runCrossHostCommand(
host: HostEntry,
argv: readonly string[],
opts: RunCrossHostCommandOpts = {},
): Promise<CrossHostResult> {
if (host.transport !== "ssh") {
return {
ok: false,
failedStep: "ssh-unreachable",
sshStderr: `host '${host.id}': transport '${host.transport}' is not supported (v0 supports ssh only)`,
};
}
if (argv.length === 0) {
return {
ok: false,
failedStep: "ssh-unreachable",
sshStderr: "internal: empty argv passed to runCrossHostCommand",
};
}
const sshOpts: string[] = [];
if (host.user) {
sshOpts.push("-l", host.user);
}
const connectTimeout = opts.connectTimeoutSeconds ?? 10;
sshOpts.push("-o", `ConnectTimeout=${connectTimeout}`);
// No BatchMode — operators may legitimately use password / interactive auth.
// Stay deferential to ~/.ssh/config defaults.
const remoteCommandLine = argv.map(shellQuote).join(" ");
const fullArgs = [...sshOpts, host.target, remoteCommandLine];
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
const child = spawn("ssh", fullArgs);
if (opts.stdin !== undefined && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
} else if (child.stdin) {
child.stdin.end();
}
let stdout = "";
let stderr = "";
if (child.stdout) {
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
}
if (child.stderr) {
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
}
const exitCode: number = await new Promise((resolve) => {
child.on("close", (code: number | null) => resolve(code ?? -1));
child.on("error", (err: Error) => {
stderr += `\n[spawn error] ${err.message}`;
resolve(-1);
});
});
return classifyResult(exitCode, stdout, stderr);
}
/**
* Map the spawned ssh exit code + stderr signature to a structured result.
*
* - Exit 0 → success.
* - Exit 255 → ssh-layer failure. If stderr matches a known permission/auth
* gate (Permission denied, Keychain, host key verification), classify as
* `permission-gate` with a hint pointing at the field-note diagnostic.
* Otherwise classify as `ssh-unreachable`.
* - Any other non-zero → ssh succeeded but the remote rig command failed.
* If the remote stderr matches the daemon-not-running signature, classify
* as `remote-daemon-unreachable`; otherwise `remote-command-failed`.
*/
export function classifyResult(exitCode: number, stdout: string, stderr: string): CrossHostResult {
if (exitCode === 0) {
return { ok: true, failedStep: "none", stdout, stderr, remoteExitCode: 0 };
}
if (exitCode === 255 || exitCode === -1) {
if (looksLikePermissionGate(stderr)) {
return {
ok: false,
failedStep: "permission-gate",
sshStderr: stderr,
hint: "See openrig-work/field-notes/2026-04-29-l4-3-d6-claude-keychain-over-ssh-diagnostic.md for guidance on Keychain-over-SSH issues.",
};
}
return { ok: false, failedStep: "ssh-unreachable", sshStderr: stderr };
}
if (looksLikeDaemonUnreachable(stderr) || looksLikeDaemonUnreachable(stdout)) {
return {
ok: false,
failedStep: "remote-daemon-unreachable",
stdout,
stderr,
remoteExitCode: exitCode,
};
}
return {
ok: false,
failedStep: "remote-command-failed",
stdout,
stderr,
remoteExitCode: exitCode,
};
}
const PERMISSION_GATE_PATTERNS = [
/Permission denied/i,
/Host key verification failed/i,
/Keychain/i,
/Could not request authentication agent/i,
/no mutual signature algorithm/i,
];
function looksLikePermissionGate(stderr: string): boolean {
return PERMISSION_GATE_PATTERNS.some((re) => re.test(stderr));
}
const DAEMON_UNREACHABLE_PATTERNS = [
/Daemon not running/i,
/Failed to fetch .* from daemon/i,
/ECONNREFUSED.*localhost/i,
];
function looksLikeDaemonUnreachable(text: string): boolean {
return DAEMON_UNREACHABLE_PATTERNS.some((re) => re.test(text));
}
/**
* Single-quote shell escaping. Wraps the input in single quotes, escaping
* any embedded single quotes via `'\''`. Safe for passing as a single arg
* to a remote POSIX shell.
*/
export function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
+188 -18
View File
@@ -1,7 +1,9 @@
import path from "node:path";
import { existsSync } from "node:fs";
import type { ChildProcess } from "node:child_process";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
import { workspaceScaffoldDirs, workspaceScaffoldFiles } from "./commands/config-init-workspace.js";
export interface DaemonState {
pid: number;
@@ -21,7 +23,7 @@ export interface DaemonStatus {
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
export function getDaemonUrl(status: DaemonStatus): string {
return `http://${status.host ?? "127.0.0.1"}:${status.port}`;
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
}
export interface StartOptions {
@@ -30,6 +32,13 @@ export interface StartOptions {
db?: string;
transcriptsEnabled?: boolean;
transcriptsPath?: string;
/** When set, daemon startup idempotently ensures the default workspace exists. */
workspaceRoot?: string;
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
// Threaded through to the daemon process env so the rotation hook
// picks up file-stored ConfigStore values, not just shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
}
export interface LifecycleDeps {
@@ -56,12 +65,52 @@ export const LOG_FILE = path.join(OPENRIG_DIR, "daemon.log");
export const LEGACY_STATE_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.json");
export const LEGACY_LOG_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.log");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 7433;
const DEFAULT_DB = "openrig.sqlite";
const HEALTHZ_RETRIES = 20;
const HEALTHZ_DELAY_MS = 250;
const HEALTHZ_PROBE_TIMEOUT_MS = 250;
export interface WorkspaceScaffoldResult {
root: string;
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
}
export function ensureWorkspaceScaffold(root: string, deps: LifecycleDeps): WorkspaceScaffoldResult {
const rootExists = deps.exists(root);
if (!rootExists) deps.mkdirp(root);
const result: WorkspaceScaffoldResult = {
root,
rootCreated: !rootExists,
subdirs: [],
files: [],
};
for (const sub of workspaceScaffoldDirs()) {
const subPath = path.join(root, sub);
const existed = deps.exists(subPath);
if (!existed) deps.mkdirp(subPath);
result.subdirs.push({ name: sub, path: subPath, created: !existed });
}
for (const file of workspaceScaffoldFiles()) {
const absPath = path.join(root, file.relPath);
const existed = deps.exists(absPath);
if (existed) {
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
continue;
}
deps.writeFile(absPath, file.content);
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
return result;
}
class HealthProbeTimeoutError extends Error {
constructor(url: string) {
super(`healthz probe timed out for ${url}`);
@@ -144,7 +193,7 @@ function resolveLifecycleFile(deps: LifecycleDeps, filename: "daemon.json" | "da
* - "dead" — PID not alive */
async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead"> {
if (!deps.isProcessAlive(state.pid)) return "dead";
const host = state.host ?? "127.0.0.1";
const host = state.host ?? DEFAULT_HOST;
try {
await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
// Any response (ok or not) means something is listening on our port → OpenRig
@@ -156,7 +205,87 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
}
}
function resolveConfiguredDaemonTarget(): { host: string; port: number } {
try {
const config = new ConfigStore().resolve();
return {
host: config.daemon.host,
port: config.daemon.port,
};
} catch {
return {
host: DEFAULT_HOST,
port: DEFAULT_PORT,
};
}
}
/**
* Scrub list: environment variable prefixes and exact names that should NOT
* be forwarded from the operator shell into the daemon process. These are
* terminal-emulator, GUI-session, and cmux-session variables that can break
* adapter initialization when the daemon runs detached.
*/
const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
const ENV_SCRUB_EXACT = new Set([
"CMUX_SOCKET_PATH",
"CMUX_SURFACE_ID",
"CMUX_WORKSPACE",
"COLORTERM",
"COMMAND_MODE",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"TERMINFO",
]);
export function buildDaemonEnv(
baseEnv: Record<string, string>,
opts: {
port: number;
host: string;
db: string;
transcriptsEnabled?: boolean;
transcriptsPath?: string;
// V1 pre-release CLI/daemon Item 1 — projected from ConfigStore so
// the rotation hook honors file-stored values, not just inherited
// shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
},
): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(baseEnv)) {
if (ENV_SCRUB_EXACT.has(key)) continue;
if (ENV_SCRUB_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
env[key] = value;
}
// Explicit OPENRIG_* overrides — always win over inherited values
env["OPENRIG_PORT"] = String(opts.port);
env["OPENRIG_HOST"] = opts.host;
env["OPENRIG_DB"] = opts.db;
if (opts.transcriptsEnabled !== undefined) {
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
}
if (opts.transcriptsPath) {
env["OPENRIG_TRANSCRIPTS_PATH"] = opts.transcriptsPath;
}
if (opts.transcriptsLines !== undefined) {
env["OPENRIG_TRANSCRIPTS_LINES"] = String(opts.transcriptsLines);
}
if (opts.transcriptsPollIntervalSeconds !== undefined) {
env["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"] = String(opts.transcriptsPollIntervalSeconds);
}
return env;
}
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? DEFAULT_HOST;
const db = opts.db ?? DEFAULT_DB;
// Check if already running
const existing = readState(deps);
if (existing) {
@@ -169,26 +298,39 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
throw new Error(`Existing daemon process (pid ${existing.pid} on port ${existing.port}) is unresponsive — recover it before starting a new daemon.`);
}
// "dead" or "not_rigged" → stale state, safe to proceed
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
throw new Error(`Daemon on port ${port} is unresponsive, and daemon state is missing — recover it before starting a new daemon.`);
}
}
if (recoveredRunning) {
throw new Error(`Daemon already running on port ${port}, but daemon state is missing`);
}
}
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? "127.0.0.1";
const db = opts.db ?? DEFAULT_DB;
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
deps.mkdirp(OPENRIG_DIR);
if (opts.workspaceRoot) {
ensureWorkspaceScaffold(opts.workspaceRoot, deps);
}
const logFd = deps.openForAppend(LOG_FILE);
const child = deps.spawn(process.execPath, [daemonEntry], {
env: {
...process.env as Record<string, string>,
OPENRIG_PORT: String(port),
OPENRIG_HOST: host,
OPENRIG_DB: db,
...(opts.transcriptsEnabled !== undefined ? { OPENRIG_TRANSCRIPTS_ENABLED: String(opts.transcriptsEnabled) } : {}),
...(opts.transcriptsPath ? { OPENRIG_TRANSCRIPTS_PATH: opts.transcriptsPath } : {}),
},
env: buildDaemonEnv(process.env as Record<string, string>, {
port,
host,
db,
transcriptsEnabled: opts.transcriptsEnabled,
transcriptsPath: opts.transcriptsPath,
transcriptsLines: opts.transcriptsLines,
transcriptsPollIntervalSeconds: opts.transcriptsPollIntervalSeconds,
}),
stdio: ["ignore", logFd, logFd],
detached: true,
});
@@ -233,7 +375,22 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
export async function stopDaemon(deps: LifecycleDeps): Promise<void> {
const state = readState(deps);
if (!state) return; // Not running — clean exit
if (!state) {
const configured = resolveConfiguredDaemonTarget();
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
throw new Error(`Daemon state is missing and port ${configured.port} is unresponsive — cannot stop safely.`);
}
}
if (recoveredRunning) {
throw new Error(`Daemon is running on port ${configured.port}, but daemon state is missing — cannot stop safely.`);
}
return;
}
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const pidState = await checkPid(state, deps);
@@ -275,14 +432,27 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
try {
const res = await fetchDaemonProbe(deps, `${openrigUrl}/healthz`);
const url = new URL(openrigUrl);
return { state: "running", port: Number(url.port) || 7433, host: url.hostname || "127.0.0.1", healthy: res.ok };
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: res.ok };
} catch {
return { state: "stopped" };
}
}
const state = readState(deps);
if (!state) return { state: "stopped" };
if (!state) {
const configured = resolveConfiguredDaemonTarget();
try {
const res = await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
return {
state: "running",
port: configured.port,
host: configured.host,
healthy: res.ok,
};
} catch {
return { state: "stopped" };
}
}
if (!deps.isProcessAlive(state.pid)) {
// Process dead — stale state
@@ -291,7 +461,7 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
}
// Process alive — check healthz
const host = state.host ?? "127.0.0.1";
const host = state.host ?? DEFAULT_HOST;
let healthy = false;
try {
const res = await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
+294
View File
@@ -0,0 +1,294 @@
import { execFileSync } from "node:child_process";
import Database from "better-sqlite3";
export const DESTROY_CONFIRM_TOKEN = "destroy-openrig-state";
export type DestroyScope = "state" | "all";
export interface DestroyRuntimeConfig {
stateRoot: string;
dbPath: string;
transcriptsPath: string;
daemonHost: string;
daemonPort: number;
}
export interface DestroyTarget {
path: string;
kind: "state_root" | "db_file" | "db_shm" | "db_wal" | "transcripts_dir";
backupPath?: string;
}
export interface DestroyPlan {
scope: DestroyScope;
backup: boolean;
stateRoot: string;
daemonHost: string;
daemonPort: number;
managedTmuxSessions: string[];
targets: DestroyTarget[];
warnings: string[];
}
export interface DestroyResult {
scope: DestroyScope;
backup: boolean;
stateRoot: string;
backupPaths: string[];
daemonStopped: boolean;
portCleared: boolean;
stateRecreated: boolean;
tmuxKilled: number;
tmuxMissing: number;
warnings: string[];
}
export interface ListenerInspection {
kind: "openrig" | "other_http" | "unreachable";
healthy?: boolean;
detail?: string;
}
export interface DestroyDeps {
stopDaemon: () => Promise<void>;
inspectListener: (host: string, port: number) => Promise<ListenerInspection>;
findListeningPid: (port: number) => number | null;
killProcess: (pid: number) => void;
exists: (path: string) => boolean;
renamePath: (from: string, to: string) => void;
removePath: (path: string) => void;
mkdirp: (path: string) => void;
listManagedTmuxSessions: (dbPath: string) => string[];
killTmuxSession: (sessionName: string) => boolean;
sleep: (ms: number) => Promise<void>;
now: () => Date;
}
function normalizePathForPrefix(path: string): string {
return path.endsWith("/") ? path : `${path}/`;
}
export function isWithinPath(candidate: string, root: string): boolean {
if (candidate === root) return true;
return candidate.startsWith(normalizePathForPrefix(root));
}
function formatTimestamp(date: Date): string {
const yyyy = String(date.getFullYear());
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
const ss = String(date.getSeconds()).padStart(2, "0");
return `${yyyy}${mm}${dd}-${hh}${min}${ss}`;
}
export function buildBackupPath(targetPath: string, exists: (path: string) => boolean, now = new Date()): string {
const stamp = formatTimestamp(now);
const base = `${targetPath}.backup-${stamp}`;
if (!exists(base)) return base;
let suffix = 2;
while (exists(`${base}-${suffix}`)) suffix += 1;
return `${base}-${suffix}`;
}
export function listManagedTmuxSessionsFromDb(dbPath: string): string[] {
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const rows = db.prepare(`
SELECT DISTINCT
COALESCE(
b.tmux_session,
(
SELECT s2.session_name
FROM sessions s2
WHERE s2.node_id = n.id
ORDER BY datetime(s2.created_at) DESC, s2.rowid DESC
LIMIT 1
)
) AS session_name
FROM nodes n
LEFT JOIN bindings b ON b.node_id = n.id
WHERE COALESCE(b.attachment_type, 'tmux') = 'tmux'
`).all() as Array<{ session_name: string | null }>;
return rows
.map((row) => row.session_name?.trim() ?? "")
.filter((name): name is string => Boolean(name))
.sort();
} finally {
db.close();
}
}
export function buildDestroyPlan(
scope: DestroyScope,
backup: boolean,
config: DestroyRuntimeConfig,
deps: Pick<DestroyDeps, "exists" | "listManagedTmuxSessions" | "now">
): DestroyPlan {
const warnings: string[] = [];
const targets: DestroyTarget[] = [];
const stateRootExists = deps.exists(config.stateRoot);
targets.push({
path: config.stateRoot,
kind: "state_root",
...(backup && stateRootExists ? { backupPath: buildBackupPath(config.stateRoot, deps.exists, deps.now()) } : {}),
});
const addExternalTarget = (path: string, kind: DestroyTarget["kind"]) => {
if (!deps.exists(path)) return;
if (isWithinPath(path, config.stateRoot)) return;
targets.push({
path,
kind,
...(backup ? { backupPath: buildBackupPath(path, deps.exists, deps.now()) } : {}),
});
warnings.push(`${kind} is outside state root and will be ${backup ? "backed up separately" : "deleted separately"}: ${path}`);
};
addExternalTarget(config.dbPath, "db_file");
addExternalTarget(`${config.dbPath}-shm`, "db_shm");
addExternalTarget(`${config.dbPath}-wal`, "db_wal");
addExternalTarget(config.transcriptsPath, "transcripts_dir");
let managedTmuxSessions: string[] = [];
if (scope === "all" && deps.exists(config.dbPath)) {
try {
managedTmuxSessions = deps.listManagedTmuxSessions(config.dbPath);
} catch (err) {
warnings.push(`Failed to enumerate managed tmux sessions from ${config.dbPath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return {
scope,
backup,
stateRoot: config.stateRoot,
daemonHost: config.daemonHost,
daemonPort: config.daemonPort,
managedTmuxSessions,
targets,
warnings,
};
}
function isLocalHost(host: string): boolean {
return host === "127.0.0.1" || host === "localhost" || host === "0.0.0.0";
}
export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Promise<DestroyResult> {
const warnings = [...plan.warnings];
const backupPaths: string[] = [];
let stateRecreated = false;
try {
await deps.stopDaemon();
} catch (err) {
warnings.push(`stopDaemon failed: ${err instanceof Error ? err.message : String(err)}`);
}
let daemonStopped = false;
let portCleared = false;
if (isLocalHost(plan.daemonHost)) {
const before = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
if (before.kind === "openrig") {
const listenerPid = deps.findListeningPid(plan.daemonPort);
if (listenerPid !== null) {
try {
deps.killProcess(listenerPid);
} catch (err) {
warnings.push(`Failed to terminate listener pid ${listenerPid}: ${err instanceof Error ? err.message : String(err)}`);
}
for (let attempt = 0; attempt < 20; attempt += 1) {
if ((await deps.inspectListener(plan.daemonHost, plan.daemonPort)).kind === "unreachable") break;
await deps.sleep(100);
}
} else {
warnings.push(`OpenRig responded on port ${plan.daemonPort}, but no listener pid was found.`);
}
} else if (before.kind === "other_http") {
warnings.push(`Port ${plan.daemonPort} is occupied by a live non-OpenRig HTTP listener. State was left untouched.`);
}
const after = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
portCleared = after.kind === "unreachable";
daemonStopped = portCleared;
} else {
warnings.push(`Daemon host ${plan.daemonHost} is not local. rig destroy only operates on local daemon targets; state was left untouched.`);
}
if (!portCleared) {
return {
scope: plan.scope,
backup: plan.backup,
stateRoot: plan.stateRoot,
backupPaths,
daemonStopped,
portCleared,
stateRecreated,
tmuxKilled: 0,
tmuxMissing: 0,
warnings,
};
}
let tmuxKilled = 0;
let tmuxMissing = 0;
if (plan.scope === "all") {
for (const sessionName of plan.managedTmuxSessions) {
if (deps.killTmuxSession(sessionName)) tmuxKilled += 1;
else tmuxMissing += 1;
}
}
for (const target of plan.targets) {
if (!deps.exists(target.path)) continue;
if (plan.backup && target.backupPath) {
deps.renamePath(target.path, target.backupPath);
backupPaths.push(target.backupPath);
} else {
deps.removePath(target.path);
}
}
deps.mkdirp(plan.stateRoot);
stateRecreated = true;
return {
scope: plan.scope,
backup: plan.backup,
stateRoot: plan.stateRoot,
backupPaths,
daemonStopped,
portCleared,
stateRecreated,
tmuxKilled,
tmuxMissing,
warnings,
};
}
export function findListeningPidWithLsof(port: number): number | null {
try {
const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { encoding: "utf-8" }).trim();
if (!output) return null;
const first = output.split(/\s+/)[0]?.trim();
if (!first) return null;
const pid = parseInt(first, 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function killTmuxSessionWithCli(sessionName: string): boolean {
try {
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
+142
View File
@@ -0,0 +1,142 @@
import { existsSync, readFileSync } from "node:fs";
import { parse as parseYaml } from "yaml";
import { getDefaultOpenRigPath } from "./openrig-compat.js";
/**
* A single declared host in `~/.openrig/hosts.yaml`. v0 supports `transport: ssh`
* only. `target` is whatever ssh resolves (DNS, SSH config alias, IP, etc.).
*/
export interface HostEntry {
id: string;
transport: "ssh";
target: string;
user?: string;
notes?: string;
}
export interface HostRegistry {
hosts: HostEntry[];
}
export type HostRegistryLoadResult =
| { ok: true; registry: HostRegistry }
| { ok: false; error: string };
export type HostResolution =
| { ok: true; host: HostEntry }
| { ok: false; error: string };
const KNOWN_TRANSPORTS = new Set(["ssh"]);
export function defaultHostRegistryPath(): string {
return getDefaultOpenRigPath("hosts.yaml");
}
/**
* Load and validate the host registry from disk. v0 file shape:
*
* hosts:
* - id: vm-claude-test
* transport: ssh
* target: vm-claude-test.local
* user: wrandom # optional
* notes: "Tart VM" # optional
*
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
* file returns a clear error pointing at the canonical path.
*/
export function loadHostRegistry(path: string = defaultHostRegistryPath()): HostRegistryLoadResult {
if (!existsSync(path)) {
return {
ok: false,
error: `host registry not found at ${path}. Create it with a 'hosts:' array of { id, transport, target, [user] } entries; v0 supports transport: ssh only.`,
};
}
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err) {
return { ok: false, error: `failed to read host registry at ${path}: ${(err as Error).message}` };
}
let parsed: unknown;
try {
parsed = parseYaml(raw);
} catch (err) {
return { ok: false, error: `failed to parse host registry YAML at ${path}: ${(err as Error).message}` };
}
return validateHostRegistry(parsed, path);
}
export function validateHostRegistry(parsed: unknown, sourcePath: string): HostRegistryLoadResult {
if (parsed === null || typeof parsed !== "object") {
return { ok: false, error: `host registry at ${sourcePath} must be a YAML object with a 'hosts' array` };
}
const obj = parsed as Record<string, unknown>;
const hosts = obj["hosts"];
if (!Array.isArray(hosts)) {
return { ok: false, error: `host registry at ${sourcePath}: 'hosts' must be an array` };
}
const seenIds = new Set<string>();
const validated: HostEntry[] = [];
for (let i = 0; i < hosts.length; i++) {
const raw = hosts[i];
const prefix = `host registry at ${sourcePath}: hosts[${i}]`;
if (raw === null || typeof raw !== "object") {
return { ok: false, error: `${prefix}: must be an object with id/transport/target` };
}
const entry = raw as Record<string, unknown>;
const id = entry["id"];
if (typeof id !== "string" || id.trim() === "") {
return { ok: false, error: `${prefix}.id: required non-empty string` };
}
if (seenIds.has(id)) {
return { ok: false, error: `${prefix}.id: duplicate host id '${id}' (each host id must be unique within the registry)` };
}
seenIds.add(id);
const transport = entry["transport"];
if (transport !== "ssh") {
return {
ok: false,
error: `${prefix}.transport: v0 supports 'ssh' only (got ${JSON.stringify(transport)}); declared known transports: ${[...KNOWN_TRANSPORTS].sort().join(", ")}`,
};
}
const target = entry["target"];
if (typeof target !== "string" || target.trim() === "") {
return { ok: false, error: `${prefix}.target: required non-empty string (an ssh target — DNS name, SSH config alias, or IP)` };
}
const user = entry["user"];
if (user !== undefined && (typeof user !== "string" || user.trim() === "")) {
return { ok: false, error: `${prefix}.user: optional, but if present must be a non-empty string` };
}
const notes = entry["notes"];
if (notes !== undefined && typeof notes !== "string") {
return { ok: false, error: `${prefix}.notes: optional, but if present must be a string` };
}
validated.push({
id,
transport: "ssh",
target,
...(user !== undefined ? { user: user as string } : {}),
...(notes !== undefined ? { notes: notes as string } : {}),
});
}
return { ok: true, registry: { hosts: validated } };
}
/**
* Resolve a host id against a loaded registry. Unknown id returns an error
* naming the requested id and listing up to 10 known ids for discoverability.
*/
export function resolveHost(registry: HostRegistry, id: string): HostResolution {
const match = registry.hosts.find((h) => h.id === id);
if (match) return { ok: true, host: match };
const knownIds = registry.hosts.map((h) => h.id).slice(0, 10);
const idsHint = knownIds.length > 0
? ` Known host ids: ${knownIds.join(", ")}${registry.hosts.length > knownIds.length ? ` (+${registry.hosts.length - knownIds.length} more)` : ""}.`
: " (registry is empty)";
return {
ok: false,
error: `unknown host id '${id}'.${idsHint}`,
};
}
+51
View File
@@ -25,21 +25,39 @@ import { agentCommand, type AgentDeps } from "./commands/agent.js";
import { rigCommand, type RigDeps } from "./commands/rig.js";
import { transcriptCommand } from "./commands/transcript.js";
import { sendCommand } from "./commands/send.js";
import { streamCommand, type StreamDeps } from "./commands/stream.js";
import { queueCommand, type QueueDeps } from "./commands/queue.js";
import { projectCommand, type ProjectDeps } from "./commands/project.js";
import { viewCommand, type ViewDeps } from "./commands/view.js";
import { watchdogCommand, type WatchdogDeps } from "./commands/watchdog.js";
import { workflowCommand, type WorkflowDeps } from "./commands/workflow.js";
import { captureCommand } from "./commands/capture.js";
import { broadcastCommand } from "./commands/broadcast.js";
import { configCommand } from "./commands/config.js";
import { preflightCommand } from "./commands/preflight.js";
import { doctorCommand } from "./commands/doctor.js";
import { expandCommand } from "./commands/expand.js";
import { envCommand } from "./commands/env.js";
import { askCommand } from "./commands/ask.js";
import { chatroomCommand } from "./commands/chatroom.js";
import { specsCommand } from "./commands/specs.js";
import { contextPackCommand } from "./commands/context-pack.js";
import { agentImageCommand } from "./commands/agent-image.js";
import { workspaceCommand, type WorkspaceDeps } from "./commands/workspace.js";
import { whoamiCommand } from "./commands/whoami.js";
import { unclaimCommand } from "./commands/unclaim.js";
import { releaseCommand } from "./commands/release.js";
import { launchCommand } from "./commands/launch.js";
import { removeCommand } from "./commands/remove.js";
import { shrinkCommand } from "./commands/shrink.js";
import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
import { setupCommand } from "./commands/setup.js";
import { contextCommand } from "./commands/context.js";
import { restoreCheckCommand } from "./commands/restore-check.js";
import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js";
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
import { seatCommand, type SeatDeps } from "./commands/seat.js";
import type { LifecycleDeps } from "./daemon-lifecycle.js";
import { CLI_VERSION } from "./version.js";
@@ -67,18 +85,33 @@ export interface ProgramDeps {
rigDeps?: RigDeps;
transcriptDeps?: StatusDeps;
sendDeps?: StatusDeps;
streamDeps?: StreamDeps;
queueDeps?: QueueDeps;
projectDeps?: ProjectDeps;
viewDeps?: ViewDeps;
watchdogDeps?: WatchdogDeps;
workflowDeps?: WorkflowDeps;
captureDeps?: StatusDeps;
broadcastDeps?: StatusDeps;
askDeps?: StatusDeps;
chatroomDeps?: StatusDeps;
specsDeps?: StatusDeps;
contextPackDeps?: StatusDeps;
agentImageDeps?: StatusDeps;
workspaceDeps?: WorkspaceDeps;
whoamiDeps?: StatusDeps;
expandDeps?: StatusDeps;
envDeps?: StatusDeps;
unclaimDeps?: StatusDeps;
releaseDeps?: StatusDeps;
launchDeps?: StatusDeps;
removeDeps?: StatusDeps;
shrinkDeps?: StatusDeps;
destroyDeps?: DestroyCommandDeps;
restorePacketDeps?: RestorePacketDeps;
compactPlanDeps?: CompactPlanDeps;
heartbeatDeps?: HeartbeatDeps;
seatDeps?: SeatDeps;
configPath?: string;
}
@@ -113,21 +146,39 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(rigCommand(depsOverride?.rigDeps));
program.addCommand(transcriptCommand(depsOverride?.transcriptDeps));
program.addCommand(sendCommand(depsOverride?.sendDeps));
program.addCommand(streamCommand(depsOverride?.streamDeps));
program.addCommand(queueCommand(depsOverride?.queueDeps));
program.addCommand(projectCommand(depsOverride?.projectDeps));
program.addCommand(viewCommand(depsOverride?.viewDeps));
program.addCommand(watchdogCommand(depsOverride?.watchdogDeps));
program.addCommand(workflowCommand(depsOverride?.workflowDeps));
program.addCommand(captureCommand(depsOverride?.captureDeps));
program.addCommand(broadcastCommand(depsOverride?.broadcastDeps));
program.addCommand(askCommand(depsOverride?.askDeps));
program.addCommand(chatroomCommand(depsOverride?.chatroomDeps));
program.addCommand(specsCommand(depsOverride?.specsDeps));
program.addCommand(contextPackCommand(depsOverride?.contextPackDeps));
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
program.addCommand(configCommand(depsOverride?.configPath));
program.addCommand(preflightCommand());
program.addCommand(doctorCommand());
program.addCommand(expandCommand(depsOverride?.expandDeps));
program.addCommand(envCommand(depsOverride?.envDeps));
program.addCommand(unclaimCommand(depsOverride?.unclaimDeps));
program.addCommand(releaseCommand(depsOverride?.releaseDeps));
program.addCommand(launchCommand(depsOverride?.launchDeps));
program.addCommand(removeCommand(depsOverride?.removeDeps));
program.addCommand(shrinkCommand(depsOverride?.shrinkDeps));
program.addCommand(destroyCommand(depsOverride?.destroyDeps));
program.addCommand(setupCommand());
program.addCommand(contextCommand());
program.addCommand(restoreCheckCommand());
program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps));
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
program.addCommand(seatCommand(depsOverride?.seatDeps));
return program;
}
+29 -3
View File
@@ -3,6 +3,9 @@ import { z } from "zod";
import type { DaemonClient, DaemonResponse } from "./client.js";
import { CLI_VERSION } from "./version.js";
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
type TextResult = {
content: Array<{ type: "text"; text: string }>;
isError?: true;
@@ -64,7 +67,11 @@ export function createMcpServer(client: DaemonClient): McpServer {
},
async ({ sourceRef, plan, autoApprove, targetRoot }) => {
try {
const res = await client.post("/api/up", { sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot });
const res = await client.post(
"/api/up",
{ sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot },
plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
);
return mapResult(res);
} catch (err) {
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
@@ -302,10 +309,24 @@ export function createMcpServer(client: DaemonClient): McpServer {
text: z.string().describe("Message text to send"),
verify: z.boolean().optional().describe("Verify delivery by checking pane content"),
force: z.boolean().optional().describe("Send even if target appears mid-task"),
waitForIdleSeconds: z.number().positive().optional().describe("Wait until explicit idle evidence before sending"),
},
async ({ session, text, verify, force }) => {
async ({ session, text, verify, force, waitForIdleSeconds }) => {
try {
const res = await client.post("/api/transport/send", { session, text, verify, force });
if (force && waitForIdleSeconds !== undefined) {
return {
content: [{ type: "text", text: JSON.stringify({ ok: false, error: "waitForIdleSeconds cannot be combined with force" }) }],
isError: true,
};
}
const waitForIdleMs = waitForIdleSeconds === undefined ? undefined : Math.ceil(waitForIdleSeconds * 1000);
const res = await client.post("/api/transport/send", {
session,
text,
verify,
force,
waitForIdleMs,
}, waitForIdleRequestOptions(waitForIdleMs));
return mapResult(res);
} catch (err) {
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
@@ -403,3 +424,8 @@ export function createMcpServer(client: DaemonClient): McpServer {
return server;
}
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
if (waitForIdleMs === undefined) return undefined;
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
}
+11 -7
View File
@@ -2,7 +2,11 @@ import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const warnedKeys = new Set<string>();
export function getOpenRigHome(): string {
const configured = readOpenRigEnv("OPENRIG_HOME", "RIGGED_HOME");
if (configured !== undefined) return configured;
return join(homedir(), ".openrig");
}
@@ -13,22 +17,22 @@ export function getLegacyRiggedHome(): string {
export const OPENRIG_HOME = getOpenRigHome();
export const LEGACY_RIGGED_HOME = getLegacyRiggedHome();
const warnedKeys = new Set<string>();
function warnOnce(key: string, message: string): void {
if (warnedKeys.has(key)) return;
warnedKeys.add(key);
console.warn(message);
}
export function readOpenRigEnv(primary: string, legacy: string): string | undefined {
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
const primaryValue = process.env[primary];
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
const legacyValue = process.env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
if (legacy) {
const legacyValue = process.env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
}
}
return undefined;
@@ -0,0 +1,280 @@
// claude-transcript-parser.ts — parses Claude transcript JSONL into the
// same StructuredTranscript shape as the Codex parser.
//
// NEW work for the Restore-Packet vertical M2b. No prior art. Inspected
// host's Claude transcript files at ~/.claude/projects/<project>/<id>.jsonl.
//
// Claude JSONL semantics (observed):
// - First few lines may carry meta records: { type, customTitle, sessionId }
// or { type, agentName, sessionId } or { type, permissionMode, sessionId }.
// - Message records have type: "user" | "assistant" with fields:
// { type, message, cwd, timestamp, parentUuid, sessionId, ... }.
// `message` is an object whose shape is provider-API-specific:
// - user: { role: "user", content: string | array }
// - assistant: { role: "assistant", content: array of { type: "text", text } }
// - Attachment records ({ type: "attachment", attachment, ... }) carry tool
// input/output content; mapped to raw_tool_outputs by the omitted-records
// classifier and skipped from the message stream.
// - Other types ({ type: "summary" }, sessionId-only meta records, etc.)
// are mapped to reasoning_records.
import { redact, hasSecretPattern } from "./redaction.js";
import { classifyClaudeRecord, OmittedCounter } from "./omitted-records.js";
import type {
ExtractedMessage,
PathCount,
StructuredTranscript,
TypeCounts,
SessionMeta,
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
function extractPaths(text: string, counts: Map<string, number>): void {
for (const pattern of PATH_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const cleaned = match[0].replace(/[),.;\]"'`]+$/g, "");
if (cleaned.length < 6) continue;
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
}
}
}
function firstLine(text: string): string {
const line = text.split(/\r?\n/).find((l) => l.trim());
if (!line) return "";
return line.trim().slice(0, 180);
}
/**
* Result of walking a Claude `message.content` field. Returns:
* - `text`: concatenated user-visible text from `{ type: "text" }` parts.
* - `toolUseCount`: number of `{ type: "tool_use" }` parts seen.
* These are nested function-call records inside an assistant turn;
* per M1 contract § 5 they map to `function_call_output`.
* - `toolResultCount`: number of `{ type: "tool_result" }` parts seen.
* These are nested tool-output records inside a user turn; per
* contract § 5 they map to `raw_tool_outputs`.
* - `toolPaths`: paths extracted from omitted tool_use inputs and
* tool_result content (mirrors `codex-jsonl-parser.ts`'s tool-call
* path-inventory behavior; the tool record itself is NOT in the
* message text but its file references stay in the touched-files
* inventory).
*
* R2 fix (per M2b R2 dispatch qitem-20260502011841-fb53d7fd): inspect
* nested content parts BEFORE discarding non-text. Previously dropped
* tool_use / tool_result silently, leading to misleading all-zero
* omittedCounts on real Claude transcripts where nearly every assistant
* turn has tool_use and most user turns have tool_result.
*/
interface ContentWalkResult {
text: string;
toolUseCount: number;
toolResultCount: number;
toolPaths: string[];
}
/**
* Extract `text` candidates from a tool_result `content` field. The
* Claude API allows two shapes for `tool_result.content`:
* - string (the most common; e.g., a command's stdout): use directly.
* - array of part objects (when the tool returned structured content):
* collect each part's `text` field.
* Returns a flat string suitable for path extraction.
*/
function toolResultText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
parts.push(part);
} else if (part && typeof part === "object") {
const p = part as Record<string, unknown>;
if (p.type === "text" && typeof p.text === "string") {
parts.push(p.text);
}
}
}
return parts.join("\n");
}
function walkClaudeContent(message: unknown): ContentWalkResult {
const out: ContentWalkResult = { text: "", toolUseCount: 0, toolResultCount: 0, toolPaths: [] };
if (!message || typeof message !== "object") return out;
const obj = message as Record<string, unknown>;
const content = obj.content;
if (typeof content === "string") {
out.text = content;
return out;
}
if (!Array.isArray(content)) return out;
const textParts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
textParts.push(part);
continue;
}
if (!part || typeof part !== "object") continue;
const p = part as Record<string, unknown>;
const partType = typeof p.type === "string" ? p.type : "";
if (partType === "text" && typeof p.text === "string") {
textParts.push(p.text);
continue;
}
if (partType === "tool_use") {
out.toolUseCount += 1;
// Extract paths from the tool_use input. Mirrors the Codex parser's
// tool-call path-inventory behavior at codex-jsonl-parser.ts.
const input = p.input;
const inputStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
out.toolPaths.push(inputStr);
continue;
}
if (partType === "tool_result") {
out.toolResultCount += 1;
// tool_result.content can be string OR array; both extract a flat
// text body for path scanning.
const resultText = toolResultText(p.content);
out.toolPaths.push(resultText);
continue;
}
// Other part types (image, etc.) are not user-visible text and not
// counted in the M1 contract enums; skip without counting.
}
out.text = textParts.filter((s) => s.length > 0).join("\n");
return out;
}
interface ClaudeRecord {
type?: unknown;
timestamp?: unknown;
message?: unknown;
cwd?: unknown;
sessionId?: unknown;
parentUuid?: unknown;
attachment?: unknown;
[key: string]: unknown;
}
/**
* Parse a Claude transcript JSONL string into a StructuredTranscript.
* Pure function; no I/O.
*
* Output is the SAME shape as the Codex parser per the M2b dispatch's
* "emit the same structured representation as the Codex parser"
* requirement. Downstream modules (packet-writer, redaction-counter)
* see no runtime difference.
*/
export function parseClaudeTranscript(content: string): StructuredTranscript {
const messages: ExtractedMessage[] = [];
const pathCounts = new Map<string, number>();
const typeCounts: TypeCounts = {};
const omittedCounter = new OmittedCounter();
let sessionMeta: SessionMeta | null = null;
let lineCount = 0;
let messageCount = 0;
// Claude transcripts don't have "compacted" records; field stays at 0.
for (const rawLine of content.split("\n")) {
if (!rawLine.trim()) continue;
lineCount++;
let record: ClaudeRecord;
try {
record = JSON.parse(rawLine) as ClaudeRecord;
} catch {
continue;
}
const recordType = typeof record.type === "string" ? record.type : "";
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
// Capture sessionMeta from the first record that carries cwd + sessionId.
if (
sessionMeta === null &&
typeof record.cwd === "string" &&
typeof record.sessionId === "string"
) {
sessionMeta = {
cwd: record.cwd,
sessionId: record.sessionId,
};
}
const classification = classifyClaudeRecord(record);
if (classification.kind === "omitted") {
omittedCounter.recordOmission(classification.reason);
// For attachment records, walk the attachment payload for paths
// (mirrors the Codex parser's tool-call path-extraction behavior).
if (recordType === "attachment" && record.attachment) {
const attachStr = typeof record.attachment === "string"
? record.attachment
: JSON.stringify(record.attachment);
extractPaths(attachStr, pathCounts);
}
continue;
}
// Kept message.
const role: "user" | "assistant" = recordType === "user" ? "user" : "assistant";
const walked = walkClaudeContent(record.message);
// R2 fix: count nested tool_use / tool_result parts BEFORE deciding
// whether to emit a visible-text message. These counters fire
// regardless of whether the record contributes a message to the
// visible transcript — capturing them here is what M1 contract § 5
// requires for an honest `omitted_classes` field downstream.
for (let i = 0; i < walked.toolUseCount; i++) {
omittedCounter.recordOmission("function_call_output");
}
for (let i = 0; i < walked.toolResultCount; i++) {
omittedCounter.recordOmission("raw_tool_outputs");
}
// Path extraction from omitted tool parts (mirrors Codex parser
// behavior of walking tool-call args / inputs for path inventory
// even though the records themselves are omitted from messages).
for (const toolText of walked.toolPaths) {
extractPaths(toolText, pathCounts);
}
if (hasSecretPattern(walked.text)) {
omittedCounter.recordRedaction();
}
const text = redact(walked.text).trim();
if (!text) continue;
messageCount++;
extractPaths(text, pathCounts);
messages.push({
timestamp: typeof record.timestamp === "string" ? record.timestamp : null,
role,
text,
preview: firstLine(text),
});
}
const paths: PathCount[] = [...pathCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 200)
.map(([path, count]) => ({ path, count }));
return {
sessionMeta,
lineCount,
messageCount,
compactedCount: 0,
typeCounts,
omittedCounts: omittedCounter.counts,
messages,
paths,
};
}
@@ -0,0 +1,186 @@
// codex-jsonl-parser.ts — parses Codex JSONL into a StructuredTranscript.
//
// Translates the Velocity prior art parser at
// field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/
// codex-jsonl-to-restore-packet.mjs:81-160 from JS to TypeScript.
// Velocity .mjs is FROZEN reference; this module borrows the structural
// shape only.
//
// Codex JSONL semantics (from Velocity prior art):
// - record.type === "session_meta" → session metadata (cwd, etc.)
// - record.type === "compacted" → compaction marker (counter only)
// - record.type === "response_item" + payload.type:
// - "function_call" → omitted (function_call_output class); extract paths from args
// - "custom_tool_call" → omitted (raw_tool_outputs class); extract paths from input
// - "reasoning" → omitted (reasoning_records class)
// - "message" → kept (after redaction); extract paths from content
//
// Output is a StructuredTranscript (per types.ts) consumed by the
// packet-writer (M2c).
import { redact, hasSecretPattern } from "./redaction.js";
import { classifyCodexRecord, OmittedCounter } from "./omitted-records.js";
import type {
ExtractedMessage,
PathCount,
StructuredTranscript,
TypeCounts,
SessionMeta,
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
function textFromContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const obj = part as Record<string, unknown>;
const val = obj.text ?? obj.content ?? obj.output ?? obj.input ?? "";
return typeof val === "string" ? val : "";
})
.filter((s) => s.length > 0)
.join("\n");
}
function extractPaths(text: string, counts: Map<string, number>): void {
for (const pattern of PATH_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const cleaned = match[0].replace(/[),.;\]"'`]+$/g, "");
if (cleaned.length < 6) continue;
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
}
}
}
function firstLine(text: string): string {
const line = text.split(/\r?\n/).find((l) => l.trim());
if (!line) return "";
return line.trim().slice(0, 180);
}
interface CodexRecord {
type?: unknown;
timestamp?: unknown;
payload?: {
type?: unknown;
role?: unknown;
content?: unknown;
arguments?: unknown;
input?: unknown;
cwd?: unknown;
[key: string]: unknown;
};
}
/**
* Parse a Codex JSONL string (the full file content as a single string)
* into a StructuredTranscript. Pure function; no I/O.
*
* Malformed lines are skipped silently (matches Velocity prior art
* `:100-102`); the lineCount counts every non-empty input line, so the
* count of skipped lines is `lineCount - <kept-or-typed records>`.
*/
export function parseCodexJsonl(content: string): StructuredTranscript {
const messages: ExtractedMessage[] = [];
const pathCounts = new Map<string, number>();
const typeCounts: TypeCounts = {};
const omittedCounter = new OmittedCounter();
let sessionMeta: SessionMeta | null = null;
let lineCount = 0;
let messageCount = 0;
let compactedCount = 0;
for (const rawLine of content.split("\n")) {
if (!rawLine.trim()) continue;
lineCount++;
let record: CodexRecord;
try {
record = JSON.parse(rawLine) as CodexRecord;
} catch {
continue;
}
const recordType = typeof record.type === "string" ? record.type : "";
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
if (recordType === "session_meta") {
const payload = (record.payload ?? null) as Record<string, unknown> | null;
sessionMeta = {
cwd: typeof payload?.cwd === "string" ? payload.cwd : null,
sessionId: typeof payload?.id === "string" ? payload.id : null,
raw: payload ?? undefined,
};
continue;
}
if (recordType === "compacted") {
compactedCount++;
continue;
}
if (recordType !== "response_item") continue;
const classification = classifyCodexRecord(record);
if (classification.kind === "omitted") {
omittedCounter.recordOmission(classification.reason);
// Still extract paths from tool calls (matches Velocity behavior at
// :119-129 where args/input are walked for paths even though the
// record itself is omitted from messages).
const payload = record.payload;
if (payload?.type === "function_call") {
const args = typeof payload.arguments === "string" ? payload.arguments : JSON.stringify(payload.arguments ?? {});
extractPaths(args, pathCounts);
} else if (payload?.type === "custom_tool_call") {
const input = typeof payload.input === "string" ? payload.input : JSON.stringify(payload.input ?? {});
extractPaths(input, pathCounts);
}
continue;
}
// Kept message.
const role = record.payload?.role;
const roleStr = typeof role === "string" ? role : "unknown";
if (roleStr !== "developer" && roleStr !== "user" && roleStr !== "assistant") {
continue;
}
const rawText = textFromContent(record.payload?.content);
if (hasSecretPattern(rawText)) {
omittedCounter.recordRedaction();
}
const text = redact(rawText).trim();
if (!text) continue;
messageCount++;
extractPaths(text, pathCounts);
messages.push({
timestamp: typeof record.timestamp === "string" ? record.timestamp : null,
role: roleStr,
text,
preview: firstLine(text),
});
}
const paths: PathCount[] = [...pathCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 200)
.map(([path, count]) => ({ path, count }));
return {
sessionMeta,
lineCount,
messageCount,
compactedCount,
typeCounts,
omittedCounts: omittedCounter.counts,
messages,
paths,
};
}
@@ -0,0 +1,128 @@
// omitted-records.ts — classifies a parsed source record into one of the
// 4 omitted-class enums (per M1 contract § 5) or accepts it as a kept
// user-visible message.
//
// v0 generator excludes the following record classes from
// transcript-latest.md and transcript.md:
//
// - reasoning_records: internal reasoning records that the runtime emitted
// but were not user-visible messages.
// - raw_tool_outputs: full raw tool execution output (often verbose; may
// contain transient state).
// - function_call_output: function-call result records that are not
// user-visible.
// - redacted_secrets: applied to any matched credential patterns per the
// redaction policy. (This class counts records whose content matched
// a secret pattern AND was therefore redacted.)
//
// `omitted_classes` in the summary enumerates the classes ACTUALLY
// excluded for THIS packet (not all available classes); the validator
// checks each name is one of the 4 enum values.
import { emptyOmittedCounts, type OmittedCounts, type OmittedRecordClass } from "./types.js";
import { hasSecretPattern } from "./redaction.js";
/** Source-runtime-agnostic record classifier output. */
export type ClassifyResult =
| { kind: "kept" }
| { kind: "omitted"; reason: OmittedRecordClass };
/**
* Classify a Codex JSONL record (the parsed JSON object) into kept /
* omitted. Caller is responsible for passing only `response_item`-class
* records; session_meta and compacted are handled separately by the
* Codex parser.
*
* Codex semantics (per Velocity prior art):
* - response_item with payload.type === "function_call" → function_call_output
* - response_item with payload.type === "custom_tool_call" → raw_tool_outputs
* - response_item with payload.type === "reasoning" → reasoning_records
* - response_item with payload.type === "message" + role in {developer,user,assistant}
* → kept (after secret-pattern check)
* - everything else → reasoning_records (catch-all for non-message non-tool
* payload types)
*/
export function classifyCodexRecord(record: { payload?: { type?: unknown; role?: unknown } }): ClassifyResult {
const payloadType = typeof record.payload?.type === "string" ? record.payload.type : "";
switch (payloadType) {
case "function_call":
return { kind: "omitted", reason: "function_call_output" };
case "custom_tool_call":
return { kind: "omitted", reason: "raw_tool_outputs" };
case "reasoning":
return { kind: "omitted", reason: "reasoning_records" };
case "message": {
const role = typeof record.payload?.role === "string" ? record.payload.role : "";
if (role !== "developer" && role !== "user" && role !== "assistant") {
return { kind: "omitted", reason: "reasoning_records" };
}
return { kind: "kept" };
}
default:
return { kind: "omitted", reason: "reasoning_records" };
}
}
/**
* Classify a Claude transcript record. Claude shape carries the role
* directly at `record.type` (user / assistant / system / attachment /
* summary etc). Per the M2b dispatch's "emit the same structured
* representation as the Codex parser" requirement, the omitted-class
* mapping mirrors Codex.
*/
export function classifyClaudeRecord(record: { type?: unknown }): ClassifyResult {
const type = typeof record.type === "string" ? record.type : "";
switch (type) {
case "user":
case "assistant":
return { kind: "kept" };
case "attachment":
// Attachments are tool-result-class records (file content the user
// attached or tool output). Map to raw_tool_outputs.
return { kind: "omitted", reason: "raw_tool_outputs" };
case "summary":
// System/summary records are non-user-visible meta; map to
// reasoning_records.
return { kind: "omitted", reason: "reasoning_records" };
default:
// Everything else (sessionId-only, agentInfo, permissionMode meta,
// etc.) is metadata, not a user-visible message.
return { kind: "omitted", reason: "reasoning_records" };
}
}
/** State holder for accumulating omitted-class counts during parsing. */
export class OmittedCounter {
readonly counts: OmittedCounts = emptyOmittedCounts();
recordOmission(reason: OmittedRecordClass): void {
this.counts[reason] += 1;
}
/**
* Mark that a kept message had secret content (was redacted). Increments
* `redacted_secrets` per occurrence. Caller checks `hasSecretPattern`
* BEFORE redaction to decide whether to record this.
*/
recordRedaction(): void {
this.counts.redacted_secrets += 1;
}
/**
* Returns the list of omitted classes that have at least one record.
* Used to populate `summary.omitted_classes` per contract § 5.
* Order: stable (the enum order from the type definition).
*/
activeClasses(): OmittedRecordClass[] {
const order: OmittedRecordClass[] = [
"reasoning_records",
"raw_tool_outputs",
"function_call_output",
"redacted_secrets",
];
return order.filter((cls) => this.counts[cls] > 0);
}
}
/** Re-export for convenience. */
export { hasSecretPattern };
@@ -0,0 +1,316 @@
// packet-writer.ts — assembles + atomically writes a v0 restore packet
// directory.
//
// Per M1 contract § 1: emits 4 required files (restore-instructions.md,
// transcript-latest.md, touched-files.md, restore-summary.json) plus
// optional transcript.md (when full transcript material is available;
// presence in lockstep with `full_transcript` summary key).
//
// Atomic emission via tempdir-then-rename:
// 1. Write all files into a sibling tempdir (`<targetDir>.tmp-<rand>/`).
// 2. Validate the tempdir's restore-summary.json against the embedded
// JSON Schema (RESTORE_SUMMARY_SCHEMA from schema-validator.ts).
// 3. If valid: rename tempdir → targetDir (single fs.rename; atomic on
// same filesystem).
// 4. If validation fails OR any write step fails: rmSync the tempdir;
// propagate the error. The targetDir is NEVER created in this case.
//
// Pre-condition: targetDir MUST NOT exist (operator must supply a fresh
// path). Reject with explicit error otherwise.
import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync, mkdtempSync } from "node:fs";
import { dirname, basename, join } from "node:path";
import {
validateRestoreSummary,
type ValidationResult,
} from "./schema-validator.js";
import type { StructuredTranscript, SourceRuntime } from "./types.js";
const TRANSCRIPT_BOUND = 120;
export interface WritePacketOptions {
/** Absolute path of the target packet directory. MUST NOT exist. */
targetDir: string;
/** Parsed source structured representation. */
structured: StructuredTranscript;
/** Runtime kind of the source seat. */
sourceRuntime: SourceRuntime;
/** Target rig name (operator-supplied). */
targetRig: string;
/** Target runtime kind (operator-supplied). */
targetRuntime: SourceRuntime;
/** Target workspace root (absolute path). */
targetWorkspaceRoot: string;
/** Default target repo (absolute path or null). */
defaultTargetRepo: string | null;
/** Path/URI to role guidance for the restored seat. */
rolePointer: string;
/** One-paragraph human-readable summary of current work. */
currentWorkSummary: string;
/** Who the restored seat hands off to next; "self" if continuing. */
nextOwner: string;
/** Caveats list (cross-runtime quirks, redaction notes, etc.). */
caveats: string[];
/** Explicit statement of restored-seat authority. */
authorityBoundaries: string;
/** Ordered source-trust ranking (subset of contract enum). */
sourceTrustRanking: string[];
/** Source session id. */
sourceSessionId: string;
/**
* Source rig name. Per M2c-CLI R2: passed explicitly by the CLI adapter
* (computed from parsed session_meta `<seat>@<rig>` split or operator
* --source-rig-override). The writer does NOT derive this from
* sourceSessionId — that hidden derivation was the M2c-CLI R1 bug.
*/
sourceRig: string;
/** Source cwd (absolute path). */
sourceCwd: string;
/** Generator version identifier (e.g., "[email protected]"). */
generatorVersion: string;
/** Whether to include the full transcript.md file. */
includeFullTranscript: boolean;
}
export interface WritePacketResult {
targetDir: string;
files: string[];
summaryPath: string;
}
function transcriptMarkdownFromMessages(
title: string,
session: string,
messages: StructuredTranscript["messages"],
note: string,
): string {
const body = messages
.map((m, i) => {
const ts = m.timestamp ? ` ${m.timestamp}` : "";
return `## ${i + 1}. ${m.role}${ts}\n\n${m.text}\n`;
})
.join("\n");
return `# ${title}\n\nSource session: \`${session}\`\n\n${note}\n\n${body}`;
}
function buildTranscriptLatest(
structured: StructuredTranscript,
sourceSessionId: string,
): string {
const latest = structured.messages.slice(-TRANSCRIPT_BOUND);
return transcriptMarkdownFromMessages(
"Bounded Latest Transcript",
sourceSessionId,
latest,
`This bounded transcript contains the latest ${latest.length} extracted messages for normal restore. Use transcript.md only when deeper forensic recovery is needed.`,
);
}
function buildTranscriptFull(
structured: StructuredTranscript,
sourceSessionId: string,
): string {
return transcriptMarkdownFromMessages(
"Full Transcript",
sourceSessionId,
structured.messages,
"This transcript contains developer/user/assistant messages extracted from the source. Reasoning records, tool calls, and tool outputs are omitted by design (see restore-summary.json `omitted_classes`).",
);
}
function buildTouchedFiles(structured: StructuredTranscript): string {
const rows = structured.paths.length > 0
? structured.paths.map((entry) => `- \`${entry.path}\` (${entry.count})`).join("\n")
: "- No paths detected in extracted messages or tool arguments.";
return `# Touched Files And Path Inventory\n\nBest-effort path inventory from messages and tool arguments. This is a triage aid, not a complete proof of all files read or written.\n\n${rows}\n`;
}
function buildRestoreInstructions(opts: WritePacketOptions): string {
return `# Restore Instructions
You are a restored seat in \`${opts.targetRig}\`. This packet records the cross-runtime context for resuming work from \`${opts.sourceSessionId}\`.
## Identity
1. Run \`rig whoami --json\`.
2. Trust the current rig identity over the source-session transcript.
3. Current workspace root: \`${opts.targetWorkspaceRoot}\`.
4. Default target repo: \`${opts.defaultTargetRepo ?? "(none)"}\`.
5. Role guidance pointer: \`${opts.rolePointer}\`.
## Restore Steps
1. Read this file.
2. Read \`touched-files.md\`.
3. Read \`transcript-latest.md\` for bounded current context.
4. Use \`transcript.md\` only if the bounded transcript is insufficient.
5. State: "restored from packet generated by ${opts.generatorVersion}; current rig is ${opts.targetRig}; default target repo is ${opts.defaultTargetRepo ?? "none"}."
6. Name any caveats before resuming work.
7. Hand off to next owner: \`${opts.nextOwner}\` (or "self" to continue).
## Current Work
${opts.currentWorkSummary}
## Authority Boundaries
${opts.authorityBoundaries}
## Caveats
${opts.caveats.length > 0 ? opts.caveats.map((c) => `- ${c}`).join("\n") : "- (none recorded)"}
## Packet Stats
- Source runtime: \`${opts.sourceRuntime}\`
- Target runtime: \`${opts.targetRuntime}\`
- JSONL/transcript lines processed: ${opts.structured.lineCount}
- Extracted messages: ${opts.structured.messageCount}
- Compaction records: ${opts.structured.compactedCount}
- Source cwd: \`${opts.sourceCwd}\`
`;
}
function buildSummary(opts: WritePacketOptions): Record<string, unknown> {
const omittedClassesActive: string[] = [];
for (const k of ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"] as const) {
if (opts.structured.omittedCounts[k] > 0) omittedClassesActive.push(k);
}
const messageCount = Math.min(opts.structured.messages.length, TRANSCRIPT_BOUND);
const summary: Record<string, unknown> = {
source_session_id: opts.sourceSessionId,
source_rig: opts.sourceRig,
source_runtime: opts.sourceRuntime,
source_cwd: opts.sourceCwd,
target_rig: opts.targetRig,
target_runtime: opts.targetRuntime,
target_workspace_root: opts.targetWorkspaceRoot,
default_target_repo: opts.defaultTargetRepo,
role_pointer: opts.rolePointer,
bounded_latest_transcript: {
path: "transcript-latest.md",
message_count: messageCount,
bound: TRANSCRIPT_BOUND,
},
touched_files: {
path: "touched-files.md",
top_paths: opts.structured.paths.slice(0, 30),
},
durable_pointers: {
queue_pointers: [],
progress_pointers: [],
field_note_pointers: [],
artifact_pointers: [],
},
current_work_summary: opts.currentWorkSummary,
next_owner: opts.nextOwner,
caveats: opts.caveats,
authority_boundaries: opts.authorityBoundaries,
omitted_classes: omittedClassesActive,
redaction_policy_id: "openrig-v0",
source_trust_ranking: opts.sourceTrustRanking,
generator_version: opts.generatorVersion,
generated_at: new Date().toISOString(),
};
if (opts.includeFullTranscript) {
summary.full_transcript = {
path: "transcript.md",
line_count: opts.structured.messages.length,
};
}
return summary;
}
/**
* Write a v0 restore packet directory atomically.
*
* Atomic guarantee: if any step fails (including schema validation of
* restore-summary.json), the targetDir is never created and any
* intermediate tempdir is removed. Operators see either a complete
* valid packet at targetDir, or no packet at all.
*/
export async function writePacket(opts: WritePacketOptions): Promise<WritePacketResult> {
if (existsSync(opts.targetDir)) {
throw new Error(`restore-packet write: target directory already exists: ${opts.targetDir}`);
}
// Build all file contents BEFORE creating any disk state. This way if
// an in-memory step throws (unlikely, but defensive), no tempdir was
// created.
const summary = buildSummary(opts);
const validation: ValidationResult = validateRestoreSummary(summary);
if (!validation.valid) {
const errSummary = validation.errors
.slice(0, 5)
.map((e) => `${e.field}: ${e.rule}`)
.join("; ");
throw new Error(
`restore-packet write: schema validation failed before atomic rename: ${errSummary}`,
);
}
const restoreInstructions = buildRestoreInstructions(opts);
const transcriptLatest = buildTranscriptLatest(opts.structured, opts.sourceSessionId);
const touchedFiles = buildTouchedFiles(opts.structured);
const transcriptFull = opts.includeFullTranscript
? buildTranscriptFull(opts.structured, opts.sourceSessionId)
: null;
// Create tempdir as a sibling of targetDir using mkdtemp for race-free
// unique name. The dirname() must exist and be writable; we don't
// mkdir the parent for the operator (operator authority bedrock).
const parentDir = dirname(opts.targetDir);
const tempPrefix = `${basename(opts.targetDir)}.tmp-restore-packet-`;
const tempDir = mkdtempSync(join(parentDir, tempPrefix));
try {
writeFileSync(join(tempDir, "restore-instructions.md"), restoreInstructions, "utf-8");
writeFileSync(join(tempDir, "transcript-latest.md"), transcriptLatest, "utf-8");
writeFileSync(join(tempDir, "touched-files.md"), touchedFiles, "utf-8");
writeFileSync(join(tempDir, "restore-summary.json"), JSON.stringify(summary, null, 2), "utf-8");
if (transcriptFull !== null) {
writeFileSync(join(tempDir, "transcript.md"), transcriptFull, "utf-8");
}
// Re-validate by parsing the on-disk summary (round-trip self-check).
// This catches any structural divergence between buildSummary's
// in-memory shape and the JSON.stringify() serialization.
const onDisk = JSON.parse(
// eslint-disable-next-line @typescript-eslint/no-require-imports
(await import("node:fs")).readFileSync(join(tempDir, "restore-summary.json"), "utf-8"),
);
const recheck = validateRestoreSummary(onDisk);
if (!recheck.valid) {
throw new Error(
`restore-packet write: on-disk schema recheck failed: ${recheck.errors.slice(0, 5).map((e) => `${e.field}: ${e.rule}`).join("; ")}`,
);
}
// Atomic rename tempdir → targetDir. fs.rename is atomic when source
// and destination are on the same filesystem; we keep them sibling
// so this holds.
renameSync(tempDir, opts.targetDir);
} catch (err) {
// Clean up tempdir on any failure; do NOT leave a partial packet.
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; original error takes precedence.
}
throw err;
}
const files = [
"restore-instructions.md",
"transcript-latest.md",
"touched-files.md",
"restore-summary.json",
];
if (transcriptFull !== null) files.push("transcript.md");
return {
targetDir: opts.targetDir,
files,
summaryPath: join(opts.targetDir, "restore-summary.json"),
};
}
@@ -0,0 +1,72 @@
// redaction.ts — applies the v0 redaction policy.
//
// Per M1 contract § 4: v0 defines two named policies:
// - `velocity-v1`: mirrors the Velocity generator's token patterns at
// field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/
// codex-jsonl-to-restore-packet.mjs:34-48 (sk-*, gh[pousr]_*,
// github_pat_*, Bearer *, base64 ≥40 chars).
// - `openrig-v0`: the productized v0 policy, identical patterns to
// `velocity-v1` but with the canonical name. v0 generator emits
// `redaction_policy_id: "openrig-v0"`.
//
// Redaction is applied to:
// - transcript content in transcript-latest.md and transcript.md.
// - body content in restore-instructions.md if it quotes transcript
// material.
// - top_paths / touched-files entries are NOT redacted (paths are not
// credential material).
//
// The validator does NOT scan content for residual credential patterns
// (per § 4 + § 8: enforcement is generator-side at write time).
/**
* v0 secret-pattern list. Mirrors Velocity prior art `:34-48` exactly:
*
* 1. /\bsk-[A-Za-z0-9_-]{16,}\b/g — OpenAI-style secret tokens.
* 2. /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g — GitHub personal/OAuth tokens.
* 3. /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g — GitHub fine-grained PATs.
* 4. /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/g — generic Bearer tokens.
* 5. /\b[A-Za-z0-9+/]{40,}={0,2}\b/g — base64-shaped strings ≥40 chars.
*/
export const SECRET_PATTERNS: readonly RegExp[] = [
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/g,
/\b[A-Za-z0-9+/]{40,}={0,2}\b/g,
];
/**
* Apply the openrig-v0 redaction policy to a string. Returns the input
* with each match of the secret patterns replaced by the literal
* string `[REDACTED]`. Pure function; no side effects.
*
* The redaction is non-destructive at the structural level — only the
* matched substrings are replaced. Surrounding characters are kept.
*/
export function redact(text: string): string {
let out = text;
for (const pattern of SECRET_PATTERNS) {
out = out.replace(pattern, "[REDACTED]");
}
return out;
}
/**
* Returns true if at least one secret pattern matches the input.
* Used by the omitted-records counter to track how many records had
* their content redacted (for the `redacted_secrets` omitted-class
* count in summary).
*
* Note: a SINGLE record may match multiple patterns; this returns true
* after the first match for efficiency.
*/
export function hasSecretPattern(text: string): boolean {
for (const pattern of SECRET_PATTERNS) {
// Non-global test: cannot reuse the global `g` regex without resetting
// lastIndex, so build a fresh non-global RegExp from source.
const probe = new RegExp(pattern.source);
if (probe.test(text)) return true;
}
return false;
}
@@ -0,0 +1,87 @@
// runtime-detect.ts — auto-detect the source runtime from a JSONL/transcript
// shape.
//
// Per M1 contract § 3.4: source-runtime resolution for `--source-jsonl` is
// auto-detected from the JSONL shape (Codex JSONL has `payload.type`
// markers; Claude transcript has different shape). If auto-detect is
// ambiguous, fail with clear error and prompt for `--source-runtime`
// flag.
//
// Detection is by content shape, NOT file extension or filename
// (per dispatch's "Do NOT use file extensions or filenames as the
// detection key. Detect by content shape.").
//
// Codex shape: each line is JSON with `type` field; the discriminating
// markers are `type: "session_meta"`, `type: "response_item"`, or
// `type: "compacted"`. response_item records carry a `payload` object
// with a `type` sub-field.
//
// Claude shape: each line is JSON with `type` field; values include
// `user`, `assistant`, `system`, `attachment`, `summary`, etc. NO
// `payload` wrapper; the message content is directly under `message`
// or `attachment`.
import type { SourceRuntime } from "./types.js";
/**
* Detect the source runtime from a sample of JSONL lines.
*
* Returns:
* - "codex" — at least one line has `type: "response_item"` OR
* `type: "session_meta"` OR `type: "compacted"`.
* - "claude-code" — at least one line has `type: "user"` or
* `type: "assistant"` AND no Codex markers seen.
* - null — input is empty / non-JSONL / ambiguous (e.g., neither
* marker class appears).
*
* Detection is non-greedy: walks the input lines until a definitive
* marker is found. Bias toward Codex markers if both shape classes
* appear (extremely unlikely in practice; would indicate a malformed
* mixed source).
*/
export function detectRuntime(content: string): SourceRuntime | null {
if (typeof content !== "string" || content.length === 0) return null;
let sawCodexMarker = false;
let sawClaudeMarker = false;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
let record: { type?: unknown; payload?: { type?: unknown } };
try {
record = JSON.parse(line);
} catch {
// Skip malformed JSON lines; they don't affect detection.
continue;
}
const recordType = typeof record.type === "string" ? record.type : "";
// Codex discriminators: type values + payload wrapper presence.
if (
recordType === "response_item" ||
recordType === "session_meta" ||
recordType === "compacted"
) {
sawCodexMarker = true;
// Early-return on the strongest Codex marker; ambiguity bias
// toward Codex if Claude markers also appear is intentional
// (Codex's response_item is structurally distinct from any
// Claude record; if it's present, the content is Codex JSONL).
return "codex";
}
// Claude discriminators: top-level type as a user/assistant marker.
if (recordType === "user" || recordType === "assistant") {
sawClaudeMarker = true;
// Don't early-return on Claude alone — keep looking for any
// Codex marker (defensive against the unlikely mixed-shape case).
}
}
if (sawCodexMarker) return "codex";
if (sawClaudeMarker) return "claude-code";
return null;
}
@@ -0,0 +1,260 @@
// schema-validator.ts — validates restore-summary.json against the v0 JSON
// Schema (packages/cli/src/schemas/restore-summary.schema.json).
//
// Per M1 contract § 8 + IMPL § M2 line 167:
// - generator-side: emit-time self-check against the schema before atomic
// rename of the packet directory.
// - operator-side: M3 `rig restore-packet validate <packet-dir>` runs this
// validator and surfaces per-field violations.
//
// Validator returns a ValidationResult with `valid` and a per-field error
// list. Each error names: field path, value (truncated/escaped), rule, and
// severity. Required-field violations are severity `error`; optional-field
// malformations are severity `warning` per § 8.
//
// M2a R2 packaging fix: the schema is embedded directly in this module as
// a typed TS const (RESTORE_SUMMARY_SCHEMA) rather than read from the
// sibling .json file at runtime. tsc emits the const into the compiled
// validator JS, so the validator works after `tsc` emit without any extra
// build-script copy step. The canonical JSON Schema file at
// `packages/cli/src/schemas/restore-summary.schema.json` remains the
// source of truth for downstream IDE / tooling consumption; a drift-catcher
// test in `test/restore-packet.test.ts` asserts the two stay byte-equivalent.
import Ajv from "ajv";
import addFormats from "ajv-formats";
export interface ValidationError {
field: string;
value: string;
rule: string;
severity: "error" | "warning";
}
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
// RESTORE_SUMMARY_SCHEMA — verbatim mirror of
// `packages/cli/src/schemas/restore-summary.schema.json`. Keep these in
// lockstep; the drift-catcher test enforces equivalence.
export const RESTORE_SUMMARY_SCHEMA: Record<string, unknown> = {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://openrig.dev/schemas/restore-summary.schema.json",
title: "Restore Packet Summary",
description:
"Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
type: "object",
required: [
"source_session_id",
"source_rig",
"source_runtime",
"source_cwd",
"target_rig",
"target_runtime",
"target_workspace_root",
"default_target_repo",
"role_pointer",
"bounded_latest_transcript",
"touched_files",
"durable_pointers",
"current_work_summary",
"next_owner",
"caveats",
"authority_boundaries",
"omitted_classes",
"redaction_policy_id",
"source_trust_ranking",
"generator_version",
"generated_at",
],
properties: {
source_session_id: { type: "string", minLength: 1 },
source_rig: { type: "string", minLength: 1 },
source_runtime: {
type: "string",
enum: ["claude-code", "codex", "terminal", "external"],
},
source_cwd: { type: "string", pattern: "^/" },
target_rig: { type: "string", minLength: 1 },
target_runtime: {
type: "string",
enum: ["claude-code", "codex", "terminal", "external"],
},
target_workspace_root: { type: "string", pattern: "^/" },
default_target_repo: { type: ["string", "null"] },
role_pointer: {
type: "string",
minLength: 1,
description:
"Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time.",
},
bounded_latest_transcript: {
type: "object",
required: ["path", "message_count", "bound"],
properties: {
path: { type: "string" },
message_count: {
type: "integer",
minimum: 0,
description: "Actual count of extracted messages in transcript-latest.md.",
},
bound: {
type: "integer",
const: 120,
description:
"v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass.",
},
},
},
full_transcript: {
type: "object",
properties: {
path: { type: "string" },
line_count: { type: "integer", minimum: 0 },
},
description:
"Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise.",
},
touched_files: {
type: "object",
required: ["path", "top_paths"],
properties: {
path: { type: "string" },
top_paths: {
type: "array",
items: {
type: "object",
required: ["path", "count"],
properties: {
path: { type: "string" },
count: { type: "integer", minimum: 0 },
},
},
},
},
},
durable_pointers: {
type: "object",
required: [
"queue_pointers",
"progress_pointers",
"field_note_pointers",
"artifact_pointers",
],
properties: {
queue_pointers: {
type: "array",
items: { type: "string" },
description: "References to durable queue items (qitem ids or queue-file paths).",
},
progress_pointers: {
type: "array",
items: { type: "string" },
description: "References to PROGRESS.md cursors the source seat was operating against.",
},
field_note_pointers: {
type: "array",
items: { type: "string" },
description: "References to field-notes folders or files.",
},
artifact_pointers: {
type: "array",
items: { type: "string" },
description: "Other durable artifacts (proof packets, slice packets, candidate dossiers).",
},
},
description:
"REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context.",
},
current_work_summary: { type: "string", minLength: 1 },
next_owner: { type: "string", minLength: 1 },
caveats: { type: "array", items: { type: "string" } },
authority_boundaries: { type: "string", minLength: 1 },
omitted_classes: {
type: "array",
items: {
type: "string",
enum: ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"],
},
},
redaction_policy_id: { type: "string", enum: ["velocity-v1", "openrig-v0"] },
source_trust_ranking: {
type: "array",
items: {
type: "string",
enum: [
"rig_whoami",
"target_rigspec",
"bounded_latest_transcript",
"full_transcript",
"touched_files",
"restore_summary",
],
},
minItems: 1,
},
generator_version: { type: "string", minLength: 1 },
generated_at: { type: "string", format: "date-time" },
},
additionalProperties: false,
};
let cachedValidator: ReturnType<Ajv["compile"]> | null = null;
function getValidator(): ReturnType<Ajv["compile"]> {
if (cachedValidator) return cachedValidator;
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
cachedValidator = ajv.compile(RESTORE_SUMMARY_SCHEMA);
return cachedValidator;
}
function truncateValue(value: unknown): string {
if (value === undefined) return "<undefined>";
if (value === null) return "null";
let s: string;
try {
s = typeof value === "string" ? value : JSON.stringify(value);
} catch {
s = String(value);
}
if (s.length > 80) return s.slice(0, 77) + "...";
return s;
}
export function validateRestoreSummary(summary: unknown): ValidationResult {
const validator = getValidator();
const ok = validator(summary);
if (ok) {
return { valid: true, errors: [] };
}
const errors: ValidationError[] = (validator.errors ?? []).map((err) => {
const instancePath = err.instancePath || "";
const missing = err.params && (err.params as { missingProperty?: string }).missingProperty;
const additional = err.params && (err.params as { additionalProperty?: string }).additionalProperty;
let field = instancePath.replace(/^\//, "").replace(/\//g, ".");
if (missing) field = field ? `${field}.${missing}` : missing;
if (additional) field = additional;
// Per M1 contract § 8 + IMPL § M3: optional-field malformations are
// severity "warning"; required-field violations are severity "error".
// The only top-level optional field in v0 is `full_transcript`.
// Errors whose path begins at `/full_transcript` come from validating
// the optional object's contents, not from a missing-required check
// on a required field.
const isOptionalFieldError = instancePath.startsWith("/full_transcript");
return {
field: field || "<root>",
value: truncateValue(err.data),
rule: `${err.keyword}: ${err.message ?? ""}`.trim(),
severity: isOptionalFieldError ? "warning" : "error",
};
});
// `valid` is false on ANY ajv violation (preserves M2c writer
// semantics: any malformation should block atomic rename). The M3
// validate command consults `errors[].severity` to decide exit code
// per M1 contract § 8 (required-field-class errors → exit nonzero;
// optional-field warnings → exit 0 with warning text).
return { valid: false, errors };
}
+101
View File
@@ -0,0 +1,101 @@
// types.ts — shared TypeScript types for the restore-packet generator.
//
// Both runtime adapters (codex-jsonl-parser, claude-transcript-parser)
// emit the same StructuredTranscript shape. Downstream modules
// (redaction, omitted-records, packet-writer) consume this shape
// without caring about the source runtime.
/** A single user-visible message extracted from a transcript. */
export interface ExtractedMessage {
/** ISO-8601 timestamp if the source provides one; else null. */
timestamp: string | null;
/** Speaker role. Restricted to user-visible roles (developer/user/assistant). */
role: "developer" | "user" | "assistant";
/** Redacted message text. */
text: string;
/** First non-empty line of the text, truncated to ~180 chars. Used in summaries. */
preview: string;
}
/** Canonical record-class enum per M1 contract § 5. */
export type OmittedRecordClass =
| "reasoning_records"
| "raw_tool_outputs"
| "function_call_output"
| "redacted_secrets";
/**
* A path frequency entry from path extraction. Sorted by count
* descending then path ascending; used to populate `touched_files.top_paths`.
*/
export interface PathCount {
path: string;
count: number;
}
/**
* Per-runtime per-record-type counter. Flexible string keys because
* Codex JSONL uses `record.type` strings and Claude transcript uses
* `record.type` strings; both are runtime-defined.
*/
export type TypeCounts = Record<string, number>;
/**
* Per-omitted-class counter. Records how many records were filtered
* for each of the 4 contract enums during parsing/redaction.
*/
export type OmittedCounts = Record<OmittedRecordClass, number>;
/**
* Optional session metadata extracted from the source. Codex emits a
* `session_meta` record at the top of a JSONL; Claude transcripts
* carry per-record `cwd` etc.
*/
export interface SessionMeta {
cwd: string | null;
sessionId: string | null;
/** Free-form additional fields the runtime emitted; not load-bearing. */
raw?: Record<string, unknown>;
}
/**
* Structured representation produced by both parsers. The packet-writer
* (M2c) consumes this to assemble a v0 restore packet.
*/
export interface StructuredTranscript {
/** Top-level session metadata (cwd, sessionId). */
sessionMeta: SessionMeta | null;
/** Total source lines processed (raw JSONL line count, including malformed/skipped). */
lineCount: number;
/** Number of user-visible messages extracted into `messages`. */
messageCount: number;
/**
* Number of "compaction" records seen. Codex emits these explicitly
* (`type: "compacted"`); Claude doesn't. The field is parser-specific
* but always present (zero when not applicable).
*/
compactedCount: number;
/** Per-record-type frequency map; used in human reports / debug. */
typeCounts: TypeCounts;
/** Per-omitted-class counter; populated as the parser filters records. */
omittedCounts: OmittedCounts;
/** Extracted messages in chronological order; redaction has been applied. */
messages: ExtractedMessage[];
/**
* Path frequency inventory; sorted by count desc then path asc.
* Capped at 200 entries.
*/
paths: PathCount[];
}
/** Source-runtime kind. v0 supports codex + claude-code transcript shapes. */
export type SourceRuntime = "codex" | "claude-code";
export function emptyOmittedCounts(): OmittedCounts {
return {
reasoning_records: 0,
raw_tool_outputs: 0,
function_call_output: 0,
redacted_secrets: 0,
};
}
@@ -0,0 +1,98 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://openrig.dev/schemas/restore-summary.schema.json",
"title": "Restore Packet Summary",
"description": "Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
"type": "object",
"required": [
"source_session_id",
"source_rig",
"source_runtime",
"source_cwd",
"target_rig",
"target_runtime",
"target_workspace_root",
"default_target_repo",
"role_pointer",
"bounded_latest_transcript",
"touched_files",
"durable_pointers",
"current_work_summary",
"next_owner",
"caveats",
"authority_boundaries",
"omitted_classes",
"redaction_policy_id",
"source_trust_ranking",
"generator_version",
"generated_at"
],
"properties": {
"source_session_id": {"type": "string", "minLength": 1},
"source_rig": {"type": "string", "minLength": 1},
"source_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
"source_cwd": {"type": "string", "pattern": "^/"},
"target_rig": {"type": "string", "minLength": 1},
"target_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
"target_workspace_root": {"type": "string", "pattern": "^/"},
"default_target_repo": {"type": ["string", "null"]},
"role_pointer": {
"type": "string",
"minLength": 1,
"description": "Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time."
},
"bounded_latest_transcript": {
"type": "object",
"required": ["path", "message_count", "bound"],
"properties": {
"path": {"type": "string"},
"message_count": {"type": "integer", "minimum": 0, "description": "Actual count of extracted messages in transcript-latest.md."},
"bound": {"type": "integer", "const": 120, "description": "v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass."}
}
},
"full_transcript": {
"type": "object",
"properties": {
"path": {"type": "string"},
"line_count": {"type": "integer", "minimum": 0}
},
"description": "Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise."
},
"touched_files": {
"type": "object",
"required": ["path", "top_paths"],
"properties": {
"path": {"type": "string"},
"top_paths": {"type": "array", "items": {"type": "object", "required": ["path", "count"], "properties": {"path": {"type": "string"}, "count": {"type": "integer", "minimum": 0}}}}
}
},
"durable_pointers": {
"type": "object",
"required": ["queue_pointers", "progress_pointers", "field_note_pointers", "artifact_pointers"],
"properties": {
"queue_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to durable queue items (qitem ids or queue-file paths)."},
"progress_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to PROGRESS.md cursors the source seat was operating against."},
"field_note_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to field-notes folders or files."},
"artifact_pointers": {"type": "array", "items": {"type": "string"}, "description": "Other durable artifacts (proof packets, slice packets, candidate dossiers)."}
},
"description": "REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context."
},
"current_work_summary": {"type": "string", "minLength": 1},
"next_owner": {"type": "string", "minLength": 1},
"caveats": {"type": "array", "items": {"type": "string"}},
"authority_boundaries": {"type": "string", "minLength": 1},
"omitted_classes": {
"type": "array",
"items": {"type": "string", "enum": ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"]}
},
"redaction_policy_id": {"type": "string", "enum": ["velocity-v1", "openrig-v0"]},
"source_trust_ranking": {
"type": "array",
"items": {"type": "string", "enum": ["rig_whoami", "target_rigspec", "bounded_latest_transcript", "full_transcript", "touched_files", "restore_summary"]},
"minItems": 1
},
"generator_version": {"type": "string", "minLength": 1},
"generated_at": {"type": "string", "format": "date-time"}
},
"additionalProperties": false
}
+14 -4
View File
@@ -3,6 +3,7 @@ import { accessSync, mkdirSync, constants } from "node:fs";
import { dirname } from "node:path";
import type { ConfigStore, RiggedConfig } from "./config-store.js";
import type { DaemonStatus } from "./daemon-lifecycle.js";
import { buildTmuxControlFailure, probeTmuxControlAsync } from "./tmux-health.js";
export interface PreflightCheck {
name: string;
@@ -135,10 +136,8 @@ export class SystemPreflight {
}
// 2. tmux availability
try {
await this.deps.exec("tmux -V");
checks.push({ name: "tmux", ok: true });
} catch {
const tmuxProbe = await probeTmuxControlAsync(this.deps.exec);
if (tmuxProbe.code === "not_installed") {
checks.push({
name: "tmux",
ok: false,
@@ -146,6 +145,17 @@ export class SystemPreflight {
reason: "OpenRig uses tmux to create and control agent sessions.",
fix: "Install tmux (brew install tmux on macOS, apt install tmux on Debian/Ubuntu).",
});
} else if (tmuxProbe.available) {
checks.push({ name: "tmux", ok: true });
} else {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
checks.push({
name: "tmux",
ok: false,
error: failure.message,
reason: failure.reason,
fix: failure.fix,
});
}
// 3. Writable OpenRig home + transcript path
+114
View File
@@ -0,0 +1,114 @@
export interface TmuxProbeResult {
installed: boolean;
available: boolean;
version: string | null;
detail: string | null;
code: "available" | "no_server" | "not_installed" | "unhealthy";
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function isTmuxNoServerMessage(message: string): boolean {
const normalized = message.toLowerCase();
return normalized.includes("no server running")
|| normalized.includes("failed to connect to server")
|| (normalized.includes("error connecting to") && normalized.includes("no such file or directory"));
}
export function buildTmuxControlFailure(message: string): { message: string; reason: string; fix: string } {
return {
message: "tmux installed, but the default control socket is unhealthy.",
reason: `OpenRig uses tmux control commands to launch, inspect, and manage agent sessions. The default tmux socket is returning: ${message}`,
fix: "Capture any needed state from visible tmux panes, then restart the default tmux server before retrying OpenRig. If this happened after a machine restore, treat it as attention required rather than a healthy running state.",
};
}
export function probeTmuxControl(exec: (cmd: string) => string): TmuxProbeResult {
let version: string;
try {
version = exec("tmux -V").trim();
} catch (err) {
return {
installed: false,
available: false,
version: null,
detail: errorMessage(err),
code: "not_installed",
};
}
try {
exec("tmux list-sessions");
return {
installed: true,
available: true,
version,
detail: null,
code: "available",
};
} catch (err) {
const detail = errorMessage(err).trim();
if (isTmuxNoServerMessage(detail)) {
return {
installed: true,
available: true,
version,
detail,
code: "no_server",
};
}
return {
installed: true,
available: false,
version,
detail,
code: "unhealthy",
};
}
}
export async function probeTmuxControlAsync(exec: (cmd: string) => Promise<string>): Promise<TmuxProbeResult> {
let version: string;
try {
version = (await exec("tmux -V")).trim();
} catch (err) {
return {
installed: false,
available: false,
version: null,
detail: errorMessage(err),
code: "not_installed",
};
}
try {
await exec("tmux list-sessions");
return {
installed: true,
available: true,
version,
detail: null,
code: "available",
};
} catch (err) {
const detail = errorMessage(err).trim();
if (isTmuxNoServerMessage(detail)) {
return {
installed: true,
available: true,
version,
detail,
code: "no_server",
};
}
return {
installed: true,
available: false,
version,
detail,
code: "unhealthy",
};
}
}
+142
View File
@@ -0,0 +1,142 @@
// PL-016 hardening v0+1 — `rig agent-image create` --image-version flag
// regression test.
//
// Pins:
// - --image-version <v> propagates as body.version on the snapshot POST
// - --version on the per-command surface no longer shadows Commander's
// global --version (creating a separate per-command name resolves
// the collision; absence of `--version` as a per-command option is
// the contract this test enforces)
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { agentImageCommand } from "../src/commands/agent-image.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function runningState(port: number): DaemonState {
return { pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" };
}
function runningLifecycleDeps(port: number): LifecycleDeps {
return mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify(runningState(port));
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
});
}
interface CapturedCall {
method: string;
url: string;
body: string;
}
function createMockDaemon(captured: CapturedCall[]) {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
if (url.pathname === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (req.method === "POST" && url.pathname === "/api/agent-images/snapshot") {
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
captured.push({ method: req.method!, url: url.pathname, body });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
imageId: "agent-image:test:2",
imagePath: "/tmp/agent-images/test",
manifest: { name: "test", version: "2", runtime: "claude-code" },
}));
});
return;
}
res.writeHead(404);
res.end();
});
return {
server,
close: () => new Promise<void>((r) => server.close(() => r())),
listen: () => new Promise<number>((r) => {
server.listen(0, () => {
const addr = server.address();
r(typeof addr === "object" && addr ? addr.port : 0);
});
}),
};
}
describe("rig agent-image create — --image-version flag", () => {
const captured: CapturedCall[] = [];
let srv: ReturnType<typeof createMockDaemon>;
let port: number;
beforeAll(async () => {
srv = createMockDaemon(captured);
port = await srv.listen();
});
afterAll(async () => { await srv.close(); });
it("--image-version <v> propagates as body.version on the snapshot POST", async () => {
captured.length = 0;
const program = new Command();
program.exitOverride();
program.addCommand(agentImageCommand({
lifecycleDeps: runningLifecycleDeps(port),
clientFactory: (url) => new DaemonClient(url),
}));
// Suppress console output for the test.
const origLog = console.log;
console.log = () => {};
try {
await program.parseAsync([
"node", "rig", "agent-image", "create", "alice@rigA",
"--name", "test",
"--image-version", "2",
]);
} finally {
console.log = origLog;
}
expect(captured).toHaveLength(1);
const body = JSON.parse(captured[0]!.body);
expect(body.version).toBe("2");
expect(body.name).toBe("test");
expect(body.sourceSession).toBe("alice@rigA");
});
it("the per-command create command does NOT register --version (avoids Commander.js global collision)", () => {
const cmd = agentImageCommand();
const createCmd = cmd.commands.find((c) => c.name() === "create");
expect(createCmd).toBeDefined();
const optionNames = createCmd!.options.map((o) => o.long);
// Must include --image-version (the rename target); must NOT include
// --version (which Commander.js intercepts as the global version flag).
expect(optionNames).toContain("--image-version");
expect(optionNames).not.toContain("--version");
});
});
+47 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveBinEntry } from "../src/bin-wrapper.js";
import { resolveBinEntry, resolveNodeReexecBinary } from "../src/bin-wrapper.js";
describe("bin-wrapper", () => {
let tempDir: string | null = null;
@@ -24,4 +24,50 @@ describe("bin-wrapper", () => {
const entry = resolveBinEntry(symlinkPath, pathToFileURL(wrapperSource).href);
expect(entry).toBe(path.resolve(path.dirname(wrapperSource), "../dist/index.js"));
});
it("prefers the sibling node next to the invoked rig binary when current node comes from elsewhere", () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
const prefixDir = path.join(tempDir, "prefix");
const binDir = path.join(prefixDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
const rigPath = path.join(binDir, "rig");
const siblingNodePath = path.join(binDir, "node");
fs.symlinkSync(wrapperSource, rigPath);
fs.writeFileSync(siblingNodePath, "");
const reexecNode = resolveNodeReexecBinary(
"/opt/homebrew/bin/node",
rigPath,
{},
(candidate) => candidate === siblingNodePath,
(candidate) => candidate,
);
expect(reexecNode).toBe(siblingNodePath);
});
it("does not re-exec when already running under the sibling node", () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
const prefixDir = path.join(tempDir, "prefix");
const binDir = path.join(prefixDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
const rigPath = path.join(binDir, "rig");
const siblingNodePath = path.join(binDir, "node");
fs.symlinkSync(wrapperSource, rigPath);
fs.writeFileSync(siblingNodePath, "");
const reexecNode = resolveNodeReexecBinary(
siblingNodePath,
rigPath,
{},
(candidate) => candidate === siblingNodePath,
(candidate) => candidate,
);
expect(reexecNode).toBeNull();
});
});
+29 -1
View File
@@ -182,7 +182,9 @@ describe("Bootstrap CLI", () => {
// T7: daemon not running -> error + exit 1
it("daemon not running returns exit code 1", async () => {
const stoppedDeps: StatusDeps = {
lifecycleDeps: mockLifecycleDeps(),
lifecycleDeps: mockLifecycleDeps({
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
const prog = new Command();
@@ -214,6 +216,32 @@ describe("Bootstrap CLI", () => {
expect((body.cwdOverride as string).endsWith("relative/project")).toBe(true);
});
it("bootstrap apply uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
timeoutMs = options?.timeoutMs;
return {
status: 201,
data: { runId: "run-2", status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
};
});
const deps: StatusDeps = {
lifecycleDeps: runningDeps(port).lifecycleDeps,
clientFactory: () => ({ post } as unknown as DaemonClient),
};
const prog = new Command();
prog.exitOverride();
prog.addCommand(bootstrapCommand(deps));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "bootstrap", "/tmp/rig.yaml", "--yes"]);
});
expect(timeoutMs).toBe(120_000);
});
// T9: partial status -> exit 1 (R2-M1)
it("partial result returns exit code 1", async () => {
const partialServer = http.createServer((_, res) => {
+2 -1
View File
@@ -5,6 +5,7 @@ import { chatroomCommand } from "../src/commands/chatroom.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
import { ulid } from "ulid";
function mockLifecycleDeps(): LifecycleDeps {
return {
@@ -308,7 +309,7 @@ describe("Chatroom CLI", () => {
// Inject a new message after 1s (will appear on second poll)
const injectionTimer = setTimeout(() => {
dynamicMessages.push({
id: "01KNZZZZZZZZZZZZZZZZZZZZZZ", // ULID > any current ULID baseline
id: ulid(),
rigId: "rig-1",
sender: "new-peer",
kind: "message",
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { checkAbi } from "../scripts/check-abi.mjs";
describe("postinstall ABI check", () => {
it("passes on supported even-numbered LTS with working native addon", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("passes on Node 20 LTS", () => {
const result = checkAbi({
nodeVersion: "v20.18.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("passes on Node 24 LTS", () => {
const result = checkAbi({
nodeVersion: "v24.0.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("fails on odd-numbered Node 25 with honest error + fix command", () => {
const result = checkAbi({
nodeVersion: "v25.8.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("odd-numbered");
expect(result.message).toContain("v25.8.0");
expect(result.message).toContain("nvm install 22");
});
it("fails on odd-numbered Node 23 with honest error", () => {
const result = checkAbi({
nodeVersion: "v23.5.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("odd-numbered");
});
it("fails on Node below 20 with version-too-low error", () => {
const result = checkAbi({
nodeVersion: "v18.20.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("requires Node.js 20, 22, or 24");
expect(result.message).toContain("nvm install 22");
});
it("fails with ABI mismatch error when native addon throws on supported version", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {
throw new Error(
"The module was compiled against a different Node.js version using NODE_MODULE_VERSION 127. " +
"This version of Node.js requires NODE_MODULE_VERSION 131."
);
},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("native binary does not match");
expect(result.message).toContain("npm rebuild better-sqlite3");
expect(result.message).toContain("NODE_MODULE_VERSION");
});
it("reports generic native-load-failure for MODULE_NOT_FOUND (not ABI-mismatch text)", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {
const err = new Error("Cannot find module 'better-sqlite3'");
(err as NodeJS.ErrnoException).code = "MODULE_NOT_FOUND";
throw err;
},
});
expect(result.ok).toBe(false);
// Should NOT say "native binary does not match" — that's ABI-specific
expect(result.message).not.toContain("native binary does not match");
// Should say generic addon-load-failure
expect(result.message).toContain("native addon failed to load");
expect(result.message).toContain("Cannot find module");
expect(result.message).toContain("npm rebuild better-sqlite3");
});
it("reports generic native-load-failure for filesystem permission errors", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {
throw new Error("EACCES: permission denied, open '/usr/local/lib/better_sqlite3.node'");
},
});
expect(result.ok).toBe(false);
expect(result.message).not.toContain("native binary does not match");
expect(result.message).toContain("native addon failed to load");
expect(result.message).toContain("EACCES");
});
it("skips native addon check entirely for odd versions (fast path)", () => {
let addonCalled = false;
const result = checkAbi({
nodeVersion: "v25.8.0",
loadNativeAddon: () => { addonCalled = true; },
});
expect(result.ok).toBe(false);
// Version check short-circuits before trying to load the addon
expect(addonCalled).toBe(false);
});
});
+25 -2
View File
@@ -124,6 +124,29 @@ describe("DaemonClient", () => {
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
});
it("per-request timeout override can extend a long-running call", async () => {
const delayedFetch: typeof fetch = ((url: string | URL | Request, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(new Response(JSON.stringify({ ok: true, url: String(url) }), {
status: 200,
headers: { "Content-Type": "application/json" },
}));
}, 40);
init?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(init.signal?.reason ?? new Error("aborted"));
}, { once: true });
})) as typeof fetch;
const client = new DaemonClient("http://localhost:9999", { fetchImpl: delayedFetch, timeoutMs: 20 });
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
const res = await client.get<{ ok: boolean; url: string }>("/api/rigs", { timeoutMs: 100 });
expect(res.status).toBe(200);
expect(res.data.ok).toBe(true);
expect(res.data.url).toContain("/api/rigs");
});
// Test 5: Client uses OPENRIG_URL env, falls back to http://127.0.0.1:7433
it("uses OPENRIG_URL env when set, falls back to http://127.0.0.1:7433", () => {
// Default (no env)
@@ -152,8 +175,8 @@ describe("DaemonClient", () => {
const execFileAsync = promisify(execFile);
const { resolve } = await import("node:path");
const cliEntry = resolve(import.meta.dirname, "../src/index.ts");
const result = await execFileAsync("npx", ["tsx", cliEntry, "--version"], {
const cliEntry = resolve(import.meta.dirname, "../dist/index.js");
const result = await execFileAsync("node", [cliEntry, "--version"], {
cwd: resolve(import.meta.dirname, ".."),
});
+482
View File
@@ -0,0 +1,482 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { compactPlanCommand, type CompactPlanDeps } from "../src/commands/compact-plan.js";
import { createProgram } from "../src/index.js";
const freshSample = () => new Date(Date.now() - 60_000).toISOString();
const staleSample = () => new Date(Date.now() - 3_600_000).toISOString();
interface TestNode {
rigId: string;
rigName: string;
logicalId: string;
canonicalSessionName: string | null;
runtime: string | null;
sessionStatus: string | null;
startupStatus: string | null;
tmuxAttachCommand: string | null;
resumeCommand: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
source: string | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
}
function node(overrides: Partial<TestNode> = {}): TestNode {
const session = overrides.canonicalSessionName ?? "orch-lead@test-rig";
return {
rigId: "rig-0",
rigName: "test-rig",
logicalId: "orch.lead",
canonicalSessionName: session,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready",
tmuxAttachCommand: session ? `tmux attach -t ${session}` : null,
resumeCommand: null,
contextUsage: {
usedPercentage: 85,
remainingPercentage: 15,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
...overrides,
};
}
function defaultNodes(): TestNode[] {
return [
node({
logicalId: "orch.lead",
canonicalSessionName: "orch-lead@test-rig",
contextUsage: {
usedPercentage: 85,
remainingPercentage: 15,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "dev.impl",
canonicalSessionName: "dev-impl@test-rig",
contextUsage: {
usedPercentage: 30,
remainingPercentage: 70,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "dev.qa",
canonicalSessionName: "dev-qa@test-rig",
runtime: "codex",
contextUsage: {
usedPercentage: 90,
remainingPercentage: 10,
contextWindowSize: 1_000_000,
source: "codex",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
];
}
function makeDeps(overrides?: {
rigNames?: string[];
nodesByRig?: Record<string, TestNode[]>;
refreshStatus?: number;
refreshThrows?: boolean;
}): { deps: CompactPlanDeps; requestedPaths: string[]; mutationMethods: Record<string, ReturnType<typeof vi.fn>> } {
const rigs = (overrides?.rigNames ?? ["test-rig"]).map((name, i) => ({ rigId: `rig-${i}`, name }));
const nodesByRig = overrides?.nodesByRig ?? { "rig-0": defaultNodes() };
const requestedPaths: string[] = [];
const mutationMethods = {
post: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
delete: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
postText: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
postExpectText: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
};
return {
requestedPaths,
mutationMethods,
deps: {
lifecycleDeps: {} as CompactPlanDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
requestedPaths.push(path);
if (path === "/api/ps") return { status: 200, data: rigs };
if (path.includes("refresh=true")) {
if (overrides?.refreshThrows) throw new Error("statusline unavailable");
return { status: overrides?.refreshStatus ?? 200, data: overrides?.refreshStatus && overrides.refreshStatus >= 400 ? { error: "refresh failed" } : [] };
}
for (const [rigId, nodes] of Object.entries(nodesByRig)) {
if (path === `/api/rigs/${rigId}/nodes`) return { status: 200, data: nodes };
}
return { status: 200, data: [] };
}),
getText: vi.fn(async (path: string) => {
requestedPaths.push(path);
return { status: 200, data: "" };
}),
...mutationMethods,
}) as unknown as ReturnType<CompactPlanDeps["clientFactory"]>,
},
};
}
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
describe("rig compact-plan", () => {
let logs: string[];
let errors: string[];
beforeEach(async () => {
logs = [];
errors = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
(getDaemonStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ state: "running", healthy: true, pid: 1234, port: 7433 });
process.exitCode = undefined;
});
it("is registered on createProgram and runs with injected deps", async () => {
const { deps } = makeDeps();
const program = createProgram({ compactPlanDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "compact-plan", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.policy.mode).toBe("read_only_plan");
expect(json.candidates[0].session).toBe("orch-lead@test-rig");
});
it("returns read-only JSON policy, candidates, recommended order, and skipped Codex seats", async () => {
const { deps, mutationMethods } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.policy).toEqual({
mode: "read_only_plan",
defaultThresholdTokens: 400_000,
defaultThresholdPercent: 80,
thresholdTokens: 400_000,
thresholdPercent: 80,
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
});
expect(json.summary.requiresAuthorization).toBe(true);
const candidate = json.candidates.find((entry: { session: string }) => entry.session === "orch-lead@test-rig");
expect(candidate.status).toBe("candidate_with_caveats");
expect(candidate.estimatedUsedTokens).toBe(850_000);
expect(candidate.thresholdReason).toBe("above_token_threshold");
expect(candidate.reasons).toContain("above_token_threshold");
expect(candidate.reasons).toContain("authorization_required");
expect(candidate.missingEvidence).toContain("checkpoint_or_restore_packet_verification");
expect(candidate.precompactRequirements).toContain("explicit_operator_authorization");
expect(candidate.seatPolicy).toEqual({
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
});
expect(candidate.notificationPacket.recipient).toBe("orch-lead@test-rig");
expect(candidate.notificationPacket.text).toContain("No automatic compaction has been run");
expect(json.recommendedOrder).toEqual(["orch-lead@test-rig"]);
const belowThreshold = json.skipped.find((entry: { session: string }) => entry.session === "dev-impl@test-rig");
expect(belowThreshold.reason).toBe("below_threshold");
const codex = json.skipped.find((entry: { session: string }) => entry.session === "dev-qa@test-rig");
expect(codex.reason).toBe("codex_not_managed_by_claude_compact_in_place");
for (const mutate of Object.values(mutationMethods)) {
expect(mutate).not.toHaveBeenCalled();
}
});
it("uses percent fallback only for Claude seats at or above 80 percent when context window is missing", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({
canonicalSessionName: "high-missing-window@test-rig",
contextUsage: {
usedPercentage: 85,
remainingPercentage: 15,
contextWindowSize: null,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "mid.missing",
canonicalSessionName: "mid-missing-window@test-rig",
contextUsage: {
usedPercentage: 79,
remainingPercentage: 21,
contextWindowSize: null,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.candidates.map((entry: { session: string }) => entry.session)).toContain("high-missing-window@test-rig");
const candidate = json.candidates.find((entry: { session: string }) => entry.session === "high-missing-window@test-rig");
expect(candidate.status).toBe("candidate_with_caveats");
expect(candidate.thresholdReason).toBe("above_percent_threshold_missing_window");
expect(candidate.estimatedUsedTokens).toBeNull();
expect(candidate.missingEvidence).toContain("context_window_size");
expect(json.skipped.find((entry: { session: string }) => entry.session === "mid-missing-window@test-rig").reason).toBe("below_threshold");
});
it("honors custom token and percent thresholds in JSON policy and candidate selection", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({
logicalId: "lower.token",
canonicalSessionName: "lower-token@test-rig",
contextUsage: {
usedPercentage: 30,
remainingPercentage: 70,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "lower.percent",
canonicalSessionName: "lower-percent@test-rig",
contextUsage: {
usedPercentage: 75,
remainingPercentage: 25,
contextWindowSize: null,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--threshold-tokens", "250000", "--threshold-percent", "70", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.policy.thresholdTokens).toBe(250_000);
expect(json.policy.thresholdPercent).toBe(70);
expect(json.candidates.map((entry: { session: string }) => entry.session)).toEqual([
"lower-token@test-rig",
"lower-percent@test-rig",
]);
expect(json.candidates.find((entry: { session: string }) => entry.session === "lower-token@test-rig").thresholdReason).toBe("above_token_threshold");
expect(json.candidates.find((entry: { session: string }) => entry.session === "lower-percent@test-rig").thresholdReason).toBe("above_percent_threshold_missing_window");
});
it("rejects invalid threshold options before reading daemon inventory", async () => {
const { deps, requestedPaths } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--threshold-tokens", "0"]);
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("--threshold-tokens");
expect(requestedPaths).toEqual([]);
});
it("surfaces stale context and missing attach or resume evidence as candidate caveats", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [node({
canonicalSessionName: "stale-no-attach@test-rig",
tmuxAttachCommand: null,
resumeCommand: null,
contextUsage: {
usedPercentage: 90,
remainingPercentage: 10,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: staleSample(),
fresh: false,
},
})],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const candidate = json.candidates[0];
expect(candidate.contextFreshness).toBe("stale");
expect(candidate.reasons).toContain("context_stale");
expect(candidate.missingEvidence).toContain("fresh_context_sample");
expect(candidate.missingEvidence).toContain("attach_or_resume_evidence");
});
it("blocks unknown context and missing canonical session, and excludes them from recommended order", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({
logicalId: "unknown.context",
canonicalSessionName: "unknown-context@test-rig",
contextUsage: { usedPercentage: null, remainingPercentage: null, contextWindowSize: null, source: null, availability: "unknown", sampledAt: null, fresh: false },
}),
node({
logicalId: "missing.session",
canonicalSessionName: null,
contextUsage: {
usedPercentage: 90,
remainingPercentage: 10,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.candidates).toHaveLength(0);
expect(json.recommendedOrder).toEqual([]);
expect(json.blocked.find((entry: { logicalId: string }) => entry.logicalId === "unknown.context").reasons).toContain("context_unknown");
const missingSession = json.blocked.find((entry: { logicalId: string }) => entry.logicalId === "missing.session");
expect(missingSession.reasons).toContain("missing_canonical_session");
expect(missingSession.notificationPacket.text).toContain("cannot safely plan");
expect(missingSession.precompactRequirements).toContain("one_seat_at_a_time_only");
});
it("does not recommend non-running or not-ready Claude seats", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({ canonicalSessionName: "stopped@test-rig", sessionStatus: "stopped" }),
node({ canonicalSessionName: "pending@test-rig", startupStatus: "pending" }),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.recommendedOrder).toEqual([]);
expect(json.blocked.map((entry: { reasons: string[] }) => entry.reasons).flat()).toContain("seat_not_running_or_ready");
});
it("--rig filters to one rig and unknown rig exits before refresh", async () => {
const { deps, requestedPaths } = makeDeps({
rigNames: ["rig-a", "rig-b"],
nodesByRig: {
"rig-0": [node({ rigId: "rig-0", rigName: "rig-a", canonicalSessionName: "lead@rig-a" })],
"rig-1": [node({ rigId: "rig-1", rigName: "rig-b", canonicalSessionName: "lead@rig-b" })],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--rig", "rig-b", "--json"]);
let json = JSON.parse(logs.join(""));
expect(json.candidates.map((entry: { session: string }) => entry.session)).toEqual(["lead@rig-b"]);
logs = [];
errors = [];
process.exitCode = undefined;
const unknownCmd = compactPlanCommand(deps);
await unknownCmd.parseAsync(["node", "rig", "--rig", "missing", "--refresh"]);
expect(process.exitCode).toBe(1);
expect(errors.join(" ")).toContain("not found");
expect(requestedPaths.some((path) => path.includes("refresh=true"))).toBe(false);
});
it("--refresh calls refresh before inventory and hard-fails without printing a plan on refresh failure", async () => {
const success = makeDeps();
const cmd = compactPlanCommand(success.deps);
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
const nodeCalls = success.requestedPaths.filter((path) => path.includes("/nodes"));
expect(nodeCalls[0]).toContain("refresh=true");
expect(nodeCalls[1]).not.toContain("refresh=true");
logs = [];
errors = [];
process.exitCode = undefined;
const failure = makeDeps({ refreshStatus: 502 });
const failCmd = compactPlanCommand(failure.deps);
await failCmd.parseAsync(["node", "rig", "--refresh"]);
expect(process.exitCode).toBe(2);
expect(errors.join("\n")).toContain("Compact-plan refresh failed");
expect(logs.join("\n")).not.toContain("READ-ONLY PLAN");
});
it("daemon down exits nonzero with honest next step copy", async () => {
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
(getDaemonStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: "stopped", healthy: false });
const { deps } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig"]);
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("Daemon is not running");
expect(errors.join("\n")).toContain("rig daemon start");
});
it("human output is explicit that this is a read-only triage plan, not compact permission", async () => {
const { deps } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig"]);
const output = logs.join("\n");
expect(output).toContain("READ-ONLY PLAN");
expect(output).toContain("does not compact");
expect(output).toContain("Thresholds:");
expect(output).toContain("one-seat-at-a-time");
expect(output).toContain("orch-lead@test-rig");
expect(output).toContain("authorization");
expect(output).toContain("checkpoint/restore");
expect(output).toContain("claude-compact-in-place");
expect(output).toContain("No automatic compaction has been run");
});
});
@@ -0,0 +1,528 @@
// User Settings v0 — extended ConfigStore + init-workspace tests.
//
// Pins the load-bearing behaviors of the v0 extension:
// - new keys parse + validate; legacy 5 keys' behavior preserved
// - per-subdir override resolution (workspace.root / per-subdir overrides / default)
// - env > file > default precedence for new keys
// - UEP env-var graduation: OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS still work
// - reset(key) clears one key; bare reset deletes the file
// - parseNamedPairs decodes the named-pair format
// - init-workspace creates mission-aware workspace files; idempotent
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Command } from "commander";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
ConfigStore,
VALID_KEYS,
parseNamedPairs,
deriveWorkspaceDefault,
} from "../src/config-store.js";
import { configCommand } from "../src/commands/config.js";
import { initWorkspaceCommand, runInitWorkspace } from "../src/commands/config-init-workspace.js";
function clearEnv(): () => void {
const keysToClear = [
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS",
"OPENRIG_UI_PREVIEW_MAX_PINS", "OPENRIG_UI_PREVIEW_DEFAULT_LINES",
"OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS",
"OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST",
"OPENRIG_AGENTS_ADVISOR_SESSION", "OPENRIG_AGENTS_OPERATOR_SESSION",
"OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS",
"OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS",
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
];
const saved: Record<string, string | undefined> = {};
for (const k of keysToClear) {
saved[k] = process.env[k];
delete process.env[k];
}
return () => {
for (const k of keysToClear) {
if (saved[k] !== undefined) process.env[k] = saved[k]!;
else delete process.env[k];
}
};
}
describe("ConfigStore — extended namespaces (User Settings v0)", () => {
let tmpDir: string;
let configPath: string;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "config-store-ext-"));
configPath = join(tmpDir, "config.json");
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
it("VALID_KEYS includes legacy, user-settings, ui.preview, recovery, agents, feed, and transcript-rotation keys", () => {
const expected = [
"daemon.port", "daemon.host", "db.path",
"transcripts.enabled", "transcripts.path",
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
"transcripts.lines", "transcripts.poll_interval_seconds",
"workspace.root", "workspace.slices_root", "workspace.steering_path",
"workspace.field_notes_root", "workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist", "progress.scan_roots",
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
"agents.advisor_session", "agents.operator_session",
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles.
"feed.subscriptions.action_required",
"feed.subscriptions.approvals",
"feed.subscriptions.shipped",
"feed.subscriptions.progress",
"feed.subscriptions.audit_log",
];
expect([...VALID_KEYS]).toEqual(expected);
});
it("transcripts.lines + transcripts.poll_interval_seconds roundtrip — set → resolve reflects file-stored values for daemon launch projection", () => {
const store = new ConfigStore(configPath);
// Defaults BEFORE set.
const before = store.resolve();
expect(before.transcripts.lines).toBe(1000);
expect(before.transcripts.pollIntervalSeconds).toBe(2);
store.set("transcripts.lines", "500");
store.set("transcripts.poll_interval_seconds", "5");
const after = store.resolve();
expect(after.transcripts.lines).toBe(500);
expect(after.transcripts.pollIntervalSeconds).toBe(5);
});
it("legacy 5-key behavior preserved: get/set/reset still work", () => {
const store = new ConfigStore(configPath);
store.set("daemon.port", "9999");
expect(store.get("daemon.port")).toBe(9999);
store.set("transcripts.enabled", "false");
expect(store.get("transcripts.enabled")).toBe(false);
store.reset("daemon.port");
expect(store.get("daemon.port")).toBe(7433);
});
it("workspace.root default is ~/.openrig/workspace; per-subdir defaults derive from it", () => {
const store = new ConfigStore(configPath);
const cfg = store.resolve();
expect(cfg.workspace.root).toMatch(/\.openrig[\/\\]workspace$/);
expect(cfg.workspace.slicesRoot).toBe(join(cfg.workspace.root, "missions"));
expect(cfg.workspace.steeringPath).toBe(join(cfg.workspace.root, "STEERING.md"));
expect(cfg.workspace.fieldNotesRoot).toBe(join(cfg.workspace.root, "field-notes"));
expect(cfg.workspace.specsRoot).toBe(join(cfg.workspace.root, "specs"));
expect(cfg.workspace.dogfoodEvidenceRoot).toBe(join(cfg.workspace.root, "dogfood-evidence"));
expect(cfg.files.allowlist).toBe(`workspace:${cfg.workspace.root}`);
expect(cfg.progress.scanRoots).toBe(`workspace:${cfg.workspace.root}`);
});
it("setting workspace.root cascades into per-subdir defaults", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/custom/ws");
const cfg = store.resolve();
expect(cfg.workspace.root).toBe("/custom/ws");
expect(cfg.workspace.slicesRoot).toBe("/custom/ws/missions");
expect(cfg.workspace.steeringPath).toBe("/custom/ws/STEERING.md");
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
expect(cfg.files.allowlist).toBe("workspace:/custom/ws");
expect(cfg.progress.scanRoots).toBe("workspace:/custom/ws");
});
it("treats persisted legacy workspace defaults as default-derived values", () => {
writeFileSync(configPath, JSON.stringify({
workspace: {
root: "/custom/ws",
slicesRoot: "/custom/ws/slices",
steeringPath: "/custom/ws/steering/STEERING.md",
},
}));
const store = new ConfigStore(configPath);
const slices = store.resolveWithSource("workspace.slices_root");
const steering = store.resolveWithSource("workspace.steering_path");
expect(slices).toMatchObject({ value: "/custom/ws/missions", source: "default" });
expect(steering).toMatchObject({ value: "/custom/ws/STEERING.md", source: "default" });
});
it("per-subdir override beats workspace.root cascade", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/custom/slices");
const cfg = store.resolve();
// workspace.root cascade applies to OTHER subdirs:
expect(cfg.workspace.fieldNotesRoot).toBe("/ws/field-notes");
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/ws/dogfood-evidence");
// per-subdir override wins:
expect(cfg.workspace.slicesRoot).toBe("/custom/slices");
});
it("env > file > default for new keys", () => {
const store = new ConfigStore(configPath);
// No file → default
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
// File set → file source
store.set("workspace.slices_root", "/from/file");
expect(store.resolveWithSource("workspace.slices_root").value).toBe("/from/file");
expect(store.resolveWithSource("workspace.slices_root").source).toBe("file");
// Env set → env source
process.env["OPENRIG_WORKSPACE_SLICES_ROOT"] = "/from/env";
try {
const r = store.resolveWithSource("workspace.slices_root");
expect(r.value).toBe("/from/env");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_WORKSPACE_SLICES_ROOT"];
}
});
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST is the env override for files.allowlist", () => {
const store = new ConfigStore(configPath);
process.env["OPENRIG_FILES_ALLOWLIST"] = "ws:/Users/me,docs:/var/docs";
try {
const r = store.resolveWithSource("files.allowlist");
expect(r.value).toBe("ws:/Users/me,docs:/var/docs");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_FILES_ALLOWLIST"];
}
});
it("UEP env-var graduation: OPENRIG_PROGRESS_SCAN_ROOTS is the env override for progress.scan_roots", () => {
const store = new ConfigStore(configPath);
process.env["OPENRIG_PROGRESS_SCAN_ROOTS"] = "main:/code/main";
try {
const r = store.resolveWithSource("progress.scan_roots");
expect(r.value).toBe("main:/code/main");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_PROGRESS_SCAN_ROOTS"];
}
});
it("dogfood evidence root defaults under workspace.root and supports env override", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/custom/ws");
expect(store.resolve().workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"] = "/proof/root";
try {
const r = store.resolveWithSource("workspace.dogfood_evidence_root");
expect(r.value).toBe("/proof/root");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"];
}
});
it("set rejects unknown keys with hint listing valid keys", () => {
const store = new ConfigStore(configPath);
expect(() => store.set("workspace.bogus", "x")).toThrow(/Unknown config key/);
expect(() => store.set("workspace.bogus", "x")).toThrow(/workspace\.root/);
});
it("get rejects unknown keys", () => {
const store = new ConfigStore(configPath);
expect(() => store.get("nope.doesnt.exist")).toThrow(/Unknown config key/);
});
it("reset(key) clears just one key; reset() deletes whole file", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/ws/slices-custom");
store.reset("workspace.slices_root");
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
expect(store.resolveWithSource("workspace.root").source).toBe("file");
store.reset();
expect(existsSync(configPath)).toBe(false);
});
it("resolveAllWithSource returns every valid key with source + default", () => {
const store = new ConfigStore(configPath);
const all = store.resolveAllWithSource();
for (const key of VALID_KEYS) {
expect(all[key]).toBeDefined();
expect(all[key].source).toBeDefined();
}
});
it("malformed config.json throws with reset hint (preserves legacy behavior)", () => {
writeFileSync(configPath, "{not-json");
const store = new ConfigStore(configPath);
expect(() => store.resolve()).toThrow(/malformed/i);
expect(() => store.resolve()).toThrow(/reset/i);
});
// --- Preview Terminal v0 (PL-018) keys ---
it("ui.preview.refresh_interval_seconds defaults to 3", () => {
const store = new ConfigStore(configPath);
expect(store.get("ui.preview.refresh_interval_seconds")).toBe(3);
});
it("ui.preview.max_pins defaults to 4", () => {
const store = new ConfigStore(configPath);
expect(store.get("ui.preview.max_pins")).toBe(4);
});
it("ui.preview.default_lines defaults to 50", () => {
const store = new ConfigStore(configPath);
expect(store.get("ui.preview.default_lines")).toBe(50);
});
it("ui.preview.* keys coerce numeric values from set", () => {
const store = new ConfigStore(configPath);
store.set("ui.preview.refresh_interval_seconds", "5");
store.set("ui.preview.max_pins", "2");
store.set("ui.preview.default_lines", "100");
expect(store.get("ui.preview.refresh_interval_seconds")).toBe(5);
expect(store.get("ui.preview.max_pins")).toBe(2);
expect(store.get("ui.preview.default_lines")).toBe(100);
});
it("ui.preview.* keys reject non-numeric values", () => {
const store = new ConfigStore(configPath);
expect(() => store.set("ui.preview.refresh_interval_seconds", "soon")).toThrow(/expected a number/);
});
it("OPENRIG_UI_PREVIEW_* env vars override file values", () => {
const store = new ConfigStore(configPath);
store.set("ui.preview.refresh_interval_seconds", "5");
process.env["OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS"] = "10";
try {
const r = store.resolveWithSource("ui.preview.refresh_interval_seconds");
expect(r.value).toBe(10);
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS"];
}
});
it("recovery provider auth env allowlist defaults empty and supports env override", () => {
const store = new ConfigStore(configPath);
expect(store.get("recovery.provider_auth_env_allowlist")).toBe("");
store.set("recovery.provider_auth_env_allowlist", "ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN");
expect(store.resolve().recovery.providerAuthEnvAllowlist).toBe("ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN");
process.env["OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST"] = "OPENAI_API_KEY";
try {
const r = store.resolveWithSource("recovery.provider_auth_env_allowlist");
expect(r.value).toBe("OPENAI_API_KEY");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST"];
}
});
it("RiggedConfig.ui.preview shape exposed", () => {
const store = new ConfigStore(configPath);
const cfg = store.resolve();
expect(cfg.ui.preview.refreshIntervalSeconds).toBe(3);
expect(cfg.ui.preview.maxPins).toBe(4);
expect(cfg.ui.preview.defaultLines).toBe(50);
});
it("recovery.auto_drive_provider_prompts defaults false and coerces booleans", () => {
const store = new ConfigStore(configPath);
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(false);
store.set("recovery.auto_drive_provider_prompts", "true");
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(true);
expect(store.resolve().recovery.autoDriveProviderPrompts).toBe(true);
});
});
describe("parseNamedPairs", () => {
it("returns empty array for empty/whitespace input", () => {
expect(parseNamedPairs("")).toEqual([]);
expect(parseNamedPairs(" ")).toEqual([]);
});
it("splits comma-separated name:path pairs", () => {
const out = parseNamedPairs("ws:/abs/path,docs:/var/docs");
expect(out).toEqual([
{ name: "ws", path: "/abs/path" },
{ name: "docs", path: "/var/docs" },
]);
});
it("skips entries without a colon", () => {
expect(parseNamedPairs("just-name,ws:/path")).toEqual([{ name: "ws", path: "/path" }]);
});
it("trims whitespace around each pair + name + path", () => {
expect(parseNamedPairs(" ws : /abs/path , docs:/var/docs ")).toEqual([
{ name: "ws", path: "/abs/path" },
{ name: "docs", path: "/var/docs" },
]);
});
it("dedupes by name (last wins)", () => {
expect(parseNamedPairs("ws:/old,ws:/new")).toEqual([{ name: "ws", path: "/new" }]);
});
});
describe("deriveWorkspaceDefault", () => {
it("returns canonical subpaths under workspace root", () => {
expect(deriveWorkspaceDefault("workspace.slices_root", "/ws")).toBe("/ws/missions");
expect(deriveWorkspaceDefault("workspace.steering_path", "/ws")).toBe("/ws/STEERING.md");
expect(deriveWorkspaceDefault("workspace.field_notes_root", "/ws")).toBe("/ws/field-notes");
expect(deriveWorkspaceDefault("workspace.specs_root", "/ws")).toBe("/ws/specs");
expect(deriveWorkspaceDefault("workspace.dogfood_evidence_root", "/ws")).toBe("/ws/dogfood-evidence");
});
});
describe("init-workspace runner", () => {
let tmpDir: string;
let configPath: string;
let workspaceRoot: string;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "init-workspace-"));
configPath = join(tmpDir, "config.json");
workspaceRoot = join(tmpDir, "workspace");
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
it("--dry-run reports what would be created without writing anything", () => {
const result = runInitWorkspace({ dryRun: true, root: workspaceRoot, configPath });
expect(result.dryRun).toBe(true);
expect(result.subdirs.map((s) => s.name)).toEqual(expect.arrayContaining([
"missions",
"artifacts",
"evidence",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
"missions/getting-started/slices/first-conveyor-run",
"missions/getting-started/slices/inspect-project-evidence",
]));
expect(existsSync(workspaceRoot)).toBe(false);
});
it("command --json emits parseable JSON for agent setup flows", async () => {
const cmd = initWorkspaceCommand(configPath);
const logs: string[] = [];
const origLog = console.log;
console.log = (msg?: unknown) => { logs.push(String(msg)); };
try {
await cmd.parseAsync(["node", "init-workspace", "--root", workspaceRoot, "--dry-run", "--json"]);
} finally {
console.log = origLog;
}
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
expect(parsed.root).toBe(workspaceRoot);
expect(parsed.dryRun).toBe(true);
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/getting-started/slices/first-conveyor-run");
});
it("rig-level --json emits parseable JSON for config init-workspace", async () => {
const cmd = new Command("rig");
cmd.exitOverride();
cmd.option("--json", "emit machine-readable JSON");
cmd.addCommand(configCommand(configPath));
const logs: string[] = [];
const origLog = console.log;
console.log = (msg?: unknown) => { logs.push(String(msg)); };
try {
await cmd.parseAsync([
"node",
"rig",
"--json",
"config",
"init-workspace",
"--root",
workspaceRoot,
"--dry-run",
]);
} finally {
console.log = origLog;
}
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
expect(parsed.root).toBe(workspaceRoot);
expect(parsed.dryRun).toBe(true);
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/getting-started/slices/inspect-project-evidence");
});
it("creates mission-aware workspace files + STEERING placeholder", () => {
const result = runInitWorkspace({ root: workspaceRoot, configPath });
expect(result.dryRun).toBe(false);
expect(existsSync(workspaceRoot)).toBe(true);
for (const sub of ["missions", "artifacts", "evidence", "progress", "field-notes", "specs", "dogfood-evidence"]) {
expect(existsSync(join(workspaceRoot, sub))).toBe(true);
expect(existsSync(join(workspaceRoot, sub, "README.md"))).toBe(true);
}
expect(existsSync(join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md"))).toBe(true);
expect(existsSync(join(workspaceRoot, "missions", "getting-started", "slices", "inspect-project-evidence", "IMPLEMENTATION-PRD.md"))).toBe(true);
const steeringMd = readFileSync(join(workspaceRoot, "STEERING.md"), "utf-8");
expect(steeringMd).toContain("OpenRig Priority Stack");
});
it("is idempotent: running twice without --force is a no-op for existing files", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const sliceReadme = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md");
writeFileSync(sliceReadme, "operator-edited content", "utf-8");
const second = runInitWorkspace({ root: workspaceRoot, configPath });
const sliceFile = second.files.find((f) => f.relPath === "missions/getting-started/slices/first-conveyor-run/README.md");
expect(sliceFile?.skipped).toBe("exists");
expect(readFileSync(sliceReadme, "utf-8")).toBe("operator-edited content");
});
it("--force overwrites existing files but never deletes operator content under directories", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const operatorFile = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "operator-note.md");
writeFileSync(operatorFile, "my work", "utf-8");
const operatorReadme = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md");
writeFileSync(operatorReadme, "edited", "utf-8");
runInitWorkspace({ root: workspaceRoot, force: true, configPath });
// Operator file under the subdir survives
expect(existsSync(operatorFile)).toBe(true);
expect(readFileSync(operatorFile, "utf-8")).toBe("my work");
// README is overwritten
expect(readFileSync(operatorReadme, "utf-8")).toContain("# First Conveyor Run");
});
it("--root override beats configured workspace.root", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/should/not/be/used");
const result = runInitWorkspace({ root: workspaceRoot, configPath });
expect(result.root).toBe(workspaceRoot);
expect(existsSync(workspaceRoot)).toBe(true);
});
it("reads workspace.root from settings when --root is not given", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", workspaceRoot);
const result = runInitWorkspace({ configPath });
expect(result.root).toBe(workspaceRoot);
expect(existsSync(workspaceRoot)).toBe(true);
});
});
+357
View File
@@ -0,0 +1,357 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family tests.
//
// Stands up a small in-memory daemon mock for the /api/context-packs/*
// surface and exercises each subcommand against it.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { contextPackCommand } from "../src/commands/context-pack.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander.exitOverride */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
const FIXTURE_PACK = {
id: "context-pack:smoke:1",
kind: "context-pack" as const,
name: "smoke",
version: "1",
purpose: "Smoke test pack",
sourceType: "user_file" as const,
sourcePath: "/home/op/.openrig/context-packs/smoke",
relativePath: "smoke",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 100,
files: [
{ path: "notes.md", role: "notes", summary: "Smoke notes", absolutePath: "/abs/notes.md", bytes: 50, estimatedTokens: 13 },
],
};
const FIXTURE_PREVIEW = {
id: FIXTURE_PACK.id,
name: FIXTURE_PACK.name,
version: FIXTURE_PACK.version,
bundleText: "# OpenRig Context Pack: smoke v1\n\nSmoke test pack\n\n## File: notes.md (role: notes) — Smoke notes\n\nSmoke body\n",
bundleBytes: 110,
estimatedTokens: 28,
files: [{ path: "notes.md", role: "notes", bytes: 50, estimatedTokens: 13 }],
missingFiles: [] as Array<{ path: string; role: string }>,
};
describe("rig context-pack CLI (PL-014)", () => {
let server: http.Server;
let port: number;
let sendLog: Array<{ id: string; body: unknown }>;
let sendBehavior: "ok" | "fail" = "ok";
beforeAll(async () => {
sendLog = [];
server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
const url = req.url ?? "";
if (url === "/api/context-packs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([FIXTURE_PACK]));
return;
}
if (url === "/api/context-packs/library/sync" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: 1, errors: [], entries: [FIXTURE_PACK] }));
return;
}
const idMatch = url.match(/^\/api\/context-packs\/library\/([^/]+)(\/(preview|send))?$/);
if (idMatch) {
const id = decodeURIComponent(idMatch[1]!);
const sub = idMatch[3];
if (id !== FIXTURE_PACK.id) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Context pack '${id}' not found in library` }));
return;
}
if (sub === "preview" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PREVIEW));
return;
}
if (sub === "send" && req.method === "POST") {
const parsed = JSON.parse(body || "{}") as { destinationSession?: string; dryRun?: boolean };
sendLog.push({ id, body: parsed });
if (sendBehavior === "fail") {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found", reason: "session_missing" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
...FIXTURE_PREVIEW,
destinationSession: parsed.destinationSession,
dryRun: !!parsed.dryRun,
...(parsed.dryRun ? {} : { sent: true }),
}));
return;
}
if (!sub && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PACK));
return;
}
}
res.writeHead(404);
res.end();
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as { port: number }).port;
});
afterAll(() => server.close());
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(contextPackCommand(runningDeps(port)));
return prog;
}
function writePack(manifest: string): string {
const dir = mkdtempSync(join(tmpdir(), "openrig-context-pack-test-"));
writeFileSync(join(dir, "manifest.yaml"), manifest);
return dir;
}
it.each([
[
"path traversal",
`name: invalid-pack
version: 1.0.0
files:
- path: ../secret.md
role: notes
`,
"must be a relative path inside the pack",
],
[
"absolute path",
`name: invalid-pack
version: 1.0.0
files:
- path: /etc/passwd
role: notes
`,
"must be a relative path inside the pack",
],
[
"leading backslash",
`name: invalid-pack
version: 1.0.0
files:
- path: '\\evil.md'
role: notes
`,
"must be a relative path inside the pack",
],
[
"unknown suffix",
`name: invalid-pack
version: 1.0.0
files:
- path: secret.bin
role: notes
`,
"has an unsupported suffix",
],
[
"missing name",
`version: 1.0.0
files:
- path: notes.md
role: notes
`,
"missing required field 'name'",
],
[
"missing version",
`name: invalid-pack
files:
- path: notes.md
role: notes
`,
"missing required field 'version'",
],
[
"missing files",
`name: invalid-pack
version: 1.0.0
`,
"must declare 'files: [...]'",
],
[
"missing path",
`name: invalid-pack
version: 1.0.0
files:
- role: notes
`,
"missing 'path'",
],
[
"missing role",
`name: invalid-pack
version: 1.0.0
files:
- path: notes.md
`,
"missing 'role'",
],
])("rejects invalid context-pack add manifest: %s", async (_name, manifest, expectedError) => {
const dir = writePack(manifest);
try {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "add", dir]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain(expectedError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("list shows discovered packs", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("smoke");
expect(logs.join("\n")).toContain("1 files");
});
it("list --json emits JSON", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list", "--json"]);
});
const parsed = JSON.parse(logs.join("")) as Array<{ name: string }>;
expect(parsed[0]!.name).toBe("smoke");
});
it("show resolves by name", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "smoke"]);
});
expect(logs.join("\n")).toContain("Name: smoke");
expect(logs.join("\n")).toContain("Smoke test pack");
});
it("show fails with helpful error on unknown name", async () => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "missing-pack"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("not found in library");
});
it("preview prints the assembled bundle text", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "preview", "smoke"]);
});
expect(logs.join("\n")).toContain("# OpenRig Context Pack: smoke v1");
expect(logs.join("\n")).toContain("Smoke body");
});
it("send --dry-run does not invoke the daemon send path twice", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig", "--dry-run"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog).toHaveLength(1);
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: true });
expect(logs.join("\n")).toContain("(dry-run)");
});
it("send (real) reports successful delivery", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: false });
expect(logs.join("\n")).toContain("Sent smoke v1");
});
it("send fails fast when daemon returns 502", async () => {
sendLog.length = 0;
sendBehavior = "fail";
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "missing@rig"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("Session not found");
sendBehavior = "ok";
});
it("sync reports indexed count", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "sync"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("Indexed 1 context pack");
});
});
+255
View File
@@ -0,0 +1,255 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { contextCommand, type ContextDeps } from "../src/commands/context.js";
function makeDeps(overrides?: {
daemonState?: string;
rigNames?: string[];
nodesByRig?: Record<string, unknown[]>;
}): ContextDeps {
const rigs = (overrides?.rigNames ?? ["test-rig"]).map((name, i) => ({
rigId: `rig-${i}`, name, nodeCount: 1, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null,
}));
const nodesByRig = overrides?.nodesByRig ?? {
"rig-0": [
{
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.impl", canonicalSessionName: "dev-impl@test-rig",
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
contextUsage: {
usedPercentage: 85, remainingPercentage: 15, contextWindowSize: 1000000,
source: "claude_statusline_json", availability: "known",
sampledAt: new Date(Date.now() - 60_000).toISOString(), fresh: true,
},
},
{
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.qa", canonicalSessionName: "dev-qa@test-rig",
nodeKind: "agent", runtime: "codex", sessionStatus: "running", startupStatus: "ready",
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
contextUsage: { usedPercentage: null, remainingPercentage: null, contextWindowSize: null, source: null, availability: "unknown", sampledAt: null, fresh: false },
},
{
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.synth", canonicalSessionName: "dev-synth@test-rig",
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
contextUsage: {
usedPercentage: 50, remainingPercentage: 50, contextWindowSize: 1000000,
source: "claude_statusline_json", availability: "known",
sampledAt: new Date(Date.now() - 3600_000).toISOString(), fresh: false,
},
},
],
};
return {
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
if (path === "/api/ps") return { status: 200, data: rigs };
for (const [key, nodes] of Object.entries(nodesByRig)) {
if (path.includes(key)) return { status: 200, data: nodes };
}
return { status: 200, data: [] };
}),
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
};
}
// Patch getDaemonStatus + getDaemonUrl for tests
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
describe("rig context", () => {
let logs: string[];
let errors: string[];
beforeEach(() => {
logs = [];
errors = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
process.exitCode = undefined;
});
it("human output includes seat table with status + fleet summary", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig"]);
const output = logs.join("\n");
expect(output).toContain("CONTEXT USAGE");
expect(output).toContain("dev-impl@test-rig");
expect(output).toContain("CRITICAL");
expect(output).toContain("dev-qa@test-rig");
expect(output).toContain("unknown");
expect(output).toContain("dev-synth@test-rig");
expect(output).toContain("stale");
expect(output).toContain("FLEET SUMMARY:");
});
it("JSON output includes seats array + summary with correct fields", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.seats).toBeInstanceOf(Array);
expect(json.seats.length).toBe(3);
expect(json.summary).toBeDefined();
expect(json.summary.total).toBe(3);
const critical = json.seats.find((s: { session: string }) => s.session === "dev-impl@test-rig");
expect(critical.urgency).toBe("critical");
expect(critical.freshness).toBe("fresh");
expect(critical.status).toBe("critical");
expect(critical.usedPercentage).toBe(85);
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
expect(unknown.urgency).toBe("unknown");
expect(unknown.status).toBe("unknown");
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
expect(stale.urgency).toBe("low");
expect(stale.freshness).toBe("stale");
expect(stale.status).toBe("stale");
});
it("sort order: critical > warning > stale > ok > unknown", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const sessions = json.seats.map((s: { session: string }) => s.session);
// critical first, then stale (low+stale), then unknown
expect(sessions).toEqual(["dev-impl@test-rig", "dev-synth@test-rig", "dev-qa@test-rig"]);
});
it("--rig filters to one rig", async () => {
const cmd = contextCommand(makeDeps({ rigNames: ["rig-a", "rig-b"] }));
await cmd.parseAsync(["node", "rig", "--rig", "rig-a", "--json"]);
const output = logs.join("\n");
// Should not error — even if node data is empty for the filtered rig
expect(process.exitCode).toBeUndefined();
});
it("--rig with unknown rig name exits 1", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--rig", "nonexistent"]);
expect(process.exitCode).toBe(1);
expect(errors.join(" ")).toContain("not found");
});
it("--threshold filters seats but keeps unknown and stale visible", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--threshold", "80", "--json"]);
const json = JSON.parse(logs.join(""));
const sessions = json.seats.map((s: { session: string }) => s.session);
// 85% critical (above threshold) + 50% stale (always visible) + unknown (always visible)
expect(sessions).toContain("dev-impl@test-rig");
expect(sessions).toContain("dev-synth@test-rig"); // stale — always visible
expect(sessions).toContain("dev-qa@test-rig"); // unknown — always visible
});
it("--threshold abc rejects with nonzero exit", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--threshold", "abc"]);
expect(process.exitCode).toBe(2);
expect(errors.join(" ")).toContain("integer percentage");
});
it("--threshold -1 rejects with nonzero exit", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--threshold", "-1"]);
expect(process.exitCode).toBe(2);
});
it("low + fresh = ok; low + stale = stale (never ok)", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
// 50% (low urgency) + stale freshness => status must be "stale", NOT "ok"
expect(stale.status).toBe("stale");
expect(stale.displayStatus).toBe("stale");
});
it("unknown seats have honest unknown status", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
expect(unknown.usedPercentage).toBeNull();
expect(unknown.urgency).toBe("unknown");
expect(unknown.status).toBe("unknown");
expect(unknown.fresh).toBe(false);
});
// --- --refresh CLI tests ---
it("--refresh calls /nodes?refresh=true once then fetches inventory normally", async () => {
const requestedPaths: string[] = [];
const deps: ContextDeps = {
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
requestedPaths.push(path);
if (path === "/api/ps") return { status: 200, data: [{ rigId: "rig-0", name: "test-rig" }] };
return { status: 200, data: [] };
}),
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
};
const cmd = contextCommand(deps);
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
// First non-ps call should be refresh, then normal inventory
const nodeCalls = requestedPaths.filter((p) => p.includes("/nodes"));
expect(nodeCalls.length).toBe(2);
expect(nodeCalls[0]).toContain("refresh=true");
expect(nodeCalls[1]).not.toContain("refresh=true");
expect(process.exitCode).toBeUndefined();
});
it("--refresh failure exits 2 with honest error copy", async () => {
const deps: ContextDeps = {
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
if (path === "/api/ps") return { status: 200, data: [{ rigId: "rig-0", name: "test-rig" }] };
if (path.includes("refresh=true")) return { status: 502, data: { code: "context_refresh_failed", error: "statusline read failed" } };
return { status: 200, data: [] };
}),
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
};
const cmd = contextCommand(deps);
await cmd.parseAsync(["node", "rig", "--refresh"]);
expect(process.exitCode).toBe(2);
const errorOutput = errors.join("\n");
expect(errorOutput).toContain("Context refresh failed");
expect(errorOutput).toContain("Fix:");
});
it("--refresh success returns same JSON semantics as non-refresh", async () => {
const deps = makeDeps();
const cmd = contextCommand(deps);
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.seats).toBeInstanceOf(Array);
expect(json.summary).toBeDefined();
// Should have the same structure as non-refresh output
expect(json.summary.total).toBeDefined();
expect(process.exitCode).toBeUndefined();
});
});
Loaded 100 of 882 files, more files were not shown because too many files have changed in this diff. Show more