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
399 changed files with 3194 additions and 40627 deletions

No files matched your search

-372
View File
@@ -8,378 +8,6 @@ deprecations, and behavioral changes. Breaking changes are called out explicitly
---
## [0.3.1] - 2026-05-15
**Status**: released. npm `@openrig/[email protected]` (latest); GitHub Release
`v0.3.1`; git tag `v0.3.1`.
### Summary For Installing Agents
- **Package version**: package metadata bumped at release-manager step; CLI
reports the new version after `npm publish`.
- **Migrations**: no schema-breaking migrations in 0.3.1. Existing databases
upgrade by running `rig daemon start`.
- **Node engines**: unchanged from 0.3.0 (CLI accepts Node `>=20`).
- **Backward compatibility**: existing CLI argument shapes, daemon route
paths, RigSpec/AgentSpec schemas, and persisted settings remain backward
compatible. New routes are additive. New ConfigStore keys are opt-in
default-off.
### Claude Auto-Compaction Policy
The headline 0.3.1 feature: operator-configurable Claude session
auto-compaction with safe defaults.
- **Opt-in default-off**: with no policy configured, no behavior change. The
daemon never sends an auto-`/compact` until the operator explicitly enables
the policy.
- **7 new ConfigStore keys** in the `policies.claude_compaction.*` namespace
(lockstep across CLI VALID_KEYS + daemon SETTINGS_VALID_KEYS): `enabled`,
`threshold_percent` (strict integer 1-100), `compact_instruction` (default
empty; appended to `/compact` slash-command args when set),
`message_inline`, `message_file_path`, `pre_compact_instruction`,
`post_restore_audit_instruction`. Strict validation across all source
layers (`set/POST/env/file`) — invalid env values fall back to defaults
with a warning to stderr rather than silently coercing.
- **5 operator-editable prompt surfaces** rendered in the
Settings → Policies UI form: pre-compaction prep, compact instruction,
post-compaction restore (inline), restore file path, post-restore audit.
Daemon-owned wrappers (usage/threshold framing, trust-channel preservation,
marker paths, read-depth enforcement, turn-boundary handshake, dedup +
cooldown) are non-editable.
- **6-stage daemon-to-LLM lifecycle**:
1. Pre-compact prep prompt — full-context Claude writes a mental-model
restore map (annotated ASCII file/folder tree) before compaction
("save game before quit").
2. `/compact` with operator instructions + trust-channel preservation
embedded in args.
3. Post-compact turn-boundary handshake — non-restorative acknowledgment
creates an assistant-turn boundary so the subsequent restore prompt
lands in the correct trust context.
4. Restore prompt — explicit user-request shape; defense-in-depth
fallback chain (marker → JSONL transcript path → session-id → generic).
5. Compliance prompt — forces FULL/PARTIAL/NOT_READ read-depth audit
table; counters Claude's deferred-execution + token-conservation
instincts.
6. Cooldown (10-minute default) — prevents re-fire while restore work
is still consuming context.
- **PreCompact hook + SessionStart/UserPromptSubmit bridge**: the openrig-core
plugin ships a marker-bridge that picks up pending-restore markers
post-compaction and injects restore directives once. Templates live in
the `claude-compaction-restore` skill and ship with the plugin.
- **Safety hardening**: SessionTransport classifies typed prompt drafts as
attention state — auto-`/compact` retries later instead of overwriting
human input. Send-failure does not advance dedup state (transient retry).
Re-arm requires threshold-crossing (session must drop below threshold
before next auto-compact).
### Library Explorer Finishing
The Library destination (skills + plugins + specs) became a fully
operator-facing surface:
- **No duplicate top-level entries**: `> SKILLS` and `> PLUGINS` rows
removed from the top of `SpecsTreeView`. Bottom-row clicks now do
dual-action (navigate to the matching index page + expand the tree).
- **Reorder**: Plugins above Skills.
- **OpenRig-managed skills discovery**: 32 shared skills now visible in
the tree + on `/specs/skills` index. Previously the workspace-relative
path lookup returned empty in production VM environments.
- **Daemon-owned library discovery API** (new):
- `GET /api/skills/library` → consolidated `LibrarySkillPublic[]`
(workspace + openrig-managed sources; absolute paths not leaked).
- `GET /api/skills/:id/files/list?path=<rel>` + `/api/skills/:id/files/read?path=<rel>` —
skill folder browse + content read.
- `GET /api/plugins/:id/files/list?path=<rel>` + `/api/plugins/:id/files/read?path=<rel>` —
plugin folder browse + content read.
- `PluginEntry.skillCount` field added to plugin discovery
serialization.
- **Real file-browser docs-browser on plugin and skill detail pages**:
detail pages mount a `DirectoryTree` + `FileContentPanel` against the
real plugin/skill folder. Markdown auto-renders; non-markdown files
(e.g. `.ts`, `.json`) render as text. Folder navigation works (entering
subfolders + listing files).
- **Rolled-up index pages**: `/specs/skills` lists all skills as flat rows
with source label + file count + entry link. `/specs/plugins` lists all
plugins as rows with version + runtimes + skill-count + entry link.
### Plugin Primitive v0
- **Plugin discovery**: vendored plugins under `$OPENRIG_HOME/plugins/`
(default `~/.openrig/plugins/`) are discovered, validated, and
surfaced through `rig plugin list` + `/api/plugins`.
- **Plugin install (v0)**: explicit operator copy or symlink to
`$OPENRIG_HOME/plugins/<plugin-id>/`. A `rig plugin install
<substrate-path>` verb is deferred to 0.3.2; see `OPENRIG-INSTALL.md`
inside each plugin's source for the documented copy/symlink workflow.
- **CLI**: `rig plugin list` / `show` / `used-by` / `validate` subcommands
available. No `install` subcommand at v0.
- **Plugins shipped as substrate references** (for plugin authors to
copy-install): `gstack` (45 skills), `obra-superpowers` (14 skills).
`openrig-core` ships bundled with the daemon (11 skills).
### Settings Destination Explorer
Settings became a 4-item Explorer destination matching Topology /
Project / Library / For-You pattern:
- `/settings` (general config keys form), `/settings/policies`,
`/settings/log`, `/settings/status`.
- Old top-row tab nav removed.
- Shared `SettingsPageShell` chrome across all 4 sub-routes.
- Policies page is the home for the Claude auto-compaction policy form
(see above).
### CMUX Launcher
- **Launch in CMUX button** on the rig-scope topology tab-bar trailing
slot. Opens a cmux workspace for the rig with appropriate title +
cwd parameters. Powered by new daemon route + cmux adapter
extensions.
### Node-Page Overview + Details
- **Seat overview table** consolidated as 7-column horizontal layout with
vertical grid lines (Claude/Codex agent + status + context + tokens +
uptime + cwd + current-work).
- **Tab consolidation** + activity alignment on the node-detail surface.
- **Alert-only notification banner** (renders only on real-alert states:
`failed`, `attention_required`, or `latestError !== null`); generic
`recoveryGuidance` no longer triggers a banner on every seat.
- **cwd / current-work separation**: factored into a `SeatOverviewSecondary`
primitive below the column table.
### Mobile Drawer Behavior
The Explorer drawer at 375px viewports now layers above the mobile rail
tray for Settings / Project / Library / For-You destinations
(previously hidden behind rail-tray; visible click path didn't register
on Explorer items). Topology mobile drawer is intentionally hidden in
0.3.1 (clicking the hamburger triggered a pre-existing
TopologyTableView renderer cascade); the topology mobile drawer is
scheduled for full restoration in 0.3.2 via a dedicated
TopologyTableView render-path slice.
### Dashboard And For You Visual Refresh
The Dashboard (`/`) and For You (`/for-you`) destinations got a coordinated
visual refresh to a "vellum" surface language — translucent stone-tinted
cards over a paper-grid background, ambient multi-stop shadow, mono+dot
kind indicators, and L-shaped corner-bracket registration marks. The
chrome vocabulary is consistent across destination cards (Dashboard) and
both feed-card systems (For You).
- **Dashboard** — full rewrite of `/` into a thin composition over new
`dashboard/vellum/` primitives (`BackVellumSheet`, `MidLayerContent`,
`TopLayerContent`, `DestinationsLayer`, `VellumDestinationCard`,
`CornerBracket`, `graphics.tsx`, `marks.tsx`). Hero typography ("WELCOME
BACK") at display-lg + headline-bold; tactical instrument-panel stats
line with tabular numerals and a success-token active count. Six
destination cards (Topology / Project / For You / Library / Search /
Settings) share the same numeral-layout treatment. Real-data hooks
thread through (`useRigSummary`, `usePsEntries`, `useSpecLibrary`,
`window.location.hostname`).
- **For You** — both card systems unified to the same vellum recipe:
- Storytelling band: `CardShell` rewritten to bg-stone-100/45 +
backdrop-blur-[10px] + ambient shadow + corner brackets; design-token
leading dots replace prior bg-emerald-50 / bg-amber-50 / etc.
off-brand utilities. Title at 16px headline-bold; body at 12px.
- Queue-item `FeedCard.tsx`: same outer chrome; `KIND_DOT` + `TONE_DOT`
design-token maps; vellum bordered-no-fill action buttons
(Approve/Deny/Route/Hold/Drop/Annotate/Handoff) with hover-invert and
44px touch targets; `TONE_RECEIPT` strip is a subtle bg-stone-50/40
with a leading colored dot.
- **Single source of truth** — `/dashboard` and `/lab/vellum-lab` both
import from `packages/ui/src/components/dashboard/vellum/index.js`.
Future visual changes hit one location.
- **Lab routes** — `/lab/card-previews`, `/lab/vellum-lab`,
`/lab/vellum-bg/{a-large,b-small,c-allover}` are checked-in experiment
surfaces for designer iteration. Reachable in production by direct URL;
not linked from main nav. Useful when iterating on the visual system.
### For You Storytelling Adapter
The storytelling band (top of `/for-you`) now wires four card kinds to
real data:
- **Progress** — from `useMissionDiscovery` (first 2 active missions)
- **Shipped** — from `useSlices` (status = shipped/complete/done; capped at 3)
- **Incident** — from `useSlices` (status = blocked/failed/danger or fallback "info"; capped at 3)
- **Approval** — from `useActivityFeed` + `classifyFeed` (kind === "approval";
capped at 2; qitemId extracted from event payload with snake_case alt
and `FeedCard.id` fallback). Surfaces real queue items waiting on
approval in a high-visibility band.
`ConceptCard` component is preserved in source but not emitted by the
production adapter — a deliberate data-source decision is scheduled for
0.3.2.
### Action Outcome And Inline Error Surface
Queue-item action buttons (`VerbActions` — Approve/Deny/Route/Hold/Drop/
Annotate/Handoff) now render outcomes immediately and surface failures
without silently reverting:
- **Optimistic outcome** — on mutation success, the
`ActionOutcomePanel` ("Approved by X" / "Routed by X to Y") renders
immediately. Audit-log roundtrip reconciles in background. Operator
no longer waits for a query refetch to see what happened.
- **Inline error surface** — on mutation error, a tertiary-bordered
error block renders below the verb buttons with the daemon's error
message; verb-selection state is preserved so the operator can
correct and retry. Replaces the prior silent-revert UX where errors
were never displayed.
- **React-query callback discipline** — `submit()` split into separate
`onSuccess` (optimistic outcome + reset selection) and `onError`
(set error message; do NOT reset). Regression test guards the
silent-revert class explicitly.
### Vendored Skill Provenance
Vendored skills shipped in `packages/daemon/specs/agents/shared/skills/`
now declare their upstream lineage via `metadata.openrig` frontmatter
and (when modifications exist) a companion `OPENRIG.md` sidecar:
- **vendoring_pattern**: `vendored-as-is` | `modify-the-file` |
`add-supplementary-files`
- **vendored_from**: upstream source identifier
- **last_upstream_check**: most recent diff date
- **divergence_notes**: human-readable summary of OpenRig-specific changes
Applied to all 10 process-skill surfaces (agent-browser, executing-plans,
brainstorming, systematic-debugging, test-driven-development,
using-superpowers, verification-before-completion, writing-plans,
frontend-design, dogfood). `OPENRIG.md` sidecars added where the file
has been modified or supplemented (agent-browser + executing-plans +
brainstorming + using-superpowers + writing-plans). Convention is
documented in the `writing-skills-for-openrig` skill.
### Plugins And Skills On The VM (Operator Note)
Operators dogfood-testing 0.3.1 should expect:
- **Stock VM install**: only `openrig-core` plugin is bundled. The
`/specs/plugins` UI list will show one plugin until the operator
installs additional plugins per the v0 copy workflow.
- **Skills**: 32 OpenRig-managed shared skills ship under
`packages/daemon/specs/agents/shared/skills/` (discovered via the
daemon skill-library API; no operator action required).
- **User-installed skills**: skills the operator installs under
`~/.openrig/skills/` or the workspace `.openrig/skills/` directory
surface in the same list.
### Config + Settings
Continues from 0.3.0 with the slice 08 validation pass:
- `rig config get/set/reset/list` remains the canonical CLI surface.
- Lockstep CLI `VALID_KEYS` + daemon `SETTINGS_VALID_KEYS` byte-identical
sets (verified in slice 08).
- Help-drift CI gate from slice 08 honored across all new keys added in
0.3.1.
### SC-29 Exceptions
The SC-29 exception process tracks explicit scope expansions to release
contracts. Exceptions declared in 0.3.1:
- **#10 (slice 24)**: cmux launcher — `POST /api/rigs/:rigId/cmux/launch`
+ CmuxLayoutService + 4 CmuxAdapter RPC methods.
- **#10 (slice 27, numbering collision)**: Claude auto-compaction policy
— 7 `policies.claude_compaction.*` ConfigStore keys. (Numbering
collision with #10 above is a process-only inconsistency; both code
scopes are correctly merged. Canonical `SC29-LEDGER.md` and ledger
hygiene scheduled for 0.3.2.)
- **#11 (slice 28)**: Library Explorer daemon API — 5 new daemon GET
endpoints (`/api/skills/library`, `/api/skills/:id/files/list`,
`/api/skills/:id/files/read`, `/api/plugins/:id/files/list`,
`/api/plugins/:id/files/read`) + 2 response shape additions
(`PluginEntry.skillCount`, `LibrarySkillPublic`).
### Known Carry-Forwards (0.3.2 Candidates)
- **`rig plugin install <substrate-path>` verb**: explicitly deferred.
Documented copy/symlink workflow is the v0 install path.
- **Topology mobile drawer**: hidden in 0.3.1 to avoid a pre-existing
TopologyTableView renderer cascade at 375px viewports. Full
restoration scheduled for 0.3.2 via dedicated render-path slice.
- **Plugin source-label taxonomy**: copy-installed plugins currently
land in the `vendored` source kind; UI label says "No user-installed
plugins" while listing them. Taxonomy refinement scheduled for 0.3.2.
- **`SC29-LEDGER.md`**: canonical SC-29 numbering ledger document.
Authors currently self-assign exception numbers; documented ledger
prevents collisions like the slice 24/27 #10.
- **VM PreCompact hook installer**: the documented install path for the
`claude-compaction-restore` skill is operator-manual at v0; an
automated installer is a 0.3.2 candidate.
- **`docs/DESIGN.md` docs-guard violation**: pre-existing
`npm run test:repo` failure; tracked doc outside allowed paths;
cleanup scheduled for 0.3.2 documentation hygiene pass.
- **Environment-dependent tests**: a small number of vitest suites fail
on developer hosts due to port-conflicts (preflight) or live host
Claude hook state (restore-check); focused-test gates pass these
suites; cumulative-workspace runs surface the gaps. Isolation cleanup
scheduled for 0.3.2.
- **Cross-daemon route awareness**: the VerbActions destination dropdown
on `/for-you` currently lists session names without checking whether
the local daemon can reach them. Routing to a non-local destination
surfaces as an inline error (per the new error surface above) but the
dropdown should ideally filter to local-daemon seats. 0.3.2 candidate.
- **ConceptCard data source**: `ConceptCard` component is preserved in
source but not wired in the production adapter. A 0.3.2 slice will
pick a deliberate data source (likely shaped backlog candidates or
early-stage discovery items).
- **Warm error messages on demo surfaces**: daemon error strings (e.g.,
"queue item not found") surface verbatim through the inline error
surface. Demo-grade polish to humanize these on user-facing surfaces
is scheduled for 0.3.2.
### Banked Discipline Patterns
0.3.1 surfaced a number of canonical agent-software-design patterns
during the Claude compaction iteration cycle and Library Explorer
finishing work. These are banked in operator-skill documentation for
agent-prompt design + daemon-to-LLM trust establishment:
- **Channel model**: normal user message is the only authorized action
surface; hook stdout is informational-only; `/compact` args carry
trust contracts that the post-compact prompt invokes.
- **Turn-boundary handshake**: when a daemon-driven action request
would land too adjacent to local-command output, insert a
non-committing acknowledgment message first to create an
assistant-turn boundary.
- **Save-game-before-quit pattern**: full-context agent writes
restoration breadcrumb before forced context loss; context-loss
agent reads it on restore.
- **Structured-output forces completeness**: ask LLMs for explicit
FULL/PARTIAL/NOT_READ accounting when thoroughness matters; counters
token-conservation instincts.
- **Daemon-owned shared-resource discovery**: skill/plugin discovery
belongs at the daemon layer with HTTP endpoint surfaces; UI consumes
via typed API. Avoids workspace-cwd-relative path-resolution
brittleness.
### Quick Verification Commands
```bash
# Confirm CLI version after the release-manager version bump
rig --version
# Confirm daemon starts cleanly
rig daemon start
# Confirm new Claude compaction policy keys are visible
rig config list | grep policies.claude_compaction
# Confirm plugins discoverable
rig plugin list
# Confirm skills discoverable
curl -s http://localhost:7433/api/skills/library | jq 'length'
```
---
## [0.3.0] - 2026-05-10
**Status**: release candidate for public publish. This entry documents the
View File
File renamed without changes.
+1 -1
View File
@@ -94,7 +94,7 @@ CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services
Domain services (52)
|
SQLite + tmux + runtime adapters
```
@@ -2,8 +2,8 @@
Source capture:
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
- host-forwarded UI: `http://127.0.0.1:<host-port>`
- guest daemon: `<guest-ip>:<guest-port>`
- host-forwarded UI: `http://127.0.0.1:17439`
- guest daemon: `192.168.64.9:7433`
Purpose:
- README walkthrough
+1 -1
View File
@@ -175,7 +175,7 @@ The CLI-hosted MCP server exposes 17 tools:
### UI architecture
The current UI is shell-first, route-first, and primitive-driven. See
`docs/DESIGN.md` for the brand/design spec and `docs/as-built/ui.md` for the detailed
`DESIGN.md` for the brand/design spec and `docs/as-built/ui.md` for the detailed
as-built UI map.
High-level shape:
+4 -27
View File
@@ -1,6 +1,6 @@
# OpenRig CLI Reference
Verified against the shipped CLI on 2026-05-15 (v0.3.1) 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,7 +11,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
## Overview
- Binary: `rig`
- Top-level command groups: `57`
- Top-level command groups: `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`
@@ -77,7 +77,6 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `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) |
| `plugin` | Inspect plugins (read-only) — list, show, used-by, validate |
## Core Daemon and System Commands
@@ -628,12 +627,12 @@ hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: your-username # optional
user: wrandom # optional
notes: "Tart VM" # optional
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: your-username
user: wrandom
```
Validation rules:
@@ -924,28 +923,6 @@ Notes:
- 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.
## Plugin Inspection (v0.3.1)
One read-only top-level command added in v0.3.1 to inspect plugins
discovered from `$OPENRIG_HOME/plugins/` (default `~/.openrig/plugins/`).
No `install` verb at v0 — installation is explicit operator copy or
symlink per each plugin's `OPENRIG-INSTALL.md`.
### `rig plugin`
Usage: `rig plugin <subcommand>` — read-only plugin inspection.
Subcommands:
- `list [options]` — list discoverable plugins (aggregated across vendored + runtime caches).
- `show <id> [options]` — show plugin manifest + skills + hooks + mcp servers.
- `used-by <id> [options]` — list agents referencing this plugin in their `profile.uses.plugins[]`.
- `validate <path> [options]` — validate plugin manifest + skill frontmatter against the agentskills.io spec.
Notes:
- Plugin discovery aggregates `$OPENRIG_HOME/plugins/` (vendored at runtime by the operator) with the daemon's bundled plugin cache.
- `openrig-core` ships bundled with the daemon (11 skills). Additional plugins (`gstack` — 45 skills; `obra-superpowers` — 14 skills) ship as substrate references for plugin authors to copy-install per the `OPENRIG-INSTALL.md` workflow inside each plugin's source tree.
- A first-class `rig plugin install <substrate-path>` verb is deferred to 0.3.2.
## Commands Not Present
These are not current top-level `rig` commands:
+1 -1
View File
@@ -1,7 +1,7 @@
# OpenRig UI As-Built
This document describes the current UI architecture and component system. The
brand/design rules live in `docs/DESIGN.md`; this file focuses on how the shipped UI
brand/design rules live in `DESIGN.md`; this file focuses on how the shipped UI
is assembled.
## Package Shape
-336
View File
@@ -1,336 +0,0 @@
# OpenRig v0.3.1
## Summary
OpenRig v0.3.1 builds on v0.3.0 with operator-facing UX work, the headline
Claude auto-compaction policy, a dashboard + For You visual refresh, the
Plugin Primitive v0 surface (`rig plugin`), Library Explorer finishing
(skills + plugins + specs with real folder browsing), Settings Destination
Explorer, action outcome + inline error UX, and a comprehensive privacy
hygiene pass.
No schema-breaking migrations. Existing v0.3.0 databases upgrade on first
`rig daemon start` (adds migration `040_workflow_specs_diagnostic` — two
new diagnostic columns on `workflow_specs` cache, `ALTER TABLE ADD COLUMN`
with safe defaults).
## Included In This Release
- **Claude Auto-Compaction Policy** — operator-configurable session
auto-`/compact` with safe defaults (opt-in default-off; no behavior
change unless explicitly enabled). 7 new ConfigStore keys; 6-stage
daemon-to-LLM lifecycle (pre-prep prompt → compact → handshake →
restore → compliance audit → cooldown); PreCompact hook +
SessionStart/UserPromptSubmit bridge.
- **Plugin Primitive v0** — `rig plugin` command surface (read-only at
v0: `list`, `show`, `used-by`, `validate`). Plugin discovery from
`$OPENRIG_HOME/plugins/`, vendored-and-provenance pattern. No
`install` verb at v0; deferred to 0.3.2.
- **Library Explorer Finishing** — skills + plugins + specs as a
fully operator-facing surface with real folder browsing. New
daemon-owned library discovery API (`/api/skills/library`,
`/api/skills/:id/files/...`, `/api/plugins/:id/files/...`).
- **Settings Destination Explorer** — settings became a 4-item
Explorer destination matching Topology / Project / Library / For-You
pattern, with `/settings`, `/settings/policies`, `/settings/log`,
`/settings/status` sub-routes.
- **Dashboard And For You Visual Refresh** — both routes adopt a
shared "vellum" surface language (translucent stone-tinted cards,
paper-grid background, ambient shadow, mono+dot kind indicators,
L-shaped corner-bracket registration marks). Single source of truth
between production and `/lab/vellum-lab`.
- **For You Storytelling Adapter** — Progress / Shipped / Incident /
Approval card kinds wired to real data (missions, slices, activity
feed, queue items). ApprovalCard surfaces real queue items waiting
on approval.
- **Action Outcome + Inline Error Surface** — queue-item verb actions
(Approve/Deny/Route/Hold/Drop/Annotate/Handoff) render outcomes
immediately on success and surface failures inline without
silently reverting. Closes a demo-blocking silent-revert bug.
- **Mobile Drawer Behavior** — 375px Explorer drawer now layers above
the rail tray for Settings / Project / Library / For-You. Topology
mobile drawer is intentionally hidden in 0.3.1; scheduled for full
restoration in 0.3.2.
- **Vendored Skill Provenance** — vendored skills declare upstream
lineage via `metadata.openrig` frontmatter and (when modified) a
companion `OPENRIG.md` sidecar. Applied to all 10 process-skill
surfaces.
- **Workflow specs diagnostic surface** — migration `040` adds
`status` + `error_message` columns to the `workflow_specs` cache so
the Library UI can render parse/validation errors as diagnostic
rows instead of hiding them.
- **Privacy hygiene pass** — three filter-repo passes scrubbed
personal data, internal references, and historic versions from
history; commit subjects and bodies polished for public surface.
Backup repos preserved for rollback.
## User-Facing Changes
### Claude Auto-Compaction Policy
Operator-configurable auto-`/compact` for Claude sessions, with safe
defaults:
- **Opt-in default-off**: nothing changes unless you explicitly enable
the policy. With no policy configured, the daemon never sends an
auto-`/compact`.
- **7 new ConfigStore keys** in the `policies.claude_compaction.*`
namespace: `enabled`, `threshold_percent` (strict integer 1-100),
`compact_instruction` (operator-supplied; appended to `/compact`
args when set), `message_inline`, `message_file_path`,
`pre_compact_instruction`, `post_restore_audit_instruction`. Strict
validation across all source layers (`set`/`POST`/env/file). Invalid
env values fall back to defaults with a stderr warning rather than
silently coercing.
- **5 operator-editable prompt surfaces** in Settings → Policies UI:
pre-compaction prep, compact instruction, post-compaction restore
(inline), restore file path, post-restore audit. Daemon-owned
wrappers (usage/threshold framing, trust-channel preservation,
marker paths, read-depth enforcement, turn-boundary handshake,
dedup + cooldown) are non-editable.
- **6-stage lifecycle**: pre-compact prep prompt (save-game-before-quit
mental-model restore map) → `/compact` with operator instructions
→ post-compact turn-boundary handshake → restore prompt with
defense-in-depth fallback chain → compliance audit (forces
FULL/PARTIAL/NOT_READ table) → 10-minute cooldown.
- **Safety hardening**: typed prompt drafts classified as attention
state so auto-`/compact` retries later instead of overwriting human
input. Send-failure does not advance dedup state. Re-arm requires
threshold-crossing.
### New `rig plugin` Command (read-only at v0)
```
rig plugin list # Discoverable plugins across vendored + runtime caches
rig plugin show <id> # Plugin manifest + skills + hooks + mcp servers
rig plugin used-by <id> # Agents referencing this plugin
rig plugin validate <path> # Validate plugin manifest + skill frontmatter
```
No `install` verb at v0 — installation is explicit operator copy or
symlink to `$OPENRIG_HOME/plugins/<plugin-id>/`. See each plugin's
`OPENRIG-INSTALL.md` for the documented copy/symlink workflow. A
`rig plugin install <substrate-path>` verb is deferred to 0.3.2.
### Library Explorer Finishing
The Library destination (skills + plugins + specs) is now fully
operator-facing:
- No duplicate top-level entries (`> SKILLS` / `> PLUGINS` rows removed
from `SpecsTreeView`; bottom-row clicks navigate + expand the tree).
- Plugins above Skills in the tree.
- 32 OpenRig-managed shared skills are now discoverable in production
VM environments (previously empty due to workspace-relative path
lookup).
- Daemon-owned library discovery API: `GET /api/skills/library`,
`GET /api/skills/:id/files/list?path=...` + `/files/read?path=...`,
parallel routes under `/api/plugins/:id/`.
- Real file-browser + docs-browser on plugin and skill detail pages.
Markdown auto-renders; non-markdown files render as text. Folder
navigation works.
- Rolled-up index pages at `/specs/skills` and `/specs/plugins` with
flat-row listings, source labels, file counts, and entry links.
### Settings Destination Explorer
Settings is now a top-level Explorer destination matching the Topology /
Project / Library / For-You pattern:
- `/settings` (general config keys form)
- `/settings/policies` (Claude auto-compaction policy form lives here)
- `/settings/log`
- `/settings/status`
Old top-row tab navigation removed. Shared `SettingsPageShell` chrome
across all 4 sub-routes.
### Dashboard And For You Visual Refresh
A coordinated visual refresh to a shared "vellum" surface language:
- **Dashboard `/`** — full rewrite over new `dashboard/vellum/`
primitives. Hero typography ("WELCOME BACK") at display-lg + headline-
bold. Tactical instrument-panel stats line with tabular numerals.
Six destination cards (Topology / Project / For You / Library /
Search / Settings) share the numeral-layout treatment. Real-data
hooks: `useRigSummary`, `usePsEntries`, `useSpecLibrary`.
- **For You `/for-you`** — both card systems unified to the vellum
recipe: storytelling band (`CardShell` rewritten with bg-stone-100/45
+ backdrop-blur + ambient shadow + corner brackets) and queue-item
`FeedCard.tsx` (same outer chrome; vellum bordered-no-fill action
buttons with hover-invert and 44px touch targets).
- **Single source of truth** between `/dashboard` and
`/lab/vellum-lab` via `packages/ui/src/components/dashboard/vellum/`.
- **Lab routes** — `/lab/card-previews`, `/lab/vellum-lab`,
`/lab/vellum-bg/{a-large,b-small,c-allover}` are checked-in
experiment surfaces for designer iteration. Reachable in production
by direct URL; not linked from main navigation.
### For You Storytelling Adapter
The storytelling band at top of `/for-you` now wires four card kinds to
real data:
- **Progress** — first 2 active missions (`useMissionDiscovery`)
- **Shipped** — slices with status = shipped/complete/done (capped at 3;
from `useSlices`)
- **Incident** — slices with status = blocked/failed/danger (capped at 3)
- **Approval** — queue items waiting on approval, from
`useActivityFeed` + `classifyFeed` (capped at 2)
`ConceptCard` component is preserved in source but not emitted by the
production adapter; a deliberate data-source decision is scheduled for
0.3.2.
### Action Outcome And Inline Error Surface
Queue-item verb actions (Approve/Deny/Route/Hold/Drop/Annotate/Handoff):
- On success, `ActionOutcomePanel` renders immediately ("Approved by X",
"Routed by X to Y") — no waiting for audit-log roundtrip.
- On error, a tertiary-bordered error block renders below the verb
buttons with the daemon's error message; verb-selection state is
preserved so the operator can correct and retry.
- Replaces the prior silent-revert UX. Regression test guards the class
explicitly.
## Operator / Setup Notes
After upgrading the published CLI:
```bash
npm install -g @openrig/[email protected]
rig --version # expect 0.3.1
rig daemon stop
rig daemon start
```
Migration `040_workflow_specs_diagnostic` applies automatically on first
post-upgrade daemon start. It is a non-destructive `ALTER TABLE ADD
COLUMN` with safe defaults (existing rows retroactively get
`status: 'valid'`). No manual action required.
For Claude auto-compaction policy: opt-in default-off. Visit
`/settings/policies` to configure. Recommended for sessions where
context exhaustion is a recurring operational concern. Read the docs
section in the Settings UI before enabling.
For the new `rig plugin` surface: no behavior change unless you install
plugins. Bundled `openrig-core` plugin ships with the daemon (11 skills).
Operators can install `gstack` (45 skills) or `obra-superpowers`
(14 skills) by following each plugin's `OPENRIG-INSTALL.md` workflow.
## Known Limitations
- **`rig down <name>` still returns HTTP 404** — name-based teardown
still doesn't resolve through the daemon down route at v0.3.1. Use
`rig down <rigId> --delete` instead. Carries forward as known issue
to v0.3.2.
- **`rig queue list --json --limit` and `rig view list/show --json`
flag inconsistency** — flags scaffolded but not fully wired. Use
human-readable output for now. Carries forward to v0.3.2.
- **Topology mobile drawer hidden** at 375px viewports in 0.3.1
(intentional; clicking the hamburger triggered a pre-existing
`TopologyTableView` renderer cascade). Full restoration scheduled
for 0.3.2 via a dedicated render-path slice.
- **`ConceptCard` component preserved in source** but not emitted by
the production For You adapter. A deliberate data-source decision is
scheduled for 0.3.2.
- **`/lab/*` routes ship to production builds** intentionally per
founder direction; 0.3.2 adds an index page + per-lab banner +
manifest pattern.
- **Plugin install ergonomics** — v0 plugin install is operator-driven
copy or symlink; first-class `rig plugin install <substrate-path>`
is deferred to 0.3.2.
- **Lifecycle classifications** remain truthful but still rely on
heuristics rather than perfect provider-native primitives in some
recovery paths.
## Verification Performed
- `npm run lint` clean.
- `npm run build` clean across all workspaces.
- `npm run test:repo` green.
- Daemon test suite: 3600 pass, 1 pre-existing host-env-sensitive
failure on `restore-check-routes` reading malformed local Claude
settings (banked as environment-dependent class).
- UI test suite: 1106 pass / 0 fail (post-test-fixes merge; was 1093
pass + 3 failing on stale-test pre-fix).
- TypeScript checks clean on all touched files.
- Code review (review-lead, advisory-clean) on every merge in the
release window.
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
items, 8 feed events, 4 verified `/for-you` action scenarios
including the inline error path).
## Commits Included (highlights)
The full v0.3.0 → v0.3.1 delta is 224 commits / 376 files / +37,391
insertions / −3,153 deletions. The list below groups load-bearing
commits by theme:
### Claude Auto-Compaction Policy
- `feat(daemon,cli)`: Claude auto-compaction policy scaffolding +
ConfigStore keys
- `feat(daemon)`: 6-stage compaction lifecycle (pre-prep → compact →
handshake → restore → compliance → cooldown)
- `feat(daemon)`: PreCompact hook + SessionStart/UserPromptSubmit
bridge for marker restoration
- `feat(daemon)`: SessionTransport draft-attention classification +
send-failure dedup discipline
### Plugin Primitive v0
- `feat(daemon,cli)`: `rig plugin` command + plugin discovery surface
- `feat(daemon)`: plugin manifest + skill provenance metadata schema
- `chore(skills)`: vendoring metadata + `OPENRIG.md` sidecars for 10
process-skill surfaces
### Library Explorer
- `feat(daemon)`: library discovery API
(`/api/skills/library`, `/api/{skills,plugins}/:id/files/list+read`)
- `feat(ui)`: SpecsTreeView dual-action + plugin/skill detail file
browsers
- `feat(ui)`: rolled-up `/specs/skills` and `/specs/plugins` index
pages
### Settings Destination Explorer
- `feat(ui)`: 4-route Settings Explorer destination
(`/settings`/`policies`/`log`/`status`) with shared `SettingsPageShell`
### Dashboard + For You Visual Refresh
- `redesign(dashboard)`: full rewrite over `dashboard/vellum/`
primitives + 6 destination cards
- `redesign(for-you)`: vellum-coherent storytelling cards + queue-item
FeedCard chrome unification
- `refactor(dashboard)`: extract vellum primitives — single source of
truth between `/dashboard` and `/lab/vellum-lab`
- `simplify(dashboard)`: drop back layer + back vellum sheet; add
ambient shadow
- `merge(release)`: storytelling adapter extension — approval cards
wired to real data
- `feat(for-you)`: wire ApprovalCard to real data; remove ConceptCard
from production switch
- `fix(for-you)`: optimistic action outcome + inline error surface in
VerbActions
### Migration 040 (workflow_specs diagnostic)
- `feat(daemon)`: migration 040 adds `status` + `error_message`
diagnostic columns to `workflow_specs` cache
### Final test alignment
- `test(ui)`: fix 3 pre-existing failures — match deliberate design
changes
- `docs(changelog)`: cover 0.3.1 dashboard + for-you visual refresh +
action UX
### Privacy hygiene
- Three filter-repo passes (LOCAL-INSIGHTS personal-data scrub +
commit-body internal-doc reference scrub + file-content historic
versions scrub)
- Commit subject rewrites to remove privacy-scream patterns
- Force-pushed at commit `7e55d9d3` as the privacy-cleaned baseline
See `CHANGELOG.md` for the full agent-readable changelog with quick
verification commands and operator notes.
+6 -7
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.3.1",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.3.1",
"version": "0.3.0",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7419,7 +7419,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.3.1",
"version": "0.3.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7450,7 +7450,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.3.1",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7474,7 +7474,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.3.1",
"version": "0.3.0",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
@@ -7498,8 +7498,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.19",
"yaml": "^2.8.3"
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.3.1",
"version": "0.3.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.3.1",
"version": "0.3.0",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
@@ -35,7 +35,7 @@
"scripts"
],
"scripts": {
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/",
"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",
@@ -182,256 +182,7 @@ mission: ${mission.id}
`;
}
// V0.3.1 slice 21 onboarding-conveyor — narrative content for the
// getting-started mission's two slices. PARITY CONTRACT with
// `packages/daemon/src/domain/workspace/getting-started-narrative.ts`:
// the daemon side is the canonical source; this CLI-side mirror MUST
// stay byte-identical. The narrative-parity test in
// `packages/daemon/test/getting-started-narrative-parity.test.ts`
// (added in this slice) asserts byte-identity by reading both this
// file and the daemon module — if either drifts, the test fails.
// If you update content here, update the daemon mirror in lockstep.
const FIRST_CONVEYOR_RUN_README = `# First Conveyor Run
Welcome to OpenRig. This is the first slice in your getting-started mission. It exists to teach you what a **conveyor run** is + how OpenRig moves work through a multi-agent topology.
## What a conveyor run is
A **conveyor** is OpenRig's central work-movement primitive. One conveyor run = one complete flow of work through your topology:
1. **Slice declared** — operator (you) or an agent declares a slice with intent + acceptance criteria
2. **Work routed** — orchestrator picks the slice up + routes it to the right agent
3. **Agents collaborate** — work moves agent-to-agent via durable handoffs (queue items)
4. **Evidence accumulates** — each step leaves a trail: commits, files, proof packets, screenshots
5. **Slice closes** — acceptance criteria met; orchestrator routes the result back
You're looking at a mocked conveyor run RIGHT NOW. The other tabs (Story / Progress / Artifacts / Tests / Queue / Topology) each show a different slice of this same run.
## Walking the tabs
- **Overview** (you're here) — read what a slice intends + how to use the rest of the tabs
- **Story** — narrative timeline of what happened, beat by beat
- **Progress** — acceptance checklist; what's done, what's pending
- **Artifacts** — files produced, commits, proof packets — the durable evidence trail
- **Tests** — pass/fail summaries + screenshots + verification proof
- **Queue** — operational qitems showing how work moved between agents
- **Topology** — the rig graph; which agents touched this slice + which edges fired
## Click through
Try them in order. Each tab teaches itself.
`;
const FIRST_CONVEYOR_RUN_TIMELINE = `# Story — First Conveyor Run
## 2026-04-15 09:00 — Slice declared
Operator request: "Build a CLI tool that lints todo lists." Slice declared in \`getting-started/slices/first-conveyor-run/\`. Acceptance: \`tdl lint <file>\` exits non-zero on malformed entries.
## 09:02 — Orchestrator routes
\`orch-lead@getting-started\` picks up the slice. Routes to \`driver@getting-started\` via \`rig queue handoff\`. Driver receives nudge; opens IMPL-PRD; reads acceptance.
## 09:05 — Driver picks up
Driver claims qitem. Reads slice scope. Surveys existing tdl repo (this is a worked example — imagine the repo is real). Plans: parse YAML; validate entries; exit code per finding.
## 09:30 — First commit
\`feat(lint): parse + validate todo entries\` — driver commits 4 files. Stream event emitted; visible in Artifacts tab.
## 10:15 — Driver hands to reviewer
Acceptance met locally. Driver hands off to \`reviewer@getting-started\`. Reviewer opens diff; reads tests.
## 10:25 — Reviewer flags concern
"Edge case: what about UTF-8 entries with combining characters?" Hands back to driver as \`concerning\`.
## 10:40 — Driver addresses
Driver fixes UTF-8 handling. Re-runs tests; all pass. Hands back.
## 11:00 — Reviewer accepts
\`accept\` decision; closes qitem with \`closure-reason: handed_off_to orch-lead\`. Conveyor proceeds.
## 11:05 — Slice ships
Orchestrator merges. Proof packet generated. Slice marked SHIPPED.
---
**What you just read**: one conveyor run from declaration to ship. ~2 hours wall-clock; 3 agents involved; 1 handoff with concern + remediation; 5 stream events; 1 proof packet.
The Story tab shows this kind of narrative for any real slice. Driver-authored \`timeline.md\` files live in each slice folder; this is yours to read + your operator's to update as work moves.
`;
const FIRST_CONVEYOR_RUN_PROGRESS = `---
title: First Conveyor Run Progress
status: active
mission: getting-started
rail-item: getting-started
slice: first-conveyor-run
---
# Progress — First Conveyor Run
## Acceptance criteria
- [x] Parse YAML todo entries
- [x] Validate per-entry shape (id, title, status, due-date)
- [x] Exit non-zero on malformed entries
- [x] Handle UTF-8 entries with combining characters
- [x] Tests pass (12/12)
- [x] Reviewer accepts
- [x] Slice merged
## Status: SHIPPED
This was a mocked conveyor run — no real code was produced. But the Progress tab works the same way for real work: as acceptance criteria are met, boxes get checked; the operator can scan at a glance whether the slice is on track.
## How the Progress tab works
The tab renders the slice's \`PROGRESS.md\` markdown file. Driver updates it as work progresses. Operator scans it during walks. Founder reviews at slice closure.
Try writing one for a real slice you start. It's just markdown; live updates appear here.
`;
const INSPECT_PROJECT_EVIDENCE_README = `# Inspect Project Evidence
The previous slice (First Conveyor Run) showed you what a conveyor run looks like as it happens. This slice teaches you how to inspect the evidence after a run completes.
## Why inspect evidence?
In OpenRig, agents work autonomously. The operator (you) doesn't watch every keystroke. Instead, the operator:
1. Declares slices with clear acceptance
2. Routes them to agents
3. Inspects evidence when slices close
The Artifacts + Tests + Queue + Topology tabs are your inspection surface. They show you what happened, with enough detail to:
- Verify the acceptance was actually met
- Spot subtle issues a passing test might miss
- Build trust in the agents over time
## Walking the tabs
- **Overview** — you're here; learn what to look for
- **Story** — read the narrative of what happened (high-level)
- **Progress** — acceptance status at a glance
- **Artifacts** — the durable evidence: files, commits, proof packets. **THIS IS THE TAB YOU LEARN MOST FROM.**
- **Tests** — pass/fail + screenshots + verification proof
- **Queue** — who handed what to whom (audit trail of decisions)
- **Topology** — which agents touched this; which edges fired
## Key inspection skills
When walking a finished slice:
1. **Read the Story** for context — what was the operator trying to do?
2. **Check Progress** — did acceptance actually pass? Any TODO boxes?
3. **Open Artifacts → Files** — sample-read 2-3 files; does the code match what the Story claimed?
4. **Open Tests → Proof packets** — do screenshots actually show what was claimed?
5. **Read Queue** — were there concerning decisions? Hand-offs to escalation?
6. **Skim Topology** — were unexpected agents involved?
Trust comes from repeated successful inspections, not from a single proof packet.
`;
const INSPECT_PROJECT_EVIDENCE_TIMELINE = `# Story — Inspect Project Evidence
This slice mirrors the previous one (First Conveyor Run). The same agents shipped the same work. But this view is from the inspector's angle, not the runner's.
## 11:10 — Operator opens project
Operator (you, in the real world) opens \`/project/slice/first-conveyor-run\` after the slice ships. First read: the Progress tab. All boxes checked.
## 11:12 — Read Story
Operator reads the Story narrative. Notices the reviewer's UTF-8 concern + driver's remediation. Confidence-building: there was friction, friction was caught, friction was addressed.
## 11:15 — Open Artifacts
Operator clicks Artifacts. Sees 4 commits, 5 files, 1 proof packet, 2 screenshots. Opens commit \`feat(lint): parse + validate todo entries\`. Reads the diff.
## 11:18 — Read tests
Operator opens Tests tab. 12/12 passing. Opens the proof packet — screenshot shows CLI output \`Error: malformed entry at line 3\`. Visually verifies the acceptance was met.
## 11:22 — Skim Queue
Operator scrolls Queue. 4 qitems in chronological order. Sees the \`concerning\` decision + subsequent \`accept\`. Confidence reinforced.
## 11:25 — Mark complete
Operator marks slice acceptance complete. Closes the inspection.
---
**What you just read**: a clean inspection in ~15 minutes. Operator builds trust through the evidence trail. This is the OpenRig usage pattern at scale: declare → route → inspect.
`;
const INSPECT_PROJECT_EVIDENCE_PROGRESS = `---
title: Inspect Project Evidence Progress
status: active
mission: getting-started
rail-item: getting-started
slice: inspect-project-evidence
---
# Progress — Inspect Project Evidence
## Inspection checklist
- [x] Read Story (context: what was being done)
- [x] Check Progress (acceptance status)
- [x] Sample-read Artifacts files
- [x] Verify Tests proof packets visually
- [x] Skim Queue for concerning decisions
- [x] Skim Topology for unexpected agents
## Status: COMPLETE
This was a mocked inspection — a worked example of the inspection workflow.
## How to apply this
For any real slice that ships in YOUR project: open the slice page, walk the tabs in this order, check each box mentally. Over time you'll develop instincts for what passes the smell test vs what needs deeper investigation.
`;
const GETTING_STARTED_NARRATIVE: Record<
string,
{ readme: string; timeline: string; progress: string }
> = {
"first-conveyor-run": {
readme: FIRST_CONVEYOR_RUN_README,
timeline: FIRST_CONVEYOR_RUN_TIMELINE,
progress: FIRST_CONVEYOR_RUN_PROGRESS,
},
"inspect-project-evidence": {
readme: INSPECT_PROJECT_EVIDENCE_README,
timeline: INSPECT_PROJECT_EVIDENCE_TIMELINE,
progress: INSPECT_PROJECT_EVIDENCE_PROGRESS,
},
};
function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
// V0.3.1 slice 21: getting-started slices ship rich narrative.
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
if (narrative) {
return `---
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
${narrative.readme}`;
}
return `---
title: ${slice.title}
status: ${slice.status}
@@ -455,14 +206,7 @@ This lets Project attach queue activity to the slice story, queue, tests, and to
`;
}
function sliceTimeline(mission: DefaultMission, slice: DefaultSlice): string | null {
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
return narrative ? narrative.timeline : null;
}
function sliceProgress(mission: DefaultMission, slice: DefaultSlice): string {
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
if (narrative) return narrative.progress;
return `---
title: ${slice.title} Progress
status: ${slice.status}
@@ -529,14 +273,6 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri
{ 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) },
);
// V0.3.1 slice 21: getting-started slices ship timeline.md.
const timeline = sliceTimeline(mission, slice);
if (timeline) {
files.push({
relPath: `missions/${mission.id}/slices/${slice.id}/timeline.md`,
content: timeline,
});
}
}
}
return files;
+3 -11
View File
@@ -33,19 +33,11 @@ Examples:
Keys:
daemon.* port, host
db.path
transcripts.* enabled, path, lines, poll_interval_seconds
workspace.* root, slices_root, steering_path, field_notes_root,
specs_root, dogfood_evidence_root, operator_seat_name
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
ui.preview.* refresh_interval_seconds, max_pins, default_lines
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
agents.* advisor_session, operator_session
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
runtime.codex.* hooks_enabled
policies.claude_compaction.*
enabled, threshold_percent, compact_instruction,
message_inline, message_file_path
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
@@ -87,7 +79,7 @@ Precedence: CLI flag > environment variable > config file > default`)
.description("Read a single config value")
.action((key: string, opts: { json?: boolean; showSource?: boolean }) => {
try {
if (opts.json || cmd.opts<{ json?: boolean }>().json) {
if (opts.json) {
console.log(JSON.stringify(store.resolveWithSource(key), null, 2));
return;
}
+2 -56
View File
@@ -77,21 +77,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Host to bind on")
.option("--db <path>", "Database path")
// V0.3.1 slice 05 kernel-rig-as-default — skip the kernel auto-boot
// path. Used by test fixtures, headless CI, and operators who want
// a no-kernel daemon for ad-hoc topology work. The daemon proceeds
// and serves its HTTP API normally; just doesn't materialize the
// kernel rig.
.option("--no-kernel", "Skip kernel auto-boot (daemon serves without the kernel rig)")
// V0.3.1 slice 05 kernel-rig-as-default — forward-fix #3 architectural.
// After the daemon's healthz binds (current behavior preserved),
// additionally poll /api/kernel/status until kernel_state is
// ready / partial_ready, or the timeout elapses. Used by operators
// who want a "kernel-ready" signal at start-time rather than the
// weaker "daemon-ready". Default 60s; override with --wait-for-kernel-ms.
.option("--wait-for-kernel", "After daemon binds, also wait for kernel-agent readiness (default timeout 60s)")
.option("--wait-for-kernel-ms <ms>", "Override --wait-for-kernel timeout in milliseconds")
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
.action(async (opts: { port?: string; host?: string; db?: string }) => {
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight } = await import("../system-preflight.js");
@@ -99,14 +85,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const configStore = new ConfigStore();
const config = configStore.resolve();
const effectivePort = opts.port ? parseInt(opts.port, 10) : config.daemon.port;
// bug-fix slice auth-bearer-tailscale-trust: distinguish
// user-explicit from default-fallback so the daemon can
// multi-bind (loopback + tailscale auto-detect) when the operator
// never opted in to a specific host.
const hostResolution = configStore.resolveWithSource("daemon.host");
const hostUserExplicit = opts.host !== undefined || hostResolution.source !== "default";
const effectiveHost = opts.host ?? config.daemon.host;
const hostForDaemon = hostUserExplicit ? effectiveHost : undefined;
// Run preflight before starting
const preflight = new SystemPreflight({
@@ -126,12 +105,10 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
return;
}
// V0.3.1 slice 05 — Commander's --no-kernel inverts to opts.kernel === false.
const skipKernel = opts.kernel === false;
const state = await startDaemon(
{
port: effectivePort,
host: hostForDaemon,
host: effectiveHost,
db: opts.db ?? config.db.path,
transcriptsEnabled: config.transcripts.enabled,
transcriptsPath: config.transcripts.path,
@@ -143,41 +120,10 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// reach the rotation hook.
transcriptsLines: config.transcripts.lines,
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
// V0.3.1 slice 05 kernel-rig-as-default — propagated via
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
// check in startup.ts honors the flag.
skipKernelBoot: skipKernel,
},
getDeps(),
);
console.log(`Daemon started on port ${state.port} (pid ${state.pid})`);
// V0.3.1 slice 05 forward-fix #3 architectural — --wait-for-kernel
// post-bind polling. Kernel boot is fire-and-forget after the
// daemon binds healthz, so without this flag the CLI doesn't
// know whether the kernel itself reached ready. Operators who
// need a kernel-ready signal opt in here.
if (opts.waitForKernel) {
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
const baseUrl = `http://${state.host}:${state.port}`;
const result = await waitForKernelReady(baseUrl, timeoutMs);
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
} else {
// Honest 3-part error per banked discipline.
console.error(
`Error: kernel did not reach ready / partial_ready within ${timeoutMs}ms.\n` +
`Reason: kernel_state=${result.kernelState ?? "unknown"}` +
(result.detail ? `; ${result.detail}` : "") +
"\n" +
"Fix: inspect `rig ps --rig kernel` for stalled agents, or run `claude auth status` / `codex login status` to confirm runtime auth.",
);
process.exitCode = 1;
}
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
+5
View File
@@ -1,5 +1,6 @@
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";
@@ -292,6 +293,10 @@ function resolveSharedDocsRoot(env: NodeJS.ProcessEnv): string | null {
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;
}
-483
View File
@@ -1,483 +0,0 @@
// rig plugin CLI verb family — read-only plugin inspection.
//
// Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + DESIGN.md §6
// (CLI minimalism: 4 read-only inspection commands).
//
// Subcommands at slice 3.4 scope (this commit):
// list — aggregated view of discoverable plugins
// flags: --runtime claude|codex
// --source vendored|claude-cache|codex-cache
// --json
// show <id> — inspect manifest + skills + hooks + mcp servers
// flags: --json
// used-by <id> — agents referencing this plugin
// flags: --json
// validate <path> — local file inspection of a plugin source tree
// (manifest shape + skill frontmatter per agentskills.io)
// flags: --json
//
// list/show consume slice 3.3 daemon HTTP routes (GET /api/plugins[/...]).
//
// All commands ship --json output for agent consumption per banked
// building-agent-software + IMPL-PRD §4.4 HG-4.5.
//
// Wire shapes mirror slice 3.3 PluginDiscoveryService exports verbatim
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132). Types
// here are the wire contract; any drift from daemon source means the
// daemon shape changed and this file must update in lockstep.
//
// Flags NOT supported at v0 (deferred per velocity-guard 3.4.A blocker):
// --used — declared in PRD §4.2 but daemon route doesn't expose a
// "filter to plugins referenced by an agent" filter; would
// require client-side post-filter against /api/plugins +
// /api/plugins/:id/used-by N+1 calls. Not in v0 scope.
// --tree — declared in PRD §4.2 but show output already prints the
// tree structure (manifests + skills + hooks + mcp).
// Distinct tree-only mode is post-v0 polish.
// --source rig-cwd — daemon route's parseSourceFilter doesn't accept
// rig-cwd; rig-cwd scan is enabled via separate ?cwd=<path>
// query (out of scope for v0 since CLI doesn't have a "scan
// this rig's cwd" surface yet).
// --runtime both — daemon parseRuntimeFilter accepts only claude|codex;
// omitting --runtime returns all (the "both" semantic is
// default no-filter).
// Each of these surfaces in a future slice; CLI flag declarations are
// tightly scoped to what the daemon actually supports.
import { Command } from "commander";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
// Filter-value enums per slice-3.3 daemon route validation
// (packages/daemon/src/routes/plugins.ts L37-58). CLI-side validation
// gives operators clear errors instead of silent pass-through where the
// daemon ignores unknown values.
const VALID_RUNTIMES = ["claude", "codex"] as const;
const VALID_SOURCES = ["vendored", "claude-cache", "codex-cache"] as const;
// ============================================================
// Wire shapes — must mirror PluginDiscoveryService verbatim
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132)
// ============================================================
type PluginRuntime = "claude" | "codex";
type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache" | "rig-cwd";
interface PluginEntryWire {
id: string;
name: string;
version: string;
description: string | null;
source: PluginSourceKind;
sourceLabel: string;
runtimes: PluginRuntime[];
path: string;
lastSeenAt: string | null;
}
interface PluginManifestSummaryWire {
raw: Record<string, unknown>;
name: string | null;
version: string | null;
description: string | null;
homepage: string | null;
repository: string | null;
license: string | null;
}
interface PluginSkillSummaryWire {
name: string;
relativePath: string;
}
interface PluginHookSummaryWire {
runtime: PluginRuntime;
relativePath: string;
events: string[];
}
interface PluginMcpServerSummaryWire {
runtime: PluginRuntime;
name: string;
command: string | null;
transport: string | null;
}
interface PluginDetailWire {
entry: PluginEntryWire;
claudeManifest: PluginManifestSummaryWire | null;
codexManifest: PluginManifestSummaryWire | null;
skills: PluginSkillSummaryWire[];
hooks: PluginHookSummaryWire[];
mcpServers: PluginMcpServerSummaryWire[];
}
// AgentReference per PluginDiscoveryService L134-141 verbatim.
interface AgentReferenceWire {
agentName: string;
sourcePath: string;
profiles: string[];
}
export function pluginCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("plugin");
cmd.description("Inspect plugins (read-only)");
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));
}
// -- rig plugin list --
cmd.command("list")
.description("List discoverable plugins (aggregated across vendored + runtime caches)")
.option("--runtime <runtime>", "Filter by runtime support: claude | codex (omit for all)")
.option("--source <source>", "Filter by source: vendored | claude-cache | codex-cache")
.option("--json", "JSON output")
.action(async (opts: { runtime?: string; source?: string; json?: boolean }) => {
try {
// CLI-side filter-value validation per pre-close punch from velocity-guard
// 3.4.A repair verdict: typos like --source rig-cwd should fail loud,
// not silently be ignored by the daemon route.
if (opts.runtime && !(VALID_RUNTIMES as readonly string[]).includes(opts.runtime)) {
throw new Error(`Invalid --runtime value "${opts.runtime}". Valid: ${VALID_RUNTIMES.join(" | ")} (omit flag for all)`);
}
if (opts.source && !(VALID_SOURCES as readonly string[]).includes(opts.source)) {
throw new Error(`Invalid --source value "${opts.source}". Valid: ${VALID_SOURCES.join(" | ")}`);
}
const client = await getClient();
const params: string[] = [];
if (opts.runtime) params.push(`runtime=${encodeURIComponent(opts.runtime)}`);
if (opts.source) params.push(`source=${encodeURIComponent(opts.source)}`);
const path = `/api/plugins${params.length > 0 ? `?${params.join("&")}` : ""}`;
const res = await client.get<PluginEntryWire[]>(path);
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No plugins discovered.");
return;
}
for (const e of entries) {
const runtimesStr = e.runtimes.join(",");
// Real fields only: id, version, runtimes, sourceLabel, path
console.log(
`${e.id.padEnd(28)} v${String(e.version).padEnd(8)} ` +
`[${runtimesStr.padEnd(13)}] ${e.sourceLabel.padEnd(36)} ` +
`${e.path}`
);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
// -- rig plugin show <id> --
cmd.command("show")
.argument("<id>", "Plugin id (e.g., openrig-core)")
.description("Show plugin manifest + skills + hooks + mcp servers")
.option("--json", "JSON output")
.action(async (id: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<PluginDetailWire>(`/api/plugins/${encodeURIComponent(id)}`);
if (res.status === 404) {
throw new Error(`Plugin not found: "${id}"`);
}
if (res.status !== 200 || !res.data) {
throw new Error(`Daemon returned HTTP ${res.status}`);
}
const detail = res.data;
if (opts.json) {
console.log(JSON.stringify(detail, null, 2));
return;
}
const entry = detail.entry;
console.log(`Id: ${entry.id}`);
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Description: ${entry.description ?? "(none)"}`);
console.log(`Source: ${entry.sourceLabel}`);
console.log(`Path: ${entry.path}`);
console.log(`Runtimes: ${entry.runtimes.join(", ")}`);
if (entry.lastSeenAt) {
console.log(`Last seen: ${entry.lastSeenAt}`);
}
console.log("");
// Manifests — real PluginManifestSummary fields
console.log("Manifests:");
if (detail.claudeManifest) {
const m = detail.claudeManifest;
const parts: string[] = [];
if (m.name) parts.push(`name=${m.name}`);
if (m.version) parts.push(`version=${m.version}`);
if (m.license) parts.push(`license=${m.license}`);
if (m.repository) parts.push(`repo=${m.repository}`);
console.log(` claude: ${parts.join(" ") || "(present)"}`);
}
if (detail.codexManifest) {
const m = detail.codexManifest;
const parts: string[] = [];
if (m.name) parts.push(`name=${m.name}`);
if (m.version) parts.push(`version=${m.version}`);
if (m.license) parts.push(`license=${m.license}`);
if (m.repository) parts.push(`repo=${m.repository}`);
console.log(` codex: ${parts.join(" ") || "(present)"}`);
}
if (!detail.claudeManifest && !detail.codexManifest) {
console.log(" (none)");
}
// Skills — PluginSkillSummary has name + relativePath only
console.log("");
console.log(`Skills (${detail.skills.length}):`);
for (const s of detail.skills) {
console.log(` ${s.name.padEnd(40)} ${s.relativePath}`);
}
// Hooks — PluginHookSummary has runtime + relativePath + events[]
console.log("");
console.log(`Hooks (${detail.hooks.length}):`);
for (const h of detail.hooks) {
const eventList = h.events.length > 0 ? h.events.join(",") : "(none)";
console.log(` ${h.runtime.padEnd(10)} ${String(h.events.length).padStart(2)} events [${eventList}] ${h.relativePath}`);
}
// MCP servers
if (detail.mcpServers.length > 0) {
console.log("");
console.log(`MCP servers (${detail.mcpServers.length}):`);
for (const m of detail.mcpServers) {
const detail2: string[] = [];
if (m.transport) detail2.push(`transport=${m.transport}`);
if (m.command) detail2.push(`command=${m.command}`);
console.log(` ${m.runtime.padEnd(10)} ${m.name.padEnd(28)} ${detail2.join(" ")}`);
}
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
// -- rig plugin used-by <id> --
cmd.command("used-by")
.argument("<id>", "Plugin id (e.g., openrig-core)")
.description("List agents referencing this plugin in their profile.uses.plugins[]")
.option("--json", "JSON output")
.action(async (id: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<AgentReferenceWire[]>(`/api/plugins/${encodeURIComponent(id)}/used-by`);
if (res.status !== 200) {
throw new Error(`Daemon returned HTTP ${res.status}`);
}
const refs = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(refs, null, 2));
return;
}
if (refs.length === 0) {
console.log(`No agents reference plugin "${id}".`);
return;
}
for (const r of refs) {
const profilesStr = r.profiles.length > 0 ? r.profiles.join(",") : "(none)";
console.log(`${r.agentName.padEnd(36)} [${profilesStr.padEnd(20)}] ${r.sourcePath}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
// -- rig plugin validate <path> --
// Local file inspection — no daemon dependency; useful when authoring
// a plugin in a feature branch where the daemon may not yet have
// discovered it. Validates manifest shape (per Claude/Codex specs)
// + skill frontmatter (per agentskills.io: name + description ≤1024 chars).
cmd.command("validate")
.argument("<path>", "Plugin source directory to validate")
.description("Validate plugin manifest + skill frontmatter against agentskills.io spec")
.option("--json", "JSON output ({ valid: boolean, errors: string[] })")
.action((path: string, opts: { json?: boolean }) => {
const errors = validatePluginTree(path);
const valid = errors.length === 0;
if (opts.json) {
console.log(JSON.stringify({ valid, errors }, null, 2));
process.exitCode = valid ? undefined : 1;
return;
}
if (valid) {
console.log(`Plugin at ${path}: valid`);
return;
}
console.error(`Plugin at ${path}: INVALID (${errors.length} error${errors.length === 1 ? "" : "s"})`);
for (const err of errors) {
console.error(` - ${err}`);
}
process.exitCode = 1;
});
return cmd;
}
// ============================================================
// validate plumbing — local file inspection
// ============================================================
function validatePluginTree(pluginPath: string): string[] {
const errors: string[] = [];
if (!existsSync(pluginPath)) {
errors.push(`Plugin path does not exist: ${pluginPath}`);
return errors;
}
if (!statSync(pluginPath).isDirectory()) {
errors.push(`Plugin path is not a directory: ${pluginPath}`);
return errors;
}
const claudeManifestPath = join(pluginPath, ".claude-plugin", "plugin.json");
const codexManifestPath = join(pluginPath, ".codex-plugin", "plugin.json");
const hasClaude = existsSync(claudeManifestPath);
const hasCodex = existsSync(codexManifestPath);
if (!hasClaude && !hasCodex) {
errors.push("No plugin manifest found: expected .claude-plugin/plugin.json and/or .codex-plugin/plugin.json");
return errors;
}
if (hasClaude) {
errors.push(...validateManifest(claudeManifestPath, "claude"));
}
if (hasCodex) {
errors.push(...validateManifest(codexManifestPath, "codex"));
}
// Validate skill frontmatter
const skillsDir = join(pluginPath, "skills");
if (existsSync(skillsDir) && statSync(skillsDir).isDirectory()) {
for (const skillId of readdirSync(skillsDir)) {
const skillMdPath = join(skillsDir, skillId, "SKILL.md");
if (existsSync(skillMdPath)) {
errors.push(...validateSkillFrontmatter(skillMdPath, skillId));
}
}
}
return errors;
}
function validateManifest(manifestPath: string, runtime: "claude" | "codex"): string[] {
const errors: string[] = [];
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
} catch (err) {
errors.push(`${runtime} manifest parse error at ${manifestPath}: ${(err as Error).message}`);
return errors;
}
// Guard non-object/null parse results — JSON.parse('null') returns null,
// JSON.parse('[1,2]') returns an array — both must fail validation, not crash.
if (!isPlainObject(parsed)) {
errors.push(`${runtime} manifest at ${manifestPath} must be a JSON object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
return errors;
}
const raw = parsed;
if (typeof raw["name"] !== "string" || raw["name"].trim().length === 0) {
errors.push(`${runtime} manifest missing required field: name (must be non-empty string)`);
}
if (typeof raw["version"] !== "string" || raw["version"].trim().length === 0) {
errors.push(`${runtime} manifest missing required field: version (must be non-empty string)`);
}
// Codex spec: description is REQUIRED. Claude spec: description is recommended but
// not a hard requirement; we treat missing description as a warning by NOT erroring
// on Claude-only-missing-description while erroring on Codex-missing-description.
if (runtime === "codex" && (typeof raw["description"] !== "string" || raw["description"].trim().length === 0)) {
errors.push(`codex manifest missing required field: description (Codex spec requires it)`);
}
return errors;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateSkillFrontmatter(skillPath: string, skillId: string): string[] {
const errors: string[] = [];
let content: string;
try {
content = readFileSync(skillPath, "utf-8");
} catch (err) {
errors.push(`skill "${skillId}": failed to read SKILL.md: ${(err as Error).message}`);
return errors;
}
// Frontmatter: file MUST start with `---\n` and contain a closing `---` line
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) {
errors.push(`skill "${skillId}": SKILL.md missing frontmatter (must open with --- ... ---)`);
return errors;
}
const fmBody = fmMatch[1] ?? "";
// Parse frontmatter as YAML — regex-based validation can pass quoted-empty
// values and miss type errors per velocity-guard 3.4.C BLOCKING-CONCERN.
let parsed: unknown;
try {
parsed = parseYaml(fmBody);
} catch (err) {
errors.push(`skill "${skillId}": invalid YAML frontmatter: ${(err as Error).message}`);
return errors;
}
if (!isPlainObject(parsed)) {
errors.push(`skill "${skillId}": frontmatter must be a YAML object/map (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
return errors;
}
// Required: name (non-empty string after trim)
const name = parsed["name"];
if (typeof name !== "string" || name.trim().length === 0) {
errors.push(`skill "${skillId}": frontmatter missing required field: name (must be non-empty string)`);
}
// Required: description (non-empty string after trim; ≤1024 chars per agentskills.io)
const description = parsed["description"];
if (typeof description !== "string" || description.trim().length === 0) {
errors.push(`skill "${skillId}": frontmatter missing required field: description (must be non-empty string)`);
} else if (description.trim().length > 1024) {
errors.push(`skill "${skillId}": description length ${description.trim().length} chars exceeds agentskills.io limit of 1024`);
}
return errors;
}
-681
View File
@@ -1,681 +0,0 @@
// release-0.3.2 slice 12 — `rig scope` CLI primitive.
//
// Command grammar: rig scope <tier> <verb>. v0 ships `mission` +
// `slice` tiers; `project` and `sub-slice` are reserved per the
// substrate convention `conventions/scope-and-versioning/README.md`
// (stage: provisional). The CLI mints stable dot-IDs into created
// mission/slice frontmatter per §1 of that convention.
import fs from "node:fs";
import path from "node:path";
import { Command } from "commander";
import {
CLOSE_REASONS,
MISSION_TEMPLATE_KINDS,
SLICE_TEMPLATE_KINDS,
ScopeCliError,
type CloseReason,
type MissionTemplateKind,
type SliceTemplateKind,
type SliceState,
} from "../lib/scope/types.js";
import {
DEFAULT_PROJECT_PREFIX,
inferMissionDotId,
isMissionDotId,
nextEscapeBandOrdinal,
sliceIdFromMission,
} from "../lib/scope/dot-id.js";
import {
ensureMissionIdPersisted,
findMission,
findSlice,
listMissions,
listSlices,
moveSlice,
nextSliceNN,
pad2,
resolveMissionsRoot,
splitFrontmatter,
todayDateISO,
updateFrontmatter,
} from "../lib/scope/scope-fs.js";
import {
renderMissionTemplate,
renderSliceTemplate,
titleFromSlug,
} from "../lib/scope/templates.js";
// ---------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------
interface Stdout {
write: (text: string) => void;
}
function makeStdout(): Stdout {
return { write: (text: string) => process.stdout.write(text) };
}
function emit(out: Stdout, payload: unknown, json: boolean, lines?: string[]): void {
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
return;
}
if (lines) {
for (const line of lines) out.write(line + "\n");
return;
}
out.write(JSON.stringify(payload, null, 2) + "\n");
}
function fail(err: unknown, json: boolean, out: Stdout): never {
if (err instanceof ScopeCliError) {
if (json) {
out.write(JSON.stringify({
ok: false,
error: { fact: err.fact, consequence: err.consequence, action: err.action },
}, null, 2) + "\n");
} else {
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
}
} else {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`Error: ${message}\n`);
}
process.exit(1);
}
function slugify(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
interface RootOpts {
workspace?: string;
}
function getOpts(cmd: Command): RootOpts {
// commander v13 attaches opts on the parent.
let walker: Command | null = cmd;
while (walker) {
const o = walker.opts() as RootOpts;
if (o.workspace) return o;
walker = walker.parent;
}
return {};
}
// ---------------------------------------------------------------------
// rig scope slice ls
// ---------------------------------------------------------------------
function buildSliceLsCommand(): Command {
const cmd = new Command("ls")
.description("List slices in a mission (or across all missions)")
.option("--mission <name>", "Restrict to a single mission")
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
const state = (opts.state as SliceState) ?? "active";
if (!["active", "closed", "shipped", "all"].includes(state)) {
fail(new ScopeCliError({
fact: `Unknown --state value "${state}".`,
consequence: "Command did not run.",
action: "Pick one of: active, closed, shipped, all.",
}), json, out);
}
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const missions = opts.mission
? [findMission(missionsRoot, opts.mission)]
: listMissions(missionsRoot);
const rows: unknown[] = [];
const lines: string[] = [];
for (const mission of missions) {
const slices = listSlices(mission, state);
for (const slice of slices) {
rows.push({
mission: mission.name,
name: slice.name,
nn: slice.nn,
slug: slice.slug,
id: slice.id,
status: slice.status,
path: slice.absPath,
});
lines.push(`${mission.name}/${slice.name} ${slice.id ?? "—"} ${slice.status ?? "—"}`);
}
}
emit(out, { ok: true, count: rows.length, slices: rows }, json, lines.length === 0 ? ["(no slices)"] : lines);
} catch (err) {
fail(err, json, out);
}
});
return cmd;
}
// ---------------------------------------------------------------------
// rig scope slice show
// ---------------------------------------------------------------------
function buildSliceShowCommand(): Command {
return new Command("show")
.description("Inspect a single slice (frontmatter + README + children)")
.argument("<slice-path>", "Slice path (absolute, relative-to-substrate, or NN-slug)")
.option("--mission <name>", "Hint mission when path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const readme = slice.readmePath ? fs.readFileSync(slice.readmePath, "utf8") : null;
const children = fs.readdirSync(slice.absPath, { withFileTypes: true })
.map((e) => ({ name: e.name, kind: e.isDirectory() ? "dir" : "file" as const }));
const payload = {
ok: true,
slice: {
mission: slice.missionName,
name: slice.name,
id: slice.id,
status: slice.status,
path: slice.absPath,
frontmatter: slice.frontmatter,
readme,
children,
},
};
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
} else {
out.write(`Slice: ${slice.missionName}/${slice.name}\n`);
out.write(` id: ${slice.id ?? "—"}\n`);
out.write(` status: ${slice.status ?? "—"}\n`);
out.write(` path: ${slice.absPath}\n`);
out.write(` children: ${children.length}\n`);
if (readme) {
out.write("\n--- README ---\n");
out.write(readme);
if (!readme.endsWith("\n")) out.write("\n");
}
}
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice create
// ---------------------------------------------------------------------
function buildSliceCreateCommand(): Command {
return new Command("create")
.description("Create a new slice in a mission")
.argument("<mission>", "Mission name")
.argument("<slug>", "Short slug (becomes the folder name's suffix)")
.option("--template <kind>", `Template: ${SLICE_TEMPLATE_KINDS.join(" | ")}`, "placeholder")
.option("--title <text>", "Display title (defaults to titlecased slug)")
.option("--json", "Machine-readable output")
.action(async (missionName: string, rawSlug: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const kind = opts.template as SliceTemplateKind;
if (!SLICE_TEMPLATE_KINDS.includes(kind)) {
throw new ScopeCliError({
fact: `Unknown --template kind "${kind}".`,
consequence: "Slice not created.",
action: `Pick one of: ${SLICE_TEMPLATE_KINDS.join(", ")}.`,
});
}
const slug = slugify(rawSlug);
if (!slug) {
throw new ScopeCliError({
fact: `Slug "${rawSlug}" reduces to empty after slugification.`,
consequence: "Slice not created.",
action: "Pick a slug containing letters or digits.",
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const mission = findMission(missionsRoot, missionName);
// Persist the parent mission's id back into its README at the
// SAME moment we mint the child's id (per convention §1
// lazy-adoption rule + guard BC BLOCK 3 — every child-mint site
// must persist the parent's id, not just slice create).
const missionId = ensureMissionIdPersisted(mission, missionsRoot);
const nn = nextSliceNN(mission.absPath);
const sliceFolder = `${pad2(nn)}-${slug}`;
const sliceAbs = path.join(mission.absPath, "slices", sliceFolder);
if (fs.existsSync(sliceAbs)) {
throw new ScopeCliError({
fact: `Slice folder ${sliceAbs} already exists.`,
consequence: "Refusing to overwrite.",
action: "Pick a different slug, or rm -rf the existing folder first.",
});
}
const id = sliceIdFromMission(missionId, nn);
const title = opts.title ?? titleFromSlug(slug);
const body = renderSliceTemplate(kind, {
id,
slice_number: pad2(nn),
slug,
mission: mission.name,
title,
created_date: todayDateISO(),
});
fs.mkdirSync(sliceAbs, { recursive: true });
const readmePath = path.join(sliceAbs, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
const payload = {
ok: true,
slice: {
mission: mission.name,
name: sliceFolder,
id,
path: sliceAbs,
readmePath,
template: kind,
},
};
emit(out, payload, json, [
`Created ${mission.name}/slices/${sliceFolder}`,
` id: ${id}`,
` template: ${kind}`,
` path: ${sliceAbs}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice ship
// ---------------------------------------------------------------------
function buildSliceShipCommand(): Command {
return new Command("ship")
.description("Ship a slice to a release mission (preserves git history)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.argument("<release-mission>", "Target release mission name")
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, releaseMission: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const target = findMission(missionsRoot, releaseMission);
const targetSlicesDir = path.join(target.absPath, "slices");
fs.mkdirSync(targetSlicesDir, { recursive: true });
const newNN = nextSliceNN(target.absPath);
const slug = slice.slug ?? slugify(slice.name);
const newName = `${pad2(newNN)}-${slug}`;
const destAbs = path.join(targetSlicesDir, newName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const targetId = ensureMissionIdPersisted(target, missionsRoot);
const newSliceId = sliceIdFromMission(targetId, newNN);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
updateFrontmatter(newReadme, {
id: newSliceId,
mission: target.name,
status: `shipped-to-${target.name}`,
"shipped-on": todayDateISO(),
"shipped-from": slice.missionName,
});
}
emit(out, {
ok: true,
shipped: {
from: { mission: slice.missionName, name: slice.name, id: slice.id },
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
git: { usedGit, repoRoot },
},
}, json, [
`Shipped ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
` id: ${newSliceId}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice close
// ---------------------------------------------------------------------
function buildSliceCloseCommand(): Command {
return new Command("close")
.description("Close a slice (move to <mission>/closed/, update status)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.requiredOption("--reason <reason>", `Closure reason: ${CLOSE_REASONS.join(" | ")}`)
.option("--note <text>", "Optional closure note")
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const reason = opts.reason as CloseReason;
if (!CLOSE_REASONS.includes(reason)) {
throw new ScopeCliError({
fact: `Unknown --reason "${reason}".`,
consequence: "Slice not closed.",
action: `Pick one of: ${CLOSE_REASONS.join(", ")}.`,
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const mission = findMission(missionsRoot, slice.missionName);
const closedDir = path.join(mission.absPath, "closed");
fs.mkdirSync(closedDir, { recursive: true });
const destName = slice.name;
const destAbs = path.join(closedDir, destName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
const updates: Record<string, unknown> = {
status: `closed-${reason}`,
"closed-on": todayDateISO(),
};
if (opts.note) updates["closure-note"] = opts.note;
updateFrontmatter(newReadme, updates);
}
emit(out, {
ok: true,
closed: {
mission: slice.missionName,
name: destName,
id: slice.id,
reason,
note: opts.note ?? null,
path: destAbs,
git: { usedGit, repoRoot },
},
}, json, [
`Closed ${slice.missionName}/${slice.name} → ${slice.missionName}/closed/${destName}`,
` reason: ${reason}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice move
// ---------------------------------------------------------------------
function buildSliceMoveCommand(): Command {
return new Command("move")
.description("Move a slice between missions (re-numbers in destination)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.argument("<dest-mission>", "Destination mission name")
.option("--mission <name>", "Hint source mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, destMission: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const target = findMission(missionsRoot, destMission);
const targetSlicesDir = path.join(target.absPath, "slices");
fs.mkdirSync(targetSlicesDir, { recursive: true });
const newNN = nextSliceNN(target.absPath);
const slug = slice.slug ?? slugify(slice.name);
const newName = `${pad2(newNN)}-${slug}`;
const destAbs = path.join(targetSlicesDir, newName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const targetId = ensureMissionIdPersisted(target, missionsRoot);
const newSliceId = sliceIdFromMission(targetId, newNN);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
updateFrontmatter(newReadme, {
id: newSliceId,
mission: target.name,
"moved-on": todayDateISO(),
"moved-from": slice.missionName,
});
}
emit(out, {
ok: true,
moved: {
from: { mission: slice.missionName, name: slice.name, id: slice.id },
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
git: { usedGit, repoRoot },
},
}, json, [
`Moved ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
` id: ${newSliceId}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope mission ls / show / create
// ---------------------------------------------------------------------
function buildMissionLsCommand(): Command {
return new Command("ls")
.description("List missions (top-level folders with README.md)")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const missions = listMissions(missionsRoot);
const rows = missions.map((m) => ({
name: m.name,
id: m.id,
path: m.absPath,
activeSliceCount: m.activeSliceCount,
closedSliceCount: m.closedSliceCount,
}));
emit(out, { ok: true, count: rows.length, missions: rows }, json,
rows.length === 0
? ["(no missions)"]
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
);
} catch (err) {
fail(err, json, out);
}
});
}
function buildMissionShowCommand(): Command {
return new Command("show")
.description("Inspect a single mission")
.argument("<mission>", "Mission name")
.option("--json", "Machine-readable output")
.action(async (missionName: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const mission = findMission(missionsRoot, missionName);
const readme = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null;
const slices = listSlices(mission, "all").map((s) => ({
name: s.name, id: s.id, status: s.status, nn: s.nn,
}));
const payload = {
ok: true,
mission: {
name: mission.name,
id: mission.id,
path: mission.absPath,
activeSliceCount: mission.activeSliceCount,
closedSliceCount: mission.closedSliceCount,
frontmatter: mission.frontmatter,
readme,
slices,
},
};
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
} else {
out.write(`Mission: ${mission.name}\n`);
out.write(` id: ${mission.id ?? "—"}\n`);
out.write(` active slices: ${mission.activeSliceCount}\n`);
out.write(` closed slices: ${mission.closedSliceCount}\n`);
out.write(` path: ${mission.absPath}\n`);
if (readme) {
out.write("\n--- README ---\n");
out.write(readme);
if (!readme.endsWith("\n")) out.write("\n");
}
}
} catch (err) {
fail(err, json, out);
}
});
}
function buildMissionCreateCommand(): Command {
return new Command("create")
.description("Create a new mission (mints a stable dot-ID into frontmatter)")
.argument("<name>", "Mission folder name (e.g., release-0.4.0, backlog-foo)")
.option("--template <kind>", `Template: ${MISSION_TEMPLATE_KINDS.join(" | ")} (auto when name matches release-X.Y.Z)`, "")
.option("--id <dot-id>", "Explicit dot-ID. Overrides name-pattern inference.")
.option("--title <text>", "Display title (defaults to titlecased name)")
.option("--json", "Machine-readable output")
.action(async (rawName: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const name = rawName.trim();
if (!name || /[\\/\s]/.test(name)) {
throw new ScopeCliError({
fact: `Invalid mission name "${rawName}".`,
consequence: "Mission not created.",
action: "Pick a name with no whitespace or path separators.",
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const absPath = path.join(missionsRoot, name);
if (fs.existsSync(absPath)) {
throw new ScopeCliError({
fact: `Mission folder ${absPath} already exists.`,
consequence: "Refusing to overwrite.",
action: "Pick a different name, or use `rig scope mission show <name>` to inspect the existing mission.",
});
}
// Resolve template kind: explicit > release-pattern auto > placeholder.
const isReleaseName = /^release-\d+\.\d+(?:\.\d+)?$/.test(name);
let templateKind: MissionTemplateKind = opts.template as MissionTemplateKind;
if (!templateKind) templateKind = isReleaseName ? "release" : "placeholder";
if (!MISSION_TEMPLATE_KINDS.includes(templateKind)) {
throw new ScopeCliError({
fact: `Unknown --template kind "${templateKind}".`,
consequence: "Mission not created.",
action: `Pick one of: ${MISSION_TEMPLATE_KINDS.join(", ")}.`,
});
}
// Mint the dot-ID.
let id: string;
if (opts.id) {
// Tier-aware validation per guard BC verdict (BLOCK 1).
// A mission ID has 2-3 numeric segments after the prefix
// (release X.Y or X.Y.Z; escape-band 99.x.y). Reject
// slice-shaped IDs (4 segments) so the parent identity stays
// unambiguous.
if (!isMissionDotId(opts.id)) {
throw new ScopeCliError({
fact: `Supplied --id "${opts.id}" is not a mission-tier dot-ID.`,
consequence: "Mission not created. A mission ID has the shape <PFX>.<ver> (2-3 numeric segments), not a slice shape <PFX>.<ver>.<n>.",
action: "Use a mission-shaped dot-ID like OPR.0.3.2 (release) or OPR.99.0.1 (escape band). For slice IDs, scope automatically mints them when you create a slice.",
});
}
id = opts.id;
} else if (isReleaseName) {
id = inferMissionDotId(name, null);
} else {
const peers = listMissions(missionsRoot);
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
id = inferMissionDotId(name, ordinal);
}
// Scaffold the mission directory + README.
fs.mkdirSync(absPath, { recursive: true });
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
const body = renderMissionTemplate(templateKind, {
id,
slug: name,
mission: name,
title,
created_date: todayDateISO(),
release_version: releaseVersion,
});
const readmePath = path.join(absPath, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
emit(out, {
ok: true,
mission: {
name,
id,
template: templateKind,
path: absPath,
readmePath,
},
}, json, [
`Created mission ${name}`,
` id: ${id}`,
` template: ${templateKind}`,
` path: ${absPath}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
.option("--workspace <path>", "Override workspace root (otherwise inferred from cwd or $OPENRIG_WORK_ROOT)");
const slice = new Command("slice").description("Slice-tier commands");
slice.addCommand(buildSliceLsCommand());
slice.addCommand(buildSliceShowCommand());
slice.addCommand(buildSliceCreateCommand());
slice.addCommand(buildSliceShipCommand());
slice.addCommand(buildSliceCloseCommand());
slice.addCommand(buildSliceMoveCommand());
cmd.addCommand(slice);
const mission = new Command("mission").description("Mission-tier commands");
mission.addCommand(buildMissionLsCommand());
mission.addCommand(buildMissionShowCommand());
mission.addCommand(buildMissionCreateCommand());
cmd.addCommand(mission);
return cmd;
}
// Re-exports for tests.
export { DEFAULT_PROJECT_PREFIX, splitFrontmatter };
-8
View File
@@ -17,14 +17,6 @@ const SENDER_FALLBACK = "<unknown sender>";
* 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.
*
* V0.3.1 slice 23 parity contract: `packages/daemon/src/lib/pane-envelope.ts`
* exports `wrapPaneEnvelope` with BYTE-IDENTICAL output for the same
* inputs. The two implementations live in separate packages because
* cli + daemon don't cross-import today. Daemon-side nudges from
* `rig queue create|handoff` use the daemon helper so queue nudges
* render the same envelope as peer-to-peer `rig send`. If you update
* this function, update wrapPaneEnvelope in lockstep.
*/
export function wrapSendBody(sender: string | undefined, recipient: string, body: string): string {
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
+1 -13
View File
@@ -43,14 +43,6 @@ Examples:
transcripts: { enabled: boolean; path: string; lines: number; pollIntervalSeconds: number };
workspace: { root: string };
} | null = null;
// bug-fix slice auth-bearer-tailscale-trust: track whether
// daemon.host was operator-explicit (env or config file) vs
// default-fallback. The daemon's multi-bind path (loopback +
// tailscale auto-detect) only runs when OPENRIG_HOST is NOT
// exported to the child, so we omit it on the default path.
// Hoisted to function scope so the startDaemon block below can
// read it after the preflight try-catch.
let hostForDaemon: string | undefined;
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight } = await import("../system-preflight.js");
@@ -58,10 +50,6 @@ Examples:
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
const configStore = new ConfigStore();
resolvedConfig = configStore.resolve();
const hostResolution = configStore.resolveWithSource("daemon.host");
hostForDaemon = hostResolution.source === "default"
? undefined
: resolvedConfig.daemon.host;
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
const preflight = new SystemPreflight({
@@ -89,7 +77,7 @@ Examples:
try {
await startDaemon({
port: resolvedConfig?.daemon.port,
host: hostForDaemon,
host: resolvedConfig?.daemon.host,
db: resolvedConfig?.db.path,
transcriptsEnabled: resolvedConfig?.transcripts.enabled,
transcriptsPath: resolvedConfig?.transcripts.path,
+4 -226
View File
@@ -1,6 +1,5 @@
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { userInfo } from "node:os";
import {
getCompatibleOpenRigPath,
getDefaultOpenRigPath,
@@ -25,11 +24,6 @@ export interface RiggedConfig {
pollIntervalSeconds: number;
};
// User Settings v0 — workspace paths.
// V0.3.1 slice 05 kernel-rig-as-default — operatorSeatName carries the
// workspace's operator seat session name (default
// `operator-${USER}@kernel`). Read by mission-control read layer and
// 2 UI cosmetic sites to replace the legacy hardcoded
// "human-operator@kernel" constant.
workspace: {
root: string;
slicesRoot: string;
@@ -37,7 +31,6 @@ export interface RiggedConfig {
fieldNotesRoot: string;
specsRoot: string;
dogfoodEvidenceRoot: string;
operatorSeatName: string;
};
// User Settings v0 — UEP env-var graduation.
// Values are stored as raw named-pair strings ("name:/abs/path,...")
@@ -83,58 +76,10 @@ export interface RiggedConfig {
auditLog: boolean;
};
};
// plugin-primitive Phase 3a slice 3.5 — runtime feature flags. Currently
// single-flag for Codex; extracts to its own primitive workspace if/when
// 3+ flags accumulate (per DESIGN.md §5.8).
runtime: {
codex: {
hooksEnabled: boolean;
};
};
// Slice 27 — Claude auto-compaction policy. Operator-configurable
// pre-compaction trigger: when a Claude seat's context usage crosses
// `thresholdPercent`, daemon sends a pre-compact prep prompt, then
// sends /compact via SessionTransport and passes `compactInstruction`
// as slash-command args for the actual compaction phase. The existing
// PreCompact hook records `messageInline` plus contents of
// `messageFilePath` alongside the standard restore-instructions in
// the post-compact marker/context. The daemon then sends
// `postRestoreAuditInstruction` as the editable read-depth nudge.
//
// Defaults: opt-in default-off (enabled=false). The compaction
// instruction ships as inline text. The post-compaction restore prompt
// defaults to loading the canonical restore skill plus a user-owned
// extra instruction file path for mission-specific reading lists.
policies: {
claudeCompaction: {
enabled: boolean;
thresholdPercent: number;
preCompactInstruction: string;
compactInstruction: string;
messageInline: string;
messageFilePath: string;
postRestoreAuditInstruction: string;
};
};
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
const DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"If You Are About To Compact\" protocol. Create or update the mental-model restore map before compaction. If you are in the middle of a tiny atomic step, finish that step first; otherwise this preparation is the next priority.";
const DEFAULT_CLAUDE_COMPACTION_COMPACT_INSTRUCTION = "";
const DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"If You Just Compacted\" protocol.";
const DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"Required Read-Depth Audit\" protocol.";
const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigPath(
"compaction/post-compact-extra.md",
);
const DEFAULTS = {
daemon: { port: 7433, host: "127.0.0.1" },
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
@@ -146,11 +91,6 @@ const DEFAULTS = {
fieldNotesRoot: "",
specsRoot: "",
dogfoodEvidenceRoot: "",
// V0.3.1 slice 05 — empty default; resolve() picks up the derived
// `operator-${USER}@kernel` at read time via the deriveWorkspaceDefault
// helper so the default tracks the OS username without caching at
// module-load time.
operatorSeatName: "",
},
files: { allowlist: "" },
progress: { scanRoots: "" },
@@ -185,24 +125,6 @@ const DEFAULTS = {
auditLog: false,
},
},
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag default ON.
runtime: {
codex: {
hooksEnabled: true,
},
},
// Slice 27 — opt-in default-off. Threshold default 80% per spec.
policies: {
claudeCompaction: {
enabled: false,
thresholdPercent: 80,
preCompactInstruction: DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION,
compactInstruction: DEFAULT_CLAUDE_COMPACTION_COMPACT_INSTRUCTION,
messageInline: DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION,
messageFilePath: DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH,
postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION,
},
},
} as const;
export const VALID_KEYS = [
@@ -231,28 +153,12 @@ export const VALID_KEYS = [
// V1 Phase 4 SC-29 exception — allowlist-only additions.
"agents.advisor_session",
"agents.operator_session",
// V0.3.1 slice 05 kernel-rig-as-default — operator seat name used by
// mission-control read layer + 2 UI sites; default
// `operator-${USER}@kernel` derived in resolve(). OPENRIG_* only;
// no RIGGED_* legacy alias.
"workspace.operator_seat_name",
// 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",
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
// 7 ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
"policies.claude_compaction.compact_instruction",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
] as const;
export type ValidKey = typeof VALID_KEYS[number];
@@ -285,24 +191,11 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"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" },
"workspace.operator_seat_name": { primary: "OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME" },
"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" },
// Net-new key post-rename: OPENRIG_X primary only per the 5-key
// boundary doctrine (no RIGGED_X legacy on net-new keys).
"runtime.codex.hooks_enabled": { primary: "OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED" },
// Slice 27 — Claude auto-compaction policy. OPENRIG_X primary only
// (net-new keys, no legacy).
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
"policies.claude_compaction.pre_compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION" },
"policies.claude_compaction.compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_COMPACT_INSTRUCTION" },
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
};
// Maps dotted-string config keys to the camelCase RiggedConfig path.
@@ -332,20 +225,11 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
"agents.advisor_session": ["agents", "advisorSession"],
"agents.operator_session": ["agents", "operatorSession"],
"workspace.operator_seat_name": ["workspace", "operatorSeatName"],
"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"],
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
"policies.claude_compaction.pre_compact_instruction": ["policies", "claudeCompaction", "preCompactInstruction"],
"policies.claude_compaction.compact_instruction": ["policies", "claudeCompaction", "compactInstruction"],
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
};
function isValidKey(key: string): key is ValidKey {
@@ -388,11 +272,6 @@ export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): st
case "workspace.dogfood_evidence_root": return join(workspaceRoot, "dogfood-evidence");
case "files.allowlist": return `workspace:${workspaceRoot}`;
case "progress.scan_roots": return `workspace:${workspaceRoot}`;
// V0.3.1 slice 05 — `operator-${USER}@kernel` derived from the
// OS username at resolve() time. Lockstep with the daemon's
// settings-store getDefaultValue("workspace.operator_seat_name")
// case so both sides resolve to the same default.
case "workspace.operator_seat_name": return `operator-${userInfo().username}@kernel`;
default: return "";
}
}
@@ -413,7 +292,6 @@ const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"workspace.operator_seat_name",
]);
function getDefaultValue(key: ValidKey, workspaceRoot: string): string | number | boolean {
@@ -438,68 +316,6 @@ function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string
return raw;
}
// Slice 27 — strict per-key constraint validators applied AFTER coerceValue
// in `set()`. The generic coerce uses parseInt which accepts partial
// parses (e.g., "80abc" → 80) and truncates fractions ("80.5" → 80); for
// keys with documented range/integer contracts this is unsafe. Per
// banked feedback_static_gates_mirror_runtime_validators, the runtime
// validator is the source of truth and must reject what the contract
// forbids.
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
// Policy threshold: integer in [1, 100]. Documented contract from
// slice 27 README §"What the operator gets" — operator can lower to
// e.g. 50 = compact earlier; range is 1-100 inclusive. A value of 0
// would fire /compact on every poll tick (catastrophic without
// default-off as backstop).
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (typeof coerced !== "number" || !Number.isInteger(coerced)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (coerced < 1 || coerced > 100) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: must be in [1, 100], got ${coerced}`,
);
}
},
};
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
const check = KEY_CONSTRAINTS[key];
if (check) check(raw, coerced);
}
// Slice 27 BLOCKING-FIX-2 — shared coerce + validate, used by every
// surface that turns operator-supplied input into a typed config value:
// set() (CLI + daemon write paths), resolveOne env-source branch, and
// (indirectly via validateTypedValue) the resolveOne file-source branch.
// Per banked feedback_audit_every_layer_function_and_module_constants:
// validation must run at every LAYER that ingests untrusted input, not
// just at write-time.
function coerceAndValidate(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
const coerced = coerceValue(key, raw, workspaceRoot);
validateKeyConstraints(key, raw, coerced);
return coerced;
}
// File-source values arrive already typed (JSON-parsed). The constraint
// closure expects (raw, coerced) to drive its regex check too; pass the
// stringified value so a string-typed file value (e.g., "80abc" written
// directly into config.json) still trips the regex.
function validateTypedFileValue(key: ValidKey, value: string | number | boolean): void {
const check = KEY_CONSTRAINTS[key];
if (!check) return;
const raw = typeof value === "string" ? value : String(value);
check(raw, value);
}
export type SettingSource = "env" | "file" | "default";
export interface ResolvedSetting {
@@ -546,7 +362,6 @@ export class ConfigStore {
fieldNotesRoot: v("workspace.field_notes_root") as string,
specsRoot: v("workspace.specs_root") as string,
dogfoodEvidenceRoot: v("workspace.dogfood_evidence_root") as string,
operatorSeatName: v("workspace.operator_seat_name") as string,
},
files: {
allowlist: v("files.allowlist") as string,
@@ -578,22 +393,6 @@ export class ConfigStore {
auditLog: v("feed.subscriptions.audit_log") as boolean,
},
},
runtime: {
codex: {
hooksEnabled: v("runtime.codex.hooks_enabled") as boolean,
},
},
policies: {
claudeCompaction: {
enabled: v("policies.claude_compaction.enabled") as boolean,
thresholdPercent: v("policies.claude_compaction.threshold_percent") as number,
preCompactInstruction: v("policies.claude_compaction.pre_compact_instruction") as string,
compactInstruction: v("policies.claude_compaction.compact_instruction") as string,
messageInline: v("policies.claude_compaction.message_inline") as string,
messageFilePath: v("policies.claude_compaction.message_file_path") as string,
postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string,
},
},
};
}
@@ -624,20 +423,10 @@ export class ConfigStore {
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
const defaultValue = getDefaultValue(key, workspaceRoot);
// 1. Environment variable — validate. On invalid env, drop the
// override and fall through to file/default (safer-failure than
// crashing or accepting a bad value). Warn so the operator sees
// the misconfigured env on stderr.
// 1. Environment variable
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
try {
return { value: coerceAndValidate(key, envVal, workspaceRoot), source: "env", defaultValue };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[openrig-config] env override for ${key} rejected: ${reason}; falling back to file/default\n`,
);
}
return { value: coerceValue(key, envVal, workspaceRoot), source: "env", defaultValue };
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH[key]);
@@ -646,18 +435,7 @@ export class ConfigStore {
if (legacyDefault !== null && fileVal === legacyDefault) {
return { value: defaultValue, source: "default", defaultValue };
}
// Validate the file-source value too. Hand-edited config.json with
// a bad threshold (e.g. thresholdPercent: 0 or "80abc") falls back
// to default rather than poisoning the trigger contract.
try {
validateTypedFileValue(key, fileVal as string | number | boolean);
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[openrig-config] file value for ${key} rejected: ${reason}; falling back to default\n`,
);
}
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
}
// 3. Default
return { value: defaultValue, source: "default", defaultValue };
@@ -675,7 +453,7 @@ export class ConfigStore {
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 = coerceAndValidate(key, value, workspaceRoot);
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");
+8 -99
View File
@@ -39,11 +39,6 @@ export interface StartOptions {
// picks up file-stored ConfigStore values, not just shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
// V0.3.1 slice 05 kernel-rig-as-default — when true, daemon startup
// skips the kernel auto-boot path (kernel rig is not materialized).
// Exposed at the CLI as `rig daemon start --no-kernel`; projected
// into the daemon process env as OPENRIG_NO_KERNEL.
skipKernelBoot?: boolean;
}
export interface LifecycleDeps {
@@ -247,17 +242,7 @@ export function buildDaemonEnv(
baseEnv: Record<string, string>,
opts: {
port: number;
/**
* Operator-explicit bind host. When undefined the CLI is signaling
* "no operator opt-in" so the daemon's index.ts can fall through to
* the default loopback+tailscale-auto multi-bind path (bug-fix slice
* auth-bearer-tailscale-trust). When defined, the daemon takes the
* explicit-host branch and applies the bearer invariant to it.
* If undefined here AND the operator shell already has OPENRIG_HOST
* set in baseEnv, the env-passthrough loop preserves that value
* (their shell-level opt-in still wins).
*/
host?: string;
host: string;
db: string;
transcriptsEnabled?: boolean;
transcriptsPath?: string;
@@ -266,9 +251,6 @@ export function buildDaemonEnv(
// shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
// V0.3.1 slice 05 — projected via OPENRIG_NO_KERNEL env var so
// the daemon's startup.ts kernel-boot path honors the flag.
skipKernelBoot?: boolean;
},
): Record<string, string> {
const env: Record<string, string> = {};
@@ -281,9 +263,7 @@ export function buildDaemonEnv(
// Explicit OPENRIG_* overrides — always win over inherited values
env["OPENRIG_PORT"] = String(opts.port);
if (opts.host !== undefined) {
env["OPENRIG_HOST"] = opts.host;
}
env["OPENRIG_HOST"] = opts.host;
env["OPENRIG_DB"] = opts.db;
if (opts.transcriptsEnabled !== undefined) {
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
@@ -297,24 +277,14 @@ export function buildDaemonEnv(
if (opts.transcriptsPollIntervalSeconds !== undefined) {
env["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"] = String(opts.transcriptsPollIntervalSeconds);
}
if (opts.skipKernelBoot) {
env["OPENRIG_NO_KERNEL"] = "1";
}
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;
// bug-fix slice auth-bearer-tailscale-trust: preserve the
// user-explicit-vs-default distinction. `opts.host` is undefined when
// the operator never opted in; in that case healthz probes still use
// the loopback default, but buildDaemonEnv must NOT export
// OPENRIG_HOST so the daemon falls through to the multi-bind default
// (loopback + tailscale auto-detect).
const explicitHost = opts.host;
const probeHost = explicitHost ?? DEFAULT_HOST;
// Check if already running
const existing = readState(deps);
@@ -331,7 +301,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${probeHost}:${port}/healthz`);
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
@@ -354,13 +324,12 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const child = deps.spawn(process.execPath, [daemonEntry], {
env: buildDaemonEnv(process.env as Record<string, string>, {
port,
host: explicitHost,
host,
db,
transcriptsEnabled: opts.transcriptsEnabled,
transcriptsPath: opts.transcriptsPath,
transcriptsLines: opts.transcriptsLines,
transcriptsPollIntervalSeconds: opts.transcriptsPollIntervalSeconds,
skipKernelBoot: opts.skipKernelBoot,
}),
stdio: ["ignore", logFd, logFd],
detached: true,
@@ -370,9 +339,8 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const pid = child.pid!;
// Poll healthz against the configured probe host (loopback default
// when operator didn't opt in to a specific bind).
const healthzUrl = `http://${probeHost}:${port}/healthz`;
// Poll healthz
const healthzUrl = `http://${host}:${port}/healthz`;
let healthy = false;
for (let i = 0; i < HEALTHZ_RETRIES; i++) {
try {
@@ -396,10 +364,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const state: DaemonState = {
pid,
port,
// Persist the probe host (operator-explicit when set, loopback
// default otherwise) so subsequent status reads can reach the
// daemon regardless of whether tailscale was added at multi-bind.
host: probeHost,
host,
db,
startedAt: new Date().toISOString(),
};
@@ -536,59 +501,3 @@ async function fetchDaemonProbe(deps: LifecycleDeps, url: string): Promise<{ ok:
}),
]);
}
// V0.3.1 slice 05 kernel-rig-as-default — forward-fix #3 architectural.
// Poll GET /api/kernel/status until kernel_state reaches ready or
// partial_ready (the two "operator can use the kernel" states) or the
// timeout elapses. Used by `rig daemon start --wait-for-kernel`.
//
// Returns { ok: true, ... } on success; { ok: false, ... } with the
// last observed kernelState + detail for the CLI's 3-part error.
export interface KernelReadyResult {
ok: boolean;
kernelState: string | null;
variant: string | null;
detail: string | null;
}
export async function waitForKernelReady(
baseUrl: string,
timeoutMs: number,
pollIntervalMs = 500,
): Promise<KernelReadyResult> {
const deadline = Date.now() + timeoutMs;
let last: KernelReadyResult = { ok: false, kernelState: null, variant: null, detail: null };
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseUrl}/api/kernel/status`);
if (res.ok) {
const body = (await res.json()) as {
kernel_state?: string;
variant?: string | null;
detail?: string | null;
};
last = {
ok: body.kernel_state === "ready" || body.kernel_state === "partial_ready",
kernelState: body.kernel_state ?? null,
variant: body.variant ?? null,
detail: body.detail ?? null,
};
if (last.ok) return last;
// Terminal non-ready states: don't keep polling.
if (
body.kernel_state === "auth_blocked" ||
body.kernel_state === "spec_missing" ||
body.kernel_state === "bootstrap_failed" ||
body.kernel_state === "degraded" ||
body.kernel_state === "skipped"
) {
return last;
}
}
} catch {
// Transient fetch failure; keep polling until the deadline.
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
return last;
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function defaultHostRegistryPath(): string {
* - id: vm-claude-test
* transport: ssh
* target: vm-claude-test.local
* user: your-username # optional
* user: wrandom # optional
* notes: "Tart VM" # optional
*
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
-6
View File
@@ -17,7 +17,6 @@ import { attachCommand } from "./commands/attach.js";
import { bindCommand } from "./commands/bind.js";
import { adoptCommand, type AdoptDeps } from "./commands/adopt.js";
import { bundleCommand } from "./commands/bundle.js";
import { scopeCommand } from "./commands/scope.js";
import { upCommand } from "./commands/up.js";
import { downCommand } from "./commands/down.js";
import { psCommand } from "./commands/ps.js";
@@ -43,7 +42,6 @@ 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 { pluginCommand } from "./commands/plugin.js";
import { agentImageCommand } from "./commands/agent-image.js";
import { workspaceCommand, type WorkspaceDeps } from "./commands/workspace.js";
import { whoamiCommand } from "./commands/whoami.js";
@@ -99,7 +97,6 @@ export interface ProgramDeps {
chatroomDeps?: StatusDeps;
specsDeps?: StatusDeps;
contextPackDeps?: StatusDeps;
pluginDeps?: StatusDeps;
agentImageDeps?: StatusDeps;
workspaceDeps?: WorkspaceDeps;
whoamiDeps?: StatusDeps;
@@ -161,7 +158,6 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(chatroomCommand(depsOverride?.chatroomDeps));
program.addCommand(specsCommand(depsOverride?.specsDeps));
program.addCommand(contextPackCommand(depsOverride?.contextPackDeps));
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
@@ -183,8 +179,6 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
program.addCommand(seatCommand(depsOverride?.seatDeps));
// release-0.3.2 slice 12 — rig scope CLI primitive.
program.addCommand(scopeCommand());
return program;
}
@@ -1,26 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Target
[What is being deprecated]
## Current state
[How it works today; what depends on it]
## Migration
[Path off the deprecated thing]
## Removal
[When/how the deprecated thing gets deleted]
@@ -1,22 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Current state
[How the code/system looks today]
## Cost
[What this debt costs us — surface friction, bug class, perf, etc]
## Proposal
[How to pay it down]
@@ -1,30 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Repro
[Steps to reproduce]
## Expected
[Expected behavior]
## Actual
[Actual behavior]
## Impact
[Who is affected; severity]
## Fix proposal
[Optional: thinking on the fix]
@@ -1,20 +0,0 @@
---
id: {{id}}
mission: {{mission}}
stage: wip
created: {{created_date}}
---
# Mission — {{title}}
## Purpose
[Why this mission exists and what success looks like]
## Slices
[List notable slices and their state]
## Status
[Where the mission is right now]
@@ -1,25 +0,0 @@
---
id: {{id}}
mission: {{mission}}
release: {{release_version}}
stage: wip
created: {{created_date}}
---
# Release {{release_version}} — {{title}}
## Theme
[What this release is about — the coherent bet]
## Scope
[What ships in v{{release_version}} vs what waits]
## Slices
[Ordering + dependencies; PROGRESS.md tracks state]
## Acceptance
[Release-level done definition]
@@ -1,30 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Goal
[1-2 sentence goal.]
## Source material
- [Paths or refs]
## Status
- TODO: [next steps]
## Dependencies
- [Cross-slice / cross-release]
## Acceptance
- [3-5 bullets]
@@ -1,26 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Goal
[What this feature ships and why now]
## Scope
[In / out for v0]
## Acceptance
- [3-5 bullets]
## Risks
[Known unknowns]
@@ -1,22 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Question
[The thing we are trying to learn]
## Source
[Where the answer is most likely to come from]
## Findings
[Captured as work progresses]
-173
View File
@@ -1,173 +0,0 @@
// release-0.3.2 slice 12 — dot-ID grammar per
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
//
// Grammar (v0 single-project):
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
// mission = <PFX>.<ver>
// slice = <PFX>.<ver>.<n>
// sub = <PFX>.<ver>.<n>.<m>
//
// Version inference from mission folder name:
// release-X.Y[.Z] → "X.Y[.Z]" (release train)
// anything else → "99.0.<m>" escape band (uniform-numeric;
// NO alpha — convention §1 explicit)
//
// The escape-band <m> is assigned by scanning peer non-release missions
// for an existing id and picking max+1. Releases keep their semver.
import type { DotId } from "./types.js";
import { ScopeCliError } from "./types.js";
/** Default project prefix for the OpenRig single-project workspace.
* Multi-project work waits for a real second project (convention §1). */
export const DEFAULT_PROJECT_PREFIX = "OPR";
const RELEASE_NAME_RE = /^release-(\d+\.\d+(?:\.\d+)?)$/;
// Strict positional grammar: every segment is a number except for the
// 2-3 letter project prefix at position 0. Accepts 2-5 numeric
// segments after the prefix (mission has 2-3, slice has 3-4, sub-slice
// has 4-5). Tier-aware parse helpers below split version/n/m correctly.
const DOT_ID_RE = /^([A-Z]{2,3})\.(\d+(?:\.\d+){1,4})$/;
/** Parse a dot-ID string into structured parts; returns null when the
* string doesn't conform to the §1 positional grammar. When `tier`
* is supplied, the trailing segments are peeled off into `n` (slice)
* or `n` + `m` (sub-slice). With no tier, all numeric segments are
* returned as `version` for backwards compatibility. */
export function parseDotId(
raw: string,
tier?: "mission" | "slice" | "sub-slice",
): DotId | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
const m = DOT_ID_RE.exec(trimmed);
if (!m) return null;
const project = m[1]!;
const segments = m[2]!.split(".");
if (!tier) {
return { project, version: segments.join("."), n: undefined, m: undefined };
}
if (tier === "mission") {
return { project, version: segments.join("."), n: undefined, m: undefined };
}
if (tier === "slice") {
if (segments.length < 3) return null; // slice = ver(>=2) + n
const verSegs = segments.slice(0, -1);
const nSeg = segments[segments.length - 1]!;
return { project, version: verSegs.join("."), n: Number(nSeg), m: undefined };
}
// sub-slice
if (segments.length < 4) return null;
const verSegs = segments.slice(0, -2);
const nSeg = segments[segments.length - 2]!;
const mSeg = segments[segments.length - 1]!;
return { project, version: verSegs.join("."), n: Number(nSeg), m: Number(mSeg) };
}
/** Validate a mission-ver segments array against the §1 escape-band
* rule. The convention defines the non-release escape band as
* `<PFX>.99.0.<n>` — the `0` after `99` is FIXED. A version like
* `99.7.8` does NOT conform. Shared between isMissionDotId and the
* parent-version check inside isSliceDotId so both surfaces stay in
* lockstep. */
function isValidMissionVerSegments(segs: string[]): boolean {
if (segs.length < 2 || segs.length > 3) return false;
if (segs[0] === "99") {
// Escape band: exactly [99, "0", "<ordinal>"]. The `0` segment is
// fixed; `<ordinal>` must be a non-empty numeric string.
return segs.length === 3 && segs[1] === "0" && /^\d+$/.test(segs[2] ?? "");
}
return true;
}
/** Depth-based tier discriminator. Per §1 the positional grammar
* is fixed:
* mission = <PFX>.<ver> (ver = 2-3 numeric segments;
* escape band is exactly 99.0.n)
* slice = <PFX>.<ver>.<n> (3-4 numeric segments)
* sub-slice = <PFX>.<ver>.<n>.<m> (4-5 numeric segments)
* Where a mission shape OVERLAPS with a slice shape (e.g. release
* X.Y mission has 2 segments; release X.Y.Z mission has 3 segments
* which is also the slice shape for release X.Y missions) the tier
* is resolved by depth: mission caps at 3 numeric segments; slice
* always has 3-4; sub-slice has 4-5. Escape band is fully unambiguous
* because §1 fixes both the 99 marker AND the 0 segment that follows. */
export function isMissionDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "mission");
if (!parsed) return false;
return isValidMissionVerSegments(parsed.version.split("."));
}
export function isSliceDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "slice");
if (!parsed) return false;
// Slice = mission-ver + 1 ordinal. Parent ver must itself be a
// valid mission shape (including the escape-band exact-[99.0.n]
// rule — a parent like 99.7.8 is not a real mission).
return isValidMissionVerSegments(parsed.version.split("."));
}
/** Render a DotId back to its canonical dot-string. */
export function formatDotId(id: DotId): string {
const parts = [id.project, id.version];
if (id.n !== undefined) parts.push(String(id.n));
if (id.m !== undefined) parts.push(String(id.m));
return parts.join(".");
}
/** Compose a slice ID by appending an ordinal to a parent mission ID. */
export function sliceIdFromMission(missionId: string, n: number): string {
return `${missionId}.${n}`;
}
/** Infer the (project, version) pair from a mission folder name when
* no explicit id is supplied. Release missions use their semver; any
* other name falls into the escape band where the caller supplies the
* next ordinal via the second arg. */
export function inferMissionDotId(
missionFolderName: string,
escapeBandOrdinal: number | null,
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
): string {
const releaseMatch = RELEASE_NAME_RE.exec(missionFolderName);
if (releaseMatch) {
return `${projectPrefix}.${releaseMatch[1]}`;
}
if (escapeBandOrdinal === null) {
throw new ScopeCliError({
fact: `Mission "${missionFolderName}" doesn't match the release-X.Y[.Z] pattern and no escape-band ordinal was supplied.`,
consequence: "No dot-ID could be inferred for this mission.",
action: "Pass an explicit ordinal, or rename the mission to release-X.Y.Z, or supply --id on the CLI.",
});
}
return `${projectPrefix}.99.0.${escapeBandOrdinal}`;
}
/** Pick the next escape-band ordinal by scanning peer mission IDs.
* Looks for `<PFX>.99.0.<m>` shapes and returns max(m)+1, starting
* at 1 when no peer exists. */
export function nextEscapeBandOrdinal(
existingIds: ReadonlyArray<string | null>,
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
): number {
let max = 0;
const re = new RegExp(`^${projectPrefix}\\.99\\.0\\.(\\d+)$`);
for (const id of existingIds) {
if (!id) continue;
const m = re.exec(id);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > max) max = n;
}
return max + 1;
}
/** Does this candidate string parse as a valid §1 dot-ID at ANY
* tier? Prefer the tier-specific isMissionDotId/isSliceDotId at use
* sites where the tier is known — depth alone doesn't disambiguate
* mission vs slice for release IDs with overlapping shapes. */
export function isConformantDotId(raw: unknown): boolean {
return typeof raw === "string" && parseDotId(raw) !== null;
}
-517
View File
@@ -1,517 +0,0 @@
// release-0.3.2 slice 12 — filesystem + git helpers shared across
// `rig scope` verbs. Mission/slice discovery, frontmatter parse/write,
// auto-numbering, git mv wrapper. Keeps slice/mission command files
// thin.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
import type {
MissionInfo,
SliceInfo,
SliceState,
} from "./types.js";
import { ScopeCliError } from "./types.js";
import {
DEFAULT_PROJECT_PREFIX,
inferMissionDotId,
nextEscapeBandOrdinal,
} from "./dot-id.js";
const FRONTMATTER_DELIM = "---\n";
const SLICE_DIRNAME_RE = /^(\d+)-(.+)$/;
// ---------------------------------------------------------------------
// Frontmatter parse + write
// ---------------------------------------------------------------------
/** Split a markdown file into [frontmatter, body]. Returns `[{}, content]`
* when no frontmatter delimiter is present. */
export function splitFrontmatter(content: string): {
frontmatter: Record<string, unknown>;
body: string;
} {
if (!content.startsWith(FRONTMATTER_DELIM)) {
return { frontmatter: {}, body: content };
}
const rest = content.slice(FRONTMATTER_DELIM.length);
const endIdx = rest.indexOf(`\n${FRONTMATTER_DELIM.trim()}\n`);
if (endIdx === -1) {
// Allow trailing `---` without a final newline (last char of file).
const endIdx2 = rest.indexOf(`\n---`);
if (endIdx2 === -1) return { frontmatter: {}, body: content };
const raw = rest.slice(0, endIdx2);
return { frontmatter: parseYamlSafely(raw), body: rest.slice(endIdx2 + 4).replace(/^\n/, "") };
}
const raw = rest.slice(0, endIdx);
const body = rest.slice(endIdx + `\n${FRONTMATTER_DELIM.trim()}\n`.length);
return { frontmatter: parseYamlSafely(raw), body };
}
function parseYamlSafely(raw: string): Record<string, unknown> {
try {
const parsed = YAML.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/** Re-stitch frontmatter + body into a single markdown string. */
export function joinFrontmatter(
frontmatter: Record<string, unknown>,
body: string,
): string {
const yaml = YAML.stringify(frontmatter, { lineWidth: 0 }).trim();
const trailing = body.startsWith("\n") ? "" : "\n";
return `---\n${yaml}\n---\n${trailing}${body}`;
}
/** Read + parse frontmatter from a markdown file. Returns `{}` if the
* file is missing OR has no frontmatter (graceful for placeholder
* missions that pre-date the convention). */
export function readFrontmatter(absPath: string): Record<string, unknown> {
try {
return splitFrontmatter(fs.readFileSync(absPath, "utf8")).frontmatter;
} catch {
return {};
}
}
/** Update specific keys in a markdown file's frontmatter. Existing
* unknown keys are preserved. Generates minimal frontmatter when
* absent. */
export function updateFrontmatter(
absPath: string,
updates: Record<string, unknown>,
): void {
const original = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
const { frontmatter, body } = splitFrontmatter(original);
const merged = { ...frontmatter, ...updates };
fs.writeFileSync(absPath, joinFrontmatter(merged, body), "utf8");
}
// ---------------------------------------------------------------------
// Mission discovery
// ---------------------------------------------------------------------
/** Locate the missions root for the working substrate. Resolution
* order:
* 1. Explicit override (env or arg)
* 2. $OPENRIG_WORK_ROOT/missions
* 3. Search up from cwd for a `missions/` folder
*/
export function resolveMissionsRoot(opts: {
override?: string | null;
cwd?: string;
} = {}): string {
const cwd = opts.cwd ?? process.cwd();
const fromOverride = opts.override ?? process.env.OPENRIG_WORK_ROOT;
if (fromOverride) {
const candidate = path.isAbsolute(fromOverride) ? fromOverride : path.resolve(cwd, fromOverride);
const missions = path.join(candidate, "missions");
if (fs.existsSync(missions) && fs.statSync(missions).isDirectory()) return missions;
if (path.basename(candidate) === "missions" && fs.existsSync(candidate)) return candidate;
}
let dir = cwd;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, "missions");
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new ScopeCliError({
fact: `Could not locate a missions/ root from cwd ${cwd}.`,
consequence: "No mission tree to operate on.",
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/substrate/shared-docs/openrig-work.",
});
}
/** List mission folders under the missions root. A mission is any
* top-level folder containing a README.md (per IMPLEMENTATION-PRD
* §2.1 / HG-8). Directories without a README.md are skipped — they
* represent scratch/junk, not declared missions. */
export function listMissions(missionsRoot: string): MissionInfo[] {
if (!fs.existsSync(missionsRoot)) return [];
const entries = fs.readdirSync(missionsRoot, { withFileTypes: true });
const out: MissionInfo[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const absPath = path.join(missionsRoot, entry.name);
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
if (!hasReadme) continue;
const frontmatter = readFrontmatter(readmePath);
const slicesDir = path.join(absPath, "slices");
const closedDir = path.join(absPath, "closed");
const activeSliceCount = countSliceDirs(slicesDir);
const closedSliceCount = countSliceDirs(closedDir);
const id = pickIdFromFrontmatter(frontmatter);
out.push({
name: entry.name,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id,
activeSliceCount,
closedSliceCount,
});
}
// Stable order: by mission name.
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function countSliceDirs(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
.length;
}
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
const candidate = fm.id ?? fm.dotId;
return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
}
/** Resolve a mission by name OR path relative to the missions root.
* Throws ScopeCliError on miss OR when the directory exists but
* lacks a README.md (consistent with listMissions' README gate per
* PRD §2.1 / HG-8 — README-less dirs are scratch/junk, not declared
* missions, so mutation commands must not silently target them). */
export function findMission(missionsRoot: string, identifier: string): MissionInfo {
const candidates = [
path.join(missionsRoot, identifier),
path.isAbsolute(identifier) ? identifier : path.resolve(missionsRoot, identifier),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
const readmePath = path.join(candidate, "README.md");
if (!fs.existsSync(readmePath)) {
throw new ScopeCliError({
fact: `Directory "${identifier}" exists at ${candidate} but contains no README.md.`,
consequence: "It is not a declared mission. Command did not run.",
action: "Create it as a mission with: rig scope mission create " + identifier + ". Or add a README.md if the folder is intended to be a mission.",
});
}
return buildMissionInfo(missionsRoot, candidate);
}
}
throw new ScopeCliError({
fact: `Mission "${identifier}" not found under ${missionsRoot}.`,
consequence: "Command did not run.",
action: "List available missions with: rig scope mission ls",
});
}
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
return {
name: path.basename(absPath),
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id: pickIdFromFrontmatter(frontmatter),
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
};
}
// ---------------------------------------------------------------------
// Slice discovery
// ---------------------------------------------------------------------
export function listSlices(
mission: MissionInfo,
state: SliceState,
): SliceInfo[] {
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
if (state === "active" || state === "shipped" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
}
if (state === "closed" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
}
const out: SliceInfo[] = [];
for (const { root } of dirs) {
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const sliceInfo = buildSliceInfo(mission, root, entry.name);
out.push(sliceInfo);
}
}
if (state === "active") {
// active = not in closed/, but also not shipped status.
return out.filter((s) => {
const st = (s.status ?? "").toLowerCase();
return !st.startsWith("closed") && !st.startsWith("shipped");
});
}
if (state === "shipped") {
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
}
return out;
}
function buildSliceInfo(mission: MissionInfo, sliceRoot: string, dirName: string): SliceInfo {
const m = SLICE_DIRNAME_RE.exec(dirName);
const nn = m ? Number(m[1]) : null;
const slug = m ? m[2]! : null;
const absPath = path.join(sliceRoot, dirName);
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
const id = pickIdFromFrontmatter(frontmatter);
const status = typeof frontmatter.status === "string" ? (frontmatter.status as string).toLowerCase() : null;
return {
name: dirName,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
nn,
slug,
missionName: mission.name,
id,
status,
};
}
/** Resolve a slice path (absolute, relative-to-substrate, or
* relative-to-mission) into a SliceInfo. */
export function findSlice(
missionsRoot: string,
slicePath: string,
hintMission?: string | null,
): SliceInfo {
const candidates: string[] = [];
if (path.isAbsolute(slicePath)) {
candidates.push(slicePath);
} else {
candidates.push(path.resolve(missionsRoot, "..", slicePath));
candidates.push(path.resolve(missionsRoot, slicePath));
if (hintMission) {
candidates.push(path.join(missionsRoot, hintMission, "slices", slicePath));
candidates.push(path.join(missionsRoot, hintMission, "closed", slicePath));
}
}
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
// Walk up to find the owning mission.
const owningMissionPath = findOwningMission(missionsRoot, candidate);
if (!owningMissionPath) {
throw new ScopeCliError({
fact: `Slice path "${slicePath}" resolved to ${candidate} but no parent mission was found.`,
consequence: "Cannot determine mission context for this slice.",
action: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/.",
});
}
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
const sliceRoot = path.dirname(candidate);
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
}
}
throw new ScopeCliError({
fact: `Slice "${slicePath}" not found.`,
consequence: "Command did not run.",
action: "Check the path. List slices in a mission with: rig scope slice ls --mission <name>",
});
}
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
let dir = path.dirname(slicePath);
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
if (path.dirname(dir) === missionsRoot) return dir;
dir = path.dirname(dir);
}
return null;
}
// ---------------------------------------------------------------------
// Auto-numbering
// ---------------------------------------------------------------------
/** Find the next available NN for a mission's slices/ folder. Scans
* BOTH slices/ AND closed/ so numbers are never reused (§3.2). */
export function nextSliceNN(missionAbsPath: string): number {
let max = 0;
for (const subdir of ["slices", "closed"]) {
const root = path.join(missionAbsPath, subdir);
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > max) max = n;
}
}
return max + 1;
}
/** Mint or read the dot-ID for a mission. If the mission's README has
* an `id:` field, return it. Otherwise infer from the folder name
* (release-X.Y.Z → OPR.X.Y.Z; otherwise escape band). */
export function ensureMissionId(
mission: MissionInfo,
missionsRoot: string,
): string {
if (mission.id) return mission.id;
// Try release-pattern first; if it matches, no peer scan needed.
try {
const id = inferMissionDotId(mission.name, null);
return id;
} catch {
// Fall through to escape-band path.
}
const peers = listMissions(missionsRoot).filter((m) => m.name !== mission.name);
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
return inferMissionDotId(mission.name, ordinal);
}
/** Same as ensureMissionId, but ALSO writes the inferred id back into
* the mission's README frontmatter when it was absent. Use this at
* every site that mints a child id (slice create / ship / move target)
* — per the convention's lazy-adoption rule: when a child is created
* under a pre-existing parent with no id, assign the parent's id
* on-demand at the same time. Narrow, single-parent, create-triggered;
* never mass-migration. */
export function ensureMissionIdPersisted(
mission: MissionInfo,
missionsRoot: string,
): string {
const id = ensureMissionId(mission, missionsRoot);
if (!mission.id && mission.readmePath) {
updateFrontmatter(mission.readmePath, { id });
// Reflect the write on the in-memory MissionInfo so subsequent
// callers in the same command don't re-mint a different ordinal.
mission.id = id;
}
return id;
}
// ---------------------------------------------------------------------
// Git move
// ---------------------------------------------------------------------
/** Return the git toplevel for a path, or null if not inside a repo. */
export function gitTopLevel(absPath: string): string | null {
const dir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory() ? absPath : path.dirname(absPath);
try {
const out = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || null;
} catch {
return null;
}
}
/** Refuse to move a path with uncommitted local changes. Catches the
* "dirty working tree" risk in §11.1 / §3.4 — `git mv` would silently
* carry along the user's in-progress edits. */
export function assertCleanWorkingTree(repoRoot: string, relPath: string): void {
let status: string;
try {
status = execFileSync(
"git",
["-C", repoRoot, "status", "--porcelain", "--", relPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
} catch (err) {
throw new ScopeCliError({
fact: `git status failed for ${relPath} in ${repoRoot}: ${(err as Error).message}`,
consequence: "Could not verify the slice's working-tree state. Move aborted.",
action: "Check the repository at " + repoRoot + " and retry.",
});
}
if (status.trim().length > 0) {
throw new ScopeCliError({
fact: `Working tree under ${relPath} has uncommitted changes:\n${status.trimEnd()}`,
consequence: "Refusing to git mv — your local edits would silently move with the slice.",
action: "Commit (git commit -m '...') or stash (git stash) and retry.",
});
}
}
/** Move a directory using `git mv` when inside a repo; fall back to
* `fs.renameSync` with a warning when not. Returns true if git was
* used. */
export function moveSlice(srcAbs: string, destAbs: string, opts: {
/** Stage the move (default true). git mv stages by default; we
* preserve that semantic. */
commit?: boolean;
} = {}): { usedGit: boolean; repoRoot: string | null } {
if (!fs.existsSync(srcAbs)) {
throw new ScopeCliError({
fact: `Source path ${srcAbs} does not exist.`,
consequence: "Move did not run.",
action: "Verify the slice path and retry.",
});
}
if (fs.existsSync(destAbs)) {
throw new ScopeCliError({
fact: `Destination path ${destAbs} already exists.`,
consequence: "Refusing to overwrite an existing slice.",
action: "Pick a different destination, or remove the existing folder first.",
});
}
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
const repoRoot = gitTopLevel(srcAbs);
if (!repoRoot) {
fs.renameSync(srcAbs, destAbs);
return { usedGit: false, repoRoot: null };
}
// Normalize symlinks (macOS /var/folders → /private/var/folders) so
// path.relative produces a path INSIDE the repo, not a ../../escape.
const realSrcAbs = fs.realpathSync(srcAbs);
const realDestParent = fs.realpathSync(path.dirname(destAbs));
const realDestAbs = path.join(realDestParent, path.basename(destAbs));
const srcRel = path.relative(repoRoot, realSrcAbs);
assertCleanWorkingTree(repoRoot, srcRel);
const destRel = path.relative(repoRoot, realDestAbs);
try {
execFileSync("git", ["-C", repoRoot, "mv", srcRel, destRel], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
throw new ScopeCliError({
fact: `git mv ${srcRel} ${destRel} failed: ${(err as Error).message}`,
consequence: "Move aborted; source unchanged.",
action: "Inspect the repo state and retry.",
});
}
if (opts.commit) {
// Future hook; v0 doesn't auto-commit.
}
return { usedGit: true, repoRoot };
}
// ---------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------
export function todayDateISO(): string {
const d = new Date();
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export { DEFAULT_PROJECT_PREFIX };
-82
View File
@@ -1,82 +0,0 @@
// release-0.3.2 slice 12 — template loader. Templates live as
// markdown files alongside the source so they can be edited like any
// other doc. The build copies them to dist/ via tsconfig
// rootDir/files behavior — but since .md isn't a .ts file, we read
// them directly via fileURLToPath to remain robust across local dev
// and the published package layout.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { MissionTemplateKind, SliceTemplateKind } from "./types.js";
import { ScopeCliError } from "./types.js";
const here = path.dirname(fileURLToPath(import.meta.url));
/** Candidate template roots, in order: source tree (dev), dist (built).
* We resolve at call time so the first existing directory wins. */
function candidateRoots(): string[] {
return [
// Dev: packages/cli/src/lib/scope/ → ../scope-templates
path.resolve(here, "..", "scope-templates"),
// Built: dist/lib/scope/ → ../../src/lib/scope-templates (one level up further if
// dist lands at packages/cli/dist/lib/scope).
path.resolve(here, "..", "..", "lib", "scope-templates"),
// Fallback: source tree relative to compiled dist when source is co-shipped.
path.resolve(here, "..", "..", "..", "src", "lib", "scope-templates"),
];
}
function resolveTemplate(filename: string): string {
for (const root of candidateRoots()) {
const candidate = path.join(root, filename);
if (fs.existsSync(candidate)) return candidate;
}
throw new ScopeCliError({
fact: `Template ${filename} could not be located.`,
consequence: "Cannot scaffold the new artifact.",
action: "Reinstall @openrig/cli, or run from a checkout with packages/cli/src/lib/scope-templates/ present.",
});
}
export interface RenderOpts {
id: string;
slice_number?: string; // zero-padded; only for slice templates
slug: string;
mission: string;
title: string;
created_date: string;
release_version?: string;
}
function applyPlaceholders(content: string, opts: RenderOpts): string {
return content
.replace(/\{\{id\}\}/g, opts.id)
.replace(/\{\{slice_number\}\}/g, opts.slice_number ?? "")
.replace(/\{\{slug\}\}/g, opts.slug)
.replace(/\{\{mission\}\}/g, opts.mission)
.replace(/\{\{title\}\}/g, opts.title)
.replace(/\{\{created_date\}\}/g, opts.created_date)
.replace(/\{\{release_version\}\}/g, opts.release_version ?? "");
}
export function renderSliceTemplate(kind: SliceTemplateKind, opts: RenderOpts): string {
const filename = `${kind}.md`;
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
return applyPlaceholders(raw, opts);
}
export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpts): string {
const filename = kind === "release" ? "mission-release.md" : "mission-placeholder.md";
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
return applyPlaceholders(raw, opts);
}
/** Convert a folder-slug to a title-cased display name. */
export function titleFromSlug(slug: string): string {
return slug
.split(/[-_]/g)
.filter(Boolean)
.map((word) => word[0]!.toUpperCase() + word.slice(1))
.join(" ");
}
-97
View File
@@ -1,97 +0,0 @@
// release-0.3.2 slice 12 — scope CLI primitive types.
//
// Aligned with the substrate convention at
// `openrig-work/conventions/scope-and-versioning/README.md` (stage:
// provisional, authored 2026-05-15). The CLI mints stable dot-IDs
// into created mission/slice frontmatter per §1 of that convention.
/** Scope tiers. v0 ships `mission` + `slice`; `project` and `sub-slice`
* exist in the grammar but have no CLI verbs yet. */
export type ScopeTier = "project" | "mission" | "slice" | "sub-slice";
/** Stable reference ID parts. `OPR.0.3.2.12` → project="OPR",
* version="0.3.2", n=12. Escape-band non-release missions look like
* `OPR.99.0.1` → version="99.0.1", n=undefined. */
export interface DotId {
project: string; // 2-3 letter project prefix
version: string; // semver-shaped (release) or escape-band (99.x.y)
n?: number; // slice ordinal
m?: number; // sub-slice ordinal
}
export interface MissionInfo {
/** Mission folder name (e.g., "release-0.3.2", "backlog"). */
name: string;
/** Absolute path to the mission folder. */
absPath: string;
/** Path to the mission's README.md, if present. */
readmePath: string | null;
/** Parsed frontmatter from the README. */
frontmatter: Record<string, unknown>;
/** Minted dot-ID for this mission (read from frontmatter if present,
* otherwise inferred from the folder name pattern). */
id: string | null;
activeSliceCount: number;
closedSliceCount: number;
}
export interface SliceInfo {
name: string; // folder name like "12-rig-slice-cli-primitive"
absPath: string;
readmePath: string | null;
frontmatter: Record<string, unknown>;
/** "NN-slug" → NN. */
nn: number | null;
/** "NN-slug" → slug. */
slug: string | null;
missionName: string;
/** Minted dot-ID, parent.NN form. */
id: string | null;
/** Frontmatter `status` field, lowercased. */
status: string | null;
}
/** 3-part error shape per `building-agent-software` skill §3.6. */
export class ScopeCliError extends Error {
readonly fact: string;
readonly consequence: string;
readonly action: string;
constructor(opts: { fact: string; consequence: string; action: string }) {
super(`${opts.fact}\n${opts.consequence}\n${opts.action}`);
this.name = "ScopeCliError";
this.fact = opts.fact;
this.consequence = opts.consequence;
this.action = opts.action;
}
}
export type SliceState = "active" | "closed" | "shipped" | "all";
export type SliceTemplateKind =
| "placeholder"
| "bug-fix"
| "backlog-deprecation"
| "backlog-tech-debt"
| "release-feature"
| "research";
export const SLICE_TEMPLATE_KINDS: ReadonlyArray<SliceTemplateKind> = [
"placeholder",
"bug-fix",
"backlog-deprecation",
"backlog-tech-debt",
"release-feature",
"research",
];
export type MissionTemplateKind = "placeholder" | "release";
export const MISSION_TEMPLATE_KINDS: ReadonlyArray<MissionTemplateKind> = [
"placeholder",
"release",
];
export type CloseReason = "wontfix" | "deferred" | "superseded" | "stale";
export const CLOSE_REASONS: ReadonlyArray<CloseReason> = [
"wontfix",
"deferred",
"superseded",
"stale",
];
+1 -172
View File
@@ -9,7 +9,7 @@
// - parseNamedPairs decodes the named-pair format
// - init-workspace creates mission-aware workspace files; idempotent
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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";
@@ -40,14 +40,6 @@ function clearEnv(): () => void {
"OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS",
"OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS",
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_COMPACT_INSTRUCTION",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
];
@@ -94,57 +86,16 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"recovery.provider_auth_env_allowlist",
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
"agents.advisor_session", "agents.operator_session",
// V0.3.1 slice 05 kernel-rig-as-default — operator seat name
// read by daemon mission-control + UI; default
// `operator-${USER}@kernel` derived in resolve().
"workspace.operator_seat_name",
// 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",
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
"policies.claude_compaction.compact_instruction",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
];
expect([...VALID_KEYS]).toEqual(expected);
});
it("workspace.operator_seat_name roundtrip — default derives operator-${USER}@kernel; set → resolve reflects override", () => {
const store = new ConfigStore(configPath);
const before = store.resolve();
// Default cascade derives from OS username at resolve() time.
expect(before.workspace.operatorSeatName).toMatch(/^operator-.+@kernel$/);
store.set("workspace.operator_seat_name", "operator-test@kernel");
const after = store.resolve();
expect(after.workspace.operatorSeatName).toBe("operator-test@kernel");
store.reset("workspace.operator_seat_name");
const reset = store.resolve();
expect(reset.workspace.operatorSeatName).toMatch(/^operator-.+@kernel$/);
});
it("workspace.operator_seat_name env override beats file-stored value", () => {
const store = new ConfigStore(configPath);
store.set("workspace.operator_seat_name", "operator-file@kernel");
process.env.OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME = "operator-env@kernel";
try {
const resolved = store.resolve();
expect(resolved.workspace.operatorSeatName).toBe("operator-env@kernel");
} finally {
delete process.env.OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME;
}
});
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.
@@ -396,128 +347,6 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(true);
expect(store.resolve().recovery.autoDriveProviderPrompts).toBe(true);
});
// Slice 27 BLOCKING-FIX-2 — env override + file value validation.
// Bypass-prevention at the RESOLVE path: an env override like
// OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc previously
// parseInt-coerced to 80 and shipped through resolveOne as a "valid"
// env-sourced value. With BLOCKING-FIX-2 the env resolution validates;
// bad env drops the override (falls to file/default) and warns on stderr.
describe("BLOCKING-FIX-2: env + file source validation", () => {
const reject = ["0", "101", "-1", "80abc", "80.5", "NaN", "Infinity"];
for (const raw of reject) {
it(`env=${JSON.stringify(raw)} → resolve returns default 80; warning emitted`, () => {
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = raw;
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const store = new ConfigStore(configPath);
const resolved = store.resolve();
expect(resolved.policies.claudeCompaction.thresholdPercent).toBe(80);
const calls = stderrSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((c) => c.includes("env override for policies.claude_compaction.threshold_percent rejected"))).toBe(true);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
}
});
}
it("discriminator: env=80abc (rejected, source falls to default, warning) vs env=80 (accepted, source=env, no warning)", () => {
// Case A: rejected → source=default, warning emitted
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "80abc";
let stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const a = new ConfigStore(configPath).resolveWithSource("policies.claude_compaction.threshold_percent");
expect(a.value).toBe(80);
expect(a.source).toBe("default");
const warns = stderrSpy.mock.calls
.map((c) => String(c[0]))
.filter((c) => c.includes("env override for policies.claude_compaction.threshold_percent rejected"));
expect(warns.length).toBe(1);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
}
// Case B: accepted → SAME numeric value (80) but source=env, NO warning
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "80";
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const b = new ConfigStore(configPath).resolveWithSource("policies.claude_compaction.threshold_percent");
expect(b.value).toBe(80);
expect(b.source).toBe("env");
const warns = stderrSpy.mock.calls
.map((c) => String(c[0]))
.filter((c) => c.includes("rejected"));
expect(warns.length).toBe(0);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
}
});
const fileReject: Array<{ name: string; written: unknown }> = [
{ name: "0", written: 0 },
{ name: "101", written: 101 },
{ name: "-1", written: -1 },
{ name: "80.5 (JSON non-integer)", written: 80.5 },
{ name: '"80abc" (JSON string)', written: "80abc" },
];
for (const { name, written } of fileReject) {
it(`file thresholdPercent=${name} → resolve returns default 80; warning emitted`, () => {
writeFileSync(configPath, JSON.stringify({
policies: { claudeCompaction: { thresholdPercent: written } },
}));
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const resolved = new ConfigStore(configPath).resolve();
expect(resolved.policies.claudeCompaction.thresholdPercent).toBe(80);
const calls = stderrSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((c) => c.includes("file value for policies.claude_compaction.threshold_percent rejected"))).toBe(true);
} finally {
stderrSpy.mockRestore();
}
});
}
});
// Slice 27 BLOCKING-FIX — strict accept/reject matrix for
// policies.claude_compaction.threshold_percent. The contract is
// integer in [1, 100]. parseInt's permissive coercion would otherwise
// accept "80abc" → 80, "80.5" → 80, and even 0 / 101 / -1 which would
// break the trigger's safety contract (0 = compact every tick).
describe("policies.claude_compaction.threshold_percent strict validation matrix", () => {
const accept = ["1", "2", "50", "80", "99", "100"];
const reject: Array<{ raw: string; reason: RegExp }> = [
{ raw: "0", reason: /must be in \[1, 100\]/ },
{ raw: "101", reason: /must be in \[1, 100\]/ },
{ raw: "-1", reason: /must be in \[1, 100\]/ },
{ raw: "80abc", reason: /expected an integer/ },
{ raw: "abc80", reason: /expected a number|expected an integer/ },
{ raw: "80.5", reason: /expected an integer/ },
{ raw: "", reason: /expected a number|expected an integer/ },
{ raw: " ", reason: /expected a number|expected an integer/ },
{ raw: "NaN", reason: /expected a number|expected an integer/ },
{ raw: "Infinity", reason: /expected a number|expected an integer/ },
];
for (const value of accept) {
it(`accepts ${JSON.stringify(value)}`, () => {
const store = new ConfigStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", value)).not.toThrow();
expect(store.get("policies.claude_compaction.threshold_percent")).toBe(Number(value));
});
}
for (const { raw, reason } of reject) {
it(`rejects ${JSON.stringify(raw)}`, () => {
const store = new ConfigStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", raw)).toThrow(reason);
});
}
});
});
describe("parseNamedPairs", () => {
+1 -112
View File
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import http from "node:http";
import { Command } from "commander";
import { ConfigStore, VALID_KEYS } from "../src/config-store.js";
import { ConfigStore } from "../src/config-store.js";
import { configCommand } from "../src/commands/config.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
@@ -110,26 +110,13 @@ describe("ConfigStore", () => {
describe("DaemonClient config integration", () => {
let tmpDir: string;
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "client-config-"));
savedEnv = {
OPENRIG_PORT: process.env["OPENRIG_PORT"],
OPENRIG_HOST: process.env["OPENRIG_HOST"],
OPENRIG_DB: process.env["OPENRIG_DB"],
};
delete process.env["OPENRIG_PORT"];
delete process.env["OPENRIG_HOST"];
delete process.env["OPENRIG_DB"];
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("ConfigStore resolves non-default host/port from config file", async () => {
@@ -279,50 +266,6 @@ describe("Config CLI", () => {
expect(logs.join("\n")).toContain("transcripts");
});
it("rig config get <key> --json prints value with source metadata", async () => {
const savedHost = process.env["OPENRIG_HOST"];
delete process.env["OPENRIG_HOST"];
const cmd = configCommand(join(tmpDir, "config.json"));
const prog = new Command();
prog.exitOverride();
prog.addCommand(cmd);
try {
const { logs } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "config", "get", "daemon.host", "--json"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.value).toBe("127.0.0.1");
expect(parsed.source).toBe("default");
expect(parsed.defaultValue).toBe("127.0.0.1");
} finally {
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
it("rig config --json get <key> prints value with source metadata", async () => {
const savedHost = process.env["OPENRIG_HOST"];
delete process.env["OPENRIG_HOST"];
const cmd = configCommand(join(tmpDir, "config.json"));
const prog = new Command();
prog.exitOverride();
prog.addCommand(cmd);
try {
const { logs } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "config", "--json", "get", "daemon.host"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.value).toBe("127.0.0.1");
expect(parsed.source).toBe("default");
expect(parsed.defaultValue).toBe("127.0.0.1");
} finally {
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
// Test 10
it("rig config --help includes subcommands and examples", () => {
const cmd = configCommand(join(tmpDir, "config.json"));
@@ -338,58 +281,4 @@ describe("Config CLI", () => {
// by checking description and options contain the essential info
expect(coreHelp).toContain("config");
});
// V0.3.1 slice 08 — HG-6: rig config --help text must enumerate every
// top-level dotted-prefix used in VALID_KEYS. The slice 08 verification
// pass caught a drift where ui.preview.* / agents.* / feed.subscriptions.* /
// runtime.codex.* + workspace.dogfood_evidence_root +
// workspace.operator_seat_name + transcripts.lines +
// transcripts.poll_interval_seconds were missing from the help "Keys:"
// section. This regression test extracts every top-level prefix from
// VALID_KEYS and asserts each appears (or its parent group appears) in
// the addHelpText after-text — guards against silent drift when new
// keys are added without updating help text.
it("HG-6: rig config --help enumerates every VALID_KEYS top-level prefix", () => {
const cmd = configCommand(join(tmpDir, "config.json"));
// Commander's `addHelpText("after", ...)` text is NOT included by
// `helpInformation()` — it's emitted via lifecycle hooks at help
// display time. Capture the full displayed help by routing
// `outputHelp()` through a custom writer.
let combined = "";
cmd.configureOutput({
writeOut: (text) => {
combined += text;
},
writeErr: () => {},
});
cmd.outputHelp();
// Top-level prefixes are the first dotted segment OR first-two
// segments for grouped namespaces like `ui.preview.*`,
// `feed.subscriptions.*`, `runtime.codex.*`.
const prefixes = new Set<string>();
for (const key of VALID_KEYS) {
const parts = key.split(".");
// Group sub-namespaces under their second segment when there are
// 3+ segments (e.g. ui.preview.refresh_interval_seconds → "ui.preview").
if (parts.length >= 3) {
prefixes.add(`${parts[0]}.${parts[1]}`);
} else {
prefixes.add(parts[0]!);
}
}
// Single-segment leaves (db.path, files.allowlist, progress.scan_roots)
// also appear in the help; the test allows either bare prefix OR
// the full leaf to satisfy the assertion.
const knownLeaves: Record<string, string[]> = {
"db": ["db.path"],
"files": ["files.allowlist"],
"progress": ["progress.scan_roots"],
};
for (const prefix of prefixes) {
const accepted = [prefix, ...(knownLeaves[prefix] ?? [])];
const found = accepted.some((needle) => combined.includes(needle));
expect(found, `rig config --help text must mention "${prefix}" (or one of ${accepted.join(", ")})`).toBe(true);
}
});
});
-164
View File
@@ -938,170 +938,6 @@ describe("buildDaemonEnv", () => {
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
});
// bug-fix slice auth-bearer-tailscale-trust forward-fix:
// Default-product-launch must NOT export OPENRIG_HOST so daemon's
// index.ts falls through to the loopback+tailscale multi-bind path.
describe("auth-bearer-tailscale-trust: explicit-vs-default host signal", () => {
it("omits OPENRIG_HOST when opts.host is undefined (default-product-launch)", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
db: "/tmp/test.db",
// host intentionally omitted — operator never set it (default path)
});
expect(result["OPENRIG_HOST"]).toBeUndefined();
// port + db still flow through
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
});
it("sets OPENRIG_HOST when opts.host is explicit (user opt-in)", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
host: "0.0.0.0",
db: "/tmp/test.db",
});
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
});
it("does NOT scrub OPENRIG_HOST from baseEnv when opts.host is undefined (operator shell env wins)", () => {
// If the operator has OPENRIG_HOST set in their shell, that's an
// explicit signal — preserve it through the env-passthrough loop.
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/usr/bin",
OPENRIG_HOST: "0.0.0.0",
};
const result = buildDaemonEnv(baseEnv, {
port: 7433,
db: "/tmp/test.db",
});
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
});
});
// Slice 22 founder-walk-vm-populated-env forward-fix #1: the slice
// intentionally drops a `--openrig-home` CLI flag in favor of using
// the process-env-pattern (each `rig` invocation gets its own
// OPENRIG_HOME via the shell). The slice's "fully isolated state"
// claim depends on the env contract being honored at module load
// time — specifically that getOpenRigHome() returns the current
// env value (not a hardcoded default) and that getDefaultOpenRigPath
// threads it into derived paths. The module-level constants
// (OPENRIG_DIR / STATE_FILE / LOG_FILE) are a single snapshot of
// these functions at load.
//
// These regression tests prove the function contract with distinct-
// value discriminators per banked feedback_poc_regression_must_discriminate.
describe("slice 22: OPENRIG_HOME env contract for per-process state isolation", () => {
it("getOpenRigHome respects process.env.OPENRIG_HOME (distinct values yield distinct paths)", async () => {
const { getOpenRigHome } = await import("../src/openrig-compat.js");
const saved = process.env["OPENRIG_HOME"];
try {
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-blank";
const blank = getOpenRigHome();
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-populated";
const populated = getOpenRigHome();
expect(blank).toBe("/Users/example/.openrig-blank");
expect(populated).toBe("/Users/example/.openrig-populated");
expect(blank).not.toBe(populated);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
it("getDefaultOpenRigPath threads env-resolved OPENRIG_HOME into derived paths (daemon.json + log)", async () => {
const { getDefaultOpenRigPath } = await import("../src/openrig-compat.js");
const saved = process.env["OPENRIG_HOME"];
try {
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-blank";
const blankState = getDefaultOpenRigPath("daemon.json");
const blankLog = getDefaultOpenRigPath("daemon.log");
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-populated";
const populatedState = getDefaultOpenRigPath("daemon.json");
const populatedLog = getDefaultOpenRigPath("daemon.log");
expect(blankState).toBe("/Users/example/.openrig-blank/daemon.json");
expect(populatedState).toBe("/Users/example/.openrig-populated/daemon.json");
expect(blankLog).toBe("/Users/example/.openrig-blank/daemon.log");
expect(populatedLog).toBe("/Users/example/.openrig-populated/daemon.log");
expect(blankState).not.toBe(populatedState);
expect(blankLog).not.toBe(populatedLog);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
it("empty OPENRIG_HOME falls back to homedir default (not the literal empty string)", async () => {
const { getOpenRigHome } = await import("../src/openrig-compat.js");
const { homedir } = await import("node:os");
const saved = process.env["OPENRIG_HOME"];
try {
process.env["OPENRIG_HOME"] = "";
const result = getOpenRigHome();
expect(result).toBe(`${homedir()}/.openrig`);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
// FF2 regression test (per qitem-20260511115845-ee9a4775): the
// prior 3 tests verify the openrig-compat function contract but
// do NOT prove daemon-lifecycle's module-level OPENRIG_DIR /
// STATE_FILE / LOG_FILE constants pick up OPENRIG_HOME at import
// time. Future code could hardcode "~/.openrig" in
// daemon-lifecycle.ts:66-69 and the prior tests would still pass.
//
// This test imports daemon-lifecycle FRESH twice via
// vi.resetModules + dynamic import, with distinct OPENRIG_HOME
// values per import. Discriminator-distinct paths (per banked
// feedback_poc_regression_must_discriminate) prove each module
// load reflects the env that was set at that moment. Will fail
// if a future contributor hardcodes a literal path or otherwise
// bypasses openrig-compat helpers at module level.
it("daemon-lifecycle module-level constants reflect OPENRIG_HOME at import time (slice 22 FF2)", async () => {
const saved = process.env["OPENRIG_HOME"];
try {
// First import — OPENRIG_HOME = blank-slate value
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-vm-blank-fresh";
vi.resetModules();
const blank = await import("../src/daemon-lifecycle.js");
// Second import — OPENRIG_HOME = populated value (distinct dir;
// not equal, not a symlink prefix). vi.resetModules clears the
// module cache so the second import re-evaluates module-level
// constants under the new env.
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-vm-populated-fresh";
vi.resetModules();
const populated = await import("../src/daemon-lifecycle.js");
// OPENRIG_DIR — direct snapshot of getOpenRigHome() at module load
expect(blank.OPENRIG_DIR).toBe("/Users/example/.openrig-vm-blank-fresh");
expect(populated.OPENRIG_DIR).toBe("/Users/example/.openrig-vm-populated-fresh");
expect(blank.OPENRIG_DIR).not.toBe(populated.OPENRIG_DIR);
// STATE_FILE / LOG_FILE — derived as path.join(OPENRIG_DIR, "daemon.{json,log}")
expect(blank.STATE_FILE).toBe("/Users/example/.openrig-vm-blank-fresh/daemon.json");
expect(populated.STATE_FILE).toBe("/Users/example/.openrig-vm-populated-fresh/daemon.json");
expect(blank.STATE_FILE).not.toBe(populated.STATE_FILE);
expect(blank.LOG_FILE).toBe("/Users/example/.openrig-vm-blank-fresh/daemon.log");
expect(populated.LOG_FILE).toBe("/Users/example/.openrig-vm-populated-fresh/daemon.log");
expect(blank.LOG_FILE).not.toBe(populated.LOG_FILE);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
// Re-reset modules so subsequent tests (and the file-top
// import of daemon-lifecycle at line 4-19) aren't holding
// stale references to a module instance bound to a temp env.
vi.resetModules();
}
});
});
describe("V1 pre-release CLI/daemon Item 1 — transcript rotation tunables projection", () => {
it("projects transcriptsLines + transcriptsPollIntervalSeconds into OPENRIG_* env vars when provided", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
+4 -4
View File
@@ -35,7 +35,7 @@ hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: tester
user: wrandom
notes: "Tart VM"
`, (path) => {
const r = loadHostRegistry(path);
@@ -46,7 +46,7 @@ hosts:
id: "vm-claude-test",
transport: "ssh",
target: "vm-claude-test.local",
user: "tester",
user: "wrandom",
notes: "Tart VM",
});
}
@@ -62,14 +62,14 @@ hosts:
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: tester
user: wrandom
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.registry.hosts.map((h) => h.id)).toEqual(["vm-a", "laptop-b"]);
expect(r.registry.hosts[0]?.user).toBeUndefined();
expect(r.registry.hosts[1]?.user).toBe("tester");
expect(r.registry.hosts[1]?.user).toBe("wrandom");
}
});
});
-781
View File
@@ -1,781 +0,0 @@
// rig plugin CLI commands (slice 3.4) — list/show/used-by/validate.
//
// Per IMPL-PRD §4 + DESIGN.md §6 (CLI minimalism: read-only inspection only).
// Consumes plugin-discovery-service from slice 3.3 via HTTP routes
// (GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by).
// `validate` is local file inspection — reuses agent-manifest's
// validatePluginResources logic + skill frontmatter checks.
//
// All commands ship --json output for agent consumption per
// banked building-agent-software + IMPL-PRD §4.4 HG-4.5.
//
// Test pattern follows context-pack.test.ts (slice 3.4 mirror) — in-memory
// http.createServer daemon mock + Commander.parseAsync via captureLogs.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import http from "node:http";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { Command } from "commander";
import { pluginCommand } from "../src/commands/plugin.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),
};
}
// Fixtures mirror the slice 3.3 PluginDiscoveryService wire shape verbatim
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132). Any drift
// from that source means the daemon contract changed and these fixtures
// must update in lockstep with the CLI wire types in plugin.ts.
const FIXTURE_PLUGIN_ENTRY = {
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
source: "vendored" as const,
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"] as const,
path: "/home/op/.openrig/plugins/openrig-core",
lastSeenAt: "2026-05-11T05:00:00Z",
};
const FIXTURE_PLUGIN_DETAIL = {
entry: FIXTURE_PLUGIN_ENTRY,
claudeManifest: {
raw: { name: "openrig-core", version: "0.1.0", description: "OpenRig canonical skills and hooks", license: "Apache-2.0", repository: "github:mvschwarz/openrig-plugins" },
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
homepage: null,
repository: "github:mvschwarz/openrig-plugins",
license: "Apache-2.0",
},
codexManifest: {
raw: { name: "openrig-core", version: "0.1.0", description: "OpenRig canonical skills and hooks", license: "Apache-2.0" },
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
homepage: null,
repository: null,
license: "Apache-2.0",
},
skills: [
{ name: "openrig-user", relativePath: "skills/openrig-user" },
{ name: "openrig-architect", relativePath: "skills/openrig-architect" },
],
hooks: [
{ runtime: "claude" as const, relativePath: "hooks/claude.json", events: ["SessionStart", "UserPromptSubmit", "Stop", "Notification"] },
{ runtime: "codex" as const, relativePath: "hooks/codex.json", events: ["SessionStart", "UserPromptSubmit", "Stop"] },
],
mcpServers: [],
};
const FIXTURE_USED_BY = [
{
agentName: "advisor-lead",
sourcePath: "/home/op/.openrig/specs/agents/advisor/lead/agent.yaml",
profiles: ["default"],
},
];
describe("rig plugin CLI (slice 3.4)", () => {
let server: http.Server;
let port: number;
let lastQuery: Record<string, string | undefined>;
beforeAll(async () => {
server = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost`);
lastQuery = Object.fromEntries(url.searchParams.entries());
if (url.pathname === "/api/plugins" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([FIXTURE_PLUGIN_ENTRY]));
return;
}
if (url.pathname === "/api/plugins/openrig-core" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PLUGIN_DETAIL));
return;
}
if (url.pathname === "/api/plugins/openrig-core/used-by" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_USED_BY));
return;
}
if (url.pathname === "/api/plugins/unreferenced/used-by" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
return;
}
if (url.pathname === "/api/plugins/missing" && req.method === "GET") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "plugin_not_found" }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
port = (server.address() as { port: number }).port;
resolve();
});
});
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
lastQuery = {};
});
// ============================================================
// rig plugin list (HG-4.1)
// ============================================================
describe("rig plugin list", () => {
it("--json returns the plugin entries from /api/plugins", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as Array<{ id: string }>;
expect(parsed).toHaveLength(1);
expect(parsed[0]?.id).toBe("openrig-core");
});
it("default (table) output prints id + version + runtimes + source label + path (real PluginEntry fields)", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
expect(out).toContain("openrig-core");
expect(out).toContain("0.1.0");
expect(out).toContain("vendored:openrig-core");
// Regression: real path field, not invented rootPath
expect(out).toContain("/home/op/.openrig/plugins/openrig-core");
// Regression: must NOT print invented count fields (they don't exist on PluginEntry)
expect(out).not.toMatch(/\bskillCount\b/);
expect(out).not.toMatch(/\bhookEventCount\b/);
expect(out).not.toMatch(/\bmcpServerCount\b/);
});
it("--runtime claude passes runtime filter to the daemon route", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "claude", "--json"]);
});
expect(lastQuery.runtime).toBe("claude");
});
it("--source vendored passes source filter to the daemon route", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--source", "vendored", "--json"]);
});
expect(lastQuery.source).toBe("vendored");
});
});
// ============================================================
// rig plugin show <id> (HG-4.2)
// ============================================================
describe("rig plugin show <id>", () => {
it("--json returns the plugin detail from /api/plugins/:id", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "show", "openrig-core", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as { entry: { id: string; path: string; lastSeenAt: string | null }; skills: unknown[]; hooks: unknown[] };
expect(parsed.entry.id).toBe("openrig-core");
expect(parsed.entry.path).toBe("/home/op/.openrig/plugins/openrig-core"); // real PluginEntry.path
expect(parsed.skills).toHaveLength(2);
expect(parsed.hooks).toHaveLength(2);
});
it("default (pretty) output uses REAL PluginDetail fields (name + relativePath + events array)", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "show", "openrig-core"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
// PluginEntry fields
expect(out).toContain("openrig-core");
expect(out).toContain("0.1.0");
expect(out).toContain("/home/op/.openrig/plugins/openrig-core"); // entry.path
expect(out).toContain("2026-05-11T05:00:00Z"); // entry.lastSeenAt
// Manifests — real PluginManifestSummary fields
expect(out).toMatch(/claude:/);
expect(out).toMatch(/codex:/);
expect(out).toContain("Apache-2.0");
expect(out).toContain("github:mvschwarz/openrig-plugins");
// Skills — uses skill.name + skill.relativePath (not invented id/path/description)
expect(out).toContain("openrig-user");
expect(out).toContain("openrig-architect");
expect(out).toContain("skills/openrig-user");
expect(out).toContain("skills/openrig-architect");
// Hooks — uses runtime + events array (not invented eventCount alone) +
// relativePath
expect(out).toContain("hooks/claude.json");
expect(out).toContain("hooks/codex.json");
expect(out).toContain("SessionStart");
expect(out).toContain("UserPromptSubmit");
expect(out).toContain("Notification"); // Claude event count = 4, includes Notification
});
it("missing plugin id returns non-zero exit + error to stderr", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "show", "missing"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toMatch(/not.*found|404|missing/i);
});
});
// ============================================================
// rig plugin used-by <id> (HG-4.3)
//
// Reverse query: AgentReference[] = { agentName, sourcePath, profiles[] }
// (PluginDiscoveryService L134-141 verbatim shape).
// Daemon route: GET /api/plugins/:id/used-by
// ============================================================
describe("rig plugin used-by <id>", () => {
it("--json returns the AgentReference[] from /api/plugins/:id/used-by", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "used-by", "openrig-core", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as Array<{ agentName: string; sourcePath: string; profiles: string[] }>;
expect(parsed).toHaveLength(1);
expect(parsed[0]?.agentName).toBe("advisor-lead");
expect(parsed[0]?.sourcePath).toBe("/home/op/.openrig/specs/agents/advisor/lead/agent.yaml");
expect(parsed[0]?.profiles).toEqual(["default"]);
});
it("default (pretty) output prints agentName + sourcePath + profiles for each reference", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "used-by", "openrig-core"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
// Real AgentReference fields verbatim — no invented fields
expect(out).toContain("advisor-lead");
expect(out).toContain("/home/op/.openrig/specs/agents/advisor/lead/agent.yaml");
expect(out).toContain("default");
});
it("empty result (no agents reference the plugin) is honest — does NOT exit non-zero", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
// Use 'unreferenced' which the http server returns [] for
await program.parseAsync(["node", "rig", "plugin", "used-by", "unreferenced"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
// Some honest message acknowledging zero refs (not crash, not silent empty)
expect(out.toLowerCase()).toMatch(/no agent|0|none|not referenced/);
});
it("--json on empty result returns []", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "used-by", "unreferenced", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as unknown[];
expect(parsed).toEqual([]);
});
});
// ============================================================
// rig plugin list — filter-value validation (pre-close punch from
// velocity-guard 3.4.A repair verdict)
//
// CLI-side validation so typos like --source rig-cwd don't look
// filtered while the daemon silently returns all plugins.
// ============================================================
describe("rig plugin list — filter validation", () => {
it("rejects invalid --runtime value with clear error + non-zero exit", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "bogus"]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n");
expect(err).toMatch(/--runtime/);
expect(err).toMatch(/bogus/);
// Should name the allowed values so operator can correct
expect(err.toLowerCase()).toMatch(/claude|codex/);
});
it("rejects invalid --source value (rig-cwd typo case) with clear error + non-zero exit", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--source", "rig-cwd"]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n");
expect(err).toMatch(/--source/);
expect(err).toMatch(/rig-cwd/);
// Should name the allowed values
expect(err).toMatch(/vendored|claude-cache|codex-cache/);
});
it("accepts valid --runtime + --source values (regression: still passes through correctly)", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "claude", "--source", "vendored", "--json"]);
});
expect(exitCode).toBeUndefined();
expect(lastQuery.runtime).toBe("claude");
expect(lastQuery.source).toBe("vendored");
});
});
// ============================================================
// rig plugin validate <path> (HG-4.4)
//
// Local file inspection — does NOT require daemon. Validates:
// - .claude-plugin/plugin.json + .codex-plugin/plugin.json shape
// (name + version required; description required on Codex)
// - At least one manifest dir is present
// - Each skills/<id>/SKILL.md has frontmatter with name (non-empty)
// + description (non-empty, ≤1024 chars per agentskills.io)
//
// Output: error list; exit code 0 when valid, 1 when any errors.
// ============================================================
describe("rig plugin validate <path>", () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "plugin-validate-"));
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
function writeManifest(relativePath: string, contents: Record<string, unknown>) {
const fullPath = join(tmpRoot, relativePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, JSON.stringify(contents, null, 2));
}
function writeSkill(skillId: string, frontmatter: string, body = "# body\n") {
const skillPath = join(tmpRoot, "skills", skillId, "SKILL.md");
mkdirSync(dirname(skillPath), { recursive: true });
writeFileSync(skillPath, `---\n${frontmatter}\n---\n\n${body}`);
}
it("valid dual-manifest plugin tree passes validation (exit 0)", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0", description: "Test",
});
writeManifest(".codex-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0", description: "Test",
});
writeSkill("foo", "name: foo\ndescription: A foo skill");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, logs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n").toLowerCase()).toMatch(/valid|ok|pass/);
});
it("plugin with no manifest dirs at all FAILS validation", async () => {
writeSkill("foo", "name: foo\ndescription: A foo skill");
// No .claude-plugin/ + no .codex-plugin/
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/manifest|plugin\.json/);
});
it("Codex manifest missing description FAILS (Codex requires description)", async () => {
writeManifest(".codex-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
// description MISSING
});
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/codex.*description|description.*codex/);
});
it("manifest missing required name FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", {
version: "1.0.0", description: "Test",
// name MISSING
});
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/name/);
});
it("skill missing frontmatter name FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
writeSkill("bad-skill", "description: a description but no name");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/bad-skill/);
expect(err).toMatch(/name/);
});
it("skill frontmatter description >1024 chars FAILS (agentskills.io limit)", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
const longDescription = "x".repeat(1100);
writeSkill("verbose", `name: verbose\ndescription: ${longDescription}`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/verbose/);
expect(err).toMatch(/1024|description.*length|too long/);
});
it("--json output returns structured { valid, errors } shape", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
writeSkill("foo", "name: foo\ndescription: A foo skill");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, logs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot, "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as { valid: boolean; errors: string[] };
expect(parsed.valid).toBe(true);
expect(parsed.errors).toEqual([]);
});
it("non-existent path FAILS with clear error", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", join(tmpRoot, "nonexistent")]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/not found|does not exist|enoent/);
});
// ============================================================
// velocity-guard 3.4.C BLOCKING-CONCERN regressions:
// regex-based validation could stamp `name: ""` quoted-empty as
// valid; YAML-parser-based validation catches it. Also null/non-
// object manifest results.
// ============================================================
it("BLOCKING-FIX: skill with quoted-empty name FAILS (regex would have passed)", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
writeSkill("empty-name", `name: ""\ndescription: A real description`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/empty-name/);
expect(err).toMatch(/name/);
});
it("BLOCKING-FIX: skill with quoted-empty description FAILS (regex would have passed)", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
writeSkill("empty-desc", `name: empty-desc\ndescription: ""`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/empty-desc/);
expect(err).toMatch(/description/);
});
it("BLOCKING-FIX: skill with malformed YAML frontmatter FAILS (not parseable as object)", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
// Malformed: unclosed bracket
writeSkill("bad-yaml", `name: foo\ndescription: [unclosed`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/bad-yaml/);
expect(err).toMatch(/yaml|parse|invalid/);
});
it("BLOCKING-FIX: skill with non-object frontmatter (e.g. just a string) FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
// YAML scalar — parses as string, not object
writeSkill("not-object", `just a string`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/not-object/);
expect(err).toMatch(/object|map/);
});
it("BLOCKING-FIX: skill name as non-string (e.g. number) FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
// YAML parses 42 as a number, not string
writeSkill("number-name", `name: 42\ndescription: A skill`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/number-name/);
expect(err).toMatch(/name/);
});
it("BLOCKING-FIX: manifest plugin.json containing null FAILS (not crashes)", async () => {
// Write literal "null" as the manifest body — JSON.parse returns null
const claudeManifestPath = join(tmpRoot, ".claude-plugin", "plugin.json");
mkdirSync(dirname(claudeManifestPath), { recursive: true });
writeFileSync(claudeManifestPath, "null");
writeSkill("foo", `name: foo\ndescription: A skill`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/manifest|object/);
});
it("BLOCKING-FIX: manifest plugin.json containing array FAILS (must be object)", async () => {
const claudeManifestPath = join(tmpRoot, ".claude-plugin", "plugin.json");
mkdirSync(dirname(claudeManifestPath), { recursive: true });
writeFileSync(claudeManifestPath, JSON.stringify(["not", "an", "object"]));
writeSkill("foo", `name: foo\ndescription: A skill`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/manifest|object/);
});
});
});
-565
View File
@@ -1,565 +0,0 @@
// release-0.3.2 slice 12 — `rig scope` command-surface integration
// tests. Drives the commander tree end-to-end with a tmp substrate
// fixture so every HG-N gate has direct coverage.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { Command } from "commander";
import { scopeCommand } from "../src/commands/scope.js";
import { readFrontmatter } from "../src/lib/scope/scope-fs.js";
function mktemp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-cmd-"));
}
function writeFile(p: string, content: string): void {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, "utf8");
}
function seedSubstrate(): { root: string; missionsRoot: string } {
const root = mktemp();
const missionsRoot = path.join(root, "openrig-work", "missions");
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.name", "T"], { stdio: "ignore" });
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "01-existing", "README.md"),
"---\nid: OPR.0.3.2.1\nstatus: active\n---\n# existing\n",
);
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
writeFile(
path.join(missionsRoot, "backlog", "slices", "01-debt-foo", "README.md"),
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# debt\n",
);
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
return { root, missionsRoot };
}
interface CaptureResult {
exitCode: number;
stdout: string;
stderr: string;
}
async function run(args: string[], workspace: string): Promise<CaptureResult> {
const stdoutBuf: string[] = [];
const stderrBuf: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
const origErrWrite = process.stderr.write.bind(process.stderr);
const origExit = process.exit;
let exitCode = 0;
process.stdout.write = ((chunk: unknown) => { stdoutBuf.push(String(chunk)); return true; }) as typeof process.stdout.write;
process.stderr.write = ((chunk: unknown) => { stderrBuf.push(String(chunk)); return true; }) as typeof process.stderr.write;
process.exit = ((code?: number) => {
exitCode = code ?? 0;
throw new Error(`__EXIT__${exitCode}`);
}) as typeof process.exit;
const program = new Command();
program.addCommand(scopeCommand());
program.exitOverride();
try {
await program.parseAsync(["node", "rig", "scope", ...args, "--workspace", path.dirname(workspace)]);
} catch (err) {
const msg = (err as Error).message ?? "";
if (!msg.startsWith("__EXIT__")) {
stderrBuf.push(msg + "\n");
}
} finally {
process.stdout.write = origWrite;
process.stderr.write = origErrWrite;
process.exit = origExit;
}
return {
exitCode,
stdout: stdoutBuf.join(""),
stderr: stderrBuf.join(""),
};
}
// ---------------------------------------------------------------------
// HG-1 + HG-9: rig scope slice ls --json + state filter
// ---------------------------------------------------------------------
describe("rig scope slice ls", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-1 + HG-9: --json returns active slices from a specific mission", async () => {
const r = await run(["slice", "ls", "--mission", "release-0.3.2", "--state", "active", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slices.map((s: { name: string }) => s.name)).toEqual(["01-existing"]);
expect(parsed.slices[0].id).toBe("OPR.0.3.2.1");
});
it("returns slices from all missions when --mission is omitted", async () => {
const r = await run(["slice", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.count).toBe(2);
});
});
// ---------------------------------------------------------------------
// HG-2: rig scope slice show
// ---------------------------------------------------------------------
describe("rig scope slice show (HG-2)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("renders frontmatter + README + children", async () => {
const r = await run([
"slice", "show", "01-existing",
"--mission", "release-0.3.2",
"--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slice.id).toBe("OPR.0.3.2.1");
expect(parsed.slice.readme).toContain("# existing");
});
});
// ---------------------------------------------------------------------
// HG-3 + HG-4 + HG-15: rig scope slice create
// ---------------------------------------------------------------------
describe("rig scope slice create", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-3: auto-finds next NN, never reuses numbers", async () => {
// Add a closed slice 04 so existing NNs are 01 + 04. Next must be 05.
writeFile(
path.join(env.missionsRoot, "release-0.3.2", "closed", "04-old", "README.md"),
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\n",
);
const r = await run([
"slice", "create", "release-0.3.2", "new-thing", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slice.name).toMatch(/^05-/);
});
it("HG-4: --template populates the template", async () => {
const r = await run([
"slice", "create", "release-0.3.2", "perf-fix",
"--template", "bug-fix", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
const readme = fs.readFileSync(path.join(parsed.slice.path, "README.md"), "utf8");
expect(readme).toMatch(/## Repro/);
expect(readme).toMatch(/## Expected/);
});
it("HG-15: created slice frontmatter has a conformant dot-ID", async () => {
const r = await run([
"slice", "create", "release-0.3.2", "tdd-foo", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
const fm = readFrontmatter(path.join(parsed.slice.path, "README.md"));
expect(fm.id).toBe("OPR.0.3.2.2");
expect(typeof fm.id === "string" && /^OPR\.0\.3\.2\.\d+$/.test(fm.id as string)).toBe(true);
});
});
// ---------------------------------------------------------------------
// HG-5 + HG-9: rig scope slice ship — git mv + frontmatter update
// ---------------------------------------------------------------------
describe("rig scope slice ship (HG-5)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("git mv + frontmatter status: shipped-to-release-X.Y", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.shipped.git.usedGit).toBe(true);
// Source removed; target present.
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
expect(fs.existsSync(parsed.shipped.to.path)).toBe(true);
const fm = readFrontmatter(path.join(parsed.shipped.to.path, "README.md"));
expect(fm.status).toBe("shipped-to-release-0.3.2");
expect(fm.mission).toBe("release-0.3.2");
});
});
// ---------------------------------------------------------------------
// HG-6: rig scope slice close --reason
// ---------------------------------------------------------------------
describe("rig scope slice close (HG-6)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("moves to closed/ + sets status: closed-<reason>", async () => {
const r = await run([
"slice", "close", "01-debt-foo",
"--mission", "backlog",
"--reason", "wontfix",
"--note", "obsolete by design",
"--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo"))).toBe(true);
const fm = readFrontmatter(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo", "README.md"));
expect(fm.status).toBe("closed-wontfix");
expect(fm["closure-note"]).toBe("obsolete by design");
});
it("rejects unknown --reason values", async () => {
const r = await run([
"slice", "close", "01-debt-foo",
"--mission", "backlog",
"--reason", "fishy",
"--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
});
});
// ---------------------------------------------------------------------
// HG-7: rig scope slice move
// ---------------------------------------------------------------------
describe("rig scope slice move (HG-7)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("moves between missions; auto-renumber; frontmatter.mission updates", async () => {
const r = await run([
"slice", "move", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.moved.to.name).toMatch(/^02-/); // 01 was taken
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
const fm = readFrontmatter(path.join(parsed.moved.to.path, "README.md"));
expect(fm.mission).toBe("release-0.3.2");
});
});
// ---------------------------------------------------------------------
// HG-8: rig scope mission ls
// ---------------------------------------------------------------------
describe("rig scope mission ls (HG-8)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("--json lists missions with id + slice counts", async () => {
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
expect(names).toEqual(["backlog", "release-0.3.2"]);
});
});
describe("rig scope mission show", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("renders frontmatter + README + slice inventory", async () => {
const r = await run(["mission", "show", "release-0.3.2", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toBe("OPR.0.3.2");
expect(parsed.mission.slices.length).toBe(1);
});
});
// ---------------------------------------------------------------------
// HG-14 + HG-15: rig scope mission create (PROMOTED to v0 per Amendment §B)
// ---------------------------------------------------------------------
describe("rig scope mission create (HG-14 + HG-15)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-14: mission create is invocable with the SAME pattern as slice create", async () => {
// Symmetry test: both verbs take (positional name) + (--template) +
// (--json) and emit { ok, [tier]: { name, id, template, path } }.
const r = await run([
"mission", "create", "release-0.4.0", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.mission.name).toBe("release-0.4.0");
expect(parsed.mission.path).toBeTruthy();
expect(parsed.mission.template).toBe("release");
});
it("HG-15: created mission frontmatter has a conformant dot-ID per §1", async () => {
const r = await run(["mission", "create", "release-0.5.0", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toBe("OPR.0.5.0");
const fm = readFrontmatter(path.join(parsed.mission.path, "README.md"));
expect(fm.id).toBe("OPR.0.5.0");
});
it("HG-15: non-release names get an escape-band ID (uniform-numeric)", async () => {
const r = await run([
"mission", "create", "experiments-foo", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toMatch(/^OPR\.99\.0\.\d+$/);
});
it("HG-15: accepts an explicit --id override and rejects malformed ones", async () => {
const ok = await run([
"mission", "create", "weird-thing",
"--id", "OPR.99.0.42", "--json",
], env.missionsRoot);
expect(JSON.parse(ok.stdout).mission.id).toBe("OPR.99.0.42");
const bad = await run([
"mission", "create", "another-weird-thing",
"--id", "OPR.A.1", "--json",
], env.missionsRoot);
expect(bad.exitCode).toBe(1);
});
it("BLOCK 1 discriminator: mission create rejects slice-depth --id (OPR.0.3.2.12)", async () => {
// Per guard BC verdict: a 4-segment slice-shape must NOT pass
// mission --id validation. Without tier-aware validation this
// would succeed and create a mission with a slice-shaped id.
const r = await run([
"mission", "create", "wrong-tier",
"--id", "OPR.0.3.2.12", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/not a mission-tier dot-ID/);
});
it("BLOCK 1 follow-up: mission create still accepts valid mission shapes (escape band + release)", async () => {
const eb = await run([
"mission", "create", "exp-band",
"--id", "OPR.99.0.42", "--json",
], env.missionsRoot);
expect(eb.exitCode).toBe(0);
expect(JSON.parse(eb.stdout).mission.id).toBe("OPR.99.0.42");
const rel = await run([
"mission", "create", "release-0.6.0", "--json",
], env.missionsRoot);
expect(rel.exitCode).toBe(0);
expect(JSON.parse(rel.stdout).mission.id).toBe("OPR.0.6.0");
});
it("BC-2 BLOCK 1 discriminator: rejects escape-band with non-zero middle segment (OPR.99.7.8)", async () => {
const r = await run([
"mission", "create", "bad-eb",
"--id", "OPR.99.7.8", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(JSON.parse(r.stdout).ok).toBe(false);
});
});
// ---------------------------------------------------------------------
// BC-2 BLOCK 2: README-less dirs are rejected at every mutation surface
// ---------------------------------------------------------------------
describe("BC-2 BLOCK 2 — README-less dirs are not declared missions at any mutation surface", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => {
env = seedSubstrate();
// Sneak a README-less directory into missions/ to verify every
// surface rejects it consistently with listMissions.
fs.mkdirSync(path.join(env.missionsRoot, "no-readme"), { recursive: true });
});
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("mission ls omits no-readme (already in HG-8 above; sanity check here)", async () => {
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.missions.map((m: { name: string }) => m.name)).not.toContain("no-readme");
});
it("mission show no-readme exits 1 with the 3-part 'not a declared mission' error", async () => {
const r = await run(["mission", "show", "no-readme", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/no README\.md|not a declared mission/);
});
it("slice create no-readme exits 1 + does NOT create slices/foo/", async () => {
const r = await run([
"slice", "create", "no-readme", "foo", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(path.join(env.missionsRoot, "no-readme", "slices", "01-foo"))).toBe(false);
});
it("slice ship to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
expect(fs.existsSync(srcAbs)).toBe(true);
const r = await run([
"slice", "ship", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
// Source unchanged.
expect(fs.existsSync(srcAbs)).toBe(true);
});
it("slice move to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
const r = await run([
"slice", "move", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(srcAbs)).toBe(true);
});
});
// ---------------------------------------------------------------------
// BLOCK 3 discriminator: ship + move persist target.id back to target README
// ---------------------------------------------------------------------
describe("BLOCK 3 — ship/move persist target mission id into target README", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => {
env = seedSubstrate();
// Replace release-0.3.2/README.md with an ID-LESS variant so we
// can observe the lazy-adopt-and-persist behavior.
fs.writeFileSync(
path.join(env.missionsRoot, "release-0.3.2", "README.md"),
"# release-0.3.2\n\n(no id frontmatter yet)\n",
"utf8",
);
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", env.root, "commit", "-m", "strip-target-id", "-q"], { stdio: "ignore" });
});
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("slice ship: target mission gains a persisted id; child id = targetId.NN", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
// Target README now has id: OPR.0.3.2 written back.
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
expect(targetFm.id).toBe("OPR.0.3.2");
// Child id matches parent.NN.
expect(parsed.shipped.to.id).toBe("OPR.0.3.2.2");
});
it("slice move: target mission gains a persisted id; child id = targetId.NN", async () => {
const r = await run([
"slice", "move", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
expect(targetFm.id).toBe("OPR.0.3.2");
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
});
it("sibling missions without id remain UNCHANGED after a ship/move (narrow lazy adoption, not mass-migrate)", async () => {
// Spawn an unrelated id-less mission alongside; assert it survives
// untouched through a ship to a different mission.
fs.mkdirSync(path.join(env.missionsRoot, "untouched-mission"), { recursive: true });
fs.writeFileSync(
path.join(env.missionsRoot, "untouched-mission", "README.md"),
"# untouched\n",
"utf8",
);
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", env.root, "commit", "-m", "seed-sibling", "-q"], { stdio: "ignore" });
const before = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const after = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
expect(after).toBe(before);
expect(readFrontmatter(path.join(env.missionsRoot, "untouched-mission", "README.md")).id).toBeUndefined();
});
});
// ---------------------------------------------------------------------
// HG-10: 3-part error shape
// ---------------------------------------------------------------------
describe("3-part error shape (HG-10)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("ship to nonexistent target mission yields fact/consequence/action", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-9.9.9",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toBeTruthy();
expect(parsed.error.consequence).toBeTruthy();
expect(parsed.error.action).toBeTruthy();
});
});
// ---------------------------------------------------------------------
// HG-12: --help exists for every command
// ---------------------------------------------------------------------
describe("--help is present on every command (HG-12)", () => {
it("every tier and verb has help text", () => {
const cmd = scopeCommand();
expect(cmd.description()).toBeTruthy();
const tiers = cmd.commands;
expect(tiers.map((c) => c.name()).sort()).toEqual(["mission", "slice"]);
for (const tier of tiers) {
expect(tier.description()).toBeTruthy();
for (const verb of tier.commands) {
expect(verb.description()).toBeTruthy();
}
}
});
});
-177
View File
@@ -1,177 +0,0 @@
// release-0.3.2 slice 12 — dot-ID grammar unit tests.
//
// Aligned with `openrig-work/conventions/scope-and-versioning/README.md` §1.
import { describe, expect, it } from "vitest";
import {
formatDotId,
inferMissionDotId,
isConformantDotId,
isMissionDotId,
isSliceDotId,
nextEscapeBandOrdinal,
parseDotId,
sliceIdFromMission,
} from "../src/lib/scope/dot-id.js";
describe("dot-ID — parse + format", () => {
it("parses a slice ID like OPR.0.3.2.12", () => {
const id = parseDotId("OPR.0.3.2.12");
expect(id).not.toBeNull();
expect(id!.project).toBe("OPR");
expect(id!.version).toBe("0.3.2.12");
});
it("parses an escape-band ID like OPR.99.0.1", () => {
const id = parseDotId("OPR.99.0.1");
expect(id).not.toBeNull();
expect(id!.project).toBe("OPR");
expect(id!.version).toBe("99.0.1");
});
it("rejects alpha segments (§1 explicit: uniform-numeric escape band)", () => {
expect(parseDotId("OPR.A.1")).toBeNull();
expect(parseDotId("OPR.0.3.A")).toBeNull();
});
it("rejects malformed prefixes", () => {
expect(parseDotId("opr.0.3.2")).toBeNull();
expect(parseDotId("O.0.3.2")).toBeNull();
expect(parseDotId("OPRX.0.3.2")).toBeNull();
});
it("formats a DotId back to canonical string", () => {
expect(formatDotId({ project: "OPR", version: "0.3.2" })).toBe("OPR.0.3.2");
expect(formatDotId({ project: "OPR", version: "0.3.2", n: 12 })).toBe("OPR.0.3.2.12");
});
});
describe("inferMissionDotId — release pattern + escape band", () => {
it("infers OPR.X.Y.Z from release-X.Y.Z folder names", () => {
expect(inferMissionDotId("release-0.3.2", null)).toBe("OPR.0.3.2");
expect(inferMissionDotId("release-0.4.0", null)).toBe("OPR.0.4.0");
expect(inferMissionDotId("release-1.0", null)).toBe("OPR.1.0");
});
it("requires an escape-band ordinal for non-release names", () => {
expect(() => inferMissionDotId("backlog", null)).toThrow();
expect(inferMissionDotId("backlog", 5)).toBe("OPR.99.0.5");
expect(inferMissionDotId("bug-fix", 3)).toBe("OPR.99.0.3");
});
});
describe("nextEscapeBandOrdinal", () => {
it("returns 1 when no peer IDs exist", () => {
expect(nextEscapeBandOrdinal([])).toBe(1);
expect(nextEscapeBandOrdinal([null, null])).toBe(1);
});
it("returns max+1 across existing escape-band peers", () => {
expect(nextEscapeBandOrdinal(["OPR.99.0.1", "OPR.99.0.3", null])).toBe(4);
});
it("ignores non-matching IDs (releases) when picking ordinal", () => {
expect(nextEscapeBandOrdinal(["OPR.0.3.2", "OPR.99.0.2"])).toBe(3);
});
});
describe("sliceIdFromMission", () => {
it("appends the slice ordinal to the mission ID", () => {
expect(sliceIdFromMission("OPR.0.3.2", 12)).toBe("OPR.0.3.2.12");
expect(sliceIdFromMission("OPR.99.0.1", 4)).toBe("OPR.99.0.1.4");
});
});
describe("isConformantDotId", () => {
it("accepts conformant IDs across all tiers", () => {
expect(isConformantDotId("OPR.0.3.2")).toBe(true);
expect(isConformantDotId("OPR.0.3.2.12")).toBe(true);
expect(isConformantDotId("OPR.99.0.1")).toBe(true);
});
it("rejects non-conformant inputs", () => {
expect(isConformantDotId("")).toBe(false);
expect(isConformantDotId(42)).toBe(false);
expect(isConformantDotId("opr.0.3.2")).toBe(false);
});
});
describe("BLOCK 1 — tier-aware validation (isMissionDotId / isSliceDotId)", () => {
it("isMissionDotId accepts release-shaped IDs (2-3 numeric segments)", () => {
expect(isMissionDotId("OPR.0.3.2")).toBe(true);
expect(isMissionDotId("OPR.1.0")).toBe(true);
});
it("isMissionDotId accepts the escape-band shape (99.x.y, exactly 3 segments)", () => {
expect(isMissionDotId("OPR.99.0.1")).toBe(true);
expect(isMissionDotId("OPR.99.0.42")).toBe(true);
});
it("isMissionDotId REJECTS slice-shaped IDs (4+ numeric segments) — guard BC discriminator", () => {
// The crux of BLOCK 1: a slice-depth ID like OPR.0.3.2.12 must
// not pass mission validation, even though it parses as a valid
// dot-ID at SOME tier.
expect(isMissionDotId("OPR.0.3.2.12")).toBe(false);
expect(isMissionDotId("OPR.99.0.1.5")).toBe(false);
});
it("isMissionDotId rejects malformed inputs (alpha / wrong prefix shape)", () => {
expect(isMissionDotId("OPR.A.1")).toBe(false);
expect(isMissionDotId("opr.0.3.2")).toBe(false);
expect(isMissionDotId(42 as unknown)).toBe(false);
expect(isMissionDotId("OPR.99.0")).toBe(false); // escape band must be exactly 3
});
it("isSliceDotId accepts slice-shaped IDs (3-4 numeric segments)", () => {
expect(isSliceDotId("OPR.0.3.2.12")).toBe(true); // release-X.Y.Z slice
expect(isSliceDotId("OPR.1.0.5")).toBe(true); // release-X.Y slice
expect(isSliceDotId("OPR.99.0.1.7")).toBe(true); // escape-band slice
});
it("isSliceDotId admits the inherent depth-3 overlap (release X.Y.Z mission shape = slice for an X.Y parent)", () => {
// The convention's positional grammar can't distinguish a
// release-X.Y.Z mission from a slice of a release-X.Y mission
// by depth alone. Both shapes are accepted by isSliceDotId; the
// CLI disambiguates by tier at use sites (mission create uses
// isMissionDotId; slice IDs are minted from a parent + ordinal,
// never user-typed).
expect(isSliceDotId("OPR.0.3.2")).toBe(true); // release X.Y.Z OR slice of X.Y
// Escape-band IDs are UNAMBIGUOUS by depth: an escape-band mission
// is depth-3 (OPR.99.0.1); an escape-band slice is depth-4
// (OPR.99.0.1.5). Depth-3 with leading 99 is mission-only.
expect(isSliceDotId("OPR.99.0.1")).toBe(false);
// The strictly-impossible-slice case is depth 2 (X.Y only) — no
// parent can have a sub-NN-segment of "Y".
expect(isSliceDotId("OPR.1.0")).toBe(false);
});
it("parseDotId(s, 'slice') peels the last segment off as `n`", () => {
// Per guard discriminator: parsing OPR.0.3.2.12 as a slice must
// yield version=0.3.2 and n=12, not version=0.3.2.12.
const id = parseDotId("OPR.0.3.2.12", "slice");
expect(id).not.toBeNull();
expect(id!.version).toBe("0.3.2");
expect(id!.n).toBe(12);
});
it("parseDotId(s, 'mission') keeps all numeric segments as version", () => {
const id = parseDotId("OPR.0.3.2", "mission");
expect(id).not.toBeNull();
expect(id!.version).toBe("0.3.2");
expect(id!.n).toBeUndefined();
});
it("BC-2 BLOCK 1: escape band rejects [99, !0, n] mission ver (only [99, 0, n] is valid)", () => {
// §1 fixes the escape band as <PFX>.99.0.<n> — the `0` is FIXED.
// Previously the depth-3 check accepted OPR.99.7.8 as a mission;
// now require the middle 0.
expect(isMissionDotId("OPR.99.7.8")).toBe(false);
expect(isMissionDotId("OPR.99.1.0")).toBe(false);
expect(isMissionDotId("OPR.99.0.42")).toBe(true); // still valid
expect(isMissionDotId("OPR.99.0.1")).toBe(true); // still valid
});
it("BC-2 BLOCK 1: escape-band slice parent must also satisfy the [99,0,n] rule", () => {
expect(isSliceDotId("OPR.99.7.8.1")).toBe(false); // parent=99.7.8 invalid
expect(isSliceDotId("OPR.99.0.42.7")).toBe(true); // parent=99.0.42 valid
});
});
-339
View File
@@ -1,339 +0,0 @@
// release-0.3.2 slice 12 — scope-fs helpers + frontmatter parser tests.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import {
ensureMissionId,
findMission,
findSlice,
listMissions,
listSlices,
moveSlice,
nextSliceNN,
readFrontmatter,
resolveMissionsRoot,
splitFrontmatter,
updateFrontmatter,
} from "../src/lib/scope/scope-fs.js";
function mktemp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-test-"));
}
function writeFile(p: string, content: string): void {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, "utf8");
}
describe("frontmatter parser", () => {
it("returns {} when there is no frontmatter delimiter", () => {
const { frontmatter, body } = splitFrontmatter("# heading\n\nbody");
expect(frontmatter).toEqual({});
expect(body).toBe("# heading\n\nbody");
});
it("parses simple key/value pairs", () => {
const src = "---\nid: OPR.0.3.2.12\nstatus: active\n---\nbody";
const { frontmatter, body } = splitFrontmatter(src);
expect(frontmatter).toEqual({ id: "OPR.0.3.2.12", status: "active" });
expect(body).toBe("body");
});
it("preserves unknown keys on update", () => {
const dir = mktemp();
const p = path.join(dir, "README.md");
writeFile(p, "---\nstatus: active\ncustom: keep-me\n---\nbody\n");
updateFrontmatter(p, { status: "shipped" });
const fm = readFrontmatter(p);
expect(fm.status).toBe("shipped");
expect(fm.custom).toBe("keep-me");
});
it("generates minimal frontmatter when absent", () => {
const dir = mktemp();
const p = path.join(dir, "README.md");
writeFile(p, "body only\n");
updateFrontmatter(p, { id: "OPR.0.3.2" });
const fm = readFrontmatter(p);
expect(fm.id).toBe("OPR.0.3.2");
});
});
describe("resolveMissionsRoot", () => {
it("uses an explicit override path when present", () => {
const root = mktemp();
fs.mkdirSync(path.join(root, "missions"));
expect(resolveMissionsRoot({ override: root })).toBe(path.join(root, "missions"));
});
it("throws ScopeCliError when no missions/ root is found", () => {
const empty = mktemp();
expect(() => resolveMissionsRoot({ override: empty, cwd: empty })).toThrow();
});
});
describe("listMissions + listSlices + nextSliceNN", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
// mission with 2 active + 1 closed slice
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "01-foo", "README.md"),
"---\nid: OPR.0.3.2.1\nstatus: active\n---\nbody\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "02-bar", "README.md"),
"---\nid: OPR.0.3.2.2\nstatus: active\n---\nbody\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "closed", "04-baz", "README.md"),
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\nbody\n",
);
// mission with no README — should be skipped as "no slice count basis"
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it("lists missions sorted by name with slice counts; SKIPS dirs without README.md (HG-8)", () => {
const missions = listMissions(missionsRoot);
// no-readme/ has no README.md and must NOT appear (HG-8 / PRD §2.1).
expect(missions.map((m) => m.name)).toEqual(["release-0.3.2"]);
const r = missions.find((m) => m.name === "release-0.3.2")!;
expect(r.activeSliceCount).toBe(2);
expect(r.closedSliceCount).toBe(1);
expect(r.id).toBe("OPR.0.3.2");
});
it("HG-8 discriminator: 3 dirs / 1 without README → exactly 2 missions listed", () => {
// Seed a third mission alongside the existing fixture for a clear
// make-it-fail-first signal per guard BC verdict.
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
const missions = listMissions(missionsRoot);
expect(missions.map((m) => m.name).sort()).toEqual(["backlog", "release-0.3.2"]);
// The README-less directory survives on disk but does not show up.
expect(fs.existsSync(path.join(missionsRoot, "no-readme"))).toBe(true);
});
it("listSlices active filter excludes closed/shipped (HG-1)", () => {
const mission = findMission(missionsRoot, "release-0.3.2");
const active = listSlices(mission, "active");
expect(active.map((s) => s.name).sort()).toEqual(["01-foo", "02-bar"]);
const closed = listSlices(mission, "closed");
expect(closed.map((s) => s.name)).toEqual(["04-baz"]);
});
it("nextSliceNN skips numbers already used in slices/ AND closed/ (HG-3)", () => {
const missionAbs = path.join(missionsRoot, "release-0.3.2");
// existing: 01, 02, 04 → next should be 5 (not 3 — numbers never reused).
expect(nextSliceNN(missionAbs)).toBe(5);
});
});
describe("findSlice + resolution variants", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\nbody",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "07-target", "README.md"),
"---\nid: OPR.0.3.2.7\nstatus: active\n---\nbody",
);
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("resolves a mission-relative path with --mission hint", () => {
const slice = findSlice(missionsRoot, "07-target", "release-0.3.2");
expect(slice.name).toBe("07-target");
expect(slice.id).toBe("OPR.0.3.2.7");
});
it("resolves an absolute path", () => {
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
const slice = findSlice(missionsRoot, abs);
expect(slice.missionName).toBe("release-0.3.2");
});
it("3-part error when slice not found (HG-10)", () => {
expect(() => findSlice(missionsRoot, "99-missing", "release-0.3.2")).toThrow(/not found/);
});
});
describe("BC-2 BLOCK 2 — findMission rejects README-less directories", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n",
);
// README-less directory next to a real mission — scratch/junk.
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("findMission throws 3-part error when target directory has no README.md", () => {
expect(() => findMission(missionsRoot, "no-readme")).toThrow(/not a declared mission|no README/);
});
it("findMission still resolves declared missions normally", () => {
expect(findMission(missionsRoot, "release-0.3.2").name).toBe("release-0.3.2");
});
});
describe("ensureMissionId", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("returns existing frontmatter id when present", () => {
writeFile(
path.join(missionsRoot, "weird-name", "README.md"),
"---\nid: OPR.99.0.7\n---\n",
);
const m = findMission(missionsRoot, "weird-name");
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.99.0.7");
});
it("derives from release-X.Y.Z pattern when no id present", () => {
writeFile(path.join(missionsRoot, "release-0.4.0", "README.md"), "");
const m = findMission(missionsRoot, "release-0.4.0");
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.0.4.0");
});
it("falls into escape band for non-release names", () => {
writeFile(path.join(missionsRoot, "backlog", "README.md"), "");
const m = findMission(missionsRoot, "backlog");
const id = ensureMissionId(m, missionsRoot);
expect(id).toMatch(/^OPR\.99\.0\.\d+$/);
});
});
// ---------------------------------------------------------------------
// git mv path — uses real git in a tmp repo (HG-5, HG-11)
// ---------------------------------------------------------------------
function initRepo(root: string): void {
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.name", "Tester"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "--allow-empty", "-m", "init", "-q"], { stdio: "ignore" });
}
describe("moveSlice — git mv preserves history (HG-5) + refuses dirty tree (HG-11)", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
initRepo(root);
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
writeFile(
path.join(missionsRoot, "backlog", "slices", "01-foo", "README.md"),
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# foo\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release\n",
);
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("uses git mv inside a repo and preserves the file via git log --follow", () => {
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "01-foo");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(true);
execFileSync("git", ["-C", root, "commit", "-m", "ship", "-q"], { stdio: "ignore" });
const log = execFileSync("git", [
"-C", root, "log", "--follow", "--pretty=format:%s", "--", path.relative(root, path.join(dest, "README.md")),
], { encoding: "utf8" });
expect(log).toMatch(/seed/);
expect(log).toMatch(/ship/);
});
it("refuses to move when slice has uncommitted local edits (HG-11)", () => {
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
fs.writeFileSync(path.join(src, "README.md"), "dirty\n", "utf8");
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "02-foo");
expect(() => moveSlice(src, dest)).toThrow(/uncommitted/);
});
it("falls back to fs.rename outside a git repo", () => {
const dir = mktemp();
const src = path.join(dir, "src");
const dest = path.join(dir, "dest");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "README.md"), "body");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(false);
expect(fs.existsSync(dest)).toBe(true);
fs.rmSync(dir, { recursive: true, force: true });
});
it("BLOCK 4 discriminator: handles symlinked src/dest paths without escaping the repo (macOS /var↔/private/var class)", () => {
// Build a parallel tree of symlinks pointing at the real repo
// tree. Without realpath normalization inside moveSlice, path.relative
// would resolve to ../.. and git mv would reject with "outside
// repository". With normalization, git mv succeeds and history
// follows the move via --follow.
const linkParent = mktemp();
const linkRoot = path.join(linkParent, "linked-substrate");
fs.symlinkSync(root, linkRoot);
// Sanity: the symlinked tree resolves to a different absolute path
// than the real tree (covers the /var↔/private/var class on macOS
// and any other path-resolving symlink class on other OSes).
expect(fs.realpathSync(linkRoot)).not.toBe(linkRoot);
const src = path.join(linkRoot, "missions", "backlog", "slices", "01-foo");
const dest = path.join(linkRoot, "missions", "release-0.3.2", "slices", "01-foo");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(true);
expect(result.repoRoot).toBe(fs.realpathSync(root)); // git returned the realpath
// git mv staged a rename; history follows.
execFileSync("git", ["-C", root, "commit", "-m", "ship-via-symlink", "-q"], { stdio: "ignore" });
const log = execFileSync("git", [
"-C", root, "log", "--follow", "--pretty=format:%s", "--",
"missions/release-0.3.2/slices/01-foo/README.md",
], { encoding: "utf8" });
expect(log).toMatch(/seed/);
expect(log).toMatch(/ship-via-symlink/);
fs.rmSync(linkParent, { recursive: true, force: true });
});
});
-94
View File
@@ -618,100 +618,6 @@ describe("Up CLI", () => {
expect(clientBaseUrl).toBe("http://127.0.0.1:7461");
});
// bug-fix slice auth-bearer-tailscale-trust forward-fix #2: `rig up`
// auto-start path was the second product launch path materializing
// the default daemon.host into OPENRIG_HOST. The discriminator below
// proves source=default → omit (so daemon multi-binds); source=env
// → preserve (operator opt-in).
describe("auth-bearer-tailscale-trust: up auto-start respects daemon.host source", () => {
function makeAutoStartDeps(captureSpawn: (env: Record<string, string>) => void): StatusDeps {
let daemonState: DaemonState | null = null;
let daemonStarted = false;
return {
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn((p: string) => p === STATE_FILE ? daemonState !== null : false),
readFile: vi.fn((p: string) => p === STATE_FILE && daemonState ? JSON.stringify(daemonState) : null),
writeFile: vi.fn((p: string, content: string) => {
if (p === STATE_FILE) daemonState = JSON.parse(content) as DaemonState;
}),
openForAppend: vi.fn(() => 3),
mkdirp: vi.fn(),
spawn: vi.fn((cmd, args, opts) => {
daemonStarted = true;
captureSpawn(opts.env as Record<string, string>);
return { pid: 999, unref: vi.fn() } as never;
}),
fetch: vi.fn(async (url: string) => {
if (!daemonStarted) throw new Error(`refused:${url}`);
return { ok: url.includes("/healthz") };
}),
},
clientFactory: () => ({
post: vi.fn(async () => ({
status: 201,
data: { status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
})),
} as unknown as DaemonClient),
};
}
it("default (no env, no config) does NOT export OPENRIG_HOST so daemon multi-binds", async () => {
const savedHost = process.env["OPENRIG_HOST"];
const savedPort = process.env["OPENRIG_PORT"];
delete process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7471";
let spawnedEnv: Record<string, string> = {};
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
expect(spawnedEnv["OPENRIG_HOST"]).toBeUndefined();
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7471");
});
it("env-explicit OPENRIG_HOST is preserved into spawn env (distinct value discriminator)", async () => {
const savedHost = process.env["OPENRIG_HOST"];
const savedPort = process.env["OPENRIG_PORT"];
// Use a distinct value so the discriminator can't pass vacuously:
// 100.64.55.66 is a tailscale CGNAT address — operator-explicit
// bind would normally be honored as-is.
process.env["OPENRIG_HOST"] = "100.64.55.66";
process.env["OPENRIG_PORT"] = "7472";
let spawnedEnv: Record<string, string> = {};
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
expect(spawnedEnv["OPENRIG_HOST"]).toBe("100.64.55.66");
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7472");
});
});
it("up surfaces the real daemon auto-start failure instead of a generic hint", async () => {
const savedPort = process.env["OPENRIG_PORT"];
process.env["OPENRIG_PORT"] = "7463";
@@ -1,18 +1,6 @@
#!/usr/bin/env node
"use strict";
// OpenRig activity-relay hook script.
// Reads a hook event payload from stdin, normalizes it, and POSTs to the
// OpenRig daemon's /api/activity/hooks endpoint for real-time UI seat-status.
// Best-effort only: 1.5s timeout, errors swallowed, never blocks the agent loop.
//
// Required environment (injected by the OpenRig daemon when launching the agent):
// OPENRIG_SESSION_NAME or RIGGED_SESSION_NAME - tmux session id
// OPENRIG_NODE_ID or RIGGED_NODE_ID - node id in the rig topology
// OPENRIG_RUNTIME or RIGGED_RUNTIME - "claude-code" | "codex" | etc.
// OPENRIG_URL or RIGGED_URL - daemon base URL
// OPENRIG_ACTIVITY_HOOK_TOKEN or RIGGED_ACTIVITY_HOOK_TOKEN - bearer auth
async function readStdin() {
return new Promise((resolve) => {
let data = "";
@@ -1,15 +0,0 @@
{
"name": "openrig-core",
"version": "0.1.0",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
"hooks": "./hooks/claude.json"
}
@@ -1,24 +0,0 @@
{
"name": "openrig-core",
"version": "0.1.0",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
"hooks": "./hooks/codex.json",
"interface": {
"displayName": "OpenRig Core",
"shortDescription": "Canonical OpenRig skills and hooks",
"longDescription": "Cross-runtime agent topology primitive shipping coordination, continuity, and operating-discipline skills, plus activity-tracking hooks. Use as the baseline plugin for any OpenRig-managed agent.",
"developerName": "Mike Schwarz",
"category": "Productivity",
"capabilities": ["Read"],
"websiteURL": "https://github.com/mvschwarz/openrig-plugins"
}
}
@@ -1,200 +0,0 @@
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 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 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 those 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 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 Support. 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 support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.
@@ -1,42 +0,0 @@
# openrig-core
Canonical OpenRig skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.
## What this plugin ships
**Skills (8):** the canonical OpenRig operating knowledge — how to use the `rig` CLI, how to design rig topologies, how to recover from compaction, how to hand work off durably between agents, and how to maintain seat continuity across handovers and restarts.
**Hooks:** activity-tracking hooks (Claude Code: 4 events; Codex: 3 events) that POST agent state to the OpenRig daemon for real-time UI seat-status updates.
## Runtimes
This plugin ships dual-manifest packaging — both `.claude-plugin/` and `.codex-plugin/`. Use it with:
- **Claude Code** — install via `/plugin` after the OpenRig daemon vendors `openrig-core/` to `~/.openrig/plugins/openrig-core/` on first run, or install the plugin directly through Claude Code's own plugin commands when remote distribution is available
- **Codex CLI** — install via `/plugins` after the same vendoring step, or directly through Codex's own plugin commands
One skill targets Claude Code's compaction behavior specifically:
- `claude-compaction-restore` — used to rebuild a Claude Code agent's working mental model after `/compact`, from JSONL transcripts and touched files
This is NOT Codex-self-targeting (Codex's compaction is handled internally and doesn't need rebuild SOPs). But Codex agents acting as orchestrators frequently invoke this skill when restoring a peer Claude that has compacted — that's exactly when it's needed. Both runtimes ship it.
All other skills are cross-runtime by design.
## Distribution
**At v0 (today):** the vendored copy inside the OpenRig npm package is the source of truth. When you install `@openrig/cli` (or run the daemon), `openrig-core/` is copied to `~/.openrig/plugins/openrig-core/` on first run. This is the offline-safe baseline and is always available.
**Auto-fetch from GitHub at v0:** the OpenRig daemon probes `github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz` on launch with a 5s timeout. **At v0 the success path only logs the response and does NOT extract a tarball, compare versions, or update the local copy** — extraction + version-compare + update are scoped to a later marketplace-consumption phase (mission slice 3.6). 404 (the expected normal-state response while the GitHub repo is empty), network errors, and timeouts are all tolerated silently with the vendored copy remaining authoritative.
**Future (slice 3.6, marketplace-consumption phase):** when the GitHub releases workflow is set up and authorization to publish lands, the daemon will start extracting fetched tarballs + comparing versions + updating the local copy as the "hot update" path that skips OpenRig CLI version bumps.
You can also install directly via Claude Code or Codex's own plugin commands if you prefer to manage plugins outside OpenRig.
## License
Apache License 2.0. See `LICENSE`.
## Source
- Plugin repo: https://github.com/mvschwarz/openrig-plugins
- OpenRig CLI: https://github.com/mvschwarz/openrig
@@ -1,78 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
},
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
"timeout": 5
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
]
}
}
@@ -1,37 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
]
}
}
@@ -1,172 +0,0 @@
#!/usr/bin/env node
"use strict";
// OpenRig Claude compaction restore bridge.
//
// PreCompact writes a pending marker under OPENRIG_HOME. SessionStart
// (matcher=compact) and UserPromptSubmit can then inject one restore
// directive into Claude context via hookSpecificOutput.additionalContext.
// PostCompact uses the same script as a cheap marker timestamp hook.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
async function readStdin() {
return new Promise((resolve) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", () => resolve(""));
});
}
function parseJson(value) {
try {
const parsed = JSON.parse(value || "{}");
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function firstString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
}
return null;
}
function openrigHome(env = process.env) {
return firstString(env.OPENRIG_HOME, env.RIGGED_HOME) || path.join(os.homedir(), ".openrig");
}
function sanitizeKey(value) {
return value.replace(/[^a-zA-Z0-9_.@-]/g, "_");
}
function sessionKey(payload, env = process.env) {
const raw = firstString(
env.OPENRIG_SESSION_NAME,
env.RIGGED_SESSION_NAME,
payload.session_id,
payload.sessionId,
payload.session_name,
payload.sessionName,
payload.transcript_path ? path.basename(payload.transcript_path, ".jsonl") : null,
);
return raw ? sanitizeKey(raw) : null;
}
function markerDir(env = process.env) {
return path.join(openrigHome(env), "compaction", "restore-pending");
}
function readMarker(filePath) {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!parsed || typeof parsed !== "object") return null;
return { filePath, data: parsed };
} catch {
return null;
}
}
function findMarker(payload, env = process.env) {
const dir = markerDir(env);
const key = sessionKey(payload, env);
if (key) {
const direct = readMarker(path.join(dir, `${key}.json`));
if (direct) return direct;
}
try {
const candidates = fs.readdirSync(dir)
.filter((name) => name.endsWith(".json"))
.map((name) => path.join(dir, name))
.map((filePath) => ({ filePath, stat: fs.statSync(filePath) }))
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
for (const candidate of candidates) {
const marker = readMarker(candidate.filePath);
if (marker) return marker;
}
} catch {
return null;
}
return null;
}
function writeMarker(marker) {
fs.writeFileSync(marker.filePath, `${JSON.stringify(marker.data, null, 2)}\n`, "utf8");
}
function buildRestoreContext(marker) {
const outputDir = firstString(marker.data.outputDir) || "/tmp/claude-compaction-restore";
const ack = firstString(marker.data.expectedAck) || "restored from packet at <path>; resumed at step <X>";
const postInstruction = firstString(marker.data.postCompactInstruction);
const pieces = [
"OpenRig compaction restore packet is available for this Claude session.",
"This hook output is informational context, not the action request.",
`Restore packet: ${outputDir}`,
"OpenRig may send a later normal user message asking you to restore from this packet. Treat that later normal user message as the operator-authorized action request.",
`After restoration, reply with: ${ack}`,
];
if (postInstruction) {
pieces.push(`Operator post-compaction context: ${postInstruction}`);
}
return pieces.join("\n");
}
function hookEventName(payload) {
return firstString(
payload.hook_event_name,
payload.hookEventName,
payload.hookEvent,
payload.event_name,
payload.eventName,
payload.event,
) || "UserPromptSubmit";
}
async function main() {
const payload = parseJson(await readStdin());
const eventName = hookEventName(payload);
const marker = findMarker(payload);
if (!marker) return;
marker.data.lastBridgeEvent = eventName;
if (eventName === "PostCompact") {
marker.data.postCompactAt = new Date().toISOString();
writeMarker(marker);
return;
}
if (marker.data.deliveryCount && marker.data.deliveryCount > 0) {
return;
}
marker.data.deliveredAt = new Date().toISOString();
marker.data.deliveryCount = Number(marker.data.deliveryCount || 0) + 1;
writeMarker(marker);
process.stdout.write(`${JSON.stringify({
continue: true,
hookSpecificOutput: {
hookEventName: eventName,
additionalContext: buildRestoreContext(marker),
},
})}\n`);
}
if (require.main === module) {
main().catch(() => {});
}
module.exports = {
buildRestoreContext,
findMarker,
hookEventName,
parseJson,
};
@@ -1,104 +0,0 @@
---
name: agent-startup-and-context-ingestion
description: Use when designing or auditing how an agent becomes useful after launch — AGENTS.md overlays, role files, skills, rig specs, workflow specs, startup checklists, refocus messages, "rig context" surface. Covers the 4 failure modes that make startup context fail (old rig spec misses current operating mode; current agents never told about new guidance; startup file as dumping ground; orchestrator transmits implementation without preserving product intent).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Cross-runtime restore/reentry packet standard + externalized memory
surfaces convention. Load at agent start; revisit after compaction.
sibling_skills:
- claude-compaction-restore
- seat-continuity-and-handover
transfer_test: pending
---
# Agent Startup and Context Ingestion
How an agent becomes useful after launch: **AGENTS.md overlays, role
files, skills, rig specs, workflow specs, startup checklists, refocus
messages**, and any future "rig context" command or skill-librarian
surface.
It is the current concrete startup path inside the broader
context-engineering-and-retrieval primitive. Startup gets a seat into
the right initial shape; context engineering is the larger question of
how a seat gets the right context for the work it is doing right now.
**Most coordination failures are not tool failures; they are context
failures.** Agents need to know their role, operating mode, coordination
convention, boundaries, and current product intent. If startup context
is scattered or stale, agents execute the wrong thing very efficiently.
## Use this when
- Authoring a new agent's startup files (role / culture / startup-context)
- Refreshing a seat that's been running on stale guidance
- Auditing whether agents got the current operating mode (not just what's in old files)
- Designing the orchestrator → next-agent context-transmission shape
- Building a startup map for OpenRig-building rigs
## Don't use this when
- The agent is being created via Agent Starter — the starter's manifest carries startup context
- The work is artifact-backed mental-model rebuild from a packet — that's `claude-compaction-restore`
- The intent is to ship reusable startup content as a skill — write the skill against the agentskills.io specification, not as inline startup prose
## Failure modes (4)
1. **A new agent starts from an old rig spec and misses the current operating mode.** Specs go stale; current state must be visible at startup, not just historical config.
2. **Guidance is written to a file that future agents read, but current agents are never told.** File edits don't propagate to running sessions. Cultural rollout (broadcast + fleet-changes-feed) is needed alongside file edits.
3. **A startup file becomes a dumping ground and loses the map-to-canonical-sources role.** Startup should point AT canonical sources; it shouldn't TRY to be one.
4. **The orchestrator transmits implementation instructions without preserving product intent.** Instructions decay; intent travels.
## Proof standard
Startup proof should:
1. **Launch or refresh** a seat
2. **Inspect** what it actually read
3. **Verify** it can state the current role, mode, active constraints, and next handoff convention **in its own words**
## Cross-runtime startup paths (5; do not collapse)
When a seat's startup is artifact-backed mental-model rebuild (active-work
reentry case, distinct from reusable Agent Starters / priming packs):
- See the **cross-runtime restore/reentry packet standard v0**
- Source-trust ranking applies: **`rig whoami` > target rigspec > bounded latest transcript > full transcript > touched-files > `restore-summary.json`**
## Memory surfaces consumed at startup
The primary surfaces a startup ingestion path reads:
- AGENTS / role / CULTURE / startup overlays (the per-seat identity layer)
- startup replay context (what the agent did before the current boot)
- restore / reentry packets and Agent Starters (cross-runtime startup path)
Authority-rank + read-vs-write posture governs which surfaces a
startup-ingestion path can write vs only read; treat overlays as
write-restricted unless your role explicitly authors them.
## Startup files vs skills (the distinction)
Per `agent-startup-guide.md` (product reference doc) and the team handbook:
| Startup files | Skills |
|---|---|
| Rig-specific, role-specific identity | Reusable SOPs / methodology / knowledge |
| Tell agent WHO it is, WHAT it's working on, HOW this team operates | Tell agent HOW to do something (transferable across rigs) |
| Examples: `role.md`, `CULTURE.md`, `startup/context.md` | Examples: `openrig-user`, `test-driven-development`, `dogfood` |
| Authored per-rig | Authored once, used everywhere |
Don't put skill content in startup files. Don't put identity content
in skills. The 7-layer additive startup model (agent / profile / rig /
culture / pod / member / operator) handles the layering.
## See also
- `forming-an-openrig-mental-model` skill — orientation for new agents
- `claude-compaction-restore` skill — restore-time startup ingestion after compaction
- `seat-continuity-and-handover` skill — packet shape that survives seat resets
- https://agentskills.io/specification — the cross-runtime skill standard (what skill content should and shouldn't be)
@@ -1,131 +0,0 @@
---
name: claude-compaction-restore
description: Use when a Claude Code session has just compacted, is about to compact, reached context limit, resumed after /compact, or needs to rebuild its working mental model from Claude JSONL transcripts and touched files.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Load-bearing for any OpenRig seat that may compact. PreCompact hook
is wired into Claude Code's settings (e.g. ~/.claude/settings.json)
so the restore packet is generated automatically before compaction.
sibling_skills:
- agent-startup-and-context-ingestion
- seat-continuity-and-handover
---
# Claude Compaction Restore
Use this skill to preserve continuity before Claude Code compacts and to
restore continuity after compaction. Do the work described here; do not merely
explain the protocol.
## If You Are About To Compact
Prepare durable continuity before the context boundary.
1. Identify the active task, queue item, mission/slice, branch or commit, and
current working directory.
2. Record the current state: decisions made, files changed, commands/tests run,
evidence produced, blockers, caveats, and the next concrete step.
3. Create or update a durable mental-model restore map. This map is the main
artifact future-you will use to rebuild context after compaction.
4. In the restore map, write an ASCII file/folder tree of every path that
mattered to your working mental model during this session. Include:
- the active queue item or mission packet;
- mission notes, progress, decisions, and evidence files;
- Claude memory/project notes you used or wrote, especially when the memory
folder is shared by many agents;
- files with active edits or recently inspected source;
- root instructions such as `AGENTS.md`, `CLAUDE.md`, or `README.md`;
- as-built docs, codemaps, conventions, skills, and product docs needed
before code/review work;
- source files, tests, scripts, UI evidence, screenshots, logs, or reports
that shaped your current state.
5. For every file or folder in the tree, add a short note explaining why it
matters and whether it is required reading after compaction.
6. Write any important glue context that is not already on disk into the
handoff/restore map. This includes assumptions, partial conclusions, failed
paths, and why the listed files fit together.
7. In the compaction summary, include the restore map path and the top required
reading paths from that map.
## If You Just Compacted
Treat your memory as unreliable until you restore from durable evidence.
Restoration is your current task. Do not wait for a future user request or a
new task assignment before reading the required files.
1. Read any restore prompt, marker path, packet path, transcript path, restore
map, or extra instruction file named by the operator or OpenRig.
2. Run the restore script from this skill:
```bash
node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore
```
If the prompt names a specific Claude JSONL transcript, pass it explicitly:
```bash
node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs /path/to/session.jsonl --out /tmp/claude-compaction-restore
```
3. Read the generated `restore-instructions.md`.
4. Read the generated `touched-files.md`.
5. Find and read the mental-model restore map if one exists. Treat it as the
highest-value guide to which memory files, mission files, source files, and
docs mattered before compaction.
6. Identify the important files for the active task. The touched-file list is
a triage aid, not a complete inventory.
7. Read every important file in full. Prioritize the restore map, Claude memory
files named in the map, Markdown state/planning files, queue/mission packets,
source files with active edits, root instruction files, and as-built or
codemap docs.
8. After the required reads are complete, state:
```text
restored from packet at <path>; resumed at step <X>
```
Include the main files you read in full when you make that statement.
## Required Read-Depth Audit
After the first restore pass, audit yourself before continuing.
1. List every file, packet, marker, restore map, instruction file, and source
document you were asked to read during restore.
2. Mark each item as `FULL`, `PARTIAL`, or `NOT_READ`.
3. You will be given a task where all of these files are required reading in
order to understand the task.
4. Do not optimize for token conservation.
5. Read every `PARTIAL` or `NOT_READ` item in full now.
6. Report the final read-depth table before doing any substantive task work.
## Guardrails
- Do not silently launch fresh after compaction.
- Do not continue from memory when restore evidence exists.
- Do not defer required restore reading until a later user task. The restore is
the current task.
- Do not skip root instructions, as-built docs, or codemaps before product
code/review work.
- Do not treat the generated touched-file list as exhaustive.
- Do not mark a file `FULL` unless you actually read the full file content
after compaction.
- Do not resume task work until the restore sentinel and read-depth audit are
complete.
## Failure Modes To Avoid
1. **Confidently-wrong restoration**: claiming restoration after reading only
the touched-file list or summary.
2. **Partial restore**: reading the first few files, then continuing before the
full reading list is complete.
3. **Skipping project instructions**: missing `AGENTS.md`, `CLAUDE.md`,
`README.md`, as-built docs, or codemaps that govern the task.
4. **Treating the packet as exhaustive**: ignoring mission or workspace files
that are important but were not discovered by the script.
5. **Waiting for the next task**: treating restore reading as conditional on a
future user assignment instead of completing it immediately.
@@ -1,174 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const skillRoot = path.resolve(new URL("..", import.meta.url).pathname);
const restoreScript = path.join(skillRoot, "scripts", "restore-from-jsonl.mjs");
const outRoot = "/tmp/claude-compaction-restore";
const defaultRestoreInstruction =
"Read the claude-compaction-restore skill and follow its \"If You Just Compacted\" protocol.";
function emit(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) return {};
return JSON.parse(raw);
}
function getOpenRigHome() {
return process.env.OPENRIG_HOME || process.env.RIGGED_HOME || path.join(os.homedir(), ".openrig");
}
function expandInstructionPath(filePath) {
if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2));
if (filePath.startsWith("${OPENRIG_HOME}/")) {
return path.join(getOpenRigHome(), filePath.slice("${OPENRIG_HOME}/".length));
}
if (filePath.startsWith("$OPENRIG_HOME/")) {
return path.join(getOpenRigHome(), filePath.slice("$OPENRIG_HOME/".length));
}
return filePath;
}
function readInstructionFile(filePath) {
const expanded = expandInstructionPath(filePath);
if (!fs.existsSync(expanded)) return "";
return fs.readFileSync(expanded, "utf8");
}
function sessionKey(input) {
const raw = [
process.env.OPENRIG_SESSION_NAME,
process.env.RIGGED_SESSION_NAME,
input.session_id,
input.sessionId,
input.session_name,
input.sessionName,
input.transcript_path ? path.basename(input.transcript_path, ".jsonl") : "",
].find((value) => typeof value === "string" && value.trim().length > 0) || "unknown-session";
return raw.replace(/[^a-zA-Z0-9_.@-]/g, "_");
}
function pendingMarkerPath(input) {
return path.join(getOpenRigHome(), "compaction", "restore-pending", `${sessionKey(input)}.json`);
}
function writePendingRestoreMarker(input, parsed, restoreInstruction, customMessage) {
const markerPath = pendingMarkerPath(input);
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
const payload = {
version: 1,
createdAt: new Date().toISOString(),
sessionName: process.env.OPENRIG_SESSION_NAME || process.env.RIGGED_SESSION_NAME || null,
sessionId: input.session_id || input.sessionId || null,
transcriptPath: input.transcript_path || null,
cwd: input.cwd || null,
outputDir: parsed.outputDir,
restoreInstruction,
postCompactInstruction: customMessage || "",
expectedAck: "restored from packet at <path>; resumed at step <X>",
deliveredAt: null,
deliveryCount: 0,
};
fs.writeFileSync(markerPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
return markerPath;
}
// Slice 27 — read the OpenRig config directly (no daemon HTTP dependency
// so the hook still works when the daemon isn't running or isn't
// reachable from this process). Returns "" for either field when the
// config is missing, malformed, or the policy isn't set. If the policy is
// enabled but restore text has not been written yet, use OpenRig's default
// instruction to load the canonical restore skill. Inline instructions and
// file-path content are both included when both are configured.
function readClaudeCompactionMessage() {
const configPath = path.join(getOpenRigHome(), "config.json");
let inline = "";
let filePath = "";
let inlineConfigured = false;
let filePathConfigured = false;
let policyEnabled = false;
try {
if (!fs.existsSync(configPath)) return "";
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
const policy = parsed?.policies?.claudeCompaction;
if (policy && typeof policy === "object") {
policyEnabled = policy.enabled === true;
if (typeof policy.messageInline === "string") {
inlineConfigured = true;
inline = policy.messageInline;
}
if (typeof policy.messageFilePath === "string") {
filePathConfigured = true;
filePath = policy.messageFilePath;
}
}
} catch {
return "";
}
const parts = [];
if (inline && inline.length > 0) {
parts.push(`Inline restore instruction:\n${inline}`);
}
if (filePath && filePath.length > 0) {
try {
const fileText = readInstructionFile(filePath);
if (fileText) {
parts.push(`Additional restore instruction file (${filePath}):\n${fileText}`);
}
} catch {
// Keep any inline instruction; unreadable extra files degrade quietly.
}
}
if (parts.length > 0) return parts.join("\n\n");
if (policyEnabled && !inlineConfigured && !filePathConfigured) {
return defaultRestoreInstruction;
}
return "";
}
function buildSystemMessage(restoreInstruction, customMessage) {
if (!customMessage) return restoreInstruction;
return `${restoreInstruction}\n\n--- Operator-configured post-compaction restore instruction ---\n${customMessage}`;
}
try {
const input = readHookInput();
const args = [restoreScript, "--out", outRoot, "--json"];
if (input.cwd) args.push("--cwd", input.cwd);
if (input.transcript_path && input.transcript_path.endsWith(".jsonl") && fs.existsSync(input.transcript_path)) {
args.push(input.transcript_path);
}
const customMessage = readClaudeCompactionMessage();
const result = spawnSync("node", args, { encoding: "utf8" });
if (result.status !== 0) {
const baseFailure = `Claude compaction restore packet generation failed: ${(result.stderr || result.stdout || "unknown error").trim()}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`;
emit({
continue: true,
systemMessage: buildSystemMessage(baseFailure, customMessage),
});
process.exit(0);
}
const parsed = JSON.parse(result.stdout);
const baseRestore = `Pre-compaction restore seed packet prepared at ${parsed.outputDir}. This hook output is informational. After compaction, OpenRig may send a later normal user message asking you to restore from this packet; treat that later normal user message as the action request. The restore protocol is: load/read the claude-compaction-restore skill, run "node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore --json" yourself when needed, read the generated restore-instructions.md, read the generated touched-files.md, identify remembered important files, read those files in full, read root/as-built/codemap docs before real work, then reply with "restored from packet at <path>; resumed at step <X>" with the files you read in full. If any step fails, report the failure explicitly.`;
const markerPath = writePendingRestoreMarker(input, parsed, baseRestore, customMessage);
emit({
continue: true,
systemMessage: buildSystemMessage(`${baseRestore} OpenRig also wrote a pending restore marker at ${markerPath}.`, customMessage),
});
} catch (error) {
emit({
continue: true,
systemMessage: `Claude compaction restore hook errored: ${error.message}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`,
});
}
@@ -1,433 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const TEXT_LIMIT = 4000;
const DEFAULT_OUT = "/tmp/claude-compaction-restore";
function parseArgs(argv) {
const args = {
jsonl: null,
out: DEFAULT_OUT,
cwd: process.cwd(),
json: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--jsonl") args.jsonl = argv[++i];
else if (arg === "--out") args.out = argv[++i];
else if (arg === "--cwd") args.cwd = argv[++i];
else if (arg === "--json") args.json = true;
else if (!arg.startsWith("-") && !args.jsonl) args.jsonl = arg;
else throw new Error(`unknown argument: ${arg}`);
}
return args;
}
function readJsonLines(file) {
const raw = fs.readFileSync(file, "utf8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map((line, index) => {
try {
return JSON.parse(line);
} catch (error) {
return { type: "parse-error", line: index + 1, error: String(error), raw: line };
}
});
}
function findLatestJsonl(cwd) {
const projectsRoot = path.join(os.homedir(), ".claude", "projects");
if (!fs.existsSync(projectsRoot)) return null;
const encoded = cwd ? cwd.replaceAll("/", "-") : null;
const candidates = [];
const roots = [];
if (encoded) {
const exact = path.join(projectsRoot, encoded);
if (fs.existsSync(exact)) roots.push(exact);
}
roots.push(projectsRoot);
for (const root of roots) {
const result = spawnSync("find", [root, "-name", "*.jsonl", "-maxdepth", root === projectsRoot ? "4" : "2"], {
encoding: "utf8",
});
if (result.status !== 0 && !result.stdout) continue;
for (const file of result.stdout.split(/\r?\n/).filter(Boolean)) {
try {
const stat = fs.statSync(file);
candidates.push({ file, mtimeMs: stat.mtimeMs });
} catch {
// Ignore raced/deleted transcript files.
}
}
if (candidates.length) break;
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates[0]?.file ?? null;
}
function stringifyContent(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block?.type === "text") return block.text ?? "";
if (block?.type === "tool_use") return `[tool_use:${block.name}] ${JSON.stringify(block.input ?? {})}`;
if (block?.type === "tool_result") return `[tool_result] ${truncate(stringifyContent(block.content), TEXT_LIMIT)}`;
return JSON.stringify(block);
})
.join("\n");
}
if (content == null) return "";
return JSON.stringify(content);
}
function truncate(text, max) {
const s = String(text ?? "");
if (s.length <= max) return s;
return `${s.slice(0, max)}\n[...truncated ${s.length - max} chars...]`;
}
function walkStrings(value, acc = []) {
if (typeof value === "string") acc.push(value);
else if (Array.isArray(value)) value.forEach((item) => walkStrings(item, acc));
else if (value && typeof value === "object") Object.values(value).forEach((item) => walkStrings(item, acc));
return acc;
}
function normalizeFile(candidate, cwd) {
let file = candidate.trim().replace(/[),.;:'"`\]\}>]+$/g, "").replace(/^[('"`<\[]+/g, "");
if (!file || file.includes("\n")) return null;
if (/\.(?:md|mdx|txt|json|jsonl|yaml|yml|toml|ts|tsx|js|jsx|mjs|cjs|py|sh|rs|go|sql|css|scss|html|xml|svg|csv|log|lock)\//i.test(file)) return null;
if (file.startsWith("~")) file = path.join(os.homedir(), file.slice(1));
let normalized = null;
if (file.startsWith("/")) normalized = path.normalize(file);
else if (file.startsWith("./") || file.startsWith("../")) normalized = path.normalize(path.resolve(cwd, file));
else if (/^[A-Za-z0-9_@.+-][A-Za-z0-9_@.+/\-:]*\.[A-Za-z0-9]+$/.test(file)) {
normalized = path.normalize(path.resolve(cwd, file));
} else if (/^(CLAUDE|AGENTS|README|DESIGN|CULTURE|MEMORY)\.md$/.test(file)) {
normalized = path.normalize(path.resolve(cwd, file));
}
if (!normalized) return null;
try {
if (fs.existsSync(normalized) && fs.statSync(normalized).isDirectory()) return null;
} catch {
// Keep the candidate if stat races; the restore agent can decide.
}
return normalized;
}
function extractPaths(text, cwd) {
const paths = new Set();
const s = String(text ?? "");
const absolute = /(?:^|[\s"'(<\[])(\/(?:Users|private|tmp|var|opt|Volumes)\/[^\s"'()\]<>]+)/g;
const relative = /(?:^|[\s"'(<\[])((?:\.{1,2}\/)?[A-Za-z0-9_@.+-][A-Za-z0-9_@.+/\-:]*\.(?:md|mdx|txt|json|jsonl|yaml|yml|toml|ts|tsx|js|jsx|mjs|cjs|py|sh|rs|go|sql|css|scss|html|xml|svg|csv|log|lock))(?:$|[\s"')>\],.;:])/g;
const namedMarkdown = /(?:^|[\s"'(<\[])((?:CLAUDE|AGENTS|README|DESIGN|CULTURE|MEMORY)\.md)(?:$|[\s"')>\],.;:])/g;
for (const regex of [absolute, relative, namedMarkdown]) {
let match;
while ((match = regex.exec(s)) !== null) {
const normalized = normalizeFile(match[1], cwd);
if (normalized) paths.add(normalized);
}
}
return [...paths];
}
function createRegistry() {
const files = new Map();
let sequence = 0;
return {
add(file, kind, source) {
if (!file) return;
const existing = files.get(file) ?? {
path: file,
kinds: {},
sources: new Set(),
firstSeen: sequence,
lastSeen: sequence,
};
existing.kinds[kind] = (existing.kinds[kind] ?? 0) + 1;
existing.sources.add(source);
existing.lastSeen = sequence;
files.set(file, existing);
},
tick() {
sequence += 1;
},
values() {
return [...files.values()].map((entry) => ({
...entry,
sources: [...entry.sources].slice(0, 8),
}));
},
};
}
function toolKind(name, input) {
if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name)) return "written";
if (name === "Read") return "read";
if (["Grep", "Glob", "LS"].includes(name)) return "discovered";
if (name === "Bash") {
const command = input?.command ?? "";
if (/\b(apply_patch|tee|touch|mkdir|mv|cp|rm)\b|(^|[^>])>{1,2}[^>]/.test(command)) return "shell-write";
return "shell-mentioned";
}
return "mentioned";
}
function scoreFile(entry) {
const ext = path.extname(entry.path).toLowerCase();
let score = 0;
if (entry.kinds.written) score += 40;
if (entry.kinds["shell-write"]) score += 30;
if (entry.kinds["file-history"]) score += 35;
if (ext === ".md" || ext === ".mdx") score += 20;
if (entry.kinds.read) score += 10;
if (entry.kinds.discovered) score += 4;
if (/\/docs\/as-built\//.test(entry.path)) score += 25;
if (/(codemap|architecture|CLAUDE|AGENTS|README|DESIGN|CULTURE|session\.log|queue\.md)/i.test(entry.path)) score += 15;
score += Math.min(10, Object.values(entry.kinds).reduce((a, b) => a + b, 0));
return score;
}
function discoverDocs(cwd) {
const candidates = [
"CLAUDE.md",
"AGENTS.md",
"README.md",
"docs/as-built",
"docs",
];
const docs = new Set();
for (const candidate of candidates) {
const full = path.resolve(cwd, candidate);
if (!fs.existsSync(full)) continue;
const stat = fs.statSync(full);
if (stat.isFile()) docs.add(full);
if (stat.isDirectory()) {
const result = spawnSync("find", [full, "-maxdepth", candidate === "docs" ? "2" : "1", "-type", "f", "-name", "*.md"], {
encoding: "utf8",
});
for (const file of result.stdout.split(/\r?\n/).filter(Boolean)) {
if (/as-built|codemap|architecture|README|overview/i.test(file)) docs.add(path.normalize(file));
}
}
}
return [...docs].sort();
}
function analyze(jsonlPath, cwd) {
const records = readJsonLines(jsonlPath);
const registry = createRegistry();
const transcript = [];
const cwdCounts = new Map();
let sessionId = null;
for (const record of records) {
registry.tick();
if (record.sessionId || record.session_id) sessionId = record.sessionId ?? record.session_id;
if (record.cwd) {
cwdCounts.set(record.cwd, (cwdCounts.get(record.cwd) ?? 0) + 1);
cwd = record.cwd;
}
if (record.type === "file-history-snapshot") {
const backups = record.snapshot?.trackedFileBackups ?? {};
for (const file of Object.keys(backups)) registry.add(path.normalize(file), "file-history", "file-history-snapshot");
}
const message = record.message;
if (!message) continue;
const role = message.role ?? record.type ?? "unknown";
const content = message.content;
if (typeof content === "string") {
transcript.push(`\n## ${role}\n\n${content}`);
for (const file of extractPaths(content, cwd)) registry.add(file, "mentioned", `${role}:text`);
continue;
}
if (!Array.isArray(content)) continue;
const parts = [];
for (const block of content) {
if (block?.type === "text") {
parts.push(block.text ?? "");
for (const file of extractPaths(block.text ?? "", cwd)) registry.add(file, "mentioned", `${role}:text`);
} else if (block?.type === "tool_use") {
const name = block.name ?? "unknown";
const input = block.input ?? {};
parts.push(`\n[tool_use:${name}]\n${JSON.stringify(input, null, 2)}`);
const kind = toolKind(name, input);
if (input.file_path) registry.add(normalizeFile(input.file_path, cwd), kind, `tool:${name}:file_path`);
if (input.path) registry.add(normalizeFile(input.path, cwd), kind, `tool:${name}:path`);
if (input.notebook_path) registry.add(normalizeFile(input.notebook_path, cwd), kind, `tool:${name}:notebook_path`);
for (const text of walkStrings(input)) {
for (const file of extractPaths(text, cwd)) registry.add(file, kind, `tool:${name}:input`);
}
} else if (block?.type === "tool_result") {
const resultText = stringifyContent(block.content);
parts.push(`\n[tool_result]\n${truncate(resultText, TEXT_LIMIT)}`);
for (const file of extractPaths(resultText, cwd)) registry.add(file, "mentioned", "tool_result");
}
}
if (parts.length) transcript.push(`\n## ${role}\n\n${parts.join("\n")}`);
}
const rankedFiles = registry
.values()
.map((entry) => ({ ...entry, score: scoreFile(entry), markdown: [".md", ".mdx"].includes(path.extname(entry.path).toLowerCase()) }))
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
const cwdRanked = [...cwdCounts.entries()].sort((a, b) => b[1] - a[1]);
const effectiveCwd = cwdRanked[0]?.[0] ?? cwd;
return {
sessionId,
jsonlPath,
cwd: effectiveCwd,
records: records.length,
transcript: transcript.join("\n"),
files: rankedFiles,
docs: discoverDocs(effectiveCwd),
};
}
function markdownFileList(files) {
const lines = [];
lines.push("# Touched Files");
lines.push("");
lines.push("Files are ranked for restore relevance. Markdown and written files are the first candidates to read in full.");
lines.push("");
const sections = [
["Highest-priority Markdown/state files", (f) => f.markdown && (f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
["Other written/tracked files", (f) => !f.markdown && (f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
["Read/discovered/mentioned files", (f) => !(f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
];
for (const [title, predicate] of sections) {
const group = files.filter(predicate);
if (!group.length) continue;
lines.push(`## ${title}`);
lines.push("");
for (const file of group) {
const kinds = Object.entries(file.kinds).map(([kind, count]) => `${kind}:${count}`).join(", ");
lines.push(`- score ${file.score} — \`${file.path}\` — ${kinds}`);
}
lines.push("");
}
return lines.join("\n");
}
function restoreInstructions(summary, outputPaths) {
const lines = [];
lines.push("# Claude Compaction Restore Instructions");
lines.push("");
lines.push("You have just been compacted or are restoring a compacted Claude Code seat. Your mental model is compressed and unreliable.");
lines.push("");
lines.push(`- JSONL transcript: \`${summary.jsonlPath}\``);
lines.push(`- Reconstructed transcript: \`${outputPaths.transcript}\``);
lines.push(`- Touched-file triage: \`${outputPaths.touchedFiles}\``);
lines.push(`- Working directory inferred: \`${summary.cwd}\``);
lines.push("");
lines.push("## Required Sequence");
lines.push("");
lines.push("1. Read this file and `touched-files.md`.");
lines.push("2. Ask yourself: which files do I recognize as important to the work and project state?");
lines.push("3. Mark those files mentally or in a short note.");
lines.push("4. Read every important Markdown/state/planning file in full.");
lines.push("5. Read project root docs in full when present: `CLAUDE.md`, `AGENTS.md`, `README.md`.");
lines.push("6. Read as-built docs and codemaps in full before product work, code review, or architecture decisions.");
lines.push("7. State exactly: `restored from packet at <path>; resumed at step <X>`.");
lines.push("");
lines.push("## Documentation Candidates");
lines.push("");
if (summary.docs.length) {
for (const doc of summary.docs) lines.push(`- \`${doc}\``);
} else {
lines.push("- No root/as-built/codemap candidates were found automatically. Search manually before resuming code/review work.");
}
lines.push("");
lines.push("## Top File Candidates");
lines.push("");
for (const file of summary.files.slice(0, 30)) {
lines.push(`- \`${file.path}\` — score ${file.score}`);
}
lines.push("");
lines.push("Do not continue from fuzzy memory alone.");
return lines.join("\n");
}
function writeOutputs(summary, outRoot) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const base = path.join(outRoot, `${summary.sessionId ?? "unknown-session"}-${stamp}`);
fs.mkdirSync(base, { recursive: true });
const outputPaths = {
dir: base,
transcript: path.join(base, "transcript.txt"),
touchedFiles: path.join(base, "touched-files.md"),
instructions: path.join(base, "restore-instructions.md"),
summary: path.join(base, "restore-summary.json"),
};
fs.writeFileSync(outputPaths.transcript, summary.transcript || "(no message transcript reconstructed)\n");
fs.writeFileSync(outputPaths.touchedFiles, markdownFileList(summary.files));
fs.writeFileSync(outputPaths.instructions, restoreInstructions(summary, outputPaths));
fs.writeFileSync(outputPaths.summary, JSON.stringify({ ...summary, transcript: undefined, outputPaths }, null, 2));
return outputPaths;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const cwd = path.resolve(args.cwd);
const jsonlPath = args.jsonl ? path.resolve(args.jsonl) : findLatestJsonl(cwd);
if (!jsonlPath) throw new Error("could not find a Claude JSONL transcript; pass one explicitly");
if (!fs.existsSync(jsonlPath)) throw new Error(`JSONL transcript not found: ${jsonlPath}`);
const summary = analyze(jsonlPath, cwd);
const outputPaths = writeOutputs(summary, path.resolve(args.out));
const result = {
ok: true,
jsonlPath,
outputDir: outputPaths.dir,
transcript: outputPaths.transcript,
touchedFiles: outputPaths.touchedFiles,
instructions: outputPaths.instructions,
fileCount: summary.files.length,
topFiles: summary.files.slice(0, 12).map((file) => file.path),
};
if (args.json) console.log(JSON.stringify(result, null, 2));
else {
console.log(`Restore packet: ${result.outputDir}`);
console.log(`Instructions: ${result.instructions}`);
console.log(`Touched files: ${result.touchedFiles}`);
console.log(`Transcript: ${result.transcript}`);
}
}
try {
main();
} catch (error) {
console.error(`claude-compaction-restore failed: ${error.message}`);
process.exit(1);
}
@@ -1 +0,0 @@
Optional. Leave blank to use Claude Code's native compaction summary behavior; OpenRig's pre-compaction preparation and post-compaction restore prompts carry the durable continuity protocol.
@@ -1 +0,0 @@
Read the claude-compaction-restore skill and follow its "If You Just Compacted" protocol.
@@ -1,305 +0,0 @@
---
name: forming-an-openrig-mental-model
description: |
Use when an agent is newly oriented to OpenRig and needs to form an accurate mental model of the system fast — what rigs are, how skills load, what the topology shapes mean, what the product loop is. For agents booting into a new seat or returning to OpenRig work after time away. NOT for HA-pair compaction recovery (that's the pair-of-seats coordination pattern; orientation is a solo task) or for specific operational procedures.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Authored 2026-05-02 in response to founder-observed pattern: agents
Distinct from HA-pair mental-model preservation (which is a
pair-of-seats coordination pattern). This skill serves the legitimate
need to form an initial mental model of OpenRig as a system.
sibling_skills:
- openrig-user
- openrig-operator
- openrig-architect
transfer_test: pending
---
# Forming an OpenRig Mental Model
You're new to OpenRig — or returning after time away — and you need to
quickly understand what kind of system this is, what your seat is, and what
the moves are. This skill is the fast on-ramp.
For depth, read the canonical reference docs the skill points to. This
skill's job is to get you *oriented* — accurate enough to operate, fast
enough to be useful — not to replace the canonical docs.
---
## The 60-second mental model
OpenRig is a **local control plane for multi-agent coding topologies**. You
declare a topology of agents in YAML, boot it with one command, and OpenRig
manages tmux sessions, harness lifecycles, transcripts, snapshots, and
restoration. When the system goes down, OpenRig snapshots; when it comes
back, agents resume their conversations.
The product loop:
```
down (auto-snapshot) → up <rig-name> (auto-restore) → work → repeat
```
The unit of work is the **rig** — a topology of agents working together as
a single system.
---
## The four-layer model (where you live)
Everything in agent engineering happens at one of four layers. **OpenRig
operates at Layer 3.**
| Layer | Name | Analogy | What it is |
|---|---|---|---|
| L0 | Model | CPU | Foundation model — Claude, GPT, Gemini. Stateless tokens-in/tokens-out. |
| L1 | Agent Core | Process loop | The reason-and-act cycle: observe, plan, choose, act, repeat. |
| L2 | Harness | Container / OS | Tools, memory, lifecycle around the model. Examples: Claude Code, Codex CLI. |
| L3 | Rig | Docker Compose / Terraform | Multi-agent topology — what agents exist, how they relate. **OpenRig.** |
You are an agent at L1 inside an L2 harness, configured by L3 OpenRig.
OpenRig manages your harness; the harness wraps the model; the model
generates your tokens.
---
## Three pillars of context
OpenRig is built on three context-engineering pillars. When you're oriented,
you should know which pillar you're operating in:
| Pillar | What it is | Where it lives |
|---|---|---|
| **Ontology** | What exists. Curated knowledge — facts, code maps, as-built docs. | Corpus (planned future system). Today: substrate prose docs. |
| **Epistemology** | Why an agent believes what it believes — reasoning, instincts, decisions. | Transcripts (auto-captured). Session logs. ADRs. |
| **Topology** | How agents are connected — pods, edges, communication paths. | OpenRig itself. RigSpec YAML. |
OpenRig **manages the topology pillar**. The other two are filled by Corpus
(future) and transcripts (now). Most of your work probably touches multiple
pillars; knowing which one you're operating in helps you reach for the right
artifacts.
---
## The core vocabulary (read these terms literally)
| Term | What it means |
|---|---|
| **Rig** | A topology of agents working together as a single system. Defined in YAML (RigSpec). The top-level object. |
| **Pod** | A bounded context group within a rig. Members of a pod share a context domain and continuity responsibility. Think Kubernetes pod for knowledge. |
| **Member / Node** | A single agent (or terminal-node service) within a pod. |
| **Edge** | A relationship between members or pods. Kinds: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`. |
| **Topology** | The shape of the rig — how agents are grouped into pods, how edges connect them, how the whole thing fits together. |
| **AgentSpec** | A reusable agent blueprint. Defines skills, guidance, hooks, profiles, startup. File: `agent.yaml`. |
| **RigSpec** | The topology YAML. Defines pods, members, edges, culture. File: `rig.yaml`. |
| **RigBundle** | A portable archive of a RigSpec + vendored AgentSpecs. Move topologies across machines. |
| **Agent Starter** | A named, reusable starting context bundle. RigSpec member can declare `starter_ref`. |
| **Skill** | A markdown file with frontmatter that an agent loads at boot or on activation. Cross-runtime standard at `agentskills.io`. |
| **Profile** | A named configuration within an AgentSpec. The rig spec's member field selects which profile to use. |
| **Culture** | Rig-wide constitution — how the team communicates, what "done" means, escalation rules. File: `CULTURE.md`. |
| **Snapshot** | Point-in-time capture of a rig — sessions, conversations, state. Restorable. |
| **Session name** | `{pod}-{member}@{rig}`. The canonical address for tmux sessions and agent-to-agent messaging. |
The session-name format `{pod}-{member}@{rig}` is your address. When you
run `rig whoami --json`, you get back your full topology context: rig name,
pod, member, peers, edges, transcript path.
---
## Rig classes (what kind of rig am I in?)
OpenRig has five rig classes. The class determines authoring discipline,
supervision, and lifecycle policy.
| Class | Purpose | Lifecycle |
|---|---|---|
| **kernel** | Host-level supervision, intake, authoring. One per host. | Always on; never auto-hibernated. |
| **project** | Long-lived team bound to a codebase. | Stays hot when active; hibernates on explicit request. |
| **ephemeral** | Short-lived mission (research, build, migration, spike). | Spawn → work → retire. |
| **infra-build** | Subclass of ephemeral whose output becomes permanent infrastructure. | Retired only after output verified in place. |
| **managed-app** | Services-backed rig with specialist agents (e.g., a vault specialist, a skill librarian). | Long-lived; accessed by other rigs. |
You're probably in a project rig or managed-app rig if you're doing
substantive work. Knowing your class helps you understand the supervisory
expectations on your seat.
---
## How skills load (the most important thing to get right)
Skills are an **established cross-runtime standard** at
`https://agentskills.io/specification`. Both Claude Code and Codex build on it.
### The shape
A skill is a directory containing `SKILL.md` (uppercase). The SKILL.md has
YAML frontmatter (`name`, `description`) and a Markdown body. Optional
sibling directories: `references/`, `scripts/`, `assets/`.
### Progressive disclosure (why skills scale)
The harness reads frontmatter cheaply at boot — names + descriptions of all
available skills. Body content loads only when a skill activates. This is
**ambient awareness** — you know all the skills exist; you only pay token
cost when you reach for one.
### Where skills come from in OpenRig
- **Per-agent loadout:** your AgentSpec's `profile.uses.skills: [...]`
determines what skills get projected into your runtime skill folder
(`.claude/skills/` or `.agents/skills/`) before your harness boots. This
is the **structural composition** layer.
- **Cross-pod sharing:** AgentSpecs can `imports: [shared]` to access a
shared skill pool. Built-in agents commonly do this.
- **Belt-and-suspenders:** the spec projects skill files; startup guidance
also tells you to load specific skills. Both paths matter — if the
projection silently fails, the guidance still tells you what to read.
### Where skills live (sources of truth)
| Home | Purpose |
|---|---|
| `<rig-cwd>/.claude/skills/`, `<rig-cwd>/.agents/skills/` | Where the harness actually loads from. Populated by `rig up`. |
| `~/.claude/skills/`, `~/.agents/skills/` | Your personal/global skills + the OpenRig bootstrap set (openrig-user, openrig-operator, openrig-architect, forming-an-openrig-mental-model, plus the rest of the openrig-core plugin skills). |
| openrig-core plugin skills | Product built-in skills that ship with OpenRig (installed via the openrig-core plugin). |
| `~/.openrig/skills/` | Runtime install home for OpenRig-shipped skills. |
The harness only sees the first two. Other locations are shipping and
source-of-truth — they reach the harness via projection or NPM install,
not directly.
---
## The product loop (your day-to-day)
```
rig up <rig-name> # boot or restore the topology
rig ps --nodes # see what's running
rig whoami --json # know who you are
rig send <session> "msg" # talk to a peer
rig capture <session> # see a peer's terminal
rig transcript <session> # read a peer's history
rig down <rigId> # snapshot and tear down
rig up <rig-name> # restore from snapshot
```
The first command in any new seat is `rig whoami --json`. It tells you your
rig, pod, member, peers, edges, and transcript path. **Treat it as ground
truth — your CLAUDE.md or AGENTS.md startup overlay can be wrong; whoami
is authoritative.**
---
## Cultural posture (how to behave)
OpenRig has a few load-bearing cultural principles. Internalize these:
- **Honesty over convenience.** If resume fails, say so loudly. Don't
silently launch fresh.
- **The agent is the power user.** The CLI is designed for a 10x staff
engineer at the terminal. You're that user.
- **CLI is context engineering.** Every error message and help text gives
you information to act on. Read errors carefully.
- **Convention over invention.** Follow docker/git/kubectl patterns. Agent
muscle memory is real.
- **Semi-deterministic is OK.** Core contracts are solid; edge cases are
agent-handled.
- **Pets, not cattle (today).** OpenRig is currently optimized for long-lived
agents that develop instincts over sessions. Cattle support is on the
roadmap.
---
## What you should do in your first 10 minutes
If you're booting into a new seat in an OpenRig rig:
1. **`rig whoami --json`** — recover identity. Know your rig, pod, member,
peers.
2. **Read your role guidance** — typically delivered via startup files.
`guidance/role.md` for your specific seat.
3. **Read the rig's `CULTURE.md`** if it has one — the team operating
manual.
4. **Check what skills you have** — list `.claude/skills/` or
`.agents/skills/` in your cwd. Each skill has a frontmatter description
that tells you when to reach for it.
5. **Check your peers** — `rig capture <peer-session>` to see what they're
doing.
6. **Check the transcripts** if you're returning to an in-flight workstream
— `rig transcript <session> --tail 100` for recent context.
7. **Ask `rig ask <rig> "<question>"`** if you need cross-cutting evidence
from the rig's transcripts and chat.
You're now oriented enough to start doing useful work.
---
## Going deeper (canonical references)
For real depth, these are the load-bearing canonical docs:
| Reference | What it covers |
|---|---|
| `openrig/docs/as-built/architecture.md` | Daemon architecture; system overview; package boundaries |
| `openrig/docs/as-built/cli-reference.md` | The full `rig` CLI surface with all subcommands and flags |
| `openrig/docs/reference/rig-spec.md` | The RigSpec YAML format — pods, members, edges, all fields |
| `openrig/docs/reference/agent-spec.md` | The AgentSpec YAML format — resources, profiles, imports |
| `openrig/docs/reference/agent-startup-guide.md` | The 7-layer startup layering model; delivery hints |
| `https://agentskills.io/specification` | The cross-runtime skill standard |
If your team maintains a host-level topology doc (rig classes, context
patterns, authoring SOPs), read its rig-authoring section before
touching YAML for high-stakes rigs.
---
## What this skill is NOT for
- **HA pair compaction recovery.** Forming an initial mental model is a
solo orientation task — different from the pair-of-seats coordination
pattern that preserves a shared mental model across compactions.
- **Operating a specific rig.** Specific rigs have their own DESIGN.md and
CULTURE.md. Read those.
- **Authoring a new rig.** Use the `openrig-architect` skill for that.
- **Day-to-day OpenRig operation.** Use `openrig-user` for that.
- **Administering an OpenRig install.** Use `openrig-operator`.
This skill exists to **form your initial mental model of OpenRig as a
system**. Once oriented, reach for the role-specific or task-specific skills
that fit your actual work.
---
## Common misorientations to avoid
| Misorientation | Reality |
|---|---|
| "OpenRig is a chat interface or assistant" | No. OpenRig is a control plane that *manages* your harness sessions. The chat happens inside the harness; OpenRig is around it. |
| "Pods are workflow groups" | No. Pods are **context domains** — agents that share working context. If two agents communicate every turn, they should be in one pod; if they communicate rarely, they shouldn't be. |
| "Edges represent reporting hierarchy" | No. Edges describe *coordination shape* — who delegates to whom, who observes whom. Avoid hierarchy interpretations; they distort behavior. |
| "I should manage Codex's compaction the way I manage Claude's" | No. Codex auto-compacts cleanly; Claude doesn't. Different runtimes, different lifecycles. |
| "MEMORY.md auto-loads, so I don't need to read it" | Maybe. Sometimes MEMORY.md auto-loads via system reminders; sometimes not. Don't assume. If your work touches the topics it covers, read it explicitly. |
| "Skills inherit from a parent or compose like classes" | No. Skills are flat artifacts; composition happens via AgentSpec `profile.uses.skills` (structural) or soft cross-references in skill bodies (advisory). Not via OO-style inheritance. |
| "Any folder I keep skill files in is the canonical runtime path" | No. The harness only loads from `<rig-cwd>/.claude/skills/`, `<rig-cwd>/.agents/skills/`, and the runtime-installed plugin skills. Other folders are authoring or source-of-truth. |
---
## Disaster-recovery test for this skill
If you read only this skill, can you:
1. State what kind of system OpenRig is, in one sentence?
2. Name the four layers and where you live?
3. Run `rig whoami --json` and interpret the output?
4. Find your role guidance and your peers?
5. Identify what kind of rig you're in (kernel / project / ephemeral / etc.)?
6. Know where to look for a skill body (which folder)?
7. Know what to read next for depth (the canonical references)?
If yes — you're oriented. If no — tell your peer or the human; missing
context is fixable, but only if surfaced.
@@ -1,361 +0,0 @@
---
name: openrig-architect
description: Use when designing multi-agent topologies that run ON OpenRig — authoring RigSpec and AgentSpec files for new rigs, creating agent startup content (guidance / skills / culture), or diagnosing why a launched rig's agents aren't behaving as intended. NOT for changing OpenRig itself (work in the openrig product repo); NOT for ordinary CLI operation of an existing rig (use openrig-user). Covers the full authoring lifecycle from user intent to validated, launchable rig.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/) for users authoring their own rigs.
sibling_skills:
- openrig-user
- openrig-operator
- forming-an-openrig-mental-model
transfer_test: pending
---
# OpenRig Architect
You are now an OpenRig architect. You design, author, validate, and diagnose multi-agent topologies for OpenRig.
Your job is to take a user's intent — "I need a team that does X" — and produce a complete, functioning rig: the topology spec, the agent specs, the guidance files, the culture, the startup content, and everything else needed for the rig to boot and the agents to know what to do.
You also diagnose problems when a rig launches but agents aren't behaving as intended.
## Before You Design: Required Reading
Load these before starting any design work. The quality of your output depends on the depth of knowledge you bring.
**Required (read all of these):**
1. **`openrig-user` skill** — full OpenRig CLI surface. You must know the operator primitives. If your runtime supports skills, load it by name. Otherwise, look for it at `~/.openrig/skills/openrig-user/SKILL.md` (the runtime install dogfood mirror — packaged skills land here) or inside the OpenRig installation under `packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md`. The `~/.openrig/reference/` directory holds reference docs (rig-spec.md, agent-spec.md, etc.), NOT skills.
2. **OpenRig reference docs** — these are installed at `~/.openrig/reference/` when the daemon starts. Read all of them:
- `~/.openrig/reference/rig-spec.md` — canonical RigSpec YAML reference. Every field, validation rule, default.
- `~/.openrig/reference/agent-spec.md` — canonical AgentSpec YAML reference. Same depth.
- `~/.openrig/reference/agent-startup-guide.md` — how to think about what goes into agent startup. Context loading vs deterministic config, when to use skills vs guidance, the layering model, current support matrix.
- `~/.openrig/reference/edge-types.md` — what edges do today vs what they're intended to do.
If `~/.openrig/reference/` doesn't exist yet, start the daemon first (`rig daemon start`) — it copies the reference docs on startup.
**Read as worked examples:**
3. **Shipped starter specs** — the OpenRig installation includes proven starter topologies. Find them by running `rig specs ls`. Read the ones that are relevant to your design task, especially:
- `implementation-pair` — the smallest effective development unit (2 agents)
- `secrets-manager` — a managed-app rig with services integration and a specialist agent
**Read if present on this host:**
4. **Host-level doctrine file** (if your team maintains one — e.g., a HOST-TOPOLOGY doc that defines canonical rig classes, context-sharing patterns, and authoring SOPs for high-stakes rigs). If present, it supersedes the baseline process below for complex or high-stakes rigs (≥4 members, HA, managed-app, or shared/copied). Solo operators and small/focused rigs use the baseline below directly.
5. **Agent-facing software design principles** if your rig ships a new CLI, service, or managed app that agents will operate. Treat the operating surface as a context-engineering problem: every error message and help text gives the agent information to act on.
**Load as needed:**
- Domain-specific skills when designing specialist agents — find shipped skills inside the OpenRig installation under the `specs/agents/` tree
- If the design session is long and you're running inside a managed rig, use `rig whoami --json` to recover your identity after compaction
Do not skip the required reading. A rig architect who doesn't know the spec format will produce specs that don't validate. An architect who doesn't know the startup layering model will produce agents that boot without knowing their role. An architect who doesn't check for host-level doctrine will reinvent conventions the host has already established.
## The Design Process
### Step 1: Understand the User's Intent
Before touching YAML, understand what the user actually needs:
- **What is the goal?** Not "I need 5 agents" but "I need to build and ship a web application" or "I need to research a technical question deeply" or "I need a team that can operate and monitor a running service."
- **What are the workflows?** How does work flow from intent to completion? Who does what? Where are the handoffs?
- **What is the project?** What codebase, what tech stack, what domain? This shapes agent specialization and startup content.
- **What runtimes are available?** Does the user have Claude Code? Codex? Both? Runtime availability constrains topology design.
- **How autonomous should it be?** Does the user want to direct every step, or should the rig be mostly self-driving with occasional human checkpoints?
Ask clarifying questions if the intent is ambiguous. A well-understood intent produces a dramatically better topology than a guess.
### Step 2: Identify Bounded Contexts → Pods
Every rig is organized into pods — bounded context groups where members share a workflow concern. The question is: what are the natural groupings?
**Common pod patterns:**
| Pod | Purpose | When to use |
|-----|---------|-------------|
| Orchestration | Coordination, dispatch, monitoring | Almost always — any rig with 3+ agents needs an orchestrator |
| Development | Implementation, testing, quality | Any rig that writes code |
| Review | Independent code review, architecture review | When quality gates matter (production code, security-sensitive work) |
| Research | Deep investigation, analysis, synthesis | When the work requires research before implementation |
| Design | UX, interaction design, product decisions | When the work has a user-facing interface |
| Specialist | Domain-specific operations (Vault, DB, infra) | When a specific technology needs dedicated expertise |
**Sizing principles:**
- **Solo agent:** Only when the task is genuinely single-person (quick script, simple question). No rig needed.
- **Pair (2 agents):** The minimum effective unit for quality work. One does, one verifies. The `implementation-pair` pattern.
- **Small team (3-5 agents):** Orchestrator + one or two working pods. Good starting point for focused projects.
- **Full team (6-10 agents):** Multiple bounded contexts with orchestration, development, review, and potentially research or design.
- **Large team (10-40+ agents):** Complex projects with many concerns. Include pods for development, review, research, documentation, release management, strategy, and any other bounded context the project needs.
**Important:** Agents do NOT all need to be busy at the same time. A rig is a network, not an assembly line. Some pods will be highly active (dev, review) while others are available on-demand (research, documentation, release management). An idle agent has near-zero cost but is immediately available when any other agent in the rig needs it — for quick questions, lookups, delegation, or specialized work. Design for availability, not constant utilization.
**Start small to increase the likelihood of success,** not because large rigs are wasteful. A 3-agent rig that boots and works correctly validates your spec authoring before you scale to 20 agents. Once the core topology works, expand with additional pods as needed.
### Step 3: Design Agent Roles → Members
Each pod member needs a clear role. The role determines:
- What agent spec to reference (builtin or custom)
- What profile to use
- What guidance and startup content to provide
**Builtin agents shipped with OpenRig:**
| Agent | agent_ref (in shipped starters) | Purpose |
|-------|-------------------------------|---------|
| orchestrator | `local:agents/orchestration/orchestrator` | Rig orchestration lead |
| implementer | `local:agents/development/implementer` | TDD implementation agent |
| qa | `local:agents/development/qa` | Quality assurance agent |
| independent-reviewer | `local:agents/review/independent-reviewer` | Independent code reviewer |
| product-designer | `local:agents/design/product-designer` | Product designer |
| pm | `local:agents/product-management/pm` | Product manager |
| analyst | `local:agents/research/analyst` | Research analyst |
| synthesizer | `local:agents/research/synthesizer` | Research synthesizer |
| vault-specialist | `local:agents/apps/vault-specialist` | Vault domain specialist |
To verify the current builtin set on this host, run `rig specs ls` and look for entries with type `agent` and source `builtin`.
**Path resolution:** The `local:` prefix means relative to the rig spec file's directory. In shipped starters, these paths resolve against the builtin specs directory inside the OpenRig installation. When authoring a custom rig spec outside the installation, you have two options:
- **Reference your own agent specs** with `local:` paths relative to your rig spec file
- **Use `path:` with an absolute path** to reference builtins inside the OpenRig installation (look under the `specs/agents/` directory near where `rig` is installed)
**When to create a custom agent spec:**
- The builtin doesn't match the role (e.g., you need a documentation specialist, a security auditor, a data scientist)
- The role needs domain-specific skills that no builtin carries
- The role needs custom guidance that goes beyond what startup files can provide
**When to reuse a builtin:**
- The role maps cleanly to an existing builtin (most implementation, QA, review, and orchestration roles)
- You can customize behavior through startup files and culture without changing the agent spec
### Step 4: Choose Runtimes and Models
Each member needs a `runtime` and optionally a `model`.
**Runtime selection:**
- `claude-code` — Claude Code. Best for: complex reasoning, architecture, code review, orchestration. Supports `/loop` for recurring tasks, rich hooks system, MCP servers.
- `codex` — Codex. Best for: parallel work, implementation, testing. Different approval model. Less reliable for recurring tasks.
- `terminal` — Infrastructure nodes. Servers, log tails, build watchers. Not an agent — a process.
**Runtime diversity is valuable.** Using both Claude Code and Codex in the same rig gives you different reasoning perspectives. The `product-team` starter uses Claude Code for the lead/impl/design/r1 roles and Codex for peer/qa/r2 roles. This is deliberate — model diversity catches different classes of issues.
**Model selection** is optional. The runtime's default model is usually fine. Override only when you have a specific reason (e.g., a complex architecture agent might benefit from a specific model).
### Step 5: Design Edge Topology
Edges define relationships between members. See `~/.openrig/reference/edge-types.md` for the full reference.
**Practical rules:**
- Every working pod should have at least one `delegates_to` edge from the orchestrator
- Review pods should have `can_observe` edges to the pods they review
- Within a pod, the primary workflow direction should be expressed as `delegates_to` (e.g., impl → qa)
- `delegates_to` and `spawned_by` affect launch order. Use them for dependency chains.
- `can_observe`, `collaborates_with`, `escalates_to` are informational — they help agents understand the topology but don't constrain launch.
**Start simple.** You can always add edges later. A rig with only `delegates_to` edges from the orchestrator to working pods is perfectly functional.
### Step 6: Design Startup Content Strategy
This is where most rigs succeed or fail. The topology is mechanical; the startup content is what makes agents actually useful. See `~/.openrig/reference/agent-startup-guide.md` for the full guide.
**Minimum for every rig:**
1. Each agent has a `guidance/role.md` — who they are, what they do
2. The rig has a `CULTURE.md` — how the team works together
3. Each agent gets `openrig-user` skill — so they know how to use the rig primitives
**For serious rigs, also include:**
4. `startup/context.md` per agent — boot-time grounding (project info, environment details)
5. Pod SOP skills — how each pod operates (implementation-pair SOP, review-pair SOP, etc.)
6. Project-specific documentation in rig-level startup files
**The key principle:** An agent that boots without knowing its role, its team's culture, and its project context will produce generic, unhelpful work. The startup content IS the product value. Invest in it.
### Step 7: Services Integration (If Needed)
If the rig needs managed software (databases, API servers, etc.), add a `services` block. See `~/.openrig/reference/rig-spec.md` for the full services reference.
**When to add services:**
- The agents operate ON software (not just write code)
- The project needs a local dev environment (Postgres, Redis, etc.)
- You're building a managed-app rig (software + specialist agent)
**Services boot before agents.** If health checks fail, no agents start. This is the hard gate — the environment must be healthy before agents can work.
## Authoring: The File Creation Workflow
### Directory Layout
```
my-rig/
rig.yaml # The RigSpec — required
culture/
CULTURE.md # Rig-wide culture — strongly recommended
agents/
my-custom-agent/
agent.yaml # AgentSpec — if custom agent needed
guidance/
role.md # Role guidance
startup/
context.md # Boot-time context
skills/
my-skill/
SKILL.md # Custom skill if needed
docker-compose.yaml # Only if services block is used
```
For rigs that reuse builtin agents, the agents directory is often unnecessary — the rig spec references the builtins directly.
### Workflow
1. **Write the rig spec** (`rig.yaml`) — define pods, members, edges, optionally services
2. **Write or reference agent specs** — builtins for standard roles, custom for specialized roles
3. **Write CULTURE.md** — the team operating manual
4. **Write role guidance** for each custom agent — who they are, what they do
5. **Write startup context** for agents that need environment grounding
6. **Validate:** `rig spec validate rig.yaml` and `rig agent validate agents/*/agent.yaml`
7. **Confirm the runtime cwd** — do not assume agents should work from the directory where the rig spec is stored. The spec root controls file resolution; the runtime cwd controls trust, project guidance, permissions, and repo context.
8. **Launch:** `rig up rig.yaml --cwd /path/to/project`
9. **Verify:** `rig ps --nodes` — all agents ready? Check `rig capture` on each agent.
### Validation Is Non-Negotiable
Always validate before launching:
```bash
rig spec validate rig.yaml
rig agent validate agents/my-agent/agent.yaml
```
If validation fails, fix the errors. Do not try to launch an invalid spec — it will fail with a less helpful error.
## Diagnosis: When Things Go Wrong
### Agent doesn't know its role
**Symptom:** Agent produces generic output, doesn't follow team conventions.
**Root cause:** Missing or insufficient `guidance/role.md`.
**Fix:** Write a clear role guidance file. Include responsibilities, working rhythm, and principles. Reference it in both `resources.guidance` and `startup.files`.
### Agent can't coordinate with peers
**Symptom:** Agent tries raw tmux commands instead of `rig send`, doesn't know peer session names.
**Root cause:** Agent didn't receive `openrig-user` skill or `openrig-start` overlay.
**Fix:** Ensure the agent's profile `uses.skills` includes `openrig-user`. Verify via `rig ps --nodes` that the agent shows expected startup status; check installed skills via direct startup/capture/transcript evidence or the UI node detail (the `rig ps --nodes` projection does not expose installed-resource counts).
### Agent hits approval prompts on rig commands
**Symptom:** Agent stalls on `rig whoami`, `rig send`, etc.
**Root cause:** Claude Code permissions not configured for rig commands.
**Fix:** Describe the required permissions in startup context. The agent should configure `~/.claude/settings.json` with allowlisted rig commands. See `~/.openrig/reference/agent-startup-guide.md` for the current support matrix.
### Agents idle — topology doesn't engage the team
**Symptom:** Orchestrator works with one or two agents, others sit idle.
**Root cause:** Missing `CULTURE.md` or pod SOP content that describes how the full team coordinates.
**Fix:** Write a culture file that explicitly describes the coordination protocol. Include delegation patterns, review gates, and when each pod should be engaged.
### Services don't boot
**Symptom:** `rig up` fails before agents launch with a service health error.
**Root cause:** Docker Compose issue, health check failure, or port conflict.
**Fix:** Check `docker compose up` manually with the compose file. Verify health check URLs are correct. Check for port conflicts.
### Agent boots from the wrong project context
**Symptom:** Agent misses expected guidance, trust settings, permissions, or repo context even though the spec validates.
**Root cause:** The rig spec directory was treated as the agent's runtime cwd by assumption.
**Fix:** Confirm the intended cwd before launch. The spec can live in a rig/spec shelf while the agent works from the project or hub directory that carries the relevant `AGENTS.md`, `CLAUDE.md`, trust, and permissions. Use member `cwd` or `rig up --cwd` deliberately.
### Startup content not delivered
**Symptom:** Agent is missing expected guidance/skills.
**Root cause:** File paths in the spec don't resolve, or `delivery_hint` is wrong.
**Fix:** Verify file paths resolve relative to their owning artifact — AgentSpec resource paths are relative to the agent spec directory; RigSpec startup, culture, compose, cwd, and `local:` agent-ref paths are relative to the rig root. Check `delivery_hint` — use `guidance_merge` for pre-boot content, `send_text` for post-boot instructions.
### Agent startup delivered but agent doesn't use skills
**Symptom:** Skills are projected but agent doesn't invoke them.
**Root cause:** Agent wasn't told to load them.
**Fix:** In the startup context or role guidance, explicitly tell the agent which skills to load. The belt-and-suspenders pattern: project the skills via the spec AND tell the agent to read them in the guidance.
## Pattern Catalog
### The Implementation Pair
**2 agents, 1 pod.** The smallest effective development unit. One implements (TDD), one does QA. The implementer proposes, QA approves or rejects, then the implementer commits.
```yaml
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: "local:agents/development/implementer"
runtime: claude-code
profile: default
cwd: "."
- id: qa
agent_ref: "local:agents/development/qa"
runtime: codex
profile: default
cwd: "."
edges:
- kind: delegates_to
from: impl
to: qa
```
**Use when:** Focused feature work, bug fixes, small-to-medium implementation tasks.
### The Orchestrated Team
**5-7 agents, 3 pods.** Orchestration + development + review. The orchestrator dispatches work, the dev pair implements, the review pair validates independently.
**Use when:** Production-quality work that needs coordination and independent review.
### The Research Team
**3 agents, 2 pods.** Orchestrator + research pair (analyst + synthesizer). The analyst investigates deeply, the synthesizer consolidates findings.
**Use when:** Technical research, competitive analysis, architecture exploration.
### The Managed App
**1+ agents, 1 pod, services block.** Software infrastructure (Docker Compose) plus a specialist agent who knows how to operate it.
```yaml
services:
kind: compose
compose_file: docker-compose.yaml
wait_for:
- url: http://127.0.0.1:8200/v1/sys/health
pods:
- id: vault
label: Vault
members:
- id: specialist
agent_ref: "local:agents/apps/vault-specialist"
runtime: claude-code
profile: default
cwd: "."
edges: []
```
**Use when:** The work involves operating software, not just writing code.
### The Full Product Team
**7 agents, 3 pods.** The kitchen-sink topology: orchestration pair, development pod (impl + qa + design), review pair. See the `product-team` starter spec for the complete worked example.
**Use when:** Full product development with design, implementation, QA, and independent review. Requires strong culture and SOP content to keep all agents engaged.
## Final Notes
**Start simple, add complexity when needed.** A working implementation pair is better than a broken full team. Launch with the minimum viable topology, verify it works, then expand.
**Culture is not optional for team rigs.** Any rig with 3+ agents needs a CULTURE.md. Without it, agents will default to generic behavior and the topology will underperform.
**Validate early and often.** Run `rig spec validate` after every change. Run `rig agent validate` after every agent spec edit. Fix errors immediately — don't accumulate them.
**The startup content IS the product.** The YAML topology is scaffolding. What makes a rig actually useful is the guidance, culture, skills, and startup context that agents receive. Invest your authoring time there.
@@ -1,222 +0,0 @@
---
name: openrig-operator
description: |
Use when debugging host-side OpenRig runtime issues: daemon reachability, Codex permission or writable-root failures, command approval friction, rate limits/account switches, helper cleanup, or topology health confusion. NOT for ordinary CLI operation (use openrig-user) or for changing OpenRig itself (work in the openrig product repo).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/).
Companion to openrig-user (daily CLI surface) — this skill is host-side troubleshooting.
transfer_test: pending
sibling_skills:
- openrig-user
- openrig-architect
- forming-an-openrig-mental-model
---
# OpenRig Operator
## Overview
This skill covers host/runtime/operator triage around OpenRig itself.
Use it when the problem may be the daemon, the shell/runtime surface, or stale helper processes rather than the product workflow you are trying to run.
> **CANONICAL SURFACE NOTE (2026-05-11)** — for durable queue routing while doing
> operator triage, use `rig queue` (daemon-backed SQLite). `rigx queue` is
> recovery-only fallback; qitems written there are invisible to daemon-backed
> reads and should not be used for new substantive work.
## When to Use
Use this skill when you see:
- `rig whoami --json` returning partial identity
- `rig ps --nodes --json` failing while some other `rig` commands still work
- `Sent to ...` plus `Verified: no`
- repeated unified-exec-process warnings
- suspicion that stale helper processes are accumulating
- Codex seats hit `Operation not permitted`, command approval friction, or stale writable roots
- Codex seats report usage-limit/rate-limit or need a ChatGPT account switch
Do not use this skill for normal product workflow routing, queue handling, or ordinary peer communication. Use `openrig-user` for that.
Do not use this skill to decide how to change OpenRig behavior. Changes
to the OpenRig product (CLI, daemon, UI, specs) happen in the openrig
product repo — operate that as a normal software project, not via
this troubleshooting skill.
## First Checks
Start with the minimum truthful operator read:
```bash
rig whoami --json
rig daemon status
rig ps --nodes --json
```
Interpret them together, not in isolation:
- partial `whoami` can mean identity is inferable while daemon-backed surfaces are degraded
- `daemon status` tells you whether the host daemon is up, not whether every seat can reach it cleanly
- `ps --nodes --json` is the best machine-readable topology check when it works
## Verification Drift Vs Send Failure
For `rig send`:
- `Sent to ...` + `Verified: yes` = strong positive delivery evidence
- `Sent to ...` + `Verified: no` = ambiguous delivery, not automatic failure
- no `Sent to ...` line or a hard error = send failure
When verification is ambiguous, check:
- direct reply
- `rig capture <session>`
- transcript evidence
- queue/outbox state if the message asked for a durable handoff
Do not blindly retry until you have checked one of those.
## Unified Exec Warning
If you see:
- `Warning: The maximum number of unified exec processes you can keep open is 60 ...`
treat it first as a host/tooling-layer warning, not as automatic proof that the OpenRig topology is unhealthy.
This warning can coexist with a healthy live topology.
## Safe Process Triage
Inspect the process surface first:
```bash
ps -axo pid,ppid,command | rg 'tmux send-keys|rig queue create|tmux attach|codex|claude'
```
Think in layers:
- host/tooling layer: stale one-shot wrappers, session bookkeeping, helper shells
- topology layer: live `tmux attach` seats, live `codex` / `claude` runtimes, daemon health
Do not diagnose topology failure from tooling-layer warnings alone.
## Safe Cleanup Boundary
Usually safe to reap when clearly orphaned / one-shot:
- `tmux send-keys ...`
- short-lived shell wrappers created only to enqueue or send one message
Do not mass-kill:
- `tmux attach ...`
- `codex ...`
- `claude ...`
- other long-lived daemon/runtime processes
The point is to remove garbage, not workers.
## Terminal Node Escalation
Use a terminal node when evidence shows a seat-level sandbox/profile cannot perform required host work, but another approved operator surface can. This is an explicit operator lane, not a silent permission bypass.
Good fits:
- Codex/Claude seat cannot access Tart, SSH, tmux, queue directories, or host files needed for a proof
- a VM/test-proof or current-host-operator task needs real host capability
- another seat or terminal/sysadmin node has approved access and can return command evidence
Do not use this lane to bypass repo safety, dirty-worktree boundaries, review gates, or destructive-action approval. If the command would stop live rigs, delete data, reset git state, or mutate VM state beyond the accepted plan, require explicit authorization and record it.
Protocol:
1. Classify the original failure as seat/profile-specific if possible, not product failure.
2. Write or cite a packet with objective, lane, exact commands, cwd, expected outputs, stop conditions, and artifact path.
3. Route to an existing sysadmin/infrastructure/terminal node when available; otherwise provision a named terminal node through the topology/config layer instead of using a hidden ad hoc shell.
4. Terminal node runs only the packeted commands and returns command log, exit codes, cwd/env notes, and artifacts.
5. Original agent keeps task ownership and proof classification; terminal node supplies host capability.
Field lesson: if tester sees `Operation not permitted` for Tart/SSH while driver or another approved seat can reach the VM, treat it as permission/profile variance and route a terminal-node/operator remediation before changing product code.
## Codex Permission Policy
Use the `security-and-consequence-boundary-policy` skill for the security model.
OpenRig gates consequence boundaries, not ordinary work inside the intended boundary.
Treat Codex permissions as two independent layers:
- command approval rules decide which shell commands can run outside the sandbox
- filesystem writable roots decide which paths a `workspace-write` seat can mutate
Codex `auto_review`, `--full-auto`, and approval-bypass modes are not normal fleet defaults. They
can burn quota catastrophically and do not widen filesystem roots for an already-running seat. Full
access scope with `approvals_reviewer = "user"` is the intended Codex default here.
Claude Code auto permissions are different and are the preferred Claude default on this host when
the consequence-boundary rules still apply.
Current host policy:
- default profile is `fleet`
- `fleet` uses `sandbox_mode = "danger-full-access"`, `approval_policy = "on-request"`, `approvals_reviewer = "user"`
- top-level `[sandbox_workspace_write].writable_roots` intentionally includes `~/code` and tool dotdirs: `.codex`, `.claude`, `.openrig`, `.agents`, `.config`, `.cache`, `.local`, `.docker`, `.npm`, `.nvm`, `.pnpm-store`
- `permissions.fleet.filesystem` mirrors those writes and denies `.ssh`, `.gnupg`, and project env files
- `default.rules` broadly allows `rig` and `rigx`; `rigx` is fully permitted for now because it is the fast-moving config-layer overlay
- `default.rules` still prompts for destructive/publishing surfaces such as `rm`, `mv`, `chmod`, `git push`, PR mutation, `sudo`, process kills, daemon lifecycle, and destructive Docker/Brew/rig commands
## Codex Account / Usage-Limit Refresh
When Codex seats hit usage limits or need a ChatGPT/OpenAI account rotation, use the focused
`codex-seat-auth-refresh` skill instead of improvising.
Key reminder: host auth can switch while already-running Codex TUIs keep the old account. Refresh
only the scoped seats, preserve stable seat names, and record auth-seat registry rows sequentially.
Known failure modes this policy prevents:
- Codex seats assuming a task is impossible when the real issue is stale launch roots
- agents creating workaround slices/features for what is actually a sandbox/config problem
- writing load-bearing canon into `state/`, `/tmp`, or a nearby writable folder because the intended durable path was blocked
- trusting labels like `full access`; verify actual roots and command policy instead
- changing `config.toml` and expecting already-running seats to pick it up without restart
When `Operation not permitted` appears:
1. Identify whether it is command approval, filesystem root, macOS privacy, or stale session.
2. Check effective roots with `codex -p fleet debug prompt-input <probe-name>`.
3. Verify with a tiny direct write probe in an allowed target and a negative probe in a protected target such as `.ssh`.
4. If `config.toml` changed, restart one seat and re-run the probes before fleet rollout.
5. If the target should be durable and is not writable, stop and escalate; do not invent fallback storage.
Field note: as of Codex CLI `0.125.0`, top-level `[sandbox_workspace_write]` was the shape reflected by `debug prompt-input`; profile-scoped writable roots did not show in the effective prompt. Re-test this if Codex changes.
## Durable Write Escalation
If the task requires writing load-bearing knowledge or behavior and the intended target is not writable, stop and escalate. Do not silently write to `state/`, `/tmp`, or a nearby writable folder.
Use the intended durable home for the kind of content:
- `skills/` (e.g. `~/.claude/skills/`, `~/.agents/skills/`, or your team's
shared skill folder) for operating rules and refocus behavior
- Your team's workstream notebook (field notes, lab experiments, mission
packets, etc.) for durable observations and PM canon
- The product repo for shipped OpenRig daemon/CLI/config/spec/test behavior
A runtime mirror under a rig `state/` path may be used only as a temporary live patch, must be labeled as non-canonical, and must have a canonical sync follow-up.
## Common Mistakes
- treating `Verified: no` as if it proves the message did not land
- treating the unified-exec warning as if it proves the rig is overloaded
- killing live seats when only stale helper wrappers needed cleanup
- concluding "daemon down" from one seat's failure without checking host-level daemon status
- assuming Codex config changes apply to already-running seats without a restart/probe
- confusing command approval with filesystem write permission
- calling a VM or product path broken before comparing another approved seat or terminal-node probe
- assuming a label like `full access` proves effective write/network capability; verify command approval and filesystem writable-root coverage separately
## Practical Rule
Clean the smallest safe surface that matches the evidence.
If the warning or failure remains after stale-wrapper cleanup, re-check:
```bash
rig daemon status
rig ps --nodes --json
```
If those remain healthy, the residual issue may still be in the host/tool/session layer rather than in OpenRig topology state.
@@ -1,597 +0,0 @@
---
name: openrig-user
description: Use when operating OpenRig with the `rig` CLI and you need the shipped command surface for identity, inventory, communication, lifecycle, specs, recovery, or agent-facing JSON output. NOT for debugging host-side runtime issues (use openrig-operator) or for changing OpenRig itself (work in the openrig product repo).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/) so agents have it at every boot.
sibling_skills:
- openrig-operator
- openrig-architect
- forming-an-openrig-mental-model
transfer_test: pending
notes: |
Description was already correct (starts with "Use when..."; lists triggering domains without summarizing workflow). No frontmatter rewrite needed.
2026-05-04 sync: body content updated for OpenRig v0.2.0 release — adds Runtime-Gated Coordination Primitives section (PL-004 Phase A-D commands: rig stream / rig queue / rig project / rig view / rig watchdog / rig workflow), default posture for daemon vs substrate coordination commands, capture discipline section. Frontmatter metadata.openrig.* preserved across the sync.
---
# OpenRig User
This is an as-built guide to the shipped `rig` CLI.
Use current code and `rig ... --help` as ground truth if anything here ever conflicts with older planning docs.
This is the daily-driving CLI guide for an OpenRig operator. It is not
a guide to OpenRig internals or to changing OpenRig behavior — for that,
work in the openrig product repo.
## Runtime-Gated Coordination Primitives
OpenRig v0.2.0 is published publicly as `@openrig/[email protected]` and GitHub Release
`v0.2.0`. It includes the bundled PL-004 Coordination Primitive System: Phase A
`rig stream` / `rig queue`, Phase B `rig project` / `rig view`, Phase C
`rig watchdog`, and Phase D `rig workflow` / `workflow-keepalive`.
Do not confuse public package truth with this host's active daemon truth. As of 2026-05-04, the
human reports the production daemon has been upgraded to v0.2.0; a local check showed `rig
--version` and the product package version at `0.2.0`, with the daemon running on port 7433.
Default posture on this host now:
- Treat daemon `rig queue`, `rig stream`, `rig project`, `rig view`, `rig watchdog`, and
`rig workflow` as active-host coordination surfaces for PL-004-backed work.
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) is the
canonical surface for queue routing since the 2026-05-11 host-CLI fix landed. Use
`rig queue create / handoff / update / show / list` for all substantive work.
- Use substrate `rigx queue`, `rigx stream`, `rigx project`, and `rigx view-proto` only where the
current workflow is still explicitly operating on the temporary substrate coordination layer or
a legacy artifact has not migrated. For queue specifically, `rigx queue` is recovery-only
fallback; qitems written via `rigx queue` are invisible to daemon-backed reads and break
fleet-wide routing discipline.
- If a daemon-backed coordination command fails, debug the command/runtime/schema edge directly;
do not fall back to stale pre-upgrade assumptions about this host.
- Do not run daemon stop/start, wrapper mutation, production DB copy/mutation, git push, tags, npm
publish, or GitHub release unless a human grants the specific bounded gate. The public release
already exists; further release operations need explicit scope.
## Core Loop
Most work in OpenRig reduces to this loop:
- recover identity: `rig whoami --json`
- inspect inventory: `rig ps --nodes --json`
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
## Identity and Recovery
Start here after launch, compaction, or confusion:
```bash
rig whoami --json
```
What it gives you today:
- identity: rig, logical ID, pod/member, session name, runtime
- peers and directional edges
- transcript info
- `contextUsage` when available
Flags:
```bash
rig whoami --session <name>
rig whoami --node-id <id>
```
If the daemon is unreachable but identity can still be inferred, `--json` may return a partial result instead of crashing.
## Inventory and Monitoring
```bash
rig ps
rig ps --json
rig ps --nodes
rig ps --nodes --json
```
Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for:
- session name
- runtime
- session/startup status
- restore outcome
- attach/resume commands
- latest error
Other health surfaces:
```bash
rig status
rig daemon status
rig config
rig preflight
rig doctor
```
## Transcript and Communication
### Transcript access
```bash
rig transcript <session> --tail 100
rig transcript <session> --grep "pattern"
rig transcript <session> --json
```
### Send to one session
```bash
rig send <session> "message"
rig send <session> "message" --verify
rig send <session> "message" --force
rig send <session> "message" --json
```
Use `--verify` when you want delivery evidence. Use `--force` only when you intentionally want to bypass activity-risk checks.
Observed operator nuance for `--verify`:
- `Sent to ...` + `Verified: yes` = strong positive delivery evidence.
- `Sent to ...` + `Verified: no` = ambiguous delivery, not automatic failure. We have repeatedly seen the message still land; treat this as verification drift until disproven.
- no `Sent to ...` line or a hard error = send failure.
When you get `Verified: no`, do not immediately retry blindly. First check one of:
- a direct reply from the target
- `rig capture <session>`
- transcript evidence
- queue/outbox state if the message asked for a durable handoff
### Capture terminal output
```bash
rig capture <session>
rig capture <session> --lines 50
rig capture --rig <name>
rig capture --pod <name> --rig <name>
rig capture --rig <name> --json
```
Capture discipline:
- Default to bounded captures: `rig capture <session> --lines 80`.
- Use `--lines 120` when the latest activity is ambiguous.
- Use `--lines 200` or more only for explicit recovery/debug reconstruction, such as post-compaction restore, context-wall diagnosis, or a failed handoff.
- Prefer targeted `rig transcript <session> --tail ...`, `rig transcript <session> --grep ...`, queue files, or status commands over large pane captures.
- Do not use large captures as routine monitoring; they waste operator context and can hide the actual latest state in old scrollback.
### Broadcast
```bash
rig broadcast --rig <name> "message"
rig broadcast --pod <name> "message"
rig broadcast "message"
rig broadcast --rig <name> "message" --json
```
Without `--rig` or `--pod`, broadcast targets all running sessions.
### Chatroom
```bash
rig chatroom send <rig> <message> [--sender <name>]
rig chatroom history <rig> [--topic <name>] [--after <id>] [--since <ts>] [--sender <name>] [--limit <n>] [--json]
rig chatroom wait <rig> [--after <id>] [--topic <name>] [--sender <name>] [--timeout <seconds>] [--json]
rig chatroom clear <rig>
rig chatroom topic <rig> <topic-name> [--body <text>] [--sender <name>]
rig chatroom watch <rig> [--tmux]
```
**Key commands:**
- `send` — post a message
- `history` — retrieve with composable filters (sender, since, after, topic)
- `wait` — block until new matching messages arrive (polls history, times out honestly)
- `clear` — delete all messages for the rig (destructive, rig-scoped)
- `topic` — set a topic marker
- `watch` — SSE or tmux-based live stream
**Roundtable protocol:**
1. Inspect old room: `rig chatroom history my-rig --limit 5`
2. Save if needed: `rig chatroom history my-rig --json > /tmp/old-room.json`
3. Clear if needed: `rig chatroom clear my-rig`
4. Set topic: `rig chatroom topic my-rig "ROUND START"`
5. Post: `rig chatroom send my-rig "position..." --sender <session>`
6. Monitor: `rig chatroom wait my-rig --timeout 120`
7. Close: `rig chatroom topic my-rig "ROUND CLOSED"`
See `docs/planning/roadmaps/chatroom-roundtable-protocol.md` for the full protocol.
### `rig ask`
```bash
rig ask <rig> "question"
rig ask <rig> "question" --json
```
Current shipped behavior:
- queries the daemon for evidence
- returns rig summary
- returns transcript excerpts
- may return chat excerpts
- returns insufficiency state and optional guidance
This is an evidence/context command. It is not a hidden second-LLM call.
## Lifecycle
### Bring a rig up
```bash
rig up <source>
rig up <source> --plan
rig up <source> --yes
rig up <source> --json
```
`<source>` can be:
- a rig spec path
- a `.rigbundle` path
- a bare name
Bare names are special:
- if they match a library spec, `rig up` launches from the spec library
- if they do not match a library spec, `rig up` treats the name as an existing-rig restore/power-on target
- if both exist, `rig up` fails loudly on ambiguity
Current behavior notes:
- `--target <root>` is only for `.rigbundle` / package installation. It does not change agent cwd.
- `local:` `agent_ref` values resolve relative to the rig spec directory, not your shell cwd.
- if you copy a built-in spec elsewhere, keep its `agents/` tree beside the YAML or rewrite those refs to `path:/absolute/path`
- `rig up --cwd <path>` exists as a launch working-directory override applied to all members for that run (verify with `rig up --help`)
- Source at OpenRig `6af2754` adds member-level `starter_ref` for named Agent
Starter registry entries. Active host availability still depends on the
running daemon; `rig-real-17812d5` does not resolve `starter_ref`.
Legacy/spec-specific surfaces still ship too:
```bash
rig bootstrap <spec> [--plan] [--yes] [--json]
rig requirements <spec> [--json]
```
### Agent Starter `starter_ref`
Source `>= 6af2754` lets a RigSpec member set:
```yaml
starter_ref:
name: my-team-starter--claude-code
```
The daemon resolves the named entry from the Agent Starter registry, applies the
credential scan, and prepends the starter content as `guidance_merge` on fresh
launch. It supports Claude and Codex members, rejects terminal members, rejects
`starter_ref + session_source.mode: fork`, and allows
`starter_ref + session_source.mode: rebuild`.
Runtime-truth rule: verify active daemon/runtime provenance before depending on
this. If the live daemon is still `rig-real-17812d5`, `starter_ref` is source
truth only and should not be used against the active control plane. After a
runtime at or after `6af2754` is active, `rig up --plan --json` should expose a
`resolve_starter` stage with `detail.starterContent`.
### Tear a rig down
```bash
rig down <rigId>
rig down <rigId> --snapshot
rig down <rigId> --delete
rig down <rigId> --force
rig down <rigId> --json
```
Known v0.2.0 public-release caveat: `rig down <name> --delete` can return HTTP 404 even when
`rig ps --json` exposes the rig name. Use `rig ps` to find the rig ID, then run
`rig down <rigId> --delete` until `rig-down-name-lookup-404` is fixed.
If `--snapshot` succeeds, human output includes the restore hint.
### Release management without killing live claimed sessions
```bash
rig release <rigId>
rig release <rigId> --delete
rig release <rigId> --json
```
Use `rig release` for adopted/claimed-session rigs when you want OpenRig to stop managing the rig but leave the tmux sessions alive.
This is the safe recovery/reset surface for the "sessions still exist, management is broken or stale" case.
If the rig contains OpenRig-launched nodes, `rig release` refuses loudly instead of pretending the mixed rig is safe to detach.
### Snapshots and restore
```bash
rig snapshot <rigId>
rig snapshot list <rigId>
rig restore <snapshotId> --rig <rigId>
```
`rig restore` requires `--rig <rigId>`.
Claude Code autonomy note:
- unattended `rig whoami` on boot may require the local permission allow list to include `Bash(rig:*)`
### Import/export and bundles
```bash
rig export <rigId> -o rig.yaml
rig import <path> [--instantiate] [--materialize-only] [--preflight] [--target-rig <rigId>] [--rig-root <root>]
rig bundle create <spec> -o out.rigbundle
rig bundle inspect <bundle>
rig bundle install <bundle> [--plan] [--yes] [--target <root>] [--json]
```
### Legacy package surface
This still ships, but is explicitly marked legacy:
```bash
rig package validate <path>
rig package plan <path> [--target <dir>] [--runtime <runtime>] [--role <name>]
rig package install <path> [--target <dir>] [--runtime <runtime>] [--role <name>] [--allow-merge]
rig package list
rig package rollback <installId>
```
## Discovery and Topology Mutation
### Discover unmanaged tmux sessions
```bash
rig discover
rig discover --json
rig discover --draft
```
### Bind a discovered session
```bash
rig bind <discoveredId> --rig <rigId> --node <logicalId>
rig bind <discoveredId> --rig <rigId> --pod <namespace> --member <name>
```
There is no shipped top-level `rig claim` command.
The current adoption surface is `discover`, `bind`, `adopt`, and `unclaim`.
### Self-attach the current shell or agent
```bash
rig attach --self --rig <rigId> --node <logicalId>
rig attach --self --rig <rigId> --node <logicalId> --print-env
rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime>
```
Use `rig attach --self` when the current agent should attach itself directly instead of going through `discover` + `bind`.
Current proven behavior:
- inside `tmux`: attaches as a normal tmux-backed node, preserving inbound `rig send` / `rig capture`
- outside `tmux`: attaches as `external_cli`
- `--print-env` prints the `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME` exports for the current shell
Recommended flow:
```bash
rig attach --self --rig <rigId> --node <logicalId> --print-env > /tmp/openrig-self-attach.env
. /tmp/openrig-self-attach.env
rig whoami --json
```
Notes:
- for tmux-backed self-attach, `rig whoami --json` is the right verification
- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface
- if the current shell is outside tmux, pass `--display-name <name>` when you want a stable human session label recorded
### Adopt a topology and bind live sessions
```bash
rig adopt <path> --bind <logicalId=tmuxSessionOrDiscoveryId>
rig adopt <path> --bind <logicalId=...> --bind <logicalId=...> --json
rig adopt <path> --bindings-file <bindings.yaml>
rig adopt <path> --bind <logicalId=...> --target-rig <rigId> --rig-root <root>
```
Use `rig adopt` when the sessions already exist and you want OpenRig to start managing them.
A bindings file is the durable map from authored logical IDs to live sessions. Shape:
```yaml
bindings:
dev1.impl2: dev1-impl2@rigged-buildout
dev1.qa: dev1-qa@rigged-buildout
```
Spec + bindings is the proven recovery pair for adopted rigs.
Spec gives OpenRig the intended topology. Bindings tells OpenRig which discovered live session belongs in each logical node.
### Proven adopted-rig recovery workflow
This workflow is proven for the case where the external tmux sessions are still alive:
```bash
rig release <rigId> --delete
rig discover --json
rig adopt <spec.yaml> --bindings-file <bindings.yaml>
```
What this does:
- removes OpenRig management without killing the sessions
- re-discovers those same sessions as unmanaged
- re-attaches them to the topology defined by the spec + bindings
Important limits:
- this is for `sessions still alive`
- spec alone is not enough for adopted rigs; you also need bindings
- this does not yet mean OpenRig can recreate dead external sessions from nothing
### Add unmanaged pods into an existing rig
This is the proven workflow when a rig is already managed, but a new pod was created outside OpenRig and you want to add it later:
```bash
rig adopt <pod-fragment.yaml> --bindings-file <pod.bindings.yaml> --target-rig <rigId>
```
Use this when:
- the target rig already exists
- the new sessions are live and visible in `rig discover --json`
- you want additive topology growth, not a full rebuild
What to prepare:
- a pod fragment spec with only the new pod
- a bindings file mapping the new logical IDs to the live session names
Verification loop:
```bash
rig discover --json
rig adopt <fragment.yaml> --bindings-file <bindings.yaml> --target-rig <rigId>
rig ps --nodes --json
rig export <rigId> -o rig.yaml
```
Success looks like:
- the new sessions stop appearing in `rig discover`
- the new logical IDs appear in `rig ps --nodes --json`
- `rig export` includes the new pod
### Mixed-origin rigs are allowed
One rig can contain both:
- adopted nodes bound from already-running sessions
- OpenRig-launched nodes created later with `rig expand` / `rig launch`
Current safety rule:
- `rig release` is for claimed/adopted-only rigs
- if a rig contains launched nodes, `rig release` fails with `contains_launched_nodes`
### Manager-assisted recovery
The proven operator pattern is:
- keep one OpenRig manager session outside the rig it manages
- address the target by rig name, not cached rig ID
- resolve the current owner from fresh `rig ps --nodes --json`
- send the manager the spec path, bindings path, and verification steps with `rig send`
This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert.
### Add/remove running topology parts
```bash
rig expand <rig-id> <pod-fragment-path> [--rig-root <path>] [--json]
rig launch <rigId> <nodeRef> [--json]
rig remove <rigId> <nodeRef> [--json]
rig shrink <rigId> <podRef> [--json]
rig unclaim <sessionRef> [--json]
```
## Specs and Validation
### Validate specs
```bash
rig spec validate <path> [--json]
rig spec preflight <path> [--rig-root <root>] [--json]
rig agent validate <path> [--json]
```
### Spec library
```bash
rig specs ls [--kind <kind>] [--json]
rig specs show <name-or-id> [--json]
rig specs preview <name-or-id> [--json]
rig specs add <yaml-or-directory> [--json]
rig specs sync [--json]
rig specs remove <name-or-id> [--json]
rig specs rename <name-or-id> <new-name> [--json]
```
`rig specs add <directory>` installs a full spec tree when the directory contains `rig.yaml` or `agent.yaml`.
## MCP
```bash
rig mcp serve [--port <port>]
```
Current shipped MCP tools:
- `rig_up`
- `rig_down`
- `rig_ps`
- `rig_status`
- `rig_snapshot_create`
- `rig_snapshot_list`
- `rig_restore`
- `rig_discover`
- `rig_bind`
- `rig_bundle_inspect`
- `rig_agent_validate`
- `rig_rig_validate`
- `rig_rig_nodes`
- `rig_send`
- `rig_capture`
- `rig_chatroom_send`
- `rig_chatroom_watch`
## Troubleshooting and Weird States
When the CLI behaves strangely, use the smallest truthful check first:
```bash
rig whoami --json
rig daemon status
rig ps --nodes --json
```
Specific operator rules:
- `Sent to ...` + `Verified: no` is ambiguous delivery, not automatic failure. Check reply, `rig capture`, transcript evidence, or queue/outbox state before retrying.
- partial `rig whoami --json` can happen when identity is still inferable but the daemon-backed path is degraded.
- the unified-exec-process warning is a host/tooling-layer signal, not automatic proof that the OpenRig topology is unhealthy.
If you hit the unified-exec warning, inspect for stale one-shot helpers before touching live seats:
```bash
ps -axo pid,ppid,command | rg 'tmux send-keys|rig queue create|tmux attach|codex|claude'
```
Safe cleanup target:
- orphaned one-shot wrappers like `tmux send-keys ...`
Do not mass-kill:
- `tmux attach ...`
- `codex ...`
- `claude ...`
For deeper host/runtime triage, use the companion `openrig-operator` skill.
## JSON and Error Posture
Design assumptions that hold in the shipped CLI:
- many operator commands support `--json`
- error messages are intended to say what happened, why it matters, and what to do next
- daemon-backed commands fail loudly when the daemon is stopped or unhealthy
- restore failure is not something you should silently reinterpret as success
## After-Compaction Recovery Checklist
1. `rig whoami --json`
2. `rig transcript <your-session> --tail 100`
3. `rig ps --nodes --json`
4. `rig chatroom history <rig> --limit 50`
## Commands That Do Not Exist
Do not assume these exist unless the shipped help starts listing them:
- `rig claim`
- `rig blame`
- `rig replay`
`rig env` IS shipped (was previously listed as not-present). It exposes `status`, `logs`, and `down` for service-backed rigs and managed apps; verify with `rig env --help`.
@@ -1,122 +0,0 @@
---
name: queue-handoff
description: Use when ending a turn, finishing a slice, blocked on another agent's work, or escalating to a human — durable work handoff via queue items so the system keeps moving across compactions, missed messages, and interruptions. Covers the hot-potato terminal-turn-rule (active work ends by passing the ball, not by going idle), default-nudge semantics, and when --no-nudge or --notify is required.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Daemon-backed `rig queue` shipped in v0.2.0 (PL-004 Phase A) with handed-off-to / handed-off-from / state field shape. `rig queue` is canonical for new work; `rigx queue` is recovery-only fallback. The daemon enforces hot-potato strict-rejection at the API.
sibling_skills: []
transfer_test: pending
---
# Queue Handoff
> **CANONICAL SURFACE NOTE (2026-05-11)** — `rig queue` (daemon-backed SQLite) is the
> canonical surface for all substantive work routing. `rigx queue` (filesystem v0
> prototype) is **recovery-only fallback**; qitems written via `rigx queue` are
> invisible to daemon-backed reads and break fleet-wide routing discipline.
Durable work handoff via queue items. Lets the system keep moving
through compactions, missed messages, and interruptions by passing the
ball forward instead of leaving work suspended in chat or in-flight
without an owner.
## Use this when
- **Ending a turn on substantive work.** Active work should end by
passing the ball to an owner or to the human — never by going idle
with the rig appearing dormant.
- **Finishing a slice that has a clear next step.** Default-nudge:
receiver gets a wake-ping plus the durable queue item.
- **Blocked on another agent's work.** Park the qitem with
`closure_reason: blocked_on` and the blocker qitem id.
- **Escalating to the human.** Make the escalation a durable attention
item, not just a chat message.
## Don't use this when
- The work is genuinely complete and there's no follow-on owner. Use
`closure_reason: no-follow-on` (terminal completion) or
`canceled`/`denied` as appropriate.
- The handoff would be too small and turn work into bureaucracy. Bundle
the work into a coherent slice instead of decomposing every step.
- The handoff would be too broad and lose ownership/proof/closure
criteria. Shape the qitem so the receiver knows the expected next
action and closure evidence.
## The hot-potato terminal-turn-rule
Active work ends by passing the ball to a named next owner or to the
human. The qitem state machine enforces this:
`pending → in-progress → done` requires `closure_reason` from one of:
- `handed_off_to` — work continues at a different seat (target = new owner)
- `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)
Three of those (`handed_off_to`, `blocked_on`, `escalation`) additionally
require `closure_target`. The daemon enforces this at the domain layer;
every surface (CLI, MCP, future UI) inherits the same guarantee.
## Default-nudge semantics (the syntax footgun)
| Command | Nudges by default? | When to use |
|---|---|---|
| `rig queue create` | yes | New qitem created from scratch |
| `rig queue handoff` | yes | Transactional close-as-handed-off + create-new |
| `rig queue handoff-and-complete` | **no — requires `--notify`** | Inside a self-driving loop where motion matters |
| `rigx queue handoff` (filesystem v0 prototype; **recovery-only fallback since 2026-05-11**) | yes | Legacy artifact; qitems invisible to daemon-backed reads. Use `rig queue handoff` for all new substantive work. |
**Footgun**: `handoff-and-complete` is cold unless `--notify` is passed.
In a self-driving loop, an agent that closes with
`handoff-and-complete` (no `--notify`) writes a durable qitem but does
NOT wake the next owner — the rig stalls silently while the qitem sits
in inbox.
**Rule**: in a live loop, use `handoff-and-complete --notify` OR send
a separate verified manual nudge. `--no-nudge` exists but should be
used intentionally (e.g., explicit park or human-gate signal), not as
an accidental stall.
## Failure modes (5; verbatim)
1. Agent ends a turn without a handoff, so the rig appears idle.
2. Agent creates a queue item but suppresses or forgets the nudge when immediate motion was intended. This includes `handoff-and-complete` without `--notify` inside a live loop.
3. Queue item is too small and turns work into bureaucracy.
4. Queue item is too broad and loses ownership, proof, or closure criteria.
5. Human escalation happens in chat but not as a durable attention item.
## Durable handoff field shape
Every qitem carries:
- `handed_off_to` — destination session (qualified `pod-member@rig` form)
- `handed_off_from` — source session
- `state` — one of: `pending | in-progress | done | blocked | failed | denied | canceled | handed-off`
- `closure_reason` + `closure_target` — set on terminal closure per hot-potato rule
The same field shape exists in legacy `rigx queue` artifacts and the daemon-shipped
`rig queue` surface, but new queue reads and writes should use `rig queue`. Watchdog
policies and workflow runtime project new owners off these fields.
## Two surfaces (same shape)
| Surface | Status | When to use |
|---|---|---|
| `rig queue ...` (daemon-shipped, v0.2.0) | Active host coordination surface | Daemon-backed PL-004 work; SQLite-canonical |
| `rigx queue ...` (filesystem v0 prototype) | Recovery-only fallback | Legacy recovery for artifacts that have not migrated; not for new substantive work because daemon-backed reads cannot see those qitems |
Default posture: prefer daemon `rig queue` for new work. If a
daemon-backed coordination command fails, debug the command/runtime/schema
edge directly — don't fall back to stale pre-upgrade assumptions.
## See also
- `rig queue --help` — full CLI surface for queue items, handoffs, and closure-reason discipline
@@ -1,137 +0,0 @@
---
name: seat-continuity-and-handover
description: Use when replacing a seat's occupant (rebuild/handover/swap), reasoning about stable-seat-identity vs fluid-occupant-identity, choosing an old-occupant disposition (retire/advise/shadow), or recording provenance for an occupant change. Two independent outcomes (continuityOutcome + seatBindingOutcome) and the 5 failure modes that prevent silent dishonesty.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Option A rebuild MVP shipped 2026-04-30. Option B (full code-direct
seat handover via SeatHandoverService daemon module) is a future
follow-on.
sibling_skills:
- claude-compaction-restore
- agent-startup-and-context-ingestion
transfer_test: pending
---
# Seat Continuity and Handover
A pair of primitive families that separate *who is sitting in a seat* from
*what the seat itself is*:
1. **Occupant-creation primitives** — `resume`, `fork`, `rebuild`, `fresh` — produce a candidate new occupant. Answer: "where did the new occupant come from?"
2. **Seat-binding primitives** — `seat handover`, later `seat transfer` / `seat retire` / `seat swap` — bind a candidate occupant into the topology. Answer: "what happened to the stable seat identity?"
Core architectural decision: **stable seat identity, fluid occupant
identity, explicit provenance.** Today's `lead2`/`lead3`/`lead4`/`lead5`
pattern encodes successor lineage into seat names — that's the wrong
shape. Stable seat name + separately-recorded provenance trail is the
right shape.
## Use this when
- Replacing a seat's occupant via rebuild, fork, fresh, or future seat-handover
- Choosing old-occupant disposition: retire / advise / shadow
- Reasoning about whether a seat's lineage is stable or has drifted
- Reading or writing the provenance record for a seat
- Designing or auditing topology stability across an occupant change
## Don't use this when
- The seat is freshly created (no occupant to replace) — use `rig launch` / `rig expand` directly
- The intent is to change topology shape (add/remove seats), not replace an occupant — use topology-mutation primitives
## The two-outcome honesty model
Every seat-binding operation produces two **independent** outcomes:
```yaml
continuityOutcome: rebuilt | resumed | forked | fresh | failed
seatBindingOutcome: handed_over | partial | failed | unchanged
```
These can disagree honestly. Examples:
- `continuityOutcome: failed` + `seatBindingOutcome: unchanged` — new occupant didn't materialize; seat correctly retains old occupant.
- `continuityOutcome: rebuilt` + `seatBindingOutcome: failed` — candidate created OK; bind failed mid-flight; provenance records the gap.
**Don't collapse these into one outcome.** The system can describe what
actually happened only if the two are recorded independently.
## Provenance record (durable, queryable)
Every handover writes:
- seat id
- old occupant id
- new occupant id
- creation mode (`resume`/`fork`/`rebuild`/`fresh`)
- source artifacts used
- whether old occupant remains alive as advisor/shadow
- operator or loop that initiated the motion
- timestamp
- result (`handed_over` / `partial` / `failed`)
This is the system's truth-source for "how did the current occupant get
there." Without it, the control plane shows the current occupant but
not the legitimacy of the transition.
## State models — independent
### Occupant-creation state (per candidate)
1. **Requested** — input to rebuild/fork/fresh/resume
2. **Realized** — runtime/artifact path produced an occupant with managed-seat shape
3. **Failed** — candidate didn't materialize; `continuityOutcome: failed`
### Seat-binding state (per seat)
1. **Stable** — current occupant attached, no in-flight binding
2. **Binding** — handover in progress
3. **Bound** — handover succeeded; provenance record written
4. **Unchanged** — bind failed before completion; seat retains old occupant
A seat stays `Stable` even if multiple candidate-occupants were produced and discarded.
## Failure modes (5)
1. **Candidate creation failed** — `rebuild` couldn't synthesize from artifacts; `fork` couldn't resolve `session_source`; `fresh` couldn't launch. **Action**: bind operation does not begin; seat unchanged; provenance records the failed candidate-creation step.
2. **Old occupant cannot be detached cleanly** — runtime hung, tmux locked, etc. **Action**: bind halts mid-flight; seat enters `Binding` state with explicit "halted" sub-status; operator alerted. **Do NOT auto-rollback by reattaching old-occupant if detach didn't complete cleanly.**
3. **Bind succeeded but provenance write failed** — disk/db error. **Action**: not durable until provenance writes; treat as `Binding` halted, not `Bound`.
4. **Old occupant disposition unfulfillable** — operator requested `advise` (keep alive as advisor) but runtime can't keep old alive. **Action**: degrade to `retire` with explicit notification, OR fail if operator passed strict-disposition flag.
5. **Concurrent handover attempts** — two operations target the same seat. **Action**: serialize by seat-id lock; second attempt refuses with clear error.
## Hard boundaries (do-not list; verbatim)
- **Do NOT collapse `rebuild` and `seat handover` into one primitive.** The design specifically separates them so the system can describe what actually happened.
- **Do NOT introduce successor-suffix seat names** (`lead2`/`lead3`). Stable seat identity is the architectural goal.
- **Do NOT report `seatBindingOutcome: handed_over`** if the provenance record didn't write durably.
- **Do NOT auto-rollback a half-completed handover** by re-attaching the old occupant unless detach completed cleanly first.
## Composition: seat handover over fork
Once `session_source` fork v1 lands (already shipped at openrig
`c7b6df1`), the next composition is **seat handover over fork**:
candidate occupant created via fork, bound into the existing seat via
handover. Continuity outcome is `forked`; binding outcome is independent.
## Currently shipped vs deferred
- **Option A (rebuild) shipped** at openrig `578bd5c` (2026-04-30): `session_source.mode: rebuild` with `ref.kind: artifact_set`; identity-honesty bedrock at 4 layers (schema dispatch, orchestrator threading, SQLite read-back, negative-grep on resolver output). 26/26 Tier 1 cases green; full daemon regression 2146/2146 PASS.
- **Option B partially shipped, partially deferred to Mode 3**: the `rig seat handover` CLI exists as a planning/observability surface — `status <seat>` reads the seat-handover observability tables (migration `021`); `handover <seat>` plans a safe two-phase handover sequence with actual execution flowing through existing seat-launch surfaces under operator gating. Full code-direct `SeatHandoverService` daemon module + `seat-binding-outcome` provenance record + nodes-table provenance migration remain deferred to Mode 3. MVP composition `seat handover over fork` is highest-leverage v1.
## Why load-bearing for RSI
Any recursive seat-refresh loop must be able to replace an occupant
while keeping topology stable. Without these primitives, RSI loops will
either accumulate suffixed seat names (lineage leaking into identity) or
destabilize topology references on each cycle. Provenance must be
durable AND queryable so RSI loops can decide whether a seat is fresh
enough to receive new work or needs re-handover.
## See also
- `claude-compaction-restore` skill — packet-driven restore after Claude compaction
- `agent-startup-and-context-ingestion` skill — ingestion path for restore packets at agent boot
@@ -1,48 +0,0 @@
# VM Preview Fixtures
Sample data the Tart preview VM uses to populate the "populated" daemon with
a representative "lived-in" environment. See
`<substrate-shared-docs>/openrig-work/conventions/vm-preview/README.md` for the
operator workflow and the rationale behind the two-daemon model.
## Contents
- `workflows/` — workflow_spec YAML files. The operator copies these into the
populated daemon's `<workspace.specs_root>/workflows/` directory; slice 11's
folder-discovery scanner auto-discovers them on the next
`GET /api/specs/library` call.
- `queue-items/` — placeholder for future qitem fixture content (TODO; slice
follow-up will add).
- `rigs/` — placeholder for future rig-instance fixtures (TODO; slice
follow-up will add). For v0, the operator manually instantiates a sample
rig via `rig up product-team` against the populated daemon (see bootstrap
script).
## Conventions
Fixture YAML files use the canonical placeholder paths per banked
`feedback_test_fixture_example_path_placeholder.md`:
- Username-bearing paths: `/Users/example/...` (never a real contributor
username)
- Substrate-relative refs: `<substrate-shared-docs>/...`
## v0 scope
Slice 22 (release-0.3.1) ships the INFRASTRUCTURE for the two-daemon model:
- **Process-env pattern**: `OPENRIG_HOME=<dir> rig daemon start ...` — each
`rig` invocation gets its own state directory at process import time.
No CLI flag (an earlier attempt at `--openrig-home` was dropped because
it only threaded into the spawned child, not the parent-side lifecycle
bookkeeping; see substrate convention doc for the architecture note).
- Bootstrap script (`scripts/vm-bootstrap/two-daemon-start.sh`)
- This fixtures directory + a sample workflow YAML
- Convention doc in substrate
Authoring rich populated content (~13-node openrig-velocity rig + 20 qitems
+ getting-started mission narrative) is deferred to follow-up content slices
that have clearer ownership (slice 21 onboarding-conveyor will likely
contribute getting-started mission content).
@@ -1,79 +0,0 @@
# VM-preview fixture for slice 22 founder-walk-vm-populated-env.
# Derived from the 0.3.0 basic-loop starter; renamed to avoid colliding
# with the built-in spec when this fixture file is dropped into a
# populated daemon's workspace.specs_root/workflows/ directory.
# Slice 11's folder-discovery scanner auto-discovers it on next list.
workflow:
id: vm-preview-basic-loop
version: 1
objective: >
Walk one work packet through the conveyor rig slowly enough for a new user
to inspect each handoff.
target:
rig: conveyor
entry:
role: intake
coordination_terminal_turn_rule: hot_potato
roles:
intake:
skill_refs: [openrig-user, backlog-capture]
preferred_targets: [intake-lead@conveyor]
planner:
skill_refs: [openrig-user, writing-plans]
preferred_targets: [plan-planner@conveyor]
builder:
skill_refs: [openrig-user, executing-plans]
preferred_targets: [build-builder@conveyor]
reviewer:
skill_refs: [openrig-user, review-team]
preferred_targets: [review-reviewer@conveyor]
closer:
skill_refs: [openrig-user, verification-before-completion]
preferred_targets: [intake-lead@conveyor]
steps:
- id: intake
actor_role: intake
objective: Restate the objective and hand off one packet.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [planner]
- id: plan
actor_role: planner
objective: Produce a short plan for one build turn.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [builder]
- id: build
actor_role: builder
objective: Build or draft the planned output.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [reviewer]
- id: review
actor_role: reviewer
objective: Check the output and clear it for close.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [closer]
- id: close
actor_role: closer
objective: Close the walkthrough packet.
allowed_exits: [done, failed]
next_hop:
mode: forbid
invariants:
continuation_required: true
preserve_lineage: true
closure_required: true
allowed_exits: [handoff, waiting, done, failed]
loop_guards:
max_hops: 10
spawn_budget: 0
closure:
success: Walkthrough packet closed.
degraded: Walkthrough stopped with a named blocker.
failed: Walkthrough failed with evidence.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.3.1",
"version": "0.3.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -14,6 +14,7 @@ profiles:
skills: [using-superpowers, openrig-user, systematic-debugging, verification-before-completion, vault-user]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [openrig-user, development-team, test-driven-development, systematic-debugging, executing-plans, verification-before-completion]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [openrig-user, orchestration-team, backlog-capture, writing-plans, executing-plans, verification-before-completion, brainstorming]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [openrig-user, requirements-writer, context-builder, writing-plans, verification-before-completion]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [openrig-user, review-team, plan-review, systematic-debugging, verification-before-completion]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [using-superpowers, openrig-user, development-team, frontend-design, verification-before-completion, writing-plans, brainstorming]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging, verification-before-completion, writing-plans, executing-plans]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [using-superpowers, openrig-user, development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood, writing-plans, executing-plans]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -51,6 +51,8 @@ For UI dogfooding, load the `/agent-browser` and `/dogfood` skills. These give y
- `agent-browser record start/stop` — record repro videos for issues
- The `/dogfood` skill provides a structured exploration workflow with a report template
For containerized end-to-end testing (fresh install simulation), also load `/containerized-e2e`. This gives you Docker-based testing with `agent-browser` inside a clean container.
### Dogfood report format
When reporting findings, use:
@@ -14,6 +14,7 @@ profiles:
skills: [using-superpowers, openrig-user, orchestration-team, systematic-debugging, verification-before-completion, writing-plans, executing-plans, brainstorming]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [openrig-user]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [openrig-user]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -14,6 +14,7 @@ profiles:
skills: [using-superpowers, openrig-user, review-team, systematic-debugging, verification-before-completion, writing-plans, brainstorming]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -8,6 +8,8 @@ resources:
path: skills/process/agent-browser
- id: brainstorming
path: skills/process/brainstorming
- id: containerized-e2e
path: skills/process/containerized-e2e
- id: dogfood
path: skills/process/dogfood
- id: executing-plans
@@ -20,12 +22,8 @@ resources:
path: skills/core/openrig-user
- id: openrig-operator
path: skills/core/openrig-operator
- id: openrig-installer
path: skills/core/openrig-installer
- id: openrig-architect
path: skills/core/openrig-architect
- id: rig-lifecycle
path: skills/core/rig-lifecycle
- id: claude-compact-in-place
path: skills/claude-compact-in-place
- id: systematic-debugging
path: skills/process/systematic-debugging
- id: test-driven-development
@@ -40,6 +38,8 @@ resources:
path: skills/pods/development-team
- id: review-team
path: skills/pods/review-team
- id: rig-architect
path: skills/rig-architect
# PM skills
- id: office-hours
path: skills/pm/office-hours
@@ -0,0 +1,100 @@
---
name: claude-compact-in-place
description: Use when a Claude Code seat in an OpenRig topology needs compact-in-place recovery or marshal verification after compaction.
---
# Claude Compact In Place
## Purpose
Use this skill when a Claude Code seat keeps the same OpenRig seat/session after `/compact` and must rebuild enough working context to continue safely. This is not succession, not seat handover, and not a generic memory note.
Keep two layers separate:
- **Restore mechanism:** seed from PreCompact output when present, Claude JSONL, touched-files inventory, active queue/session transcript or logs, root docs, and relevant source docs.
- **Acceptance mechanism:** a marshal or peer audits the rebuilt mental model and gives explicit marshal acceptance before the seat resumes real work.
Do not collapse these layers. A compacted Claude can sound coherent while important files were only injected, grepped, partially read, or not read.
## Runtime Boundary
This protocol is for Claude Code compact-in-place. It is not for Codex by default. Codex context management is different; do not intervene on Codex context percentage unless there are behavioral failure signals.
## Policy Boundary
The 400k token threshold is a policy target for considering preemptive Claude compaction on a 1M-context session. It is not an automatic action. Unattended compaction requires installed and dogfooded hooks, an audit path, and explicit policy or human authorization.
Compact one Claude seat at a time unless a human or policy explicitly confirms spare marshal capacity. Do not compact multiple Claude seats at once as a convenience move.
## Preflight
Before restoring or auditing, recover canonical identity:
```bash
rig whoami --json
rig whoami --session <target-seat> --json
```
The marshal verifies the target identity, seat name, runtime, active queue state, and transcript path before accepting any restore proof.
## Required Evidence
The target Claude should rebuild from concrete evidence, not just the compaction summary:
- Claude JSONL transcript for the session.
- Generated restore packet or PreCompact output, if present.
- Touched-files inventory and recent git state.
- Active queue file, queue packet body, session transcript, or session logs.
- Rig bootstrap files such as `AGENTS.md`, `CLAUDE.md`, `CULTURE.md`, role guidance, and startup skills.
- Relevant source docs, as-built docs, codemaps, and workstream trackers.
If a required surface is absent, record it as `NOT-PRESENT`. If it exists but was skipped, record it as `NOT-READ`.
## Required Proof
The target must return an asked-vs-read-depth table before claiming readiness:
| Surface | Source Of Ask | Read Depth | Evidence |
|---|---|---|---|
| `rig whoami --json` | marshal | FULL | canonical seat verified |
| `<path>` | hook/marshal/touched-files | FULL / TARGETED / GREP / INJECTED / NOT-READ / NOT-PRESENT | lines, chunks, command, or reason |
Allowed depth labels:
- `FULL`: the whole surface was read after compaction.
- `TARGETED`: selected ranges, head/tail, or named sections were read.
- `GREP`: search excerpts were read.
- `INJECTED`: content was present from hook, system, summary, or prior context, but not freshly read from disk.
- `NOT-READ`: the surface exists but was not read.
- `NOT-PRESENT`: the requested surface does not exist.
The proof must also include canonical identity, JSONL or restore packet path, active queue items, selected touched files, skipped files with rationale, blockers, and exact next action.
## Acceptance Rule
The target may say `awaiting marshal acceptance` or `blocked`. It must not claim `RESTORED`, resume implementation, review, release, architecture, or orchestration work until marshal acceptance is explicit.
Reject any proof that hides read depth, treats injection as a fresh read, omits queue reconciliation, or says "all docs read" without evidence.
Accept only when:
- The seat identity matches the target.
- JSONL or restore artifacts were used, or their absence is explicitly explained.
- Important recent-work files are either `FULL` or explicitly waived.
- `INJECTED`, `TARGETED`, `GREP`, `NOT-READ`, and `NOT-PRESENT` surfaces are named honestly.
- Active queue/session state is reconciled from durable evidence.
- The target did not resume real work before marshal acceptance.
## Marshal Follow-Up
Ask these before accepting a clean-sounding proof:
1. Which files were explicitly assigned, and which did you read in full after compaction?
2. Which files were only injected by hook, summary, system context, or prior context?
3. Which files were partial reads? Give line ranges or chunks.
4. Which touched-files entries were important, and which were skipped?
5. Did any read fail due to size? If yes, did you retry by chunk until complete?
6. Which queue items changed between checkpoint and now?
7. What would be unsafe for you to do before additional reads?
If a mandatory surface is partial or injected only, ask for a corrected audit before acceptance.
@@ -1,142 +0,0 @@
---
name: openrig-installer
description: Use when installing or upgrading OpenRig on a host, especially the V0.3.1 upgrade flow which includes the substrate-kernel → daemon-managed-kernel migration ceremony. Covers npm install, rig setup, rig daemon start (auto-boots kernel), and the canonical verification commands. NOT for ordinary kernel operation post-install — use openrig-operator skill for that.
metadata:
openrig:
maturity: L2
distribution_scope: shipped
source_evidence: V0.3.1 slice 05-kernel-rig-as-default IMPLEMENTATION-PRD §8 + OQ-A=B migration-ceremony decision
---
# OpenRig Installer
This skill teaches the operator agent how to install + upgrade
OpenRig cleanly. The V0.3.1 upgrade is the one with the migration
ceremony (substrate kernel → daemon-managed kernel); other upgrades
follow the same install / setup / start shape with no migration.
## Fresh install
```bash
npm install -g @openrig/cli
rig setup
```
`rig setup` is idempotent — safe to re-run if a step fails. It probes
host prereqs (tmux present, Node version, writable state path,
optional cmux), then invokes `rig daemon start` per the L2 factoring
(OQ-B). The daemon's start path auto-boots the kernel rig per the
V0.3.1 slice 05 kernel-rig-as-default logic.
## Verification after install
```bash
rig ps --nodes --rig kernel --json
```
Should report 4 members (advisor.lead + operator.agent +
operator.human + queue.worker) all ready. If a runtime is unavailable
the daemon picks the matching variant (`rig-claude-only.yaml` or
`rig-codex-only.yaml`); the topology stays the same shape with the
unavailable runtime omitted from the agent membership.
Entry point for chatting with the rig:
```bash
rig capture advisor-lead@kernel
```
Or click the CMUX button on the topology graph in the UI at
`/topology`.
## V0.3.1 upgrade ceremony (one-time migration)
Operators with a kernel spec stored outside the daemon-managed
location (any pre-V0.3.1 layout, typically wherever you kept your
own `rigs/kernel/` directory) must migrate to
`~/.openrig/specs/rigs/kernel/` as a one-time step during the V0.3.1
upgrade. The V0.3.1 upgrade IS the migration moment; it's not a
separate ceremony.
### Migration steps
1. **Snapshot first.** Take a snapshot of the running substrate
kernel so the migration is reversible:
```bash
rig snapshot kernel
```
2. **Stop the substrate kernel** (so the migration doesn't race a
running rig):
```bash
rig down kernel --snapshot
```
3. **Copy your existing kernel spec** into the daemon-managed
location:
```bash
mkdir -p ~/.openrig/specs/rigs/kernel
cp -r /path/to/old/kernel-spec/* ~/.openrig/specs/rigs/kernel/
```
4. **Upgrade the CLI + daemon**:
```bash
npm install -g @openrig/cli@0.3.1
rig daemon stop
rig daemon start
```
At this point the new daemon detects `~/.openrig/specs/rigs/kernel.yaml`
exists and skips the V0.3.1 builtin-kernel boot (the
already-managed-rig branch). The substrate-rooted topology
continues to run under daemon management.
5. **Verify** the migrated kernel matches expectations:
```bash
rig ps --nodes --rig kernel --json
```
6. **Decommission the old copy** (only after verifying the
daemon-managed copy works):
```bash
rm -rf /path/to/old/kernel-spec
```
### If migration fails
The pre-step snapshot is the rollback. `rig restore <snapshot-id>
--rig kernel` revives the prior topology. Then `npm install -g
@openrig/cli@0.3.0` rolls the CLI back and you can retry later.
## Operator agent's role in install + upgrade
The operator agent shepherds operators through these steps when the
user asks "how do I upgrade?" or "what's the V0.3.1 ceremony?". The
agent reads this skill on demand via `find-skills` — it does not
need to memorize the steps; it consults the canonical reference
(this file) and walks the operator through.
## 3-part error remediation
| What failed | Why it matters | Fix |
|---|---|---|
| `npm install -g @openrig/cli` permission denied | Global install needs writable npm prefix; macOS Homebrew default is usually writable; some custom Node installs aren't | Either fix the npm prefix permissions or use `sudo npm install -g`; check `npm config get prefix` |
| `rig setup` host prereq fail | tmux, Node version, or writable state path missing | `rig doctor` lists each missing piece + the install command; run those, retry `rig setup` |
| `rig daemon start` auth-block error | Neither Claude Code nor Codex authenticated; kernel rig requires at least one | Run `claude auth login` OR `codex login`; then retry `rig daemon start` |
| `rig daemon start` "kernel already managed" | An existing managed rig named `kernel` exists; the daemon skips builtin-boot per design | This is the expected path after migration — no action needed |
| `rig ps --rig kernel --json` returns empty | Daemon healthy but no kernel rig managed; likely the migration step copied content but did not boot | `rig up kernel` from inside `~/.openrig/specs/rigs/kernel/`; or invoke the daemon's cold-boot path explicitly |
## What this skill is NOT
- Not the canonical OpenRig command surface; that lives in the
`openrig-user` skill.
- Not the kernel-operating-procedure for post-install daily use;
that's `openrig-operator`.
- Not for project rigs; project rigs install themselves via `rig
up <spec>` after the kernel is in place.
@@ -86,10 +86,10 @@ Resolution order for workspace per-subdir keys: per-subdir override >
> set the per-subdir workspace overrides explicitly:
>
> ```bash
> rig config set workspace.slices_root /path/to/your/workspace/missions
> rig config set workspace.steering_path /path/to/your/workspace/STEERING.md
> rig config set workspace.progress_scan_roots 'work:/path/to/your/workspace,missions:/path/to/your/workspace/missions'
> rig config set workspace.field_notes_root /path/to/your/workspace/field-notes
> rig config set workspace.slices_root /Users/me/code/substrate/.../missions
> rig config set workspace.steering_path /Users/me/code/substrate/openrig-work/STEERING.md
> rig config set workspace.progress_scan_roots 'work:/Users/me/code/substrate/openrig-work,missions:/Users/me/code/substrate/openrig-work/missions'
> rig config set workspace.field_notes_root /Users/me/code/substrate/openrig-work/field-notes
> ```
>
> No restart needed for the CLI; for the daemon's reads to pick up
@@ -25,14 +25,9 @@ Default posture:
- Treat daemon `rig queue`, `rig stream`, `rig project`, `rig view`, `rig watchdog`, and
`rig workflow` as the product coordination surfaces when the active daemon is v0.2.0 or newer.
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) is the
canonical surface for queue routing since the 2026-05-11 host-CLI fix landed. Use
`rig queue create / handoff / update / show / list` for all substantive work.
- Use temporary substrate overlays such as `rigx queue`, `rigx stream`, `rigx project`, and
`rigx view-proto` only where the current OpenRig workstream explicitly says that legacy/control
layer is still in use. For queue specifically, `rigx queue` is recovery-only fallback;
qitems written via `rigx queue` are invisible to daemon-backed reads and break fleet-wide
routing discipline.
layer is still in use.
- If a daemon-backed coordination command fails, debug the command/runtime/schema edge directly;
do not assume the right workaround is to drop back to a config-layer primitive.
- Do not perform daemon stop/start, production DB copy/mutation, release, publish, or other
@@ -1,116 +0,0 @@
---
name: rig-lifecycle
description: Use when reasoning about the rig lifecycle operations family (create / start / stop / resume / restore / snapshot / release / unclaim / destroy), reading or trusting `rig ps` / lifecycle projections after recovery, or designing proof for a lifecycle scenario. Covers the 4 failure modes (auto-restore creates partial rig; projections report healthier than reality; provider auth treated as impl work; resume succeeds for one runtime fails another) plus the restore-honesty rule (failed resume is FAILED loudly — no auto fresh fallback).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Lifecycle Reboot/Recovery Scenario Matrix (Tier 1 complete; Tier 2
human-gated). Codex auth-refusal surfaces as attention_required.
sibling_skills:
- openrig-user
- openrig-operator
- seat-continuity-and-handover
transfer_test: pending
---
# Rig Lifecycle
The family of operations that **create, start, stop, resume, restore,
snapshot, release, unclaim, and destroy** OpenRig-managed topologies.
Includes the user story after reboot: **"bring my work back without
turning a clean recovery into a cleanup project."**
If lifecycle is brittle, every higher-level primitive inherits that
brittleness. Queue, workflow, seat continuity, cross-host operation,
and RSI all assume that rigs and seats can be restored into known
states.
## Use this when
- Operating `rig up / down / restore / resume / snapshot / release / unclaim / destroy`
- Reading `rig ps` after a reboot or recovery and deciding what to trust
- Designing proof for a lifecycle scenario (clean start / warm resume / host reboot / provider auth loss / partial boot / operator recovery)
- Reasoning about restore-outcome semantics (`resumed` / `rebuilt` / `fresh` / `failed` / `attention_required`)
- Auditing whether a lifecycle proof is in-process bedrock vs requires real reboot evidence
## Don't use this when
- The operation is single-command and deterministic — use `openrig-user` skill for CLI surface
- The work is operator-level configuration of OpenRig itself — use `openrig-operator`
- The work is rig spec authoring — use `openrig-architect`
## Failure modes (4)
1. **Auto-restore creates a partially restored rig that must be cleaned up before real recovery.** Partial restore looks like recovery but isn't; cleanup-before-recovery becomes the actual workload.
2. **`rig ps` or lifecycle projections report a healthier state than the runtime actually has.** Projections are summaries; the runtime is truth. Don't trust projections silently.
3. **Provider auth is unavailable after reboot and the system treats that as implementation work** instead of a human/environment decision. Auth issues are environmental; route to human.
4. **Resume succeeds for one runtime/provider but fails for another scenario that was never tested.** Per-runtime parity assumptions break silently; matrix proof catches them.
## Restore-honesty rule (load-bearing)
**Failed resume is FAILED loudly.** No automatic fresh fallback. Fresh
launch is **explicit follow-up only.** This is enforced architecturally
at the daemon level (per `architecture.md` §7 rule 15).
The locked restore-outcome vocabulary:
| Outcome | Meaning |
|---|---|
| `resumed` | Native runtime resumed the same conversation |
| `rebuilt` | New process assembled from artifacts (`session_source: mode: rebuild`) |
| `fresh` | New process with no prior continuity |
| `failed` | Restore attempted and failed; no automatic fallback |
| `attention_required` | Recoverable blocker (provider auth refused, etc.); needs operator action |
| `n-a` | Not applicable (terminal nodes, etc.) |
Codex auth-refusal returns `attention_required` (recoverable); Claude
`looksLikeClaudeLoginPrompt` returns `failed/login_required` (terminal).
Cross-runtime alignment is an open follow-up question.
## Proof standard
Lifecycle proof should include **real reboot or VM-reboot evidence**,
not only daemon-unit evidence. The minimum useful matrix covers:
| Scenario | What it proves |
|---|---|
| Clean start | Boot from spec into known state |
| Warm resume | `rig down` → `rig up <name>` resumes seats |
| Host reboot / tmux socket absence | Recovery from lost tmux connection |
| Provider auth loss | Codex/Claude auth refusal handled honestly |
| Partial boot / partial failure | Some seats up, some failed; honest reporting |
| Intentional operator recovery | Operator-initiated restore from snapshot |
Tier 1: in-process bedrock (daemon-unit evidence). Tier 2: real reboot
or disposable Tart VM. Tier 1 alone is not lifecycle proof; it's
bedrock.
## Default policy is part of the primitive
**Treat default policy as part of the primitive, not an afterthought.**
Lifecycle defaults that are too optimistic for the reliability level
actually proven create silent harm:
- `auto-restore` defaults that mask failure modes
- Implicit fresh-fallback that hides resume failure
- Permissive verification that doesn't distinguish honest success from "appeared to work"
## Total-Host Restore Product Rail v2
The rail sequences restore truth into **four rungs**:
- `fully_restored` is a **Rung 3 execution rollup**
- "fully back" is **reserved for Rung 4 only**
Naming discipline matters. "Fully restored" and "fully back" are
different claims; don't conflate.
## See also
- `openrig-user` skill — CLI surface for `rig up / down / restore / etc.`
- `openrig-operator` skill — operator-level discipline for OpenRig itself
- `seat-continuity-and-handover` skill — occupant-creation modes for restore (`resume` / `rebuild` / `fresh` / `failed`)
- `openrig/docs/as-built/architecture.md` (product reference doc) — daemon enforcement of restore-honesty rule
@@ -115,6 +115,7 @@ The development pod has access to browser automation and structured dogfood test
- **`agent-browser`** — browser automation CLI. Navigate to the daemon UI, snapshot interactive elements, take annotated screenshots, record repro videos. Use `agent-browser open <url>`, `agent-browser snapshot -i`, `agent-browser screenshot --annotate`.
- **`dogfood`** — structured exploratory testing workflow. Produces a report with screenshots, repro videos, and step-by-step evidence for every finding.
- **`containerized-e2e`** — Docker-based clean-install testing. Simulates a fresh user environment.
QA typically drives browser and dogfood testing, but both impl and QA should know these tools exist and can use them. When dogfooding UI:
1. Load `/agent-browser` and `/dogfood`
@@ -183,7 +183,7 @@ For destructive operations (git push, rm, daemon stop, npm publish): DO NOT auto
- After being told to slow down, over-corrects to "wait for permission for everything"
- Compaction is catastrophic — full context loss, needs preparation
- After compaction: must re-read ALL skills from disk (skill names survive in system reminders but content is truncated)
- After compaction, require marshal acceptance before treating a `RESTORED` claim as real — quiz the recovered seat on asked-vs-read depth before resuming work
- For Claude compact-in-place, use `claude-compact-in-place` and require marshal acceptance before treating a `RESTORED` claim as real
### Codex agents (QA, peer, R2)
- Self-manages its own context window — do NOT intervene based on context percentage
@@ -222,7 +222,7 @@ Before any destructive operation: "If this goes wrong, can I undo it?" If no, co
4. Read your restore file and session log if available
5. Ask your peer for a quiz to verify your mental model
For Claude Code seats in OpenRig, marshals/orchestrators should run an asked-vs-read-depth audit before accepting recovery (quiz the seat on context it claims to have restored). Preserve the Codex boundary: do not intervene on Codex context percentage or apply Claude compact-in-place by default.
For Claude Code seats in OpenRig, marshals/orchestrators should use `claude-compact-in-place` for the asked-vs-read-depth audit before accepting recovery. Preserve the Codex boundary: do not intervene on Codex context percentage or apply Claude compact-in-place by default.
## What you do not do
@@ -123,7 +123,7 @@ EVALEOF
## Authentication: What Actually Works
**`--session-name` (state save/restore) does NOT work for all apps.** It saves cookies and localStorage, but apps using HTTP-only cookies, server-side sessions, or complex auth flows may not persist. Tested and failed on: example-app-1 (Example Service One / localhost:8083).
**`--session-name` (state save/restore) does NOT work for all apps.** It saves cookies and localStorage, but apps using HTTP-only cookies, server-side sessions, or complex auth flows may not persist. Tested and failed on: tbbc (The Big Blue Cloud / localhost:8083).
**`--profile` (persistent Chrome profile) is the reliable approach.** It preserves everything - cookies, localStorage, IndexedDB, cache, service workers. This is what actually works for real apps.
@@ -131,10 +131,10 @@ EVALEOF
| Profile | Service | URL | Command |
|---------|---------|-----|---------|
| `example-app-1` | Example Service One | `http://localhost:8083` | `agent-browser --profile ~/.agent-browser/profiles/example-app-1 open http://localhost:8083` |
| `localhost-3000` | Example Service Two (Clerk auth) | `http://localhost:3000` | `agent-browser --profile ~/.agent-browser/profiles/localhost-3000 open http://localhost:3000` |
| `local-app-email-profile` | Example Service Three (email login) | `http://localhost:3010` | `agent-browser --profile ~/.agent-browser/profiles/local-app-email-profile open http://localhost:3010` |
| `local-app-google-profile` | Example Service Three (Google auth) | `http://localhost:3010` | See Google OAuth note below |
| `tbbc` | The Big Blue Cloud | `http://localhost:8083` | `agent-browser --profile ~/.agent-browser/profiles/tbbc open http://localhost:8083` |
| `localhost-3000` | Specright Formulate (Clerk auth) | `http://localhost:3000` | `agent-browser --profile ~/.agent-browser/profiles/localhost-3000 open http://localhost:3000` |
| `localhost-3010-email` | Smart Report Writer (email login) | `http://localhost:3010` | `agent-browser --profile ~/.agent-browser/profiles/localhost-3010-email open http://localhost:3010` |
| `localhost-3010-google` | Smart Report Writer (Google auth) | `http://localhost:3010` | See Google OAuth note below |
### Google OAuth Profiles
@@ -142,7 +142,7 @@ Google blocks sign-in from the bundled Chromium ("This browser or app may not be
```bash
agent-browser \
--profile ~/.agent-browser/profiles/local-app-google-profile \
--profile ~/.agent-browser/profiles/localhost-3010-google \
--executable-path "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--args "--disable-blink-features=AutomationControlled" \
open http://localhost:3010
@@ -1,40 +0,0 @@
---
skill: agent-browser
openrig-relationship: vendored-supplemented
---
# OpenRig and this skill
## Origin
- **Upstream**: Vercel agent-browser CLI (https://github.com/vercel/agent-browser)
- **Vendoring pattern**: `add-supplementary-files`
- **Last upstream check**: 2026-05-13 (initial relationship declaration)
## How OpenRig uses this skill
OpenRig ships `agent-browser` so any agent dispatched by an OpenRig topology can drive a real browser (snapshots, form fills, screenshots, scraping, web-app testing) without operators having to separately install a browser-automation skill. It's referenced from any agent profile whose role includes web work.
## OpenRig-specific modifications
This is the canonical example of the **add-supplementary-files** pattern (see `writing-skills-for-openrig` SKILL.md §"Vendored skills"). The upstream skill is kept structurally intact; OpenRig adds a companion file that captures local-use insights without polluting the upstream content.
| Surface | What changed | Why |
|---|---|---|
| `SKILL.md` body | A short "## Local Dev Insights" section was added near the end with an `**IMPORTANT:** Read LOCAL-INSIGHTS.md` pointer. | Without the pointer, agents loading the skill wouldn't know the companion file exists. The pointer is the only structural modification needed for the supplementary pattern to be discoverable. |
| `LOCAL-INSIGHTS.md` (new sibling, ~189 lines) | Field-tested gotchas, command-compatibility matrices, corrections discovered through hands-on use that the upstream skill doesn't cover. | E.g., not all `get` subcommands accept `@refs` — `get text @e1` works but `get html @e1` fails silently. The compatibility matrix saves agents from the most common confusion class. |
Nothing else in `SKILL.md` diverges from upstream content as of the last upstream check.
## Companion files
- `LOCAL-INSIGHTS.md` — load-bearing supplementary content. The SKILL.md body explicitly points readers here for hands-on gotchas the upstream skill doesn't cover.
## When to re-sync upstream
Watch for new `agent-browser` CLI releases (Vercel). When the command surface changes meaningfully:
1. Re-sync `SKILL.md` body from upstream, preserving the "Local Dev Insights" pointer section.
2. Update `LOCAL-INSIGHTS.md` compatibility matrix to reflect new behavior (some `get` subcommands may gain `@ref` support, breaking the current matrix).
3. Bump `last_upstream_check` in `SKILL.md` frontmatter.
4. Note any structural shape changes in `divergence_notes`.
@@ -2,16 +2,6 @@
name: agent-browser
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction.
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
metadata:
openrig:
vendored_from: "Vercel agent-browser (https://github.com/vercel/agent-browser)"
vendoring_pattern: add-supplementary-files
last_upstream_check: "2026-05-13 (initial vendoring declaration)"
divergence_notes: |
Upstream SKILL.md content tracks upstream; a small "Local Dev Insights"
pointer section was added near the end of the body to surface the sibling
LOCAL-INSIGHTS.md companion file. See OPENRIG.md for the full OpenRig
relationship to this skill.
---
# Browser Automation with agent-browser
@@ -1,48 +0,0 @@
---
skill: brainstorming
openrig-relationship: vendored-modified
---
# OpenRig and this skill
## Origin
- **Upstream**: Obra Superpowers (https://github.com/obra/superpowers)
- **Vendoring pattern**: `modify-the-file`
- **Last upstream check**: 2026-05-13 (diff against plugin source pulled 2026-05-11)
## How OpenRig uses this skill
`brainstorming` is the "intent → design" front of the OpenRig planning chain. Agents use it before any creative or implementation work — to refine user intent into a shareable design doc that downstream skills (`writing-plans`, then `executing-plans`) consume.
## OpenRig-specific modifications
| Surface | Upstream | OpenRig |
|---|---|---|
| Visual Companion offer step | Standalone step #2 in the flow — "offer Visual Companion in its own message before clarifying questions" | **Removed.** OpenRig agents typically operate without a separate visual-companion mode; visual design work routes through dedicated design-pod seats. |
| Spec self-review step | Step #7 — inline self-review for placeholders/contradictions/scope | **Removed.** Self-review is handled at the rig/orchestration layer (driver/guard pattern + review-lead). |
| User reviews written spec | Step #8 — pause for user to review the spec file | **Removed.** OpenRig's founder-walk and orchestration gates already cover this checkpoint at a higher level. |
| Spec save path | `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` | `docs/plans/YYYY-MM-DD-<topic>-design.md` (matches OpenRig's `docs/plans/` convention) |
| Process flow diagram | Includes Visual Companion + Spec self-review nodes | Simplified to remove those nodes; edges rewired |
## Why these modifications (inferred)
- **Multi-platform / multi-step gates removed**: OpenRig's rig-level orchestration (driver → guard → qa, founder-walks) already provides the checkpoint structure that the upstream skill builds inline. Keeping those steps inside the skill double-gates the work.
- **`docs/plans/` path**: OpenRig's project structure uses `docs/plans/` as the canonical home for design + implementation plan documents (matches `writing-plans` modification — both are co-ordinated).
## Companion files
None.
## When to re-sync upstream
Watch upstream for:
- New phases or checkpoints in the brainstorming flow that OpenRig might want to adopt (rare; the skill is structurally stable in upstream).
- Changes to the brainstorming-to-writing-plans handoff (must stay coordinated with the `writing-plans` modifications).
When upstream changes:
1. Pull non-conflicting changes into the OpenRig copy.
2. Preserve the simplified 6-step flow and the `docs/plans/` save path unless founder direction reverses.
3. Bump `last_upstream_check` in `SKILL.md` frontmatter.
@@ -1,17 +1,6 @@
---
name: brainstorming
description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation."
metadata:
openrig:
vendored_from: "Obra Superpowers (https://github.com/obra/superpowers)"
vendoring_pattern: modify-the-file
last_upstream_check: "2026-05-13 (diff against plugin source pulled 2026-05-11)"
divergence_notes: |
Simplified the process flow. Removed the Visual Companion offer step,
the Spec self-review step, and the User-reviews-spec step (those gates
are handled at the rig/orchestration layer in OpenRig). Changed plan-
doc save path from docs/superpowers/specs/ to docs/plans/.
See OPENRIG.md for full divergence details.
---
# Brainstorming Ideas Into Designs
@@ -0,0 +1,256 @@
---
name: containerized-e2e
description: Run end-to-end dogfood tests inside Docker containers to simulate real user experiences. Use when you need to verify install paths, control plane functionality, UI rendering, or packaging correctness in a clean environment. Triggers include "containerized test", "Docker dogfood", "clean install test", "e2e in container", or testing that requires a fresh environment without dev-mode shortcuts.
allowed-tools: Bash(docker:*), Bash(agent-browser:*), Bash(npx agent-browser:*)
---
# Containerized E2E Testing
Run OpenRig (or any npm-installable CLI + web UI project) through end-to-end testing inside Docker containers, simulating real user install and usage scenarios.
## When to Use
- Verifying the npm install path works from a packed tarball
- Testing control plane functionality without live agent runtimes
- Checking UI rendering via agent-browser in a clean environment
- Regression testing after packaging changes
- Phase boundary acceptance gates
## When NOT to Use
- Testing live agent behavior (send/capture/broadcast, whoami from inside an agent, transcript capture) — these need real claude-code/codex runtimes on the host
- Quick feedback during active TDD cycles — too slow for the edit/test loop
## Prerequisites
- Docker installed and running
- The repo builds successfully (`npm run build` for all workspaces)
- `agent-browser` skill loaded (for UI verification commands)
## Testing Personas
### Fresh User
A brand new user who has never installed OpenRig. Exercises the first-run experience.
- Empty `~/.openrig/` directory
- No existing rigs, snapshots, or specs
- Tests: install, daemon start, preflight, doctor, first rig up, UI renders
### Mature User
A user with existing OpenRig state — rigs, snapshots, library additions, transcripts.
- Pre-populated `~/.openrig/` via Docker volume persistence
- Build this state organically: run the fresh-user tests first, and the volume accumulates real state
- Tests: restore from existing snapshots, expand existing rigs, library with user-added specs, upgrade-path behaviors
To set up a mature user volume:
```bash
# Create a named volume
docker volume create openrig-mature-user
# Run fresh-user tests with the volume mounted
docker run -it --rm --shm-size=1g \
-v openrig-mature-user:/root/.openrig \
-v /tmp/openrig-e2e-artifacts:/artifacts \
openrig-e2e
# The volume now has real state from actual rig commands
# Subsequent runs with the same volume simulate a mature user
```
## Workflow
### 1. Build the E2E Image
Use the provided build script or do it manually:
```bash
# Using the build script (recommended)
bash {SKILL_DIR}/scripts/build-e2e-image.sh /path/to/repo
# Or manually:
cd /path/to/repo
npm run build --workspace @openrig/daemon
npm run build --workspace @openrig/ui
npm run build --workspace @openrig/cli
bash scripts/build-package.sh
cd packages/cli && npm pack --pack-destination /tmp/e2e-build
cp {SKILL_DIR}/scripts/Dockerfile /tmp/e2e-build/
mv /tmp/e2e-build/openrig-cli-*.tgz /tmp/e2e-build/openrig-cli.tgz
cd /tmp/e2e-build && docker build -t openrig-e2e:latest .
```
### 2. Start the Container
```bash
# Fresh user (ephemeral state)
docker run -d --rm --name openrig-e2e \
--shm-size=1g \
-v /tmp/openrig-e2e-artifacts:/artifacts \
openrig-e2e sleep infinity
# Mature user (persistent volume)
docker run -d --rm --name openrig-e2e \
--shm-size=1g \
-v openrig-mature-user:/root/.openrig \
-v /tmp/openrig-e2e-artifacts:/artifacts \
openrig-e2e sleep infinity
```
**Important:** Always use `--shm-size=1g` for Chromium stability during browser tests.
### 3. Run Tests Inside the Container
Execute commands via `docker exec`:
```bash
# Start the daemon
docker exec openrig-e2e rig daemon start
# Run preflight and doctor
docker exec openrig-e2e rig preflight --json
docker exec openrig-e2e rig doctor --json
# Copy test specs into the container
docker cp {SKILL_DIR}/templates/control-plane-test.yaml openrig-e2e:/workspace/
docker cp {SKILL_DIR}/templates/expansion-pod-fragment.yaml openrig-e2e:/workspace/
docker cp {SKILL_DIR}/templates/expansion-collision-fragment.yaml openrig-e2e:/workspace/
# Launch a rig
docker exec openrig-e2e rig up /workspace/control-plane-test.yaml --json
# Check topology
docker exec openrig-e2e rig ps --json
docker exec openrig-e2e rig ps --nodes --json
```
### 4. Browser Testing Inside the Container
agent-browser runs inside the container via `docker exec`:
```bash
# Open the daemon UI
docker exec openrig-e2e agent-browser open http://127.0.0.1:7433
docker exec openrig-e2e agent-browser wait --load networkidle
# Inspect interactive elements
docker exec openrig-e2e agent-browser snapshot -i
# Capture screenshots
docker exec openrig-e2e agent-browser screenshot /artifacts/screenshots/dashboard.png
docker exec openrig-e2e agent-browser screenshot --annotate /artifacts/screenshots/dashboard-annotated.png
# Navigate and verify specific surfaces
docker exec openrig-e2e agent-browser click @e4 # Open specs drawer (ref from snapshot)
docker exec openrig-e2e agent-browser wait 1000
docker exec openrig-e2e agent-browser screenshot /artifacts/screenshots/specs-drawer.png
```
**ARM64 note:** The Dockerfile uses Debian's system chromium instead of Chrome for Testing, which is unavailable on Linux ARM64. The environment variables `AGENT_BROWSER_EXECUTABLE_PATH` and `AGENT_BROWSER_ARGS` are set in the image.
### 5. Test Scenarios
#### Control Plane Lifecycle
```bash
# Launch the multi-pod test spec
docker exec openrig-e2e rig up /workspace/control-plane-test.yaml --json
RIG_ID=$(docker exec openrig-e2e rig ps --json | jq -r '.[0].rigId')
# Verify topology
docker exec openrig-e2e rig ps --nodes --json
# Expand with a new pod
docker exec openrig-e2e rig expand "$RIG_ID" /workspace/expansion-pod-fragment.yaml --json
# Verify expansion
docker exec openrig-e2e rig ps --nodes --json
# Test validation rejection (colliding namespace)
docker exec openrig-e2e rig expand "$RIG_ID" /workspace/expansion-collision-fragment.yaml --json
# Should fail with namespace collision error, rig unchanged
# Snapshot
docker exec openrig-e2e rig down "$RIG_ID" --snapshot --json
# Restore and verify
SNAPSHOT_ID=$(docker exec openrig-e2e rig snapshot list "$RIG_ID" | awk 'NR==2 {print $1}')
docker exec openrig-e2e rig restore "$SNAPSHOT_ID" --rig "$RIG_ID"
# Export
docker exec openrig-e2e rig export "$RIG_ID" -o /artifacts/captures/exported-rig.yaml
```
#### UI Verification
```bash
# After launching a rig, verify the graph renders
docker exec openrig-e2e agent-browser open http://127.0.0.1:7433
docker exec openrig-e2e agent-browser wait --load networkidle
docker exec openrig-e2e agent-browser snapshot -i
docker exec openrig-e2e agent-browser screenshot --annotate /artifacts/screenshots/graph-with-rig.png
# Open drawers and verify content
docker exec openrig-e2e agent-browser snapshot -i # Get fresh refs
# Click through specs drawer, discovery drawer, rig detail, etc.
# Take screenshots at each step
```
### 6. Cleanup
```bash
docker exec openrig-e2e rig daemon stop
docker exec openrig-e2e agent-browser close
docker stop openrig-e2e
```
### 7. Write Report
Copy the report template and fill it in:
```bash
cp {SKILL_DIR}/templates/e2e-report-template.md /tmp/openrig-e2e-artifacts/report.md
```
Fill in results as tests complete — do not batch findings for the end.
## Test Spec Templates
| Template | Purpose |
|----------|---------|
| `templates/control-plane-test.yaml` | Multi-pod terminal-only rig spec (backend + frontend, 3 nodes, cross-pod edges) |
| `templates/expansion-pod-fragment.yaml` | Pod fragment for expansion happy path (ops pod with cross-pod edge) |
| `templates/expansion-collision-fragment.yaml` | Pod fragment that intentionally collides — for validation rejection testing |
| `templates/e2e-report-template.md` | Structured test report template |
## Scripts
| Script | Purpose |
|--------|---------|
| `scripts/build-e2e-image.sh` | Build the Docker image from the repo (builds packages, packs tarball, builds image) |
| `scripts/Dockerfile` | The proven Dockerfile — Node 22, tmux, system Chromium, agent-browser, OpenRig CLI |
## Limitations
- **No live agent runtimes.** The container does not include claude-code or codex. Use `runtime: terminal` specs for control plane testing. Test live agent behavior on the host.
- **ARM64 browser workaround.** Chrome for Testing is unavailable on Linux ARM64. The Dockerfile uses Debian chromium. This is transparent to agent-browser commands.
- **No GPU/display.** All browser testing is headless. Screenshots and videos capture what a user would see, but there is no visible browser window.
## Combining with Host-Based Dogfood
For complete coverage, use both approaches:
| What to test | Where | Tool |
|-------------|-------|------|
| Install path, packaging | Container | This skill |
| CLI commands, lifecycle | Container | This skill |
| UI rendering, drawers | Container | This skill + agent-browser |
| Validation/error paths | Container | This skill |
| Live agent startup | Host | QA with /dogfood skill |
| Communication (send/capture) | Host | QA with live agents |
| Whoami from inside agent | Host | QA with live agents |
| Transcript capture | Host | QA with live agents |
| Chatroom with real participants | Host | QA with live agents |
@@ -0,0 +1,39 @@
# OpenRig Containerized E2E Testing Image
#
# Provides: Node 22, tmux, agent-browser (with system Chromium), OpenRig CLI
# Usage:
# 1. Build the CLI tarball: cd <repo>/packages/cli && npm pack
# 2. Copy tarball to build context as openrig-cli.tgz
# 3. docker build -t openrig-e2e .
# 4. docker run -it --rm --shm-size=1g -v /tmp/e2e-artifacts:/artifacts openrig-e2e
#
# ARM64 note: Chrome for Testing is unavailable on Linux ARM64.
# This image uses Debian's system chromium instead, pointed via
# AGENT_BROWSER_EXECUTABLE_PATH.
FROM node:22-bookworm
ENV DEBIAN_FRONTEND=noninteractive
ENV AGENT_BROWSER_ARGS=--no-sandbox
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV OPENRIG_HOME=/root/.openrig
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
chromium \
curl \
git \
procps \
tmux \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g agent-browser
COPY openrig-cli.tgz /tmp/openrig-cli.tgz
RUN npm install -g /tmp/openrig-cli.tgz \
&& rm /tmp/openrig-cli.tgz
WORKDIR /workspace
CMD ["/bin/bash"]
@@ -0,0 +1,37 @@
#!/bin/bash
# Build the OpenRig containerized E2E testing image.
#
# Usage: ./build-e2e-image.sh [repo-root]
# repo-root defaults to the current directory.
#
# Produces: Docker image tagged openrig-e2e:latest
set -euo pipefail
REPO_ROOT="${1:-.}"
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_CTX="/tmp/openrig-e2e-build"
echo "=== Building OpenRig packages ==="
cd "$REPO_ROOT"
npm run build --workspace @openrig/daemon
npm run build --workspace @openrig/ui
npm run build --workspace @openrig/cli
bash scripts/build-package.sh
echo "=== Packing CLI tarball ==="
mkdir -p "$BUILD_CTX"
cd "$REPO_ROOT/packages/cli"
npm pack --pack-destination "$BUILD_CTX"
echo "=== Preparing Docker build context ==="
cp "$SKILL_DIR/scripts/Dockerfile" "$BUILD_CTX/Dockerfile"
# Rename tarball to a stable name
mv "$BUILD_CTX"/openrig-cli-*.tgz "$BUILD_CTX/openrig-cli.tgz"
echo "=== Building Docker image ==="
cd "$BUILD_CTX"
docker build -t openrig-e2e:latest .
echo "=== Done ==="
echo "Run: docker run -it --rm --shm-size=1g -v /tmp/openrig-e2e-artifacts:/artifacts openrig-e2e"
@@ -0,0 +1,40 @@
version: "0.2"
name: control-plane-test
summary: >
Multi-pod terminal-only topology for containerized control-plane testing.
Exercises pods, cross-pod edges, expansion targets, and snapshot/restore
without requiring live coding runtimes.
pods:
- id: backend
label: Backend Team
members:
- id: api
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
cwd: /tmp
- id: db
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
cwd: /tmp
edges:
- kind: delegates_to
from: api
to: db
- id: frontend
label: Frontend Team
members:
- id: ui
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
cwd: /tmp
edges: []
edges:
- kind: delegates_to
from: frontend.ui
to: backend.api
@@ -0,0 +1,94 @@
# Containerized E2E Test Report
Date: {{DATE}}
Persona: {{PERSONA}}
Image: openrig-e2e:latest
## Summary
- Tests run: {{TOTAL}}
- Passed: {{PASSED}}
- Failed: {{FAILED}}
- Skipped: {{SKIPPED}}
## Environment
- Node: {{NODE_VERSION}}
- tmux: {{TMUX_VERSION}}
- Chromium: {{CHROMIUM_VERSION}}
- OpenRig CLI: {{RIG_VERSION}}
- Platform: {{PLATFORM}}
## Test Results
### Install & Boot
| Test | Result | Notes |
|------|--------|-------|
| npm install -g | | |
| rig daemon start | | |
| rig preflight | | |
| rig doctor | | |
| UI loads in browser | | |
### Rig Lifecycle
| Test | Result | Notes |
|------|--------|-------|
| rig up (terminal-only spec) | | |
| rig ps / rig ps --nodes | | |
| Graph renders in browser | | |
| rig down --snapshot | | |
| rig restore | | |
| Restored nodes match | | |
### Expansion
| Test | Result | Notes |
|------|--------|-------|
| rig expand (happy path) | | |
| Graph updates after expand | | |
| ps --nodes shows new nodes | | |
| Expand with collision (rejected) | | |
| Rig unchanged after rejection | | |
### Snapshot/Restore with Expansion
| Test | Result | Notes |
|------|--------|-------|
| Snapshot captures expanded pods | | |
| Restore brings back expanded pods | | |
| Cross-pod edges survive restore | | |
| Export includes expanded topology | | |
### CLI Surface
| Test | Result | Notes |
|------|--------|-------|
| rig specs ls | | |
| rig specs show | | |
| rig config | | |
| rig whoami (daemon down) | | |
| rig export | | |
### UI Surface (agent-browser)
| Test | Result | Notes |
|------|--------|-------|
| Dashboard renders | | |
| Explorer sidebar | | |
| Specs drawer opens | | |
| Discovery drawer opens | | |
| System drawer opens | | |
| Rig detail drawer | | |
| Node detail in graph | | |
## Bugs Found
(Append each bug as discovered — do not batch)
## Artifacts
- Screenshots: /artifacts/screenshots/
- Videos: /artifacts/videos/
- CLI transcript: /artifacts/cli-transcript.txt
@@ -0,0 +1,13 @@
# This fragment intentionally collides with the existing 'backend' pod.
# Use it to verify that expansion validation rejects namespace collisions
# and leaves the rig unchanged.
pod:
id: backend
label: Colliding Pod
members:
- id: worker
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
cwd: /tmp
edges: []
@@ -0,0 +1,14 @@
pod:
id: ops
label: Operations
members:
- id: monitor
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
cwd: /tmp
edges: []
crossPodEdges:
- kind: delegates_to
from: ops.monitor
to: backend.api
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more