Compare commits

...
Author SHA1 Message Date
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
459 changed files with 72740 additions and 1681 deletions

No files matched your search

+10 -1
View File
@@ -3,9 +3,14 @@ dist/
.DS_Store
*.log
# All docs are internal except as-built
# All docs are internal except as-built and reference
docs/*
!docs/as-built/
!docs/reference/
!docs/releases/
!docs/releases/**
notes/*
# Local environment
.env
@@ -16,6 +21,10 @@ docs/*
*.sqlite-wal
*.sqlite.pre-reboot-backup
.openrig/
.claude
.agents
.openrig
.worktrees/
# Local demo proof artifacts
demo/proof/
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Mike Schwarz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+174
View File
@@ -0,0 +1,174 @@
# OpenRig
Open source multi-agent harness for coding teams.
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
![OpenRig UI](https://openrig.dev/screenshots/remotion/hero-ui-workspace.png)
## Quick Start
```bash
# Install
npm install -g @openrig/cli
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
rig setup
# Boot the demo rig (3 pods, 8 nodes, mixed runtimes)
rig up demo
# Open the UI
rig ui open
```
After the demo boots, open the UI and click **orch1.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
## Launch Walkthrough
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
### 1. Open the demo rig
When the UI opens, the explorer is already visible on the left. Click the `demo` rig to load its live topology.
![Step 1: Click the demo rig in the explorer.](assets/ui/screenshots/launch-happy-path/guide-step-01-open-demo.png)
### 2. Click `CMUX` on `orch1.lead`
Once the topology loads, go to the `orch1.lead` node and click its `CMUX` button. That opens the orchestrator terminal directly.
![Step 2: Click CMUX on orch1.lead in the topology.](assets/ui/screenshots/launch-happy-path/guide-step-02-open-cmux.png)
> From here just talk to the orchestrator and tell it what you want it to build...
## What It Does
OpenRig is a multi-agent harness — it manages the system that coding agents form when you run them together. Not the agents themselves, but the team they create: which sessions are running, how they relate, how to recover after a reboot, and how to stop it from becoming terminal sprawl.
- **Define** topologies in YAML (RigSpec) with pods, edges, and continuity policies
- **Boot** everything with `rig up` — tmux sessions, harnesses, startup files, readiness checks
- **See** the topology in a live graph with explorer, node detail, and system log
- **Discover** existing Claude Code and Codex sessions in tmux and adopt them into a managed rig
- **Snapshot** the full topology on `rig down`, restore by name with `rig up <name>`
- **Communicate** across agents with `rig send`, `rig broadcast`, and `rig chatroom`
- **Evolve** running topologies with `rig expand`, `rig shrink`, `rig launch`, `rig remove`
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
## Demo Rig
OpenRig ships with a demo rig you can boot in seconds:
```bash
rig specs preview demo
```
```
demo (rig, pod_aware)
Launch-grade starter: a stable full product squad with two
orchestrators, implementation, QA, design, and two independent
reviewers.
Pod: orch1 (2 members)
lead — claude-code
peer — codex
Pod: dev1 (3 members)
impl — claude-code
qa — codex
design — claude-code
Pod: rev1 (2 members)
r1 — claude-code
r2 — codex
```
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, `product-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
Browse the library: `rig specs ls`
## How It Works
OpenRig is a local daemon + CLI + MCP server + React UI, built on tmux.
```
CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services (52)
|
SQLite + tmux + runtime adapters
```
- **CLI**: 40+ commands designed for both humans and agents. Every mutating command ends with what happened, current state, and next action.
- **UI**: Explorer sidebar, topology graph with pod grouping, node detail panel, system log, chatroom.
- **MCP**: 17 tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
- **Runtimes**: Claude Code, Codex, and terminal nodes. Adapters for Pi and OpenCode in development.
## Key Concepts
- **RigSpec**: Declarative multi-agent harness definition in YAML. Pods, members, edges, continuity policies, culture file.
- **AgentSpec**: Reusable agent blueprint with skills, guidance, hooks, profiles, and startup contracts.
- **Pod**: Bounded context group. Agents in a pod share memory and can maintain each other's context.
- **Discovery**: `rig discover` fingerprints existing tmux sessions. `rig adopt` brings them under management.
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes (resumed, fresh, or failed).
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines.
- **Culture**: CULTURE.md sets coordination norms for the group. Research rigs get exploratory culture. Implementation rigs get conservative, trust-but-verify culture.
## Agent-Managed Software
A rig can package actual software alongside the agents that manage it. The shipped example is `secrets-manager`: a HashiCorp Vault instance operated by a specialist agent.
```bash
rig up secrets-manager
rig env status secrets-manager
rig send vault-specialist@secrets-manager "Check Vault health and report status." --verify
```
Requires Docker for service-backed rigs.
## Requirements
- Node.js 20, 22, or 24 (even-numbered LTS releases; odd releases like 25 lack native addon prebuilds)
- tmux
Optional:
- cmux for `Open CMUX` node surface controls
- Docker for service-backed rigs and managed apps
## Setup and Troubleshooting
- `rig setup` attempts core machine preparation: tmux, cmux, Claude Code, Codex, and tmux defaults. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
- `rig setup --full` attempts a broader operator workstation setup (jq, gh) on top of core.
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes.
Both commands support `--json` for agent-driven workflows.
Managed runtime boot (during `rig up`) may modify runtime config for core bootstrap and spec-selected runtime resources. `rig setup` discloses these paths so agents know what may be changed:
- global Claude: `~/.claude/settings.json` for minimal OpenRig command allowlisting
- global Claude state: `~/.claude.json` for managed workspace trust and onboarding completion
- project Claude: `.claude/settings.local.json` for context collector/activity hooks and selected `claude_settings_fragment` resources
- project Claude MCP: `.mcp.json` for selected `claude_mcp_fragment` resources
- global Codex: `~/.codex/config.toml` for workspace trust and selected `codex_config_fragment` resources
Already-running adopted sessions may need restart before they pick up newly written runtime config.
**For agents:** Ask the user whether they want core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) before choosing the invocation. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues.
## Comparison with Claude Managed Agents
Anthropic shipped Claude Managed Agents — a cloud-hosted, Claude-only runtime at $0.08/session-hour. OpenRig is the local side: open source, cross-harness, runs on your machine, costs nothing.
[Full comparison](https://openrig.dev/compare/claude-managed-agents)
## Links
- **Website**: [openrig.dev](https://openrig.dev)
- **Blog**: [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs)
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
- **Twitter**: [@_feralmachine](https://twitter.com/_feralmachine)
## License
Apache 2.0
@@ -0,0 +1,24 @@
# Launch Happy-Path Screenshot Set
Source capture:
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
- host-forwarded UI: `http://127.0.0.1:17439`
- guest daemon: `192.168.64.9:7433`
Purpose:
- README walkthrough
- website/docs reuse
- launch gate evidence bundle
Annotated README walkthrough:
- `guide-step-01-open-demo.png` — click the `demo` rig in the explorer
- `guide-step-02-open-cmux.png` — click the `CMUX` button on `orch1.lead` in the topology
Archived proof/source bundle:
- raw proof captures and guide source were moved out of the repo to:
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
Notes:
- The README should use the annotated walkthrough files.
- The raw captures are still the fidelity record for proof and future reuse, just not inside the product repo.
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+1 -1
View File
@@ -62,7 +62,7 @@ pods:
files: []
actions:
- type: send_text
value: "rigged status"
value: "rig status"
phase: after_ready
idempotent: true
applies_on: [fresh_start]
+7 -7
View File
@@ -10,11 +10,11 @@ echo ""
# 0. Start or reuse daemon
echo "Step 0: Ensure daemon is running..."
if rigged daemon start; then
if rig daemon start; then
sleep 2
else
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
if rigged ps --json >/dev/null 2>&1; then
if rig ps --json >/dev/null 2>&1; then
echo "Reusing existing healthy daemon."
else
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
@@ -25,7 +25,7 @@ echo ""
# 1. Boot
echo "Step 1: Boot demo topology..."
UP_OUTPUT=$(rigged up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
UP_OUTPUT=$(rig up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
if [ -z "$RIG_ID" ]; then
echo "ERROR: No rig ID found in boot output. Proof failed."
@@ -35,7 +35,7 @@ echo ""
# 2. Node status
echo "Step 2: Node status after boot..."
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
echo ""
# 3. Health check after boot
@@ -74,7 +74,7 @@ echo "Rig ID: $RIG_ID"
# 7. Tear down
echo ""
echo "Step 7: Tear down..."
DOWN_OUTPUT=$(rigged down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
DOWN_OUTPUT=$(rig down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
SNAPSHOT_ID=$(printf '%s\n' "$DOWN_OUTPUT" | sed -n 's/^Snapshot: //p' | tail -n1)
if [ -z "$SNAPSHOT_ID" ]; then
echo "ERROR: No snapshot ID found in teardown output. Proof failed."
@@ -90,12 +90,12 @@ echo ""
# 9. Restore via explicit snapshot + rig ID
echo "Step 9: Restore via explicit snapshot + rig ID..."
rigged restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
rig restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
echo ""
# 10. Node status after restore
echo "Step 10: Node status after restore..."
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
echo ""
echo "=== Automated proof artifacts produced ==="
+8 -8
View File
@@ -6,11 +6,11 @@ echo ""
# Start daemon
echo "Starting daemon..."
if rigged daemon start; then
if rig daemon start; then
sleep 2
else
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
if rigged ps --json >/dev/null 2>&1; then
if rig ps --json >/dev/null 2>&1; then
echo "Reusing existing healthy daemon."
else
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
@@ -21,18 +21,18 @@ fi
# Boot the demo topology
echo ""
echo "Booting demo topology..."
UP_OUTPUT=$(rigged up demo/rig.yaml)
UP_OUTPUT=$(rig up demo/rig.yaml)
printf '%s\n' "$UP_OUTPUT"
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
if [ -z "$RIG_ID" ]; then
echo "ERROR: failed to determine rig ID from 'rigged up' output." >&2
echo "ERROR: failed to determine rig ID from 'rig up' output." >&2
exit 1
fi
# Show node status
echo ""
echo "Node status:"
rigged ps --nodes
rig ps --nodes
# Establish a restore-safe baseline for the demo rig.
echo ""
@@ -55,6 +55,6 @@ echo "=== Demo topology is running ==="
echo "Dashboard: http://localhost:5173"
echo ""
echo "Next steps:"
echo " rigged ps --nodes # Check node status"
echo " rigged down $RIG_ID # Tear down (auto-snapshots)"
echo " rigged restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
echo " rig ps --nodes # Check node status"
echo " rig down $RIG_ID # Tear down (auto-snapshots)"
echo " rig restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
+6 -6
View File
@@ -59,12 +59,12 @@ export function runCommand(command: string, args: string[], cwd = repoRoot()): s
}).trim();
}
export function runRigged(args: string[]): string {
return runCommand("rigged", args);
export function runRig(args: string[]): string {
return runCommand("rig", args);
}
export function runRiggedJson<T>(args: string[]): T {
const output = runRigged(args);
export function runRigJson<T>(args: string[]): T {
const output = runRig(args);
return JSON.parse(output) as T;
}
@@ -77,7 +77,7 @@ export function sleep(ms: number): Promise<void> {
}
export function listRigSummaries(): DemoRigSummary[] {
return runRiggedJson<DemoRigSummary[]>(["ps", "--json"]);
return runRigJson<DemoRigSummary[]>(["ps", "--json"]);
}
export function getCurrentRigSummary(rigNameOrId: string): DemoRigSummary | null {
@@ -95,7 +95,7 @@ export function listRigNodes(rigNameOrId: string): DemoNodeEntry[] {
return [];
}
return filterNodesForRigId(
runRiggedJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
runRigJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
summary.rigId
);
}
+1 -1
View File
@@ -59,7 +59,7 @@ export async function probeNodeResume(
const pollMs = opts.pollMs ?? 500;
const maxWaitMs = opts.maxWaitMs ?? 6_000;
const attempts = Math.max(1, Math.floor(maxWaitMs / pollMs));
const sessionName = `rigged-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
const sessionName = `rig-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
try {
runTmux(["new-session", "-d", "-s", sessionName, "-c", resolveNodeCwd(node.cwd ?? ".")]);
+1 -1
View File
@@ -100,7 +100,7 @@ function sendWarmup(sessionName: string | null, text: string): void {
}
execFileSync(
"rigged",
"rig",
["send", sessionName, text, "--verify", "--force", "--json"],
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
);
+317 -82
View File
@@ -1,16 +1,16 @@
# OpenRig — As-Built Architecture
## Final Stretch 1 Snapshot (as of 2026-04-05)
## Current Architecture
Status:
- Four implementation rounds have landed: AgentSpec reboot (16 tasks), North Star (16 tasks), Post-North-Star (11 tasks + dogfood fixes + UI polish), and Final Stretch 1 (spec library, `whoami`, adopted-session parity, specs/discovery drawer workflows, reusable spec display surfaces, live-node full-details route, graph hover/runtime hints).
- Shipped source footprint: `253` source files across the three packages.
- Daemon footprint: `133` source files total, including `79` domain files, `21` route files, `10` adapters, and `17` migrations.
- CLI footprint: `37` source files.
- UI footprint: `83` source files.
- OpenRig currently ships a pod-aware multi-agent runtime, rig-scoped environment management, a filesystem-backed spec library, managed-app browsing/review surfaces, and the canonical `secrets-manager` + `vault.specialist` agent-managed app example.
- Shipped source footprint: `286` source files across the three packages.
- Daemon footprint: `150` source files total, including `90` domain files, `22` route files, `11` adapters, and `20` migrations.
- CLI footprint: `49` source files.
- UI footprint: `87` source files.
- Current full-suite verification during this refresh:
- daemon: `112/112` files, `1461/1461` passing
- CLI: `29/29` files, `275/275` passing
- UI: `35` files, `361/364` passing (`3` known design/foundation failures)
- daemon: `127/127` files, `1668/1668` passing
- CLI: `37/37` files, `366/366` passing
- UI: `37/37` files, `388/388` passing
Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
@@ -18,42 +18,47 @@ Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
## 1. System Overview
OpenRig is a local control plane for multi-agent coding topologies. The system currently has four architectural layers:
OpenRig is a local control plane for multi-agent coding topologies. The system currently has six architectural layers:
1. **AgentSpec / pod-aware core**: spec parsing, resolution, precedence, startup orchestration, snapshot/restore, bundles.
2. **North Star operator layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
3. **Post-North-Star transport/history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
4. **Final Stretch 1 authoring/identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
2. **Operator and topology layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
3. **Communication and history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
4. **Authoring and identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
5. **Rig environment layer**: rig-scoped services records, Compose-backed service orchestration, readiness gates, env snapshot/restore integration, runtime env status/logs/down surfaces.
6. **Agent-managed software layer**: managed-app classification in the library, app-focused browse/review/runtime UI, managed-app-aware CLI/help surfaces, and the canonical `secrets-manager` + `vault.specialist` example.
Legacy flat-node/package flows remain for backward compatibility.
The stack is:
```text
CLI (31 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
CLI (40 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
v
Hono daemon routes (21 route groups + dedicated health/export/static handlers)
Hono daemon routes (22 route groups + dedicated health/export/static handlers)
|
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
+-- env routes (status / logs / down)
+-- transport routes (send/capture/broadcast)
+-- transcript routes (tail/grep)
+-- ask routes (context evidence packs)
+-- chat routes (durable rig messaging + SSE)
+-- spec review/library routes
+-- whoami identity route
+-- spec review/library routes (managed-app enrichment + compose preview)
+-- whoami identity + context-usage route
|
v
Framework-free domain services
|
+-- SQLite state (17 migrations)
+-- SQLite state (20 migrations)
+-- tmux / cmux / resume adapters
+-- runtime adapters (Claude Code / Codex / Terminal)
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
+-- transport layer (SessionTransport)
+-- transcript store (pipe-pane backed)
+-- chat repository (SQLite backed)
+-- spec review service
+-- spec library service
+-- spec review service (services metadata + compose preview seam)
+-- spec library service (`hasServices` classification)
+-- context-usage store / monitor
+-- whoami identity service
The core product loop:
@@ -66,7 +71,7 @@ The core product loop:
### HTTP routes
`createApp()` now mounts 21 route groups plus:
`createApp()` now mounts 22 route groups plus:
- `GET /healthz`
- `GET /api/rigs/:rigId/spec`
- `GET /api/rigs/:rigId/spec.json`
@@ -74,7 +79,7 @@ The core product loop:
In addition to the reboot-era routes:
**North Star additions:**
**Operator and topology routes:**
- `GET /api/rigs/:rigId/nodes` — canonical shared node-inventory projection
- `GET /api/rigs/:rigId/nodes/:logicalId` — rich node-detail payload for the drawer
- `POST /api/rigs/:rigId/nodes/:logicalId/launch` — manual node launch
@@ -82,7 +87,7 @@ In addition to the reboot-era routes:
- `POST /api/rigs/:id/up` — existing-rig restore by rig ID
- `POST /api/discovery/draft-rig` — generate candidate rig spec from discovered sessions
**Final Stretch 1 additions:**
**Authoring and identity routes:**
- `POST /api/rigs/import/materialize` — create pod-aware rig topology without launching
- `POST /api/discovery/:id/bind` — bind discovered session to an existing logical node
- `POST /api/discovery/:id/adopt` — UI-friendly composite adopt route (`bind` or `create_and_bind`)
@@ -90,7 +95,12 @@ In addition to the reboot-era routes:
- `/api/specs/library` — list/get/review/sync filesystem-backed library entries
- `/api/whoami` — daemon-backed runtime identity projection by `nodeId` or `sessionName`
**Post-North-Star additions:**
**Rig environment routes:**
- `GET /api/rigs/:rigId/env` — live rig-environment status with best-effort fresh receipt capture
- `GET /api/rigs/:rigId/env/logs` — service logs from the Compose adapter
- `POST /api/rigs/:rigId/env/down` — explicit env teardown for service-backed rigs
**Communication and history routes:**
- `/api/transport` — `POST /send`, `/capture`, `/broadcast` — communication primitives
- `/api/transcripts` — `GET /:session/tail`, `/:session/grep` — transcript access
- `/api/ask` — `POST /` — context evidence pack over rig summary + transcripts + chat
@@ -103,30 +113,44 @@ Important route behaviors:
- `/api/transport/send` classifies target activity before sending and can refuse unless forced
- `/api/ask` returns context/evidence, not LLM-synthesized answers
- `/api/rigs/:rigId/chat` streams via SSE for real-time delivery
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`)
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`), managed-app services metadata, and best-effort compose preview
- `/api/whoami` fails honestly on missing/ambiguous identity instead of fabricating “healthy” state
- `/api/rigs/:rigId/env` returns `{ ok: true, hasServices: false }` honestly when a rig has no service substrate
- `/api/rigs/:rigId/env/down` currently honors stored down policy; explicit volume-removal override is not fully wired through yet
### CLI commands
`index.ts` currently mounts 31 command groups. Reboot-era commands plus:
`index.ts` currently mounts 40 command groups. Reboot-era commands plus:
**North Star additions:**
**Operator and topology commands:**
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
- `rig down` — auto-snapshots before teardown, handoff includes restore command
- `rig discover --draft` — generate candidate rig spec from discovered sessions
- Changed: `rig ps --nodes` shows per-node detail with session names, status, startup status
**Final Stretch 1 additions:**
**Authoring and identity commands:**
- `rig specs ls/show/preview/add/sync` — agent-facing spec library browse/review workflow
- `rig whoami --json` — daemon-backed runtime identity with peers, edges, transcript helpers, and command hints
- `rig bind <discoveredId> <rigId> <logicalId>` — bind discovered session to an existing node
- `rig adopt <file> --bind logicalId=session` — materialize then bind live sessions into a rig
- Changed: `rig up <source>` and `rig bootstrap <source>` now resolve spec-library names and fail loudly on rig-name vs library-name ambiguity
**Post-North-Star additions (8 new command groups):**
- `rig send <session> "message" [--verify] [--force]` — send to agent terminal
- `rig capture <session> [--lines N] [--json]` — capture agent pane content
**Rig environment commands:**
- `rig env status <rig>` — inspect rig-scoped environment status for service-backed rigs and managed apps
- `rig env logs <rig> [service]` — fetch service logs
- `rig env down <rig>` — tear down the rig environment
**Managed-app-aware help and copy:**
- `rig up` help/examples explicitly include library-backed managed apps like `secrets-manager`
- `rig specs` help/list/preview text now distinguishes rigs, agents, and managed apps
- `openrig-user` now teaches the shipped managed-app model (`software + specialist`) and cross-rig specialist interaction
**Communication and history commands:**
- `rig send <session> "message" [--verify] [--force] [--host <id>]` — send to agent terminal; `--host` routes to a host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). SSH success is NOT verify success — the remote rig's verify result is surfaced verbatim.
- `rig capture <session> [--lines N] [--json] [--host <id>]` — capture agent pane content; `--host` same shape as `rig send --host`.
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
**Session source declaration:** Member YAML may carry `session_source` to control how a new managed seat derives its starting context. v1 supports two modes: `fork` (start from a prior native runtime conversation; captures and persists a NEW post-fork token, never the parent — see cli-reference.md "Session source declaration"); and `rebuild` (fresh-launch seeded with operator-declared artifacts in trust-precedence order; the seat's `continuityOutcome` is `rebuilt` and no `resumeToken` is persisted). Both modes flow through the existing rig spec and `rig expand` member-input shapes; no new top-level command was introduced for either.
- `rig transcript <session> --tail N / --grep "pattern" [--json]` — transcript access
- `rig config [get <key> / set <key> <value> / reset <key>] [--json]` — configuration surface
- `rig preflight [--json]` — system readiness check (Node, tmux, writable dirs, port)
@@ -139,7 +163,7 @@ The CLI-hosted MCP server exposes 17 tools:
1-12. Reboot-era: `rigged_up`, `rigged_down`, `rigged_ps`, `rigged_status`, `rigged_snapshot_create`, `rigged_snapshot_list`, `rigged_restore`, `rigged_discover`, `rigged_claim`, `rigged_bundle_inspect`, `rigged_agent_validate`, `rigged_rig_validate`
13-17. Post-North-Star additions:
13-17. Communication and operator additions:
- `rigged_rig_nodes` — node inventory for a rig (agents can look up infrastructure sessions)
- `rigged_send` — send message to agent terminal
- `rigged_capture` — capture agent pane content
@@ -157,13 +181,21 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
- The shared drawer has five primary surfaces:
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
- **Specs drawer:** library list, draft/spec review routing, and spec authoring entry points
- **Specs drawer:** unified library list with `All / Apps / Rigs / Agents` filters, type/stability badges, draft/spec review routing, and spec authoring entry points
- **Discovery drawer:** placement-first adopt/bind/materialize flow
- **System drawer:** global event/activity surface replacing the old status-bar role
- Pod selection opens the rig drawer with pod section expanded (no separate pod drawer)
- Human-readable IDs are UI-only: pod labels instead of ULIDs in explorer, short ULID tails for glanceability, full IDs in detail views
- Infrastructure/terminal nodes have distinct visual treatment
- Graph shows pod grouping via React Flow group nodes, status colors (green=ready, amber=launching, red=failed, gray=stopped), and lightweight hover/runtime hints
- Service-backed library entries render as `APP` rows instead of generic rigs
- `LibraryReview` for a managed app shows:
- `Library — Managed App`
- `Copy Setup Prompt`
- a `Specialist Agent` card (canonical example: `vault.specialist`)
- a library-only `Environment` tab with compose preview, health gates, and surfaces
- The rig drawer now has `Info`, `Env`, and `Chat Room` tabs for service-backed rigs
- `RigEnvPanel` exposes overall env state, per-service health, health gates, surfaces, logs, and explicit env teardown
- Explicit center-workspace routes now exist for:
- `/specs`
- `/specs/rig`
@@ -175,7 +207,7 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
## 3. Database Schema
The daemon now has 17 migrations.
The daemon now has 27 migrations (22 existing plus 5 added by PL-004 Phase A).
### Core state tables
@@ -199,6 +231,9 @@ The daemon now has 17 migrations.
**bindings**
- Physical surface attachment: tmux/cmux coordinates.
- `019_external_cli_attachment.ts` extends this row shape with:
- `attachment_type`
- `external_session_name`
**sessions**
- Live execution state.
@@ -240,7 +275,7 @@ These remain active:
- `label`
- `summary`
- serialized continuity policy
- Final Stretch 1 made `namespace` first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
- `namespace` is first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
**continuity_state** (`014_agentspec_reboot.ts`)
- Live per-`pod_id` / `node_id` operational continuity state.
@@ -263,11 +298,80 @@ These remain active:
- Indexed by `(rig_id, created_at)` for fast rig-scoped queries
- Note: transcript persistence is filesystem-backed (pipe-pane → log files), not SQLite. Chat is SQLite-backed.
**context_usage** (`018_context_usage.ts`)
- Persisted per-node context usage snapshots for runtime/operator surfaces.
- Used by the daemon-owned context monitor and `whoami`/inventory projections.
**rig_services** (`020_rig_services.ts`)
- Persisted rig-scoped environment record for service-backed rigs.
- Stores:
- `rig_id`
- `kind`
- `spec_json`
- `rig_root`
- resolved `compose_file`
- resolved `project_name`
- `latest_receipt_json`
- timestamps
Migration boundary:
- `014_agentspec_reboot.ts` adds the reboot schema shape.
- `015_startup_context.ts` adds persisted startup replay context.
- `016_chat_messages.ts` adds durable rig chat.
- `017_pod_namespace.ts` backfills/persists authored pod namespace for export, adoption, and identity parity.
- `018_context_usage.ts` adds context-usage persistence.
- `019_external_cli_attachment.ts` extends binding rows for external CLI attachment.
- `020_rig_services.ts` adds rig-scoped services persistence.
- `021_seat_handover_observability.ts` adds seat-handover observability fields.
- `022_node_codex_config_profile.ts` adds Codex config-profile field on nodes.
- `023_stream_items.ts` through `027_outbox_entries.ts` add the PL-004 Phase A coordination tables (see § Coordination Layer below).
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Phase D extends the policy enum to four values (Phase C's three plus `workflow-keepalive`); see § Workflow Runtime below.
- `033_workflow_specs.ts`, `034_workflow_instances.ts`, `035_workflow_step_trails.ts` add the PL-004 Phase D Workflow Runtime tables. Migration `036_watchdog_policy_enum_extension.ts` is a documenting no-op that records the Phase D enum extension (Phase C uses application-layer enforcement via `PHASE_D_POLICIES` array, so no DDL is needed).
### Workflow Runtime (PL-004 Phase D)
Daemon-native Workflow Runtime — declarative workflow specs, live instance state, append-only step trails, and the load-bearing transactional-scribe contract. Per PRD § L4 and founder direction (2026-04-22): owner-as-author semantically + workflow-as-transactional-scribe mechanically.
`workflow_specs` is a read-through cache of human-authored markdown/YAML spec files. Sources are workspace-surface; the daemon caches by `(name, version)` with a content `source_hash` so that valid operator edits to the spec file win at next read (workspace-surface reconciliation). Spec authoring stays markdown-authoritative; `workflow_specs` exists for fast lookup and runtime resolution.
`workflow_instances` carries live state per running workflow: `status` (`active|waiting|completed|failed`), `current_frontier_json` (active qitem ids), `hop_count` (loop-guard counter), and `last_continuation_decision_json` (most recent close/continue decision). Instances survive daemon restart from SQLite — no filesystem reconciliation required.
`workflow_step_trails` is the append-only history of meaningful step transitions. Every closure produces one trail row pairing the prior qitem with the next qitem (null on terminal). Append-only at the API surface: `WorkflowStepTrailLog.record()` is the only writer.
The transactional-scribe contract — the load-bearing Phase D guarantee — is implemented in `WorkflowProjector.project()`. A single `db.transaction` holds: closing the current packet (state mutation on `queue_items`), creating the next-step packet (`QueueRepository.createWithinTransaction()`), recording the trail entry, updating the instance frontier + status, and persisting workflow events. Either everything commits or everything rolls back; lost handoffs are impossible by design. Post-commit, subscribers are notified and the next owner is nudged.
`workflow-keepalive` (the Phase C-deferred watchdog policy) reads `workflow_instances` directly via SQLite — never the markdown source. Eligible only when `status` is `active|waiting`; resolves frontier qitem owners by querying `queue_items`; combines with explicit observer/created_by targets; sends to the first resolved target.
Phase D extends `RigEvent` with: `workflow.instantiated`, `workflow.step_closed`, `workflow.next_qitem_projected`, `workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`. Existing 20 PL-004 events are unchanged.
Phase D scope intentionally excludes multi-hop chaining (graduation per PRD § Risks "Workflow runtime over-engineered"), gate-return-sweep, and the closure-enforcement path (the daemon's transactional state remains the closure authority via Phase A's hot-potato strict-rejection; workflow runtime PROJECTS on closure, does not GATE closure). Phase E (progress-rail) remains markdown-authoritative for v1.
### Coordination tables (PL-004 Phase A)
Five host-scoped tables back the coordination primitive:
**stream_items** (`023_stream_items.ts`)
- L1 — append-only intake/audit root.
- Columns: `stream_item_id` (ULID PK), `ts_emitted`, `stream_sort_key`, `source_session`, `body`, `format` (default `text`), `hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON), `interrupt`, `archived_at`.
- Items immutable after emit (only `archived_at` may be set).
**queue_items** (`024_queue_items.ts`)
- L3 — owned-work queue.
- Columns: `qitem_id` (TEXT PK preserving POC `qitem-YYYYMMDDHHMMSS-<hex>` shape), `ts_created`, `ts_updated`, `source_session`, `destination_session`, `state` (8-value enum), `priority`, `tier`, `tags` (JSON), `blocked_on`, `handed_off_to`, `handed_off_from`, `expires_at`, `chain_of_record` (JSON), `body`, `closure_reason`, `closure_target`, `closure_required_at`, `claimed_at`, `last_nudge_attempt`, `last_nudge_result`, `last_heartbeat`, `resolution`.
- State enum: `pending | in-progress | done | blocked | failed | denied | canceled | handed-off`.
**queue_transitions** (`025_queue_transitions.ts`)
- L3 — append-only transition log. Authoritative audit trail for queue state evolution. No UPDATE / DELETE from domain code.
- Columns: `transition_id` (AUTOINCREMENT PK), `qitem_id`, `ts`, `state`, `transition_note`, `actor_session`, `closure_reason`, `closure_target`.
**inbox_entries** (`026_inbox_entries.ts`)
- Mailbox-style asynchronous deposit; idempotent on `inbox_id`.
- Columns: `inbox_id` (PK), `destination_session`, `sender_session` (authenticated), `body`, `tags` (JSON), `urgency`, `ts_dropped`, `state` (`pending | absorbed | denied`), `absorbed_at`, `absorbed_qitem_id`, `denied_at`, `denied_reason`, `audit_pointer`.
**outbox_entries** (`027_outbox_entries.ts`)
- Sender-side audit; symmetric to inbox; idempotent on `outbox_id`.
- Columns: `outbox_id` (PK), `sender_session`, `destination_session`, `body`, `tags` (JSON), `urgency`, `ts_dispatched`, `delivery_state` (`pending | delivered | failed`), `delivered_at`, `audit_pointer`.
---
@@ -284,6 +388,17 @@ Migration boundary:
- Uses `version: "0.2"` and `pods[]`.
- Owns cross-pod `edges[]`, rig-level startup overlays, and `cultureFile`.
**RigServicesSpec**
- Optional `services` block on a pod-aware `RigSpec`.
- Current shipped service kind is Compose-backed env management with:
- `composeFile`
- `projectName?`
- `profiles?`
- `downPolicy?`
- `waitFor?`
- `surfaces?`
- `checkpoints?`
**RigSpecPod**
- Pod-local bounded context with `members[]`, pod-local `edges[]`, pod startup, and optional continuity policy.
@@ -329,13 +444,21 @@ Migration boundary:
- Pre-launch vs interactive delivery split: `guidance_merge`/`skill_install` happen before harness boot (filesystem); `send_text` happens after harness is ready (TUI).
- Persists replay context including resume token for future restores.
### Operator-layer types (North Star + Post-North-Star)
**EnvReceipt**
- RigEnv capture result persisted for service-backed rigs.
- Carries service status/health plus evaluated wait targets and capture timestamp.
**RigServicesRecord**
- Persisted database representation of a rig-scoped services substrate.
- Holds resolved compose path, persisted spec JSON, project name, and latest receipt JSON.
### Operator, identity, and communication
**NodeInventoryEntry**
- Universal node projection consumed by CLI/UI/MCP: rigId, rigName, logicalId, canonicalSessionName, podId, nodeKind (agent/infrastructure), runtime, sessionStatus, startupStatus, restoreOutcome, tmuxAttachCommand, resumeCommand, latestError.
**NodeDetailEntry**
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus Final Stretch 1 live-identity fields:
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus live-identity fields:
- `peers`
- directional `edges`
- transcript helpers/path
@@ -396,7 +519,7 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
- `startup-validation.ts`: shared startup block validation
- `path-safety.ts`: shared relative-path safety checks
- `spec-validation-service.ts`: pure raw-YAML validation helpers
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data and provenance state
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data, provenance state, and managed-app services metadata (`waitFor`, `surfaces`, `composePreview`)
### Resolution pipeline
@@ -415,6 +538,15 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
- `rigspec-exporter.ts`: dual-format live rig export back to YAML/JSON
- `pod-repository.ts`: pod CRUD plus live continuity-state CRUD
### Rig environment and agent-managed software
- `compose-services-adapter.ts`: thin Compose-backed adapter for `up`, `ps`, `logs`, and teardown
- `services-readiness.ts`: shared wait-target evaluator for `url`, `tcp`, and `service + condition: healthy`
- `service-orchestrator.ts`: rig-scoped env boot, receipt capture, readiness gating, persisted receipt updates, and teardown
- `rig-repository.ts`: now persists `rig_services` rows and surfaces `hasServices` in rig summaries for runtime/UI gating
- `spec-library-service.ts`: classifies builtin/user specs and marks service-backed rigs with `hasServices`
- `spec-review-service.ts`: lifts services metadata into review models; route layer enriches with compose preview
### Runtime adapters
Three runtime adapters implement the five-method contract: projection, startup delivery, harness launch, readiness, and installed-resource listing.
@@ -429,18 +561,20 @@ Three runtime adapters implement the five-method contract: projection, startup d
- launches via `codex`, resumes via `codex resume <threadId>`
- readiness probe: polls for Codex ready indicator
**TerminalAdapter** (North Star)
**TerminalAdapter**
- no-op project/deliver/launch (shell IS the harness)
- immediate readiness (shell is ready as soon as tmux session exists)
- for infrastructure nodes: servers, log tails, build watchers
### Node inventory and operator surfaces (North Star)
### Node inventory and operator surfaces
- `node-inventory.ts`: universal node-level projection. The single source of truth for node state consumed by CLI (`ps --nodes`), UI (explorer + graph + drawer), and MCP (`rigged_rig_nodes`). Core + extended field tiers.
- `demo-rig-selector.ts`: existing-rig power-on helper — finds the right rig by name from ps summary
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows
- `context-usage-store.ts`: SQLite + state-dir persistence for per-node context usage
- `context-monitor.ts`: daemon-owned monitor that records context usage for runtime/operator surfaces
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows, now with context-usage access
### Transport and communication (Post-North-Star)
### Transport and communication
- `session-transport.ts`: communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection, honest error reporting, pod/rig/global targeting
- `transcript-store.ts`: pipe-pane transcript management — ANSI stripping on read, boundary markers, readTail, grep. Filesystem-backed, not SQLite.
@@ -448,29 +582,40 @@ Three runtime adapters implement the five-method contract: projection, startup d
- `ask-service.ts`: context engineering evidence pack — gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance. Does NOT call an external LLM.
- `chat-repository.ts`: durable rig-scoped chat — CRUD for chat_messages table, SSE-compatible event emission
### Resume honesty (North Star)
### Coordination primitive (PL-004 Phase A)
Six host-scoped services implementing the SQLite-canonical coordination layer. POC `rigx queue` / `rigx stream` filesystem path remains untouched; daemon `rig queue` / `rig stream` writes only to SQLite. Routes import these; services are Hono-free.
- `stream-store.ts`: L1 stream — idempotent emit (on `stream_item_id`), chronological list with cursor pagination, archive (soft).
- `queue-repository.ts`: L3 queue — create, claim/unclaim, update (general state mutator with hot-potato strict-rejection on `done`), transactional handoff (close source as `handed-off` plus create new owned qitem, single transaction), pod-fallback rerouting, overdue lookup, nudge/heartbeat tracking. Cross-rig validation hook exposed as `validateRig` constructor option.
- `queue-transition-log.ts`: append-only state transition log. Used by `queue-repository.ts` and exposed read-only on `queue_repository.transitionLog`.
- `hot-potato-enforcer.ts`: pure validator for the load-bearing API contract — `state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation}`; `handed_off_to | blocked_on | escalation` additionally require `closure_target`. Tier→SLA mapping for `closure_required_at` lives here.
- `inbox-handler.ts`: mailbox handler — authenticated drop (idempotent on `inbox_id`), absorb (promotes pending entry to a queue_item, idempotent), deny (records reason). Auth check is a pluggable constructor hook.
- `outbox-handler.ts`: sender-side outbox; idempotent record, mark delivered/failed, list. No event-bus events emitted (pure audit).
### Resume honesty
- `native-resume-probe.ts`: honest assessment of whether a harness actually resumed vs fresh-launched — probes pane content for runtime-specific indicators
- `resume-metadata-refresher.ts`: post-launch resume token capture — reads Claude conversation IDs from `.claude/` state, Codex thread IDs from SQLite state files
- `codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite database
### Discovery extensions (North Star)
### Discovery extensions
- `draft-rig-generator.ts`: synthesize a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs
- `claim-service.ts`: adopted-session integration service — claim/bind/create-and-bind flows, tmux metadata writes, and post-claim onboarding hint
### Final Stretch 1 authoring and library surfaces
### Authoring, library, and identity surfaces
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, and reusable display components
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review and `hasServices`
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, reusable display components, and managed-app env details
- `whoami-service.ts`: daemon-side identity surface with peers/edges/transcript helpers
### Snapshot, restore, and continuity
- `checkpoint-store.ts`: checkpoint persistence with pod/continuity context
- `snapshot-capture.ts`: captures pods, continuity state, and startup replay context
- `snapshot-capture.ts`: captures pods, continuity state, startup replay context, and latest env receipt
- `snapshot-repository.ts`: snapshot CRUD
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, and topology ordering
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, topology ordering, and RigEnv boot gating before agent restore
### Bundles, bootstrap, and legacy compatibility
@@ -551,14 +696,14 @@ Restore now:
- restore states: `resumed` / `rebuilt` / `fresh` / `failed`
- failed resume is FAILED loudly — no automatic fresh fallback
### Auto-snapshot + existing-rig power-on (North Star)
### Auto-snapshot and existing-rig power-on
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before teardown
- `rig up <rig-name>` (no file extension) searches for existing rig by name, restores from latest auto-pre-down snapshot
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot from a spec or bundle path.")
- Post-command handoff: down output includes snapshot ID + restore command; up output includes node statuses + attach command
### Communication flow (Post-North-Star)
### Communication flow
`rig send <session> "message"` → CLI → `POST /api/transport/send` → `SessionTransport`:
1. Resolve session name (canonical or legacy, by session/rig/pod/global)
@@ -567,7 +712,7 @@ Restore now:
4. Optional `--verify`: capture post-send pane, check message visibility
5. Honest result with reason on failure
### Transcript flow (Post-North-Star)
### Transcript flow
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation (before harness boot)
2. Raw terminal output streams to `~/.openrig/transcripts/{rig-name}/{session-name}.log`
@@ -576,7 +721,7 @@ Restore now:
5. On restore: boundary marker written before re-launch. Pipe-pane reconnects to same file (append).
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance
### Chat flow (Post-North-Star)
### Chat flow
1. `rig chatroom send <rig> "message"` → `POST /api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`
2. SSE stream: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages
@@ -585,7 +730,7 @@ Restore now:
5. MCP: `rigged_chatroom_send` + `rigged_chatroom_watch`
6. Source of truth: daemon-backed SQLite (`chat_messages` table), not tmux scrollback
### Config + preflight flow (Post-North-Star)
### Config and preflight flow
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`
- Precedence: CLI flag > env var > config file > default
@@ -593,11 +738,11 @@ Restore now:
- Auto-preflight runs on `rig up` and daemon start
- Every preflight error: what failed + why it matters + what to do (3-part pattern)
### Discovery-to-draft-rig flow (North Star)
### Discovery-to-draft-rig flow
`rig discover --draft` → scan tmux sessions → group by CWD → suggest pod structure → generate candidate RigSpec YAML → output to stdout or file
### Spec review and spec library flow (Final Stretch 1)
### Spec review and spec library flow
1. Raw YAML preview:
- UI/CLI posts YAML to `/api/specs/review/rig` or `/api/specs/review/agent`
@@ -606,12 +751,18 @@ Restore now:
2. Filesystem-backed library:
- `SpecLibraryService` scans builtin + user roots (`packages/daemon/specs`, `~/.openrig/specs`) with legacy fallback from `~/.rigged/specs`
- each YAML file is classified via structured review
- service-backed rigs are marked `hasServices`
- `/api/specs/library` serves list/get/review/sync
3. CLI:
- `rig specs ls/show/preview/add/sync`
- `rig specs add` installs either a single YAML spec or a full spec directory containing `rig.yaml` or `agent.yaml`
- `rig up` / `rig bootstrap` resolve library names before falling back to other source kinds
4. UI:
- `Specs` drawer presents a unified library list with `All / Apps / Rigs / Agents`
- service-backed entries render as `APP`
- library review shows specialist identity, setup prompt, and environment details
### Whoami and adopted-session parity flow (Final Stretch 1)
### Whoami and adopted-session parity flow
1. Managed sessions still prefer projected `OPENRIG_NODE_ID` / `OPENRIG_SESSION_NAME`.
2. Adopted sessions use tmux-owned metadata written at claim/bind time:
@@ -628,7 +779,7 @@ Restore now:
- raw tmux session-name fallback
4. The daemon owns the truth surface through `/api/whoami`; tmux metadata is an adopted-session anchor, not sovereign truth.
### Materialize / bind / adopt flow (Final Stretch 1)
### Materialize / bind / adopt flow
1. `POST /api/rigs/import/materialize` creates a pod-aware topology without launching sessions.
2. `POST /api/discovery/:id/bind` attaches a discovered live session to an existing logical node.
@@ -640,7 +791,7 @@ Restore now:
- `rig adopt`
5. Authored pod namespace is preserved through adoption so logical ids stay `${podNamespace}.${memberName}`.
### Live identity / specs UI flow (Final Stretch 1)
### Live identity / specs UI flow
1. Explorer/graph selection opens the shared right drawer.
2. Node drawer stays runtime-first and now includes:
@@ -656,6 +807,48 @@ Restore now:
- live full-details surfaces
5. Graph nodes carry lightweight hover/runtime hints without replacing drawer-first operation.
### Rig environment flow
1. Rig instantiation persists a `rig_services` record when a pod-aware `RigSpec` has a `services` block.
2. `ServiceOrchestrator.boot(...)` runs before agent launch/restore:
- Compose `up`
- evaluate `waitFor`
- persist `EnvReceipt`
- fail honestly on timeout or unhealthy waits
3. `GET /api/rigs/:rigId/env` returns live status:
- `hasServices: false` for non-service rigs
- otherwise kind, compose identity, receipt, and surfaces
4. `rig env status/logs/down` and the rig drawer `Env` tab are thin client surfaces over those routes.
5. Snapshot/restore preserves the latest env receipt and reboots services before agent restore.
6. **Current runtime truth model:** Runtime env truth is provided through boot-time gating (services must be healthy before agents launch) and on-demand inspection via `rig env` / the `Env` tab, which polls with honest `probeStatus` (fresh/stale/no_orchestrator). Background health monitoring and `rig ps` env health summary are deferred.
### Agent-managed app flow
1. A managed app is a service-backed rig plus a specialist agent.
2. The canonical shipped example is:
- rig: `secrets-manager`
- pod: `vault`
- member: `specialist`
- session: `vault-specialist@secrets-manager`
3. Specs/UI flow:
- browse `secrets-manager` under `Apps`
- open library review
- inspect specialist identity, env stack, health gates, and surfaces
- copy the setup prompt
4. Runtime flow:
- launch the rig
- services boot before `vault.specialist`
- the rig drawer `Env` tab exposes state, health, surfaces, logs, and teardown
### Specialist interaction flow
1. Other humans or agents address a specialist by session name, e.g. `vault-specialist@secrets-manager`.
2. Cross-rig communication uses the existing transport seam:
- `rig send vault-specialist@secrets-manager "..." --verify`
3. No new routing primitive exists here:
- delegation is currently conventional/social
- session transport remains the transport truth
---
## 7. Architecture Rules
@@ -727,6 +920,13 @@ Present in the union but not yet emitted by production code:
- `pod.created`, `pod.deleted`
- `continuity.sync`, `continuity.degraded`
PL-004 Phase A adds 9 coordination events emitted by domain services:
- `stream.emitted` (StreamStore.emit)
- `queue.created`, `queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue` (QueueRepository)
- `inbox.absorbed`, `inbox.denied` (InboxHandler)
Two SSE surfaces stream coordination events: `/api/stream/watch` for new stream items, `/api/queue/watch` for queue/inbox events.
The event log remains append-only and SQLite-backed. The global SSE stream (`/api/events`) delivers all events; the chat SSE stream (`/api/rigs/:rigId/chat/watch`) delivers chat messages for one rig.
---
@@ -735,7 +935,7 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
`createDaemon()` now does the following:
1. Open SQLite and run all 17 migrations.
1. Open SQLite and run all 27 migrations (22 existing plus the 5 PL-004 Phase A coordination tables).
2. Construct core repositories and legacy services.
3. Construct package/bootstrap/discovery services.
4. Construct rebooted startup/runtime services:
@@ -743,19 +943,25 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
- `ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`
- `PodRigInstantiator`
- `PodBundleSourceResolver`
5. Construct North Star + Post-North-Star services:
5. Construct rig environment services:
- `ComposeServicesAdapter`
- `ServiceOrchestrator`
6. Construct operator, transport, and history services:
- `TranscriptStore` (with config from env for transcript path/enabled)
- `SessionTransport`
- `ChatRepository`
- `AskService` (with `HistoryQuery`)
- `ResumeMetadataRefresher`
- `NodeInventory`
6. Construct Final Stretch 1 services:
- `ResumeMetadataRefresher`
- `ContextUsageStore`
- `ContextMonitor`
- `NodeInventory`
7. Construct authoring, identity, and managed-app services:
- `SpecReviewService`
- `SpecLibraryService` (builtin + user roots)
- `WhoamiService`
7. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
8. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including specs review/library and `whoami`.
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
9. Construct PL-004 Phase A coordination services from a shared `QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue` write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`, `OutboxHandler`.
10. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including env, specs review/library, `whoami`, plus the PL-004 coordination routes (`/api/stream`, `/api/queue`).
---
@@ -763,25 +969,18 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
### Test footprint
- daemon: 112 Vitest files / 1,461 tests
- CLI: 29 Vitest files / 275 tests
- UI: 35 Vitest files / 364 tests
- total: 176 files / 2,100 tests
- daemon: 127 Vitest files / 1,668 tests
- CLI: 37 Vitest files / 366 tests
- UI: 37 Vitest files / 388 tests
- total: 201 files / 2,422 tests
### Verified during this doc refresh
- daemon: `1461/1461` passing
- CLI: `275/275` passing
- UI: `361/364` passing
- daemon: `1668/1668` passing
- CLI: `366/366` passing
- UI: `388/388` passing
Current known UI failures during this refresh:
- `design-compliance.test.tsx`
- non-zero border-radius count still above zero
- stale `StatusBar.tsx` file assumption in the mono/data-display assertion
- `tailwind-foundation.test.tsx`
- missing `.bg-card` assertion in generated CSS
### Post-North-Star test suites
### Communication, identity, and authoring suites
In addition to reboot-era coverage:
@@ -804,9 +1003,23 @@ In addition to reboot-era coverage:
- `adopt.test.ts` — CLI materialize/bind adoption flow
- UI tests for spec review displays, spec library review, specs panel, live node details, and adopted-session node identity
### Rig environment and agent-managed software suites
- `rigspec-services.test.ts` — RigSpec `services` schema and parsing
- `service-orchestrator.test.ts` — Compose-backed env boot, readiness, receipt capture, teardown
- `services-up.test.ts` — end-to-end service-backed rig boot
- `services-snapshot-restore.test.ts` — env receipt capture + restore gating
- `env-routes.test.ts` — runtime env status/logs/down route contracts
- `spec-library-starters.test.ts` — builtin `secrets-manager` + `vault-specialist` presence, topology, and `hasServices`
- `spec-review-service.test.ts` / `spec-library-routes.test.ts` — managed-app services review, wait-target contract, compose preview
- CLI `env.test.ts` — env status/logs/down command surface
- UI `specs-panel.test.tsx` — `All / Apps / Rigs / Agents`, type/stability badges
- UI `library-review.test.tsx` — specialist card, setup prompt, managed-app environment tab
- UI `rig-detail-panel.test.tsx` — runtime `Env` tab and env-state rendering
### Dogfood status
Post-North-Star + Final Stretch 1 dogfood verified:
Core operator, communication, identity, and library flows verified:
- Full product loop: `rig up demo/rig.yaml` → harnesses launch → `rig ps --nodes` → `rig down` → `rig up demo-rig` (restore by name) → agents resume
- Communication: `rig send`, `rig capture`, `rig broadcast` across running rig
@@ -820,6 +1033,25 @@ Post-North-Star + Final Stretch 1 dogfood verified:
- Adoption: materialize + bind/adopt flows from discovery/specs workflows
- MCP: all 17 tools verified
Rig environment and agent-managed software flows additionally verified:
- Managed-app browse flow in the UI:
- `Specs` → `Apps` → `secrets-manager`
- managed-app review with `vault.specialist`
- `secrets-manager` launch as a real service-backed rig with a specialist agent
- Real Vault operations through `vault-specialist@secrets-manager`
- health check
- write secret
- read secret back
- Cross-rig direct messaging to `vault-specialist@secrets-manager`
- Runtime `Env` tab:
- overall state
- health gates
- surfaces
- logs
- explicit teardown
- Failure honesty when Vault is stopped mid-rig
---
## 11. Remaining Compatibility Notes
@@ -834,6 +1066,9 @@ These are the main intentional limits that still describe the shipped system:
6. Chat is rig-scoped only — no cross-rig channels or DMs.
7. `--verify` on `rig send` checks pane content for message visibility but can produce false positives from pre-existing matching content. Known limitation.
8. Terminal node readiness is shell-ready only — no service health probes.
9. `rig env down --volumes` exists in the CLI surface, but the explicit daemon-side override is not fully plumbed through yet.
10. Managed-app service surfaces are descriptive only — OpenRig does not automatically inject service URLs/tokens into agent prompts beyond authored startup/context files.
11. Specialist delegation is conventional, not automatic — other agents/humans address the specialist by session name or other normal communication surfaces.
---
+384 -18
View File
@@ -1,6 +1,6 @@
# OpenRig CLI Reference
Verified against the shipped CLI on 2026-04-07 using:
Verified against the shipped CLI on 2026-05-03 (v0.2.0) using:
- `packages/cli/src/index.ts`
- `packages/cli/src/commands/*.ts`
- `packages/cli/src/mcp-server.ts`
@@ -11,9 +11,10 @@ This document reflects the current `rig` surface as shipped. Where live help tex
## Overview
- Binary: `rig`
- Top-level command groups: `36`
- Top-level command groups: `53`
- Output mode: human-readable by default; many commands also support `--json`
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
- Legacy surface still shipped: `package`
## Top-Level Commands
@@ -31,11 +32,13 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `bootstrap` | Bootstrap a rig from a spec file |
| `requirements` | Check requirements for a rig spec |
| `discover` | Scan for unmanaged tmux sessions |
| `attach` | Attach the current shell or agent into a rig node |
| `bind` | Bind a discovered session to a rig node |
| `adopt` | Materialize topology and bind discovered live sessions |
| `bundle` | Manage rig bundles |
| `up` | Bootstrap a rig from a spec or bundle |
| `down` | Tear down a rig |
| `env` | Inspect and control rig environment services |
| `ps` | List rigs and their status |
| `mcp` | MCP server for agent integration |
| `agent` | Manage agent specs |
@@ -51,11 +54,26 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `config` | Inspect and change OpenRig configuration |
| `preflight` | Check system readiness for OpenRig |
| `doctor` | Verify OpenRig install health |
| `destroy` | Destroy OpenRig local state for recovery |
| `expand` | Add a pod to a running rig |
| `unclaim` | Release an adopted session without killing tmux |
| `release` | Release claimed sessions from a rig |
| `launch` | Launch or relaunch a node in a running rig |
| `remove` | Remove a node from a running rig |
| `shrink` | Remove an entire pod from a running rig |
| `setup` | Prepare the machine for OpenRig |
| `stream` | Coordination L1 — append-only intake stream |
| `queue` | Coordination L3 — owned-work queue + inbox/outbox |
| `project` | Coordination L2 — agent-backed classifier with daemon-enforced lease + idempotency + reclaim |
| `view` | Coordination L5 — daemon-backed views over coordination state |
| `watchdog` | Coordination Watchdog — daemon-native scheduler |
| `workflow` | Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection |
| `restore-packet` | Generate, read, and validate cross-runtime restore packets |
| `restore-check` | Check restore readiness across running rigs |
| `context` | Show context-usage across running agents |
| `compact-plan` | Plan Claude compact-in-place candidates without compacting anything |
| `heartbeat` | Show workflow execution proof state from queue files |
| `seat` | Inspect OpenRig seat observability state |
## Core Daemon and System Commands
@@ -124,7 +142,24 @@ Usage: `rig doctor [--json]`
Notes:
- Verifies install health for packaged/local CLI usage.
- Checks daemon dist, UI dist, Node version, tmux availability, writable state paths, and daemon port availability.
- Checks daemon dist, UI dist, Node version, `tmux`, optional `cmux` control health, writable state paths, and daemon port availability.
- On macOS, also warns when tmux mouse mode appears disabled, gives the current-server fix (`tmux set -g mouse on`), and points to the persistent fix in `~/.tmux.conf`.
- `cmux` issues are warnings, not hard failures. OpenRig still works without `cmux`; only `Open CMUX` workflows are unavailable.
- `--json` is suitable for agent use and only exits non-zero on real failures, not warnings.
### `rig destroy`
Usage:
- `rig destroy --state [--backup] --yes --confirm destroy-openrig-state`
- `rig destroy --all [--backup] --yes --confirm destroy-openrig-state`
Notes:
- This is the destructive recovery surface for polluted local OpenRig state.
- `--state` stops the daemon, clears the active OpenRig listener on the configured port if needed, rotates or deletes the effective state root, and recreates an empty state root.
- `--all` includes `--state` plus managed tmux session cleanup for sessions that are discoverable from the current OpenRig database.
- `--backup` moves the state root aside to a collision-safe timestamped path such as `~/.openrig.backup-YYYYMMDD-HHMMSS`.
- Managed tmux cleanup is intentionally conservative. It only removes sessions that are present in current DB state; unrelated tmux sessions are left alone.
- Human output prints a compact destroy plan followed by the destroy result.
### `rig mcp`
@@ -159,7 +194,10 @@ Shipped MCP tools:
Usage: `rig bootstrap <spec> [--plan] [--yes] [--json]`
Arguments:
- `spec`: path to a rig spec YAML file
- `spec`: path to a rig spec YAML file or a library name
Notes:
- Bare names resolve through the spec library before falling back to the raw source value.
### `rig requirements`
@@ -168,6 +206,10 @@ Usage: `rig requirements <spec> [--json]`
Arguments:
- `spec`: path to a rig spec YAML file
Notes:
- `rig requirements` is the spec/app-specific dependency surface.
- Use `rig doctor` for host-level install health, then `rig requirements <spec>` for rig-specific requirements.
### `rig up`
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
@@ -187,6 +229,7 @@ Current behavior notes:
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
Success modes:
- fresh boot
@@ -206,18 +249,38 @@ Notes:
- When `--snapshot` succeeds, human output includes the restore command.
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
### `rig env`
Usage:
- `rig env status <rig> [--json]`
- `rig env logs <rig> [service] [--tail <n>]`
- `rig env down <rig> [--volumes]`
Notes:
- This surface is only meaningful for service-backed rigs and managed apps.
- `status` resolves rig names or IDs and returns the env receipt with an honest freshness probe. The response includes `probeStatus` (fresh/stale/no_orchestrator) so operators can distinguish current truth from cached state.
- `logs` proxies compose-backed service logs; `[service]` is optional.
- `down` tears down the rig environment. `--volumes` overrides the stored down policy to force volume removal via `docker compose down --volumes`.
- Note: `rig ps` does not yet surface env health. Runtime env truth is available through `rig env status` and the rig drawer `Env` tab.
### `rig ps`
Usage:
- `rig ps [--json]`
- `rig ps --nodes [--json]`
- `rig ps [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
Notes:
- `rig ps` lists rig summaries.
- `rig ps --nodes` expands into a cross-rig node inventory including session name, runtime, startup status, restore outcome, attach command, resume command, and latest error.
- Exit codes are defined in help:
- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`.
- `rig ps --nodes` expands into a cross-rig node inventory. Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command.
- JSON output for both rig and node tiers includes a `rigName` alias (equal to `name`) for forward compatibility; agent code should prefer `rigName`. Default `--json` is a bare array (back-compat); the envelope shape `{entries, totalRigs|totalNodes, truncated, hint?}` is only used when `--limit`, `--fields`, `--summary`, or `--filter` is set.
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
- `--filter <key=value>` accepts only `status`, `lifecycleState`, `name-prefix`, `name`. Unknown keys are rejected before any HTTP call with a clear error naming the supported list.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
- Exit codes:
- `0` success
- `1` daemon not running
- `1` daemon not running, or invalid `--filter` / `--limit`
- `2` daemon fetch failure
### `rig snapshot`
@@ -240,6 +303,29 @@ Notes:
- Human output prints each restored node and any failed node error.
- Non-zero exit if any restored node fails.
### `rig restore-check`
Usage: `rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`
Notes:
- Checks restore readiness across running rigs (or one rig with `--rig`).
- `--no-queue` skips queue file checks; `--no-hooks` skips hook checks.
- Exit codes: `0` restorable (or restorable with caveats), `1` not restorable (red blockers found), `2` unknown / probe error.
### `rig restore-packet`
Usage: `rig restore-packet <subcommand>`
Subcommands:
- `write [options]` — generate a restore packet from a source session or JSONL file.
- `read <packet-dir> [--json]` — render a restore packet's contents (human or JSON).
- `validate <packet-dir> [--json]` — validate a restore packet against the v0 schema.
Notes:
- Packet shape is the cross-runtime v0 standard (Claude Code and Codex transcripts both supported via runtime parsers + redaction).
- `write` emits a packet directory with the canonical schema files plus `omitted-records` accounting.
- `read` and `validate` operate on existing packet directories and do not mutate them.
### `rig export`
Usage: `rig export <rigId> [-o|--output <path>]`
@@ -312,9 +398,11 @@ Subcommands:
- `rename <name-or-id> <new-name> [--json]`
Notes:
- `specs` is the library surface.
- `specs` is the library surface for rigs, agents, and managed apps.
- `preview` returns structured review data from the daemon.
- `add` validates the file as either RigSpec or AgentSpec before copying it into the user library.
- `add` accepts either a YAML spec file or a full spec directory containing `rig.yaml` or `agent.yaml`.
- Directory adds copy the whole tree into the user library so adjacent agents, guidance, skills, and docs remain available.
- `preview secrets-manager` is the canonical managed-app review example.
## Discovery and Topology Mutation
@@ -326,6 +414,18 @@ Notes:
- Scans unmanaged tmux sessions.
- `--draft` generates a candidate rig spec from the discovery set.
### `rig attach`
Usage:
- `rig attach --self --rig <rigId> --node <logicalId> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
- `rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
Notes:
- `--self` is currently required.
- Node attach and pod-create attach are exclusive modes.
- In tmux-backed shells, the command records tmux attachment metadata; otherwise it records an `external_cli` attachment.
- `--print-env` prints shell exports for `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME`.
### `rig bind`
Usage: `rig bind <discoveredId> --rig <rigId> (--node <logicalId> | --pod <namespace> --member <name>)`
@@ -356,6 +456,52 @@ Usage: `rig expand <rig-id> <pod-fragment-path> [--json] [--rig-root <path>]`
Notes:
- Adds a pod fragment to a running rig.
- `--rig-root` controls agent resolution.
- Member YAML may carry `session_source` (see "Session source declaration" below) to start the new seat from a prior native conversation (`mode: fork`) or from operator-declared artifacts (`mode: rebuild`).
### Session source declaration (`session_source`)
Member YAML in a rig spec or `rig expand` payload may declare a launch-time `session_source` to control how the new managed seat derives its starting context. Two modes are supported in v1:
```yaml
# Fork from a prior native runtime conversation. Captures and persists a NEW
# post-fork token; the parent token is NEVER persisted onto the new seat.
members:
- id: reviewer-2
runtime: claude-code # or "codex"; not valid on terminal
session_source:
mode: fork
ref:
kind: native_id # v1 fork mode supports "native_id" only
value: "0b0165d7-cb4d-4650-90de-15c0a1ede9e6"
```
```yaml
# Rebuild from operator-declared artifacts (CULTURE, role doc, handover packet,
# queue files, session logs). Fresh-launches the harness and seeds the running
# TUI with the artifacts in the operator-declared trust-precedence order.
# The seat's continuityOutcome is `rebuilt` (NEVER `fresh`/`resumed`/`forked`)
# and NO `resumeToken` is persisted.
members:
- id: writer-2
runtime: claude-code # or "codex"; not valid on terminal
session_source:
mode: rebuild
ref:
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
value: # ordered list, highest-trust first
- /Users/wrandom/code/substrate/shared-docs/rigs/<rig>/CULTURE.md
- /Users/wrandom/code/substrate/shared-docs/specs/agents/<role>.md
- /path/to/handover-packet.md
- /path/to/state/<pod>/<member>.queue.md
- /path/to/state/<pod>/shared.session.log
- /path/to/state/<pod>/<member>.session.log
```
Notes:
- `terminal` runtime rejects `session_source` (no native fork primitive; no agent context to rebuild).
- `mode: fork` requires `ref.kind: native_id` and a non-empty `ref.value` string. Other ref kinds (`artifact_path`, `name`, `last`) are reserved shapes for follow-up slices and are refused in v1 fork mode.
- `mode: rebuild` requires `ref.kind: artifact_set` and a non-empty `ref.value` array of paths. Missing paths are recorded as gaps and the launch proceeds with what resolved; if NO declared paths resolve, the launch fails with a clear error.
- The two modes are mutually exclusive on a given member; mixing is a schema error.
### `rig unclaim`
@@ -364,6 +510,15 @@ Usage: `rig unclaim <sessionRef> [--json]`
Notes:
- Releases an adopted session without killing its tmux session.
### `rig release`
Usage: `rig release <rigId> [--delete] [--json]`
Notes:
- Releases all claimed/adopted sessions from a rig without killing their tmux sessions.
- `--delete` removes the rig record after a clean release.
- OpenRig-launched nodes still require `rig down`.
### `rig launch`
Usage: `rig launch <rigId> <nodeRef> [--json]`
@@ -391,7 +546,7 @@ Notes:
### `rig whoami`
Usage: `rig whoami [--node-id <id>] [--session <name>] [--json]`
Usage: `rig whoami [--node-id <id>] [--session <name>] [--host <id>] [--json]`
Identity resolution order:
1. `--node-id`
@@ -406,6 +561,7 @@ Notes:
- If the daemon is unreachable but an identity source can still be resolved, `--json` returns a partial result instead of crashing.
- Human-readable output includes transcript location and context usage when available.
- In Claude Code projects, unattended `rig whoami` on boot may require the local permissions allow list to include `Bash(rig:*)`.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Identity resolution happens on the REMOTE rig (each host has its own daemon + tmux + identity context); local `--node-id`/`--session` flags are forwarded to the remote `rig whoami` invocation. The remote rig's output is verbatim passthrough on success; failure is distinguished into the same `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` enum as `rig ps --host` and `rig send --host`.
### `rig transcript`
@@ -420,23 +576,68 @@ Notes:
### `rig send`
Usage: `rig send <session> <text> [--verify] [--force] [--json]`
Usage: `rig send <session> <text> [--verify] [--force] [--wait-for-idle <s>] [--host <id>] [--json]`
Notes:
- Uses the two-step send pattern automatically: paste text, wait, submit Enter.
- `--verify` requests delivery verification.
- `--force` overrides mid-task safety checks.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below. SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim.
### `rig capture`
Usage:
- `rig capture <session> [--lines <n>] [--json]`
- `rig capture --rig <name> [--lines <n>] [--json]`
- `rig capture --pod <name> --rig <name> [--lines <n>] [--json]`
- `rig capture <session> [--lines <n>] [--host <id>] [--json]`
- `rig capture --rig <name> [--lines <n>] [--host <id>] [--json]`
- `rig capture --pod <name> --rig <name> [--lines <n>] [--host <id>] [--json]`
Default:
- `--lines 20`
Notes:
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below.
### Cross-host execution (`--host <id>`)
`rig send` and `rig capture` accept a `--host <id>` flag that routes the
command to a remote host over single-hop SSH. v0 is CLI-side shell-out
only — the local daemon is not involved in routing, and there is no
daemon-to-daemon networking. The remote host is expected to have its own
managed `rig` available on `$PATH`.
Hosts are declared by the operator in `~/.openrig/hosts.yaml`:
```yaml
hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: wrandom # optional
notes: "Tart VM" # optional
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: wrandom
```
Validation rules:
- `hosts` is required and must be a non-null array.
- Each entry: `id` required (non-empty, unique), `transport` required (v0 supports `ssh` only), `target` required (non-empty — DNS name, SSH config alias, or IP).
- `user` and `notes` are optional.
- The file is operator-managed; v0 does NOT include any sub-command to add, remove, or list hosts (operators edit the YAML directly).
- A missing or invalid file returns a clear error pointing at the canonical path.
The CLI distinguishes four structured failure modes (operators get an
actionable error per mode; JSON output preserves the `failedStep` enum):
- `ssh-unreachable` — SSH itself failed (connection refused, host key mismatch, etc.). Verify SSH access and the registry entry.
- `permission-gate` — SSH hit an auth/permission gate (Permission denied, Keychain). The error includes a hint to the keychain-over-SSH field note.
- `remote-daemon-unreachable` — SSH succeeded but the remote `rig` reported the remote daemon was not reachable. Start it with `ssh <target> rig daemon start`.
- `remote-command-failed` — SSH succeeded but the remote `rig` exited non-zero for some other reason; the remote stderr is surfaced.
Out of scope for v0: non-SSH transports; `--host` on `rig ps` / `rig whoami` / other commands; connection pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
### `rig broadcast`
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--json]`
@@ -481,12 +682,177 @@ Notes:
- `clear` is destructive and rig-scoped. Removes all messages for that rig.
- `watch --tmux` starts a dedicated tmux watcher session.
## Coordination Primitive (PL-004 Phase A)
Two top-level commands back the SQLite-canonical coordination layer. They speak only to the daemon HTTP API; they do NOT touch the POC `rigx-stream-proto` / `rigx-queue-proto` filesystem state. POC and daemon coexist at OPERATOR level only.
### `rig stream`
Usage: `rig stream <subcommand>` — L1 append-only intake stream.
Subcommands:
- `emit --source <session> --body <text> [--hint-destination <session>] [--hint-type <type>] [--hint-urgency <urgency>] [--hint-tags <csv>] [--interrupt] [--id <streamItemId>] [--json]`
- `list [--source <session>] [--hint-destination <session>] [--limit <n>] [--after <sortKey>] [--include-archived] [--json]`
- `show <streamItemId> [--json]`
- `archive <streamItemId> [--json]`
Notes:
- `--id` is for idempotency; same id returns the same row, body of subsequent calls is ignored.
- `archive` is soft — the row remains for audit and is excluded from `list` unless `--include-archived` is passed.
### `rig queue`
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
Subcommands:
- `create --source <session> --destination <session> --body <text> [--priority <p>] [--tier <t>] [--tags <csv>] [--expires-at <iso>] [--id <qitemId>] [--json]`
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--json]` — transactional close-as-handed-off + create-new
- `fallback <qitemId> --destination <session> [--reason <text>] [--json]` — reroute to fallback seat
- `show <qitemId> [--json]`
- `transitions <qitemId> [--json]` — append-only transition log
- `list [--destination <session>] [--source <session>] [--state <csv>] [--limit <n>] [--json]`
- `overdue [--json]` — in-progress qitems past closure_required_at
- `inbox-drop <destinationSession> --sender <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <inboxId>] [--json]`
- `inbox-absorb <inboxId> --receiver <session> [--json]` — promote a pending inbox entry to a queue_item
- `inbox-deny <inboxId> --receiver <session> --reason <text> [--json]`
- `inbox-pending <destinationSession> [--json]`
- `outbox-record --sender <session> --destination <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <outboxId>] [--json]`
- `outbox-list <senderSession> [--limit <n>] [--json]`
Hot-potato strict-rejection (load-bearing API contract):
- `update --state done` REQUIRES `--closure-reason` from one of: `handed_off_to`, `blocked_on`, `denied`, `canceled`, `no-follow-on`, `escalation`. Missing or invalid reason → exit 1 with structured error naming the 6 valid values.
- `closure-reason` of `handed_off_to`, `blocked_on`, or `escalation` additionally requires `--closure-target`.
- All hot-potato enforcement happens at the daemon domain layer; every surface (CLI, future MCP, future UI) inherits the same guarantee.
Closure-reason semantics:
- `handed_off_to` — work continues at a different seat (target = new owner). `handoff` subcommand is the preferred path; `update` accepts it for non-handoff terminal closures.
- `blocked_on` — parked pending another qitem (target = blocker qitem_id).
- `denied` — receiver rejected the work.
- `canceled` — sender or receiver withdrew.
- `no-follow-on` — terminal completion, nothing else needed.
- `escalation` — kicked up to a higher tier (target = escalation target).
## Coordination Project / Classifier and View (PL-004 Phase B)
Two top-level commands extend the coordination layer with L2 (project / classifier) and L5 (views).
### `rig project`
Usage: `rig project <subcommand>` — L2 agent-backed classifier with daemon-enforced lease + idempotency + reclaim.
Subcommands:
- `lease-acquire [options]` — acquire the active classifier lease for the caller.
- `lease-heartbeat [options]` — send a heartbeat for an active classifier lease (extends TTL).
- `lease-show [options]` — show the currently-active classifier lease.
- `reclaim-classifier [options]` — operator verb to reclaim the active classifier lease. Use `--if-dead` to refuse if holder is still alive.
- `classify <streamItemId> [options]` — project a stream item with classification fields. Idempotent on `stream_item_id`; requires an active lease.
- `list [options]` — list project classifications with filters.
- `show <projectId> [options]` — show one project classification.
Notes:
- Lease semantics: only one classifier holds the active lease at a time. Heartbeats extend TTL; lease expiry frees the slot for the next acquirer.
- `classify` enforces L1→L2 foreign-key existence: the referenced `stream_items` row must exist or the call is rejected before any state mutation.
- `reclaim-classifier` is the operator-side verb to recover from a hung classifier; `--if-dead` adds a liveness guard so a still-heartbeating classifier is not stolen from.
### `rig view`
Usage: `rig view <subcommand>` — L5 daemon-backed views over coordination state.
Subcommands:
- `list [options]` — list built-in + custom views.
- `show <viewName> [options]` — run a view (built-in or custom).
- `register [options]` — register or update a custom view.
Built-in views:
- `recently-active`, `founder`, `pod-load`, `escalations`, `held`, `activity`.
Notes:
- Views emit a `view.changed` SSE event on every state mutation that affects them. Queue update mutations bridge to `queue.updated` and then to `view.changed` for all six built-in views (Phase B R2).
- Custom view registration writes to the `views_custom` table; the daemon hot-reloads on registration.
## Coordination Watchdog (PL-004 Phase C)
`rig watchdog` registers, lists, inspects, and stops daemon-native scheduler jobs. The scheduler runs inside the daemon supervision tree and persists state in SQLite (`watchdog_jobs`/`watchdog_history`); jobs survive daemon restarts. Three policies are available at v1: `periodic-reminder`, `artifact-pool-ready`, and `edge-artifact-required`. The fourth POC policy `workflow-keepalive` is rejected with `policy_deferred_to_phase_d` and ships in Phase D.
### `rig watchdog`
- `register --spec <path> --policy <name> --target-session <s> --interval-seconds <n> --registered-by <s>` — register a job from a YAML spec; `--active-wake-interval-seconds` and `--scan-interval-seconds` are pool-ready-specific opt-ins.
- `list` — list all jobs (active + stopped + terminal).
- `show <job_id>` — show one job.
- `status <job_id>` — show job + recent evaluation history (last 20 entries).
- `stop <job_id> [--reason <text>]` — operator stop; scheduler skips the job thereafter.
History records only loud evaluations: `sent` (delivery executed) or `terminal` (policy declared the job done). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded — POC parity so agents are not woken about scheduler polls. Loud `skipped` rows are recorded only if a policy returns a non-quiet reason.
Phase D extends the policy enum with `workflow-keepalive` (the policy deferred from Phase C). It reads `workflow_instances` directly via SQLite, requires `status: active|waiting`, and resolves frontier qitem owners from `queue_items`.
## Workflow Runtime (PL-004 Phase D)
`rig workflow` operates on the daemon-native Workflow Runtime: declarative spec validation, instance creation, step projection (transactional-scribe), trace, and idempotent continue. Workflow specs are markdown/YAML files on disk (workspace-surface); the daemon caches them in SQLite for fast lookup.
### `rig workflow`
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness).
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner.
- `project --instance <id> --current-packet <qitem-id> --exit <handoff|waiting|done|failed> --actor-session <session>` — close the current packet AND project the next-step packet IN THE SAME daemon transaction (transactional-scribe; lost handoffs impossible by design). `--result-note <text>`, `--blocked-on <ref>`, `--next-owner <session>` modify behavior.
- `list [--status <s>]` — list instances; optionally filter by status (`active`/`waiting`/`completed`/`failed`).
- `show <instanceId>` — show one instance.
- `trace <instanceId>` — show the instance + its append-only step trail (audit-only).
- `continue <instanceId>` — idempotent inspector; in v1 returns the current state.
The owner-as-author + workflow-as-transactional-scribe contract is enforced by the daemon. The owner of a packet decides when it closes; the workflow runtime atomically records the closure AND creates/projects the next qitem per the workflow spec, in a single daemon transaction.
## Operational Inspection
Read-only inspection commands for context-window state, compaction planning, workflow heartbeat, and seat handover observability. Default mode is read-only across this section.
### `rig context`
Usage: `rig context [--rig <name>] [--threshold <pct>] [--refresh] [--json]`
Notes:
- Shows context-window usage across running agents.
- `--rig <name>` narrows to a single rig; `--threshold <pct>` filters to seats at or above the percent (plus unknown + stale).
- `--refresh` re-samples context usage before displaying instead of reading the latest cached snapshot.
### `rig compact-plan`
Usage: `rig compact-plan [--rig <name>] [--refresh] [--threshold-tokens <n>] [--threshold-percent <0-100>] [--json]`
Notes:
- Plans Claude compact-in-place candidates without compacting anything (read-only triage).
- `--threshold-tokens <n>` is the estimated used-token threshold; `--threshold-percent <0-100>` is the used-percent threshold when the context window size is missing.
- Output identifies seats that are candidates for compaction by current heuristics; the operator decides what (if anything) to compact.
### `rig heartbeat`
Usage: `rig heartbeat [--rig <name>] [--nudge] [--include-done] [--json]`
Notes:
- Shows workflow execution proof state from queue files.
- Default mode is read-only. `--nudge` sends informational proof instructions to stalled or unproven owners; it does not modify queue files or reroute work.
- `--include-done` includes done/handed-off queue items in the output (excluded by default).
### `rig seat`
Usage: `rig seat <subcommand>`
Subcommands:
- `status <seat> [options]` — show read-only seat handover observability status.
- `handover <seat> [options]` — plan a safe two-phase seat handover.
Notes:
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
## Commands Not Present
These are not current top-level `rig` commands:
- `rig claim`
- `rig blame`
- `rig replay`
- `rig env`
If older docs or habits mention them, treat those references as stale.
+91 -24
View File
@@ -3,10 +3,10 @@
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
Current footprint:
- daemon: 133 source files (79 domain, 21 routes, 10 adapters, 17 migrations, 6 other)
- CLI: 37 source files
- UI: 83 source files
- total: 253 source files
- daemon: 150 source files (90 domain, 22 routes, 11 adapters, 20 migrations, 7 other)
- CLI: 49 source files
- UI: 87 source files
- total: 286 source files
How to use this doc:
- `Exports` lists the public symbols defined by the file.
@@ -16,6 +16,7 @@ How to use this doc:
Fast lookup:
- Daemon app wiring and mounted HTTP surface: `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/routes/*`
- AgentSpec / pod-aware engine: `packages/daemon/src/domain/agent-*`, `profile-resolver.ts`, `projection-planner.ts`, `startup-*`, `rigspec-*`, `pod-repository.ts`
- Rig environment / managed-app substrate: `packages/daemon/src/adapters/compose-services-adapter.ts`, `packages/daemon/src/domain/service-orchestrator.ts`, `packages/daemon/src/domain/services-readiness.ts`, `packages/daemon/src/routes/env.ts`
- Snapshot / restore / continuity: `packages/daemon/src/domain/snapshot-*`, `checkpoint-store.ts`, `restore-orchestrator.ts`
- Transport / communication: `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/routes/transport.ts`
- Transcripts / persistence: `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/domain/history-query.ts`, `packages/daemon/src/routes/transcripts.ts`
@@ -23,13 +24,15 @@ Fast lookup:
- Chat: `packages/daemon/src/domain/chat-repository.ts`, `packages/daemon/src/routes/chat.ts`
- Spec review / library / identity: `packages/daemon/src/domain/spec-review-service.ts`, `packages/daemon/src/domain/spec-library-service.ts`, `packages/daemon/src/domain/whoami-service.ts`, `packages/daemon/src/routes/spec-review.ts`, `packages/daemon/src/routes/spec-library.ts`, `packages/daemon/src/routes/whoami.ts`
- Node inventory: `packages/daemon/src/domain/node-inventory.ts`
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (31 groups)
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (40 groups)
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
- UI spec/library surfaces: `packages/ui/src/components/RigSpecDisplay.tsx`, `packages/ui/src/components/AgentSpecDisplay.tsx`, `packages/ui/src/components/LibraryReview.tsx`, `packages/ui/src/components/SpecsWorkspace.tsx`
- UI managed-app runtime surfaces: `packages/ui/src/components/RigEnvPanel.tsx`, `packages/ui/src/hooks/useRigEnv.ts`, `packages/ui/src/lib/build-setup-prompt.ts`
- UI live identity surfaces: `packages/ui/src/components/LiveIdentityDisplay.tsx`, `packages/ui/src/components/LiveNodeDetails.tsx`
- UI data layer: `packages/ui/src/hooks/*`
@@ -38,14 +41,15 @@ Fast lookup:
### Entrypoints And Wiring
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 17 migrations, wires legacy, reboot, North Star, Post-North-Star, and Final Stretch 1 services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 20 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, and Final Stretch 3 managed-app services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
### Adapters
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
- `packages/daemon/src/adapters/claude-resume.ts`: Resume adapter for claude sessions. Exports: ClaudeResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
- `packages/daemon/src/adapters/cmux-transport.ts`: Adapter/util module for cmux transport integration. Exports: createCmuxCliTransport. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
- `packages/daemon/src/adapters/cmux.ts`: Adapter/util module for cmux integration. Exports: CmuxAdapter, CmuxResult, CmuxStatus, CmuxSurface, CmuxTransport, CmuxTransportFactory, CmuxWorkspace. Related: none.
- `packages/daemon/src/adapters/compose-services-adapter.ts`: Compose-backed rig-environment adapter for `up`, `ps`, `logs`, and teardown. Exports: ComposePsService, ComposeServicesAdapter, ComposeServicesUpParams, ComposeServicesUpResult, ComposeServicesLogsParams, ComposeServicesLogsResult, ComposeServicesDownParams, ComposeServicesDownResult. Related: `packages/daemon/src/adapters/tmux-exec.js`.
- `packages/daemon/src/adapters/codex-resume.ts`: Resume adapter for codex sessions. Exports: CodexResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`: Adapter implementation for codex runtime integration. Exports: CodexAdapterFsOps, CodexRuntimeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
- `packages/daemon/src/adapters/terminal-adapter.ts`: Infrastructure runtime adapter for terminal/shell nodes. No-op project/deliver/launch, immediate readiness. Exports: TerminalAdapter. Related: `packages/daemon/src/domain/runtime-adapter.js`.
@@ -75,19 +79,65 @@ Fast lookup:
- `packages/daemon/src/db/migrations/015_startup_context.ts`: Database migration for startup context. Exports: startupContextSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/016_chat_messages.ts`: Database migration for durable rig-scoped chat messages. Exports: chatMessagesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/017_pod_namespace.ts`: Database migration that persists authored pod namespace separately from pod DB id for export, adoption, and identity parity. Exports: podNamespaceSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/021_seat_handover_observability.ts`: Database migration for seat-handover observability fields. Exports: seatHandoverObservabilitySchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/022_node_codex_config_profile.ts`: Database migration adding Codex config-profile field on nodes. Exports: nodeCodexConfigProfileSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/023_stream_items.ts`: PL-004 Phase A — L1 stream items table (append-only intake/audit root). Exports: streamItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/024_queue_items.ts`: PL-004 Phase A — L3 queue items table (owned-work queue with closure obligation). Exports: queueItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/025_queue_transitions.ts`: PL-004 Phase A — append-only queue state transition log. Exports: queueTransitionsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/026_inbox_entries.ts`: PL-004 Phase A — mailbox-style inbox for asynchronous deposit. Exports: inboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/027_outbox_entries.ts`: PL-004 Phase A — sender-side outbox audit. Exports: outboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/028_project_classifications.ts`: PL-004 Phase B — L2 project classifications table (idempotent on stream_item_id; FK to stream_items). Exports: projectClassificationsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/029_classifier_leases.ts`: PL-004 Phase B — single-writer classifier lease table (partial UNIQUE INDEX on state='active'). Exports: classifierLeasesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/030_views_custom.ts`: PL-004 Phase B — custom view registry. Exports: viewsCustomSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/031_watchdog_jobs.ts`: PL-004 Phase C — watchdog jobs (canonical scheduler state; policy enum + intervals + lifecycle). Exports: watchdogJobsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/032_watchdog_history.ts`: PL-004 Phase C — append-only watchdog evaluation history (sent/skipped/terminal; not_due polls not recorded). Exports: watchdogHistorySchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/033_workflow_specs.ts`: PL-004 Phase D — workflow specs (cached read-through from markdown/YAML; UNIQUE on name+version; source_hash invalidation). Exports: workflowSpecsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
- `packages/daemon/src/domain/watchdog-history-log.ts`: Append-only writer for watchdog_history. Records meaningful evaluations only (sent/skipped/terminal). API surface intentionally lacks update/delete. Exports: WatchdogHistoryLog, WatchdogHistoryEntry, WatchdogOutcome. Related: `packages/daemon/src/db/migrations/032_watchdog_history.js`.
- `packages/daemon/src/domain/watchdog-policy-engine.ts`: Resolves policy by name, parses spec_yaml context, dispatches policy.evaluate(), records meaningful outcomes via history-log, emits watchdog.* events, calls injected delivery callback for action=send. Exports: WatchdogPolicyEngine, DeliveryFn, DeliveryRequest, DeliveryOutcome. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
- `packages/daemon/src/domain/watchdog-scheduler.ts`: Tick-driven daemon-native scheduler that joins the supervision tree. Filters listActive() through isDue(), evaluates due jobs sequentially, supports start/stop with graceful in-flight await. Exports: WatchdogScheduler, isDue. Related: `packages/daemon/src/domain/watchdog-policy-engine.js`, `packages/daemon/src/domain/watchdog-jobs-repository.js`.
- `packages/daemon/src/domain/policies/types.ts`: Policy contract (Policy, PolicyJob, PolicyEvaluation). Exports: Policy, PolicyJob, PolicyEvaluation. Related: none.
- `packages/daemon/src/domain/policies/periodic-reminder.ts`: Policy that returns send {target, message} unconditionally; throws policy_spec_invalid if context.target.session or context.message missing. Translated from POC `lib/policies/periodic-reminder.mjs`. Exports: periodicReminderPolicy. Related: `packages/daemon/src/domain/policies/types.js`.
- `packages/daemon/src/domain/policies/artifact-pool-ready.ts`: Policy that scans context.pools and skips with no_actionable_artifacts when empty; otherwise sends a formatted message capped at max_items. Translated from POC `lib/policies/artifact-pool-ready.mjs`. Exports: artifactPoolReadyPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
- `packages/daemon/src/domain/policies/edge-artifact-required.ts`: Policy that scans source_pools and target_pools, computes source-key per artifact, and sends an edge-repair message for sources lacking matching downstream artifacts. Skips with no_missing_edge_artifacts when all sources matched. Translated from POC `lib/policies/edge-artifact-required.mjs`. Exports: edgeArtifactRequiredPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
- `packages/daemon/src/domain/policies/artifact-pool-helpers.ts`: Shared filesystem scanner + formatter for artifact-pool-ready and edge-artifact-required. Translated from POC `lib/policies/artifact-pool.js`. Exports: scanArtifactPools, formatArtifactList, sourceKeyFor, ArtifactPoolSpec, ScannedArtifact. Related: none.
- `packages/daemon/src/routes/watchdog.ts`: Hono route module for `/api/watchdog` (`POST /register`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:job_id`, `GET /:job_id/status`, `POST /:job_id/stop`). SSE/literal paths mounted before /:job_id catchall per Phase A R1 route-order discipline. Exports: watchdogRoutes. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
- `packages/daemon/src/domain/policies/workflow-keepalive.ts`: PL-004 Phase D — workflow-keepalive policy (carry-forward from Phase C deferral). Reads workflow_instances directly via SQLite (audit row 18 LOAD-BEARING); requires status active|waiting; resolves frontier qitem owners via queue_items. Translated from POC `lib/policies/workflow-keepalive.mjs`; source-of-truth switched from markdown to SQLite. Exports: makeWorkflowKeepalivePolicy. Related: `packages/daemon/src/domain/policies/types.js`, `packages/daemon/src/db/migrations/034_workflow_instances.js`.
### Domain: Workflow Runtime (PL-004 Phase D)
- `packages/daemon/src/domain/workflow-types.ts`: Shared types (WorkflowSpec, WorkflowInstance, WorkflowStepTrailEntry, WorkflowExitKind). Reverse-engineered from POC `workflow-runtime-v0/lib/workflow-runtime.rb` + fixture YAMLs. Exports: WorkflowSpec, WorkflowStepSpec, WorkflowRoleSpec, WorkflowInvariants, WorkflowClosureMessages, WorkflowLoopGuards, WorkflowInstance, WorkflowInstanceStatus, WorkflowStepTrailEntry, WorkflowExitKind, WorkflowSpecRow. Related: none.
- `packages/daemon/src/domain/workflow-spec-cache.ts`: Read-through cache from markdown/YAML spec files into workflow_specs table. Workspace-surface reconciliation: source_hash invalidates the cache on next read; valid operator edits to spec files win. Uses the `yaml` package for parsing. Exports: WorkflowSpecCache, WorkflowSpecError, parseWorkflowSpec. Related: `packages/daemon/src/db/migrations/033_workflow_specs.js`.
- `packages/daemon/src/domain/workflow-validator.ts`: Validates a workflow spec against role resolution, step uniqueness, allowed-exits consistency, and (optional via injected callback) seat liveness. Returns structured ValidationResult with severity-tagged issues. Exports: WorkflowValidator, ValidationIssue, ValidationResult, SeatLivenessCheckFn. Related: `packages/daemon/src/domain/workflow-types.js`.
- `packages/daemon/src/domain/workflow-instance-store.ts`: CRUD on workflow_instances + frontier tracking. Survives daemon restart (queries SQLite directly). Exports: WorkflowInstanceStore, WorkflowInstanceError, CreateWorkflowInstanceInput. Related: `packages/daemon/src/db/migrations/034_workflow_instances.js`.
- `packages/daemon/src/domain/workflow-step-trail-log.ts`: Append-only writer/reader for workflow_step_trails. API surface lacks update/delete (contract enforcement). Mirrors Phase A's queue-transition-log + Phase C's watchdog-history-log shape. Exports: WorkflowStepTrailLog, WorkflowStepTrailRecordInput. Related: `packages/daemon/src/db/migrations/035_workflow_step_trails.js`.
- `packages/daemon/src/domain/workflow-projector.ts`: LOAD-BEARING. Implements the transactional-scribe contract: closing a workflow_instances frontier qitem AND creating the next-step queue_items row AND appending the workflow_step_trails entry AND updating instance frontier+status all happen in the SAME daemon transaction (db.transaction). If any side throws, all roll back. Lost handoffs are impossible by design. Post-commit, fans out persisted events + nudges next owner. Uses Phase A QueueRepository.createWithinTransaction() (Phase D extension point). Exports: WorkflowProjector, WorkflowProjectorError, ProjectStepInput, ProjectStepResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/workflow-instance-store.js`, `packages/daemon/src/domain/workflow-step-trail-log.js`.
- `packages/daemon/src/domain/workflow-runtime.ts`: Facade over spec-cache + validator + instance-store + projector + step-trail-log. Operations: validate, instantiate (creates instance + entry qitem in same transaction), project (delegates to projector), continue (idempotent inspector). Exports: WorkflowRuntime, WorkflowRuntimeDeps, InstantiateInput, InstantiateResult, WorkflowInstanceError, WorkflowProjectorError, WorkflowSpecError. Related: `packages/daemon/src/domain/workflow-projector.js`, `packages/daemon/src/domain/workflow-spec-cache.js`.
- `packages/daemon/src/routes/workflow.ts`: Hono route module for `/api/workflow` (`POST /validate`, `POST /instantiate`, `POST /project`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:instance_id`, `GET /:instance_id/trace`, `POST /:instance_id/continue`). SSE/literal paths mounted before /:instance_id catchall per Phase A R1 route-order discipline. Exports: workflowRoutes. Related: `packages/daemon/src/domain/workflow-runtime.js`, `packages/daemon/src/domain/event-bus.js`.
### Domain: Core Runtime
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
- `packages/daemon/src/domain/event-bus.ts`: Persistent event log plus in-process pub/sub for routes, SSE, and domain emitters. Exports: EventBus. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/graph-projection.ts`: Pure projection from persisted rig/session state to React Flow graph nodes and edges. Exports: ReactFlowGraph, RigGraphInput, projectRigToGraph. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/context-monitor.ts`: Daemon-owned context-usage recorder that samples live runtime state and updates persistent usage summaries. Exports: ContextMonitor. Related: `packages/daemon/src/domain/context-usage-store.js`.
- `packages/daemon/src/domain/context-usage-store.ts`: SQLite-backed store for per-node context usage snapshots used by whoami/node inventory surfaces. Exports: ContextUsageStore, ContextUsageSnapshot, ContextUsageStoreOpts. Related: none.
- `packages/daemon/src/domain/node-launcher.ts`: Atomic node launch service that creates tmux, session rows, bindings, and launch events together. Exports: LaunchResult, NodeLauncher. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-name.js`, +2 more.
- `packages/daemon/src/domain/ps-projection.ts`: Builds the `/api/ps` summary view over rigs, nodes, sessions, and latest snapshots. Exports: PsEntry, PsProjectionService. Related: none.
- `packages/daemon/src/domain/reconciler.ts`: Marks missing tmux sessions as detached and keeps persisted session state aligned with reality. Exports: ReconcileResult, Reconciler. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-registry.js`.
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, and edge state. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, edge, and rig-services state. Also projects `hasServices` into rig summaries for runtime/UI gating. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/rig-teardown.ts`: Graceful rig shutdown/delete orchestrator used by `/api/down`. Exports: RigTeardownOrchestrator, TeardownResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +2 more.
- `packages/daemon/src/domain/service-orchestrator.ts`: Rig-scoped services orchestrator for env boot, readiness gating, receipt capture, and teardown. Exports: ServiceBootResult, ServiceOrchestrator, ServiceTeardownResult. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/services-readiness.js`.
- `packages/daemon/src/domain/session-name.ts`: Tmux session-name derivation and validation helpers. Exports: deriveSessionName, validateSessionName. Related: none.
- `packages/daemon/src/domain/session-registry.ts`: Session and binding lifecycle repository, including claimed-session registration and startup-status updates. Exports: SessionRegistry. Related: `packages/daemon/src/domain/session-name.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
- `packages/daemon/src/domain/services-readiness.ts`: Shared readiness evaluator for RigEnv wait targets (`url`, `tcp`, `service + condition`). Exports: WaitTargetFailure, WaitTargetSuccess, evaluateWaitTargets. Related: none.
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, RigEnv/service contracts, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, EnvCheckpoint, EnvReceipt, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigServicesRecord, RigServicesSpec, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
- `packages/daemon/src/domain/up-command-router.ts`: Source router that classifies a path as a rig spec or rig bundle before bootstrap. Exports: RouteResult, SourceKind, UpCommandRouter. Related: `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
### Domain: Specs, Resolution, Startup
@@ -104,8 +154,8 @@ Fast lookup:
- `packages/daemon/src/domain/rigspec-schema.ts`: Validation and normalization for both legacy and rebooted RigSpec schemas. Exports: LegacyRigSpecSchema, RigSpecSchema. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/runtime-adapter.ts`: Runtime-adapter contract plus bridge types used by startup orchestration and runtime-specific adapters. Exports: InstalledResource, NodeBinding, ProjectionResult, ReadinessResult, ResolvedStartupFile, RuntimeAdapter, StartupDeliveryResult. Related: `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/spec-validation-service.ts`: Pure validation helpers for raw AgentSpec and RigSpec YAML strings. Exports: validateAgentSpecFromYaml, validateRigSpecFromYaml. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, and provenance/source-state for preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, provenance/source-state, and managed-app services metadata used by preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, RigSpecServicesReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, marks service-backed rigs with `hasServices`, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/domain/whoami-service.ts`: Daemon-side runtime identity projector used by `/api/whoami`. Resolves by node or session, expands peers/edges/transcript helpers, and surfaces ambiguity honestly. Exports: WhoamiResult, WhoamiAmbiguousError, WhoamiService. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/transcript-store.js`.
- `packages/daemon/src/domain/startup-orchestrator.ts`: Startup execution engine for projection, startup-file delivery, action execution, readiness, and persisted replay context. Exports: StartupInput, StartupOrchestrator, StartupResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`, +2 more.
- `packages/daemon/src/domain/startup-resolver.ts`: Pure additive startup-layer merge function across agent, profile, rig, pod, member, and operator layers. Exports: StartupLayerInputs, resolveStartup. Related: `packages/daemon/src/domain/types.js`.
@@ -113,8 +163,8 @@ Fast lookup:
### Domain: Snapshots And Restore
- `packages/daemon/src/domain/checkpoint-store.ts`: Checkpoint persistence, lookup, and rig-wide checkpoint aggregation. Exports: CheckpointStore. Related: `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, and topology ordering. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, and startup context. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, topology ordering, and RigEnv boot gating before agent restore. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, startup context, and the latest rig-env receipt. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
- `packages/daemon/src/domain/snapshot-repository.ts`: SQLite-backed repository for snapshot CRUD and listing. Exports: SnapshotRepository. Related: `packages/daemon/src/domain/types.js`.
### Domain: Packages And Install Engine
@@ -169,6 +219,14 @@ Fast lookup:
- `packages/daemon/src/domain/ask-service.ts`: Context engineering evidence pack — gathers topology snapshot, transcript excerpts, chat history excerpts, restore metadata. Returns structured context for the agent to reason about. Does NOT call an external LLM. Exports: AskService, AskDeps. Related: `packages/daemon/src/domain/ps-projection.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/history-query.js`.
- `packages/daemon/src/domain/chat-repository.ts`: Durable rig-scoped chat messages — CRUD for chat_messages table, monotonic ULID IDs, rig-scoped queries. Exports: ChatRepository, ChatMessage. Related: none (SQLite-only).
### Domain: Coordination Primitive (PL-004 Phase A)
- `packages/daemon/src/domain/stream-store.ts`: L1 coordination stream — append-only intake/audit root. Idempotent emit on stream_item_id, chronological list with cursor pagination, soft archive. Exports: StreamStore, StreamItem, StreamEmitInput, StreamListOptions. Related: `packages/daemon/src/domain/event-bus.js`.
- `packages/daemon/src/domain/queue-repository.ts`: L3 coordination queue — create, claim/unclaim, update with hot-potato strict-rejection, transactional handoff (close source + create new in one transaction), pod-fallback rerouting, overdue lookup. POC qitem_id format preserved. Exports: QueueRepository, QueueRepositoryError, QueueItem, QueueState, QueuePriority, QueueCreateInput, QueueUpdateInput, QueueHandoffInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-transition-log.js`, `packages/daemon/src/domain/hot-potato-enforcer.js`.
- `packages/daemon/src/domain/queue-transition-log.ts`: Append-only state transition log for queue_items. No UPDATE / DELETE from domain code. Exports: QueueTransitionLog, QueueTransition, QueueTransitionInput. Related: `packages/daemon/src/domain/hot-potato-enforcer.js`.
- `packages/daemon/src/domain/hot-potato-enforcer.ts`: Pure validator for the load-bearing hot-potato API contract — state=done requires closure_reason from the 6-value enum. Tier→SLA mapping for closure_required_at. Exports: CLOSURE_REASONS, ClosureReason, validateClosure, isClosureReason, computeClosureRequiredAt. Related: none.
- `packages/daemon/src/domain/inbox-handler.ts`: Mailbox handler — authenticated drop (idempotent on inbox_id), absorb (promote pending entry to a queue_item), deny (record reason). Authentication is a pluggable constructor hook. Exports: InboxHandler, InboxHandlerError, InboxEntry, InboxState, InboxDropInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-repository.js`.
- `packages/daemon/src/domain/outbox-handler.ts`: Sender-side outbox — symmetric to inbox; idempotent record, mark delivered/failed, list. No event-bus events emitted. Exports: OutboxHandler, OutboxHandlerError, OutboxEntry, OutboxDeliveryState, OutboxRecordInput. Related: none (SQLite-only).
### Routes
- `packages/daemon/src/routes/adapters.ts`: Hono route module for adapters API endpoints. Exports: adaptersRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
- `packages/daemon/src/routes/agents.ts`: Hono route module for AgentSpec validation. POST `/api/agents/validate` using `validateAgentSpecFromYaml`. Exports: agentsRoutes. Related: `packages/daemon/src/domain/spec-validation-service.js`.
@@ -176,6 +234,7 @@ Fast lookup:
- `packages/daemon/src/routes/bundles.ts`: Hono route module for dual-format bundle create/inspect/install. Supports legacy v1 package bundles and schema-version-2 pod bundles, including v2 inspect responses with `agents[]` and integrity data. Exports: bundleRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-assembler.js`, +7 more.
- `packages/daemon/src/routes/discovery.ts`: Hono route module for discovery scan/list/detail plus Final Stretch flows: `draft-rig`, `bind`, and composite `adopt`. Exports: discoveryRoutes. Related: `packages/daemon/src/domain/claim-service.js`, `packages/daemon/src/domain/discovery-coordinator.js`, `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/draft-rig-generator.js`.
- `packages/daemon/src/routes/down.ts`: Hono route module for down API endpoints. Exports: downRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rig-teardown.js`.
- `packages/daemon/src/routes/env.ts`: Hono route module for rig-environment status, logs, and teardown. Refreshes env receipts best-effort and returns additive managed-app/runtime metadata (`hasServices`, `surfaces`, `receipt`). Exports: envRoutes. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/service-orchestrator.js`.
- `packages/daemon/src/routes/events.ts`: Hono route module for events API endpoints. Exports: eventsRoute. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/types.js`.
- `packages/daemon/src/routes/packages.ts`: Hono route module for the legacy package-install surface (`/validate`, `/plan`, `/install`, history, rollback). AgentSpec validation no longer lives here. Exports: packagesRoutes. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, +6 more.
- `packages/daemon/src/routes/ps.ts`: Hono route module for ps API endpoints. Exports: psRoutes. Related: `packages/daemon/src/domain/ps-projection.js`.
@@ -187,8 +246,10 @@ Fast lookup:
- `packages/daemon/src/routes/transcripts.ts`: Hono route module for transcript access (`GET /:session/tail`, `/:session/grep`). Resolves session to rig/node, delegates to TranscriptStore. Exports: transcriptRoutes. Related: `packages/daemon/src/domain/transcript-store.js`, `packages/daemon/src/domain/rig-repository.js`.
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
- `packages/daemon/src/routes/stream.ts`: Hono route module for the PL-004 Phase A coordination L1 stream (`POST /emit`, `GET /list`, `GET /:id`, `POST /:id/archive`, `GET /watch` SSE). Host-scoped (no rigId). Exports: streamRoutes. Related: `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/event-bus.js`.
- `packages/daemon/src/routes/queue.ts`: Hono route module for the PL-004 Phase A coordination L3 queue plus inbox/outbox (`POST /create`, `POST /:id/{claim,unclaim,update,handoff,fallback}`, `GET /list`, `GET /overdue`, `GET /:id`, `GET /:id/transitions`, inbox/outbox subroutes, `GET /watch` SSE). Surfaces hot-potato strict-rejection structured errors with validReasons enum. Exports: queueRoutes. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/inbox-handler.js`, `packages/daemon/src/domain/outbox-handler.js`, `packages/daemon/src/domain/event-bus.js`.
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Enriches service-backed reviews with best-effort compose preview data for managed-app UI surfaces. Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
- `packages/daemon/src/routes/whoami.ts`: Hono route module for daemon-backed runtime identity lookup by `nodeId` or `sessionName`. Exports: whoamiRoutes. Related: `packages/daemon/src/domain/whoami-service.js`.
- `packages/daemon/src/routes/up.ts`: Hono route module for `/api/up`, routing direct rig specs, bundles, and rig-name restore into the staged bootstrap flow. Exports: upRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/up-command-router.js`.
@@ -197,7 +258,7 @@ Fast lookup:
### Entrypoints And Infrastructure
- `packages/cli/src/client.ts`: Shared HTTP client for talking to the daemon API, including raw JSON posts and text-body posts with optional extra headers such as `X-Rig-Root`. Exports: DaemonClient, DaemonConnectionError, DaemonResponse. Related: none.
- `packages/cli/src/daemon-lifecycle.ts`: Starts, stops, inspects, and tails the local daemon process. Spawns the daemon with `process.execPath` and bypasses local `daemon.json` gating when `OPENRIG_URL` is set by probing remote `/healthz` directly. Exports: DaemonState, DaemonStatus, LOG_FILE, LifecycleDeps, OPENRIG_DIR, STATE_FILE, StartOptions, getDaemonPath, getDaemonStatus, readLogs, startDaemon, stopDaemon, tailLogs. Related: none.
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (31 mounted command groups, including Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +18 more.
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (40 mounted command groups, including env status/logs/down, `destroy`, and Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +19 more.
- `packages/cli/src/mcp-server.ts`: Implements the CLI-hosted MCP server over stdio, wrapping 17 daemon-backed tools including node inventory, send/capture, bundle/spec validation, and chatroom. Exports: createMcpServer. Related: `packages/cli/src/client.js`.
### Commands
@@ -209,6 +270,7 @@ Fast lookup:
- `packages/cli/src/commands/daemon.ts`: Commander command module for `daemon`. Exports: daemonCommand, realDeps. Related: `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/discover.ts`: Commander command module for `discover`. Exports: discoverCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/down.ts`: Commander command module for `down`. Exports: downCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/env.ts`: Commander command module for `env` — rig environment status/logs/down for service-backed rigs and managed apps. Exports: envCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/commands/status.js`.
- `packages/cli/src/commands/export.ts`: Commander command module for `export`. Exports: ExportDeps, exportCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/import.ts`: Commander command module for `import`. Locally parses YAML to detect pod-aware rigs, sends `X-Rig-Root` for pod-aware preflight/instantiate flows, and supports `--rig-root`. Exports: ImportDeps, importCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/mcp.ts`: Commander command module for `mcp`. Exports: mcpCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`, +1 more.
@@ -219,9 +281,9 @@ Fast lookup:
- `packages/cli/src/commands/rig.ts`: Commander command module for `rig`. Subcommands: `validate` (POST `/api/rigs/import/validate`), `preflight` (POST `/api/rigs/import/preflight` with `X-Rig-Root` header). Both support `--json`. Exports: RigDeps, rigCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/snapshot.ts`: Commander command module for `snapshot`. Exports: snapshotCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/status.ts`: Commander command module for `status`. Exports: StatusDeps, statusCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. `add` accepts single YAML files and full spec directories containing `rig.yaml` or `agent.yaml`. Help/examples and not-found guidance explicitly call out managed apps. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/ui.ts`: Commander command module for `ui`. Exports: UiDeps, uiCommand. Related: `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Help/examples now treat managed apps like `secrets-manager` as first-class launch targets. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
- `packages/cli/src/commands/whoami.ts`: Commander command module for `whoami`. Resolves identity from explicit args, env vars, tmux metadata, and session-name fallback, then delegates to daemon `/api/whoami`. Exports: resolveIdentitySource, whoamiCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/adopt.ts`: Commander command module for `adopt` — materialize topology from a pod-aware RigSpec, then bind discovered live sessions according to `--bind` mappings. Exports: AdoptDeps, adoptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/import.js`, `packages/cli/src/daemon-lifecycle.js`.
@@ -230,6 +292,8 @@ Fast lookup:
- `packages/cli/src/commands/capture.ts`: Commander command module for `capture` — capture agent pane content. Supports `--lines N` and `--json`. Exports: captureCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/broadcast.ts`: Commander command module for `broadcast` — send to all agents in a rig or pod. Reports per-session delivery status. Exports: broadcastCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/transcript.ts`: Commander command module for `transcript` — composable transcript access via `--tail N` and `--grep "pattern"`. Exports: transcriptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/stream.ts`: Commander command module for `rig stream` (PL-004 Phase A) — coordination L1 intake stream. Subcommands: `emit`, `list`, `show`, `archive`. Speaks only to the daemon HTTP API; does not touch POC filesystem state. Exports: streamCommand, StreamDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/queue.ts`: Commander command module for `rig queue` (PL-004 Phase A) — coordination L3 queue plus inbox/outbox. Subcommands: `create`, `claim`, `unclaim`, `update`, `handoff`, `fallback`, `show`, `transitions`, `list`, `overdue`, `inbox-drop`, `inbox-absorb`, `inbox-deny`, `inbox-pending`, `outbox-record`, `outbox-list`. Hot-potato strict-rejection enforced at daemon. Exports: queueCommand, QueueDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
- `packages/cli/src/commands/config.ts`: Commander command module for `config` — read/write/reset config keys. Supports `--json`. Exports: configCommand. Related: `packages/cli/src/config-store.js`.
- `packages/cli/src/commands/preflight.ts`: Commander command module for `preflight` — system readiness checks. Supports `--json`. Exports: preflightCommand. Related: `packages/cli/src/system-preflight.js`, `packages/cli/src/config-store.js`.
- `packages/cli/src/commands/ask.ts`: Commander command module for `ask` — context evidence pack over transcripts + topology + chat. Supports `--json`. Exports: askCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
@@ -273,19 +337,20 @@ Fast lookup:
### Components: North Star + Post-North-Star Additions
- `packages/ui/src/components/Explorer.tsx`: Hierarchical tree navigator — rigs → pods → agents with status indicators. Primary navigation surface. Click rig loads graph, click node opens detail drawer. Exports: Explorer. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useNodeInventory.js`.
- `packages/ui/src/components/SharedDetailDrawer.tsx`: Unified drawer surface keyed by `DrawerSelection`. Routes to rig, node, system, discovery, or specs panels without leaving the current workspace. Exports: DrawerSelection, SharedDetailDrawer. Related: `packages/ui/src/components/DiscoveryPanel.js`, `packages/ui/src/components/NodeDetailPanel.js`, `packages/ui/src/components/RigDetailPanel.js`, `packages/ui/src/components/SpecsPanel.js`, `packages/ui/src/components/SystemPanel.js`.
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab. Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots/actions, and runtime drawer tabs (`Info`, conditional `Env`, `Chat Room`). Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
- `packages/ui/src/components/RigEnvPanel.tsx`: Runtime env surface for service-backed rigs. Shows overall env state, per-service health, health gates, surfaces, logs, and teardown action. Exports: RigEnvPanel. Related: `packages/ui/src/hooks/useRigEnv.js`.
- `packages/ui/src/components/NodeDetailPanel.tsx`: Runtime-first node drawer. Shows rig/session/runtime identity, peers, directional edges, transcript helpers, compact spec summary, status, actions (tmux/cmux/resume/full-details), startup files, and recent events. Exports: NodeDetailPanel. Related: `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`, `packages/ui/src/lib/copy-text.js`, `packages/ui/src/lib/display-name.js`.
- `packages/ui/src/components/NodeDetailPlaceholder.tsx`: Empty state for node detail when no node is selected. Exports: NodeDetailPlaceholder. Related: none.
- `packages/ui/src/components/DiscoveryPanel.tsx`: Drawer-native discovery/adoption surface. Supports scan/list, draft-rig generation, placement-first bind/adopt, and pod/member targeting inside the shared drawer. Exports: DiscoveryPlacementTarget, DiscoveryPanel. Related: `packages/ui/src/hooks/useDiscovery.js`.
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Lists library entries, opens review routes, and bridges into specs workspace tasks. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Presents a unified library list with `All / Apps / Rigs / Agents`, type/stability badges, richer managed-app rows, and review-route handoff. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/RigChatPanel.tsx`: Chat room tab in rig drawer — send messages, watch live stream, view history. Exports: RigChatPanel. Related: `packages/ui/src/hooks/useRigChat.js`.
- `packages/ui/src/components/LiveIdentityDisplay.tsx`: Reusable runtime-identity subsection for peers, edges, transcript helpers, and compact spec summary. Used by the node drawer and full-details route. Exports: LiveIdentityDisplay. Related: none.
- `packages/ui/src/components/LiveNodeDetails.tsx`: Explicit center-workspace route for deep inspection of one live node. Composes runtime identity plus reusable spec display components. Exports: LiveNodeDetails. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`.
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Supports a library-only managed-app Environment tab with compose preview, health gates, and surfaces. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
- `packages/ui/src/components/AgentSpecDisplay.tsx`: Canonical AgentSpec display primitive used by preview, library, and live-details flows. Exports: AgentSpecDisplay. Related: none.
- `packages/ui/src/components/RigSpecReview.tsx`: Route-level rig review page that consumes `RigSpecDisplay` and specs workspace state. Exports: RigSpecReview. Related: `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
- `packages/ui/src/components/AgentSpecReview.tsx`: Route-level agent review page that consumes `AgentSpecDisplay` and specs workspace state. Exports: AgentSpecReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Service-backed rigs surface managed-app copy, specialist identity, setup-prompt copy, and env-aware review details. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
- `packages/ui/src/components/SpecTopologyPreview.tsx`: Lightweight topology preview for structured rig review models. Exports: SpecTopologyPreview. Related: none.
- `packages/ui/src/components/SystemPanel.tsx`: Drawer surface for system-wide event log/status tabs. Replaces the old status-bar role with a richer right-drawer system surface. Exports: SystemPanel. Related: `packages/ui/src/hooks/useActivityFeed.js`.
- `packages/ui/src/components/WorkspaceHome.tsx`: Landing page when no rig is selected. Entry point for explorer-first workflow. Exports: WorkspaceHome. Related: `packages/ui/src/components/AppShell.js`.
@@ -315,16 +380,17 @@ Fast lookup:
- `packages/ui/src/hooks/usePsEntries.ts`: `/api/ps` summary hook. Exports: PsEntry, usePsEntries. Related: none.
- `packages/ui/src/hooks/useRigEvents.ts`: Rig-scoped SSE subscription and invalidation hook. Exports: UseRigEventsResult, useRigEvents. Related: none.
- `packages/ui/src/hooks/useRigGraph.ts`: Rig graph fetch/transformation hook. Exports: useRigGraph. Related: none.
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Exports: RigSummary, useRigSummary. Related: none.
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Includes `hasServices` so runtime/UI can gate managed-app env surfaces. Exports: RigSummary, useRigSummary. Related: none.
- `packages/ui/src/hooks/useSnapshots.ts`: Snapshot list hook for one rig. Exports: Snapshot, useSnapshots. Related: none.
### Hooks: North Star + Post-North-Star Additions
- `packages/ui/src/hooks/useNodeInventory.ts`: Fetches node inventory for a rig from the canonical `GET /api/rigs/:rigId/nodes` endpoint. Exports: useNodeInventory. Related: none.
- `packages/ui/src/hooks/useNodeDetail.ts`: Fetches extended node detail from `GET /api/rigs/:rigId/nodes/:logicalId`, including peers, edges, transcript helpers, and compact spec summary for the adopted-parity/live-identity drawer. Exports: NodeDetailData, useNodeDetail. Related: none.
- `packages/ui/src/hooks/useRigEnv.ts`: Fetches `GET /api/rigs/:rigId/env` for service-backed rig status, surfaces, and receipt data, with runtime-friendly refetch behavior. Exports: RigEnvData, useRigEnv. Related: none.
- `packages/ui/src/hooks/useRigChat.ts`: Chat send/watch/history hooks for the rig chat panel. Exports: useRigChat. Related: none.
- `packages/ui/src/hooks/useGlobalEvents.ts`: Global SSE subscription for explorer status updates. Exports: useGlobalEvents. Related: none.
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results and `hasServices`-aware managed-app classification. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. The rig review type now carries managed-app services metadata and compose preview. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
### Lib Utilities
- `packages/ui/src/lib/edge-styles.ts`: Edge-style resolver for graph links. Exports: EdgeStyleResult, getEdgeStyle. Related: none.
@@ -338,4 +404,5 @@ Fast lookup:
### Lib Utilities: North Star + Post-North-Star Additions
- `packages/ui/src/lib/display-id.ts`: Short ULID display utility — uses ULID tail (not head) for better uniqueness in same-view contexts. UI-only, no backend changes. Exports: shortId. Related: none.
- `packages/ui/src/lib/display-name.ts`: Pod name extraction from qualified logical IDs (e.g., "dev1" from "dev1.impl"). Exports: displayPodName, inferPodName. Related: none.
- `packages/ui/src/lib/build-setup-prompt.ts`: Managed-app setup-prompt generator used by library review copy surfaces. Exports: buildSetupPrompt. Related: none.
- `packages/ui/src/lib/copy-text.ts`: Clipboard copy utility for the "Copy tmux attach" and "Copy resume command" buttons. Exports: copyText. Related: none.
+418
View File
@@ -0,0 +1,418 @@
# AgentSpec Reference
Version: 1.0
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`
This is the canonical reference for the AgentSpec YAML format (`agent.yaml`). Every field, validation rule, and default documented here was traced from the actual parser and validator code.
---
## Minimal Valid Example
```yaml
name: my-agent
version: "1.0"
profiles:
default:
uses:
skills: []
guidance: []
subagents: []
hooks: []
runtime_resources: []
resources: {}
startup:
files: []
actions: []
```
## Practical Example (implementer agent)
```yaml
name: implementer
version: "1.0"
description: Implementation agent — writes code following TDD discipline
defaults:
runtime: claude-code
imports:
- ref: local:../../shared
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha]
guidance: []
subagents: []
hooks: []
runtime_resources: []
resources:
guidance:
- id: role
path: guidance/role.md
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
actions: []
```
## Complete Example (all features)
```yaml
name: vault-specialist
version: "1.0"
description: Vault specialist agent — manages HashiCorp Vault for this managed app
defaults:
runtime: claude-code
model: claude-opus-4-6
lifecycle:
execution_mode: interactive_resident
compaction_strategy: harness_native
restore_policy: resume_if_possible
imports:
- ref: local:../../shared
- ref: local:../common-tools
version: "2.0"
profiles:
default:
summary: Standard Vault operations profile
preferences:
runtime: claude-code
uses:
skills: [openrig-user, mental-model-ha, vault-user]
guidance: []
subagents: []
hooks: []
runtime_resources: []
startup:
files:
- path: guidance/profile-specific.md
delivery_hint: send_text
actions: []
lifecycle:
restore_policy: resume_if_possible
resources:
skills:
- id: vault-user
path: skills/vault-user
guidance:
- id: role
path: guidance/role.md
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
runtime: claude-code
type: claude_settings_fragment
startup:
files:
- path: guidance/role.md
delivery_hint: send_text
required: true
- path: startup/context.md
delivery_hint: send_text
required: true
- path: guidance/optional-tips.md
delivery_hint: guidance_merge
required: false
applies_on: [fresh_start]
actions:
- type: send_text
value: "Load vault-user skill and verify Vault health."
phase: after_ready
idempotent: true
```
---
## Top-Level Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `name` | string | yes | — | Agent name. Used in spec library identification and validation messages. |
| `version` | string | yes | — | Spec version. Informational — not used for compatibility gating. |
| `description` | string | no | — | Human-readable description. Shown in spec library and review surfaces. |
| `defaults` | Defaults | no | — | Default runtime, model, and lifecycle settings. Applied when not overridden by the rig spec or profile. |
| `imports` | Import[] | no | `[]` | Other AgentSpecs to import. Resources from imported specs become available for profile `uses` references. |
| `profiles` | map<string, Profile> | no | `{}` | Named profiles. Each profile selects resources and can override startup/lifecycle. The rig spec member's `profile` field selects which profile to use. |
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, hooks, runtime resources). These are the available pool that profiles select from via `uses`. |
| `startup` | StartupBlock | no | `{ files: [], actions: [] }` | Agent-level startup files and actions. Applied to all profiles via the startup layering model. |
---
## Defaults
```yaml
defaults:
runtime: claude-code
model: claude-opus-4-6
lifecycle:
execution_mode: interactive_resident
compaction_strategy: harness_native
restore_policy: resume_if_possible
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `runtime` | string | no | — | Default runtime for this agent. Can be overridden by the rig spec member's `runtime` field. |
| `model` | string | no | — | Default model. Can be overridden by the rig spec member's `model` field. |
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
### Lifecycle Defaults
| Field | Type | Default | Allowed Values |
|-------|------|---------|----------------|
| `execution_mode` | string | `interactive_resident` | `interactive_resident` (only value in v1; `wake_on_demand` is explicitly rejected) |
| `compaction_strategy` | string | `harness_native` | `harness_native`, `pod_continuity` (`custom_prompt` is explicitly rejected in v1) |
| `restore_policy` | string | `resume_if_possible` | `resume_if_possible`, `relaunch_fresh`, `checkpoint_only` |
---
## Imports
```yaml
imports:
- ref: local:../../shared
- ref: local:../common-tools
version: "2.0"
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ref` | string | yes | Reference to another AgentSpec directory. Must start with `local:` (relative) or `path:` (absolute). The referenced directory must contain an `agent.yaml`. |
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). |
### Import Resolution
- `local:` paths resolve relative to the importing spec's directory
- `path:` paths are absolute filesystem paths
- Imported resources become available for `uses` references in profiles
- When referencing imported resources in `uses`, use qualified `namespace:id` format (e.g., `shared:openrig-user`)
- Unqualified references (just `id`) resolve against the spec's own local resources first
### The Shared Import Pattern
Most built-in agents import the shared builtin spec:
```yaml
imports:
- ref: local:../../shared
```
This gives access to the full pool of shared skills (openrig-user, mental-model-ha, development-team, etc.) and agents select the ones they need via profile `uses`.
---
## Profiles
```yaml
profiles:
default:
summary: Standard operations profile
preferences:
runtime: claude-code
model: claude-opus-4-6
uses:
skills: [openrig-user, mental-model-ha, vault-user]
guidance: [role]
subagents: []
hooks: []
runtime_resources: []
startup:
files: []
actions: []
lifecycle:
restore_policy: resume_if_possible
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `summary` | string | no | — | Profile description. |
| `preferences` | object | no | — | Runtime/model preferences for this profile. |
| `preferences.runtime` | string | no | — | Preferred runtime. |
| `preferences.model` | string | no | — | Preferred model. |
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. |
### Uses
The `uses` block selects which resources from the `resources` pool (including imported resources) are active for this profile.
```yaml
uses:
skills: [openrig-user, mental-model-ha, vault-user]
guidance: [role]
subagents: []
hooks: [pre-commit]
runtime_resources: [claude-settings]
```
Each array contains resource IDs. These can be:
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
---
## Resources
```yaml
resources:
skills:
- id: vault-user
path: skills/vault-user
guidance:
- id: role
path: guidance/role.md
subagents:
- id: helper
path: subagents/helper
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
runtime: claude-code
type: claude_settings_fragment
```
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
### Resource Categories
| Category | Fields | Description |
|----------|--------|-------------|
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
| `subagents` | `id`, `path` | Subagent definitions. |
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
*Fields marked with `*` are optional.
Recognized runtime resource types:
- `claude_settings_fragment` — merge a JSON object into `<cwd>/.claude/settings.local.json`.
- `claude_mcp_fragment` — merge a JSON object into `<cwd>/.mcp.json`.
- `codex_config_fragment` — upsert a TOML fragment into `~/.codex/config.toml` inside an OpenRig-managed block.
Unknown runtime resource types are still copied to the runtime extension directory for agent-visible context.
### Resource Path Rules
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths)
- Paths resolve relative to the agent spec's directory
- Resource IDs must be unique within their category
- Resource IDs are the identifiers used in `uses` references
### Guidance Resources
```yaml
guidance:
- id: role
path: guidance/role.md
target: CLAUDE.md # optional — where to merge
merge: managed_block # optional — how to merge (default: managed_block)
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Resource identifier. |
| `path` | string | yes | — | Relative path to the guidance file. |
| `target` | string | no | — | Target file for merging (e.g., `CLAUDE.md`). |
| `merge` | string | no | `managed_block` | Merge strategy. One of: `managed_block`, `append`. |
---
## Startup Block
The startup block follows the same format as in the RigSpec (see `docs/reference/rig-spec.md` for full details on files and actions).
Agent-level startup is applied to all profiles. Profile-level startup is applied only when that profile is active. Both merge additively via the startup layering model.
### Delivery Hint Quick Reference
| Hint | When Delivered | Mechanism |
|------|---------------|-----------|
| `auto` | Before harness boot | System chooses based on file type |
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
| `skill_install` | Before harness boot | Installed to runtime skill directory |
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
---
## Validation Rules Summary
1. `name` and `version` are required non-empty strings.
2. `imports` must be an array of objects with `ref` field.
3. Import `ref` must start with `local:` (relative) or `path:` (absolute).
4. Import `version` must be an exact version (no ranges).
5. `profiles` must be a map (object), not an array.
6. Profile `uses` references must resolve to declared resources (local or imported).
7. Unqualified `uses` references that don't resolve to local resources require imports to be present.
8. Qualified `uses` references must be in `namespace:id` format.
9. All resource paths must be safe relative paths.
10. Resource IDs must be unique within their category.
11. `runtime_resources` entries require `runtime` field.
12. Lifecycle `execution_mode` must be `interactive_resident`.
13. Lifecycle `compaction_strategy` must be `harness_native` or `pod_continuity`.
14. Lifecycle `restore_policy` must be `resume_if_possible`, `relaunch_fresh`, or `checkpoint_only`.
15. Startup files and actions follow the same validation rules as in RigSpec.
---
## File System Layout
An agent spec directory follows this conventional layout:
```
my-agent/
agent.yaml # required — the AgentSpec
guidance/
role.md # role definition
startup/
context.md # boot-time grounding
skills/
my-skill/
SKILL.md # skill content
hooks/
pre-commit.sh # hook script
runtime/
claude-settings.fragment.json # runtime-specific resource
```
The only required file is `agent.yaml`. Everything else is referenced by paths in the spec and must exist at those relative paths.
---
## Shipped Examples
| Agent | Location | Imports | Profile Skills | Purpose |
|-------|----------|---------|---------------|---------|
| `shared` | `specs/agents/shared/` | none | — (resource pool only) | Shared skill pool for all built-in agents |
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, mental-model-ha, etc. | TDD implementation agent |
| `qa` | `specs/agents/development/qa/` | `shared` | openrig-user, development-team, etc. | Quality assurance agent |
| `orchestrator` | `specs/agents/orchestration/orchestrator/` | `shared` | openrig-user, orchestration-team, etc. | Rig orchestration lead |
| `reviewer` | `specs/agents/review/reviewer/` | `shared` | openrig-user, review-team, etc. | Independent code reviewer |
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, mental-model-ha, vault-user | Vault domain specialist |
| `design` | `specs/agents/design/` | none | — | Product designer |
+358
View File
@@ -0,0 +1,358 @@
# Agent Startup Guide
Version: 0.2.0
Last validated: 2026-04-11
Applies to: OpenRig 0.1.x
This guide teaches you how to think about what goes into an agent's startup experience — what files to write, where to put them, and how the layering model delivers them. It is an authoring guide, not a schema reference. For field-level details, see `rig-spec.md` and `agent-spec.md`.
---
## Two Categories of Startup
Everything an agent receives at boot time falls into one of two categories:
### Category 1: Context Loading
The agent reads markdown files into its context window. This is the **primary mechanism** in OpenRig today and the one you should invest most of your authoring effort in.
Context loading shapes what the agent knows, believes, and is capable of:
- Who it is (role, identity, pod membership)
- How the team works (culture, communication norms, coordination protocols)
- What the project is (codebase context, architecture, domain knowledge)
- What it can do (skills, SOPs, operational procedures)
- What its environment looks like (services, access credentials, tools)
### Category 2: Deterministic Configuration
The rig spec declaratively installs things into the agent's runtime environment:
- Hooks (git hooks, pre-commit scripts)
- Permissions (`.claude/settings.json` allowlists, approval modes)
- MCPs (Model Context Protocol servers)
- System dependencies (tools, packages)
See the **Current Support Matrix** section at the end of this guide for what is reliable today vs experimental.
**The v0.2.0 recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
---
## Context Loading: What Goes Where
### Skills vs Startup Files — The Key Distinction
**Skills** are reusable SOPs. They teach an agent HOW to do something — how to use OpenRig, how to do TDD, how to run a code review, how to operate Vault. Skills transfer across rigs. A skill you write once can be used by any agent in any rig.
**Startup/guidance files** are rig-specific and role-specific. They tell an agent WHO it is, WHAT it's working on, and HOW this particular team operates. They are authored per-rig and often per-pod or per-member.
| Put in a skill when... | Put in a startup/guidance file when... |
|------------------------|---------------------------------------|
| The knowledge is reusable across rigs | The knowledge is specific to this rig or project |
| It teaches a procedure or methodology | It teaches identity, role, or team context |
| It could be useful to any agent of this type | It's only useful to agents in this specific topology |
| Examples: `openrig-user`, `test-driven-development`, `vault-user` | Examples: `role.md`, `CULTURE.md`, `startup/context.md` |
### The Belt-and-Suspenders Pattern for Skills
Skills are delivered to agents through two parallel paths:
1. **Spec declaration:** The agent spec's `resources.skills` + profile `uses.skills` ensures the skill files are projected to the agent's workspace (installed before harness boot)
2. **Startup instruction:** The guidance/startup files tell the agent to actually read and load those skills
Both paths are important. The spec projection ensures the files are physically present. The startup instruction ensures the agent knows to read them. This redundancy is intentional — it handles cases where one path fails.
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, mental-model-ha" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
### The File Types
**Role guidance (`guidance/role.md`)**
Tells the agent who it is and what its responsibilities are:
- Title and primary function
- Specific responsibilities (bulleted list)
- Working rhythm (how the agent operates day-to-day)
- Principles (behavioral guidelines)
- Relationship to other team members
This is the agent's identity document. Every agent should have one.
**Rig culture (`CULTURE.md`)**
The rig-wide constitution. Applied to ALL agents in the rig:
- Communication norms (use `rig send`/chatroom, not raw tmux)
- Coordination protocol (how work flows between pods)
- Quality standards (what "done" means)
- Commit/merge policy
- Escalation rules
Think of this as the team operating manual that every team member reads on day one.
**Startup context (`startup/context.md`)**
Boot-time grounding information specific to this agent's operating environment:
- Identity recovery instructions (`rig whoami --json`)
- Environment details (service URLs, access credentials, ports)
- System check instructions (what should be running, how to verify)
- Role-specific delegation information (who to ask, who delegates to you)
This is especially important for managed-app specialists (e.g., a Vault specialist needs to know the Vault address and dev token).
**Project documentation**
Rig-level startup files that teach the agent about the project:
- Architecture overview
- Key conventions
- Domain vocabulary
- Recent context (what's been happening, what's in progress)
These go in rig-level or pod-level startup blocks and are delivered to relevant agents.
---
## The Layering Model
Startup content is merged additively through layers. Each layer adds to what the previous layers provided. Later layers do NOT replace earlier layers — they append.
### The Layers (in delivery order)
```
1. Agent layer — from the AgentSpec's top-level startup block
2. Profile layer — from the active profile's startup block
3. Rig layer — from the RigSpec's top-level startup block
4. Culture layer — from the RigSpec's culture_file
5. Pod layer — from the pod's startup block
6. Member layer — from the member's startup block in the RigSpec
7. Operator layer — injected at runtime (openrig-start overlay, context collector, etc.)
```
### What Each Layer Is For
| Layer | Authored by | Purpose | Example content |
|-------|-------------|---------|-----------------|
| Agent | Agent spec author | Core identity and capabilities that travel with this agent type | Role guidance, default skills |
| Profile | Agent spec author | Profile-specific variations | Different skill sets for "default" vs "minimal" profiles |
| Rig | Rig spec author | Rig-wide context for all agents | Team norms, project documentation |
| Culture | Rig spec author | Rig constitution | `CULTURE.md` — communication, quality, operating philosophy |
| Pod | Rig spec author | Pod-specific coordination context | Pod SOP, intra-pod workflow |
| Member | Rig spec author | Individual member overrides | Member-specific instructions, cwd-specific context |
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md`, context collector |
### Practical Guidance
**Most rigs only need three layers:** agent (role.md), rig (culture), and operator (openrig-start). Start simple. Add pod and member layers only when agents in the same pod need different startup content.
**The culture layer is high-value and often skipped.** A rig without a `CULTURE.md` relies on agents to guess how the team communicates and coordinates. Write one. Even a short culture file dramatically improves team coherence.
**Member-level startup is for exceptions, not the rule.** If every member has its own startup block, the layering model is being used as a configuration dump. Refactor shared content up to the pod or rig level.
---
## Delivery Mechanisms
### How Files Reach the Agent
| Delivery Hint | When | How | Use For |
|---------------|------|-----|---------|
| `auto` | Before harness boot | System chooses | Default — let OpenRig decide |
| `guidance_merge` | Before harness boot | Merged into `CLAUDE.md` / `AGENTS.md` as a managed block | Role guidance, culture, project context |
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
### Delivery Timing Matters
Files delivered via `guidance_merge` and `skill_install` happen BEFORE the agent's harness boots. The agent sees them immediately when it starts — they're part of the initial context.
Files delivered via `send_text` happen AFTER the harness is ready. The agent receives them as messages in its terminal. Use this for:
- Identity grounding (agent reads and processes the instructions)
- Instructions to load skills (the files are already projected, the message tells the agent to read them)
- Context that should feel like an operator briefing, not pre-loaded content
### The `applies_on` Field
Each startup file and action specifies when it applies:
- `fresh_start` — delivered on first launch only
- `restore` — delivered on restore from snapshot
- Default: `[fresh_start, restore]` (both)
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
---
## Deterministic Configuration
The AgentSpec and RigSpec allow declaring deterministic environment configuration:
### What the Spec Supports
**Hooks** (in `resources.hooks`):
```yaml
resources:
hooks:
- id: pre-commit
path: hooks/pre-commit.sh
runtimes: [claude-code]
```
Hooks are scripts copied to the agent's workspace. They can be git hooks, automation scripts, or environment setup.
**Runtime resources** (in `resources.runtime_resources`):
```yaml
resources:
runtime_resources:
- id: claude-settings
path: runtime/claude-settings.fragment.json
runtime: claude-code
type: claude_settings_fragment
```
Runtime-specific configuration files projected into the agent's runtime environment.
**Startup actions** (in `startup.actions`):
```yaml
startup:
actions:
- type: send_text
value: "/install-mcp my-server"
phase: after_ready
idempotent: true
```
Commands sent to the agent's terminal after it's ready. Can install MCPs, run setup commands, etc.
### Current Support Matrix (OpenRig 0.1.x)
| Capability | Status | Notes |
|------------|--------|-------|
| Guidance file projection (`guidance_merge`) | **Supported** | Reliable. Primary delivery mechanism. |
| Skill projection (`skill_install`) | **Supported** | Reliable. Skills are copied to workspace. |
| `send_text` delivery after ready | **Supported** | Reliable. Requires harness to be ready. |
| Hook projection | **Experimental** | Files are copied but execution/integration varies by runtime. |
| Runtime resource projection | **Supported for recognized fragments** | `claude_settings_fragment`, `claude_mcp_fragment`, and `codex_config_fragment` are applied to provider config. Unknown types are copied to runtime extension directories. |
| Permission allowlisting | **Supported via harness config** | Core OpenRig boot adds the minimal Claude `Bash(rig:*)` baseline. Additional policy should be supplied through spec-selected runtime resource fragments or native Codex config profiles, not adapter hard-code. |
| MCP installation | **Experimental** | Claude Code: `/mcp` interactive command or `claude mcp add` from CLI. Can also be described in startup files for agent self-configuration. Reliability depends on runtime TUI state. |
| System dependency installation | **Not deterministic** | Describe in startup files; agent handles via shell commands. |
| Recurring tasks / wake timers | **Runtime-dependent** | Claude Code supports recurring tasks via the `/loop` command. Codex does not have a confirmed equivalent. Orchestrators should include `/loop` instructions in startup for Claude Code agents. |
### The v0.2.0 Approach: Describe, Then Let the Agent Handle It
For anything beyond `guidance_merge`, `skill_install`, and `send_text`, the recommended approach is:
1. **Describe the desired end state** in a startup file (e.g., "You need these MCP servers configured, these permissions set, these hooks installed")
2. **Include a system check** in the startup instructions ("Verify your environment: check that X is installed, Y is configured, Z is accessible")
3. **Empower the agent to self-configure** ("If any of these are missing, install/configure them")
4. **Optionally also declare it in the spec** for when deterministic support improves — the spec serves as the blueprint, and the startup file serves as the fallback instruction
This way, when deterministic support becomes fully reliable, the agent will boot up, see that everything is already set up (by the deterministic path), run its system check, confirm everything looks good, and proceed. Until then, the agent reads the instructions and handles the setup itself.
### Runtime Config Disclosure
OpenRig performs best-effort deterministic runtime configuration for managed sessions. Core bootstrap stays minimal; user/custom policy belongs in spec-selected runtime resources. These writes are intentionally invasive and should be disclosed plainly:
- Claude global config: `~/.claude/settings.json`
Purpose: allow core `rig` commands without repeated Claude permission prompts.
- Claude global state: `~/.claude.json`
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
- Claude project-local config: `.claude/settings.local.json`
Purpose: apply context collector/activity hooks and selected `claude_settings_fragment` resources inside the project without committing them to git.
- Claude project-local MCP config: `.mcp.json`
Purpose: apply selected `claude_mcp_fragment` resources for Claude in that project.
- Codex global config: `~/.codex/config.toml`
Purpose: pre-trust managed workspaces and apply selected `codex_config_fragment` resources. Codex currently has no equivalent project-local MCP config path for global profile settings.
Two important caveats:
- these writes are best-effort and should still be paired with startup guidance so the local agent can verify and repair them if needed
- already-running adopted sessions may need restart before they pick up newly written config
### Runtime Differences
**Claude Code:**
- Reads from `CLAUDE.md` and `.claude/` directory
- Recurring tasks via the `/loop` command (e.g., `/loop 5m "check rig health"`) — this is NOT hooks; hooks are event-driven
- MCP server management via `/mcp` interactive command or `claude mcp add` from CLI
- Event-driven hooks system in `.claude/settings.json` — reacts to events like `PreToolUse`, `PostToolUse`, `SessionStart`, etc. (not time-based)
- Permission allowlisting via `.claude/settings.json` (`permissions.allow`, `permissions.deny`, `permissions.defaultMode`)
- Can self-configure MCP servers, permissions, and hooks from startup instructions
**Codex:**
- Reads from `AGENTS.md` and `.agents/` directory
- Recurring task support is limited — no confirmed equivalent of Claude Code's `/loop` command
- MCP configuration mechanism differs from Claude Code
- Approval mode controlled via launch flags (`-a`, `-s`, `--full-auto`)
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
---
## Patterns and Anti-Patterns
### Good Patterns
**Start with role + culture + one skill**
```
agent.yaml → guidance/role.md
rig.yaml → culture_file: CULTURE.md
profile → uses.skills: [openrig-user]
```
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
**Separate project context from role**
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
**Tell the agent about its skills explicitly**
In a startup file or guidance, include a line like:
```
You have the following skills loaded: openrig-user, test-driven-development, mental-model-ha. Use them.
```
This prompts the agent to actually invoke the skills, not just have them as passive context.
**Include a system check in startup context**
```
## System Check
After identity recovery, verify:
1. `rig ps --nodes` shows your rig running
2. `rig env status` shows services healthy (if applicable)
3. Your working directory is correct
4. Required tools are available (node, npm, git, etc.)
If anything is missing, fix it before starting work.
```
### Anti-Patterns
**Dumping everything into one giant CLAUDE.md**
Don't. Use the layering model. Role goes in the agent spec. Culture goes in the rig spec. Project context goes in rig/pod startup files. If everything is in one file, you can't reuse any of it.
**Duplicating skill content in guidance files**
If you find yourself copying text from a skill into a guidance file, stop. Reference the skill instead. Skills are projected; guidance should point to them, not duplicate them.
**Using send_text for content that should be pre-loaded**
If the agent needs to know something BEFORE it starts reasoning, use `guidance_merge` (delivered before boot), not `send_text` (delivered after boot). `send_text` is for instructions the agent should process as a first task, not for foundational context.
**Over-specifying member-level startup**
If every member has a large startup block, the rig spec becomes a configuration dump. Refactor shared content up to the pod level. Member-level startup should be small overrides, not complete agent briefings.
**Relying on deterministic hooks for critical setup**
If your rig REQUIRES a hook to function and the hook installation fails silently, the agent won't know something is wrong. Always pair deterministic config with a startup instruction or system check that verifies the result.
---
## Authoring Checklist
When creating a new agent's startup experience:
- [ ] Write a `guidance/role.md` — who is this agent?
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
- [ ] Choose skills from the shared pool via profile `uses`
- [ ] Write a `startup/context.md` if the agent needs environment grounding
- [ ] Include a system check in the startup context
- [ ] Verify the agent spec validates: `rig agent validate agent.yaml`
- [ ] Verify the rig spec validates: `rig spec validate rig.yaml`
- [ ] Test by launching the rig and checking that the agent received the expected content
+126
View File
@@ -0,0 +1,126 @@
# Edge Types Reference
Version: 0.2.0
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`
---
## Overview
Edges define relationships between members in a rig topology. They appear in two places:
- **Pod-local edges** — between members in the same pod (use unqualified member IDs)
- **Cross-pod edges** — between members in different pods (use `pod.member` format)
See `docs/reference/rig-spec.md` for the YAML syntax.
## Edge Kinds
Five edge kinds are accepted by the validator:
| Kind | Accepted | Has Runtime Behavior | Description |
|------|----------|---------------------|-------------|
| `delegates_to` | yes | **yes — affects launch order** | Source delegates work to target |
| `spawned_by` | yes | **yes — affects launch order** | Target was spawned by source |
| `can_observe` | yes | no | Source can observe target's output |
| `collaborates_with` | yes | no | Peer collaboration |
| `escalates_to` | yes | no | Source escalates to target |
## What Edges Actually Do Today (OpenRig 0.1.x)
### Launch ordering
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails.
All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT constrain launch order.
### Graph visualization
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
### Identity projection
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
### Attach hint heuristic
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) uses edges to prefer the orchestrator as the default attach target. The heuristic looks for the first node with `delegates_to` outgoing edges.
## What Edges Do NOT Do Today
Edges do NOT currently:
- **Route messages** — `rig send` can target any session, regardless of edges
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
- **Control permissions** — there's no edge-based access control
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
These are aspirational capabilities. The edge vocabulary is intentionally richer than the current runtime behavior so that the topology captures design intent even before the runtime enforces it.
## Design Intent (Why We Have Five Kinds)
The five kinds represent a taxonomy of how agents relate to each other in a working team:
**`delegates_to`** — the most common edge. An orchestrator delegates to an implementer. A lead delegates to a worker. This is the primary workflow direction. When an orchestrator sends a task, it uses `rig send` to the session it `delegates_to`.
**`spawned_by`** — for hierarchical launch relationships. A subagent spawned by a parent process. Less common in current topologies.
**`can_observe`** — the review/oversight relationship. A reviewer observes an implementer's work. The reviewer can `rig capture` and `rig transcript` the implementation session. This edge communicates intent: "I'm watching your output."
**`collaborates_with`** — peer relationships. Two agents working side-by-side at the same level of the hierarchy. Neither delegates to the other.
**`escalates_to`** — the reverse of delegation. When a worker hits a decision it can't make, it escalates to a lead. This is less common in current topologies but represents a real coordination pattern.
## Choosing Edge Kinds
### Common Patterns
**Orchestrator → workers:**
```yaml
edges:
- kind: delegates_to
from: orch.lead
to: dev.impl
```
**Reviewer → implementer:**
```yaml
edges:
- kind: can_observe
from: rev.r1
to: dev.impl
```
**Implementation pair:**
```yaml
edges:
- kind: delegates_to
from: impl
to: qa
```
### When You're Not Sure
If you're unsure which edge kind to use:
1. Does one agent give work to the other? → `delegates_to`
2. Does one agent watch the other's output? → `can_observe`
3. Are they equals working together? → `collaborates_with`
4. Does one report problems up? → `escalates_to`
5. Did one create the other? → `spawned_by`
If none fit, `can_observe` is the safest default — it documents the relationship without implying a workflow direction and doesn't affect launch ordering.
## Validation Rules
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
5. Self-edges are not validated at the schema level but are not meaningful
+367
View File
@@ -0,0 +1,367 @@
# RigBundle Reference
Version: 2 (pod-aware)
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/bundle-types.ts`, `packages/daemon/src/domain/bundle-archive.ts`, `packages/daemon/src/domain/pod-bundle-assembler.ts`
A `.rigbundle` is a self-contained distributable archive that packages a rig spec, all referenced agent specs, their resources (skills, guidance, startup files), culture file, documentation, and an integrity manifest into a single file. The recipient can install and launch the rig without needing the original source tree.
---
## Archive Format
A `.rigbundle` file is a gzip-compressed tar archive (`.tar.gz`) with a fixed structure.
### File extension
The archive MUST have the `.rigbundle` extension. The packer rejects output paths that don't end with `.rigbundle`.
### Sibling digest file
Every `.rigbundle` has a sibling `.rigbundle.sha256` file containing the SHA-256 hex digest of the archive. This detects corruption during transfer. The unpacker verifies this digest before extraction.
Example:
```
my-rig.rigbundle — the archive
my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
```
### Determinism
The packer produces deterministic output:
- Files are sorted alphabetically
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
- Portable mode normalizes uid/gid/mode
- Maximum gzip compression (level 9)
This means the same inputs always produce the same archive hash.
---
## Archive Layout
```
bundle.yaml — manifest (required)
rig.yaml — the RigSpec (required, may be rewritten)
CULTURE.md — culture file (if declared in rig spec)
SETUP.md — documentation (if declared in rig spec docs field)
agents/
agent-name/
agent.yaml — AgentSpec
guidance/
role.md — guidance files
skills/
skill-name/
SKILL.md — skill files
startup/
context.md — startup files
```
### Key rules
- `bundle.yaml` is the manifest — always present, always at the root
- `rig.yaml` is the rig spec — rewritten during assembly with vendored `agent_ref` paths
- Agent directories are vendored copies of the original agent specs with all their resources
- Import refs are rewritten from the original `local:` or `path:` paths to bundle-relative `local:` paths
- All file paths within the archive are safe relative paths (no `..`, no absolute, no symlinks)
---
## Manifest (`bundle.yaml`)
The manifest is a YAML file at the archive root that describes the bundle contents.
### Schema Version 2 (pod-aware, current)
```yaml
schema_version: 2
name: my-bundle
version: "0.1.0"
created_at: "2026-04-11T22:32:48.570Z"
rig_spec: rig.yaml
agents:
- name: pm-lead
version: "1.0"
path: agents/pm-lead
original_ref: "local:agents/pm-lead"
hash: "ed4cff20..."
import_entries: []
- name: researcher
version: "1.0"
path: agents/researcher
original_ref: "local:agents/researcher"
hash: "11f8a077..."
import_entries:
- name: shared
version: "1.0"
path: agents/shared
original_ref: "local:../../shared"
hash: "abc123..."
culture_file: CULTURE.md
integrity:
algorithm: sha256
files:
rig.yaml: "b80c0674..."
CULTURE.md: "9354361b..."
agents/pm-lead/agent.yaml: "ed4cff20..."
# ... every file in the archive
```
### Manifest Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `schema_version` | number | yes | Must be `2` for pod-aware bundles. |
| `name` | string | yes | Bundle name. |
| `version` | string | yes | Bundle version. |
| `created_at` | string | yes | ISO-8601 timestamp of creation. |
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
| `agents` | AgentEntry[] | yes | Array of vendored agent entries. |
| `culture_file` | string | no | Relative path to the culture file if present. |
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. |
### Agent Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Agent name (from agent.yaml). |
| `version` | string | no | Agent version. |
| `path` | string | yes | Relative path to the vendored agent directory. Safe relative path. |
| `original_ref` | string | yes | The original `agent_ref` before rewriting. |
| `hash` | string | yes | SHA-256 hash of the agent.yaml content. |
| `import_entries` | ImportEntry[] | yes | Vendored imports for this agent (may be empty). |
### Import Entry Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | yes | Imported agent name. |
| `version` | string | yes | Imported agent version. |
| `path` | string | yes | Relative path to the vendored import within the archive. |
| `original_ref` | string | yes | The original import ref before rewriting. |
| `hash` | string | yes | SHA-256 hash of the imported agent.yaml. |
### Integrity Section
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `algorithm` | string | yes | Must be `sha256`. |
| `files` | map<string, string> | yes | Map of archive-relative file path → SHA-256 hex hash. Every file in the archive (except `bundle.yaml` itself) should be listed. |
---
## Security Model
Bundle integrity provides **self-consistency verification, not authenticity**.
- The sibling `.sha256` file detects corruption during transfer
- The per-file integrity hashes detect tampering of individual files within the archive
- Neither mechanism authenticates the bundle author
An attacker who can rewrite the full bundle + digest can bypass verification. Users must trust the source they obtained the bundle from. This is the same trust model as unsigned npm packages and Docker images.
Future enhancement: cryptographic signing (Ed25519) for author authentication.
---
## Safety Guarantees
The unpacker enforces these safety rules before extraction:
1. **No symlinks or hardlinks** — `SymbolicLink` and `Link` entries are rejected
2. **No absolute paths** — entries starting with `/` are rejected
3. **No path traversal** — entries containing `..` segments are rejected
4. **Digest verification** — archive SHA-256 must match the sibling `.sha256` file
5. **Content integrity** — after extraction, per-file hashes are verified against the manifest
If any check fails, extraction is aborted and an error is thrown.
---
## CLI Surface
### Create a bundle
```bash
rig bundle create <spec-path> -o <output.rigbundle> [--rig-root <dir>] [--name <name>] [--bundle-version <ver>]
```
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `<spec-path>` | yes | — | Path to the rig spec YAML file. |
| `-o, --output` | yes | — | Output path. Must end with `.rigbundle`. |
| `--rig-root` | no | spec directory | Root directory for resolving `agent_ref` and other relative paths. |
| `--name` | no | `my-bundle` | Bundle name in the manifest. |
| `--bundle-version` | no | `0.1.0` | Bundle version in the manifest. |
The create command:
1. Validates the rig spec
2. Resolves all `agent_ref` paths and their imports
3. Vendors all agent specs, resources, and startup files into a staging directory
4. Rewrites `agent_ref` paths to bundle-relative `local:` refs
5. Collects culture file, docs files, and rig-level startup files
6. Computes per-file integrity hashes
7. Writes the manifest (`bundle.yaml`)
8. Packs into a deterministic `.tar.gz`
9. Writes the sibling `.sha256` digest
### Inspect a bundle
```bash
rig bundle inspect <bundle-path> [--json]
```
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install the bundle.
### Install a bundle
```bash
rig bundle install <bundle-path> [--plan] [--yes] [--target <root>] [--json]
```
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `<bundle-path>` | yes | — | Path to the `.rigbundle` file. |
| `--plan` | no | `false` | Preview the bootstrap plan without installing or launching. |
| `--yes` | no | `false` | Auto-approve trusted actions during apply mode. |
| `--target <root>` | yes in apply mode | — | Target root directory for package installation. Required unless `--plan` is used. |
| `--json` | no | `false` | Emit machine-readable JSON. |
Extracts the bundle, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
### Launch directly
```bash
rig up <bundle-path> [--target <root>] [--cwd <dir>]
```
`rig up` auto-detects `.rigbundle` files and routes them through the bundle bootstrap path.
- `--target <root>` controls where packaged files are installed
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory
- `--cwd <dir>` does **not** change the install target; it only overrides the launched members' working directory for that run
---
## Assembly Process
When `rig bundle create` runs, the `PodBundleAssembler` performs these steps:
1. **Parse and validate** the rig spec
2. **Collect rig-level files:**
- Culture file (if `culture_file` is set)
- Docs files (if `docs` array is set) — **required: missing docs fail assembly**
- Rig-level startup files
- Pod-level and member-level startup files
3. **For each member's `agent_ref`:**
- Resolve the ref to an agent spec directory
- Copy the agent spec and all its resources (skills, guidance, hooks, startup, runtime resources)
- Recursively resolve and copy imports
- Record the agent entry in the manifest with its hash
- Rewrite the ref to a bundle-relative `local:` path
4. **Write the rewritten rig spec** to the staging directory
5. **Compute integrity** — SHA-256 of every file in the staging directory
6. **Write the manifest** (`bundle.yaml`) with all entries and integrity
7. **Pack** the staging directory into a deterministic tar.gz
### Terminal nodes
Members with `agent_ref: "builtin:terminal"` are bundle-native sentinels. They are not vendored — the runtime handles them directly.
### Deduplication
If multiple members reference the same agent spec (same resolved path), the spec is vendored once and all members' refs are rewritten to the same bundle-relative path.
### Import resolution
When an agent spec has `imports`, each import is resolved, vendored into the bundle, and the import refs in the vendored agent.yaml are rewritten to bundle-relative `local:` paths. Import entries are recorded in the manifest's agent entry.
---
## Validation Rules Summary
### Manifest validation (schema version 2)
1. `schema_version` must be `2`
2. `name` is required non-empty string
3. `version` is required non-empty string
4. `created_at` is required non-empty string
5. `rig_spec` is required and must be a safe relative path
6. `agents` must be an array
7. Each agent must have `name`, `path` (safe relative), and `hash`
8. Integrity `algorithm` must be `sha256`
9. Integrity `files` must be a non-empty map of safe-relative-path → 64-char hex hash
### Archive safety (enforced on unpack)
1. No symlinks or hardlinks
2. No absolute paths
3. No `..` path traversal
4. Archive digest must match sibling `.sha256`
5. Per-file content hashes must match integrity section
### Assembly validation
1. Rig spec must validate
2. All `agent_ref` paths must resolve to valid agent specs
3. All declared `docs` files must exist on disk (missing docs fail assembly)
4. Culture file and startup files are collected best-effort (missing = skipped)
---
## Legacy Bundles (Schema Version 1)
Schema version 1 bundles are the pre-reboot format using flat-node rig specs and package-based bundling. They are still supported for backward compatibility but should not be created for new rigs.
Key differences from v2:
- `schema_version: 1` in the manifest
- `packages` array instead of `agents` array
- Package entries have `original_source` instead of `original_ref`
- No `import_entries` in package entries
- Legacy rig spec format (flat nodes, not pods)
---
## Example: Creating and Using a Bundle
### Create
```bash
# From the rig directory
rig bundle create rig.yaml -o my-team.rigbundle --rig-root . --name my-team --bundle-version 1.0.0
```
Output:
```
Bundle created: my-team.rigbundle
Name: my-team v1.0.0
Hash: a1b2c3d4e5f6...
```
### Inspect
```bash
rig bundle inspect my-team.rigbundle
```
Output:
```
Bundle: my-team v1.0.0
Digest valid: true
Integrity: PASS
```
### Install and launch
```bash
cd ~/projects/my-project
rig up /path/to/my-team.rigbundle
```
Equivalent explicit form:
```bash
rig up /path/to/my-team.rigbundle --target ~/projects/my-project
```
The bundle is extracted to a temporary directory, integrity is verified, packaged files are installed into the target root, and the rig is bootstrapped with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
+479
View File
@@ -0,0 +1,479 @@
# RigSpec Reference
Version: 0.2 (pod-aware)
Last validated against code: 2026-04-11
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
---
## Minimal Valid Example
```yaml
version: "0.2"
name: my-rig
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: "."
edges: []
edges: []
```
## Complete Example (all features)
```yaml
version: "0.2"
name: my-product-team
summary: A full product squad with orchestration, development, and review pods.
culture_file: culture/CULTURE.md
docs:
- path: SETUP.md
- path: README.md
startup:
files:
- path: guidance/team-norms.md
delivery_hint: guidance_merge
required: true
actions: []
services:
kind: compose
compose_file: docker-compose.yaml
project_name: my-product
profiles: [core]
down_policy: down
wait_for:
- url: http://127.0.0.1:5432/health
- service: redis
condition: healthy
surfaces:
urls:
- name: App
url: http://127.0.0.1:3000
commands:
- name: psql
command: "psql postgresql://app:[email protected]:5432/app"
checkpoints:
- id: postgres
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
pods:
- id: orch
label: Orchestration
members:
- id: lead
agent_ref: "local:agents/orchestrator"
profile: default
runtime: claude-code
cwd: "."
- id: peer
agent_ref: "local:agents/orchestrator"
profile: default
runtime: codex
cwd: "."
edges: []
- id: dev
label: Development
summary: Implementation and quality assurance pair.
continuity_policy:
enabled: true
sync_triggers: [pre_compaction, pre_shutdown]
artifacts:
session_log: true
restore_brief: true
restore_protocol:
peer_driven: true
verify_via_quiz: false
startup:
files:
- path: guidance/dev-sop.md
delivery_hint: guidance_merge
required: true
actions: []
members:
- id: impl
agent_ref: "local:agents/impl"
profile: default
runtime: claude-code
cwd: "."
label: "Implementation Lead"
model: claude-opus-4-6
restore_policy: resume_if_possible
startup:
files:
- path: guidance/impl-specific.md
delivery_hint: send_text
required: false
applies_on: [fresh_start]
actions:
- type: send_text
value: "Load the implementation-pair skill and begin."
phase: after_ready
idempotent: true
- id: qa
agent_ref: "local:agents/qa"
profile: default
runtime: codex
cwd: "."
edges:
- kind: delegates_to
from: impl
to: qa
- id: rev
label: Review
members:
- id: r1
agent_ref: "local:agents/reviewer"
profile: default
runtime: claude-code
cwd: "."
- id: r2
agent_ref: "local:agents/reviewer"
profile: default
runtime: codex
cwd: "."
edges: []
edges:
- kind: delegates_to
from: orch.lead
to: dev.impl
- kind: delegates_to
from: orch.peer
to: dev.qa
- kind: can_observe
from: rev.r1
to: dev.impl
- kind: can_observe
from: rev.r2
to: dev.qa
```
---
## Top-Level Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `version` | string | yes | — | Must be `"0.2"` for pod-aware specs. |
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
| `summary` | string | no | — | Human-readable description. Shown in spec library, review surfaces, and `rig specs show`. |
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, services boot before any agent launches. |
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
---
## Pod
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
| `summary` | string | no | — | Pod description. |
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy. Controls compaction recovery, artifact management, and peer-driven restoration. |
| `startup` | StartupBlock | no | — | Pod-level startup files and actions. Applied to all members in this pod via the startup layering model. |
| `members` | Member[] | yes | — | At least one member required (enforced by pods needing content). |
| `edges` | PodLocalEdge[] | no | `[]` | Edges between members within this pod. Must use unqualified member IDs (not `pod.member`). |
### Pod ID Rules
- Must not contain dots (`.`)
- Must be unique across all pods in the rig
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
---
## Member
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `terminal`. |
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
### Terminal Nodes
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
```yaml
runtime: terminal
agent_ref: "builtin:terminal"
profile: none
```
All three must be present together. Any partial combination is a validation error.
### agent_ref Rules
- Must start with `local:` or `path:`
- `local:` paths are relative to the rig spec file's directory (the rig root)
- `path:` paths are absolute filesystem paths
- The referenced path must contain an `agent.yaml` file
- Exception: `builtin:terminal` for terminal nodes
### Session Naming
The canonical session name is derived from the pod ID, member ID, and rig name:
```
{podId}-{memberId}@{rigName}
```
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
---
## Edges
### Edge Kinds
| Kind | Meaning | Use When |
|------|---------|----------|
| `delegates_to` | Source delegates work to target. Constrains launch order. | Orchestrator → implementer, lead → worker |
| `spawned_by` | Target was spawned by source. Constrains launch order. | Parent → child in hierarchical topologies |
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
### Pod-Local Edges
Edges within a pod use **unqualified member IDs** (just the member `id`, not `pod.member`):
```yaml
pods:
- id: dev
members:
- id: impl
# ...
- id: qa
# ...
edges:
- kind: delegates_to
from: impl # NOT dev.impl
to: qa # NOT dev.qa
```
Both `from` and `to` must reference members that exist in the same pod.
### Cross-Pod Edges
Edges between pods use **fully-qualified `pod.member` IDs**:
```yaml
edges:
- kind: delegates_to
from: orch.lead # pod.member format
to: dev.impl # pod.member format
```
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
---
## Startup Block
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see `docs/reference/startup-layering.md`).
### Files
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
#### Delivery Hints
| Hint | Behavior |
|------|----------|
| `auto` | System chooses based on file type and context. |
| `guidance_merge` | Merged into the runtime's guidance file (`CLAUDE.md` or `AGENTS.md`) as a managed block. Delivered before harness boot. |
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
### Actions
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`. Note: `shell` is explicitly NOT supported in v1. |
| `value` | string | yes | — | The command or text to send. |
| `phase` | string | no | `after_files` | When to execute. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). |
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
---
## Services Block
The services block is optional. When present, services boot before any agent node launches. If service health checks fail, agent launch is blocked.
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `kind` | string | yes | — | Service backend. Only `compose` is supported in v1. |
| `compose_file` | string | yes | — | Relative path to the Docker Compose file. Must be a safe relative path. Resolved relative to rig root. |
| `project_name` | string | no | derived from rig name | Docker Compose project name. Must match `[a-z0-9][a-z0-9_-]*`. If omitted, derived by sanitizing the rig name. |
| `profiles` | string[] | no | — | Compose profiles to activate. |
| `down_policy` | string | no | `down` | What happens on `rig down`. One of: `leave_running`, `down`, `down_and_volumes`. |
| `wait_for` | WaitTarget[] | no | — | Health targets that must pass before agent launch. |
| `surfaces` | Surfaces | no | — | Metadata about accessible URLs and commands. Not executed — informational only. |
| `checkpoints` | CheckpointHook[] | no | — | Shell commands for checkpoint export/import during snapshot/restore. |
### Wait Targets
Each target must define exactly one of `service`, `url`, or `tcp`:
```yaml
wait_for:
# HTTP probe — hits the URL, expects 2xx
- url: http://127.0.0.1:8200/v1/sys/health
# TCP probe — connects to host:port
- tcp: "127.0.0.1:5432"
# Compose health check — requires Docker health to report "healthy"
- service: postgres
condition: healthy
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | one of three | HTTP URL to probe. |
| `tcp` | string | one of three | `host:port` for TCP probe. |
| `service` | string | one of three | Compose service name. Requires `condition: healthy`. |
| `condition` | string | only with `service` | Must be `healthy`. Only valid with `service` targets. |
### Surfaces
```yaml
surfaces:
urls:
- name: Vault UI
url: http://127.0.0.1:8200/ui
commands:
- name: Vault status
command: "vault status -address=http://127.0.0.1:8200"
```
Surfaces are metadata only. They are displayed in the UI and in `rig env status` output but are NOT executed by OpenRig.
### Checkpoint Hooks
```yaml
checkpoints:
- id: postgres
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | yes | Unique identifier for this checkpoint. |
| `export` | string | yes | Shell command to export state. `{{artifacts_dir}}` is replaced with a daemon-managed path. |
| `import` | string | no | Shell command to import state on restore. |
Checkpoint hooks are shell commands run by the daemon. They are best-effort — a failed export does not block snapshot, but continuity is classified as `receipt_only` instead of `checkpointed`.
---
## Continuity Policy
Optional pod-level configuration for compaction recovery behavior.
```yaml
continuity_policy:
enabled: true
sync_triggers: [pre_compaction, pre_shutdown, manual, milestone]
artifacts:
session_log: true
restore_brief: true
quiz: false
restore_protocol:
peer_driven: true
verify_via_quiz: false
```
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `enabled` | boolean | yes | — | Whether continuity is active for this pod. |
| `sync_triggers` | string[] | no | — | When to sync. Values: `pre_compaction`, `pre_shutdown`, `manual`, `milestone`. |
| `artifacts.session_log` | boolean | no | — | Whether to maintain a session log. |
| `artifacts.restore_brief` | boolean | no | — | Whether to maintain a restore brief. |
| `artifacts.quiz` | boolean | no | — | Whether to use quiz-based verification. |
| `restore_protocol.peer_driven` | boolean | no | — | Whether peers drive the restore process. |
| `restore_protocol.verify_via_quiz` | boolean | no | — | Whether to verify restoration via quiz. |
---
## Validation Rules Summary
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
1. `version` and `name` are required non-empty strings.
2. `pods` must be a non-empty array.
3. Pod IDs must not contain dots and must be unique.
4. Pod labels are required.
5. Member IDs must not contain dots and must be unique within their pod.
6. `agent_ref`, `profile`, `runtime`, and `cwd` are required for every member.
7. Terminal nodes require the exact triple: `runtime: terminal`, `agent_ref: builtin:terminal`, `profile: none`.
8. `agent_ref` must start with `local:` (relative) or `path:` (absolute), except `builtin:terminal`.
9. `local:` refs must be relative paths. `path:` refs must be absolute paths.
10. `restore_policy` must be one of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`.
11. Pod-local edges use unqualified member IDs. Cross-pod edges use `pod.member` format.
12. Cross-pod edges must reference different pods.
13. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`.
14. All file paths (`culture_file`, startup file paths, `compose_file`) must be safe relative paths.
15. `services.kind` must be `compose`.
16. `services.compose_file` is required when services is present.
17. `services.project_name` must match `[a-z0-9][a-z0-9_-]*`.
18. `services.down_policy` must be one of: `leave_running`, `down`, `down_and_volumes`.
19. Each wait target must define exactly one of: `service`, `url`, `tcp`.
20. `condition` is only valid on `service` targets and must be `healthy`.
21. Startup file `delivery_hint` must be one of: `auto`, `guidance_merge`, `skill_install`, `send_text`.
22. Startup action `type` must be one of: `slash_command`, `send_text`. (`shell` is explicitly rejected.)
23. Startup action `phase` must be one of: `after_files`, `after_ready`.
24. Startup action `idempotent` is a required boolean.
25. Non-idempotent actions must not include `restore` in `applies_on`.
26. `applies_on` values must be from: `fresh_start`, `restore`.
---
## Shipped Examples
These are the built-in specs shipped with OpenRig. Read them as worked examples.
| Spec | Location | Pods | Members | Services |
|------|----------|------|---------|----------|
| `product-team` | `packages/daemon/specs/rigs/product-team.yaml` | orch, dev, rev | 7 (lead, peer, impl, qa, design, r1, r2) | no |
| `implementation-pair` | `packages/daemon/specs/rigs/implementation-pair.yaml` | dev | 2 (impl, qa) | no |
| `adversarial-review` | `packages/daemon/specs/rigs/adversarial-review.yaml` | orch, rev | 3 (lead, r1, r2) | no |
| `research-team` | `packages/daemon/specs/rigs/research-team.yaml` | orch, research | 3 (lead, analyst, synthesizer) | no |
| `secrets-manager` | `packages/daemon/specs/rigs/launch/secrets-manager/rig.yaml` | vault | 1 (specialist) | yes (Vault) |
+65
View File
@@ -0,0 +1,65 @@
# Release Notes
This directory is the lightweight release history for OpenRig.
It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.1.13.md`
- and so on
## Why This Exists
We want a practical release-management pattern that works with how OpenRig is actually being shipped today:
- npm package release
- optional Git tag
- optional GitHub Release
- short, human-written summary of what is included
This keeps release notes:
- easy to author
- easy to link in GitHub Releases
- easy to paste into announcements
- durable in the repo
## Minimal Process
For each release:
1. Copy `_template.md` to `vX.Y.Z.md`.
2. Fill in the release summary, included changes, operator notes, known limitations, and verification performed.
3. Create a git tag for the release:
```bash
git tag vX.Y.Z
git push origin vX.Y.Z
```
4. Create a GitHub Release using the same file:
```bash
gh release create vX.Y.Z \
--repo mvschwarz/openrig \
--title "OpenRig vX.Y.Z" \
--notes-file docs/releases/vX.Y.Z.md
```
5. Publish the npm package if that is part of the release flow.
## Guidance
- Prefer user-facing language over commit-log language.
- Group related fixes into a small number of bullets.
- Be explicit about operator-impacting changes: setup, permissions, startup state, restore, recovery, and environment notes.
- Keep internal refactors out unless they materially change user behavior or operator confidence.
- If verification was limited, say so directly.
## Scope
This directory is a release-note archive, not a full historical changelog taxonomy.
If OpenRig later wants a curated `CHANGELOG.md`, it can be generated or summarized from the release notes here.
+38
View File
@@ -0,0 +1,38 @@
# OpenRig vX.Y.Z
## Summary
One short paragraph describing the release at a human level.
## Included In This Release
- Change or fix cluster 1
- Change or fix cluster 2
- Change or fix cluster 3
## User-Facing Changes
- What users or operators will notice
- What behavior is different now
- Any new classifications, flows, or visible fixes
## Operator / Setup Notes
- Any upgrade, restart, or environment notes
- Any migration or manual action notes
- Any caveats for fresh installs or managed sessions
## Known Limitations
- Honest limitations that still exist after this release
- Keep this short and specific
## Verification Performed
- Local targeted tests
- VM or fresh-install validation
- Manual operator-path verification
## Commits Included
- Optional: list the main commits if useful for maintainers
+58
View File
@@ -0,0 +1,58 @@
# OpenRig v0.1.12
## Summary
This release hardens startup-state truth, improves transcript cleanup, and makes restore/recovery behavior more reliable across Claude and Codex managed sessions.
## Included In This Release
- Normalize user-clearable startup blockers so they do not masquerade as hard failures.
- Clear stale Claude startup/error residue more aggressively.
- Improve Codex restore continuity detection and resume metadata capture.
- Fix transcript redraw and status-noise cleanup.
- Harden Claude managed-session startup around MCP-related blockers.
- Improve operational reliability around tmux health and instantiate timing.
## User-Facing Changes
- Nodes blocked on interactive startup prompts are now normalized as user-clearable startup blockers instead of being left in misleading stale-failure states.
- Claude startup state is cleaned up more aggressively when current runtime evidence proves the old failure residue is stale.
- Codex restore behavior is more robust when resuming wrapped processes and when the TUI has scrolled or rendered extra footer/header noise.
- Transcript cleanup is less noisy and better at removing redraw/status artifacts without leaving the terminal output in a confusing state.
## Operator / Setup Notes
- After upgrading, restart the daemon so new startup and restore behavior is active:
```bash
rig daemon stop
rig daemon start
```
- This release improves classification and recovery behavior, but it does not turn every interrupted runtime into guaranteed native continuity. Operators should still treat restore results honestly and use manual recovery paths when the product says continuity could not be proved.
## Known Limitations
- Lifecycle/state handling is more truthful than before, but OpenRig still does not have a fully explicit infrastructure-grade state machine.
- Restore remains runtime-specific and evidence-driven; some recovery paths still rely on heuristics rather than perfect provider-native primitives.
## Verification Performed
- Targeted daemon and CLI test coverage during the release workstream.
- Focused review and validation of the transcript-cleanup change set.
- Fresh-VM npm-install verification of the published `@openrig/cli` package, including daemon start, health route, doctor checks, `rig ps --nodes`, and UI load.
## Commits Included
- `8b07461` `cli: detect unhealthy tmux control sockets`
- `8ca8e43` `daemon: ignore stale codex update banner once tui is live`
- `a8adfe2` `daemon: capture codex resume ids from wrapped processes`
- `e4619cf` `daemon: recognize resumed codex tui after header scrolls`
- `30cf604` `daemon: accept codex footer with trailing blank lines`
- `c44507f` `harden restore continuity and recovery guidance`
- `105ed7e` `fix Claude MCP startup blockers for managed sessions`
- `848b032` `cli: extend import instantiate timeout budget`
- `43a0ace` `daemon: reject pod-aware node relaunch via legacy route`
- `0d7e6ec` `daemon: normalize stale Claude startup state`
- `37e2d5f` `daemon: normalize user-clearable startup blockers`
- `74cf893` `fix: clean transcript redraw and status noise (#10)`
+276
View File
@@ -0,0 +1,276 @@
# OpenRig v0.2.0
## Summary
OpenRig v0.2.0 is a major step forward. It ships a durable
daemon-backed coordination layer — streams, queues, projects, views,
watchdog supervision, and workflow runtime — together with a redesigned
restore experience, an agent-starter registry, cross-host operations,
and a wide set of lifecycle and recovery hardening fixes. This is the
first public release after a long internal hardening cycle, so the
delta from v0.1.12 is large.
## Included In This Release
- A new daemon-owned coordination layer: `rig stream`, `rig queue`,
`rig project`, `rig view`, `rig watchdog`, and `rig workflow`.
- An agent-starter registry that lets RigSpec members reference named
startup-priming entries (`starter_ref`) instead of inlining them.
- A new restore-packet command surface for capturing agent
conversation packets across runtimes.
- New member-level launch source modes: `session_source: { mode:
rebuild }` and `session_source: { mode: fork }`.
- Cross-host operations: `rig ps`, `rig whoami`, `rig send`, and
`rig capture` accept `--host` for SSH-based shell-out.
- Substantial lifecycle, recovery, and runtime-truth improvements
across Claude Code and Codex managed sessions.
- Sixteen new SQLite migrations (021 → 036) to back the new
coordination, view, watchdog, and workflow tables, plus a few
observability and config-profile additions.
## User-Facing Changes
### New coordination commands
A new family of `rig` commands provides a durable, daemon-backed
coordination surface:
- `rig stream` — emit and query stream items; subscribe via SSE.
- `rig queue` — create, claim, hand off, and close queue items.
Includes `handoff`, `handoff-and-complete`, `update`, `show`,
`list`, `whoami`, `inbox-drop`, `inbox-absorb`, `outbox-drop`,
and `outbox-collect`.
- `rig project` — group classified work items into durable projects
with classifier lease/liveness/reclaim semantics.
- `rig view` — query built-in and custom views over coordination
state; consume change events via SSE.
- `rig watchdog` — schedule periodic and conditional jobs with
daemon-native supervision.
- `rig workflow` — run multi-step workflows with durable trail
records and a workflow-keepalive job.
These commands persist their state in the OpenRig daemon's SQLite
database, are observable through the daemon API, and emit
`view.changed` and related SSE events on update.
### New `starter_ref` member field
`rig up` now accepts `starter_ref` on RigSpec members. The daemon
resolves the named entry from the `agent-starters/` registry at
launch and merges its content into the seat's per-cwd guidance file
(`CLAUDE.md` for Claude, `AGENTS.md` for Codex). Inputs containing
credential markers are refused with a redacted refusal envelope.
`starter_ref` is mutually exclusive with inline `session_source` on
the same member, and is rejected upfront in plan-mode for terminal
runtimes and fork compositions.
### New `session_source` modes
Members may now declare:
- `session_source: { mode: rebuild }` — rebuild a member from a
named artifact set on every materialize.
- `session_source: { mode: fork }` — fork a new member from an
existing source on launch.
### Restore-packet command surface
A new `rig restore-packet` family captures restorable conversation
packets across runtimes. It includes parsers and redaction for
Claude and Codex transcripts, schema validation, packet writing, and
a read/validate flow. There is also a `rig restore-check` command
for restore-readiness checks.
### Cross-host operations
`rig ps`, `rig whoami`, `rig send`, and `rig capture` now accept
`--host <ssh-host>` to run against a remote machine. The CLI
shells out over SSH; no long-lived agent or daemon needs to be on
the remote machine ahead of the call.
### Lifecycle, recovery, and runtime-truth improvements
- New lifecycle classifications: `recoverable`, `degraded`, and
`attention_required`. Codex auth-refusal now surfaces as
`attention_required` end-to-end.
- `rig up <rigName>` falls back to a manual snapshot when no
auto-pre-down snapshot exists, so launches no longer fail
silently in that path.
- Restore now has an explicit attempt id, and runtime-truth
reconciliation prevents stale state from masquerading as live.
- Codex resume now verifies the pane via a native probe; rig expand
recovery guidance points to shrink+re-expand instead of trying to
launch a doomed rig.
- `rig ps --fields` now rejects unknown keys with a per-level
allow-list. `rig ps` also supports `rigName` aliasing and a
bounded output mode.
### Agents launched by the daemon now run plain `rig`
Codex seats launched by the daemon previously required an explicit
`OPENRIG_HOME`/`OPENRIG_PORT` env and a non-default binary path on
PATH. Daemon-launched seats now have plain `rig` available, so
agents and operators can use the standard CLI without per-seat env
gymnastics.
### Watchdog help, queue-actor inference, and other small fixes
- `rig watchdog --help` now lists the workflow-keepalive job kind.
- `rig queue` infers the actor from the managed session when no
explicit actor is provided.
- The CLI bin keeps its executable bit across builds.
- Chatroom wait test fixture is time-stable (no more flaky
`chatroom wait` test under sanitized environments).
## Operator / Setup Notes
After upgrading the published CLI:
```bash
npm install -g @openrig/[email protected]
rig --version # expect 0.2.0
```
You will need to restart the daemon to pick up the new schema and
behavior:
```bash
rig daemon stop
rig daemon start
```
The daemon will apply migrations 021-036 to its SQLite database on
first startup. Migrations are forward-only by convention; if you
need to roll back, restore the daemon DB from a snapshot and switch
the daemon binary back to the previous build before restart. Take a
DB snapshot before the first post-upgrade daemon start if you want a
clean rollback path:
```bash
cp ~/.openrig/openrig.sqlite ~/.openrig/openrig.sqlite.pre-v0.2.0.bak
```
If you maintain a managed control-plane environment with live rigs
mid-flight, plan the daemon restart as an upgrade window. The
upgrade is straightforward, but daemon stop/start interrupts active
SSE consumers and any in-progress `rig up` flow.
## Known Limitations
- The new coordination surfaces (`rig stream`, `rig queue`,
`rig project`, `rig view`, `rig watchdog`, `rig workflow`) are
daemon-backed. The host daemon must be on v0.2.0 to serve them; an
older daemon will not expose these commands and may silently
ignore opt-in fields like `starter_ref` on members.
- `runtime: claude` is not a valid RigSpec runtime spelling; use
`runtime: claude-code` for Claude managed seats. This is harness/
spec syntax, not a product limitation, but it has surprised
operators.
- Watchdog policies and workflow specs are persisted in the daemon
DB. There is currently no first-class CLI for editing custom
watchdog policies in bulk; expect more ergonomics in a future
release.
- The Phase E surfaces (rails, rendering, steering for the
coordination layer) are intentionally deferred to future product
lanes (PL-006/PL-007/PL-008) and are not part of v0.2.0.
- Lifecycle classification is more truthful than before, but
OpenRig still does not have a fully explicit infrastructure-grade
state machine. Some recovery paths still rely on heuristics
rather than perfect provider-native primitives.
## Verification Performed
- Sanitized full CLI suite under Node 22 with OpenRig env stripped:
56 files / 794 tests passing.
- Root `npm run build` and `npm run build:package` passing.
- Real-agent dogfood under Mode 3 isolated VM with VM-local OpenRig
daemon/state and real OpenRig-managed Claude Code and Codex seats:
Claude-only, Codex-only, and mixed Claude→Codex paths all
exercised end-to-end through the new `rig queue`, `rig stream`,
`rig workflow`, and `rig watchdog` surfaces.
- VM-local global install of `@openrig/cli` from source, with
`rig --version` reporting the expected version and managed seats
running plain `rig`.
## Commits Included (highlights)
The full delta from `v0.1.12` to `e40214f` is 117 commits, 277
files changed, 49,209 insertions and 683 deletions. The list below
groups the load-bearing commits by theme:
### Coordination primitive system
- `d8d8ee9 feat(daemon,cli): coordination primitive system — stream/queue/inbox/outbox + hot-potato strict-rejection`
- `fd4fb30 fix(daemon,cli): topology-backed validation + default nudge + handoff-and-complete + whoami + SSE alias + CLI tests`
- `194a1eb fix(daemon): SSE route-order shadowing fix`
- `4ac0d20 feat(daemon,cli): L2 Project (Classifier) + L5 View`
- `9f86df4 fix(daemon,cli): L1→L2 FK + view.changed event bridge`
- `629d061 fix(daemon): queue.updated event + bridge mapping for general state mutations`
- `48c0267 feat(daemon,cli): daemon-native Watchdog supervision tree`
- `dd9820e fix(daemon): watchdog policy POC contract parity + active-wake throttle`
- `1a3b313 fix(daemon): yaml-package frontmatter parser + scan_interval scheduler test`
- `55bdcbb feat(daemon,cli): daemon-native Workflow Runtime + workflow-keepalive`
- `bf28cc2 fix(daemon): workflow projection routes through queue closure primitive + waiting frontier preservation + failed status`
- `1c94719 fix(daemon): durable current-step binding + allowed_exits enforcement at projection`
- `3e32b0a fix(daemon): map exit_not_allowed to HTTP 400 + route regression`
- `05ca19c fix(daemon): workflow_step_trails listForInstance ordered by rowid (deterministic insertion order)`
- `e40214f fix: include workflow keepalive in watchdog help`
### Restore-Packet surface
- `de2dbd4 feat(cli): restore-packet schema + validator + CLI shell + wiring`
- `40d0724 fix(cli): restore-packet schema packaging fix`
- `bdf4cf6 feat(cli): restore-packet runtime parsers + runtime-detect + redaction + omitted-records`
- `5c87145 fix(cli): Claude parser nested tool_use/tool_result handling`
- `e5de3ab feat(cli): packet-writer atomic emission + write action impl`
- `a8401f6 fix(cli): --source-jsonl provenance from parsed session_meta`
- `c7b74fa feat(daemon,cli): full-read transcript route + final M2 regression`
- `a5132da feat(cli): rig restore-packet read + validate actual implementations`
- `ab822ee test(cli): command-registration meta-tests + final regression check`
### Agent Starter v1 vertical
- `eb4acf2 feat(daemon): Agent Starter v1 schema + resolver scaffolding`
- `2673e30 fix(daemon): codec emits starter_ref; expansion preserves it; resolver redacts match-line`
- `2f8f269 feat(daemon): Agent Starter v1 adapter pass-through + rig up integration`
- `6af2754 test(daemon, cli): Agent Starter v1 real-adapter, route end-to-end, CLI plan content, continuityOutcome + SQLite roundtrip`
### session_source rebuild and fork modes
- `c7b6df1 feat(daemon): session_source: { mode: fork } member-level launch input`
- `578bd5c feat(daemon): session_source: { mode: rebuild }`
### Cross-host operations
- `cdce3a6 feat(cli): cross-host rig send/capture via --host (CLI-side ssh shell-out)`
- `6b7043a feat(cli): rig ps and rig whoami accept --host`
### Lifecycle, recovery, and runtime-truth
- `3ad1df6 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile`
- `fad10a9 feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection`
- `66785c8 feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation`
- `e70694f fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists`
- `0a9fb43 feat(daemon,cli): add rigName alias, lifecycleState column, and bounded ps output options`
- `4e277d7 docs(cli-reference): bring rig ps section in line with shipped 0a9fb43`
- `315745a feat(cli): rig ps --fields rejects unknown keys with per-level allow-list`
- `ab01331 feat(daemon): codex resume verifies pane via native probe`
- `fcfc7dc fix(cli): rig expand recovery guidance points to shrink+re-expand, not doomed rig launch`
- `63ee206 feat(daemon): codex auth-refusal surfaces as attention_required end-to-end`
- `53e9409 fix(daemon): codex auth-refusal surfaces as attention_required across pod-aware launch path`
### Final fixes during release loop
- `934f3c9 fix: launch codex seats with full access posture`
- `fe5b23e fix: propagate daemon env to launched seats`
- `cff08c7 test: make chatroom wait fixture time-stable`
- `a536df3 fix: preserve cli bin executable bit on build`
- `90b1772 fix: infer queue actor from managed session`
### Recovery markers
- `7a0ae0f chore: mark PL-004 Phase D checkout recovery`
The remaining ~50 commits are smaller fixes, test additions, and
docs updates that compose into the themed groups above. See `git
log v0.1.12..v0.2.0` for the full list.
+25 -6
View File
@@ -1,19 +1,20 @@
{
"name": "openrig",
"version": "0.1.3",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.1.3",
"version": "0.2.0",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
"packages/ui",
"packages/cli"
],
"engines": {
"node": ">=20"
"node": "^20 || ^22 || ^24"
}
},
"node_modules/@adobe/css-tools": {
@@ -4592,6 +4593,12 @@
"node": ">=6"
}
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -7379,14 +7386,18 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.1.3",
"version": "0.2.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^11.8.0",
"commander": "^13.0.0",
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
@@ -7399,11 +7410,15 @@
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": "^20 || ^22 || ^24"
}
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.1.3",
"version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
"better-sqlite3": "^11.8.0",
@@ -7419,11 +7434,15 @@
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": "^20 || ^22 || ^24"
}
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.1.3",
"version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
+6 -3
View File
@@ -1,7 +1,8 @@
{
"name": "openrig",
"version": "0.1.3",
"version": "0.2.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"workspaces": [
"packages/daemon",
@@ -10,11 +11,13 @@
],
"scripts": {
"build": "npm run build --workspaces",
"test": "npm run test --workspaces",
"test": "npm run test:repo && npm run test:workspaces",
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs",
"test:workspaces": "npm run test --workspaces",
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
"build:package": "bash scripts/build-package.sh"
},
"engines": {
"node": ">=20"
"node": "^20 || ^22 || ^24"
}
}
+11 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.1.3",
"version": "0.2.0",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
@@ -31,10 +31,12 @@
"files": [
"dist",
"daemon",
"ui"
"ui",
"scripts"
],
"scripts": {
"build": "tsc",
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
"test:watch": "vitest"
@@ -42,9 +44,12 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@modelcontextprotocol/sdk": "^1.28.0",
"ajv": "^8.18.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^11.8.0",
"commander": "^13.0.0",
"hono": "^4.7.0",
"jsonc-parser": "^3.3.1",
"tar": "^7.5.13",
"ulid": "^2.3.0",
"yaml": "^2.8.3",
@@ -54,5 +59,8 @@
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": "^20 || ^22 || ^24"
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env node
// Postinstall ABI sanity check for @openrig/cli.
// Fails loudly if better-sqlite3 native binary does not match the active
// Node runtime. Layered on top of the engines.node constraint in
// package.json — engines warns at npm level; this hard-blocks at install
// time with a copy-paste fix.
/**
* @param {{ nodeVersion: string, loadNativeAddon: () => void }} deps
* @returns {{ ok: true } | { ok: false, message: string }}
*/
export function checkAbi({ nodeVersion, loadNativeAddon }) {
// Phase 1: version-range check (fast path).
// Even-numbered Node majors (20, 22, 24) are LTS lines with native addon
// prebuilds. Odd-numbered majors (21, 23, 25) generally lack them.
const match = nodeVersion.match(/^v?(\d+)/);
const major = match ? parseInt(match[1], 10) : 0;
if (major < 20) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
` ║ @openrig/cli requires Node.js 20, 22, or 24 (LTS). ║`,
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
].join("\n"),
};
}
if (major % 2 !== 0) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
` ║ @openrig/cli does not support odd-numbered Node releases. ║`,
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Odd Node versions (21, 23, 25, …) lack native addon ║",
" ║ prebuilds for better-sqlite3. The daemon will fail. ║",
" ║ ║",
" ║ Fix: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
].join("\n"),
};
}
// Phase 2: ABI load check (catches edge cases like local Node builds
// or prebuild packaging gaps on supported versions).
try {
loadNativeAddon();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
const isAbiMismatch = detail.includes("NODE_MODULE_VERSION");
if (isAbiMismatch) {
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
" ║ better-sqlite3 native binary does not match this Node. ║",
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: npm rebuild better-sqlite3 ║",
" ║ or: nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
` Detail: ${detail}`,
"",
].join("\n"),
};
}
return {
ok: false,
message: [
"",
" ╔══════════════════════════════════════════════════════════════╗",
" ║ better-sqlite3 native addon failed to load. ║",
` ║ Current: ${nodeVersion.padEnd(49)}║`,
" ║ ║",
" ║ Fix: npm rebuild better-sqlite3 ║",
" ║ If that fails, reinstall with a supported Node version: ║",
" ║ nvm install 22 && npm install -g @openrig/cli ║",
" ╚══════════════════════════════════════════════════════════════╝",
"",
` Detail: ${detail}`,
"",
].join("\n"),
};
}
return { ok: true };
}
// --- Run when executed as postinstall script ---
const isMain =
process.argv[1] &&
(import.meta.url === `file://${process.argv[1]}` ||
process.argv[1].endsWith("check-abi.mjs"));
if (isMain) {
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const result = checkAbi({
nodeVersion: process.version,
loadNativeAddon: () => require("better-sqlite3"),
});
if (!result.ok) {
console.error(result.message);
process.exitCode = 1;
}
}
+50 -1
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env node
import path from "node:path";
import { realpathSync } from "node:fs";
import { existsSync, realpathSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
const packageRoot = path.resolve(path.dirname(wrapperPath), "..");
@@ -19,6 +22,51 @@ export function isDirectRun(argv1 = process.argv[1], moduleUrl = import.meta.url
}
}
export function resolveNodeReexecBinary(
currentExecPath = process.execPath,
invokedPath = process.argv[1],
env: NodeJS.ProcessEnv = process.env,
exists: (candidate: string) => boolean = existsSync,
realpath: (candidate: string) => string = realpathSync,
): string | null {
if (!invokedPath) return null;
if (env[REEXEC_GUARD_ENV] === "1") return null;
try {
const binDir = realpath(path.dirname(invokedPath));
const siblingNode = path.join(binDir, process.platform === "win32" ? "node.exe" : "node");
if (!exists(siblingNode)) return null;
const normalizedSiblingNode = realpath(siblingNode);
const normalizedCurrentExec = realpath(currentExecPath);
if (normalizedSiblingNode === normalizedCurrentExec) return null;
return normalizedSiblingNode;
} catch {
return null;
}
}
function maybeReexecWithSiblingNode(argv = process.argv): void {
const preferredNode = resolveNodeReexecBinary(process.execPath, argv[1], process.env);
if (!preferredNode) return;
const scriptPath = argv[1] ? realpathSync(argv[1]) : fileURLToPath(import.meta.url);
const result = spawnSync(preferredNode, [scriptPath, ...argv.slice(2)], {
stdio: "inherit",
env: {
...process.env,
[REEXEC_GUARD_ENV]: "1",
},
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 0);
}
export async function run(argv = process.argv): Promise<void> {
const normalizedArgv = [...argv];
if (normalizedArgv[1]) {
@@ -31,5 +79,6 @@ export async function run(argv = process.argv): Promise<void> {
}
if (isDirectRun()) {
maybeReexecWithSiblingNode(process.argv);
await run(process.argv);
}
+24 -19
View File
@@ -14,6 +14,10 @@ export interface DaemonResponse<T = unknown> {
data: T;
}
interface DaemonRequestOptions {
timeoutMs?: number;
}
interface DaemonClientOptions {
fetchImpl?: typeof fetch;
timeoutMs?: number;
@@ -42,51 +46,52 @@ export class DaemonClient {
this.timeoutMs = options?.timeoutMs ?? 5_000;
}
async get<T = unknown>(path: string): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "GET" });
async get<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "GET" }, options);
}
async getText(path: string): Promise<DaemonResponse<string>> {
return this.requestText(path, { method: "GET" });
async getText(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
return this.requestText(path, { method: "GET" }, options);
}
async post<T = unknown>(path: string, body?: unknown): Promise<DaemonResponse<T>> {
async post<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}, options);
}
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>): Promise<DaemonResponse<T>> {
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, {
method: "POST",
headers: { "Content-Type": contentType, ...extraHeaders },
body: text,
});
}, options);
}
async postExpectText(path: string, body?: unknown): Promise<DaemonResponse<string>> {
async postExpectText(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
return this.requestText(path, {
method: "POST",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}, options);
}
async delete<T = unknown>(path: string): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "DELETE" });
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
return this.requestJson<T>(path, { method: "DELETE" }, options);
}
private async fetch(path: string, init: RequestInit): Promise<Response> {
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
try {
return await fetchWithTimeout(
this.fetchImpl,
`${this.baseUrl}${path}`,
init,
{
timeoutMs: this.timeoutMs,
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${this.timeoutMs}ms`,
timeoutMs,
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${timeoutMs}ms`,
},
);
} catch (err) {
@@ -95,14 +100,14 @@ export class DaemonClient {
}
}
private async requestJson<T>(path: string, init: RequestInit): Promise<DaemonResponse<T>> {
const res = await this.fetch(path, init);
private async requestJson<T>(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
const res = await this.fetch(path, init, options);
const data = (await res.json()) as T;
return { status: res.status, data };
}
private async requestText(path: string, init: RequestInit): Promise<DaemonResponse<string>> {
const res = await this.fetch(path, init);
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
const res = await this.fetch(path, init, options);
const data = await res.text();
return { status: res.status, data };
}
+87
View File
@@ -0,0 +1,87 @@
import path from "node:path";
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
export const CMUX_SETTINGS_DISCLOSURE_PATH = "~/.config/cmux/settings.json";
export const CMUX_DEFAULT_SOCKET_CONTROL_MODE = "cmuxOnly";
const CMUX_INCOMPATIBLE_MODES = new Set(["off", "cmuxOnly"]);
export interface CmuxSettingsReadResult {
mode: string;
source: "settings" | "default";
error?: string;
}
export function resolveCmuxSettingsPath(homeDir = process.env["HOME"] ?? "~"): string {
return path.join(homeDir, ".config", "cmux", "settings.json");
}
export function isCmuxSocketControlCompatible(mode: string | null): boolean {
return typeof mode === "string" && !CMUX_INCOMPATIBLE_MODES.has(mode);
}
export function readCmuxSocketControlModeFromText(text: string | null): CmuxSettingsReadResult {
if (!text || !text.trim()) {
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
const errors: ParseError[] = [];
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
if (errors.length > 0) {
return {
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
source: "settings",
error: formatParseErrors(errors),
};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
source: "settings",
error: "cmux settings must contain a JSON object.",
};
}
const automation = parsed["automation"];
if (!automation || typeof automation !== "object" || Array.isArray(automation)) {
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
const mode = automation["socketControlMode"];
if (typeof mode === "string" && mode.trim()) {
return { mode, source: "settings" };
}
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
}
export function upsertCmuxSocketControlMode(text: string | null, mode: string): { content: string; changed: boolean } {
const source = text && text.trim() ? text : "{\n}\n";
const edits = modify(source, ["automation", "socketControlMode"], mode, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
eol: "\n",
},
});
if (edits.length === 0) {
return { content: ensureTrailingNewline(source), changed: false };
}
return {
content: ensureTrailingNewline(applyEdits(source, edits)),
changed: true,
};
}
function ensureTrailingNewline(text: string): string {
return text.endsWith("\n") ? text : `${text}\n`;
}
function formatParseErrors(errors: ParseError[]): string {
return errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join("; ");
}
+3 -1
View File
@@ -5,6 +5,8 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS = 120_000;
function logStageDetailErrors(data: Record<string, unknown>) {
const stages = (data["stages"] as Array<{ stage: string; status: string; detail?: unknown }>) ?? [];
for (const stage of stages) {
@@ -107,7 +109,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
sourceRef,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
autoApprove: opts.yes ?? false,
});
}, { timeoutMs: LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
+77 -4
View File
@@ -3,10 +3,22 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
export function captureCommand(depsOverride?: StatusDeps): Command {
export interface CaptureDeps extends StatusDeps {
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
export function captureCommand(depsOverride?: CaptureDeps): Command {
const cmd = new Command("capture").description("Capture terminal output from agent sessions");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): CaptureDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -16,6 +28,7 @@ export function captureCommand(depsOverride?: StatusDeps): Command {
.option("--rig <name>", "Capture all sessions in a rig")
.option("--pod <name>", "Capture all sessions in a pod")
.option("--lines <n>", "Number of lines to capture (default: 20)", "20")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -24,12 +37,22 @@ Examples:
rig capture --rig my-rig
rig capture --pod dev --rig my-rig
rig capture --rig my-rig --json
rig capture --host vm-claude-test dev-impl@my-rig --lines 50
Supported notes:
- Multi-target capture reports unsupported external_cli nodes as explicit per-target failures.
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; json?: boolean }) => {
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.
- --host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. The remote rig is authoritative on what it can capture.`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; host?: string; json?: boolean }) => {
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostCapture(opts.host, session, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -84,3 +107,53 @@ Supported notes:
return cmd;
}
async function runCrossHostCapture(
hostId: string,
session: string | undefined,
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
deps: CaptureDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv. Order: positional first, then flags.
const argv: string[] = ["rig", "capture"];
if (session) argv.push(session);
if (opts.rig) argv.push("--rig", opts.rig);
if (opts.pod) argv.push("--pod", opts.pod);
if (opts.lines !== undefined) argv.push("--lines", opts.lines);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: host.target },
result,
}));
if (!result.ok) process.exitCode = 1;
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, opts.json);
}
+496
View File
@@ -0,0 +1,496 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface CompactPlanDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
}
interface RigEntry {
rigId: string;
name: string;
}
interface NodeEntry {
rigId: string;
rigName: string;
logicalId: string;
canonicalSessionName: string | null;
runtime: string | null;
sessionStatus: string | null;
startupStatus: string | null;
tmuxAttachCommand: string | null;
resumeCommand: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
source: string | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
}
interface BasePlanEntry {
session: string | null;
rig: string;
logicalId: string;
runtime: string;
usedPercentage: number | null;
contextWindowSize: number | null;
estimatedUsedTokens: number | null;
contextFreshness: "fresh" | "stale" | "unknown";
sessionStatus: string | null;
startupStatus: string | null;
}
interface CompactPlanThresholds {
thresholdTokens: number;
thresholdPercent: number;
}
interface CompactPlanSeatPolicy {
oneSeatAtATime: true;
autoCompactAllowed: false;
explicitAuthorizationRequired: true;
}
interface NotificationPacket {
recipient: string | null;
subject: string;
text: string;
}
interface CandidateEntry extends BasePlanEntry {
status: "candidate_with_caveats";
thresholdReason: string;
reasons: string[];
missingEvidence: string[];
precompactRequirements: string[];
seatPolicy: CompactPlanSeatPolicy;
notificationPacket: NotificationPacket;
nextAction: string;
}
interface BlockedEntry extends BasePlanEntry {
status: "blocked";
thresholdReason: string | null;
reasons: string[];
missingEvidence: string[];
precompactRequirements: string[];
seatPolicy: CompactPlanSeatPolicy;
notificationPacket: NotificationPacket;
nextAction: string;
}
interface SkippedEntry extends BasePlanEntry {
status: "skipped";
reason: string;
}
interface CompactPlanResult {
summary: {
totalSeats: number;
claudeSeats: number;
candidateCount: number;
blockedCount: number;
skippedCount: number;
requiresAuthorization: true;
};
policy: {
mode: "read_only_plan";
defaultThresholdTokens: number;
defaultThresholdPercent: number;
thresholdTokens: number;
thresholdPercent: number;
oneSeatAtATime: true;
autoCompactAllowed: false;
explicitAuthorizationRequired: true;
};
recommendedOrder: string[];
candidates: CandidateEntry[];
blocked: BlockedEntry[];
skipped: SkippedEntry[];
}
const DEFAULT_THRESHOLD_TOKENS = 400_000;
const PERCENT_FALLBACK_THRESHOLD = 80;
const FRESHNESS_THRESHOLD_S = 600;
const PRECOMPACT_REQUIREMENTS = [
"fresh_context_sample",
"checkpoint_or_restore_packet_verification",
"explicit_operator_authorization",
"one_seat_at_a_time_only",
"post_compaction_restore_audit",
];
const SEAT_POLICY: CompactPlanSeatPolicy = {
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
};
function defaultThresholds(): CompactPlanThresholds {
return {
thresholdTokens: DEFAULT_THRESHOLD_TOKENS,
thresholdPercent: PERCENT_FALLBACK_THRESHOLD,
};
}
function estimateUsedTokens(usedPercentage: number | null, contextWindowSize: number | null): number | null {
if (usedPercentage == null || contextWindowSize == null) return null;
return Math.round(contextWindowSize * usedPercentage / 100);
}
function computeFreshness(contextUsage: NodeEntry["contextUsage"]): "fresh" | "stale" | "unknown" {
if (!contextUsage || contextUsage.usedPercentage == null) return "unknown";
if (contextUsage.sampledAt) {
const ageSeconds = (Date.now() - new Date(contextUsage.sampledAt).getTime()) / 1000;
return ageSeconds <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
}
if (contextUsage.fresh) return "fresh";
return "unknown";
}
function baseEntry(node: NodeEntry): BasePlanEntry {
const usedPercentage = node.contextUsage?.usedPercentage ?? null;
const contextWindowSize = node.contextUsage?.contextWindowSize ?? null;
return {
session: node.canonicalSessionName,
rig: node.rigName,
logicalId: node.logicalId,
runtime: node.runtime ?? "unknown",
usedPercentage,
contextWindowSize,
estimatedUsedTokens: estimateUsedTokens(usedPercentage, contextWindowSize),
contextFreshness: computeFreshness(node.contextUsage),
sessionStatus: node.sessionStatus,
startupStatus: node.startupStatus,
};
}
function isClaude(node: NodeEntry): boolean {
return node.runtime === "claude-code";
}
function isSeatRunningReady(node: NodeEntry): boolean {
return node.sessionStatus === "running" && node.startupStatus === "ready";
}
function candidateSortScore(candidate: CandidateEntry): number {
return candidate.estimatedUsedTokens ?? ((candidate.usedPercentage ?? 0) * 1_000);
}
function notificationPacket(node: NodeEntry, status: "candidate_with_caveats" | "blocked", reasons: string[], missingEvidence: string[]): NotificationPacket {
const recipient = node.canonicalSessionName;
const subject = status === "candidate_with_caveats"
? `Compact-plan marshal check for ${node.logicalId}`
: `Compact-plan evidence needed for ${node.logicalId}`;
const session = recipient ?? node.logicalId;
const text = status === "candidate_with_caveats"
? [
`Read-only compact-plan flagged ${session} for one-seat-at-a-time Claude continuity triage.`,
`Reasons: ${reasons.join(", ")}.`,
`Before any compaction: verify checkpoint/restore evidence, get explicit operator authorization, compact only this seat, and audit restore after compact-in-place.`,
`No automatic compaction has been run.`,
].join(" ")
: [
`Read-only compact-plan cannot safely plan ${session} yet.`,
`Blockers: ${reasons.join(", ")}.`,
`Missing evidence: ${missingEvidence.join(", ") || "none"}.`,
`Resolve these before requesting one-seat-at-a-time compaction authorization.`,
].join(" ");
return { recipient, subject, text };
}
function thresholdReason(base: BasePlanEntry, thresholds: CompactPlanThresholds): string | null {
if (base.estimatedUsedTokens != null && base.estimatedUsedTokens >= thresholds.thresholdTokens) {
return "above_token_threshold";
}
if (base.estimatedUsedTokens == null && (base.usedPercentage ?? 0) >= thresholds.thresholdPercent) {
return "above_percent_threshold_missing_window";
}
return null;
}
function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): CandidateEntry | BlockedEntry | SkippedEntry {
const base = baseEntry(node);
if (!isClaude(node)) {
return {
...base,
status: "skipped",
reason: node.runtime === "codex" ? "codex_not_managed_by_claude_compact_in_place" : "non_claude_runtime",
};
}
const blockedReasons: string[] = [];
const blockedMissingEvidence: string[] = [];
if (!node.canonicalSessionName) {
blockedReasons.push("missing_canonical_session");
blockedMissingEvidence.push("canonical_session_name");
}
if (!isSeatRunningReady(node)) {
blockedReasons.push("seat_not_running_or_ready");
}
if (base.usedPercentage == null) {
blockedReasons.push("context_unknown");
blockedMissingEvidence.push("context_usage");
}
if (blockedReasons.length > 0) {
return {
...base,
status: "blocked",
thresholdReason: thresholdReason(base, thresholds),
reasons: blockedReasons,
missingEvidence: blockedMissingEvidence,
precompactRequirements: PRECOMPACT_REQUIREMENTS,
seatPolicy: SEAT_POLICY,
notificationPacket: notificationPacket(node, "blocked", blockedReasons, blockedMissingEvidence),
nextAction: "Resolve blocked seat state and collect fresh context before adding this seat to marshal triage.",
};
}
const reason = thresholdReason(base, thresholds);
if (!reason) {
return {
...base,
status: "skipped",
reason: "below_threshold",
};
}
const reasons = [
reason,
"authorization_required",
];
const missingEvidence = ["checkpoint_or_restore_packet_verification"];
if (base.contextWindowSize == null) {
missingEvidence.push("context_window_size");
}
if (base.contextFreshness === "stale") {
reasons.push("context_stale");
missingEvidence.push("fresh_context_sample");
} else if (base.contextFreshness === "unknown") {
reasons.push("context_freshness_unknown");
missingEvidence.push("fresh_context_sample");
}
if (!node.tmuxAttachCommand && !node.resumeCommand) {
missingEvidence.push("attach_or_resume_evidence");
}
return {
...base,
status: "candidate_with_caveats",
thresholdReason: reason,
reasons,
missingEvidence,
precompactRequirements: PRECOMPACT_REQUIREMENTS,
seatPolicy: SEAT_POLICY,
notificationPacket: notificationPacket(node, "candidate_with_caveats", reasons, missingEvidence),
nextAction: "Verify checkpoint/restore evidence, get explicit authorization, compact one Claude seat, then audit restore using claude-compact-in-place.",
};
}
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): CompactPlanResult {
const candidates: CandidateEntry[] = [];
const blocked: BlockedEntry[] = [];
const skipped: SkippedEntry[] = [];
for (const node of nodes) {
const entry = analyzeNode(node, thresholds);
if (entry.status === "candidate_with_caveats") candidates.push(entry);
else if (entry.status === "blocked") blocked.push(entry);
else skipped.push(entry);
}
candidates.sort((a, b) => {
const score = candidateSortScore(b) - candidateSortScore(a);
if (score !== 0) return score;
return (a.session ?? a.logicalId).localeCompare(b.session ?? b.logicalId);
});
return {
summary: {
totalSeats: nodes.length,
claudeSeats: nodes.filter(isClaude).length,
candidateCount: candidates.length,
blockedCount: blocked.length,
skippedCount: skipped.length,
requiresAuthorization: true,
},
policy: {
mode: "read_only_plan",
defaultThresholdTokens: DEFAULT_THRESHOLD_TOKENS,
defaultThresholdPercent: PERCENT_FALLBACK_THRESHOLD,
thresholdTokens: thresholds.thresholdTokens,
thresholdPercent: thresholds.thresholdPercent,
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
},
recommendedOrder: candidates
.map((candidate) => candidate.session)
.filter((session): session is string => Boolean(session)),
candidates,
blocked,
skipped,
};
}
function printHuman(plan: CompactPlanResult): void {
console.log("READ-ONLY PLAN - does not compact");
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
console.log();
if (plan.recommendedOrder.length > 0) {
console.log("One-seat-at-a-time recommended marshal triage order:");
for (const [index, session] of plan.recommendedOrder.entries()) {
console.log(` ${index + 1}. ${session}`);
}
} else {
console.log("One-seat-at-a-time recommended marshal triage order: none");
}
if (plan.candidates.length > 0) {
console.log();
console.log("Candidates with caveats:");
for (const candidate of plan.candidates) {
const estimate = candidate.estimatedUsedTokens == null ? "unknown tokens" : `${candidate.estimatedUsedTokens} estimated tokens`;
console.log(` - ${candidate.session}: ${estimate}; threshold=${candidate.thresholdReason}; reasons=${candidate.reasons.join(", ")}; missing=${candidate.missingEvidence.join(", ")}`);
console.log(` Notify: ${candidate.notificationPacket.text}`);
}
}
if (plan.blocked.length > 0) {
console.log();
console.log("Blocked / not safely plannable:");
for (const blocked of plan.blocked) {
console.log(` - ${blocked.session ?? blocked.logicalId}: reasons=${blocked.reasons.join(", ")}; missing=${blocked.missingEvidence.join(", ") || "none"}`);
console.log(` Notify: ${blocked.notificationPacket.text}`);
}
}
console.log();
console.log("Next action: verify checkpoint/restore evidence, get explicit authorization, compact one Claude seat only, then audit restore with claude-compact-in-place.");
}
export function compactPlanCommand(depsOverride?: CompactPlanDeps): Command {
const cmd = new Command("compact-plan")
.description("Plan Claude compact-in-place candidates without compacting anything")
.addHelpText("after", `
Examples:
rig compact-plan Show a read-only Claude compaction triage plan
rig compact-plan --rig openrig-pm Plan one rig only
rig compact-plan --refresh Re-sample context before planning
rig compact-plan --json JSON output for agents`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--rig <name>", "Plan one rig only")
.option("--refresh", "Re-sample context usage before planning")
.option("--threshold-tokens <n>", "Estimated used-token threshold for Claude compact-plan candidates")
.option("--threshold-percent <0-100>", "Used-percent threshold when context window size is missing")
.action(async (opts: { json?: boolean; rig?: string; refresh?: boolean; thresholdTokens?: string; thresholdPercent?: string }) => {
const deps = getDepsF();
const thresholds = parseThresholdOptions(opts);
if (!thresholds.ok) {
console.error(thresholds.error);
process.exitCode = 1;
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon is not running. Start it with: rig daemon start");
console.error("Cannot build compact-plan without current read-only rig inventory.");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
try {
const psResult = await client.get<RigEntry[]>("/api/ps");
const rigs = psResult.data ?? [];
const targetRigs = opts.rig ? rigs.filter((rig) => rig.name === opts.rig) : rigs;
if (opts.rig && targetRigs.length === 0) {
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
process.exitCode = 1;
return;
}
if (opts.refresh && targetRigs.length > 0) {
const firstRig = targetRigs[0]!;
try {
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
if (refreshResult.status >= 400) {
console.error("Compact-plan refresh failed. Data may be stale.");
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
} catch (refreshErr) {
console.error("Compact-plan refresh failed. Data may be stale.");
console.error(`Detail: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
}
const allNodes: NodeEntry[] = [];
for (const rig of targetRigs) {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
if (Array.isArray(nodesResult.data)) {
allNodes.push(...nodesResult.data);
}
}
const plan = buildPlan(allNodes, thresholds.value);
if (opts.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
printHuman(plan);
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
console.error("Fix: check daemon status with: rig daemon status");
process.exitCode = 2;
}
});
return cmd;
}
function parseThresholdOptions(opts: { thresholdTokens?: string; thresholdPercent?: string }): { ok: true; value: CompactPlanThresholds } | { ok: false; error: string } {
const thresholds = defaultThresholds();
if (opts.thresholdTokens != null) {
const value = Number(opts.thresholdTokens);
if (!Number.isInteger(value) || value <= 0) {
return { ok: false, error: "--threshold-tokens must be a positive integer" };
}
thresholds.thresholdTokens = value;
}
if (opts.thresholdPercent != null) {
const value = Number(opts.thresholdPercent);
if (!Number.isFinite(value) || value < 0 || value > 100) {
return { ok: false, error: "--threshold-percent must be a number from 0 to 100" };
}
thresholds.thresholdPercent = value;
}
return { ok: true, value: thresholds };
}
+278
View File
@@ -0,0 +1,278 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface ContextDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
}
interface NodeEntry {
rigId: string;
rigName: string;
logicalId: string;
canonicalSessionName: string | null;
runtime: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
source: string | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
[key: string]: unknown;
}
interface Seat {
session: string;
rig: string;
logicalId: string;
runtime: string;
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
urgency: "critical" | "warning" | "low" | "unknown";
freshness: "fresh" | "stale" | "none";
status: string;
displayStatus: string;
source: string | null;
availability: string;
sampledAt: string | null;
staleness: string;
fresh: boolean;
}
const FRESHNESS_THRESHOLD_S = 600; // 10 minutes
function ageLabel(sampledAt: string | null): string {
if (!sampledAt) return "\u2014";
const age = Math.max(0, Math.floor((Date.now() - new Date(sampledAt).getTime()) / 1000));
if (age < 60) return "<1m ago";
const minutes = Math.floor(age / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function computeFreshness(sampledAt: string | null): "fresh" | "stale" | "none" {
if (!sampledAt) return "none";
const age = (Date.now() - new Date(sampledAt).getTime()) / 1000;
return age <= FRESHNESS_THRESHOLD_S ? "fresh" : "stale";
}
function analyze(node: NodeEntry): Seat {
const ctx = node.contextUsage ?? {} as Partial<NonNullable<NodeEntry["contextUsage"]>>;
const used = ctx.usedPercentage != null ? Math.round(ctx.usedPercentage) : null;
const remaining = ctx.remainingPercentage != null ? Math.round(ctx.remainingPercentage) : null;
const window = ctx.contextWindowSize ?? null;
const sampledAt = ctx.sampledAt ?? null;
const source = ctx.source ?? null;
const availability = ctx.availability ?? (used != null ? "known" : "unknown");
if (used == null) {
return {
session: node.canonicalSessionName ?? "",
rig: node.rigName ?? "",
logicalId: node.logicalId ?? "",
runtime: node.runtime ?? "unknown",
usedPercentage: null, remainingPercentage: null, contextWindowSize: null,
urgency: "unknown", freshness: "none", status: "unknown", displayStatus: "unknown",
source, availability, sampledAt, staleness: "\u2014", fresh: false,
};
}
const urgency: "critical" | "warning" | "low" = used >= 80 ? "critical" : used >= 60 ? "warning" : "low";
const freshness = computeFreshness(sampledAt);
let status: string;
let displayStatus: string;
if (urgency === "critical") {
status = freshness === "fresh" ? "critical" : "critical_stale";
displayStatus = freshness === "fresh" ? "CRITICAL" : "CRITICAL (stale)";
} else if (urgency === "warning") {
status = freshness === "fresh" ? "warning" : "warning_stale";
displayStatus = freshness === "fresh" ? "WARNING" : "WARNING (stale)";
} else if (freshness === "fresh") {
status = "ok";
displayStatus = "ok";
} else {
status = "stale";
displayStatus = "stale";
}
return {
session: node.canonicalSessionName ?? "",
rig: node.rigName ?? "",
logicalId: node.logicalId ?? "",
runtime: node.runtime ?? "unknown",
usedPercentage: used, remainingPercentage: remaining, contextWindowSize: window,
urgency, freshness, status, displayStatus,
source, availability, sampledAt, staleness: ageLabel(sampledAt), fresh: freshness === "fresh",
};
}
const STATUS_RANK: Record<string, number> = {
critical: 0, critical_stale: 0,
warning: 1, warning_stale: 1,
stale: 2, ok: 3, unknown: 4,
};
function sortSeats(seats: Seat[]): Seat[] {
return seats.sort((a, b) => {
const ra = STATUS_RANK[a.status] ?? 9;
const rb = STATUS_RANK[b.status] ?? 9;
if (ra !== rb) return ra - rb;
const pa = a.usedPercentage ?? -1;
const pb = b.usedPercentage ?? -1;
if (pa !== pb) return pb - pa; // higher percentage first
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
return a.session.localeCompare(b.session);
});
}
function isVisible(seat: Seat, threshold: number | null): boolean {
if (threshold == null) return true;
if (seat.status === "unknown") return true;
if (seat.freshness === "stale") return true;
return seat.usedPercentage != null && seat.usedPercentage >= threshold;
}
export function contextCommand(depsOverride?: ContextDeps): Command {
const cmd = new Command("context")
.description("Show context-usage across running agents")
.addHelpText("after", `
Examples:
rig context Show all seats with context usage
rig context --rig openrig-pm Show one rig
rig context --threshold 80 Show seats at or above 80% (plus unknown + stale)
rig context --refresh Re-sample context before displaying
rig context --json JSON output for agents`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--rig <name>", "Show one rig only")
.option("--threshold <pct>", "Show seats at or above this percentage")
.option("--refresh", "Re-sample context usage before displaying")
.action(async (opts: { json?: boolean; rig?: string; threshold?: string; refresh?: boolean }) => {
const deps = getDepsF();
// Strict threshold validation — reject non-integer input
let threshold: number | null = null;
if (opts.threshold != null) {
const parsed = Number(opts.threshold);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
console.error("rig context: --threshold must be an integer percentage (0-100)");
process.exitCode = 2;
return;
}
threshold = parsed;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon is not running. Start it with: rig daemon start");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
try {
// Get rig list to iterate
const psResult = await client.get<Array<{ rigId: string; name: string }>>("/api/ps");
const rigs = psResult.data ?? [];
const targetRigs = opts.rig
? rigs.filter((r) => r.name === opts.rig)
: rigs;
if (opts.rig && targetRigs.length === 0) {
console.error(`Rig "${opts.rig}" not found. List rigs with: rig ps`);
process.exitCode = 1;
return;
}
// Refresh once globally if requested (pollOnce samples all active Claude sessions).
// Refresh failure is a hard error — do not silently display stale data as if refreshed.
if (opts.refresh && targetRigs.length > 0) {
const firstRig = targetRigs[0]!;
try {
const refreshResult = await client.get(`/api/rigs/${firstRig.rigId}/nodes?refresh=true`);
if (refreshResult.status >= 400) {
console.error("Context refresh failed. Data may be stale.");
console.error(`Detail: ${JSON.stringify(refreshResult.data)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
} catch (refreshErr) {
console.error("Context refresh failed. Data may be stale.");
console.error(`Detail: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
console.error("Fix: retry without --refresh to see stale data, or check daemon logs.");
process.exitCode = 2;
return;
}
}
// Fetch node inventory per rig (without refresh — already done globally)
const allNodes: NodeEntry[] = [];
for (const rig of targetRigs) {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
if (Array.isArray(nodesResult.data)) {
allNodes.push(...nodesResult.data);
}
}
const seats = sortSeats(
allNodes
.map(analyze)
.filter((s) => isVisible(s, threshold))
);
const summary = {
total: seats.length,
known: seats.filter((s) => s.usedPercentage != null).length,
critical: seats.filter((s) => s.urgency === "critical").length,
warning: seats.filter((s) => s.urgency === "warning").length,
ok: seats.filter((s) => s.status === "ok").length,
stale: seats.filter((s) => s.freshness === "stale").length,
unknown: seats.filter((s) => s.status === "unknown").length,
};
if (opts.json) {
console.log(JSON.stringify({ seats, summary }, null, 2));
return;
}
// Human-readable output
const title = opts.rig ? `CONTEXT USAGE - ${opts.rig}` : "CONTEXT USAGE - all rigs";
console.log(title);
console.log(
`${"seat".padEnd(42)} ${"runtime".padEnd(12)} ${"context".padStart(7)} ${"window".padStart(8)} ${"sampled".padStart(10)} status`
);
for (const seat of seats) {
const context = seat.usedPercentage == null ? "?" : `${seat.usedPercentage}%`;
const window = seat.contextWindowSize == null ? "?" : String(seat.contextWindowSize);
console.log(
`${seat.session.slice(0, 42).padEnd(42)} ${seat.runtime.slice(0, 12).padEnd(12)} ${context.padStart(7)} ${window.padStart(8)} ${seat.staleness.padStart(10)} ${seat.displayStatus}`
);
}
console.log();
console.log(
`FLEET SUMMARY: ${summary.known}/${summary.total} known | ` +
`${summary.critical} CRITICAL | ${summary.warning} WARNING | ` +
`${summary.ok} ok | ${summary.stale} stale | ${summary.unknown} unknown`
);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 2;
}
});
return cmd;
}
+3 -2
View File
@@ -140,12 +140,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.description("Show daemon status")
.action(async () => {
const status = await getDaemonStatus(getDeps());
const pidSuffix = status.pid !== undefined ? ` (pid ${status.pid})` : "";
switch (status.state) {
case "running":
if (status.healthy === false) {
console.log(`Daemon running on port ${status.port} (pid ${status.pid}) — healthz failed`);
console.log(`Daemon running on port ${status.port}${pidSuffix} — healthz failed`);
} else {
console.log(`Daemon running on port ${status.port} (pid ${status.pid})`);
console.log(`Daemon running on port ${status.port}${pidSuffix}`);
}
break;
case "stopped":
+210
View File
@@ -0,0 +1,210 @@
import { Command } from "commander";
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
import { realDeps } from "./daemon.js";
import { getDefaultOpenRigPath, getPreferredOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
import {
DESTROY_CONFIRM_TOKEN,
buildDestroyPlan,
executeDestroy,
findListeningPidWithLsof,
killTmuxSessionWithCli,
listManagedTmuxSessionsFromDb,
type DestroyDeps,
type ListenerInspection,
type DestroyRuntimeConfig,
type DestroyScope,
} from "../destroy-helpers.js";
import { stopDaemon } from "../daemon-lifecycle.js";
export interface DestroyCommandDeps {
configStore: Pick<ConfigStore, "resolve">;
destroyDeps: DestroyDeps;
}
interface ResolvedDestroyRuntime {
runtimeConfig: DestroyRuntimeConfig;
warnings: string[];
}
function resolveRuntimeConfig(configStore: Pick<ConfigStore, "resolve">): ResolvedDestroyRuntime {
const warnings: string[] = [];
const stateRoot = getPreferredOpenRigHome();
const defaultDbPath = getDefaultOpenRigPath("openrig.sqlite");
const defaultTranscriptsPath = getDefaultOpenRigPath("transcripts");
const effectiveDefaultDbPath = join(stateRoot, "openrig.sqlite");
const effectiveDefaultTranscriptsPath = join(stateRoot, "transcripts");
const overrideUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL")?.trim();
if (overrideUrl) {
warnings.push(`Ignoring ${overrideUrl} from OPENRIG_URL/RIGGED_URL because rig destroy only operates on local state.`);
}
try {
const config = configStore.resolve();
return {
runtimeConfig: {
stateRoot,
dbPath: config.db.path === defaultDbPath ? effectiveDefaultDbPath : config.db.path,
transcriptsPath: config.transcripts.path === defaultTranscriptsPath ? effectiveDefaultTranscriptsPath : config.transcripts.path,
daemonHost: config.daemon.host,
daemonPort: config.daemon.port,
},
warnings,
};
} catch (err) {
warnings.push(`Failed to read config; using compatibility defaults instead: ${err instanceof Error ? err.message : String(err)}`);
return {
runtimeConfig: {
stateRoot,
dbPath: effectiveDefaultDbPath,
transcriptsPath: effectiveDefaultTranscriptsPath,
daemonHost: "127.0.0.1",
daemonPort: 7433,
},
warnings,
};
}
}
function realDestroyDeps(): DestroyDeps {
const lifecycleDeps = realDeps();
return {
stopDaemon: async () => stopDaemon(lifecycleDeps),
inspectListener: async (host: string, port: number): Promise<ListenerInspection> => {
try {
const res = await fetch(`http://${host}:${port}/healthz`);
if (!res.ok) {
return {
kind: "other_http",
healthy: false,
detail: `HTTP ${res.status}`,
};
}
let body: unknown = null;
try {
body = await res.json();
} catch {
return {
kind: "other_http",
healthy: false,
detail: "Response body was not valid OpenRig health JSON.",
};
}
if (body && typeof body === "object" && (body as Record<string, unknown>).status === "ok") {
return { kind: "openrig", healthy: true };
}
return {
kind: "other_http",
healthy: false,
detail: "Response body did not match the OpenRig health contract.",
};
} catch {
return { kind: "unreachable" };
}
},
findListeningPid: findListeningPidWithLsof,
killProcess: (pid: number) => {
process.kill(pid, "SIGTERM");
},
exists: existsSync,
renamePath: (from: string, to: string) => renameSync(from, to),
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
killTmuxSession: killTmuxSessionWithCli,
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => new Date(),
};
}
function printPlan(
scope: DestroyScope,
backup: boolean,
runtimeConfig: DestroyRuntimeConfig,
managedTmuxSessions: string[],
warnings: string[]
): void {
console.log("DESTROY PLAN");
console.log(` scope: ${scope}`);
console.log(` state root: ${runtimeConfig.stateRoot}`);
console.log(` daemon: http://${runtimeConfig.daemonHost}:${runtimeConfig.daemonPort}`);
console.log(` backup: ${backup ? "enabled" : "disabled"}`);
console.log(` tmux cleanup: ${scope === "all" ? `enabled (${managedTmuxSessions.length} managed session${managedTmuxSessions.length === 1 ? "" : "s"})` : "disabled"}`);
for (const warning of warnings) {
console.log(` warning: ${warning}`);
}
console.log("");
}
function printResult(result: Awaited<ReturnType<typeof executeDestroy>>): void {
console.log("DESTROY RESULT");
console.log(` daemon: ${result.daemonStopped ? "stopped" : "still responding"}`);
console.log(` port: ${result.portCleared ? "cleared" : "still occupied"}`);
if (result.scope === "all") {
console.log(` tmux sessions removed: ${result.tmuxKilled}`);
if (result.tmuxMissing > 0) {
console.log(` tmux sessions missing/already gone: ${result.tmuxMissing}`);
}
}
if (result.backupPaths.length > 0) {
for (const backupPath of result.backupPaths) {
console.log(` backup: ${backupPath}`);
}
} else if (result.backup) {
console.log(" backup: nothing to move");
}
console.log(result.stateRecreated ? ` fresh state root: ${result.stateRoot}` : " fresh state root: not created (state left untouched)");
for (const warning of result.warnings) {
console.log(` warning: ${warning}`);
}
}
export function destroyCommand(depsOverride?: DestroyCommandDeps): Command {
const cmd = new Command("destroy").description("Destroy OpenRig local state for recovery");
cmd
.option("--state", "Destroy OpenRig state and recreate an empty state root")
.option("--all", "Destroy OpenRig state and remove managed tmux sessions")
.option("--backup", "Move state aside instead of deleting it")
.option("--yes", "Confirm the destructive operation")
.option("--confirm <token>", `Exact confirmation token: ${DESTROY_CONFIRM_TOKEN}`)
.action(async (opts: { state?: boolean; all?: boolean; backup?: boolean; yes?: boolean; confirm?: string }) => {
const selectedScopes = [opts.state ? "state" : null, opts.all ? "all" : null].filter(Boolean) as DestroyScope[];
if (selectedScopes.length !== 1) {
console.error("Specify exactly one destroy scope: --state or --all");
process.exitCode = 1;
return;
}
if (!opts.yes) {
console.error("Destroy requires --yes");
process.exitCode = 1;
return;
}
if (opts.confirm !== DESTROY_CONFIRM_TOKEN) {
console.error(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
process.exitCode = 1;
return;
}
const deps = depsOverride ?? {
configStore: new ConfigStore(),
destroyDeps: realDestroyDeps(),
};
const { runtimeConfig, warnings } = resolveRuntimeConfig(deps.configStore);
const plan = buildDestroyPlan(selectedScopes[0]!, Boolean(opts.backup), runtimeConfig, deps.destroyDeps);
printPlan(plan.scope, plan.backup, runtimeConfig, plan.managedTmuxSessions, [...warnings, ...plan.warnings]);
plan.warnings = [...warnings, ...plan.warnings];
const result = await executeDestroy(plan, deps.destroyDeps);
printResult(result);
if (!result.portCleared) {
process.exitCode = 1;
}
});
return cmd;
}
+178 -20
View File
@@ -1,15 +1,22 @@
import { Command } from "commander";
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import net from "node:net";
import { execSync } from "node:child_process";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { ConfigStore } from "../config-store.js";
import { buildWritableHomeCheck } from "../system-preflight.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
isCmuxSocketControlCompatible,
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
interface DoctorCheck {
name: string;
status: "pass" | "warn" | "fail";
status: "pass" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fix?: string;
@@ -18,11 +25,14 @@ interface DoctorCheck {
export interface DoctorDeps {
exists: (p: string) => boolean;
baseDir: string;
readFile: (path: string) => string | null;
exec: (cmd: string) => string;
checkPort: (port: number) => Promise<boolean>;
configStore: Pick<ConfigStore, "resolve">;
platform?: NodeJS.Platform;
mkdirp?: (path: string) => void;
checkWritable?: (path: string) => void;
fetch?: (url: string) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
}
const MIN_NODE_MAJOR = 20;
@@ -39,8 +49,9 @@ function defaultCheckPort(port: number): Promise<boolean> {
});
}
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck> } {
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck>; asyncChecks: Promise<DoctorCheck>[] } {
const checks: DoctorCheck[] = [];
const platform = deps.platform ?? process.platform;
// 1. Daemon dist
const daemonPath = resolveDaemonPath(deps.baseDir, deps.exists);
@@ -86,10 +97,8 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
}
// 4. tmux
try {
const ver = deps.exec("tmux -V").trim();
checks.push({ name: "tmux", status: "pass", message: ver });
} catch {
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
if (tmuxProbe.code === "not_installed") {
checks.push({
name: "tmux",
status: "fail",
@@ -97,9 +106,78 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
reason: "OpenRig uses tmux to manage agent sessions.",
fix: "Install tmux: brew install tmux (macOS), apt install tmux (Linux).",
});
} else if (tmuxProbe.available && tmuxProbe.version) {
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
if (platform === "darwin") {
const mouseMode = readTmuxMouseMode(deps);
if (mouseMode === "on") {
checks.push({
name: "tmux_mouse",
status: "pass",
message: "tmux mouse mode enabled.",
});
} else if (mouseMode === "off") {
checks.push({
name: "tmux_mouse",
status: "warn",
message: "tmux mouse mode appears disabled.",
reason: "On macOS, scrolling and text selection inside tmux panes is much smoother with mouse mode enabled.",
fix: "Run `tmux set -g mouse on` for the current tmux server. To keep it enabled, add `set -g mouse on` to `~/.tmux.conf` and reload with `tmux source-file ~/.tmux.conf`.",
});
}
}
} else {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
checks.push({
name: "tmux",
status: "fail",
message: failure.message,
reason: failure.reason,
fix: failure.fix,
});
}
// 5. Writable state paths (shared with preflight)
// 5. cmux shell check (optional but recommended for Open CMUX workflows)
let shellCmuxPassed = false;
try {
deps.exec("cmux capabilities --json");
shellCmuxPassed = true;
checks.push({
name: "cmux_shell",
status: "pass",
message: "cmux shell control available.",
});
} catch (err) {
try {
deps.exec("cmux --help");
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
const modeHint = socketMode?.error
? ` Likely cause on macOS: ${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable (${socketMode.error}).`
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode) && socketMode.source === "default"
? ` Likely cause on macOS: cmux is still using its default automation.socketControlMode '${socketMode.mode}'.`
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode)
? ` Likely cause on macOS: automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}.`
: "";
checks.push({
name: "cmux_shell",
status: "warn",
message: "cmux installed, but control unavailable right now.",
reason: "OpenRig can run without cmux, but Open CMUX actions and cmux-aware node control will be unavailable until cmux control works.",
fix: `Open the cmux app, verify control access/socket sharing is enabled for OpenRig, then rerun 'rig doctor'. If you are running this for someone else, tell the user that cmux is optional but required for Open CMUX. If you do not need cmux features, you can ignore this warning.${modeHint}`,
});
} catch {
checks.push({
name: "cmux_shell",
status: "warn",
message: "cmux not found.",
reason: "OpenRig can run without cmux, but Open CMUX actions and surface control will be unavailable.",
fix: "Install and launch cmux if you want Open CMUX support. If you are running this for someone else, tell the user that cmux is optional and only needed for Open CMUX workflows.",
});
}
void err;
}
// 6. Writable state paths (shared with preflight)
const config = deps.configStore.resolve();
const writableCheck = buildWritableHomeCheck(config, path.dirname(config.db.path), {
mkdirp: deps.mkdirp,
@@ -113,7 +191,7 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
fix: writableCheck.fix,
});
// 6. Port availability (async) — daemon already running on that port counts as OK
// 7. Port availability (async) — daemon already running on that port counts as OK
const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise<DoctorCheck> => {
if (available) {
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` };
@@ -134,7 +212,86 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
};
});
return { checks, portCheck };
// 8. Daemon cmux control (async, only when shell cmux passed)
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
if (shellCmuxPassed) {
const fetchFn = deps.fetch ?? globalThis.fetch;
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
try {
const healthRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
if (!healthRes.ok) {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not running. Skipping daemon cmux check." };
}
} catch {
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
}
try {
const cmuxRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/api/adapters/cmux/status`);
if (cmuxRes.ok && cmuxRes.json) {
const data = (await cmuxRes.json()) as { available?: boolean };
if (data.available) {
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
}
return buildCmuxDaemonWarning(deps, platform);
}
} catch { /* fetch failed */ }
return buildCmuxDaemonWarning(deps, platform);
})();
asyncChecks.push(daemonCmuxCheck);
}
return { checks, portCheck, asyncChecks };
}
function readCmuxSocketControlMode(deps: DoctorDeps) {
return readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
}
function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): DoctorCheck {
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
if (platform === "darwin" && socketMode?.error) {
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason: `${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable: ${socketMode.error}`,
fix: `Repair ${CMUX_SETTINGS_DISCLOSURE_PATH} or remove it so cmux can regenerate the template, then rerun \`rig setup\` or \`rig doctor\`.`,
};
}
if (platform === "darwin" && socketMode && !isCmuxSocketControlCompatible(socketMode.mode)) {
const reason = socketMode.source === "default"
? `cmux is still using its default automation.socketControlMode '${socketMode.mode}', so the daemon cannot attach as an external cmux client yet.`
: `automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}, so the daemon cannot attach as an external cmux client yet.`;
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason,
fix: `Run \`rig setup\` to set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then restart the daemon with \`rig daemon start\`.`,
};
}
return {
name: "cmux_daemon",
status: "warn",
message: "Shell cmux works, but the daemon cannot control cmux.",
reason: "The daemon inherited a terminal/session environment that broke cmux adapter initialization.",
fix: "Restart the daemon with `rig daemon start` after setup. If it still fails, inspect the daemon log with `rig daemon logs`.",
};
}
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
try {
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
if (mode === "on" || mode === "off") return mode;
return null;
} catch {
return null;
}
}
export function doctorCommand(depsOverride?: DoctorDeps): Command {
@@ -146,34 +303,35 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
const deps: DoctorDeps = depsOverride ?? {
exists: existsSync,
baseDir: import.meta.dirname,
exec: (c: string) => execSync(c, { encoding: "utf-8" }),
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
checkPort: defaultCheckPort,
configStore: new ConfigStore(),
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
checkWritable: (dirPath: string) => accessSync(dirPath, constants.W_OK),
};
const { checks, portCheck } = runDoctorChecks(deps);
const portResult = await portCheck;
const allChecks = [...checks, portResult];
const allPass = allChecks.every((c) => c.status === "pass");
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolvedAsync = await Promise.all(asyncChecks);
const allChecks = [...checks, ...resolvedAsync];
const healthy = allChecks.every((c) => c.status !== "fail");
if (opts.json) {
console.log(JSON.stringify({ healthy: allPass, checks: allChecks }, null, 2));
if (!allPass) process.exitCode = 1;
console.log(JSON.stringify({ healthy, checks: allChecks }, null, 2));
if (!healthy) process.exitCode = 1;
return;
}
for (const check of allChecks) {
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : "FAIL";
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : check.status === "skipped" ? "SKIP" : "FAIL";
console.log(` [${icon}] ${check.name}: ${check.message}`);
if (check.reason) console.log(` Why: ${check.reason}`);
if (check.fix) console.log(` Fix: ${check.fix}`);
}
console.log("");
console.log(allPass ? "All checks passed." : "Some checks failed.");
if (!allPass) process.exitCode = 1;
console.log(healthy ? "System checks look good." : "Some checks failed.");
if (!healthy) process.exitCode = 1;
});
return cmd;
+3 -1
View File
@@ -14,6 +14,8 @@ interface TeardownResult {
errors: string[];
}
const LONG_RUNNING_TIMEOUT_MS = 45_000;
/**
* `rig down <rigId>` — tear down a rig.
* @param depsOverride - injectable deps for testing
@@ -46,7 +48,7 @@ export function downCommand(depsOverride?: StatusDeps): Command {
delete: opts.delete ?? false,
force: opts.force ?? false,
snapshot: opts.snapshot ?? false,
});
}, { timeoutMs: LONG_RUNNING_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
+142
View File
@@ -0,0 +1,142 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function envCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("env")
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
.addHelpText("after", `
Examples:
rig env status secrets-manager
rig env logs secrets-manager vault
rig env down secrets-manager
`);
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running");
return null;
}
return deps.clientFactory(getDaemonUrl(status));
}
async function resolveRigId(client: DaemonClient, rigRef: string): Promise<string> {
const summaries = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
if (!match) throw new Error(`Rig '${rigRef}' not found`);
return match.id;
}
// rig env status <rig>
cmd
.command("status")
.argument("<rig>", "Rig name or ID")
.option("--json", "JSON output")
.action(async (rig: string, opts: { json?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
if (!res.data["hasServices"]) {
console.log("No services configured for this rig.");
return;
}
const receipt = res.data["receipt"] as Record<string, unknown> | null;
if (!receipt) {
console.log("Services configured but no receipt available yet.");
return;
}
console.log(`Env: ${res.data["kind"]} (${res.data["projectName"]})`);
const services = receipt["services"] as Array<{ name: string; status: string; health?: string | null }>;
if (services) {
for (const svc of services) {
const health = svc.health ? ` (${svc.health})` : "";
console.log(` ${svc.name}: ${svc.status}${health}`);
}
}
});
// rig env logs <rig> [service]
cmd
.command("logs")
.argument("<rig>", "Rig name or ID")
.argument("[service]", "Specific service name")
.option("--tail <n>", "Number of lines", "100")
.action(async (rig: string, service: string | undefined, opts: { tail: string }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const params = new URLSearchParams({ tail: opts.tail });
if (service) params.set("service", service);
const res = await client.get<{ ok: boolean; output?: string; error?: string }>(
`/api/rigs/${encodeURIComponent(rigId)}/env/logs?${params}`,
);
if (res.status >= 400 || !res.data.ok) {
console.error(res.data.error ?? `Failed to get logs (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
console.log(res.data.output ?? "");
});
// rig env down <rig> [--volumes]
cmd
.command("down")
.argument("<rig>", "Rig name or ID")
.option("--volumes", "Also remove volumes")
.action(async (rig: string, opts: { volumes?: boolean }) => {
const deps = getDeps();
const client = await getClient(deps);
if (!client) { process.exitCode = 1; return; }
let rigId: string;
try { rigId = await resolveRigId(client, rig); } catch (err) {
console.error((err as Error).message); process.exitCode = 1; return;
}
const res = await client.post<{ ok: boolean; error?: string }>(
`/api/rigs/${encodeURIComponent(rigId)}/env/down`,
{ volumes: opts.volumes ?? false },
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
);
if (res.status >= 400 || !res.data.ok) {
console.error(res.data.error ?? `Failed to stop services (HTTP ${res.status})`);
process.exitCode = 1;
return;
}
console.log(`Services stopped for ${rig}.`);
});
return cmd;
}
+14 -4
View File
@@ -102,13 +102,23 @@ export function expandCommand(depsOverride?: StatusDeps): Command {
for (const w of data.warnings) console.log(` Warning: ${w}`);
}
// Honest retry guidance for failed nodes
// Honest retry guidance for failed nodes. The pod-aware /launch route
// returns pod_aware_launch_unsupported for any node `expand` creates
// (every expand-created node has podId set), so the recoverable path is
// shrink-the-failed-pod + fix + re-expand, not per-node relaunch.
if (data.retryTargets && data.retryTargets.length > 0) {
console.log("");
console.log(" Failed nodes can be relaunched via the dashboard or CLI:");
for (const target of data.retryTargets) {
console.log(` rig launch ${rigId} ${target}`);
console.log(" Failed nodes cannot be relaunched directly because pod-aware launch");
console.log(" bypasses startup orchestration. Recover by:");
console.log(` 1. Verify other seats are healthy: rig ps --filter rigName=${rigId} --nodes`);
if (data.podNamespace) {
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} ${data.podNamespace}`);
} else {
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} <pod-namespace>`);
}
console.log(` 3. Fix the spec/config issue.`);
console.log(` 4. Re-run: rig expand ${rigId} <pod-fragment-path>`);
console.log(` Failed targets: ${data.retryTargets.join(", ")}`);
}
if (data.status !== "ok") {
+530
View File
@@ -0,0 +1,530 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { Command } from "commander";
export type HeartbeatExecutionState =
| "checked-out"
| "proven-active"
| "stalled"
| "unproven"
| "blocked"
| "parked"
| "done";
export interface HeartbeatProof {
at: string;
ageSeconds: number;
line: string;
path: string | null;
}
export interface HeartbeatItem {
id: string;
rig: string;
owner: string;
session: string;
title: string;
queueState: string;
executionState: HeartbeatExecutionState;
queueFile: string;
checkoutAt: string | null;
checkoutAgeSeconds: number | null;
lastProof: HeartbeatProof | null;
blockedOn: string | null;
}
export interface HeartbeatSummary {
total: number;
checkedOut: number;
provenActive: number;
stalled: number;
unproven: number;
blocked: number;
parked: number;
done: number;
}
export interface HeartbeatResult {
generatedAt: string;
sharedDocsRoot: string;
rigFilter: string | null;
windows: {
firstProofSeconds: number;
heartbeatSeconds: number;
};
summary: HeartbeatSummary;
items: HeartbeatItem[];
nudgeResults?: HeartbeatNudgeResult[];
}
export interface HeartbeatNudgeResult {
id: string;
session: string;
executionState: "stalled" | "unproven";
ok: boolean;
message: string;
}
interface QueueEntry {
id: string;
rig: string;
owner: string;
session: string;
title: string;
queueState: string;
queueFile: string;
meta: Record<string, string>;
body: string;
}
export interface HeartbeatOptions {
rig?: string;
firstProofSeconds?: number;
heartbeatSeconds?: number;
includeDone?: boolean;
now?: Date;
}
export interface HeartbeatDeps {
sharedDocsRoot?: string;
env?: NodeJS.ProcessEnv;
now?: () => Date;
send?: (session: string, text: string) => Promise<{ ok: boolean; message: string }>;
}
const DEFAULT_FIRST_PROOF_SECONDS = 7_200;
const DEFAULT_HEARTBEAT_SECONDS = 7_200;
const TIMESTAMP_PATTERN = String.raw`\d{4}-\d{2}-\d{2}T[\d:~]+(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?`;
const STATE_RANK: Record<HeartbeatExecutionState, number> = {
stalled: 0,
unproven: 1,
blocked: 2,
"checked-out": 3,
"proven-active": 4,
parked: 5,
done: 6,
};
export function heartbeatCommand(depsOverride?: HeartbeatDeps): Command {
const cmd = new Command("heartbeat")
.description("Show workflow execution proof state from queue files")
.addHelpText("after", `
Examples:
rig heartbeat --rig openrig-pm
rig heartbeat --rig openrig-pm --json
rig heartbeat --rig openrig-pm --nudge
Default mode is read-only. --nudge only sends informational proof instructions
to stalled/unproven owners; it does not modify queue files or reroute work.`);
cmd
.option("--rig <name>", "Limit to a single rig")
.option("--json", "JSON output for agents")
.option("--nudge", "Send informational nudges to stalled/unproven owners")
.option("--include-done", "Include done/handed-off queue items in output")
.action(async (opts: { rig?: string; json?: boolean; nudge?: boolean; includeDone?: boolean }) => {
const deps = depsOverride ?? {};
const env = deps.env ?? process.env;
const sharedDocsRoot = deps.sharedDocsRoot ?? resolveSharedDocsRoot(env);
if (!sharedDocsRoot) {
console.error("rig heartbeat: cannot resolve shared-docs root. Set RIGX_SHARED_DOCS_ROOT.");
process.exitCode = 1;
return;
}
const firstProofSeconds = readPositiveInt(env["HEARTBEAT_FIRST_PROOF_WINDOW"], DEFAULT_FIRST_PROOF_SECONDS);
const heartbeatSeconds = readPositiveInt(env["HEARTBEAT_HEARTBEAT_CADENCE"], DEFAULT_HEARTBEAT_SECONDS);
const now = deps.now?.() ?? new Date();
const result = analyzeHeartbeat({
sharedDocsRoot,
rig: opts.rig,
firstProofSeconds,
heartbeatSeconds,
includeDone: !!opts.includeDone,
now,
});
if (opts.nudge) {
const send = deps.send ?? defaultSend;
result.nudgeResults = await sendHeartbeatNudges(result.items, send);
}
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
printHumanHeartbeat(result);
});
return cmd;
}
export function analyzeHeartbeat(input: {
sharedDocsRoot: string;
rig?: string;
firstProofSeconds?: number;
heartbeatSeconds?: number;
includeDone?: boolean;
now?: Date;
}): HeartbeatResult {
const firstProofSeconds = input.firstProofSeconds ?? DEFAULT_FIRST_PROOF_SECONDS;
const heartbeatSeconds = input.heartbeatSeconds ?? DEFAULT_HEARTBEAT_SECONDS;
const now = input.now ?? new Date();
const generatedAt = now.toISOString();
const queueFiles = discoverQueueFiles(input.sharedDocsRoot, input.rig);
const entries = queueFiles.flatMap((file) => parseQueueFile(input.sharedDocsRoot, file));
const items = entries
.map((entry) => classifyQueueEntry(entry, { now, firstProofSeconds, heartbeatSeconds }))
.filter((item): item is HeartbeatItem => item !== null)
.filter((item) => input.includeDone || item.executionState !== "done")
.sort(compareHeartbeatItems);
return {
generatedAt,
sharedDocsRoot: input.sharedDocsRoot,
rigFilter: input.rig ?? null,
windows: { firstProofSeconds, heartbeatSeconds },
summary: summarize(items),
items,
};
}
export function parseQueueFile(sharedDocsRoot: string, queueFile: string): QueueEntry[] {
const content = fs.readFileSync(queueFile, "utf8");
const rig = deriveRigName(sharedDocsRoot, queueFile);
const owner = deriveOwner(queueFile);
const session = deriveSessionName(owner, rig);
const entries: QueueEntry[] = [];
const entryPattern = new RegExp(
String.raw`(?:^|\n)---\nid:\s*(\S+)\n([\s\S]*?)\n---\n([\s\S]*?)(?=\n---\nid:\s*\S+\n|$)`,
"g",
);
for (const match of content.matchAll(entryPattern)) {
const id = match[1]!;
const meta = parseMetadata(match[2] ?? "");
meta["id"] = id;
const body = match[3] ?? "";
const queueState = normalizeState(meta["state"] ?? "unknown");
const title = body.match(/^###\s+(.+)$/m)?.[1]?.trim() ?? id;
entries.push({
id,
rig,
owner,
session,
title: title.slice(0, 120),
queueState,
queueFile,
meta,
body,
});
}
return entries;
}
export function classifyQueueEntry(
entry: QueueEntry,
opts: { now: Date; firstProofSeconds: number; heartbeatSeconds: number },
): HeartbeatItem | null {
const blockedOn = cleanNullable(entry.meta["blocked-on"]);
if (entry.queueState === "blocked" || entry.queueState === "blocked-on") {
return baseItem(entry, "blocked", opts.now, null, null, blockedOn);
}
if (entry.queueState === "deferred" || entry.queueState === "parked") {
return baseItem(entry, "parked", opts.now, null, null, blockedOn);
}
if (entry.queueState === "done" || entry.queueState === "handed-off") {
return baseItem(entry, "done", opts.now, null, null, blockedOn);
}
if (entry.queueState !== "in-progress") {
return null;
}
const checkoutAt = firstStateTransitionTimestamp(entry.body, "in-progress")
?? cleanNullable(entry.meta["ts-created"]);
const checkoutAgeSeconds = ageSeconds(checkoutAt, opts.now);
const lastProof = latestProof(entry.body, opts.now);
let executionState: HeartbeatExecutionState;
if (lastProof && lastProof.ageSeconds <= opts.heartbeatSeconds) {
executionState = "proven-active";
} else if (lastProof) {
executionState = "stalled";
} else if (checkoutAgeSeconds != null && checkoutAgeSeconds > opts.firstProofSeconds) {
executionState = "unproven";
} else {
executionState = "checked-out";
}
return baseItem(entry, executionState, opts.now, checkoutAt, lastProof, blockedOn);
}
export async function sendHeartbeatNudges(
items: HeartbeatItem[],
send: (session: string, text: string) => Promise<{ ok: boolean; message: string }>,
): Promise<HeartbeatNudgeResult[]> {
const targets = items.filter((item): item is HeartbeatItem & { executionState: "stalled" | "unproven" } =>
item.executionState === "stalled" || item.executionState === "unproven"
);
const results: HeartbeatNudgeResult[] = [];
for (const item of targets) {
const proofCopy = item.executionState === "stalled" && item.lastProof
? `Last proof was ${formatAge(item.lastProof.ageSeconds)} ago.`
: `No proof-of-work since checkout${item.checkoutAgeSeconds == null ? "." : ` ${formatAge(item.checkoutAgeSeconds)} ago.`}`;
const text = [
`[heartbeat-nudge] Your task \`${item.id}\` is ${item.executionState}. ${proofCopy}`,
"If actively working, add a task-specific proof note to your queue item naming the artifact.",
"If blocked, transition to blocked with reason. If pausing, transition to deferred with reason.",
].join(" ");
const result = await send(item.session, text);
results.push({ id: item.id, session: item.session, executionState: item.executionState, ...result });
}
return results;
}
function resolveSharedDocsRoot(env: NodeJS.ProcessEnv): string | null {
for (const key of ["RIGX_SHARED_DOCS_ROOT", "OPENRIG_SHARED_DOCS_ROOT"]) {
const value = env[key];
if (value && fs.existsSync(nodePath.join(value, "rigs"))) return value;
}
const home = env["HOME"] ?? os.homedir();
const wellKnown = nodePath.join(home, "code", "substrate", "shared-docs");
if (fs.existsSync(nodePath.join(wellKnown, "rigs"))) return wellKnown;
return null;
}
function discoverQueueFiles(sharedDocsRoot: string, rig?: string): string[] {
const base = rig
? nodePath.join(sharedDocsRoot, "rigs", rig, "state")
: nodePath.join(sharedDocsRoot, "rigs");
const files: string[] = [];
walk(base, files);
return files.filter((file) => file.endsWith(".queue.md")).sort();
}
function walk(dir: string, files: string[]): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = nodePath.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath, files);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
function parseMetadata(text: string): Record<string, string> {
const meta: Record<string, string> = {};
for (const line of text.split("\n")) {
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (match) meta[match[1]!.toLowerCase()] = match[2]!.trim();
}
return meta;
}
function normalizeState(state: string): string {
return state.trim().toLowerCase().replace(/_/g, "-");
}
function firstStateTransitionTimestamp(body: string, state: string): string | null {
const pattern = new RegExp(`(${TIMESTAMP_PATTERN})\\s*[-\u2014]\\s*${escapeRegex(state).replace("-", "[- ]")}`, "gi");
const matches = Array.from(body.matchAll(pattern)).map((match) => match[1]!.replace("~", ""));
return matches.length > 0 ? matches[0]! : null;
}
function latestProof(body: string, now: Date): HeartbeatProof | null {
const proofs: HeartbeatProof[] = [];
for (const rawLine of body.split("\n")) {
const line = rawLine.trim();
if (!/\bproof\b/i.test(line)) continue;
const timestamp = line.match(new RegExp(`(${TIMESTAMP_PATTERN})`, "i"))?.[1]?.replace("~", "");
if (!timestamp) continue;
const path = extractEvidencePath(line);
if (!path) continue;
const age = ageSeconds(timestamp, now);
if (age == null) continue;
proofs.push({ at: timestamp, ageSeconds: age, line, path });
}
proofs.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime());
return proofs.at(-1) ?? null;
}
function extractEvidencePath(line: string): string | null {
const backtickPath = Array.from(line.matchAll(/`([^`]+)`/g))
.map((match) => match[1]!)
.find(looksLikePath);
if (backtickPath) return backtickPath;
const tokenPath = line
.split(/\s+/)
.map((token) => token.replace(/[),.;:]+$/g, "").replace(/^["']|["']$/g, ""))
.find(looksLikePath);
return tokenPath ?? null;
}
function looksLikePath(value: string): boolean {
if (value.startsWith("/")) return value.length > 1;
if (value.startsWith("./") || value.startsWith("../")) return true;
if (/^[A-Za-z0-9_.-]+\.(md|txt|ts|tsx|js|jsx|json|jsonl|yaml|yml|toml|sqlite|db)$/i.test(value)) return true;
return /[A-Za-z0-9_.-]+\/[A-Za-z0-9_.\/-]+/.test(value);
}
function baseItem(
entry: QueueEntry,
executionState: HeartbeatExecutionState,
now: Date,
checkoutAt: string | null,
lastProof: HeartbeatProof | null,
blockedOn: string | null,
): HeartbeatItem {
return {
id: entry.id,
rig: entry.rig,
owner: entry.owner,
session: entry.session,
title: entry.title,
queueState: entry.queueState,
executionState,
queueFile: entry.queueFile,
checkoutAt,
checkoutAgeSeconds: ageSeconds(checkoutAt, now),
lastProof,
blockedOn,
};
}
function ageSeconds(ts: string | null, now: Date): number | null {
if (!ts) return null;
const parsed = parseTimestamp(ts);
if (!parsed) return null;
return Math.max(0, Math.floor((now.getTime() - parsed.getTime()) / 1000));
}
function parseTimestamp(raw: string): Date | null {
const normalized = raw.trim().replace("~", "");
const iso = /Z$|[+-]\d{2}:?\d{2}$/.test(normalized) ? normalized : `${normalized}Z`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
function summarize(items: HeartbeatItem[]): HeartbeatSummary {
return {
total: items.length,
checkedOut: items.filter((item) => item.executionState === "checked-out").length,
provenActive: items.filter((item) => item.executionState === "proven-active").length,
stalled: items.filter((item) => item.executionState === "stalled").length,
unproven: items.filter((item) => item.executionState === "unproven").length,
blocked: items.filter((item) => item.executionState === "blocked").length,
parked: items.filter((item) => item.executionState === "parked").length,
done: items.filter((item) => item.executionState === "done").length,
};
}
function compareHeartbeatItems(a: HeartbeatItem, b: HeartbeatItem): number {
const rankA = STATE_RANK[a.executionState];
const rankB = STATE_RANK[b.executionState];
if (rankA !== rankB) return rankA - rankB;
if (a.rig !== b.rig) return a.rig.localeCompare(b.rig);
if (a.owner !== b.owner) return a.owner.localeCompare(b.owner);
return a.id.localeCompare(b.id);
}
function deriveRigName(sharedDocsRoot: string, queueFile: string): string {
const rel = nodePath.relative(nodePath.join(sharedDocsRoot, "rigs"), queueFile);
return rel.split(nodePath.sep)[0] ?? "";
}
function deriveOwner(queueFile: string): string {
const parts = queueFile.split(nodePath.sep);
const stateIndex = parts.lastIndexOf("state");
if (stateIndex < 0 || stateIndex + 2 >= parts.length) return nodePath.basename(queueFile, ".queue.md");
const pod = parts[stateIndex + 1]!;
const member = nodePath.basename(parts[stateIndex + 2]!, ".queue.md");
return `${pod}.${member}`;
}
function deriveSessionName(owner: string, rig: string): string {
const [pod, member] = owner.split(".", 2);
return pod && member ? `${pod}-${member}@${rig}` : `${owner}@${rig}`;
}
function cleanNullable(value: string | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
return trimmed === "" || trimmed.toLowerCase() === "null" ? null : trimmed;
}
function readPositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function formatAge(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3_600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86_400) return `${Math.floor(seconds / 3_600)}h${Math.floor((seconds % 3_600) / 60)}m`;
return `${Math.floor(seconds / 86_400)}d${Math.floor((seconds % 86_400) / 3_600)}h`;
}
function printHumanHeartbeat(result: HeartbeatResult): void {
const title = result.rigFilter
? `WORKFLOW EXECUTION HEARTBEAT - ${result.rigFilter}`
: "WORKFLOW EXECUTION HEARTBEAT - all rigs";
console.log(title);
console.log(
`items: ${result.summary.total} | proven-active: ${result.summary.provenActive} | checked-out: ${result.summary.checkedOut} | stalled: ${result.summary.stalled} | unproven: ${result.summary.unproven} | blocked: ${result.summary.blocked} | parked: ${result.summary.parked} | done: ${result.summary.done}`,
);
if (result.items.length === 0) {
console.log("No workflow queue items found.");
} else {
console.log("");
console.log(`${"execution".padEnd(15)} ${"queue".padEnd(12)} ${"task".padEnd(32)} ${"owner".padEnd(28)} ${"proof".padEnd(12)} age`);
for (const item of result.items) {
const proof = item.lastProof ? `${formatAge(item.lastProof.ageSeconds)} ago` : "-";
const age = item.checkoutAgeSeconds == null ? "-" : formatAge(item.checkoutAgeSeconds);
console.log(
`${item.executionState.padEnd(15)} ${item.queueState.padEnd(12)} ${item.id.slice(0, 31).padEnd(32)} ${(item.rig + "/" + item.owner).padEnd(28)} ${proof.padEnd(12)} ${age}`,
);
}
}
if (result.nudgeResults) {
console.log("");
console.log(`Nudges: ${result.nudgeResults.length}`);
for (const nudge of result.nudgeResults) {
console.log(` ${nudge.session}: ${nudge.ok ? "sent" : "failed"} ${nudge.message}`);
}
}
}
function defaultSend(session: string, text: string): Promise<{ ok: boolean; message: string }> {
return new Promise((resolve) => {
execFile("rig", ["send", session, text], { timeout: 10_000 }, (error, stdout, stderr) => {
if (error) {
resolve({ ok: false, message: (stderr || error.message).trim() });
} else {
resolve({ ok: true, message: stdout.trim() || "sent" });
}
});
});
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+9 -1
View File
@@ -10,6 +10,8 @@ export interface ImportDeps extends StatusDeps {
readFile: (path: string) => string;
}
const LONG_RUNNING_IMPORT_TIMEOUT_MS = 120_000;
export function importCommand(depsOverride?: ImportDeps): Command {
const cmd = new Command("import").description("Import a rig spec from YAML");
const getDeps = (): ImportDeps => depsOverride ?? {
@@ -120,7 +122,13 @@ export function importCommand(depsOverride?: ImportDeps): Command {
}
if (opts.instantiate) {
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import", yaml, "text/yaml", extraHeaders);
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>(
"/api/rigs/import",
yaml,
"text/yaml",
extraHeaders,
{ timeoutMs: LONG_RUNNING_IMPORT_TIMEOUT_MS },
);
if (res.status === 409 || res.status === 400) {
const data = res.data as { ok: false; code: string; errors?: string[]; message?: string };
const detail = data.errors?.join("\n ") ?? data.message ?? `status ${res.status}`;
+1 -1
View File
@@ -22,7 +22,7 @@ export function preflightCommand(depsOverride?: PreflightCommandDeps): Command {
const exec = depsOverride?.exec ?? (async (c: string) => {
const { execSync } = await import("node:child_process");
return execSync(c, { encoding: "utf-8" });
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
});
const preflight = new SystemPreflight({
+199
View File
@@ -0,0 +1,199 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig project` — coordination primitive L2 (classifier) commands (PL-004 Phase B).
*
* Backed by `/api/projects`. Operates only via the daemon HTTP API.
*
* Per PRD § L2: classifier judgment stays with the agent; daemon enforces
* the lease + idempotency + reclaim contract.
*/
export interface ProjectDeps extends StatusDeps {}
async function withClient<T>(
deps: ProjectDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function projectCommand(depsOverride?: ProjectDeps): Command {
const cmd = new Command("project").description(
"Coordination L2 — agent-backed classifier with daemon-enforced lease + idempotency + reclaim",
);
const getDeps = (): ProjectDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
// ---- Lease lifecycle ----
cmd
.command("lease-acquire")
.description("Acquire the active classifier lease for the caller")
.requiredOption("--session <session>", "Classifier session name")
.option(
"--evaluate-deadness-first",
"Before acquire, call evaluateDeadness to clear any stale TTL-expired or dead-holder lease (per PRD § L2 deadness-detection)",
)
.option("--json", "JSON output for agents")
.action(async (opts: { session: string; evaluateDeadnessFirst?: boolean; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/lease/acquire", {
classifierSession: opts.session,
evaluateDeadnessFirst: opts.evaluateDeadnessFirst,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("lease-heartbeat")
.description("Send a heartbeat for an active classifier lease (extends TTL)")
.requiredOption("--lease-id <id>", "Lease ID")
.requiredOption("--session <session>", "Classifier session name")
.option("--json", "JSON output for agents")
.action(async (opts: { leaseId: string; session: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/lease/heartbeat", {
leaseId: opts.leaseId,
classifierSession: opts.session,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("lease-show")
.description("Show the currently-active classifier lease")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/projects/lease");
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Operator-verb reclaim ----
cmd
.command("reclaim-classifier")
.description(
"Operator-verb: reclaim the active classifier lease. Use --if-dead to refuse if holder is still alive.",
)
.requiredOption("--session <session>", "Session that will hold the new lease")
.option("--if-dead", "Only reclaim if the current holder is reported dead by the liveness check")
.option("--reason <text>", "Reclaim reason (free-form; recorded in classifier_leases.reclaim_reason)")
.option("--json", "JSON output for agents")
.action(async (opts: { session: string; ifDead?: boolean; reason?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/reclaim-classifier", {
byClassifierSession: opts.session,
ifDead: opts.ifDead,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Project a stream item ----
cmd
.command("classify <streamItemId>")
.description(
"Project a stream item with classification fields (idempotent on stream_item_id; requires active lease)",
)
.requiredOption("--session <session>", "Classifier session name (must hold active lease)")
.option("--type <type>", "Classification type (e.g., idea, bug, feature-request)")
.option("--urgency <urgency>", "Classification urgency (e.g., normal, high, critical)")
.option("--maturity <maturity>", "Classification maturity (e.g., concept, drafted, ratified)")
.option("--confidence <confidence>", "Classification confidence (e.g., low, medium, high)")
.option("--destination <destination>", "Classification destination (downstream slice/seat)")
.option("--action <action>", "Action type (e.g., create, advance)")
.option("--json", "JSON output for agents")
.action(async (streamItemId: string, opts: {
session: string;
type?: string;
urgency?: string;
maturity?: string;
confidence?: string;
destination?: string;
action?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/projects/project", {
streamItemId,
classifierSession: opts.session,
classificationType: opts.type,
classificationUrgency: opts.urgency,
classificationMaturity: opts.maturity,
classificationConfidence: opts.confidence,
classificationDestination: opts.destination,
action: opts.action,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- List + show ----
cmd
.command("list")
.description("List project classifications with filters")
.option("--session <session>", "Filter by classifier session")
.option("--destination <destination>", "Filter by classification destination")
.option("--limit <n>", "Result limit", "100")
.option("--json", "JSON output for agents")
.action(async (opts: { session?: string; destination?: string; limit: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.session) params.set("classifierSession", opts.session);
if (opts.destination) params.set("classificationDestination", opts.destination);
if (opts.limit) params.set("limit", opts.limit);
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/projects/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <projectId>")
.description("Show one project classification")
.option("--json", "JSON output for agents")
.action(async (projectId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/projects/${encodeURIComponent(projectId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+508 -36
View File
@@ -3,13 +3,19 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
interface PsEntry {
rigId: string;
name: string;
/** L3-followup: alias of `name`. Always populated and equal to `name`. */
rigName?: string;
nodeCount: number;
runningCount: number;
status: "running" | "partial" | "stopped";
lifecycleState?: "running" | "recoverable" | "stopped" | "degraded" | "attention_required";
uptime: string | null;
latestSnapshot: string | null;
}
@@ -24,41 +30,302 @@ interface NodeEntry {
nodeKind: "agent" | "infrastructure";
runtime: string | null;
sessionStatus: string | null;
startupStatus: "pending" | "ready" | "failed" | null;
startupStatus: "pending" | "ready" | "attention_required" | "failed" | null;
restoreOutcome: string;
lifecycleState?: "running" | "detached" | "recoverable" | "attention_required";
tmuxAttachCommand: string | null;
resumeCommand: string | null;
latestError: string | null;
agentActivity?: {
state: "running" | "needs_input" | "idle" | "unknown";
reason: string;
evidenceSource: string;
sampledAt: string;
evidence: string | null;
};
[key: string]: unknown;
}
// L3-followup: human-output budgets bound default terminal output for hosts
// with realistic agent counts. `--full` opts out. JSON output remains
// unbounded by default for back-compat (Decision C hybrid).
const HUMAN_RIG_BUDGET = 50;
const HUMAN_NODE_BUDGET = 100;
// L3-followup: --filter accepts only this allow-list. Unknown keys produce a
// clear error with the supported list (Guard Review Checklist: filter-parser
// security).
const ALLOWED_FILTER_KEYS = new Set(["status", "lifecycleState", "name-prefix", "name"]);
// C9a: --fields accepts a per-level allow-list. Unknown keys produce a clear
// error with the supported list, mirroring the --filter rejection pattern.
// Per-level because rig and node entries have different schemas (PsEntry
// rig-level vs NodeEntry node-level).
//
// Source of truth: PsEntry / NodeEntry interfaces above. `name` exists at
// rig-level only; `rigName` is the alias and exists at both levels (per the
// closed `rig ps trust and scale-safety` slice at openrig 0a9fb43).
const ALLOWED_RIG_FIELDS = new Set([
"rigId",
"name",
"rigName",
"nodeCount",
"runningCount",
"status",
"lifecycleState",
"uptime",
"latestSnapshot",
]);
const ALLOWED_NODE_FIELDS = new Set([
"rigId",
"rigName",
"logicalId",
"podId",
"podNamespace",
"canonicalSessionName",
"nodeKind",
"runtime",
"sessionStatus",
"startupStatus",
"restoreOutcome",
"lifecycleState",
"tmuxAttachCommand",
"resumeCommand",
"latestError",
"agentActivity",
]);
interface PsCliOptions {
json?: boolean;
nodes?: boolean;
full?: boolean;
limit?: string;
fields?: string;
summary?: boolean;
filter?: string;
host?: string;
}
export interface PsDeps extends StatusDeps {
/**
* Cross-host hooks. Both default to the production loaders/executors; tests
* inject in-package mocks so no real ssh / no real ~/.ssh / no real network
* is touched. Mirrors the SendDeps/CaptureDeps shape from the closed
* cross-host-rig-commands slice (cdce3a6).
*/
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
interface ParsedFilter {
key: string;
value: string;
}
function parseFilter(filter: string): ParsedFilter | { error: string } {
const idx = filter.indexOf("=");
if (idx === -1) return { error: `--filter must be key=value; got: '${filter}'` };
const key = filter.slice(0, idx);
const value = filter.slice(idx + 1);
if (!ALLOWED_FILTER_KEYS.has(key)) {
return {
error: `Unknown --filter key '${key}'. Supported: ${[...ALLOWED_FILTER_KEYS].sort().join(", ")}`,
};
}
if (!value) {
return { error: `--filter value is empty for key '${key}'` };
}
return { key, value };
}
// C9a: validate --fields against a per-level allow-list. Mirrors parseFilter's
// shape: rejects with a sorted-supported-list error message including the
// unknown key(s) quoted. The level-aware "Hint" fires when an operator types
// `--fields name` against a node query (the most likely confusion case
// preserved by the rigName/name aliasing at rig-level only).
function parseFields(input: string, allowed: Set<string>, level: "rig" | "nodes"): string[] | { error: string } {
const fields = input.split(",").map((f) => f.trim()).filter((f) => f.length > 0);
if (fields.length === 0) {
return { error: `--fields cannot be empty` };
}
const unknown = fields.filter((f) => !allowed.has(f));
if (unknown.length > 0) {
const sorted = [...allowed].sort().join(", ");
const hint = level === "nodes" && unknown.includes("name")
? ` Hint: 'name' is a rig-level field; use 'rigName' for node entries.`
: "";
const keyWord = unknown.length > 1 ? "keys" : "key";
return {
error: `Unknown --fields ${keyWord} ${unknown.map((u) => `'${u}'`).join(", ")}. Supported: ${sorted}.${hint}`,
};
}
return fields;
}
function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
return entries.filter((e) => {
if (filter.key === "status") return e.status === filter.value;
if (filter.key === "lifecycleState") return e.lifecycleState === filter.value;
if (filter.key === "name-prefix") return (e.rigName ?? e.name).startsWith(filter.value);
if (filter.key === "name") return (e.rigName ?? e.name) === filter.value;
return true;
});
}
function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[] {
return entries.filter((n) => {
// status maps to sessionStatus for nodes; lifecycleState applies directly.
if (filter.key === "status") return n.sessionStatus === filter.value;
if (filter.key === "lifecycleState") return n.lifecycleState === filter.value;
if (filter.key === "name-prefix") return n.rigName.startsWith(filter.value);
if (filter.key === "name") return n.rigName === filter.value;
return true;
});
}
function selectFields<T extends Record<string, unknown>>(entries: T[], fields: string[]): Array<Record<string, unknown>> {
return entries.map((e) => {
const out: Record<string, unknown> = {};
for (const f of fields) out[f] = e[f];
return out;
});
}
function summarizeRigs(entries: PsEntry[]): {
totalRigs: number;
totalRunning: number;
byLifecycle: Record<string, number>;
byStatus: Record<string, number>;
} {
const byLifecycle: Record<string, number> = {};
const byStatus: Record<string, number> = {};
let totalRunning = 0;
for (const e of entries) {
const ls = e.lifecycleState ?? "unknown";
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
if (e.status === "running") totalRunning++;
}
return { totalRigs: entries.length, totalRunning, byLifecycle, byStatus };
}
function summarizeNodes(entries: NodeEntry[]): {
totalNodes: number;
byLifecycle: Record<string, number>;
bySessionStatus: Record<string, number>;
} {
const byLifecycle: Record<string, number> = {};
const bySessionStatus: Record<string, number> = {};
for (const n of entries) {
const ls = n.lifecycleState ?? "unknown";
byLifecycle[ls] = (byLifecycle[ls] ?? 0) + 1;
const ss = n.sessionStatus ?? "unknown";
bySessionStatus[ss] = (bySessionStatus[ss] ?? 0) + 1;
}
return { totalNodes: entries.length, byLifecycle, bySessionStatus };
}
// Compact rig-level lifecycle codes for the rig table header (3-char fixed width).
function abbrevRigLifecycle(state: PsEntry["lifecycleState"] | undefined): string {
if (!state) return "—";
if (state === "running") return "run";
if (state === "recoverable") return "rec";
if (state === "stopped") return "stp";
if (state === "degraded") return "deg";
if (state === "attention_required") return "att";
return "—";
}
// Compact per-node lifecycle codes for the nodes table.
function abbrevNodeLifecycle(state: NodeEntry["lifecycleState"] | undefined): string {
if (!state) return "—";
if (state === "running") return "run";
if (state === "recoverable") return "rec";
if (state === "detached") return "det";
if (state === "attention_required") return "att";
return "—";
}
/**
* `rig ps` — list running rigs and optionally their nodes.
* @param depsOverride - injectable deps for testing
* @returns Commander command
*
* L3-followup adds CLI-side shaping flags (`--limit`, `--fields`, `--summary`,
* `--filter`, `--full`) for context-window-safe output on large hosts. Default
* `rig ps` (human) truncates to ~50 rigs with an explicit footer naming totals
* and the `--full` opt-out. Default `rig ps --json` keeps the bare-array shape
* for back-compat; the truncation/envelope shape only applies when at least
* one of `--limit`/`--summary`/`--fields`/`--filter` is specified.
*/
export function psCommand(depsOverride?: StatusDeps): Command {
export function psCommand(depsOverride?: PsDeps): Command {
const cmd = new Command("ps")
.description("List rigs and their status")
.addHelpText("after", `
Examples:
rig ps Show all rigs with status
rig ps --json JSON output for piping/parsing
rig ps --nodes Show per-node detail for all rigs
rig ps --nodes --json Node inventory as JSON array
rig ps Show all rigs (human; truncated to ${HUMAN_RIG_BUDGET} with footer)
rig ps --full Disable human truncation
rig ps --json JSON output (bare array; back-compat)
rig ps --json --limit 20 Bounded JSON envelope
rig ps --json --summary Aggregate-only JSON (no per-rig entries)
rig ps --json --fields rigName,status,lifecycleState
Project JSON to named fields
rig ps --filter lifecycleState=attention_required
Show only rigs needing attention
rig ps --filter status=running Show only running rigs
rig ps --filter name-prefix=demo Filter by rig-name prefix
rig ps --nodes Per-node detail (human; truncated to ${HUMAN_NODE_BUDGET})
rig ps --nodes --json --limit 50 Bounded per-node JSON envelope
rig ps --host vm-claude-test --nodes --json Run on a remote host via single-hop ssh
JSON output entries include both \`name\` and \`rigName\` (alias) for forward
compatibility; agent code should prefer \`rigName\` (matches per-node JSON).
--filter accepts: status, lifecycleState, name-prefix, name. Other keys are rejected.
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
status, lifecycleState, uptime, latestSnapshot.
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
podNamespace, canonicalSessionName, nodeKind, runtime, sessionStatus,
startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
resumeCommand, latestError, agentActivity. Other keys are rejected.
\`name\` is rig-level only; for node entries use \`rigName\`. Nested-field
projection (e.g. \`agentActivity.state\`) is not supported in this slice; pass
\`agentActivity\` to project the whole object and read the nested value
downstream.
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh (CLI-side shell-out; daemon untouched). The remote rig's
output is what counts and is surfaced verbatim on success; failure is
distinguished into ssh-unreachable / permission-gate / remote-daemon-unreachable
/ remote-command-failed.
Exit codes:
0 Success
1 Daemon not running
1 Daemon not running, or invalid --filter / --limit / --fields
2 Failed to fetch data from daemon`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
const getDepsF = (): PsDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--nodes", "Show per-node detail for all rigs")
.action(async (opts: { json?: boolean; nodes?: boolean }) => {
.option("--full", "Disable default human-output truncation")
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
.option("--summary", "Emit aggregate-only output (counts by status/lifecycle)")
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.action(async (opts: PsCliOptions) => {
const deps = getDepsF();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostPs(opts.host, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
@@ -66,10 +333,47 @@ Exit codes:
return;
}
// Parse filter once up front so unknown keys/malformed filters surface
// as exit-1 errors before any HTTP call.
let parsedFilter: ParsedFilter | null = null;
if (opts.filter) {
const result = parseFilter(opts.filter);
if ("error" in result) {
console.error(result.error);
process.exitCode = 1;
return;
}
parsedFilter = result;
}
const limit = opts.limit !== undefined ? Number(opts.limit) : null;
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) {
console.error(`--limit must be a non-negative integer; got '${opts.limit}'`);
process.exitCode = 1;
return;
}
// C9a: validate --fields against the per-level allow-list before any HTTP
// call, mirroring the --filter validation above. Level is determined by
// --nodes; both branches downstream use the same `fields` variable, so
// one up-front validation covers both paths.
let fields: string[] | null = null;
if (opts.fields !== undefined) {
const fieldsLevel: "rig" | "nodes" = opts.nodes ? "nodes" : "rig";
const fieldsAllowed = fieldsLevel === "nodes" ? ALLOWED_NODE_FIELDS : ALLOWED_RIG_FIELDS;
const fieldsResult = parseFields(opts.fields, fieldsAllowed, fieldsLevel);
if ("error" in fieldsResult) {
console.error(fieldsResult.error);
process.exitCode = 1;
return;
}
fields = fieldsResult;
}
const useEnvelope = parsedFilter !== null || limit !== null || fields !== null || opts.summary === true;
const client = deps.clientFactory(getDaemonUrl(status));
if (opts.nodes) {
await handleNodes(client, opts.json ?? false);
await handleNodes(client, opts, parsedFilter, limit, fields, useEnvelope);
return;
}
@@ -81,38 +385,95 @@ Exit codes:
return;
}
const entries = res.data;
const all = res.data;
// Apply CLI-side filter (Amendment A: prefer CLI shaping).
const filtered = parsedFilter ? applyRigFilter(all, parsedFilter) : all;
// Summary mode short-circuits per-entry output.
if (opts.summary) {
const summary = summarizeRigs(filtered);
if (opts.json) {
console.log(JSON.stringify(summary));
} else {
console.log(`totalRigs: ${summary.totalRigs}`);
console.log(`totalRunning: ${summary.totalRunning}`);
console.log(`byStatus: ${JSON.stringify(summary.byStatus)}`);
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
}
return;
}
// Apply --limit on top of filter (CLI side; daemon stays bare-array).
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
const truncated = limit !== null && filtered.length > limit;
// Field projection runs last so requested fields apply to the limited set.
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
if (opts.json) {
console.log(JSON.stringify(entries));
if (useEnvelope) {
// Envelope only on flag use; default JSON stays a bare array for compat.
const envelope: Record<string, unknown> = {
entries: projected,
totalRigs: filtered.length,
truncated,
};
if (truncated) envelope.hint = "rig ps --full --json";
console.log(JSON.stringify(envelope));
} else {
console.log(JSON.stringify(projected));
}
return;
}
if (entries.length === 0) {
console.log("No rigs");
if (limited.length === 0) {
if (parsedFilter) {
console.log(`No rigs match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else {
console.log("No rigs");
}
return;
}
// Formatted table
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "UPTIME", "SNAPSHOT");
// Human output: apply default truncation budget unless --full.
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_RIG_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_RIG_BUDGET;
const header = padRigRow("RIG", "NODES", "RUNNING", "STATUS", "LIFECYCLE", "UPTIME", "SNAPSHOT");
console.log(header);
for (const e of entries) {
for (const e of humanList as PsEntry[]) {
console.log(padRigRow(
e.name,
e.rigName ?? e.name,
String(e.nodeCount),
String(e.runningCount),
e.status,
abbrevRigLifecycle(e.lifecycleState),
e.uptime ?? "—",
e.latestSnapshot ?? "—",
));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_RIG_BUDGET;
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_RIG_BUDGET}).`);
console.log("Run 'rig ps --full' to see all, or 'rig ps --filter lifecycleState=attention_required' to narrow.");
} else if (truncated) {
const remaining = filtered.length - (limit ?? 0);
console.log(`... and ${remaining} more rig${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
}
});
return cmd;
}
async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
// Fetch rig list first
async function handleNodes(
client: DaemonClient,
opts: PsCliOptions,
parsedFilter: ParsedFilter | null,
limit: number | null,
fields: string[] | null,
useEnvelope: boolean,
): Promise<void> {
const rigRes = await client.get<PsEntry[]>("/api/ps");
if (rigRes.status >= 400) {
console.error(`Failed to fetch rig list from daemon (HTTP ${rigRes.status}). Check daemon status with: rig status`);
@@ -124,25 +485,60 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
for (const rig of rigRes.data) {
const nodesRes = await client.get<NodeEntry[]>(`/api/rigs/${encodeURIComponent(rig.rigId)}/nodes`);
if (nodesRes.status >= 400) {
console.error(`Warning: failed to fetch nodes for rig "${rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
console.error(`Warning: failed to fetch nodes for rig "${rig.rigName ?? rig.name}" (HTTP ${nodesRes.status}). List rigs with: rig ps`);
continue;
}
allNodes.push(...nodesRes.data);
}
if (json) {
console.log(JSON.stringify(allNodes));
const filtered = parsedFilter ? applyNodeFilter(allNodes, parsedFilter) : allNodes;
if (opts.summary) {
const summary = summarizeNodes(filtered);
if (opts.json) {
console.log(JSON.stringify(summary));
} else {
console.log(`totalNodes: ${summary.totalNodes}`);
console.log(`bySessionStatus: ${JSON.stringify(summary.bySessionStatus)}`);
console.log(`byLifecycle: ${JSON.stringify(summary.byLifecycle)}`);
}
return;
}
if (allNodes.length === 0) {
console.log("No nodes");
const limited = limit !== null ? filtered.slice(0, limit) : filtered;
const limitTruncated = limit !== null && filtered.length > limit;
const projected = fields ? selectFields(limited as unknown as Array<Record<string, unknown>>, fields) : limited;
if (opts.json) {
if (useEnvelope) {
const envelope: Record<string, unknown> = {
entries: projected,
totalNodes: filtered.length,
truncated: limitTruncated,
};
if (limitTruncated) envelope.hint = "rig ps --nodes --full --json";
console.log(JSON.stringify(envelope));
} else {
console.log(JSON.stringify(projected));
}
return;
}
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "RESTORE", "ERROR");
if (limited.length === 0) {
if (parsedFilter) {
console.log(`No nodes match --filter ${parsedFilter.key}=${parsedFilter.value}`);
} else {
console.log("No nodes");
}
return;
}
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "RESTORE", "ERROR");
console.log(header);
for (const n of allNodes) {
for (const n of humanList as NodeEntry[]) {
const parts = n.logicalId.split(".");
const pod = n.podNamespace ?? (parts.length > 1 ? parts[0]! : "—");
const member = parts.length > 1 ? parts.slice(1).join(".") : n.logicalId;
@@ -155,10 +551,28 @@ async function handleNodes(client: DaemonClient, json: boolean): Promise<void> {
n.runtime ?? "—",
n.sessionStatus ?? "—",
n.startupStatus ?? "—",
abbrevNodeLifecycle(n.lifecycleState),
formatActivity(n.agentActivity),
n.restoreOutcome,
n.latestError ? truncate(n.latestError, 30) : "—",
));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_NODE_BUDGET;
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (truncated at ${HUMAN_NODE_BUDGET}).`);
console.log("Run 'rig ps --nodes --full' to see all, or '--filter lifecycleState=attention_required' to narrow.");
} else if (limitTruncated) {
const remaining = filtered.length - (limit ?? 0);
console.log(`... and ${remaining} more node${remaining === 1 ? "" : "s"} (--limit ${limit}).`);
}
}
function formatActivity(activity: NodeEntry["agentActivity"]): string {
if (!activity) return "unknown";
if (activity.state === "running") return "running";
if (activity.state === "needs_input") return "needs_input";
if (activity.state === "idle") return "idle";
return "unknown";
}
function truncate(s: string, max: number): string {
@@ -169,27 +583,85 @@ function fitCell(value: string, width: number): string {
return truncate(value, width).padEnd(width);
}
function padRigRow(rig: string, nodes: string, running: string, status: string, uptime: string, snapshot: string): string {
function padRigRow(rig: string, nodes: string, running: string, status: string, lifecycle: string, uptime: string, snapshot: string): string {
return [
fitCell(rig, 24),
fitCell(nodes, 7),
fitCell(running, 9),
fitCell(status, 10),
fitCell(lifecycle, 11),
fitCell(uptime, 11),
snapshot,
].join("");
}
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, restore: string, error: string): string {
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, restore: string, error: string): string {
return [
fitCell(rig, 32),
fitCell(pod, 12),
fitCell(member, 16),
fitCell(session, 40),
fitCell(runtime, 14),
fitCell(rig, 30),
fitCell(pod, 10),
fitCell(member, 14),
fitCell(session, 34),
fitCell(runtime, 12),
fitCell(status, 10),
fitCell(startup, 10),
fitCell(lifecycle, 11),
fitCell(activity, 12),
fitCell(restore, 10),
error,
].join("");
}
async function runCrossHostPs(
hostId: string,
opts: PsCliOptions,
deps: PsDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv. `rig ps` has no positional args; all flags propagate.
const argv: string[] = ["rig", "ps"];
if (opts.nodes) argv.push("--nodes");
if (opts.full) argv.push("--full");
if (opts.limit !== undefined) argv.push("--limit", opts.limit);
if (opts.fields !== undefined) argv.push("--fields", opts.fields);
if (opts.summary) argv.push("--summary");
if (opts.filter !== undefined) argv.push("--filter", opts.filter);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
if (result.ok) {
// Verbatim remote stdout passthrough — the remote `rig ps --json` already
// produced the correct JSON shape (bare array OR envelope per its own
// shaping flags); we do NOT double-wrap.
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, true);
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, false);
}
+493
View File
@@ -0,0 +1,493 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
*
* Backed by `/api/queue`. Operates only via the daemon HTTP API.
* Does NOT touch the POC `rigx-queue-proto` filesystem state.
*
* Hot-potato strict-rejection is enforced at the daemon; `update --state done`
* without `--closure-reason` returns exit 1 with structured error naming the
* 6 valid closure reasons.
*/
export interface QueueDeps extends StatusDeps {}
async function withClient<T>(
deps: QueueDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (session) return session;
console.error(`--${optionName} is required when OPENRIG_SESSION_NAME is not set`);
process.exitCode = 1;
return undefined;
}
export function queueCommand(depsOverride?: QueueDeps): Command {
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
const getDeps = (): QueueDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("create")
.description("Create a new qitem")
.requiredOption("--source <session>", "Source session")
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
.requiredOption("--body <text>", "Qitem body")
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
.option("--json", "JSON output for agents")
.action(async (opts: {
source: string;
destination: string;
body: string;
priority: string;
tier?: string;
tags?: string;
expiresAt?: string;
id?: string;
nudge?: boolean;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
qitemId: opts.id,
sourceSession: opts.source,
destinationSession: opts.destination,
body: opts.body,
priority: opts.priority,
tier: opts.tier,
tags,
expiresAt: opts.expiresAt,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("claim <qitemId>")
.description("Claim a qitem (pending → in-progress); computes closure_required_at from tier")
.option("--destination <session>", "Destination session claiming the qitem (defaults to OPENRIG_SESSION_NAME)")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination?: string; json?: boolean }) => {
const destination = resolveCurrentSession(opts.destination, "destination");
if (!destination) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {
destinationSession: destination,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("unclaim <qitemId>")
.description("Release a claimed qitem (in-progress → pending)")
.option("--destination <session>", "Destination session releasing the qitem (defaults to OPENRIG_SESSION_NAME)")
.option("--reason <text>", "Reason for unclaim", "manual")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination?: string; reason: string; json?: boolean }) => {
const destination = resolveCurrentSession(opts.destination, "destination");
if (!destination) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
destinationSession: destination,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("update <qitemId>")
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation)")
.option("--actor <session>", "Actor session performing the transition (defaults to OPENRIG_SESSION_NAME)")
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
.option("--closure-reason <reason>", "Required for state=done")
.option("--closure-target <target>", "Required for handed_off_to, blocked_on, escalation")
.option("--note <text>", "Transition note for the audit log")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
actor?: string;
state: string;
closureReason?: string;
closureTarget?: string;
note?: string;
json?: boolean;
}) => {
const actor = resolveCurrentSession(opts.actor, "actor");
if (!actor) return;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
actorSession: actor,
state: opts.state,
closureReason: opts.closureReason,
closureTarget: opts.closureTarget,
transitionNote: opts.note,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("handoff <qitemId>")
.description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to")
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
.option("--body <text>", "New qitem body (defaults to source body)")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
from?: string;
to: string;
body?: string;
note?: string;
priority?: string;
tier?: string;
tags?: string;
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff`, {
fromSession: from,
toSession: opts.to,
body: opts.body,
transitionNote: opts.note,
priority: opts.priority,
tier: opts.tier,
tags,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("handoff-and-complete <qitemId>")
.description(
"Atomic close (state=done, closure_reason=handed_off_to) + create new qitem owned by --to. Variant of handoff that fully terminates the source qitem."
)
.option("--from <session>", "Source seat handing off (defaults to OPENRIG_SESSION_NAME)")
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
.option("--body <text>", "New qitem body (defaults to source body)")
.option("--note <text>", "Transition note")
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
from?: string;
to: string;
body?: string;
note?: string;
priority?: string;
tier?: string;
tags?: string;
nudge?: boolean;
json?: boolean;
}) => {
const from = resolveCurrentSession(opts.from, "from");
if (!from) return;
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff-and-complete`, {
fromSession: from,
toSession: opts.to,
body: opts.body,
transitionNote: opts.note,
priority: opts.priority,
tier: opts.tier,
tags,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("whoami")
.description("Show the caller's queue position from the daemon's perspective")
.option("--session <session>", "Caller's session name (defaults to OPENRIG_SESSION_NAME)")
.option("--recent-limit <n>", "How many recent active qitems to include", "25")
.option("--json", "JSON output for agents")
.action(async (opts: { session?: string; recentLimit: string; json?: boolean }) => {
const session = resolveCurrentSession(opts.session, "session");
if (!session) return;
const deps = getDeps();
const params = new URLSearchParams({
session,
recentLimit: opts.recentLimit,
});
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/whoami?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("fallback <qitemId>")
.description("Reroute a qitem to a fallback destination (e.g. unreachable seat)")
.requiredOption("--destination <session>", "Fallback destination seat")
.option("--reason <text>", "Reason for fallback", "manual")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { destination: string; reason: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/fallback`, {
fallbackDestination: opts.destination,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <qitemId>")
.description("Show one qitem")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("transitions <qitemId>")
.description("Show the append-only transition log for a qitem")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List qitems with filters")
.option("--destination <session>", "Filter by destination session")
.option("--source <session>", "Filter by source session")
.option("--state <state>", "Filter by state (comma-separated for multiple)")
.option("--limit <n>", "Result limit", "100")
.option("--json", "JSON output for agents")
.action(async (opts: {
destination?: string;
source?: string;
state?: string;
limit: string;
json?: boolean;
}) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
if (opts.state) params.set("state", opts.state);
if (opts.limit) params.set("limit", opts.limit);
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("overdue")
.description("List in-progress qitems past their closure_required_at deadline")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/queue/overdue");
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Inbox subcommands ----
cmd
.command("inbox-drop <destinationSession>")
.description("Drop a mailbox-style entry into a destination's inbox")
.requiredOption("--sender <session>", "Sender session")
.requiredOption("--body <text>", "Inbox body")
.option("--tags <tags>", "Comma-separated tags")
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
.option("--audit <pointer>", "Audit pointer reference")
.option("--id <inboxId>", "Idempotent inbox_id")
.option("--json", "JSON output for agents")
.action(async (destinationSession: string, opts: {
sender: string;
body: string;
tags?: string;
urgency: string;
audit?: string;
id?: string;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/queue/inbox/drop", {
inboxId: opts.id,
destinationSession,
senderSession: opts.sender,
body: opts.body,
tags,
urgency: opts.urgency,
auditPointer: opts.audit,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("inbox-absorb <inboxId>")
.description("Absorb a pending inbox entry into the receiver's main queue")
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
.option("--json", "JSON output for agents")
.action(async (inboxId: string, opts: { receiver: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/absorb`, {
receiverSession: opts.receiver,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("inbox-deny <inboxId>")
.description("Deny a pending inbox entry with a recorded reason")
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
.requiredOption("--reason <text>", "Reason for denial")
.option("--json", "JSON output for agents")
.action(async (inboxId: string, opts: { receiver: string; reason: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/deny`, {
receiverSession: opts.receiver,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("inbox-pending <destinationSession>")
.description("List pending inbox entries for a destination seat")
.option("--json", "JSON output for agents")
.action(async (destinationSession: string, opts: { json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams({ destinationSession });
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/inbox/pending?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
// ---- Outbox subcommands ----
cmd
.command("outbox-record")
.description("Record an outbound dispatch in the sender's outbox")
.requiredOption("--sender <session>", "Sender session")
.requiredOption("--destination <session>", "Destination session")
.requiredOption("--body <text>", "Body")
.option("--tags <tags>", "Comma-separated tags")
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
.option("--audit <pointer>", "Audit pointer reference")
.option("--id <outboxId>", "Idempotent outbox_id")
.option("--json", "JSON output for agents")
.action(async (opts: {
sender: string;
destination: string;
body: string;
tags?: string;
urgency: string;
audit?: string;
id?: string;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/queue/outbox/record", {
outboxId: opts.id,
senderSession: opts.sender,
destinationSession: opts.destination,
body: opts.body,
tags,
urgency: opts.urgency,
auditPointer: opts.audit,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("outbox-list <senderSession>")
.description("List outbox entries for a sender seat")
.option("--limit <n>", "Result limit", "100")
.option("--json", "JSON output for agents")
.action(async (senderSession: string, opts: { limit: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams({ senderSession, limit: opts.limit });
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/outbox/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+340
View File
@@ -0,0 +1,340 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
export interface RestoreCheckDeps {
lifecycleDeps: LifecycleDeps;
clientFactory: (url: string) => DaemonClient;
}
interface CheckEntry {
check: string;
status: "green" | "yellow" | "red";
evidence: string;
remediation: string;
}
interface RepairStep {
step: number;
command: string;
rationale: string;
safe: boolean;
blocking: boolean;
}
interface ReadinessAssertion {
status: "ready" | "ready_with_caveats" | "not_ready" | "unknown";
reason: string;
blockingRigCount: number;
caveatRigCount: number;
unknownRigCount: number;
}
interface ContinuityAssertion {
status: "proven" | "not_proven" | "partial" | "not_applicable";
evidence: string;
provenCapabilities: string[];
unprovenCapabilities: string[];
}
interface RigRestoreRollup {
rigId: string;
rigName: string;
status: "ready" | "ready_with_caveats" | "not_ready" | "unknown";
verdict: "restorable" | "restorable_with_caveats" | "not_restorable" | "unknown";
expectedNodes: number;
runningReadyNodes: number;
blockedNodes: number;
caveatNodes: number;
blockingChecks: CheckEntry[];
caveatChecks: CheckEntry[];
}
interface HostInfraAssertion {
status: "not_inspected" | "not_declared" | "declared" | "unknown";
evidence: string;
}
interface RecoveryAction {
scope: "rig";
rigId: string;
rigName: string;
action: "restore_from_latest_snapshot";
command: string;
reason: string;
safe: boolean;
blocking: boolean;
}
interface RecoveryIssue {
scope: "host" | "rig";
rigId?: string;
rigName?: string;
reason: string;
}
interface RecoveryPlan {
status: "not_needed" | "actionable" | "blocked" | "unknown";
summary: string;
actions: RecoveryAction[];
blocked: RecoveryIssue[];
unknown: RecoveryIssue[];
}
interface RestoreCheckResult {
verdict: "restorable" | "restorable_with_caveats" | "not_restorable" | "unknown";
readiness: ReadinessAssertion;
continuity: ContinuityAssertion;
rigs: RigRestoreRollup[];
hostInfra: HostInfraAssertion;
recovery: RecoveryPlan;
counts: { red: number; yellow: number; green: number };
checks: CheckEntry[];
repairPacket: RepairStep[] | null;
}
const STATUS_SYMBOLS: Record<string, string> = {
green: "✓",
yellow: "⚠",
red: "✗",
};
const STATUS_COLORS: Record<string, string> = {
green: "",
yellow: "",
red: "",
};
export function restoreCheckCommand(depsOverride?: RestoreCheckDeps): Command {
const cmd = new Command("restore-check")
.description("Check restore readiness across running rigs")
.addHelpText("after", `
Examples:
rig restore-check Check all rigs
rig restore-check --rig openrig-pm Check one rig
rig restore-check --no-queue Skip queue file checks
rig restore-check --no-hooks Skip hook checks
rig restore-check --json JSON output for agents
Exit codes:
0 Restorable (or restorable with caveats)
1 Not restorable (red blockers found)
2 Unknown / probe error`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.option("--json", "JSON output for agents")
.option("--rig <name>", "Check one rig only")
.option("--no-queue", "Skip queue file checks")
.option("--no-hooks", "Skip hook checks")
.action(async (opts: { json?: boolean; rig?: string; queue?: boolean; hooks?: boolean }) => {
const deps = getDepsF();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
// Daemon down — produce a structured not_restorable result
const result = localRestoreResult({
verdict: "not_restorable",
checks: [{
check: "daemon.reachable",
status: "red",
evidence: "Daemon is not running",
remediation: "Start the daemon with: rig daemon start",
}],
repairPacket: [{
step: 1,
command: "Start the daemon with: rig daemon start",
rationale: "Daemon is not running",
safe: false, // mutating: starts a daemon process
blocking: true,
}],
recovery: {
status: "blocked",
summary: "No exact rig recovery action is known until the daemon is running.",
actions: [],
blocked: [{
scope: "host",
reason: "Daemon is not running; rig state could not be inspected for recovery planning.",
}],
unknown: [],
},
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(status));
try {
const params = new URLSearchParams();
if (opts.rig) params.set("rig", opts.rig);
if (opts.queue === false) params.set("noQueue", "true");
if (opts.hooks === false) params.set("noHooks", "true");
const query = params.toString();
const path = `/api/restore-check${query ? `?${query}` : ""}`;
const response = await client.get<RestoreCheckResult>(path);
if (response.status >= 500) {
const result = localRestoreResult({
verdict: "unknown",
checks: [{
check: "probe.error",
status: "red",
evidence: `Daemon returned HTTP ${response.status}`,
remediation: "Check daemon logs with: rig daemon logs",
}],
repairPacket: [{
step: 1,
command: "Check daemon logs with: rig daemon logs",
rationale: `Daemon returned HTTP ${response.status}`,
safe: true,
blocking: true,
}],
recovery: {
status: "unknown",
summary: `Recovery status could not be inspected because daemon returned HTTP ${response.status}.`,
actions: [],
blocked: [],
unknown: [{
scope: "host",
reason: `Daemon returned HTTP ${response.status}`,
}],
},
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
process.exitCode = 2;
return;
}
const result = response.data;
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
printHuman(result);
}
// Exit codes: 0 for restorable/restorable_with_caveats; 1 for not_restorable; 2 for unknown
if (result.verdict === "not_restorable") {
process.exitCode = 1;
} else if (result.verdict === "unknown") {
process.exitCode = 2;
}
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
console.error("Fix: check daemon status with: rig daemon status");
process.exitCode = 2;
}
});
return cmd;
}
function printHuman(result: RestoreCheckResult): void {
const verdictLabel = result.verdict.replace(/_/g, " ").toUpperCase();
console.log(`RESTORE CHECK — ${verdictLabel}`);
console.log(`${result.counts.green} green | ${result.counts.yellow} yellow | ${result.counts.red} red`);
const readinessLabel = result.readiness.status.replace(/_/g, " ");
console.log(`READINESS: ${readinessLabel} (${result.readiness.reason})`);
console.log(`CONTINUITY: ${result.continuity.status.replace(/_/g, " ")}`);
if (result.hostInfra) {
console.log(`Host bootstrap/autostart: ${result.hostInfra.status} — ${result.hostInfra.evidence}`);
}
console.log(`RECOVERY: ${result.recovery.status.replace(/_/g, " ").toUpperCase()}`);
console.log(result.recovery.summary);
for (const action of result.recovery.actions) {
console.log(` Action: ${action.command}`);
}
for (const issue of result.recovery.blocked) {
console.log(` Blocked: ${issue.reason}`);
}
for (const issue of result.recovery.unknown) {
console.log(` Unknown: ${issue.reason}`);
}
if (result.rigs && result.rigs.length > 0) {
console.log();
console.log("Per-rig summary:");
console.log(`${"rig".padEnd(24)} ${"status".padEnd(16)} ${"ready".padEnd(9)} ${"blocked".padEnd(8)} caveats`);
for (const rig of result.rigs) {
console.log(
`${rig.rigName.slice(0, 24).padEnd(24)} ${rig.status.padEnd(16)} ` +
`${`${rig.runningReadyNodes}/${rig.expectedNodes}`.padEnd(9)} ${String(rig.blockedNodes).padEnd(8)} ${rig.caveatNodes}`
);
}
}
console.log();
for (const check of result.checks) {
const sym = STATUS_SYMBOLS[check.status] ?? "?";
console.log(` ${sym} ${check.check}: ${check.evidence}`);
if (check.remediation) {
console.log(` Fix: ${check.remediation}`);
}
}
if (result.repairPacket && result.repairPacket.length > 0) {
const blockers = result.repairPacket.filter((s) => s.blocking).length;
const caveats = result.repairPacket.filter((s) => !s.blocking).length;
console.log();
console.log(`Repair steps: ${result.repairPacket.length} (${blockers} blocking, ${caveats} caveats)`);
if (blockers > 0) {
console.log("Resolve blocking steps before restoring.");
}
} else if (result.counts.red > 0) {
console.log();
console.log("Blockers found. Resolve red items before restoring.");
} else if (result.counts.yellow > 0) {
console.log();
console.log("Restorable with caveats. Yellow items are non-blocking but worth reviewing.");
}
}
function localRestoreResult(input: {
verdict: RestoreCheckResult["verdict"];
checks: CheckEntry[];
repairPacket: RepairStep[] | null;
recovery: RecoveryPlan;
}): RestoreCheckResult {
const red = input.checks.filter((c) => c.status === "red").length;
const yellow = input.checks.filter((c) => c.status === "yellow").length;
const green = input.checks.filter((c) => c.status === "green").length;
const unknown = input.verdict === "unknown";
return {
verdict: input.verdict,
readiness: {
status: unknown ? "unknown" : "not_ready",
reason: unknown ? "unknown_probe_state" : "blockers_present",
blockingRigCount: 0,
caveatRigCount: 0,
unknownRigCount: 0,
},
continuity: {
status: "not_proven",
evidence: "Strict same-session/provider-context resume is not verified by restore-check v1.",
provenCapabilities: [],
unprovenCapabilities: ["provider_session_resume", "context_window_preservation", "interrupted_work_functional_resume"],
},
rigs: [],
hostInfra: {
status: "unknown",
evidence: "Host bootstrap/autostart source could not be inspected because restore-check did not receive daemon route evidence",
},
counts: { red, yellow, green },
checks: input.checks,
repairPacket: input.repairPacket,
recovery: input.recovery,
};
}
+528
View File
@@ -0,0 +1,528 @@
// restore-packet.ts — `rig restore-packet {write,read,validate}` CLI command.
//
// M2a: command shape + mutual-exclusion + read/validate stubs.
// M2b: source-adapter wiring (codex-jsonl-parser, claude-transcript-parser,
// runtime-detect, redaction, omitted-records).
// M2c-CLI: write action full impl using packet-writer.ts; --source-jsonl
// + --source-session adapter wiring; CLI flag defaults for the
// ~11 contract-required fields not derivable from the parser.
// M2c-Daemon: full-read transcript route + auth + redaction + tests.
// M3: real read + validate implementations replacing the stubs.
import { Command } from "commander";
import { readFileSync, existsSync, statSync } from "node:fs";
import { resolve as resolvePath, join as joinPath } from "node:path";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import { detectRuntime } from "../restore-packet/runtime-detect.js";
import { parseCodexJsonl } from "../restore-packet/codex-jsonl-parser.js";
import { parseClaudeTranscript } from "../restore-packet/claude-transcript-parser.js";
import { writePacket, type WritePacketOptions } from "../restore-packet/packet-writer.js";
import {
validateRestoreSummary,
type ValidationError,
} from "../restore-packet/schema-validator.js";
import type { SourceRuntime, StructuredTranscript } from "../restore-packet/types.js";
// Per M1 contract § 1: 4 required packet files. Used by validate's
// packet-shape check and by read for transcript-presence detection.
const REQUIRED_PACKET_FILES = [
"restore-instructions.md",
"transcript-latest.md",
"touched-files.md",
"restore-summary.json",
] as const;
export interface RestorePacketDeps {
lifecycleDeps?: LifecycleDeps;
clientFactory?: (url: string) => DaemonClient;
}
interface WriteOptions {
sourceSession?: string;
sourceJsonl?: string;
sourceRuntime?: string;
/**
* R2: explicit operator override for source_session_id. Wins over parsed
* session_meta and over the --source-session arg. Required when JSONL
* has no session_meta record.
*/
sourceSessionIdOverride?: string;
/**
* R2: explicit operator override for source_rig. Wins over <seat>@<rig>
* derivation. Required when the canonical source_session_id is bare
* (no @) and the operator has not supplied --source-session-id-override
* with @ shape.
*/
sourceRigOverride?: string;
target: string;
targetRig?: string;
targetRuntime?: string;
targetWorkspaceRoot?: string;
defaultTargetRepo?: string;
rolePointer?: string;
currentWorkSummary?: string;
nextOwner?: string;
caveat?: string[];
authorityBoundaries?: string;
sourceTrustRanking?: string;
generatorVersion?: string;
}
/**
* R2: derive canonical (source_session_id, source_rig) per honest-fallback
* policy. Override flags > parsed session_meta (jsonl) > supplied --source-session.
*
* Returns either the derived pair (with no exception) OR throws an Error
* containing the explicit operator-facing guidance message. The action
* caller catches and routes to reportFailure().
*/
function deriveProvenance(
opts: WriteOptions,
parsedSessionId: string | null,
hasJsonl: boolean,
): { sourceSessionId: string; sourceRig: string } {
// Step 1: source_session_id — override > parsed > --source-session arg.
let sourceSessionId: string;
if (typeof opts.sourceSessionIdOverride === "string" && opts.sourceSessionIdOverride.length > 0) {
sourceSessionId = opts.sourceSessionIdOverride;
} else if (hasJsonl) {
if (!parsedSessionId) {
throw new Error(
"no session_meta record in source JSONL; supply --source-session-id-override <id> AND --source-rig-override <rig> to set provenance explicitly. Silent fallback to file-path-as-session-id is not allowed (M2c-CLI R2 honest-provenance policy).",
);
}
sourceSessionId = parsedSessionId;
} else {
// hasSession path: operator-named session is authoritative.
sourceSessionId = opts.sourceSession!;
}
// Step 2: source_rig — override > <seat>@<rig> split.
let sourceRig: string;
if (typeof opts.sourceRigOverride === "string" && opts.sourceRigOverride.length > 0) {
sourceRig = opts.sourceRigOverride;
} else {
const m = /^[^@]+@([^@]+)$/.exec(sourceSessionId);
const captured = m?.[1];
if (!captured) {
throw new Error(
`could not derive source_rig from session id '${sourceSessionId}' (does not match <seat>@<rig> shape); supply --source-rig-override <rig> explicitly. Silent fallback to source_rig:"unknown" is not allowed (M2c-CLI R2 honest-provenance policy).`,
);
}
sourceRig = captured;
}
return { sourceSessionId, sourceRig };
}
function reportFailure(message: string): void {
console.error(`rig restore-packet write: ${message}`);
process.exitCode = 2;
}
function isValidRuntime(value: string | undefined): value is SourceRuntime {
return value === "codex" || value === "claude-code";
}
async function fetchSourceTranscriptViaDaemon(
session: string,
deps: RestorePacketDeps | undefined,
): Promise<{ content: string; sourceCwd: string | null }> {
const lifecycleDeps = deps?.lifecycleDeps ?? realDeps();
const status = await getDaemonStatus(lifecycleDeps);
if (status.state !== "running" || typeof status.port !== "number") {
throw new Error("daemon not running; start it with: rig daemon start");
}
const url = getDaemonUrl(status);
const client = (deps?.clientFactory ?? ((u: string) => new DaemonClient(u)))(url);
// M2c-Daemon will define this route. M2c-CLI calls it; M2c-CLI tests
// mock the daemon (compact-plan.test.ts:166-176 pattern). The path is
// a draft — M2c-Daemon may pick a different naming convention; if so,
// M2c-Daemon will land that change in restore-packet.ts the same commit
// that adds the daemon route.
const path = `/api/transcripts/${encodeURIComponent(session)}/full`;
const response = await client.get<{ content: string; cwd?: string | null }>(path);
if (response.status >= 400 || !response.data) {
throw new Error(
`daemon transcript fetch failed: HTTP ${response.status}: ${
typeof response.data === "object" && response.data !== null
? JSON.stringify(response.data)
: "(no body)"
}`,
);
}
return {
content: response.data.content ?? "",
sourceCwd: response.data.cwd ?? null,
};
}
function buildWritePacketOptions(
opts: WriteOptions,
structured: StructuredTranscript,
sourceRuntime: SourceRuntime,
sourceCwdFallback: string,
sourceSessionId: string,
sourceRig: string,
): WritePacketOptions {
const trustRankingDefault = "rig_whoami,bounded_latest_transcript";
const trustRankingRaw = opts.sourceTrustRanking ?? trustRankingDefault;
const trustRanking = trustRankingRaw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
return {
targetDir: resolvePath(opts.target),
structured,
sourceRuntime,
targetRig: opts.targetRig ?? "openrig-velocity",
targetRuntime: isValidRuntime(opts.targetRuntime) ? opts.targetRuntime : "claude-code",
targetWorkspaceRoot: opts.targetWorkspaceRoot ?? structured.sessionMeta?.cwd ?? sourceCwdFallback,
defaultTargetRepo: opts.defaultTargetRepo ?? null,
rolePointer: opts.rolePointer ?? `rigs/${opts.targetRig ?? "openrig-velocity"}/state/velocity/role.md`,
currentWorkSummary: opts.currentWorkSummary ?? "Cross-runtime restore packet generated by rig restore-packet write.",
nextOwner: opts.nextOwner ?? "self",
caveats: opts.caveat ?? [],
authorityBoundaries: opts.authorityBoundaries ?? "Restored seat authority per source session role.",
sourceTrustRanking: trustRanking,
sourceSessionId,
sourceRig,
sourceCwd: structured.sessionMeta?.cwd ?? sourceCwdFallback,
generatorVersion: opts.generatorVersion ?? "[email protected]",
includeFullTranscript: structured.messages.length > 0,
};
}
export function restorePacketCommand(depsOverride?: RestorePacketDeps): Command {
const cmd = new Command("restore-packet")
.description(
"Generate, read, and validate cross-runtime restore packets per the v0 standard",
);
cmd.command("write")
.description(
"Generate a restore packet from a source session or JSONL file",
)
.option("--source-session <session>", "Source session name (daemon-backed)")
.option(
"--source-jsonl <path>",
"Source Codex/Claude JSONL transcript file (direct)",
)
.option(
"--source-runtime <runtime>",
"Force source runtime (claude-code | codex); auto-detected if omitted",
)
.requiredOption(
"--target <dir>",
"Target packet directory (must not exist; created atomically)",
)
.option("--target-rig <rig>", "Target rig name for the restored seat")
.option(
"--target-runtime <runtime>",
"Target runtime (claude-code | codex)",
)
.option(
"--target-workspace-root <path>",
"Target workspace root absolute path",
)
.option(
"--default-target-repo <path>",
"Default target repo absolute path or null",
)
.option(
"--role-pointer <path>",
"Role guidance pointer path or URI for the restored seat",
)
.option(
"--current-work-summary <text>",
"One-paragraph summary of the source seat's current work",
)
.option(
"--next-owner <name>",
'Who the restored seat hands off to next (default: "self")',
)
.option(
"--caveat <text>",
"Caveat to include in the packet (repeatable)",
(value: string, prev: string[] = []) => prev.concat([value]),
[] as string[],
)
.option(
"--authority-boundaries <text>",
"Explicit statement of restored-seat authority",
)
.option(
"--source-trust-ranking <csv>",
"Comma-separated source-trust ranking; default: rig_whoami,bounded_latest_transcript",
)
.option(
"--generator-version <version>",
'Generator version identifier (default: "[email protected]")',
)
.option(
"--source-session-id-override <id>",
"Operator override for source_session_id (R2; required when JSONL has no session_meta record)",
)
.option(
"--source-rig-override <rig>",
"Operator override for source_rig (R2; required when canonical session id is bare with no <seat>@<rig> shape)",
)
.action(async (opts: WriteOptions) => {
const hasSession = typeof opts.sourceSession === "string" && opts.sourceSession.length > 0;
const hasJsonl = typeof opts.sourceJsonl === "string" && opts.sourceJsonl.length > 0;
if (hasSession && hasJsonl) {
reportFailure(
"--source-session and --source-jsonl are mutually exclusive; supply exactly one.",
);
return;
}
if (!hasSession && !hasJsonl) {
reportFailure(
"exactly one of --source-session or --source-jsonl is required.",
);
return;
}
try {
let content: string;
let sourceCwdFallback: string;
if (hasJsonl) {
const jsonlPath = resolvePath(opts.sourceJsonl!);
content = readFileSync(jsonlPath, "utf-8");
sourceCwdFallback = "/";
} else {
const session = opts.sourceSession!;
const fetched = await fetchSourceTranscriptViaDaemon(session, depsOverride);
content = fetched.content;
sourceCwdFallback = fetched.sourceCwd ?? "/";
}
let runtime: SourceRuntime | null = null;
if (isValidRuntime(opts.sourceRuntime)) {
runtime = opts.sourceRuntime;
} else if (typeof opts.sourceRuntime === "string" && opts.sourceRuntime.length > 0) {
reportFailure(
`--source-runtime must be 'codex' or 'claude-code'; got '${opts.sourceRuntime}'.`,
);
return;
} else {
runtime = detectRuntime(content);
if (runtime === null) {
reportFailure(
"could not auto-detect source runtime; pass --source-runtime <claude-code|codex> explicitly.",
);
return;
}
}
const structured = runtime === "codex"
? parseCodexJsonl(content)
: parseClaudeTranscript(content);
// R2: derive (source_session_id, source_rig) honestly. Override
// flags > parsed session_meta (jsonl) > supplied --source-session
// arg. Throws an Error with explicit operator guidance when the
// honest-provenance policy can't be satisfied.
let provenance: { sourceSessionId: string; sourceRig: string };
try {
provenance = deriveProvenance(opts, structured.sessionMeta?.sessionId ?? null, hasJsonl);
} catch (err) {
reportFailure((err as Error).message);
return;
}
const writeOpts = buildWritePacketOptions(
opts,
structured,
runtime,
sourceCwdFallback,
provenance.sourceSessionId,
provenance.sourceRig,
);
const result = await writePacket(writeOpts);
// Per Quality Lesson v9: log only metadata, NOT transcript content.
console.log(`packet: ${result.targetDir}`);
console.log(`files: ${result.files.length}`);
for (const f of result.files) {
console.log(` - ${f}`);
}
console.log(`messages: ${structured.messageCount}`);
console.log(`omitted-classes: ${Object.entries(structured.omittedCounts).filter(([, v]) => v > 0).map(([k, v]) => `${k}=${v}`).join(",") || "(none)"}`);
} catch (err) {
reportFailure((err as Error).message);
}
});
cmd.command("read")
.description("Render a restore packet contents (human or --json)")
.argument("<packet-dir>", "Packet directory path")
.option("--json", "Emit machine-readable JSON output")
.action(async (packetDir: string, opts: { json?: boolean }) => {
const dir = resolvePath(packetDir);
const summaryPath = joinPath(dir, "restore-summary.json");
if (!existsSync(summaryPath)) {
console.error(
`rig restore-packet read: restore-summary.json not found at ${summaryPath}.`,
);
process.exitCode = 2;
return;
}
let summary: Record<string, unknown>;
try {
summary = JSON.parse(readFileSync(summaryPath, "utf-8")) as Record<string, unknown>;
} catch (err) {
console.error(
`rig restore-packet read: restore-summary.json is not valid JSON: ${(err as Error).message}.`,
);
process.exitCode = 2;
return;
}
if (opts.json) {
// Per IMPL § M3: --json emits raw restore-summary.json content
// verbatim; round-trippable.
console.log(JSON.stringify(summary, null, 2));
return;
}
// Human output: restore-instructions body + summary metadata + transcript digest.
const instructionsPath = joinPath(dir, "restore-instructions.md");
if (existsSync(instructionsPath)) {
console.log(readFileSync(instructionsPath, "utf-8"));
} else {
console.log("(restore-instructions.md absent in packet directory)");
}
console.log("--- Summary metadata ---");
const metadataKeys = [
"source_session_id",
"source_rig",
"source_cwd",
"source_runtime",
"target_rig",
"target_runtime",
"generator_version",
];
for (const k of metadataKeys) {
const v = summary[k];
console.log(`${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`);
}
const blt = summary.bounded_latest_transcript as { bound?: number; message_count?: number } | undefined;
if (blt) {
console.log(`bounded_latest_transcript.bound: ${blt.bound}`);
console.log(`bounded_latest_transcript.message_count: ${blt.message_count}`);
}
const omittedClasses = summary.omitted_classes;
console.log(`omitted_classes: ${Array.isArray(omittedClasses) ? omittedClasses.join(",") || "(none)" : "(unknown)"}`);
// Transcript digest line (per IMPL § M3 + contract § 1 parity rule).
const transcriptPath = joinPath(dir, "transcript.md");
const hasFullTranscriptKey = Object.prototype.hasOwnProperty.call(summary, "full_transcript");
if (existsSync(transcriptPath)) {
const sz = statSync(transcriptPath).size;
console.log(`transcript.md: ${sz} bytes`);
if (!hasFullTranscriptKey) {
console.log("(parity warning: transcript.md present but full_transcript summary key absent)");
}
} else {
if (hasFullTranscriptKey) {
console.log("(parity warning: full_transcript summary key present but transcript.md absent)");
} else {
console.log("transcript.md absent (full_transcript key absent in summary; valid per parity rule)");
}
}
});
cmd.command("validate")
.description("Validate a restore packet against the v0 schema")
.argument("<packet-dir>", "Packet directory path")
.option("--json", "Emit machine-readable validation report")
.action(async (packetDir: string, opts: { json?: boolean }) => {
const dir = resolvePath(packetDir);
const errors: ValidationError[] = [];
// Packet-shape check: 4 required files per contract § 1.
for (const required of REQUIRED_PACKET_FILES) {
const p = joinPath(dir, required);
if (!existsSync(p)) {
errors.push({
field: required,
value: "<missing>",
rule: `packet-shape: required file missing at ${p}`,
severity: "error",
});
}
}
// Schema validation if restore-summary.json exists.
let summary: Record<string, unknown> | null = null;
const summaryPath = joinPath(dir, "restore-summary.json");
if (existsSync(summaryPath)) {
try {
summary = JSON.parse(readFileSync(summaryPath, "utf-8")) as Record<string, unknown>;
} catch (err) {
errors.push({
field: "restore-summary.json",
value: "<invalid-json>",
rule: `restore-summary.json parse failed: ${(err as Error).message}`,
severity: "error",
});
}
if (summary) {
const schemaResult = validateRestoreSummary(summary);
for (const e of schemaResult.errors) errors.push(e);
}
}
// Parity check (per contract § 1 + § 8): full_transcript summary key
// ↔ transcript.md presence.
if (summary) {
const hasFullTranscriptKey = Object.prototype.hasOwnProperty.call(summary, "full_transcript");
const transcriptExists = existsSync(joinPath(dir, "transcript.md"));
if (hasFullTranscriptKey && !transcriptExists) {
errors.push({
field: "full_transcript",
value: "<key-present-file-absent>",
rule: "parity: full_transcript summary key present but transcript.md absent at packet directory",
severity: "error",
});
}
if (!hasFullTranscriptKey && transcriptExists) {
errors.push({
field: "transcript.md",
value: "<file-present-key-absent>",
rule: "parity: transcript.md present but full_transcript summary key absent",
severity: "error",
});
}
}
const hasErrorSeverity = errors.some((e) => e.severity === "error");
const valid = !hasErrorSeverity;
if (opts.json) {
console.log(JSON.stringify({ valid, errors }, null, 2));
} else {
if (errors.length === 0) {
console.log(`valid: packet at ${dir} passed all checks.`);
} else {
console.log(`${valid ? "valid (with warnings)" : "invalid"}: ${errors.length} issue(s) at ${dir}`);
for (const e of errors) {
const tag = e.severity === "warning" ? "WARNING" : "ERROR";
console.log(` [${tag}] ${e.field}: ${e.rule} (value: ${e.value})`);
}
}
}
// Per contract § 8: required-field violations / parity violations →
// exit nonzero. Optional-field warnings → exit 0.
if (hasErrorSeverity) {
process.exitCode = 2;
}
});
return cmd;
}
+125 -2
View File
@@ -4,6 +4,8 @@ import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lif
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
const LONG_RUNNING_TIMEOUT_MS = 45_000;
export function restoreCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("restore").description("Restore a rig from a snapshot");
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
@@ -28,29 +30,85 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
const rigId = opts.rig;
const res = await client.post<{ nodes?: Array<{ nodeId: string; logicalId: string; status: string; error?: string }>; attachCommand?: string }>(`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`);
// L3: install a SIGINT/SIGTERM handler that prints an honest message —
// interrupting the CLI client does NOT stop daemon-side restore work.
// Cancellation as a daemon protocol is a separate slice; ship the
// message so operators are not surprised.
const onSignal = () => {
console.error("Client interrupt received; daemon-side restore may continue. Use 'rig ps --nodes' or 'rig restore-check' to follow progress.");
process.exit(1);
};
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
const res = await client.post<{
ok?: boolean;
attemptId?: number;
status?: string;
rigId?: string;
// Pre-restore-started error path keeps the original payload shape.
rigResult?: string;
blockers?: RestoreBlocker[];
nodes?: Array<{
nodeId: string;
logicalId: string;
status: string;
error?: string;
canonicalSessionName?: string | null;
tmuxAttachCommand?: string | null;
resumeCommand?: string | null;
recoveryGuidance?: {
summary: string;
commands: string[];
notes: string[];
} | null;
cwd?: string | null;
}>;
attachCommand?: string;
}>(
`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`,
undefined,
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
);
if (res.status === 404) {
console.error(`Snapshot "${snapshotId}" or rig "${rigId}" not found. List snapshots with: rig snapshot list --rig ${rigId}`);
process.exitCode = 1;
} else if (res.status === 409) {
if ((res.data as { code?: string }).code === "pre_restore_validation_failed") {
printRestoreNotAttempted(res.data);
process.exitCode = 1;
return;
}
console.error(`Restore conflict: ${(res.data as { error?: string }).error ?? "rig may still be running"}. Stop the rig first with: rig down ${rigId}`);
process.exitCode = 1;
} else if (res.status >= 400) {
console.error(`Restore failed: ${(res.data as { error?: string }).error ?? "unknown error"} (HTTP ${res.status}). Check daemon logs or try a different snapshot.`);
process.exitCode = 1;
} else if (res.data.attemptId !== undefined) {
// L3 success path: route returned 202 immediately after restore.started.
console.log(`Restore attempt id: ${res.data.attemptId}`);
console.log(`Status: ${res.data.status ?? "started"}`);
console.log("Daemon is restoring per-node in the background; follow progress with 'rig ps --nodes' or 'rig restore-check'.");
} else {
// Defensive: server responded ok=true but didn't include attemptId.
// Fall back to the legacy summary if it's present (back-compat with
// pre-L3 daemons during rolling upgrades).
console.log("Restore complete:");
if (res.data.rigResult) {
console.log(`Rig result: ${res.data.rigResult}`);
}
const nodes = res.data.nodes ?? [];
for (const node of nodes) {
const label = node.status === "failed" && node.error ? `${node.status} — ${node.error}` : node.status;
console.log(` ${node.logicalId}: ${label}`);
}
printRecoveryGuidance(nodes);
const attachCommand = (res.data as Record<string, unknown>)["attachCommand"] as string | undefined;
if (attachCommand) {
console.log(`Attach: ${attachCommand}`);
}
if (nodes.some((node) => node.status === "failed")) {
if (res.data.rigResult === "partially_restored" || res.data.rigResult === "failed" || res.data.rigResult === "not_attempted" || nodes.some((node) => node.status === "failed")) {
process.exitCode = 1;
}
}
@@ -58,3 +116,68 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
return cmd;
}
interface RestoreBlocker {
code: string;
severity?: string;
logicalId?: string;
nodeId?: string;
target?: string;
path?: string;
message: string;
remediation: string;
}
function printRestoreNotAttempted(data: { rigResult?: string; blockers?: RestoreBlocker[]; error?: string }): void {
console.error(`Restore blocked: ${data.error ?? "pre-restore validation failed"}`);
if (data.rigResult) {
console.error(`Rig result: ${data.rigResult}`);
}
printBlockers(data.blockers ?? []);
}
function printBlockers(blockers: RestoreBlocker[]): void {
for (const blocker of blockers) {
const scope = blocker.logicalId ?? blocker.nodeId ?? blocker.target ?? blocker.code;
console.error(` ${scope}: ${blocker.message}`);
if (blocker.path) console.error(` path: ${blocker.path}`);
console.error(` remediation: ${blocker.remediation}`);
}
}
function printRecoveryGuidance(
nodes: Array<{
logicalId: string;
status: string;
canonicalSessionName?: string | null;
tmuxAttachCommand?: string | null;
recoveryGuidance?: { summary: string; commands: string[]; notes: string[] } | null;
cwd?: string | null;
}>,
): void {
const actionable = nodes.filter((node) =>
(node.status === "fresh" || node.status === "failed") && node.recoveryGuidance
);
if (actionable.length === 0) return;
console.log("\nRecovery guidance:");
for (const node of actionable) {
console.log(` ${node.logicalId}: ${node.recoveryGuidance!.summary}`);
if (node.tmuxAttachCommand) {
console.log(` attach: ${node.tmuxAttachCommand}`);
}
if (node.canonicalSessionName) {
console.log(` session: ${node.canonicalSessionName}`);
}
if (node.cwd) {
console.log(` cwd: ${node.cwd}`);
}
for (const command of node.recoveryGuidance!.commands) {
console.log(` $ ${command}`);
}
for (const note of node.recoveryGuidance!.notes) {
console.log(` note: ${note}`);
}
}
}
+288
View File
@@ -0,0 +1,288 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export type SeatDeps = StatusDeps;
interface SeatStatusResponse {
seat_ref: string;
rig_id: string;
rig_name: string;
logical_id: string;
pod_id: string | null;
pod_namespace: string | null;
runtime: string | null;
current_occupant: string | null;
session_status: string | null;
startup_status: string | null;
occupant_lifecycle: string;
continuity_outcome: string | null;
handover_result: string | null;
previous_occupant: string | null;
handover_at: string | null;
restore_outcome: string;
}
interface SeatStatusError {
ok: false;
code: string;
message?: string;
error?: string;
guidance?: string;
matches?: Array<{ rig_name: string; logical_id: string; current_occupant: string | null }>;
}
interface SeatHandoverPlan {
ok: true;
dryRun: true;
willMutate: false;
seat: {
ref: string;
rigId: string;
rigName: string;
logicalId: string;
podId: string | null;
podNamespace: string | null;
runtime: string | null;
};
source: {
mode: "fresh" | "rebuild" | "fork" | "discovered";
ref: string | null;
raw: string;
defaulted: boolean;
};
reason: string;
operator: string | null;
currentOccupant: string | null;
currentStatus: {
sessionStatus: string | null;
startupStatus: string | null;
occupantLifecycle: string;
continuityOutcome: string | null;
handoverResult: string | null;
previousOccupant: string | null;
handoverAt: string | null;
restoreOutcome: string;
};
phases: Array<{
id: "prepare" | "commit";
title: string;
bindingUnchangedUntilComplete: boolean;
steps: Array<{ id: string; title: string; description: string; willMutate: false }>;
}>;
}
interface SeatHandoverMutationResult {
ok: true;
dryRun: false;
mutated: true;
continuityTransferred: false;
seat: SeatHandoverPlan["seat"];
source: {
mode: "discovered";
ref: string;
raw: string;
defaulted: false;
};
reason: string;
operator: string | null;
previousOccupant: string;
currentOccupant: string;
previousSessionIdsSuperseded: string[];
newSessionId: string;
discovery: {
id: string;
status: "claimed";
tmuxSession: string;
tmuxPane: string | null;
};
currentStatus: SeatHandoverPlan["currentStatus"];
handoverAt: string;
eventSeq: number;
sideEffects: {
departingSessionKilled: false;
startupContextDelivered: false;
provenanceRecordWritten: false;
};
}
function display(value: string | null | undefined, empty = "none"): string {
return value ?? empty;
}
function printHuman(status: SeatStatusResponse): void {
console.log(`Seat ${status.seat_ref}`);
console.log(`Rig: ${status.rig_name}`);
console.log(`Logical ID: ${status.logical_id}`);
console.log(`Current occupant: ${display(status.current_occupant)}`);
console.log(`Session: ${display(status.session_status, "unknown")}`);
console.log(`Startup: ${display(status.startup_status, "unknown")}`);
console.log(`Occupant lifecycle: ${status.occupant_lifecycle}`);
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unknown")}`);
console.log(`Handover result: ${display(status.handover_result)}`);
console.log(`Previous occupant: ${display(status.previous_occupant)}`);
console.log(`Handover at: ${display(status.handover_at)}`);
}
function printHumanHandoverPlan(plan: SeatHandoverPlan): void {
console.log(`Seat handover dry run: ${plan.seat.ref}`);
console.log(`Rig: ${plan.seat.rigName}`);
console.log(`Logical ID: ${plan.seat.logicalId}`);
console.log(`Source: ${plan.source.mode}${plan.source.ref ? `:${plan.source.ref}` : ""}`);
console.log(`Reason: ${plan.reason}`);
console.log(`Operator: ${display(plan.operator)}`);
console.log(`Current occupant: ${display(plan.currentOccupant)}`);
console.log(`Current status: session=${display(plan.currentStatus.sessionStatus, "unknown")} startup=${display(plan.currentStatus.startupStatus, "unknown")} lifecycle=${plan.currentStatus.occupantLifecycle}`);
for (const phase of plan.phases) {
console.log(phase.title);
for (const step of phase.steps) {
console.log(` - ${step.title}`);
}
}
console.log("No changes were made.");
}
function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
console.log(`Seat handover complete: ${result.seat.ref}`);
console.log(`Rig: ${result.seat.rigName}`);
console.log(`Logical ID: ${result.seat.logicalId}`);
console.log(`Source: discovered:${result.discovery.id}`);
console.log(`Reason: ${result.reason}`);
console.log(`Operator: ${display(result.operator)}`);
console.log(`Previous occupant: ${result.previousOccupant}`);
console.log(`Current occupant: ${result.currentOccupant}`);
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
console.log("Seat binding and inventory provenance were updated.");
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
}
function printSeatError(error: SeatStatusError, fallback: string): void {
console.error(error.message ?? error.error ?? fallback);
if (error.guidance) {
console.error(error.guidance);
}
if (error.code === "seat_ambiguous" && error.matches?.length) {
for (const match of error.matches) {
console.error(` ${match.logical_id}@${match.rig_name} (${display(match.current_occupant)})`);
}
}
}
export function seatCommand(depsOverride?: SeatDeps): Command {
const cmd = new Command("seat")
.description("Inspect OpenRig seat observability state");
const getDeps = (): SeatDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("status")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--json", "JSON output for agents")
.description("Show read-only seat handover observability status")
.addHelpText("after", `
Examples:
rig seat status spec-writer@openrig-pm
rig seat status spec.writer@openrig-pm --json
rig seat status spec.writer --json`)
.action(async (seat: string, opts: { json?: boolean }) => {
const deps = getDeps();
const daemon = await getDaemonStatus(deps.lifecycleDeps);
if (daemon.state !== "running" || daemon.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(daemon));
const res = await client.get<SeatStatusResponse | SeatStatusError>(`/api/seat/status/${encodeURIComponent(seat)}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
if (res.status >= 400) {
const error = res.data as SeatStatusError;
printSeatError(error, `Seat status failed (HTTP ${res.status})`);
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
printHuman(res.data as SeatStatusResponse);
});
cmd
.command("handover")
.argument("<seat>", "Canonical session name or logical seat ref")
.option("--source <source>", "Source: discovered:<id> for live MVP; fresh, rebuild, or fork:<id> for dry-run planning")
.option("--reason <reason>", "Why the handover is happening")
.option("--operator <address>", "Operator initiating the handover")
.option("--dry-run", "Plan the handover without changing topology")
.option("--json", "JSON output for agents")
.description("Plan a safe two-phase seat handover")
.addHelpText("after", `
Examples:
rig seat handover spec-writer@openrig-pm --reason context-wall --dry-run
rig seat handover spec-writer@openrig-pm --source rebuild --reason context-wall --dry-run --json
rig seat handover spec-writer@openrig-pm --source fork:0b0165d7 --reason successor-test --operator orch-lead@openrig-pm --dry-run
rig seat handover spec-writer@openrig-pm --source discovered:01H... --reason mvp-proof --json`)
.action(async (seat: string, opts: { source?: string; reason?: string; operator?: string; dryRun?: boolean; json?: boolean }) => {
if (!opts.reason?.trim()) {
const error: SeatStatusError = {
ok: false,
code: "missing_reason",
message: "Missing required option: --reason <reason>",
guidance: "Provide an explicit handover reason, for example: --reason context-wall",
};
if (opts.json) {
console.log(JSON.stringify(error, null, 2));
} else {
printSeatError(error, "Missing required option: --reason <reason>");
}
process.exitCode = 2;
return;
}
const deps = getDeps();
const daemon = await getDaemonStatus(deps.lifecycleDeps);
if (daemon.state !== "running" || daemon.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return;
}
const client = deps.clientFactory(getDaemonUrl(daemon));
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
source: opts.source,
reason: opts.reason,
operator: opts.operator,
dryRun: opts.dryRun === true,
});
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
if (res.status >= 400) {
printSeatError(res.data as SeatStatusError, `Seat handover failed (HTTP ${res.status})`);
process.exitCode = res.status >= 500 ? 2 : 1;
return;
}
const data = res.data as SeatHandoverPlan | SeatHandoverMutationResult;
if (data.dryRun) {
printHumanHandoverPlan(data);
} else {
printHumanHandoverResult(data);
}
});
return cmd;
}
+118 -8
View File
@@ -3,10 +3,29 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
export function sendCommand(depsOverride?: StatusDeps): Command {
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
export interface SendDeps extends StatusDeps {
/**
* Cross-host hooks. Both default to the production loaders/executors; tests
* inject in-package mocks so no real ssh / no real ~/.ssh / no real network
* is touched.
*/
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
export function sendCommand(depsOverride?: SendDeps): Command {
const cmd = new Command("send").description("Send a message to an agent's terminal");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): SendDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -14,21 +33,50 @@ export function sendCommand(depsOverride?: StatusDeps): Command {
cmd
.argument("<session>", "Target session name (e.g. dev-impl@my-rig)")
.argument("<text>", "Message text to send")
.option("--verify", "Verify delivery by checking pane content after send")
.option("--verify", "Verify pane only delivery by checking content after send")
.option("--force", "Send even if target pane appears mid-task")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
rig send dev-impl@my-rig "Context update: QA approved. Proceed."
rig send dev-impl@my-rig "message" --verify
rig send dev-impl@my-rig "safe proof prompt" --wait-for-idle 30 --verify
rig send dev-impl@my-rig "Stop and read the spec." --force
rig send dev-impl@my-rig "message" --json
rig send --host vm-claude-test dev-impl@my-rig "remote message" --verify
The two-step send pattern (paste text, wait, submit Enter) is handled
automatically. Use --verify to confirm the message appeared in the pane.
Use --force to override mid-task safety checks.`)
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; json?: boolean }) => {
automatically. Use --wait-for-idle to send only after explicit idle evidence;
it fails closed on attention prompts, unknown activity, or timeout. Use --verify
to confirm the message appeared in the pane only; it is not agent acknowledgement.
Use --force to override mid-task safety checks without wait mode.
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. SSH success is NOT verify success: the remote rig's
'Verified: yes/no' line is what counts and is surfaced verbatim.`)
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; waitForIdle?: string; host?: string; json?: boolean }) => {
const waitForIdleMs = parseWaitForIdleMs(opts.waitForIdle);
if (opts.force && waitForIdleMs !== undefined) {
console.error("--wait-for-idle cannot be combined with --force");
process.exitCode = 1;
return;
}
if (waitForIdleMs === null) {
console.error("--wait-for-idle must be a positive number of seconds");
process.exitCode = 1;
return;
}
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostSend(opts.host, session, text, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -39,8 +87,8 @@ Use --force to override mid-task safety checks.`)
const client = deps.clientFactory(getDaemonUrl(status));
const res = await client.post<Record<string, unknown>>("/api/transport/send", {
session, text, verify: opts.verify, force: opts.force,
});
session, text, verify: opts.verify, force: opts.force, waitForIdleMs,
}, waitForIdleRequestOptions(waitForIdleMs));
if (opts.json) {
console.log(JSON.stringify(res.data));
@@ -64,3 +112,65 @@ Use --force to override mid-task safety checks.`)
return cmd;
}
async function runCrossHostSend(
hostId: string,
session: string,
text: string,
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; json?: boolean },
deps: SendDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv for the remote `rig send` invocation. Order is positional
// first so the remote Commander parses it the same way local does.
const argv: string[] = ["rig", "send", session, text];
if (opts.verify) argv.push("--verify");
if (opts.force) argv.push("--force");
if (opts.waitForIdle !== undefined) argv.push("--wait-for-idle", opts.waitForIdle);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: host.target },
result,
}));
if (!result.ok) process.exitCode = 1;
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, opts.json);
}
function parseWaitForIdleMs(value: string | undefined): number | undefined | null {
if (value === undefined) return undefined;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.ceil(seconds * 1000);
}
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
if (waitForIdleMs === undefined) return undefined;
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
}
+640
View File
@@ -0,0 +1,640 @@
import { Command } from "commander";
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
import path from "node:path";
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
import { resolveDaemonPath } from "../daemon-lifecycle.js";
import { ConfigStore } from "../config-store.js";
import {
CMUX_SETTINGS_DISCLOSURE_PATH,
isCmuxSocketControlCompatible,
readCmuxSocketControlModeFromText,
resolveCmuxSettingsPath,
upsertCmuxSocketControlMode,
} from "../cmux-config.js";
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
export interface SetupStep {
id: string;
status: "pass" | "applied" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fixHint?: string;
}
export interface VerificationCheck {
name: string;
status: "pass" | "warn" | "fail" | "skipped";
message: string;
reason?: string;
fix?: string;
}
export interface RuntimeConfigDisclosure {
scope: "global" | "project";
runtime: "claude-code" | "codex" | "cmux";
path: string;
purpose: string;
}
export interface SetupResult {
profile: "core" | "full";
platform: string;
ready: boolean;
steps: SetupStep[];
runtimeConfig: RuntimeConfigDisclosure[];
verification?: {
checks: VerificationCheck[];
};
}
export interface SetupDeps {
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
readFile: (path: string) => string | null;
writeFile: (path: string, content: string) => void;
exists: (path: string) => boolean;
mkdirp?: (path: string) => void;
platform?: NodeJS.Platform;
}
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
const INSTALL_COMMAND_TIMEOUT_MS = 5 * 60_000;
const CMUX_READY_ATTEMPTS = 5;
const CMUX_READY_DELAY_MS = 1_000;
const CORE_STEP_IDS = [
"brew",
"tmux_install",
"cmux_install",
"claude_install",
"claude_auth",
"codex_install",
"codex_auth",
"tmux_config",
"verify",
];
const FULL_EXTRA_STEP_IDS = ["jq_install", "gh_install"];
const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
{
scope: "global",
runtime: "claude-code",
path: "~/.claude/settings.json",
purpose: "Allow OpenRig commands without Claude permission prompts.",
},
{
scope: "global",
runtime: "claude-code",
path: "~/.claude.json",
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
},
{
scope: "project",
runtime: "claude-code",
path: ".claude/settings.local.json",
purpose: "Apply context-collector statusLine config and selected Claude settings runtime-resource fragments.",
},
{
scope: "project",
runtime: "claude-code",
path: ".mcp.json",
purpose: "Apply selected Claude MCP runtime-resource fragments.",
},
{
scope: "global",
runtime: "codex",
path: "~/.codex/config.toml",
purpose: "Pre-trust managed workspaces and apply selected Codex config runtime-resource fragments.",
},
];
const DARWIN_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure = {
scope: "global",
runtime: "cmux",
path: CMUX_SETTINGS_DISCLOSURE_PATH,
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
};
function defaultDeps(): SetupDeps {
return {
exec: (cmd: string, opts?: { timeoutMs?: number }) =>
execSync(cmd, {
encoding: "utf-8",
timeout: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
stdio: ["pipe", "pipe", "pipe"],
}),
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
writeFile: (p: string, c: string) => writeFileSync(p, c, "utf-8"),
exists: (p: string) => existsSync(p),
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
};
}
function installCommand(deps: SetupDeps, cmd: string): string {
return deps.exec(cmd, { timeoutMs: INSTALL_COMMAND_TIMEOUT_MS });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCmuxCapabilities(deps: SetupDeps, attempts = CMUX_READY_ATTEMPTS): Promise<boolean> {
for (let index = 0; index < attempts; index += 1) {
try {
deps.exec("cmux capabilities --json");
return true;
} catch {
if (index < attempts - 1) {
await sleep(CMUX_READY_DELAY_MS);
}
}
}
return false;
}
async function tryEnableCmuxControl(deps: SetupDeps, platform: NodeJS.Platform): Promise<boolean> {
if (platform !== "darwin") return false;
const settingsPath = resolveCmuxSettingsPath();
const settingsText = deps.readFile(settingsPath);
const currentMode = readCmuxSocketControlModeFromText(settingsText);
if (currentMode.error) {
return false;
}
try {
deps.mkdirp?.(path.dirname(settingsPath));
const next = upsertCmuxSocketControlMode(settingsText, "automation");
if (next.changed) {
deps.writeFile(settingsPath, next.content);
}
} catch {
return false;
}
const shellReady = await waitForCmuxCapabilities(deps, 1);
if (shellReady) {
try {
deps.exec("cmux reload-config");
} catch {
// Best effort: if reload fails, the daemon-side verification will surface it honestly.
}
return waitForCmuxCapabilities(deps, 1);
}
try {
deps.exec("open -a /Applications/cmux.app");
} catch {
// Best effort: cmux may already be running or the app open may be blocked; capability probe decides readiness.
}
return waitForCmuxCapabilities(deps);
}
function buildRuntimeConfigDisclosure(platform: NodeJS.Platform): RuntimeConfigDisclosure[] {
return platform === "darwin"
? [...BASE_RUNTIME_CONFIG_DISCLOSURE, DARWIN_RUNTIME_CONFIG_DISCLOSURE]
: [...BASE_RUNTIME_CONFIG_DISCLOSURE];
}
async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"available" | "unavailable" | "skipped"> {
const fetchFn = doctorDeps?.fetch;
if (!fetchFn) return "skipped";
const config = doctorDeps.configStore.resolve();
const host = config.daemon.host;
const port = config.daemon.port;
try {
const healthRes = await fetchFn(`http://${host}:${port}/healthz`);
if (!healthRes.ok) return "skipped";
} catch {
return "skipped";
}
try {
const cmuxRes = await fetchFn(`http://${host}:${port}/api/adapters/cmux/status`);
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
const data = (await cmuxRes.json()) as { available?: boolean };
return data.available ? "available" : "unavailable";
} catch {
return "unavailable";
}
}
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
const profile = opts.full ? "full" : "core";
const platform = deps.platform ?? process.platform;
const runtimeConfig = buildRuntimeConfigDisclosure(platform);
const stepIds = opts.full ? [...CORE_STEP_IDS, ...FULL_EXTRA_STEP_IDS] : [...CORE_STEP_IDS];
const steps: SetupStep[] = [];
if (opts.dryRun) {
for (const id of stepIds) {
steps.push({ id, status: "skipped", message: `Dry run: ${id} would be attempted.` });
}
return { profile, platform, ready: false, steps, runtimeConfig };
}
// Core steps
// 1. Homebrew (macOS-first setup path)
let brewOk = false;
if (platform !== "darwin") {
steps.push({
id: "brew",
status: "skipped",
message: "Skipped: Homebrew setup path is only used on macOS.",
});
} else {
try {
deps.exec("brew --version");
brewOk = true;
steps.push({ id: "brew", status: "pass", message: "Homebrew available." });
} catch {
steps.push({
id: "brew",
status: "fail",
message: "Homebrew not found.",
reason: "Homebrew is required to install tmux and cmux on macOS.",
fixHint: "Install Homebrew: https://brew.sh",
});
}
}
// 2. tmux
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
if (tmuxProbe.code === "not_installed") {
if (!brewOk) {
steps.push({ id: "tmux_install", status: "skipped", message: "Skipped: Homebrew not available.", reason: "tmux install requires Homebrew." });
} else {
try {
installCommand(deps, "brew install tmux");
steps.push({ id: "tmux_install", status: "applied", message: "Installed tmux with Homebrew." });
} catch (err) {
steps.push({ id: "tmux_install", status: "fail", message: `Failed to install tmux: ${(err as Error).message}` });
}
}
} else if (!tmuxProbe.available) {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
steps.push({
id: "tmux_install",
status: "fail",
message: failure.message,
reason: failure.reason,
fixHint: failure.fix,
});
} else {
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
}
// 3. cmux
const daemonCmuxBefore = await probeDaemonCmuxStatus(opts.doctorDeps);
if (await waitForCmuxCapabilities(deps, 1)) {
const socketMode = readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
if (platform === "darwin" && socketMode.error) {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux settings file is unreadable.",
reason: `OpenRig could not parse ${CMUX_SETTINGS_DISCLOSURE_PATH}: ${socketMode.error}`,
fixHint: "Repair or remove the cmux settings file, then rerun `rig setup`.",
});
} else if (platform === "darwin" && !isCmuxSocketControlCompatible(socketMode.mode)) {
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig updated cmux settings, but the running daemon still cannot control cmux.",
reason: "The cmux settings file is now compatible, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Normalized cmux socket control to automation mode in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but OpenRig could not normalize cmux socket control.",
reason: "OpenRig needs a compatible cmux socket control mode so the daemon can open CMUX surfaces reliably.",
fixHint: `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`,
});
}
} else if (daemonCmuxBefore === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "cmux shell control works, but the running daemon still cannot control cmux.",
reason: "Current cmux settings already look compatible, so the remaining blocker is outside the cmux settings file OpenRig can repair automatically.",
fixHint: "Run `rig doctor` for the exact daemon cmux diagnosis, then restart the daemon after clearing the underlying blocker.",
});
} else {
steps.push({ id: "cmux_install", status: "pass", message: "cmux available." });
}
} else {
try {
deps.exec("cmux --help");
if (await tryEnableCmuxControl(deps, platform)) {
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
if (daemonCmuxAfter === "unavailable") {
steps.push({
id: "cmux_install",
status: "fail",
message: "OpenRig enabled cmux socket control, but the running daemon still cannot control cmux.",
reason: "The cmux app and settings are now in place, so the remaining blocker is in the live daemon/cmux session state.",
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
});
} else {
steps.push({
id: "cmux_install",
status: "applied",
message: "Enabled cmux socket control in ~/.config/cmux/settings.json.",
});
}
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "cmux installed but control unavailable.",
reason: "Open CMUX workflows need cmux socket control to be enabled.",
fixHint: platform === "darwin"
? `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`
: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch {
if (!brewOk) {
steps.push({ id: "cmux_install", status: "skipped", message: "Skipped: Homebrew not available." });
} else {
try {
installCommand(deps, "brew install --cask cmux");
if (await tryEnableCmuxControl(deps, platform) || await waitForCmuxCapabilities(deps, 1)) {
steps.push({ id: "cmux_install", status: "applied", message: "Installed cmux with Homebrew." });
} else {
steps.push({
id: "cmux_install",
status: platform === "darwin" ? "fail" : "warn",
message: "Installed cmux, but control is still unavailable.",
reason: "Open CMUX workflows need the cmux app to expose socket control after installation.",
fixHint: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
});
}
} catch (err) {
steps.push({
id: "cmux_install",
status: "fail",
message: `Failed to install cmux: ${(err as Error).message}`,
reason: "Open CMUX workflows stay unavailable until the cmux app and CLI are installed.",
fixHint: "Retry `brew install --cask cmux` after connectivity stabilizes, or install cmux manually.",
});
}
}
}
}
// 4. tmux config
// 4. Claude Code runtime
let claudeInstalled = false;
try {
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "pass", message: "Claude Code available." });
} catch {
try {
installCommand(deps, "npm install -g @anthropic-ai/claude-code");
deps.exec("claude --version");
claudeInstalled = true;
steps.push({ id: "claude_install", status: "applied", message: "Installed Claude Code with npm." });
} catch (err) {
steps.push({
id: "claude_install",
status: "fail",
message: `Failed to install Claude Code: ${(err as Error).message}`,
reason: "The demo rig launches Claude Code nodes, so the Claude CLI must be installed on this machine.",
fixHint: "Install Claude Code with `npm install -g @anthropic-ai/claude-code`.",
});
}
}
if (claudeInstalled) {
try {
deps.exec("claude auth status");
steps.push({ id: "claude_auth", status: "pass", message: "Claude Code authentication available." });
} catch (err) {
steps.push({
id: "claude_auth",
status: "fail",
message: `Claude Code is installed but not ready to launch: ${(err as Error).message}`,
reason: "The demo rig cannot launch Claude Code nodes until the Claude CLI is logged in and usable.",
fixHint: "Run `claude auth login` or open `claude` once to complete authentication, then rerun `rig setup` or `rig doctor`.",
});
}
} else {
steps.push({
id: "claude_auth",
status: "skipped",
message: "Skipped: Claude Code is not installed.",
reason: "Authentication cannot be checked until the Claude Code CLI is installed.",
});
}
// 5. Codex runtime
let codexInstalled = false;
try {
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "pass", message: "Codex available." });
} catch {
try {
installCommand(deps, "npm install -g @openai/codex");
deps.exec("codex --version");
codexInstalled = true;
steps.push({ id: "codex_install", status: "applied", message: "Installed Codex with npm." });
} catch (err) {
steps.push({
id: "codex_install",
status: "fail",
message: `Failed to install Codex: ${(err as Error).message}`,
reason: "The demo rig launches Codex nodes, so the Codex CLI must be installed on this machine.",
fixHint: "Install Codex with `npm install -g @openai/codex`.",
});
}
}
if (codexInstalled) {
try {
deps.exec("codex login status");
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
} catch (err) {
steps.push({
id: "codex_auth",
status: "fail",
message: `Codex is installed but not ready to launch: ${(err as Error).message}`,
reason: "The demo rig cannot launch Codex nodes until the Codex CLI is logged in and usable.",
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
});
}
} else {
steps.push({
id: "codex_auth",
status: "skipped",
message: "Skipped: Codex is not installed.",
reason: "Authentication cannot be checked until the Codex CLI is installed.",
});
}
// 6. tmux config
const TMUX_CONF = `${process.env["HOME"] ?? "~"}/.tmux.conf`;
const MANAGED_MARKER = "# OpenRig managed block";
const MANAGED_BLOCK = [
MANAGED_MARKER,
"set -g mouse on",
"set -g history-limit 50000",
`# End ${MANAGED_MARKER}`,
].join("\n");
try {
const existing = deps.readFile(TMUX_CONF);
if (existing && existing.includes(MANAGED_MARKER)) {
// Replace existing managed block
const replaced = existing.replace(
new RegExp(`${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?# End ${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
MANAGED_BLOCK,
);
deps.writeFile(TMUX_CONF, replaced);
steps.push({ id: "tmux_config", status: "applied", message: "Updated OpenRig managed tmux config block." });
} else if (existing) {
deps.writeFile(TMUX_CONF, existing.trimEnd() + "\n\n" + MANAGED_BLOCK + "\n");
steps.push({ id: "tmux_config", status: "applied", message: "Appended OpenRig managed tmux config block." });
} else {
deps.writeFile(TMUX_CONF, MANAGED_BLOCK + "\n");
steps.push({ id: "tmux_config", status: "applied", message: "Created .tmux.conf with OpenRig managed block." });
}
} catch (err) {
steps.push({ id: "tmux_config", status: "warn", message: `Could not update tmux config: ${(err as Error).message}` });
}
// 7. Verify
const tmuxOk = steps.some((s) => s.id === "tmux_install" && (s.status === "pass" || s.status === "applied"));
const anyFail = steps.some((s) => s.status === "fail");
steps.push({
id: "verify",
status: anyFail ? "warn" : "pass",
message: anyFail ? "Some setup steps failed. Run `rig doctor` for detailed diagnostics." : "Core setup verified.",
});
// Full profile extras
if (opts.full) {
for (const tool of [{ id: "jq_install", cmd: "jq", brew: "jq" }, { id: "gh_install", cmd: "gh", brew: "gh" }]) {
try {
deps.exec(`${tool.cmd} --version`);
steps.push({ id: tool.id, status: "pass", message: `${tool.cmd} available.` });
} catch {
if (!brewOk) {
steps.push({ id: tool.id, status: "skipped", message: `Skipped: Homebrew not available.` });
} else {
try {
installCommand(deps, `brew install ${tool.brew}`);
steps.push({ id: tool.id, status: "applied", message: `Installed ${tool.cmd} with Homebrew.` });
} catch {
steps.push({ id: tool.id, status: "warn", message: `Failed to install ${tool.cmd}.`, fixHint: `Install ${tool.cmd} manually.` });
}
}
}
}
}
// Run doctor-backed verification if not dry-run and doctorDeps available
let verification: SetupResult["verification"];
if (!opts.dryRun && opts.doctorDeps) {
const doctorDeps = opts.doctorDeps;
const doctor = runDoctorChecks(doctorDeps);
const asyncResults = await Promise.all(doctor.asyncChecks);
const allDoctorChecks = [...doctor.checks, ...asyncResults];
verification = {
checks: allDoctorChecks.map((c) => ({
name: c.name,
status: c.status,
message: c.message,
...(c.reason ? { reason: c.reason } : {}),
...(c.fix ? { fix: c.fix } : {}),
})),
};
}
// ready = no fail statuses in steps or verification checks
const stepsFailed = steps.some((s) => s.status === "fail");
const verificationFailed = verification?.checks.some((c) => c.status === "fail") ?? false;
const ready = !stepsFailed && !verificationFailed;
return { profile, platform, ready, steps, runtimeConfig, ...(verification ? { verification } : {}) };
}
function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
const platform = setupDeps.platform ?? process.platform;
const baseDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
return {
exists: setupDeps.exists,
baseDir,
readFile: setupDeps.readFile,
exec: setupDeps.exec,
checkPort: async (port: number) => {
const net = await import("node:net");
return new Promise<boolean>((resolve) => {
const socket = new net.default.Socket();
socket.once("connect", () => { socket.destroy(); resolve(false); });
socket.once("error", () => resolve(true));
socket.connect(port, "127.0.0.1");
});
},
configStore: new ConfigStore(),
platform: platform as NodeJS.Platform,
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
checkWritable: (p: string) => accessSync(p, constants.W_OK),
fetch: globalThis.fetch,
};
}
export function setupCommand(depsOverride?: SetupDeps): Command {
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
cmd
.option("--dry-run", "Show the plan without making changes")
.option("--json", "Machine-readable JSON output")
.option("--full", "Install broader operator workstation tools")
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean }) => {
const deps = depsOverride ?? defaultDeps();
const doctorDeps = opts.dryRun ? undefined : buildDefaultDoctorDeps(deps);
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, doctorDeps });
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
if (!opts.dryRun && !result.ready) process.exitCode = 1;
return;
}
console.log(`\nProfile: ${result.profile}`);
console.log(`Platform: ${result.platform}\n`);
console.log("OpenRig may modify runtime config in these locations:");
for (const item of result.runtimeConfig) {
console.log(` - [${item.scope}] ${item.runtime} ${item.path} — ${item.purpose}`);
}
console.log(" - Note: already-running adopted sessions may need restart to pick up runtime config changes.\n");
for (const step of result.steps) {
const icon = step.status === "pass" ? "OK" : step.status === "applied" ? "APPLIED" : step.status === "warn" ? "WARN" : step.status === "skipped" ? "SKIP" : "FAIL";
console.log(` [${icon}] ${step.id}: ${step.message}`);
if (step.reason) console.log(` Why: ${step.reason}`);
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
console.log(`\n${result.ready ? "Setup complete." : "Some steps need attention. Run `rig doctor` for detailed diagnostics."}`);
if (!opts.dryRun && !result.ready) process.exitCode = 1;
});
return cmd;
}
+143 -22
View File
@@ -1,4 +1,17 @@
import { Command } from "commander";
import {
copyFileSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
} from "node:fs";
import { basename, join } from "node:path";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
@@ -17,6 +30,114 @@ interface LibraryEntry {
summary?: string;
}
interface AddSpecSource {
inputPath: string;
yamlPath: string;
installName: string;
installKind: "file" | "directory";
libraryEntrySuffix: string;
}
function normalizePathForMatch(path: string): string {
return path.replaceAll("\\", "/");
}
function requireRegularFile(path: string, label: string): void {
const stat = lstatSync(path);
if (stat.isSymbolicLink()) {
throw new Error(`${label} must not be a symlink: ${path}`);
}
if (!stat.isFile()) {
throw new Error(`${label} must be a regular file: ${path}`);
}
}
function assertTreeHasNoSymlinks(root: string): void {
const stack = [root];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absPath = join(current, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Spec directories must not contain symlinks: ${absPath}`);
}
if (entry.isDirectory()) {
stack.push(absPath);
}
}
}
}
function resolveAddSpecSource(inputPath: string): AddSpecSource {
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
}
const stat = lstatSync(inputPath);
if (stat.isSymbolicLink()) {
throw new Error(`Spec path must not be a symlink: ${inputPath}`);
}
if (stat.isFile()) {
return {
inputPath,
yamlPath: inputPath,
installName: basename(inputPath),
installKind: "file",
libraryEntrySuffix: basename(inputPath),
};
}
if (!stat.isDirectory()) {
throw new Error(`Spec path must be a YAML file or spec directory: ${inputPath}`);
}
const rootSpec = ["rig.yaml", "rig.yml", "agent.yaml", "agent.yml"]
.map((candidate) => join(inputPath, candidate))
.find((candidate) => existsSync(candidate));
if (!rootSpec) {
throw new Error(`Spec directory must contain rig.yaml or agent.yaml: ${inputPath}`);
}
requireRegularFile(rootSpec, "Root spec file");
const installName = basename(inputPath);
return {
inputPath,
yamlPath: rootSpec,
installName,
installKind: "directory",
libraryEntrySuffix: normalizePathForMatch(join(installName, basename(rootSpec))),
};
}
function installSpecSource(source: AddSpecSource, userRoot: string): string {
mkdirSync(userRoot, { recursive: true });
const dest = join(userRoot, source.installName);
if (source.installKind === "file") {
requireRegularFile(source.inputPath, "Spec file");
copyFileSync(source.inputPath, dest);
return dest;
}
if (existsSync(dest)) {
throw new Error(`A spec directory already exists at ${dest}. Remove or rename it before adding this spec.`);
}
assertTreeHasNoSymlinks(source.inputPath);
const tempParent = mkdtempSync(join(userRoot, ".spec-add-"));
const tempDest = join(tempParent, source.installName);
try {
cpSync(source.inputPath, tempDest, { recursive: true, errorOnExist: true, force: false });
renameSync(tempDest, dest);
} catch (err) {
rmSync(tempParent, { recursive: true, force: true });
throw err;
}
rmSync(tempParent, { recursive: true, force: true });
return dest;
}
/**
* Shared name resolution for library specs.
* Returns the matching entry, or throws with guidance on ambiguity/not-found.
@@ -44,12 +165,19 @@ export async function resolveLibrarySpec(
}
throw new Error(
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available specs.`
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
);
}
export function specsCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("specs").description("Browse, preview, and manage the spec library");
const cmd = new Command("specs")
.description("Browse, preview, and manage the spec library, including managed apps")
.addHelpText("after", `
Examples:
rig specs ls
rig specs preview secrets-manager
rig specs show vault-specialist
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
@@ -66,7 +194,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs ls
cmd.command("ls")
.description("List library specs")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig or agent)")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
@@ -125,7 +253,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs preview
cmd.command("preview")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show structured spec review")
.description("Show structured spec review, including managed app details")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
@@ -160,20 +288,13 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// specs add
cmd.command("add")
.argument("<path>", "Path to YAML spec file")
.description("Add a spec to the user library")
.argument("<path>", "Path to YAML spec file or spec directory")
.description("Add a spec file or full spec directory to the user library")
.option("--json", "JSON output")
.action(async (filePath: string, opts: { json?: boolean }) => {
.action(async (inputPath: string, opts: { json?: boolean }) => {
try {
const { readFileSync, copyFileSync, existsSync } = await import("node:fs");
const { join, basename } = await import("node:path");
const { homedir } = await import("node:os");
if (!existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const yaml = readFileSync(filePath, "utf-8");
const source = resolveAddSpecSource(inputPath);
const yaml = readFileSync(source.yamlPath, "utf-8");
const client = await getClient();
// Validate via daemon
@@ -189,16 +310,16 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
// Copy to user library
const userRoot = getDefaultOpenRigPath("specs");
const { mkdirSync } = await import("node:fs");
mkdirSync(userRoot, { recursive: true });
const dest = join(userRoot, basename(filePath));
copyFileSync(filePath, dest);
const dest = installSpecSource(source, userRoot);
// Sync and find the new entry
const syncRes = await client.post<LibraryEntry[]>("/api/specs/library/sync");
const entries = syncRes.data ?? [];
const name = (res.data as Record<string, unknown>)["name"] as string ?? basename(filePath);
const newEntry = entries.find((e) => e.name === name && e.sourcePath.endsWith(basename(filePath)));
const name = (res.data as Record<string, unknown>)["name"] as string ?? source.installName;
const newEntry = entries.find((e) => (
e.name === name &&
normalizePathForMatch(e.sourcePath).endsWith(source.libraryEntrySuffix)
));
if (opts.json) {
console.log(JSON.stringify({ name, kind, path: dest, id: newEntry?.id ?? null, entry: newEntry ?? null }));
+141
View File
@@ -0,0 +1,141 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig stream` — coordination primitive L1 commands (PL-004 Phase A).
*
* Backed by `/api/stream`. Operates only via the daemon HTTP API.
* Does NOT touch the POC `rigx-stream-proto` filesystem state.
*/
export interface StreamDeps extends StatusDeps {}
async function withClient<T>(
deps: StreamDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function streamCommand(depsOverride?: StreamDeps): Command {
const cmd = new Command("stream").description("Coordination L1 — append-only intake stream");
const getDeps = (): StreamDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("emit")
.description("Append a stream item")
.requiredOption("--source <session>", "Source session (e.g. velocity-driver@openrig-velocity-claude)")
.requiredOption("--body <text>", "Stream item body")
.option("--hint-destination <session>", "Hint at intended destination seat")
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
.option("--hint-tags <tags>", "Comma-separated hint tags")
.option("--interrupt", "Mark item as interrupting")
.option("--id <streamItemId>", "Idempotent stream_item_id (skip if not provided)")
.option("--json", "JSON output for agents")
.action(async (opts: {
source: string;
body: string;
hintDestination?: string;
hintType?: string;
hintUrgency?: string;
hintTags?: string;
interrupt?: boolean;
id?: string;
json?: boolean;
}) => {
const deps = getDeps();
const tags = opts.hintTags ? opts.hintTags.split(",").map((s) => s.trim()).filter(Boolean) : null;
await withClient(deps, async (client) => {
const res = await client.post<Record<string, unknown>>("/api/stream/emit", {
streamItemId: opts.id,
sourceSession: opts.source,
body: opts.body,
hintDestination: opts.hintDestination ?? null,
hintType: opts.hintType ?? null,
hintUrgency: opts.hintUrgency ?? null,
hintTags: tags,
interrupt: opts.interrupt ?? false,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List stream items chronologically")
.option("--source <session>", "Filter by source session")
.option("--hint-destination <session>", "Filter by hint destination")
.option("--limit <n>", "Result limit", "100")
.option("--after <sortKey>", "Cursor pagination — return items after this sort key")
.option("--include-archived", "Include archived items")
.option("--json", "JSON output for agents")
.action(async (opts: {
source?: string;
hintDestination?: string;
limit: string;
after?: string;
includeArchived?: boolean;
json?: boolean;
}) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.source) params.set("sourceSession", opts.source);
if (opts.hintDestination) params.set("hintDestination", opts.hintDestination);
if (opts.limit) params.set("limit", opts.limit);
if (opts.after) params.set("afterSortKey", opts.after);
if (opts.includeArchived) params.set("includeArchived", "true");
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/stream/list?${params.toString()}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <streamItemId>")
.description("Fetch one stream item by id")
.option("--json", "JSON output for agents")
.action(async (streamItemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("archive <streamItemId>")
.description("Soft-archive a stream item (audit row preserved)")
.option("--json", "JSON output for agents")
.action(async (streamItemId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}/archive`, {});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+101 -9
View File
@@ -5,12 +5,26 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: LifecycleDeps }): Command {
const cmd = new Command("up").description("Bootstrap a rig from a spec or bundle");
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
export function upCommand(
depsOverride?: StatusDeps & {
lifecycleDeps?: LifecycleDeps;
preflightExec?: (cmd: string) => Promise<string>;
},
): Command {
const cmd = new Command("up")
.description("Launch a rig or managed app from a spec, library entry, or bundle")
.addHelpText("after", `
Examples:
rig up secrets-manager
rig up ./rig.yaml
rig up ./demo.rigbundle --target ~/work
`);
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd
.argument("<source>", "Path to .yaml rig spec or .rigbundle")
.argument("<source>", "Path to a .yaml rig spec or .rigbundle, or a library name such as secrets-manager")
.option("--plan", "Plan mode — preview without executing")
.option("--yes", "Auto-approve trusted actions")
.option("--cwd <path>", "Override launch working directory for all members for this run only")
@@ -34,8 +48,10 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
const configStore = new ConfigStore();
resolvedConfig = configStore.resolve();
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
const preflight = new SystemPreflight({
exec: async (cmd) => execSync(cmd, { encoding: "utf-8" }),
exec: preflightExec,
configStore,
getDaemonStatus: () => getDaemonStatus(deps.lifecycleDeps),
openrigHome: OPENRIG_DIR,
@@ -85,14 +101,28 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
let sourceRef = isRigName ? source : nodePath.resolve(source);
// If it looks like a name, check for library spec match
let defaultLibraryCwdOverride: string | undefined;
// Cache rig summaries so we can both detect ambiguity AND derive lifecycleState
// for "Recovering ..." vs "Turning on ..." wording (post-L2).
let rigSummariesCache: Array<{ id: string; name: string; lifecycleState?: string }> | null = null;
const fetchRigSummaries = async () => {
if (rigSummariesCache !== null) return rigSummariesCache;
try {
const res = await client.get<Array<{ id: string; name: string; lifecycleState?: string }>>("/api/rigs/summary");
rigSummariesCache = res.data ?? [];
} catch {
rigSummariesCache = [];
}
return rigSummariesCache;
};
if (isRigName) {
try {
const { resolveLibrarySpec } = await import("./specs.js");
const entry = await resolveLibrarySpec(client, source);
// Library match found — check for existing-rig collision
// Use /api/rigs/summary which mirrors findRigsByName (includes stopped rigs)
const rigRes = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
const rigMatches = (rigRes.data ?? []).filter((r) => r.name === source);
const rigSummaries = await fetchRigSummaries();
const rigMatches = rigSummaries.filter((r) => r.name === source);
if (rigMatches.length > 0) {
console.error(`'${source}' is ambiguous — it matches both an existing rig restore target and a library spec.`);
console.error(` To launch the library spec: rig up ${entry.sourcePath}`);
@@ -102,6 +132,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
return;
}
sourceRef = entry.sourcePath;
if (!opts.cwd && entry.kind === "rig" && entry.sourceType === "builtin") {
defaultLibraryCwdOverride = process.cwd();
}
} catch (resolveErr) {
// Ambiguity within library — surface it
if ((resolveErr as Error).message?.includes("ambiguous")) {
@@ -111,6 +144,21 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
}
// Not found or other error — proceed with existing rig-name behavior
}
// Wording divergence (post-L2): if sourceRef stayed as the rig name we are
// routing through the existing-rig restore path. Print "Recovering ..." or
// "Turning on ..." per the rig's derived lifecycleState. Help text honesty:
// "Recover" describes what `rig up` does; it does not promise success before
// tester L4 VM proof completes.
if (sourceRef === source && !opts.json) {
const summaries = await fetchRigSummaries();
const match = summaries.find((r) => r.name === source);
if (match?.lifecycleState === "recoverable") {
console.log(`Recovering rig "${source}" from latest snapshot...`);
} else if (match?.lifecycleState === "stopped") {
console.log(`Turning on rig "${source}"...`);
}
}
}
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
@@ -120,13 +168,15 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
sourceRef,
plan: opts.plan ?? false,
autoApprove: opts.yes ?? false,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
targetRoot,
});
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
if (opts.json) {
console.log(JSON.stringify(res.data));
if (res.status >= 400) process.exitCode = res.status === 409 ? 1 : 2;
const rigResult = res.data["rigResult"] as string | undefined;
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted") process.exitCode = 1;
return;
}
@@ -140,6 +190,8 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
} else if (code === "preflight_failed") {
const errors = (res.data["errors"] as string[]) ?? [];
console.error(`Preflight check failed:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: resolve the issues above and retry.`);
} else if (code === "pre_restore_validation_failed") {
printRestoreNotAttempted(res.data as RestoreNotAttemptedData);
} else {
const errorText = String(res.data["error"] ?? "unknown error");
console.error(`Up failed: ${errorText} (HTTP ${res.status}). Check daemon logs or validate your spec with: rig spec validate <path>`);
@@ -163,7 +215,17 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
// Existing-rig power-on handoff
const rigId = res.data["rigId"] as string;
const rigName = res.data["rigName"] as string | undefined;
const rigResult = res.data["rigResult"] as string | undefined;
const snapshotKind = res.data["snapshotKind"] as string | undefined;
// L3b: when the daemon falls back to a non-auto-pre-down snapshot, surface
// the kind so operators see the manual fallback explicitly. The note is
// printed BEFORE the "Rig restored" line so it's visible in the typical
// top-of-output scan.
if (snapshotKind && snapshotKind !== "auto-pre-down") {
console.log(`Restoring from manual snapshot (kind=${snapshotKind}); no auto-pre-down snapshot available.`);
}
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
if (rigResult) console.log(`Result: ${rigResult}`);
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
for (const n of nodes) {
console.log(` ${n.logicalId}: ${n.status}`);
@@ -177,7 +239,7 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
if (attachCommand) {
console.log(`Attach: ${attachCommand}`);
}
if (nodes.some((n) => n.status === "failed")) {
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted" || nodes.some((n) => n.status === "failed")) {
process.exitCode = 1;
}
} else {
@@ -213,3 +275,33 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
return cmd;
}
interface RestoreBlocker {
code: string;
severity?: string;
logicalId?: string;
nodeId?: string;
target?: string;
path?: string;
message: string;
remediation: string;
}
interface RestoreNotAttemptedData {
error?: string;
rigResult?: string;
blockers?: RestoreBlocker[];
}
function printRestoreNotAttempted(data: RestoreNotAttemptedData): void {
console.error(`Restore blocked: ${data.error ?? "pre-restore validation failed"}`);
if (data.rigResult) {
console.error(`Result: ${data.rigResult}`);
}
for (const blocker of data.blockers ?? []) {
const scope = blocker.logicalId ?? blocker.nodeId ?? blocker.target ?? blocker.code;
console.error(` ${scope}: ${blocker.message}`);
if (blocker.path) console.error(` path: ${blocker.path}`);
console.error(` remediation: ${blocker.remediation}`);
}
}
+101
View File
@@ -0,0 +1,101 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig view` — coordination primitive L5 (view) commands (PL-004 Phase B).
*
* Backed by `/api/views`. 6 built-in views (recently-active, founder,
* pod-load, escalations, held, activity) + custom view registration.
*/
export interface ViewDeps extends StatusDeps {}
async function withClient<T>(
deps: ViewDeps,
fn: (client: DaemonClient) => Promise<T>
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function viewCommand(depsOverride?: ViewDeps): Command {
const cmd = new Command("view").description(
"Coordination L5 — daemon-backed views over coordination state",
);
const getDeps = (): ViewDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("list")
.description("List built-in + custom views")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/views/list");
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <viewName>")
.description(
"Run a view (built-in or custom). Built-in views: recently-active, founder, pod-load, escalations, held, activity",
)
.option("--rig <rig>", "Filter by rig name (matches destination_session OR source_session @<rig>)")
.option("--limit <n>", "Result row limit", "100")
.option("--json", "JSON output for agents")
.action(async (viewName: string, opts: { rig?: string; limit: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.rig) params.set("rig", opts.rig);
if (opts.limit) params.set("limit", opts.limit);
const query = params.toString();
await withClient(deps, async (client) => {
const path = `/api/views/${encodeURIComponent(viewName)}${query ? `?${query}` : ""}`;
const res = await client.get<unknown>(path);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("register")
.description("Register or update a custom view")
.requiredOption("--name <name>", "Custom view name (must NOT collide with built-in names)")
.requiredOption("--definition <sql>", "SQL definition (operator-supplied; not validated for taxonomy)")
.requiredOption("--session <session>", "Registering operator session")
.option("--json", "JSON output for agents")
.action(async (opts: { name: string; definition: string; session: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/views/custom/register", {
viewName: opts.name,
definition: opts.definition,
registeredBySession: opts.session,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+153
View File
@@ -0,0 +1,153 @@
import { readFileSync } from "node:fs";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig watchdog` — coordination primitive Watchdog (PL-004 Phase C).
*
* Backed by `/api/watchdog`. Operates only via the daemon HTTP API.
*
* Per PRD § Watchdog: scheduler is daemon-native and joins the
* supervision tree. Phase D includes workflow-keepalive alongside the
* Phase C watchdog policies.
*/
export interface WatchdogDeps extends StatusDeps {}
async function withClient<T>(
deps: WatchdogDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
const cmd = new Command("watchdog").description(
"Coordination Watchdog — daemon-native scheduler for periodic-reminder, artifact-pool-ready, edge-artifact-required (PL-004 Phase C; workflow-keepalive ships in Phase D)",
);
const getDeps = (): WatchdogDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("register")
.description("Register a new watchdog job from a YAML spec file")
.requiredOption("--spec <path>", "Path to YAML spec file (policy + target + interval + context)")
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive)")
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
.requiredOption("--registered-by <session>", "Registering session (for audit)")
.option("--active-wake-interval-seconds <n>", "(pool-ready-specific) wake-up cadence when actionable artifacts exist")
.option("--scan-interval-seconds <n>", "(pool-ready-specific) artifact pool scan cadence")
.option("--json", "JSON output for agents")
.action(async (opts: {
spec: string;
policy: string;
targetSession: string;
intervalSeconds: string;
registeredBy: string;
activeWakeIntervalSeconds?: string;
scanIntervalSeconds?: string;
json?: boolean;
}) => {
const deps = getDeps();
let specYaml: string;
try {
specYaml = readFileSync(opts.spec, "utf-8");
} catch (err) {
console.error(`Failed to read spec file ${opts.spec}: ${err instanceof Error ? err.message : err}`);
process.exitCode = 1;
return;
}
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/watchdog/register", {
policy: opts.policy,
specYaml,
targetSession: opts.targetSession,
intervalSeconds: Number.parseInt(opts.intervalSeconds, 10),
activeWakeIntervalSeconds: opts.activeWakeIntervalSeconds
? Number.parseInt(opts.activeWakeIntervalSeconds, 10)
: undefined,
scanIntervalSeconds: opts.scanIntervalSeconds
? Number.parseInt(opts.scanIntervalSeconds, 10)
: undefined,
registeredBySession: opts.registeredBy,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List all watchdog jobs")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>("/api/watchdog/list");
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <jobId>")
.description("Show one watchdog job")
.option("--json", "JSON output for agents")
.action(async (jobId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("status <jobId>")
.description("Show one watchdog job + recent evaluation history")
.option("--json", "JSON output for agents")
.action(async (jobId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}/status`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("stop <jobId>")
.description("Stop a watchdog job (operator-stopped; scheduler skips it)")
.option("--reason <text>", "Stop reason (free-form; recorded in terminal_reason)")
.option("--json", "JSON output for agents")
.action(async (jobId: string, opts: { reason?: string; json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/watchdog/${encodeURIComponent(jobId)}/stop`, {
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
+85 -5
View File
@@ -5,6 +5,26 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { readOpenRigEnv } from "../openrig-compat.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
interface WhoamiCliOptions {
nodeId?: string;
session?: string;
host?: string;
json?: boolean;
}
export interface WhoamiDeps extends StatusDeps {
/** Cross-host hooks; mirrors PsDeps/SendDeps shape. Tests inject mocks. */
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
interface WhoamiIdentity {
rigName: string;
@@ -133,9 +153,9 @@ export function resolveIdentitySource(
return null;
}
export function whoamiCommand(depsOverride?: StatusDeps): Command {
export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
const cmd = new Command("whoami").description("Show current managed identity in an OpenRig topology");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): WhoamiDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -143,16 +163,25 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
cmd
.option("--node-id <id>", "Resolve by node ID")
.option("--session <name>", "Resolve by session name")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.action(async (opts: { nodeId?: string; session?: string; json?: boolean }) => {
.action(async (opts: WhoamiCliOptions) => {
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
// Runs BEFORE local identity-source resolution: the remote rig has its
// own daemon and identity context; we just forward the raw flags.
if (opts.host) {
await runCrossHostWhoami(opts.host, opts, deps);
return;
}
const source = resolveIdentitySource(opts);
if (!source) {
console.error("Cannot determine identity. Run inside an OpenRig-managed session, or use --session or --node-id.");
process.exitCode = 1;
return;
}
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
const partial = buildPartialWhoamiResult(source);
@@ -249,3 +278,54 @@ export function whoamiCommand(depsOverride?: StatusDeps): Command {
return cmd;
}
async function runCrossHostWhoami(
hostId: string,
opts: WhoamiCliOptions,
deps: WhoamiDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv. `rig whoami` has no positional args; just the resolution
// flags. Identity resolution happens on the REMOTE rig (each host has its
// own daemon + tmux + identity context); we don't pre-resolve locally.
const argv: string[] = ["rig", "whoami"];
if (opts.nodeId !== undefined) argv.push("--node-id", opts.nodeId);
if (opts.session !== undefined) argv.push("--session", opts.session);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
if (result.ok) {
// Verbatim remote stdout passthrough — the remote `rig whoami --json`
// already produced the correct JSON envelope; we do NOT double-wrap.
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, true);
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, false);
}
+180
View File
@@ -0,0 +1,180 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
/**
* `rig workflow` — daemon-native Workflow Runtime (PL-004 Phase D).
*
* Backed by `/api/workflow`. Operates only via the daemon HTTP API.
*
* Per PRD § L4 Workflow Runtime: workflow specs are markdown/YAML
* authoritative, daemon caches a read-through copy. Owner-as-author
* + workflow-as-transactional-scribe: owner closure + next-qitem
* projection happen in the SAME daemon transaction. Lost handoffs
* are impossible by design.
*/
export interface WorkflowDeps extends StatusDeps {}
async function withClient<T>(
deps: WorkflowDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
function printResult(json: boolean, body: unknown, status: number): void {
if (json) {
console.log(JSON.stringify(body));
} else {
console.log(JSON.stringify(body, null, 2));
}
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
const cmd = new Command("workflow").description(
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
);
const getDeps = (): WorkflowDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("validate <specPath>")
.description("Validate a workflow spec file (returns structured ok/error report)")
.option("--json", "JSON output for agents")
.action(async (specPath: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/validate", { specPath });
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("instantiate <specPath>")
.description("Create a workflow instance + entry-step qitem from a spec")
.requiredOption("--root-objective <text>", "Root objective for the run")
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
.option("--entry-owner <session>", "Override default entry-step owner")
.option("--json", "JSON output for agents")
.action(async (specPath: string, opts: {
rootObjective: string;
createdBy: string;
entryOwner?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/instantiate", {
specPath,
rootObjective: opts.rootObjective,
createdBySession: opts.createdBy,
entryOwnerSession: opts.entryOwner,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("project")
.description("Close a current packet AND project the next-step packet (transactional-scribe; one daemon transaction)")
.requiredOption("--instance <id>", "Workflow instance id")
.requiredOption("--current-packet <qitem-id>", "qitem being closed (must be on instance frontier)")
.requiredOption("--exit <kind>", "Closure exit kind: handoff | waiting | done | failed")
.requiredOption("--actor-session <session>", "Session closing the packet (owner-as-author)")
.option("--result-note <text>", "Closure result note (audit context)")
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
.option("--next-owner <session>", "Override default next-step owner")
.option("--json", "JSON output for agents")
.action(async (opts: {
instance: string;
currentPacket: string;
exit: "handoff" | "waiting" | "done" | "failed";
actorSession: string;
resultNote?: string;
blockedOn?: string;
nextOwner?: string;
json?: boolean;
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/project", {
instanceId: opts.instance,
currentPacketId: opts.currentPacket,
exit: opts.exit,
actorSession: opts.actorSession,
resultNote: opts.resultNote,
blockedOn: opts.blockedOn,
nextOwnerSession: opts.nextOwner,
});
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("list")
.description("List workflow instances; optionally filter by status")
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
.option("--json", "JSON output for agents")
.action(async (opts: { status?: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
if (opts.status) params.set("status", opts.status);
const qs = params.toString();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("show <instanceId>")
.description("Show one workflow instance")
.option("--json", "JSON output for agents")
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("trace <instanceId>")
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
.option("--json", "JSON output for agents")
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/trace`);
printResult(opts.json ?? false, res.data, res.status);
});
});
cmd
.command("continue <instanceId>")
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
.option("--json", "JSON output for agents")
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
printResult(opts.json ?? false, res.data, res.status);
});
});
return cmd;
}
@@ -0,0 +1,59 @@
import type { CrossHostResult } from "./cross-host-executor.js";
/**
* Shared CLI-side helpers for commands that gate on `--host`. Centralizes
* the structured failure formatting + JSON envelope so `send.ts` and
* `capture.ts` (and any future v1 cross-host command) format identically.
*/
export function emitCrossHostError(hostId: string, code: string, message: string, json?: boolean): void {
if (json) {
console.log(JSON.stringify({ ok: false, cross_host: { host: hostId }, failedStep: code, error: message }));
} else {
console.error(`cross-host (host=${hostId}): ${message}`);
}
process.exitCode = 1;
}
export function emitCrossHostFailure(
hostId: string,
target: string,
result: CrossHostResult,
json?: boolean,
): void {
if (result.ok) return;
const message = formatCrossHostFailure(hostId, target, result);
if (json) {
console.log(JSON.stringify({
ok: false,
cross_host: { host: hostId, target },
failedStep: result.failedStep,
error: message,
...(result.failedStep === "permission-gate" && result.hint ? { hint: result.hint } : {}),
}));
} else {
console.error(message);
}
process.exitCode = 1;
}
export function formatCrossHostFailure(
hostId: string,
target: string,
result: Extract<CrossHostResult, { ok: false }>,
): string {
switch (result.failedStep) {
case "ssh-unreachable":
return `ssh to host=${hostId} (target=${target}) failed: ${oneLine(result.sshStderr)}. Verify SSH access and host registry config.`;
case "permission-gate":
return `ssh to host=${hostId} (target=${target}) hit a permission/auth gate: ${oneLine(result.sshStderr)}. ${result.hint ?? ""}`.trim();
case "remote-daemon-unreachable":
return `remote rig command on host=${hostId} could not reach the remote daemon (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}. Start the daemon on the remote with 'ssh ${target} rig daemon start'.`;
case "remote-command-failed":
return `remote rig command on host=${hostId} failed (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}`;
}
}
function oneLine(s: string): string {
return s.split("\n").map((l) => l.trim()).filter(Boolean).join(" | ").slice(0, 400);
}
+190
View File
@@ -0,0 +1,190 @@
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import type { HostEntry } from "./host-registry.js";
/**
* Structured result of a cross-host command. Distinguishes SSH-layer failures
* (couldn't reach the remote shell) from remote-command failures (reached the
* remote shell, but the command itself returned non-zero). This distinction
* is load-bearing: collapsing both into "failed" hides the operational
* difference between "fix your SSH" and "fix your remote rig daemon."
*/
export type CrossHostResult =
| { ok: true; failedStep: "none"; stdout: string; stderr: string; remoteExitCode: 0 }
| { ok: false; failedStep: "ssh-unreachable"; sshStderr: string }
| { ok: false; failedStep: "permission-gate"; sshStderr: string; hint?: string }
| { ok: false; failedStep: "remote-daemon-unreachable"; stdout: string; stderr: string; remoteExitCode: number }
| { ok: false; failedStep: "remote-command-failed"; stdout: string; stderr: string; remoteExitCode: number };
export type FailedStep = CrossHostResult["failedStep"];
/**
* Pluggable spawn function for testing. The default uses Node's
* `child_process.spawn` directly. Tests inject a mock that returns a
* controllable process-like object.
*/
export type SpawnFn = (
command: string,
args: readonly string[],
) => ChildProcess;
export interface RunCrossHostCommandOpts {
/** Optional stdin to write to the spawned ssh process (forwarded to remote stdin). */
stdin?: string;
/** Inject a non-default spawn function (used in tests). */
spawn?: SpawnFn;
/** Connect timeout in seconds for the underlying ssh invocation. Default: 10. */
connectTimeoutSeconds?: number;
}
/**
* Run a remote rig command via single-hop ssh. Returns a structured result
* that maps each failure mode named in the dossier to a distinct
* `failedStep` value.
*
* Important: this function does NOT collapse remote `--verify` semantics into
* the SSH exit code. Callers that pass `--verify` get the remote command's
* stdout (containing `Verified: yes`/`no`) verbatim; the remote rig is
* authoritative on verification. SSH success (exit 0) just means "we reached
* the remote shell and it ran the command", not "the verify passed."
*/
export async function runCrossHostCommand(
host: HostEntry,
argv: readonly string[],
opts: RunCrossHostCommandOpts = {},
): Promise<CrossHostResult> {
if (host.transport !== "ssh") {
return {
ok: false,
failedStep: "ssh-unreachable",
sshStderr: `host '${host.id}': transport '${host.transport}' is not supported (v0 supports ssh only)`,
};
}
if (argv.length === 0) {
return {
ok: false,
failedStep: "ssh-unreachable",
sshStderr: "internal: empty argv passed to runCrossHostCommand",
};
}
const sshOpts: string[] = [];
if (host.user) {
sshOpts.push("-l", host.user);
}
const connectTimeout = opts.connectTimeoutSeconds ?? 10;
sshOpts.push("-o", `ConnectTimeout=${connectTimeout}`);
// No BatchMode — operators may legitimately use password / interactive auth.
// Stay deferential to ~/.ssh/config defaults.
const remoteCommandLine = argv.map(shellQuote).join(" ");
const fullArgs = [...sshOpts, host.target, remoteCommandLine];
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
const child = spawn("ssh", fullArgs);
if (opts.stdin !== undefined && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
} else if (child.stdin) {
child.stdin.end();
}
let stdout = "";
let stderr = "";
if (child.stdout) {
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
}
if (child.stderr) {
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
}
const exitCode: number = await new Promise((resolve) => {
child.on("close", (code: number | null) => resolve(code ?? -1));
child.on("error", (err: Error) => {
stderr += `\n[spawn error] ${err.message}`;
resolve(-1);
});
});
return classifyResult(exitCode, stdout, stderr);
}
/**
* Map the spawned ssh exit code + stderr signature to a structured result.
*
* - Exit 0 → success.
* - Exit 255 → ssh-layer failure. If stderr matches a known permission/auth
* gate (Permission denied, Keychain, host key verification), classify as
* `permission-gate` with a hint pointing at the field-note diagnostic.
* Otherwise classify as `ssh-unreachable`.
* - Any other non-zero → ssh succeeded but the remote rig command failed.
* If the remote stderr matches the daemon-not-running signature, classify
* as `remote-daemon-unreachable`; otherwise `remote-command-failed`.
*/
export function classifyResult(exitCode: number, stdout: string, stderr: string): CrossHostResult {
if (exitCode === 0) {
return { ok: true, failedStep: "none", stdout, stderr, remoteExitCode: 0 };
}
if (exitCode === 255 || exitCode === -1) {
if (looksLikePermissionGate(stderr)) {
return {
ok: false,
failedStep: "permission-gate",
sshStderr: stderr,
hint: "See openrig-work/field-notes/2026-04-29-l4-3-d6-claude-keychain-over-ssh-diagnostic.md for guidance on Keychain-over-SSH issues.",
};
}
return { ok: false, failedStep: "ssh-unreachable", sshStderr: stderr };
}
if (looksLikeDaemonUnreachable(stderr) || looksLikeDaemonUnreachable(stdout)) {
return {
ok: false,
failedStep: "remote-daemon-unreachable",
stdout,
stderr,
remoteExitCode: exitCode,
};
}
return {
ok: false,
failedStep: "remote-command-failed",
stdout,
stderr,
remoteExitCode: exitCode,
};
}
const PERMISSION_GATE_PATTERNS = [
/Permission denied/i,
/Host key verification failed/i,
/Keychain/i,
/Could not request authentication agent/i,
/no mutual signature algorithm/i,
];
function looksLikePermissionGate(stderr: string): boolean {
return PERMISSION_GATE_PATTERNS.some((re) => re.test(stderr));
}
const DAEMON_UNREACHABLE_PATTERNS = [
/Daemon not running/i,
/Failed to fetch .* from daemon/i,
/ECONNREFUSED.*localhost/i,
];
function looksLikeDaemonUnreachable(text: string): boolean {
return DAEMON_UNREACHABLE_PATTERNS.some((re) => re.test(text));
}
/**
* Single-quote shell escaping. Wraps the input in single quotes, escaping
* any embedded single quotes via `'\''`. Safe for passing as a single arg
* to a remote POSIX shell.
*/
export function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
+119 -18
View File
@@ -1,6 +1,7 @@
import path from "node:path";
import { existsSync } from "node:fs";
import type { ChildProcess } from "node:child_process";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
export interface DaemonState {
@@ -21,7 +22,7 @@ export interface DaemonStatus {
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
export function getDaemonUrl(status: DaemonStatus): string {
return `http://${status.host ?? "127.0.0.1"}:${status.port}`;
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
}
export interface StartOptions {
@@ -56,6 +57,7 @@ export const LOG_FILE = path.join(OPENRIG_DIR, "daemon.log");
export const LEGACY_STATE_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.json");
export const LEGACY_LOG_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.log");
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 7433;
const DEFAULT_DB = "openrig.sqlite";
const HEALTHZ_RETRIES = 20;
@@ -144,7 +146,7 @@ function resolveLifecycleFile(deps: LifecycleDeps, filename: "daemon.json" | "da
* - "dead" — PID not alive */
async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead"> {
if (!deps.isProcessAlive(state.pid)) return "dead";
const host = state.host ?? "127.0.0.1";
const host = state.host ?? DEFAULT_HOST;
try {
await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
// Any response (ok or not) means something is listening on our port → OpenRig
@@ -156,7 +158,70 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
}
}
function resolveConfiguredDaemonTarget(): { host: string; port: number } {
try {
const config = new ConfigStore().resolve();
return {
host: config.daemon.host,
port: config.daemon.port,
};
} catch {
return {
host: DEFAULT_HOST,
port: DEFAULT_PORT,
};
}
}
/**
* Scrub list: environment variable prefixes and exact names that should NOT
* be forwarded from the operator shell into the daemon process. These are
* terminal-emulator, GUI-session, and cmux-session variables that can break
* adapter initialization when the daemon runs detached.
*/
const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
const ENV_SCRUB_EXACT = new Set([
"CMUX_SOCKET_PATH",
"CMUX_SURFACE_ID",
"CMUX_WORKSPACE",
"COLORTERM",
"COMMAND_MODE",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"TERMINFO",
]);
export function buildDaemonEnv(
baseEnv: Record<string, string>,
opts: { port: number; host: string; db: string; transcriptsEnabled?: boolean; transcriptsPath?: string },
): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(baseEnv)) {
if (ENV_SCRUB_EXACT.has(key)) continue;
if (ENV_SCRUB_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
env[key] = value;
}
// Explicit OPENRIG_* overrides — always win over inherited values
env["OPENRIG_PORT"] = String(opts.port);
env["OPENRIG_HOST"] = opts.host;
env["OPENRIG_DB"] = opts.db;
if (opts.transcriptsEnabled !== undefined) {
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
}
if (opts.transcriptsPath) {
env["OPENRIG_TRANSCRIPTS_PATH"] = opts.transcriptsPath;
}
return env;
}
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? DEFAULT_HOST;
const db = opts.db ?? DEFAULT_DB;
// Check if already running
const existing = readState(deps);
if (existing) {
@@ -169,11 +234,20 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
throw new Error(`Existing daemon process (pid ${existing.pid} on port ${existing.port}) is unresponsive — recover it before starting a new daemon.`);
}
// "dead" or "not_rigged" → stale state, safe to proceed
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
throw new Error(`Daemon on port ${port} is unresponsive, and daemon state is missing — recover it before starting a new daemon.`);
}
}
if (recoveredRunning) {
throw new Error(`Daemon already running on port ${port}, but daemon state is missing`);
}
}
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? "127.0.0.1";
const db = opts.db ?? DEFAULT_DB;
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
deps.mkdirp(OPENRIG_DIR);
@@ -181,14 +255,13 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const logFd = deps.openForAppend(LOG_FILE);
const child = deps.spawn(process.execPath, [daemonEntry], {
env: {
...process.env as Record<string, string>,
OPENRIG_PORT: String(port),
OPENRIG_HOST: host,
OPENRIG_DB: db,
...(opts.transcriptsEnabled !== undefined ? { OPENRIG_TRANSCRIPTS_ENABLED: String(opts.transcriptsEnabled) } : {}),
...(opts.transcriptsPath ? { OPENRIG_TRANSCRIPTS_PATH: opts.transcriptsPath } : {}),
},
env: buildDaemonEnv(process.env as Record<string, string>, {
port,
host,
db,
transcriptsEnabled: opts.transcriptsEnabled,
transcriptsPath: opts.transcriptsPath,
}),
stdio: ["ignore", logFd, logFd],
detached: true,
});
@@ -233,7 +306,22 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
export async function stopDaemon(deps: LifecycleDeps): Promise<void> {
const state = readState(deps);
if (!state) return; // Not running — clean exit
if (!state) {
const configured = resolveConfiguredDaemonTarget();
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
throw new Error(`Daemon state is missing and port ${configured.port} is unresponsive — cannot stop safely.`);
}
}
if (recoveredRunning) {
throw new Error(`Daemon is running on port ${configured.port}, but daemon state is missing — cannot stop safely.`);
}
return;
}
const stateFile = resolveLifecycleFile(deps, "daemon.json");
const pidState = await checkPid(state, deps);
@@ -275,14 +363,27 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
try {
const res = await fetchDaemonProbe(deps, `${openrigUrl}/healthz`);
const url = new URL(openrigUrl);
return { state: "running", port: Number(url.port) || 7433, host: url.hostname || "127.0.0.1", healthy: res.ok };
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: res.ok };
} catch {
return { state: "stopped" };
}
}
const state = readState(deps);
if (!state) return { state: "stopped" };
if (!state) {
const configured = resolveConfiguredDaemonTarget();
try {
const res = await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
return {
state: "running",
port: configured.port,
host: configured.host,
healthy: res.ok,
};
} catch {
return { state: "stopped" };
}
}
if (!deps.isProcessAlive(state.pid)) {
// Process dead — stale state
@@ -291,7 +392,7 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
}
// Process alive — check healthz
const host = state.host ?? "127.0.0.1";
const host = state.host ?? DEFAULT_HOST;
let healthy = false;
try {
const res = await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
+294
View File
@@ -0,0 +1,294 @@
import { execFileSync } from "node:child_process";
import Database from "better-sqlite3";
export const DESTROY_CONFIRM_TOKEN = "destroy-openrig-state";
export type DestroyScope = "state" | "all";
export interface DestroyRuntimeConfig {
stateRoot: string;
dbPath: string;
transcriptsPath: string;
daemonHost: string;
daemonPort: number;
}
export interface DestroyTarget {
path: string;
kind: "state_root" | "db_file" | "db_shm" | "db_wal" | "transcripts_dir";
backupPath?: string;
}
export interface DestroyPlan {
scope: DestroyScope;
backup: boolean;
stateRoot: string;
daemonHost: string;
daemonPort: number;
managedTmuxSessions: string[];
targets: DestroyTarget[];
warnings: string[];
}
export interface DestroyResult {
scope: DestroyScope;
backup: boolean;
stateRoot: string;
backupPaths: string[];
daemonStopped: boolean;
portCleared: boolean;
stateRecreated: boolean;
tmuxKilled: number;
tmuxMissing: number;
warnings: string[];
}
export interface ListenerInspection {
kind: "openrig" | "other_http" | "unreachable";
healthy?: boolean;
detail?: string;
}
export interface DestroyDeps {
stopDaemon: () => Promise<void>;
inspectListener: (host: string, port: number) => Promise<ListenerInspection>;
findListeningPid: (port: number) => number | null;
killProcess: (pid: number) => void;
exists: (path: string) => boolean;
renamePath: (from: string, to: string) => void;
removePath: (path: string) => void;
mkdirp: (path: string) => void;
listManagedTmuxSessions: (dbPath: string) => string[];
killTmuxSession: (sessionName: string) => boolean;
sleep: (ms: number) => Promise<void>;
now: () => Date;
}
function normalizePathForPrefix(path: string): string {
return path.endsWith("/") ? path : `${path}/`;
}
export function isWithinPath(candidate: string, root: string): boolean {
if (candidate === root) return true;
return candidate.startsWith(normalizePathForPrefix(root));
}
function formatTimestamp(date: Date): string {
const yyyy = String(date.getFullYear());
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
const ss = String(date.getSeconds()).padStart(2, "0");
return `${yyyy}${mm}${dd}-${hh}${min}${ss}`;
}
export function buildBackupPath(targetPath: string, exists: (path: string) => boolean, now = new Date()): string {
const stamp = formatTimestamp(now);
const base = `${targetPath}.backup-${stamp}`;
if (!exists(base)) return base;
let suffix = 2;
while (exists(`${base}-${suffix}`)) suffix += 1;
return `${base}-${suffix}`;
}
export function listManagedTmuxSessionsFromDb(dbPath: string): string[] {
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const rows = db.prepare(`
SELECT DISTINCT
COALESCE(
b.tmux_session,
(
SELECT s2.session_name
FROM sessions s2
WHERE s2.node_id = n.id
ORDER BY datetime(s2.created_at) DESC, s2.rowid DESC
LIMIT 1
)
) AS session_name
FROM nodes n
LEFT JOIN bindings b ON b.node_id = n.id
WHERE COALESCE(b.attachment_type, 'tmux') = 'tmux'
`).all() as Array<{ session_name: string | null }>;
return rows
.map((row) => row.session_name?.trim() ?? "")
.filter((name): name is string => Boolean(name))
.sort();
} finally {
db.close();
}
}
export function buildDestroyPlan(
scope: DestroyScope,
backup: boolean,
config: DestroyRuntimeConfig,
deps: Pick<DestroyDeps, "exists" | "listManagedTmuxSessions" | "now">
): DestroyPlan {
const warnings: string[] = [];
const targets: DestroyTarget[] = [];
const stateRootExists = deps.exists(config.stateRoot);
targets.push({
path: config.stateRoot,
kind: "state_root",
...(backup && stateRootExists ? { backupPath: buildBackupPath(config.stateRoot, deps.exists, deps.now()) } : {}),
});
const addExternalTarget = (path: string, kind: DestroyTarget["kind"]) => {
if (!deps.exists(path)) return;
if (isWithinPath(path, config.stateRoot)) return;
targets.push({
path,
kind,
...(backup ? { backupPath: buildBackupPath(path, deps.exists, deps.now()) } : {}),
});
warnings.push(`${kind} is outside state root and will be ${backup ? "backed up separately" : "deleted separately"}: ${path}`);
};
addExternalTarget(config.dbPath, "db_file");
addExternalTarget(`${config.dbPath}-shm`, "db_shm");
addExternalTarget(`${config.dbPath}-wal`, "db_wal");
addExternalTarget(config.transcriptsPath, "transcripts_dir");
let managedTmuxSessions: string[] = [];
if (scope === "all" && deps.exists(config.dbPath)) {
try {
managedTmuxSessions = deps.listManagedTmuxSessions(config.dbPath);
} catch (err) {
warnings.push(`Failed to enumerate managed tmux sessions from ${config.dbPath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return {
scope,
backup,
stateRoot: config.stateRoot,
daemonHost: config.daemonHost,
daemonPort: config.daemonPort,
managedTmuxSessions,
targets,
warnings,
};
}
function isLocalHost(host: string): boolean {
return host === "127.0.0.1" || host === "localhost" || host === "0.0.0.0";
}
export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Promise<DestroyResult> {
const warnings = [...plan.warnings];
const backupPaths: string[] = [];
let stateRecreated = false;
try {
await deps.stopDaemon();
} catch (err) {
warnings.push(`stopDaemon failed: ${err instanceof Error ? err.message : String(err)}`);
}
let daemonStopped = false;
let portCleared = false;
if (isLocalHost(plan.daemonHost)) {
const before = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
if (before.kind === "openrig") {
const listenerPid = deps.findListeningPid(plan.daemonPort);
if (listenerPid !== null) {
try {
deps.killProcess(listenerPid);
} catch (err) {
warnings.push(`Failed to terminate listener pid ${listenerPid}: ${err instanceof Error ? err.message : String(err)}`);
}
for (let attempt = 0; attempt < 20; attempt += 1) {
if ((await deps.inspectListener(plan.daemonHost, plan.daemonPort)).kind === "unreachable") break;
await deps.sleep(100);
}
} else {
warnings.push(`OpenRig responded on port ${plan.daemonPort}, but no listener pid was found.`);
}
} else if (before.kind === "other_http") {
warnings.push(`Port ${plan.daemonPort} is occupied by a live non-OpenRig HTTP listener. State was left untouched.`);
}
const after = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
portCleared = after.kind === "unreachable";
daemonStopped = portCleared;
} else {
warnings.push(`Daemon host ${plan.daemonHost} is not local. rig destroy only operates on local daemon targets; state was left untouched.`);
}
if (!portCleared) {
return {
scope: plan.scope,
backup: plan.backup,
stateRoot: plan.stateRoot,
backupPaths,
daemonStopped,
portCleared,
stateRecreated,
tmuxKilled: 0,
tmuxMissing: 0,
warnings,
};
}
let tmuxKilled = 0;
let tmuxMissing = 0;
if (plan.scope === "all") {
for (const sessionName of plan.managedTmuxSessions) {
if (deps.killTmuxSession(sessionName)) tmuxKilled += 1;
else tmuxMissing += 1;
}
}
for (const target of plan.targets) {
if (!deps.exists(target.path)) continue;
if (plan.backup && target.backupPath) {
deps.renamePath(target.path, target.backupPath);
backupPaths.push(target.backupPath);
} else {
deps.removePath(target.path);
}
}
deps.mkdirp(plan.stateRoot);
stateRecreated = true;
return {
scope: plan.scope,
backup: plan.backup,
stateRoot: plan.stateRoot,
backupPaths,
daemonStopped,
portCleared,
stateRecreated,
tmuxKilled,
tmuxMissing,
warnings,
};
}
export function findListeningPidWithLsof(port: number): number | null {
try {
const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { encoding: "utf-8" }).trim();
if (!output) return null;
const first = output.split(/\s+/)[0]?.trim();
if (!first) return null;
const pid = parseInt(first, 10);
return Number.isNaN(pid) ? null : pid;
} catch {
return null;
}
}
export function killTmuxSessionWithCli(sessionName: string): boolean {
try {
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
+142
View File
@@ -0,0 +1,142 @@
import { existsSync, readFileSync } from "node:fs";
import { parse as parseYaml } from "yaml";
import { getDefaultOpenRigPath } from "./openrig-compat.js";
/**
* A single declared host in `~/.openrig/hosts.yaml`. v0 supports `transport: ssh`
* only. `target` is whatever ssh resolves (DNS, SSH config alias, IP, etc.).
*/
export interface HostEntry {
id: string;
transport: "ssh";
target: string;
user?: string;
notes?: string;
}
export interface HostRegistry {
hosts: HostEntry[];
}
export type HostRegistryLoadResult =
| { ok: true; registry: HostRegistry }
| { ok: false; error: string };
export type HostResolution =
| { ok: true; host: HostEntry }
| { ok: false; error: string };
const KNOWN_TRANSPORTS = new Set(["ssh"]);
export function defaultHostRegistryPath(): string {
return getDefaultOpenRigPath("hosts.yaml");
}
/**
* Load and validate the host registry from disk. v0 file shape:
*
* hosts:
* - id: vm-claude-test
* transport: ssh
* target: vm-claude-test.local
* user: wrandom # optional
* notes: "Tart VM" # optional
*
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
* file returns a clear error pointing at the canonical path.
*/
export function loadHostRegistry(path: string = defaultHostRegistryPath()): HostRegistryLoadResult {
if (!existsSync(path)) {
return {
ok: false,
error: `host registry not found at ${path}. Create it with a 'hosts:' array of { id, transport, target, [user] } entries; v0 supports transport: ssh only.`,
};
}
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err) {
return { ok: false, error: `failed to read host registry at ${path}: ${(err as Error).message}` };
}
let parsed: unknown;
try {
parsed = parseYaml(raw);
} catch (err) {
return { ok: false, error: `failed to parse host registry YAML at ${path}: ${(err as Error).message}` };
}
return validateHostRegistry(parsed, path);
}
export function validateHostRegistry(parsed: unknown, sourcePath: string): HostRegistryLoadResult {
if (parsed === null || typeof parsed !== "object") {
return { ok: false, error: `host registry at ${sourcePath} must be a YAML object with a 'hosts' array` };
}
const obj = parsed as Record<string, unknown>;
const hosts = obj["hosts"];
if (!Array.isArray(hosts)) {
return { ok: false, error: `host registry at ${sourcePath}: 'hosts' must be an array` };
}
const seenIds = new Set<string>();
const validated: HostEntry[] = [];
for (let i = 0; i < hosts.length; i++) {
const raw = hosts[i];
const prefix = `host registry at ${sourcePath}: hosts[${i}]`;
if (raw === null || typeof raw !== "object") {
return { ok: false, error: `${prefix}: must be an object with id/transport/target` };
}
const entry = raw as Record<string, unknown>;
const id = entry["id"];
if (typeof id !== "string" || id.trim() === "") {
return { ok: false, error: `${prefix}.id: required non-empty string` };
}
if (seenIds.has(id)) {
return { ok: false, error: `${prefix}.id: duplicate host id '${id}' (each host id must be unique within the registry)` };
}
seenIds.add(id);
const transport = entry["transport"];
if (transport !== "ssh") {
return {
ok: false,
error: `${prefix}.transport: v0 supports 'ssh' only (got ${JSON.stringify(transport)}); declared known transports: ${[...KNOWN_TRANSPORTS].sort().join(", ")}`,
};
}
const target = entry["target"];
if (typeof target !== "string" || target.trim() === "") {
return { ok: false, error: `${prefix}.target: required non-empty string (an ssh target — DNS name, SSH config alias, or IP)` };
}
const user = entry["user"];
if (user !== undefined && (typeof user !== "string" || user.trim() === "")) {
return { ok: false, error: `${prefix}.user: optional, but if present must be a non-empty string` };
}
const notes = entry["notes"];
if (notes !== undefined && typeof notes !== "string") {
return { ok: false, error: `${prefix}.notes: optional, but if present must be a string` };
}
validated.push({
id,
transport: "ssh",
target,
...(user !== undefined ? { user: user as string } : {}),
...(notes !== undefined ? { notes: notes as string } : {}),
});
}
return { ok: true, registry: { hosts: validated } };
}
/**
* Resolve a host id against a loaded registry. Unknown id returns an error
* naming the requested id and listing up to 10 known ids for discoverability.
*/
export function resolveHost(registry: HostRegistry, id: string): HostResolution {
const match = registry.hosts.find((h) => h.id === id);
if (match) return { ok: true, host: match };
const knownIds = registry.hosts.map((h) => h.id).slice(0, 10);
const idsHint = knownIds.length > 0
? ` Known host ids: ${knownIds.join(", ")}${registry.hosts.length > knownIds.length ? ` (+${registry.hosts.length - knownIds.length} more)` : ""}.`
: " (registry is empty)";
return {
ok: false,
error: `unknown host id '${id}'.${idsHint}`,
};
}
+42
View File
@@ -25,12 +25,19 @@ import { agentCommand, type AgentDeps } from "./commands/agent.js";
import { rigCommand, type RigDeps } from "./commands/rig.js";
import { transcriptCommand } from "./commands/transcript.js";
import { sendCommand } from "./commands/send.js";
import { streamCommand, type StreamDeps } from "./commands/stream.js";
import { queueCommand, type QueueDeps } from "./commands/queue.js";
import { projectCommand, type ProjectDeps } from "./commands/project.js";
import { viewCommand, type ViewDeps } from "./commands/view.js";
import { watchdogCommand, type WatchdogDeps } from "./commands/watchdog.js";
import { workflowCommand, type WorkflowDeps } from "./commands/workflow.js";
import { captureCommand } from "./commands/capture.js";
import { broadcastCommand } from "./commands/broadcast.js";
import { configCommand } from "./commands/config.js";
import { preflightCommand } from "./commands/preflight.js";
import { doctorCommand } from "./commands/doctor.js";
import { expandCommand } from "./commands/expand.js";
import { envCommand } from "./commands/env.js";
import { askCommand } from "./commands/ask.js";
import { chatroomCommand } from "./commands/chatroom.js";
import { specsCommand } from "./commands/specs.js";
@@ -40,6 +47,14 @@ import { releaseCommand } from "./commands/release.js";
import { launchCommand } from "./commands/launch.js";
import { removeCommand } from "./commands/remove.js";
import { shrinkCommand } from "./commands/shrink.js";
import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
import { setupCommand } from "./commands/setup.js";
import { contextCommand } from "./commands/context.js";
import { restoreCheckCommand } from "./commands/restore-check.js";
import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js";
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
import { seatCommand, type SeatDeps } from "./commands/seat.js";
import type { LifecycleDeps } from "./daemon-lifecycle.js";
import { CLI_VERSION } from "./version.js";
@@ -67,6 +82,12 @@ export interface ProgramDeps {
rigDeps?: RigDeps;
transcriptDeps?: StatusDeps;
sendDeps?: StatusDeps;
streamDeps?: StreamDeps;
queueDeps?: QueueDeps;
projectDeps?: ProjectDeps;
viewDeps?: ViewDeps;
watchdogDeps?: WatchdogDeps;
workflowDeps?: WorkflowDeps;
captureDeps?: StatusDeps;
broadcastDeps?: StatusDeps;
askDeps?: StatusDeps;
@@ -74,11 +95,17 @@ export interface ProgramDeps {
specsDeps?: StatusDeps;
whoamiDeps?: StatusDeps;
expandDeps?: StatusDeps;
envDeps?: StatusDeps;
unclaimDeps?: StatusDeps;
releaseDeps?: StatusDeps;
launchDeps?: StatusDeps;
removeDeps?: StatusDeps;
shrinkDeps?: StatusDeps;
destroyDeps?: DestroyCommandDeps;
restorePacketDeps?: RestorePacketDeps;
compactPlanDeps?: CompactPlanDeps;
heartbeatDeps?: HeartbeatDeps;
seatDeps?: SeatDeps;
configPath?: string;
}
@@ -113,6 +140,12 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(rigCommand(depsOverride?.rigDeps));
program.addCommand(transcriptCommand(depsOverride?.transcriptDeps));
program.addCommand(sendCommand(depsOverride?.sendDeps));
program.addCommand(streamCommand(depsOverride?.streamDeps));
program.addCommand(queueCommand(depsOverride?.queueDeps));
program.addCommand(projectCommand(depsOverride?.projectDeps));
program.addCommand(viewCommand(depsOverride?.viewDeps));
program.addCommand(watchdogCommand(depsOverride?.watchdogDeps));
program.addCommand(workflowCommand(depsOverride?.workflowDeps));
program.addCommand(captureCommand(depsOverride?.captureDeps));
program.addCommand(broadcastCommand(depsOverride?.broadcastDeps));
program.addCommand(askCommand(depsOverride?.askDeps));
@@ -123,11 +156,20 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(preflightCommand());
program.addCommand(doctorCommand());
program.addCommand(expandCommand(depsOverride?.expandDeps));
program.addCommand(envCommand(depsOverride?.envDeps));
program.addCommand(unclaimCommand(depsOverride?.unclaimDeps));
program.addCommand(releaseCommand(depsOverride?.releaseDeps));
program.addCommand(launchCommand(depsOverride?.launchDeps));
program.addCommand(removeCommand(depsOverride?.removeDeps));
program.addCommand(shrinkCommand(depsOverride?.shrinkDeps));
program.addCommand(destroyCommand(depsOverride?.destroyDeps));
program.addCommand(setupCommand());
program.addCommand(contextCommand());
program.addCommand(restoreCheckCommand());
program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps));
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
program.addCommand(seatCommand(depsOverride?.seatDeps));
return program;
}
+29 -3
View File
@@ -3,6 +3,9 @@ import { z } from "zod";
import type { DaemonClient, DaemonResponse } from "./client.js";
import { CLI_VERSION } from "./version.js";
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
type TextResult = {
content: Array<{ type: "text"; text: string }>;
isError?: true;
@@ -64,7 +67,11 @@ export function createMcpServer(client: DaemonClient): McpServer {
},
async ({ sourceRef, plan, autoApprove, targetRoot }) => {
try {
const res = await client.post("/api/up", { sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot });
const res = await client.post(
"/api/up",
{ sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot },
plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
);
return mapResult(res);
} catch (err) {
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
@@ -302,10 +309,24 @@ export function createMcpServer(client: DaemonClient): McpServer {
text: z.string().describe("Message text to send"),
verify: z.boolean().optional().describe("Verify delivery by checking pane content"),
force: z.boolean().optional().describe("Send even if target appears mid-task"),
waitForIdleSeconds: z.number().positive().optional().describe("Wait until explicit idle evidence before sending"),
},
async ({ session, text, verify, force }) => {
async ({ session, text, verify, force, waitForIdleSeconds }) => {
try {
const res = await client.post("/api/transport/send", { session, text, verify, force });
if (force && waitForIdleSeconds !== undefined) {
return {
content: [{ type: "text", text: JSON.stringify({ ok: false, error: "waitForIdleSeconds cannot be combined with force" }) }],
isError: true,
};
}
const waitForIdleMs = waitForIdleSeconds === undefined ? undefined : Math.ceil(waitForIdleSeconds * 1000);
const res = await client.post("/api/transport/send", {
session,
text,
verify,
force,
waitForIdleMs,
}, waitForIdleRequestOptions(waitForIdleMs));
return mapResult(res);
} catch (err) {
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
@@ -403,3 +424,8 @@ export function createMcpServer(client: DaemonClient): McpServer {
return server;
}
function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeoutMs: number } | undefined {
if (waitForIdleMs === undefined) return undefined;
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
}
+4 -2
View File
@@ -2,7 +2,11 @@ import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const warnedKeys = new Set<string>();
export function getOpenRigHome(): string {
const configured = readOpenRigEnv("OPENRIG_HOME", "RIGGED_HOME");
if (configured !== undefined) return configured;
return join(homedir(), ".openrig");
}
@@ -13,8 +17,6 @@ export function getLegacyRiggedHome(): string {
export const OPENRIG_HOME = getOpenRigHome();
export const LEGACY_RIGGED_HOME = getLegacyRiggedHome();
const warnedKeys = new Set<string>();
function warnOnce(key: string, message: string): void {
if (warnedKeys.has(key)) return;
warnedKeys.add(key);
@@ -0,0 +1,280 @@
// claude-transcript-parser.ts — parses Claude transcript JSONL into the
// same StructuredTranscript shape as the Codex parser.
//
// NEW work for the Restore-Packet vertical M2b. No prior art. Inspected
// host's Claude transcript files at ~/.claude/projects/<project>/<id>.jsonl.
//
// Claude JSONL semantics (observed):
// - First few lines may carry meta records: { type, customTitle, sessionId }
// or { type, agentName, sessionId } or { type, permissionMode, sessionId }.
// - Message records have type: "user" | "assistant" with fields:
// { type, message, cwd, timestamp, parentUuid, sessionId, ... }.
// `message` is an object whose shape is provider-API-specific:
// - user: { role: "user", content: string | array }
// - assistant: { role: "assistant", content: array of { type: "text", text } }
// - Attachment records ({ type: "attachment", attachment, ... }) carry tool
// input/output content; mapped to raw_tool_outputs by the omitted-records
// classifier and skipped from the message stream.
// - Other types ({ type: "summary" }, sessionId-only meta records, etc.)
// are mapped to reasoning_records.
import { redact, hasSecretPattern } from "./redaction.js";
import { classifyClaudeRecord, OmittedCounter } from "./omitted-records.js";
import type {
ExtractedMessage,
PathCount,
StructuredTranscript,
TypeCounts,
SessionMeta,
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
function extractPaths(text: string, counts: Map<string, number>): void {
for (const pattern of PATH_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const cleaned = match[0].replace(/[),.;\]"'`]+$/g, "");
if (cleaned.length < 6) continue;
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
}
}
}
function firstLine(text: string): string {
const line = text.split(/\r?\n/).find((l) => l.trim());
if (!line) return "";
return line.trim().slice(0, 180);
}
/**
* Result of walking a Claude `message.content` field. Returns:
* - `text`: concatenated user-visible text from `{ type: "text" }` parts.
* - `toolUseCount`: number of `{ type: "tool_use" }` parts seen.
* These are nested function-call records inside an assistant turn;
* per M1 contract § 5 they map to `function_call_output`.
* - `toolResultCount`: number of `{ type: "tool_result" }` parts seen.
* These are nested tool-output records inside a user turn; per
* contract § 5 they map to `raw_tool_outputs`.
* - `toolPaths`: paths extracted from omitted tool_use inputs and
* tool_result content (mirrors `codex-jsonl-parser.ts`'s tool-call
* path-inventory behavior; the tool record itself is NOT in the
* message text but its file references stay in the touched-files
* inventory).
*
* R2 fix (per M2b R2 dispatch qitem-20260502011841-fb53d7fd): inspect
* nested content parts BEFORE discarding non-text. Previously dropped
* tool_use / tool_result silently, leading to misleading all-zero
* omittedCounts on real Claude transcripts where nearly every assistant
* turn has tool_use and most user turns have tool_result.
*/
interface ContentWalkResult {
text: string;
toolUseCount: number;
toolResultCount: number;
toolPaths: string[];
}
/**
* Extract `text` candidates from a tool_result `content` field. The
* Claude API allows two shapes for `tool_result.content`:
* - string (the most common; e.g., a command's stdout): use directly.
* - array of part objects (when the tool returned structured content):
* collect each part's `text` field.
* Returns a flat string suitable for path extraction.
*/
function toolResultText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
parts.push(part);
} else if (part && typeof part === "object") {
const p = part as Record<string, unknown>;
if (p.type === "text" && typeof p.text === "string") {
parts.push(p.text);
}
}
}
return parts.join("\n");
}
function walkClaudeContent(message: unknown): ContentWalkResult {
const out: ContentWalkResult = { text: "", toolUseCount: 0, toolResultCount: 0, toolPaths: [] };
if (!message || typeof message !== "object") return out;
const obj = message as Record<string, unknown>;
const content = obj.content;
if (typeof content === "string") {
out.text = content;
return out;
}
if (!Array.isArray(content)) return out;
const textParts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
textParts.push(part);
continue;
}
if (!part || typeof part !== "object") continue;
const p = part as Record<string, unknown>;
const partType = typeof p.type === "string" ? p.type : "";
if (partType === "text" && typeof p.text === "string") {
textParts.push(p.text);
continue;
}
if (partType === "tool_use") {
out.toolUseCount += 1;
// Extract paths from the tool_use input. Mirrors the Codex parser's
// tool-call path-inventory behavior at codex-jsonl-parser.ts.
const input = p.input;
const inputStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
out.toolPaths.push(inputStr);
continue;
}
if (partType === "tool_result") {
out.toolResultCount += 1;
// tool_result.content can be string OR array; both extract a flat
// text body for path scanning.
const resultText = toolResultText(p.content);
out.toolPaths.push(resultText);
continue;
}
// Other part types (image, etc.) are not user-visible text and not
// counted in the M1 contract enums; skip without counting.
}
out.text = textParts.filter((s) => s.length > 0).join("\n");
return out;
}
interface ClaudeRecord {
type?: unknown;
timestamp?: unknown;
message?: unknown;
cwd?: unknown;
sessionId?: unknown;
parentUuid?: unknown;
attachment?: unknown;
[key: string]: unknown;
}
/**
* Parse a Claude transcript JSONL string into a StructuredTranscript.
* Pure function; no I/O.
*
* Output is the SAME shape as the Codex parser per the M2b dispatch's
* "emit the same structured representation as the Codex parser"
* requirement. Downstream modules (packet-writer, redaction-counter)
* see no runtime difference.
*/
export function parseClaudeTranscript(content: string): StructuredTranscript {
const messages: ExtractedMessage[] = [];
const pathCounts = new Map<string, number>();
const typeCounts: TypeCounts = {};
const omittedCounter = new OmittedCounter();
let sessionMeta: SessionMeta | null = null;
let lineCount = 0;
let messageCount = 0;
// Claude transcripts don't have "compacted" records; field stays at 0.
for (const rawLine of content.split("\n")) {
if (!rawLine.trim()) continue;
lineCount++;
let record: ClaudeRecord;
try {
record = JSON.parse(rawLine) as ClaudeRecord;
} catch {
continue;
}
const recordType = typeof record.type === "string" ? record.type : "";
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
// Capture sessionMeta from the first record that carries cwd + sessionId.
if (
sessionMeta === null &&
typeof record.cwd === "string" &&
typeof record.sessionId === "string"
) {
sessionMeta = {
cwd: record.cwd,
sessionId: record.sessionId,
};
}
const classification = classifyClaudeRecord(record);
if (classification.kind === "omitted") {
omittedCounter.recordOmission(classification.reason);
// For attachment records, walk the attachment payload for paths
// (mirrors the Codex parser's tool-call path-extraction behavior).
if (recordType === "attachment" && record.attachment) {
const attachStr = typeof record.attachment === "string"
? record.attachment
: JSON.stringify(record.attachment);
extractPaths(attachStr, pathCounts);
}
continue;
}
// Kept message.
const role: "user" | "assistant" = recordType === "user" ? "user" : "assistant";
const walked = walkClaudeContent(record.message);
// R2 fix: count nested tool_use / tool_result parts BEFORE deciding
// whether to emit a visible-text message. These counters fire
// regardless of whether the record contributes a message to the
// visible transcript — capturing them here is what M1 contract § 5
// requires for an honest `omitted_classes` field downstream.
for (let i = 0; i < walked.toolUseCount; i++) {
omittedCounter.recordOmission("function_call_output");
}
for (let i = 0; i < walked.toolResultCount; i++) {
omittedCounter.recordOmission("raw_tool_outputs");
}
// Path extraction from omitted tool parts (mirrors Codex parser
// behavior of walking tool-call args / inputs for path inventory
// even though the records themselves are omitted from messages).
for (const toolText of walked.toolPaths) {
extractPaths(toolText, pathCounts);
}
if (hasSecretPattern(walked.text)) {
omittedCounter.recordRedaction();
}
const text = redact(walked.text).trim();
if (!text) continue;
messageCount++;
extractPaths(text, pathCounts);
messages.push({
timestamp: typeof record.timestamp === "string" ? record.timestamp : null,
role,
text,
preview: firstLine(text),
});
}
const paths: PathCount[] = [...pathCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 200)
.map(([path, count]) => ({ path, count }));
return {
sessionMeta,
lineCount,
messageCount,
compactedCount: 0,
typeCounts,
omittedCounts: omittedCounter.counts,
messages,
paths,
};
}
@@ -0,0 +1,186 @@
// codex-jsonl-parser.ts — parses Codex JSONL into a StructuredTranscript.
//
// Translates the Velocity prior art parser at
// field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/
// codex-jsonl-to-restore-packet.mjs:81-160 from JS to TypeScript.
// Velocity .mjs is FROZEN reference; this module borrows the structural
// shape only.
//
// Codex JSONL semantics (from Velocity prior art):
// - record.type === "session_meta" → session metadata (cwd, etc.)
// - record.type === "compacted" → compaction marker (counter only)
// - record.type === "response_item" + payload.type:
// - "function_call" → omitted (function_call_output class); extract paths from args
// - "custom_tool_call" → omitted (raw_tool_outputs class); extract paths from input
// - "reasoning" → omitted (reasoning_records class)
// - "message" → kept (after redaction); extract paths from content
//
// Output is a StructuredTranscript (per types.ts) consumed by the
// packet-writer (M2c).
import { redact, hasSecretPattern } from "./redaction.js";
import { classifyCodexRecord, OmittedCounter } from "./omitted-records.js";
import type {
ExtractedMessage,
PathCount,
StructuredTranscript,
TypeCounts,
SessionMeta,
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
function textFromContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const obj = part as Record<string, unknown>;
const val = obj.text ?? obj.content ?? obj.output ?? obj.input ?? "";
return typeof val === "string" ? val : "";
})
.filter((s) => s.length > 0)
.join("\n");
}
function extractPaths(text: string, counts: Map<string, number>): void {
for (const pattern of PATH_PATTERNS) {
for (const match of text.matchAll(pattern)) {
const cleaned = match[0].replace(/[),.;\]"'`]+$/g, "");
if (cleaned.length < 6) continue;
counts.set(cleaned, (counts.get(cleaned) ?? 0) + 1);
}
}
}
function firstLine(text: string): string {
const line = text.split(/\r?\n/).find((l) => l.trim());
if (!line) return "";
return line.trim().slice(0, 180);
}
interface CodexRecord {
type?: unknown;
timestamp?: unknown;
payload?: {
type?: unknown;
role?: unknown;
content?: unknown;
arguments?: unknown;
input?: unknown;
cwd?: unknown;
[key: string]: unknown;
};
}
/**
* Parse a Codex JSONL string (the full file content as a single string)
* into a StructuredTranscript. Pure function; no I/O.
*
* Malformed lines are skipped silently (matches Velocity prior art
* `:100-102`); the lineCount counts every non-empty input line, so the
* count of skipped lines is `lineCount - <kept-or-typed records>`.
*/
export function parseCodexJsonl(content: string): StructuredTranscript {
const messages: ExtractedMessage[] = [];
const pathCounts = new Map<string, number>();
const typeCounts: TypeCounts = {};
const omittedCounter = new OmittedCounter();
let sessionMeta: SessionMeta | null = null;
let lineCount = 0;
let messageCount = 0;
let compactedCount = 0;
for (const rawLine of content.split("\n")) {
if (!rawLine.trim()) continue;
lineCount++;
let record: CodexRecord;
try {
record = JSON.parse(rawLine) as CodexRecord;
} catch {
continue;
}
const recordType = typeof record.type === "string" ? record.type : "";
typeCounts[recordType] = (typeCounts[recordType] ?? 0) + 1;
if (recordType === "session_meta") {
const payload = (record.payload ?? null) as Record<string, unknown> | null;
sessionMeta = {
cwd: typeof payload?.cwd === "string" ? payload.cwd : null,
sessionId: typeof payload?.id === "string" ? payload.id : null,
raw: payload ?? undefined,
};
continue;
}
if (recordType === "compacted") {
compactedCount++;
continue;
}
if (recordType !== "response_item") continue;
const classification = classifyCodexRecord(record);
if (classification.kind === "omitted") {
omittedCounter.recordOmission(classification.reason);
// Still extract paths from tool calls (matches Velocity behavior at
// :119-129 where args/input are walked for paths even though the
// record itself is omitted from messages).
const payload = record.payload;
if (payload?.type === "function_call") {
const args = typeof payload.arguments === "string" ? payload.arguments : JSON.stringify(payload.arguments ?? {});
extractPaths(args, pathCounts);
} else if (payload?.type === "custom_tool_call") {
const input = typeof payload.input === "string" ? payload.input : JSON.stringify(payload.input ?? {});
extractPaths(input, pathCounts);
}
continue;
}
// Kept message.
const role = record.payload?.role;
const roleStr = typeof role === "string" ? role : "unknown";
if (roleStr !== "developer" && roleStr !== "user" && roleStr !== "assistant") {
continue;
}
const rawText = textFromContent(record.payload?.content);
if (hasSecretPattern(rawText)) {
omittedCounter.recordRedaction();
}
const text = redact(rawText).trim();
if (!text) continue;
messageCount++;
extractPaths(text, pathCounts);
messages.push({
timestamp: typeof record.timestamp === "string" ? record.timestamp : null,
role: roleStr,
text,
preview: firstLine(text),
});
}
const paths: PathCount[] = [...pathCounts.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 200)
.map(([path, count]) => ({ path, count }));
return {
sessionMeta,
lineCount,
messageCount,
compactedCount,
typeCounts,
omittedCounts: omittedCounter.counts,
messages,
paths,
};
}
@@ -0,0 +1,128 @@
// omitted-records.ts — classifies a parsed source record into one of the
// 4 omitted-class enums (per M1 contract § 5) or accepts it as a kept
// user-visible message.
//
// v0 generator excludes the following record classes from
// transcript-latest.md and transcript.md:
//
// - reasoning_records: internal reasoning records that the runtime emitted
// but were not user-visible messages.
// - raw_tool_outputs: full raw tool execution output (often verbose; may
// contain transient state).
// - function_call_output: function-call result records that are not
// user-visible.
// - redacted_secrets: applied to any matched credential patterns per the
// redaction policy. (This class counts records whose content matched
// a secret pattern AND was therefore redacted.)
//
// `omitted_classes` in the summary enumerates the classes ACTUALLY
// excluded for THIS packet (not all available classes); the validator
// checks each name is one of the 4 enum values.
import { emptyOmittedCounts, type OmittedCounts, type OmittedRecordClass } from "./types.js";
import { hasSecretPattern } from "./redaction.js";
/** Source-runtime-agnostic record classifier output. */
export type ClassifyResult =
| { kind: "kept" }
| { kind: "omitted"; reason: OmittedRecordClass };
/**
* Classify a Codex JSONL record (the parsed JSON object) into kept /
* omitted. Caller is responsible for passing only `response_item`-class
* records; session_meta and compacted are handled separately by the
* Codex parser.
*
* Codex semantics (per Velocity prior art):
* - response_item with payload.type === "function_call" → function_call_output
* - response_item with payload.type === "custom_tool_call" → raw_tool_outputs
* - response_item with payload.type === "reasoning" → reasoning_records
* - response_item with payload.type === "message" + role in {developer,user,assistant}
* → kept (after secret-pattern check)
* - everything else → reasoning_records (catch-all for non-message non-tool
* payload types)
*/
export function classifyCodexRecord(record: { payload?: { type?: unknown; role?: unknown } }): ClassifyResult {
const payloadType = typeof record.payload?.type === "string" ? record.payload.type : "";
switch (payloadType) {
case "function_call":
return { kind: "omitted", reason: "function_call_output" };
case "custom_tool_call":
return { kind: "omitted", reason: "raw_tool_outputs" };
case "reasoning":
return { kind: "omitted", reason: "reasoning_records" };
case "message": {
const role = typeof record.payload?.role === "string" ? record.payload.role : "";
if (role !== "developer" && role !== "user" && role !== "assistant") {
return { kind: "omitted", reason: "reasoning_records" };
}
return { kind: "kept" };
}
default:
return { kind: "omitted", reason: "reasoning_records" };
}
}
/**
* Classify a Claude transcript record. Claude shape carries the role
* directly at `record.type` (user / assistant / system / attachment /
* summary etc). Per the M2b dispatch's "emit the same structured
* representation as the Codex parser" requirement, the omitted-class
* mapping mirrors Codex.
*/
export function classifyClaudeRecord(record: { type?: unknown }): ClassifyResult {
const type = typeof record.type === "string" ? record.type : "";
switch (type) {
case "user":
case "assistant":
return { kind: "kept" };
case "attachment":
// Attachments are tool-result-class records (file content the user
// attached or tool output). Map to raw_tool_outputs.
return { kind: "omitted", reason: "raw_tool_outputs" };
case "summary":
// System/summary records are non-user-visible meta; map to
// reasoning_records.
return { kind: "omitted", reason: "reasoning_records" };
default:
// Everything else (sessionId-only, agentInfo, permissionMode meta,
// etc.) is metadata, not a user-visible message.
return { kind: "omitted", reason: "reasoning_records" };
}
}
/** State holder for accumulating omitted-class counts during parsing. */
export class OmittedCounter {
readonly counts: OmittedCounts = emptyOmittedCounts();
recordOmission(reason: OmittedRecordClass): void {
this.counts[reason] += 1;
}
/**
* Mark that a kept message had secret content (was redacted). Increments
* `redacted_secrets` per occurrence. Caller checks `hasSecretPattern`
* BEFORE redaction to decide whether to record this.
*/
recordRedaction(): void {
this.counts.redacted_secrets += 1;
}
/**
* Returns the list of omitted classes that have at least one record.
* Used to populate `summary.omitted_classes` per contract § 5.
* Order: stable (the enum order from the type definition).
*/
activeClasses(): OmittedRecordClass[] {
const order: OmittedRecordClass[] = [
"reasoning_records",
"raw_tool_outputs",
"function_call_output",
"redacted_secrets",
];
return order.filter((cls) => this.counts[cls] > 0);
}
}
/** Re-export for convenience. */
export { hasSecretPattern };
@@ -0,0 +1,316 @@
// packet-writer.ts — assembles + atomically writes a v0 restore packet
// directory.
//
// Per M1 contract § 1: emits 4 required files (restore-instructions.md,
// transcript-latest.md, touched-files.md, restore-summary.json) plus
// optional transcript.md (when full transcript material is available;
// presence in lockstep with `full_transcript` summary key).
//
// Atomic emission via tempdir-then-rename:
// 1. Write all files into a sibling tempdir (`<targetDir>.tmp-<rand>/`).
// 2. Validate the tempdir's restore-summary.json against the embedded
// JSON Schema (RESTORE_SUMMARY_SCHEMA from schema-validator.ts).
// 3. If valid: rename tempdir → targetDir (single fs.rename; atomic on
// same filesystem).
// 4. If validation fails OR any write step fails: rmSync the tempdir;
// propagate the error. The targetDir is NEVER created in this case.
//
// Pre-condition: targetDir MUST NOT exist (operator must supply a fresh
// path). Reject with explicit error otherwise.
import { mkdirSync, writeFileSync, existsSync, renameSync, rmSync, mkdtempSync } from "node:fs";
import { dirname, basename, join } from "node:path";
import {
validateRestoreSummary,
type ValidationResult,
} from "./schema-validator.js";
import type { StructuredTranscript, SourceRuntime } from "./types.js";
const TRANSCRIPT_BOUND = 120;
export interface WritePacketOptions {
/** Absolute path of the target packet directory. MUST NOT exist. */
targetDir: string;
/** Parsed source structured representation. */
structured: StructuredTranscript;
/** Runtime kind of the source seat. */
sourceRuntime: SourceRuntime;
/** Target rig name (operator-supplied). */
targetRig: string;
/** Target runtime kind (operator-supplied). */
targetRuntime: SourceRuntime;
/** Target workspace root (absolute path). */
targetWorkspaceRoot: string;
/** Default target repo (absolute path or null). */
defaultTargetRepo: string | null;
/** Path/URI to role guidance for the restored seat. */
rolePointer: string;
/** One-paragraph human-readable summary of current work. */
currentWorkSummary: string;
/** Who the restored seat hands off to next; "self" if continuing. */
nextOwner: string;
/** Caveats list (cross-runtime quirks, redaction notes, etc.). */
caveats: string[];
/** Explicit statement of restored-seat authority. */
authorityBoundaries: string;
/** Ordered source-trust ranking (subset of contract enum). */
sourceTrustRanking: string[];
/** Source session id. */
sourceSessionId: string;
/**
* Source rig name. Per M2c-CLI R2: passed explicitly by the CLI adapter
* (computed from parsed session_meta `<seat>@<rig>` split or operator
* --source-rig-override). The writer does NOT derive this from
* sourceSessionId — that hidden derivation was the M2c-CLI R1 bug.
*/
sourceRig: string;
/** Source cwd (absolute path). */
sourceCwd: string;
/** Generator version identifier (e.g., "[email protected]"). */
generatorVersion: string;
/** Whether to include the full transcript.md file. */
includeFullTranscript: boolean;
}
export interface WritePacketResult {
targetDir: string;
files: string[];
summaryPath: string;
}
function transcriptMarkdownFromMessages(
title: string,
session: string,
messages: StructuredTranscript["messages"],
note: string,
): string {
const body = messages
.map((m, i) => {
const ts = m.timestamp ? ` ${m.timestamp}` : "";
return `## ${i + 1}. ${m.role}${ts}\n\n${m.text}\n`;
})
.join("\n");
return `# ${title}\n\nSource session: \`${session}\`\n\n${note}\n\n${body}`;
}
function buildTranscriptLatest(
structured: StructuredTranscript,
sourceSessionId: string,
): string {
const latest = structured.messages.slice(-TRANSCRIPT_BOUND);
return transcriptMarkdownFromMessages(
"Bounded Latest Transcript",
sourceSessionId,
latest,
`This bounded transcript contains the latest ${latest.length} extracted messages for normal restore. Use transcript.md only when deeper forensic recovery is needed.`,
);
}
function buildTranscriptFull(
structured: StructuredTranscript,
sourceSessionId: string,
): string {
return transcriptMarkdownFromMessages(
"Full Transcript",
sourceSessionId,
structured.messages,
"This transcript contains developer/user/assistant messages extracted from the source. Reasoning records, tool calls, and tool outputs are omitted by design (see restore-summary.json `omitted_classes`).",
);
}
function buildTouchedFiles(structured: StructuredTranscript): string {
const rows = structured.paths.length > 0
? structured.paths.map((entry) => `- \`${entry.path}\` (${entry.count})`).join("\n")
: "- No paths detected in extracted messages or tool arguments.";
return `# Touched Files And Path Inventory\n\nBest-effort path inventory from messages and tool arguments. This is a triage aid, not a complete proof of all files read or written.\n\n${rows}\n`;
}
function buildRestoreInstructions(opts: WritePacketOptions): string {
return `# Restore Instructions
You are a restored seat in \`${opts.targetRig}\`. This packet records the cross-runtime context for resuming work from \`${opts.sourceSessionId}\`.
## Identity
1. Run \`rig whoami --json\`.
2. Trust the current rig identity over the source-session transcript.
3. Current workspace root: \`${opts.targetWorkspaceRoot}\`.
4. Default target repo: \`${opts.defaultTargetRepo ?? "(none)"}\`.
5. Role guidance pointer: \`${opts.rolePointer}\`.
## Restore Steps
1. Read this file.
2. Read \`touched-files.md\`.
3. Read \`transcript-latest.md\` for bounded current context.
4. Use \`transcript.md\` only if the bounded transcript is insufficient.
5. State: "restored from packet generated by ${opts.generatorVersion}; current rig is ${opts.targetRig}; default target repo is ${opts.defaultTargetRepo ?? "none"}."
6. Name any caveats before resuming work.
7. Hand off to next owner: \`${opts.nextOwner}\` (or "self" to continue).
## Current Work
${opts.currentWorkSummary}
## Authority Boundaries
${opts.authorityBoundaries}
## Caveats
${opts.caveats.length > 0 ? opts.caveats.map((c) => `- ${c}`).join("\n") : "- (none recorded)"}
## Packet Stats
- Source runtime: \`${opts.sourceRuntime}\`
- Target runtime: \`${opts.targetRuntime}\`
- JSONL/transcript lines processed: ${opts.structured.lineCount}
- Extracted messages: ${opts.structured.messageCount}
- Compaction records: ${opts.structured.compactedCount}
- Source cwd: \`${opts.sourceCwd}\`
`;
}
function buildSummary(opts: WritePacketOptions): Record<string, unknown> {
const omittedClassesActive: string[] = [];
for (const k of ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"] as const) {
if (opts.structured.omittedCounts[k] > 0) omittedClassesActive.push(k);
}
const messageCount = Math.min(opts.structured.messages.length, TRANSCRIPT_BOUND);
const summary: Record<string, unknown> = {
source_session_id: opts.sourceSessionId,
source_rig: opts.sourceRig,
source_runtime: opts.sourceRuntime,
source_cwd: opts.sourceCwd,
target_rig: opts.targetRig,
target_runtime: opts.targetRuntime,
target_workspace_root: opts.targetWorkspaceRoot,
default_target_repo: opts.defaultTargetRepo,
role_pointer: opts.rolePointer,
bounded_latest_transcript: {
path: "transcript-latest.md",
message_count: messageCount,
bound: TRANSCRIPT_BOUND,
},
touched_files: {
path: "touched-files.md",
top_paths: opts.structured.paths.slice(0, 30),
},
durable_pointers: {
queue_pointers: [],
progress_pointers: [],
field_note_pointers: [],
artifact_pointers: [],
},
current_work_summary: opts.currentWorkSummary,
next_owner: opts.nextOwner,
caveats: opts.caveats,
authority_boundaries: opts.authorityBoundaries,
omitted_classes: omittedClassesActive,
redaction_policy_id: "openrig-v0",
source_trust_ranking: opts.sourceTrustRanking,
generator_version: opts.generatorVersion,
generated_at: new Date().toISOString(),
};
if (opts.includeFullTranscript) {
summary.full_transcript = {
path: "transcript.md",
line_count: opts.structured.messages.length,
};
}
return summary;
}
/**
* Write a v0 restore packet directory atomically.
*
* Atomic guarantee: if any step fails (including schema validation of
* restore-summary.json), the targetDir is never created and any
* intermediate tempdir is removed. Operators see either a complete
* valid packet at targetDir, or no packet at all.
*/
export async function writePacket(opts: WritePacketOptions): Promise<WritePacketResult> {
if (existsSync(opts.targetDir)) {
throw new Error(`restore-packet write: target directory already exists: ${opts.targetDir}`);
}
// Build all file contents BEFORE creating any disk state. This way if
// an in-memory step throws (unlikely, but defensive), no tempdir was
// created.
const summary = buildSummary(opts);
const validation: ValidationResult = validateRestoreSummary(summary);
if (!validation.valid) {
const errSummary = validation.errors
.slice(0, 5)
.map((e) => `${e.field}: ${e.rule}`)
.join("; ");
throw new Error(
`restore-packet write: schema validation failed before atomic rename: ${errSummary}`,
);
}
const restoreInstructions = buildRestoreInstructions(opts);
const transcriptLatest = buildTranscriptLatest(opts.structured, opts.sourceSessionId);
const touchedFiles = buildTouchedFiles(opts.structured);
const transcriptFull = opts.includeFullTranscript
? buildTranscriptFull(opts.structured, opts.sourceSessionId)
: null;
// Create tempdir as a sibling of targetDir using mkdtemp for race-free
// unique name. The dirname() must exist and be writable; we don't
// mkdir the parent for the operator (operator authority bedrock).
const parentDir = dirname(opts.targetDir);
const tempPrefix = `${basename(opts.targetDir)}.tmp-restore-packet-`;
const tempDir = mkdtempSync(join(parentDir, tempPrefix));
try {
writeFileSync(join(tempDir, "restore-instructions.md"), restoreInstructions, "utf-8");
writeFileSync(join(tempDir, "transcript-latest.md"), transcriptLatest, "utf-8");
writeFileSync(join(tempDir, "touched-files.md"), touchedFiles, "utf-8");
writeFileSync(join(tempDir, "restore-summary.json"), JSON.stringify(summary, null, 2), "utf-8");
if (transcriptFull !== null) {
writeFileSync(join(tempDir, "transcript.md"), transcriptFull, "utf-8");
}
// Re-validate by parsing the on-disk summary (round-trip self-check).
// This catches any structural divergence between buildSummary's
// in-memory shape and the JSON.stringify() serialization.
const onDisk = JSON.parse(
// eslint-disable-next-line @typescript-eslint/no-require-imports
(await import("node:fs")).readFileSync(join(tempDir, "restore-summary.json"), "utf-8"),
);
const recheck = validateRestoreSummary(onDisk);
if (!recheck.valid) {
throw new Error(
`restore-packet write: on-disk schema recheck failed: ${recheck.errors.slice(0, 5).map((e) => `${e.field}: ${e.rule}`).join("; ")}`,
);
}
// Atomic rename tempdir → targetDir. fs.rename is atomic when source
// and destination are on the same filesystem; we keep them sibling
// so this holds.
renameSync(tempDir, opts.targetDir);
} catch (err) {
// Clean up tempdir on any failure; do NOT leave a partial packet.
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; original error takes precedence.
}
throw err;
}
const files = [
"restore-instructions.md",
"transcript-latest.md",
"touched-files.md",
"restore-summary.json",
];
if (transcriptFull !== null) files.push("transcript.md");
return {
targetDir: opts.targetDir,
files,
summaryPath: join(opts.targetDir, "restore-summary.json"),
};
}
@@ -0,0 +1,72 @@
// redaction.ts — applies the v0 redaction policy.
//
// Per M1 contract § 4: v0 defines two named policies:
// - `velocity-v1`: mirrors the Velocity generator's token patterns at
// field-notes/2026-04-27-velocity-claude-from-codex-restore/tools/
// codex-jsonl-to-restore-packet.mjs:34-48 (sk-*, gh[pousr]_*,
// github_pat_*, Bearer *, base64 ≥40 chars).
// - `openrig-v0`: the productized v0 policy, identical patterns to
// `velocity-v1` but with the canonical name. v0 generator emits
// `redaction_policy_id: "openrig-v0"`.
//
// Redaction is applied to:
// - transcript content in transcript-latest.md and transcript.md.
// - body content in restore-instructions.md if it quotes transcript
// material.
// - top_paths / touched-files entries are NOT redacted (paths are not
// credential material).
//
// The validator does NOT scan content for residual credential patterns
// (per § 4 + § 8: enforcement is generator-side at write time).
/**
* v0 secret-pattern list. Mirrors Velocity prior art `:34-48` exactly:
*
* 1. /\bsk-[A-Za-z0-9_-]{16,}\b/g — OpenAI-style secret tokens.
* 2. /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g — GitHub personal/OAuth tokens.
* 3. /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g — GitHub fine-grained PATs.
* 4. /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/g — generic Bearer tokens.
* 5. /\b[A-Za-z0-9+/]{40,}={0,2}\b/g — base64-shaped strings ≥40 chars.
*/
export const SECRET_PATTERNS: readonly RegExp[] = [
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b/g,
/\b[A-Za-z0-9+/]{40,}={0,2}\b/g,
];
/**
* Apply the openrig-v0 redaction policy to a string. Returns the input
* with each match of the secret patterns replaced by the literal
* string `[REDACTED]`. Pure function; no side effects.
*
* The redaction is non-destructive at the structural level — only the
* matched substrings are replaced. Surrounding characters are kept.
*/
export function redact(text: string): string {
let out = text;
for (const pattern of SECRET_PATTERNS) {
out = out.replace(pattern, "[REDACTED]");
}
return out;
}
/**
* Returns true if at least one secret pattern matches the input.
* Used by the omitted-records counter to track how many records had
* their content redacted (for the `redacted_secrets` omitted-class
* count in summary).
*
* Note: a SINGLE record may match multiple patterns; this returns true
* after the first match for efficiency.
*/
export function hasSecretPattern(text: string): boolean {
for (const pattern of SECRET_PATTERNS) {
// Non-global test: cannot reuse the global `g` regex without resetting
// lastIndex, so build a fresh non-global RegExp from source.
const probe = new RegExp(pattern.source);
if (probe.test(text)) return true;
}
return false;
}
@@ -0,0 +1,87 @@
// runtime-detect.ts — auto-detect the source runtime from a JSONL/transcript
// shape.
//
// Per M1 contract § 3.4: source-runtime resolution for `--source-jsonl` is
// auto-detected from the JSONL shape (Codex JSONL has `payload.type`
// markers; Claude transcript has different shape). If auto-detect is
// ambiguous, fail with clear error and prompt for `--source-runtime`
// flag.
//
// Detection is by content shape, NOT file extension or filename
// (per dispatch's "Do NOT use file extensions or filenames as the
// detection key. Detect by content shape.").
//
// Codex shape: each line is JSON with `type` field; the discriminating
// markers are `type: "session_meta"`, `type: "response_item"`, or
// `type: "compacted"`. response_item records carry a `payload` object
// with a `type` sub-field.
//
// Claude shape: each line is JSON with `type` field; values include
// `user`, `assistant`, `system`, `attachment`, `summary`, etc. NO
// `payload` wrapper; the message content is directly under `message`
// or `attachment`.
import type { SourceRuntime } from "./types.js";
/**
* Detect the source runtime from a sample of JSONL lines.
*
* Returns:
* - "codex" — at least one line has `type: "response_item"` OR
* `type: "session_meta"` OR `type: "compacted"`.
* - "claude-code" — at least one line has `type: "user"` or
* `type: "assistant"` AND no Codex markers seen.
* - null — input is empty / non-JSONL / ambiguous (e.g., neither
* marker class appears).
*
* Detection is non-greedy: walks the input lines until a definitive
* marker is found. Bias toward Codex markers if both shape classes
* appear (extremely unlikely in practice; would indicate a malformed
* mixed source).
*/
export function detectRuntime(content: string): SourceRuntime | null {
if (typeof content !== "string" || content.length === 0) return null;
let sawCodexMarker = false;
let sawClaudeMarker = false;
for (const rawLine of content.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
let record: { type?: unknown; payload?: { type?: unknown } };
try {
record = JSON.parse(line);
} catch {
// Skip malformed JSON lines; they don't affect detection.
continue;
}
const recordType = typeof record.type === "string" ? record.type : "";
// Codex discriminators: type values + payload wrapper presence.
if (
recordType === "response_item" ||
recordType === "session_meta" ||
recordType === "compacted"
) {
sawCodexMarker = true;
// Early-return on the strongest Codex marker; ambiguity bias
// toward Codex if Claude markers also appear is intentional
// (Codex's response_item is structurally distinct from any
// Claude record; if it's present, the content is Codex JSONL).
return "codex";
}
// Claude discriminators: top-level type as a user/assistant marker.
if (recordType === "user" || recordType === "assistant") {
sawClaudeMarker = true;
// Don't early-return on Claude alone — keep looking for any
// Codex marker (defensive against the unlikely mixed-shape case).
}
}
if (sawCodexMarker) return "codex";
if (sawClaudeMarker) return "claude-code";
return null;
}
@@ -0,0 +1,260 @@
// schema-validator.ts — validates restore-summary.json against the v0 JSON
// Schema (packages/cli/src/schemas/restore-summary.schema.json).
//
// Per M1 contract § 8 + IMPL § M2 line 167:
// - generator-side: emit-time self-check against the schema before atomic
// rename of the packet directory.
// - operator-side: M3 `rig restore-packet validate <packet-dir>` runs this
// validator and surfaces per-field violations.
//
// Validator returns a ValidationResult with `valid` and a per-field error
// list. Each error names: field path, value (truncated/escaped), rule, and
// severity. Required-field violations are severity `error`; optional-field
// malformations are severity `warning` per § 8.
//
// M2a R2 packaging fix: the schema is embedded directly in this module as
// a typed TS const (RESTORE_SUMMARY_SCHEMA) rather than read from the
// sibling .json file at runtime. tsc emits the const into the compiled
// validator JS, so the validator works after `tsc` emit without any extra
// build-script copy step. The canonical JSON Schema file at
// `packages/cli/src/schemas/restore-summary.schema.json` remains the
// source of truth for downstream IDE / tooling consumption; a drift-catcher
// test in `test/restore-packet.test.ts` asserts the two stay byte-equivalent.
import Ajv from "ajv";
import addFormats from "ajv-formats";
export interface ValidationError {
field: string;
value: string;
rule: string;
severity: "error" | "warning";
}
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
// RESTORE_SUMMARY_SCHEMA — verbatim mirror of
// `packages/cli/src/schemas/restore-summary.schema.json`. Keep these in
// lockstep; the drift-catcher test enforces equivalence.
export const RESTORE_SUMMARY_SCHEMA: Record<string, unknown> = {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://openrig.dev/schemas/restore-summary.schema.json",
title: "Restore Packet Summary",
description:
"Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
type: "object",
required: [
"source_session_id",
"source_rig",
"source_runtime",
"source_cwd",
"target_rig",
"target_runtime",
"target_workspace_root",
"default_target_repo",
"role_pointer",
"bounded_latest_transcript",
"touched_files",
"durable_pointers",
"current_work_summary",
"next_owner",
"caveats",
"authority_boundaries",
"omitted_classes",
"redaction_policy_id",
"source_trust_ranking",
"generator_version",
"generated_at",
],
properties: {
source_session_id: { type: "string", minLength: 1 },
source_rig: { type: "string", minLength: 1 },
source_runtime: {
type: "string",
enum: ["claude-code", "codex", "terminal", "external"],
},
source_cwd: { type: "string", pattern: "^/" },
target_rig: { type: "string", minLength: 1 },
target_runtime: {
type: "string",
enum: ["claude-code", "codex", "terminal", "external"],
},
target_workspace_root: { type: "string", pattern: "^/" },
default_target_repo: { type: ["string", "null"] },
role_pointer: {
type: "string",
minLength: 1,
description:
"Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time.",
},
bounded_latest_transcript: {
type: "object",
required: ["path", "message_count", "bound"],
properties: {
path: { type: "string" },
message_count: {
type: "integer",
minimum: 0,
description: "Actual count of extracted messages in transcript-latest.md.",
},
bound: {
type: "integer",
const: 120,
description:
"v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass.",
},
},
},
full_transcript: {
type: "object",
properties: {
path: { type: "string" },
line_count: { type: "integer", minimum: 0 },
},
description:
"Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise.",
},
touched_files: {
type: "object",
required: ["path", "top_paths"],
properties: {
path: { type: "string" },
top_paths: {
type: "array",
items: {
type: "object",
required: ["path", "count"],
properties: {
path: { type: "string" },
count: { type: "integer", minimum: 0 },
},
},
},
},
},
durable_pointers: {
type: "object",
required: [
"queue_pointers",
"progress_pointers",
"field_note_pointers",
"artifact_pointers",
],
properties: {
queue_pointers: {
type: "array",
items: { type: "string" },
description: "References to durable queue items (qitem ids or queue-file paths).",
},
progress_pointers: {
type: "array",
items: { type: "string" },
description: "References to PROGRESS.md cursors the source seat was operating against.",
},
field_note_pointers: {
type: "array",
items: { type: "string" },
description: "References to field-notes folders or files.",
},
artifact_pointers: {
type: "array",
items: { type: "string" },
description: "Other durable artifacts (proof packets, slice packets, candidate dossiers).",
},
},
description:
"REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context.",
},
current_work_summary: { type: "string", minLength: 1 },
next_owner: { type: "string", minLength: 1 },
caveats: { type: "array", items: { type: "string" } },
authority_boundaries: { type: "string", minLength: 1 },
omitted_classes: {
type: "array",
items: {
type: "string",
enum: ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"],
},
},
redaction_policy_id: { type: "string", enum: ["velocity-v1", "openrig-v0"] },
source_trust_ranking: {
type: "array",
items: {
type: "string",
enum: [
"rig_whoami",
"target_rigspec",
"bounded_latest_transcript",
"full_transcript",
"touched_files",
"restore_summary",
],
},
minItems: 1,
},
generator_version: { type: "string", minLength: 1 },
generated_at: { type: "string", format: "date-time" },
},
additionalProperties: false,
};
let cachedValidator: ReturnType<Ajv["compile"]> | null = null;
function getValidator(): ReturnType<Ajv["compile"]> {
if (cachedValidator) return cachedValidator;
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
cachedValidator = ajv.compile(RESTORE_SUMMARY_SCHEMA);
return cachedValidator;
}
function truncateValue(value: unknown): string {
if (value === undefined) return "<undefined>";
if (value === null) return "null";
let s: string;
try {
s = typeof value === "string" ? value : JSON.stringify(value);
} catch {
s = String(value);
}
if (s.length > 80) return s.slice(0, 77) + "...";
return s;
}
export function validateRestoreSummary(summary: unknown): ValidationResult {
const validator = getValidator();
const ok = validator(summary);
if (ok) {
return { valid: true, errors: [] };
}
const errors: ValidationError[] = (validator.errors ?? []).map((err) => {
const instancePath = err.instancePath || "";
const missing = err.params && (err.params as { missingProperty?: string }).missingProperty;
const additional = err.params && (err.params as { additionalProperty?: string }).additionalProperty;
let field = instancePath.replace(/^\//, "").replace(/\//g, ".");
if (missing) field = field ? `${field}.${missing}` : missing;
if (additional) field = additional;
// Per M1 contract § 8 + IMPL § M3: optional-field malformations are
// severity "warning"; required-field violations are severity "error".
// The only top-level optional field in v0 is `full_transcript`.
// Errors whose path begins at `/full_transcript` come from validating
// the optional object's contents, not from a missing-required check
// on a required field.
const isOptionalFieldError = instancePath.startsWith("/full_transcript");
return {
field: field || "<root>",
value: truncateValue(err.data),
rule: `${err.keyword}: ${err.message ?? ""}`.trim(),
severity: isOptionalFieldError ? "warning" : "error",
};
});
// `valid` is false on ANY ajv violation (preserves M2c writer
// semantics: any malformation should block atomic rename). The M3
// validate command consults `errors[].severity` to decide exit code
// per M1 contract § 8 (required-field-class errors → exit nonzero;
// optional-field warnings → exit 0 with warning text).
return { valid: false, errors };
}
+101
View File
@@ -0,0 +1,101 @@
// types.ts — shared TypeScript types for the restore-packet generator.
//
// Both runtime adapters (codex-jsonl-parser, claude-transcript-parser)
// emit the same StructuredTranscript shape. Downstream modules
// (redaction, omitted-records, packet-writer) consume this shape
// without caring about the source runtime.
/** A single user-visible message extracted from a transcript. */
export interface ExtractedMessage {
/** ISO-8601 timestamp if the source provides one; else null. */
timestamp: string | null;
/** Speaker role. Restricted to user-visible roles (developer/user/assistant). */
role: "developer" | "user" | "assistant";
/** Redacted message text. */
text: string;
/** First non-empty line of the text, truncated to ~180 chars. Used in summaries. */
preview: string;
}
/** Canonical record-class enum per M1 contract § 5. */
export type OmittedRecordClass =
| "reasoning_records"
| "raw_tool_outputs"
| "function_call_output"
| "redacted_secrets";
/**
* A path frequency entry from path extraction. Sorted by count
* descending then path ascending; used to populate `touched_files.top_paths`.
*/
export interface PathCount {
path: string;
count: number;
}
/**
* Per-runtime per-record-type counter. Flexible string keys because
* Codex JSONL uses `record.type` strings and Claude transcript uses
* `record.type` strings; both are runtime-defined.
*/
export type TypeCounts = Record<string, number>;
/**
* Per-omitted-class counter. Records how many records were filtered
* for each of the 4 contract enums during parsing/redaction.
*/
export type OmittedCounts = Record<OmittedRecordClass, number>;
/**
* Optional session metadata extracted from the source. Codex emits a
* `session_meta` record at the top of a JSONL; Claude transcripts
* carry per-record `cwd` etc.
*/
export interface SessionMeta {
cwd: string | null;
sessionId: string | null;
/** Free-form additional fields the runtime emitted; not load-bearing. */
raw?: Record<string, unknown>;
}
/**
* Structured representation produced by both parsers. The packet-writer
* (M2c) consumes this to assemble a v0 restore packet.
*/
export interface StructuredTranscript {
/** Top-level session metadata (cwd, sessionId). */
sessionMeta: SessionMeta | null;
/** Total source lines processed (raw JSONL line count, including malformed/skipped). */
lineCount: number;
/** Number of user-visible messages extracted into `messages`. */
messageCount: number;
/**
* Number of "compaction" records seen. Codex emits these explicitly
* (`type: "compacted"`); Claude doesn't. The field is parser-specific
* but always present (zero when not applicable).
*/
compactedCount: number;
/** Per-record-type frequency map; used in human reports / debug. */
typeCounts: TypeCounts;
/** Per-omitted-class counter; populated as the parser filters records. */
omittedCounts: OmittedCounts;
/** Extracted messages in chronological order; redaction has been applied. */
messages: ExtractedMessage[];
/**
* Path frequency inventory; sorted by count desc then path asc.
* Capped at 200 entries.
*/
paths: PathCount[];
}
/** Source-runtime kind. v0 supports codex + claude-code transcript shapes. */
export type SourceRuntime = "codex" | "claude-code";
export function emptyOmittedCounts(): OmittedCounts {
return {
reasoning_records: 0,
raw_tool_outputs: 0,
function_call_output: 0,
redacted_secrets: 0,
};
}
@@ -0,0 +1,98 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://openrig.dev/schemas/restore-summary.schema.json",
"title": "Restore Packet Summary",
"description": "Machine-readable metadata for a cross-runtime restore packet per the v0 paper standard.",
"type": "object",
"required": [
"source_session_id",
"source_rig",
"source_runtime",
"source_cwd",
"target_rig",
"target_runtime",
"target_workspace_root",
"default_target_repo",
"role_pointer",
"bounded_latest_transcript",
"touched_files",
"durable_pointers",
"current_work_summary",
"next_owner",
"caveats",
"authority_boundaries",
"omitted_classes",
"redaction_policy_id",
"source_trust_ranking",
"generator_version",
"generated_at"
],
"properties": {
"source_session_id": {"type": "string", "minLength": 1},
"source_rig": {"type": "string", "minLength": 1},
"source_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
"source_cwd": {"type": "string", "pattern": "^/"},
"target_rig": {"type": "string", "minLength": 1},
"target_runtime": {"type": "string", "enum": ["claude-code", "codex", "terminal", "external"]},
"target_workspace_root": {"type": "string", "pattern": "^/"},
"default_target_repo": {"type": ["string", "null"]},
"role_pointer": {
"type": "string",
"minLength": 1,
"description": "Path or URI pointing to the role guidance file the restored seat should consume. REQUIRED per v0 standard field #9; resolves at restore time."
},
"bounded_latest_transcript": {
"type": "object",
"required": ["path", "message_count", "bound"],
"properties": {
"path": {"type": "string"},
"message_count": {"type": "integer", "minimum": 0, "description": "Actual count of extracted messages in transcript-latest.md."},
"bound": {"type": "integer", "const": 120, "description": "v0 bound is fixed at 120 messages to match Velocity prior art (per contract § 7). v1+ may make configurable; v0 schema requires const 120 so packets with the wrong bound cannot pass."}
}
},
"full_transcript": {
"type": "object",
"properties": {
"path": {"type": "string"},
"line_count": {"type": "integer", "minimum": 0}
},
"description": "Optional per v0 standard field #11. Generator emits when full transcript material is available (e.g., from --source-jsonl or full-read --source-session); omitted otherwise."
},
"touched_files": {
"type": "object",
"required": ["path", "top_paths"],
"properties": {
"path": {"type": "string"},
"top_paths": {"type": "array", "items": {"type": "object", "required": ["path", "count"], "properties": {"path": {"type": "string"}, "count": {"type": "integer", "minimum": 0}}}}
}
},
"durable_pointers": {
"type": "object",
"required": ["queue_pointers", "progress_pointers", "field_note_pointers", "artifact_pointers"],
"properties": {
"queue_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to durable queue items (qitem ids or queue-file paths)."},
"progress_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to PROGRESS.md cursors the source seat was operating against."},
"field_note_pointers": {"type": "array", "items": {"type": "string"}, "description": "References to field-notes folders or files."},
"artifact_pointers": {"type": "array", "items": {"type": "string"}, "description": "Other durable artifacts (proof packets, slice packets, candidate dossiers)."}
},
"description": "REQUIRED per v0 standard field #13. Pointers to durable work the source seat was operating against; restored seat reads to recover work-context."
},
"current_work_summary": {"type": "string", "minLength": 1},
"next_owner": {"type": "string", "minLength": 1},
"caveats": {"type": "array", "items": {"type": "string"}},
"authority_boundaries": {"type": "string", "minLength": 1},
"omitted_classes": {
"type": "array",
"items": {"type": "string", "enum": ["reasoning_records", "raw_tool_outputs", "function_call_output", "redacted_secrets"]}
},
"redaction_policy_id": {"type": "string", "enum": ["velocity-v1", "openrig-v0"]},
"source_trust_ranking": {
"type": "array",
"items": {"type": "string", "enum": ["rig_whoami", "target_rigspec", "bounded_latest_transcript", "full_transcript", "touched_files", "restore_summary"]},
"minItems": 1
},
"generator_version": {"type": "string", "minLength": 1},
"generated_at": {"type": "string", "format": "date-time"}
},
"additionalProperties": false
}
+14 -4
View File
@@ -3,6 +3,7 @@ import { accessSync, mkdirSync, constants } from "node:fs";
import { dirname } from "node:path";
import type { ConfigStore, RiggedConfig } from "./config-store.js";
import type { DaemonStatus } from "./daemon-lifecycle.js";
import { buildTmuxControlFailure, probeTmuxControlAsync } from "./tmux-health.js";
export interface PreflightCheck {
name: string;
@@ -135,10 +136,8 @@ export class SystemPreflight {
}
// 2. tmux availability
try {
await this.deps.exec("tmux -V");
checks.push({ name: "tmux", ok: true });
} catch {
const tmuxProbe = await probeTmuxControlAsync(this.deps.exec);
if (tmuxProbe.code === "not_installed") {
checks.push({
name: "tmux",
ok: false,
@@ -146,6 +145,17 @@ export class SystemPreflight {
reason: "OpenRig uses tmux to create and control agent sessions.",
fix: "Install tmux (brew install tmux on macOS, apt install tmux on Debian/Ubuntu).",
});
} else if (tmuxProbe.available) {
checks.push({ name: "tmux", ok: true });
} else {
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
checks.push({
name: "tmux",
ok: false,
error: failure.message,
reason: failure.reason,
fix: failure.fix,
});
}
// 3. Writable OpenRig home + transcript path
+114
View File
@@ -0,0 +1,114 @@
export interface TmuxProbeResult {
installed: boolean;
available: boolean;
version: string | null;
detail: string | null;
code: "available" | "no_server" | "not_installed" | "unhealthy";
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function isTmuxNoServerMessage(message: string): boolean {
const normalized = message.toLowerCase();
return normalized.includes("no server running")
|| normalized.includes("failed to connect to server")
|| (normalized.includes("error connecting to") && normalized.includes("no such file or directory"));
}
export function buildTmuxControlFailure(message: string): { message: string; reason: string; fix: string } {
return {
message: "tmux installed, but the default control socket is unhealthy.",
reason: `OpenRig uses tmux control commands to launch, inspect, and manage agent sessions. The default tmux socket is returning: ${message}`,
fix: "Capture any needed state from visible tmux panes, then restart the default tmux server before retrying OpenRig. If this happened after a machine restore, treat it as attention required rather than a healthy running state.",
};
}
export function probeTmuxControl(exec: (cmd: string) => string): TmuxProbeResult {
let version: string;
try {
version = exec("tmux -V").trim();
} catch (err) {
return {
installed: false,
available: false,
version: null,
detail: errorMessage(err),
code: "not_installed",
};
}
try {
exec("tmux list-sessions");
return {
installed: true,
available: true,
version,
detail: null,
code: "available",
};
} catch (err) {
const detail = errorMessage(err).trim();
if (isTmuxNoServerMessage(detail)) {
return {
installed: true,
available: true,
version,
detail,
code: "no_server",
};
}
return {
installed: true,
available: false,
version,
detail,
code: "unhealthy",
};
}
}
export async function probeTmuxControlAsync(exec: (cmd: string) => Promise<string>): Promise<TmuxProbeResult> {
let version: string;
try {
version = (await exec("tmux -V")).trim();
} catch (err) {
return {
installed: false,
available: false,
version: null,
detail: errorMessage(err),
code: "not_installed",
};
}
try {
await exec("tmux list-sessions");
return {
installed: true,
available: true,
version,
detail: null,
code: "available",
};
} catch (err) {
const detail = errorMessage(err).trim();
if (isTmuxNoServerMessage(detail)) {
return {
installed: true,
available: true,
version,
detail,
code: "no_server",
};
}
return {
installed: true,
available: false,
version,
detail,
code: "unhealthy",
};
}
}
+47 -1
View File
@@ -3,7 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { resolveBinEntry } from "../src/bin-wrapper.js";
import { resolveBinEntry, resolveNodeReexecBinary } from "../src/bin-wrapper.js";
describe("bin-wrapper", () => {
let tempDir: string | null = null;
@@ -24,4 +24,50 @@ describe("bin-wrapper", () => {
const entry = resolveBinEntry(symlinkPath, pathToFileURL(wrapperSource).href);
expect(entry).toBe(path.resolve(path.dirname(wrapperSource), "../dist/index.js"));
});
it("prefers the sibling node next to the invoked rig binary when current node comes from elsewhere", () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
const prefixDir = path.join(tempDir, "prefix");
const binDir = path.join(prefixDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
const rigPath = path.join(binDir, "rig");
const siblingNodePath = path.join(binDir, "node");
fs.symlinkSync(wrapperSource, rigPath);
fs.writeFileSync(siblingNodePath, "");
const reexecNode = resolveNodeReexecBinary(
"/opt/homebrew/bin/node",
rigPath,
{},
(candidate) => candidate === siblingNodePath,
(candidate) => candidate,
);
expect(reexecNode).toBe(siblingNodePath);
});
it("does not re-exec when already running under the sibling node", () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
const prefixDir = path.join(tempDir, "prefix");
const binDir = path.join(prefixDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
const rigPath = path.join(binDir, "rig");
const siblingNodePath = path.join(binDir, "node");
fs.symlinkSync(wrapperSource, rigPath);
fs.writeFileSync(siblingNodePath, "");
const reexecNode = resolveNodeReexecBinary(
siblingNodePath,
rigPath,
{},
(candidate) => candidate === siblingNodePath,
(candidate) => candidate,
);
expect(reexecNode).toBeNull();
});
});
+29 -1
View File
@@ -182,7 +182,9 @@ describe("Bootstrap CLI", () => {
// T7: daemon not running -> error + exit 1
it("daemon not running returns exit code 1", async () => {
const stoppedDeps: StatusDeps = {
lifecycleDeps: mockLifecycleDeps(),
lifecycleDeps: mockLifecycleDeps({
fetch: vi.fn(async () => { throw new Error("refused"); }),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
const prog = new Command();
@@ -214,6 +216,32 @@ describe("Bootstrap CLI", () => {
expect((body.cwdOverride as string).endsWith("relative/project")).toBe(true);
});
it("bootstrap apply uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
timeoutMs = options?.timeoutMs;
return {
status: 201,
data: { runId: "run-2", status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
};
});
const deps: StatusDeps = {
lifecycleDeps: runningDeps(port).lifecycleDeps,
clientFactory: () => ({ post } as unknown as DaemonClient),
};
const prog = new Command();
prog.exitOverride();
prog.addCommand(bootstrapCommand(deps));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "bootstrap", "/tmp/rig.yaml", "--yes"]);
});
expect(timeoutMs).toBe(120_000);
});
// T9: partial status -> exit 1 (R2-M1)
it("partial result returns exit code 1", async () => {
const partialServer = http.createServer((_, res) => {
+2 -1
View File
@@ -5,6 +5,7 @@ import { chatroomCommand } from "../src/commands/chatroom.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
import { ulid } from "ulid";
function mockLifecycleDeps(): LifecycleDeps {
return {
@@ -308,7 +309,7 @@ describe("Chatroom CLI", () => {
// Inject a new message after 1s (will appear on second poll)
const injectionTimer = setTimeout(() => {
dynamicMessages.push({
id: "01KNZZZZZZZZZZZZZZZZZZZZZZ", // ULID > any current ULID baseline
id: ulid(),
rigId: "rig-1",
sender: "new-peer",
kind: "message",
+116
View File
@@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import { checkAbi } from "../scripts/check-abi.mjs";
describe("postinstall ABI check", () => {
it("passes on supported even-numbered LTS with working native addon", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("passes on Node 20 LTS", () => {
const result = checkAbi({
nodeVersion: "v20.18.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("passes on Node 24 LTS", () => {
const result = checkAbi({
nodeVersion: "v24.0.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(true);
});
it("fails on odd-numbered Node 25 with honest error + fix command", () => {
const result = checkAbi({
nodeVersion: "v25.8.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("odd-numbered");
expect(result.message).toContain("v25.8.0");
expect(result.message).toContain("nvm install 22");
});
it("fails on odd-numbered Node 23 with honest error", () => {
const result = checkAbi({
nodeVersion: "v23.5.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("odd-numbered");
});
it("fails on Node below 20 with version-too-low error", () => {
const result = checkAbi({
nodeVersion: "v18.20.0",
loadNativeAddon: () => {},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("requires Node.js 20, 22, or 24");
expect(result.message).toContain("nvm install 22");
});
it("fails with ABI mismatch error when native addon throws on supported version", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {
throw new Error(
"The module was compiled against a different Node.js version using NODE_MODULE_VERSION 127. " +
"This version of Node.js requires NODE_MODULE_VERSION 131."
);
},
});
expect(result.ok).toBe(false);
expect(result.message).toContain("native binary does not match");
expect(result.message).toContain("npm rebuild better-sqlite3");
expect(result.message).toContain("NODE_MODULE_VERSION");
});
it("reports generic native-load-failure for MODULE_NOT_FOUND (not ABI-mismatch text)", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {
const err = new Error("Cannot find module 'better-sqlite3'");
(err as NodeJS.ErrnoException).code = "MODULE_NOT_FOUND";
throw err;
},
});
expect(result.ok).toBe(false);
// Should NOT say "native binary does not match" — that's ABI-specific
expect(result.message).not.toContain("native binary does not match");
// Should say generic addon-load-failure
expect(result.message).toContain("native addon failed to load");
expect(result.message).toContain("Cannot find module");
expect(result.message).toContain("npm rebuild better-sqlite3");
});
it("reports generic native-load-failure for filesystem permission errors", () => {
const result = checkAbi({
nodeVersion: "v22.22.1",
loadNativeAddon: () => {
throw new Error("EACCES: permission denied, open '/usr/local/lib/better_sqlite3.node'");
},
});
expect(result.ok).toBe(false);
expect(result.message).not.toContain("native binary does not match");
expect(result.message).toContain("native addon failed to load");
expect(result.message).toContain("EACCES");
});
it("skips native addon check entirely for odd versions (fast path)", () => {
let addonCalled = false;
const result = checkAbi({
nodeVersion: "v25.8.0",
loadNativeAddon: () => { addonCalled = true; },
});
expect(result.ok).toBe(false);
// Version check short-circuits before trying to load the addon
expect(addonCalled).toBe(false);
});
});
+25 -2
View File
@@ -124,6 +124,29 @@ describe("DaemonClient", () => {
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
});
it("per-request timeout override can extend a long-running call", async () => {
const delayedFetch: typeof fetch = ((url: string | URL | Request, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(new Response(JSON.stringify({ ok: true, url: String(url) }), {
status: 200,
headers: { "Content-Type": "application/json" },
}));
}, 40);
init?.signal?.addEventListener("abort", () => {
clearTimeout(timer);
reject(init.signal?.reason ?? new Error("aborted"));
}, { once: true });
})) as typeof fetch;
const client = new DaemonClient("http://localhost:9999", { fetchImpl: delayedFetch, timeoutMs: 20 });
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
const res = await client.get<{ ok: boolean; url: string }>("/api/rigs", { timeoutMs: 100 });
expect(res.status).toBe(200);
expect(res.data.ok).toBe(true);
expect(res.data.url).toContain("/api/rigs");
});
// Test 5: Client uses OPENRIG_URL env, falls back to http://127.0.0.1:7433
it("uses OPENRIG_URL env when set, falls back to http://127.0.0.1:7433", () => {
// Default (no env)
@@ -152,8 +175,8 @@ describe("DaemonClient", () => {
const execFileAsync = promisify(execFile);
const { resolve } = await import("node:path");
const cliEntry = resolve(import.meta.dirname, "../src/index.ts");
const result = await execFileAsync("npx", ["tsx", cliEntry, "--version"], {
const cliEntry = resolve(import.meta.dirname, "../dist/index.js");
const result = await execFileAsync("node", [cliEntry, "--version"], {
cwd: resolve(import.meta.dirname, ".."),
});
+482
View File
@@ -0,0 +1,482 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { compactPlanCommand, type CompactPlanDeps } from "../src/commands/compact-plan.js";
import { createProgram } from "../src/index.js";
const freshSample = () => new Date(Date.now() - 60_000).toISOString();
const staleSample = () => new Date(Date.now() - 3_600_000).toISOString();
interface TestNode {
rigId: string;
rigName: string;
logicalId: string;
canonicalSessionName: string | null;
runtime: string | null;
sessionStatus: string | null;
startupStatus: string | null;
tmuxAttachCommand: string | null;
resumeCommand: string | null;
contextUsage?: {
usedPercentage: number | null;
remainingPercentage: number | null;
contextWindowSize: number | null;
source: string | null;
availability: string | null;
sampledAt: string | null;
fresh: boolean;
};
}
function node(overrides: Partial<TestNode> = {}): TestNode {
const session = overrides.canonicalSessionName ?? "orch-lead@test-rig";
return {
rigId: "rig-0",
rigName: "test-rig",
logicalId: "orch.lead",
canonicalSessionName: session,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready",
tmuxAttachCommand: session ? `tmux attach -t ${session}` : null,
resumeCommand: null,
contextUsage: {
usedPercentage: 85,
remainingPercentage: 15,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
...overrides,
};
}
function defaultNodes(): TestNode[] {
return [
node({
logicalId: "orch.lead",
canonicalSessionName: "orch-lead@test-rig",
contextUsage: {
usedPercentage: 85,
remainingPercentage: 15,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "dev.impl",
canonicalSessionName: "dev-impl@test-rig",
contextUsage: {
usedPercentage: 30,
remainingPercentage: 70,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "dev.qa",
canonicalSessionName: "dev-qa@test-rig",
runtime: "codex",
contextUsage: {
usedPercentage: 90,
remainingPercentage: 10,
contextWindowSize: 1_000_000,
source: "codex",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
];
}
function makeDeps(overrides?: {
rigNames?: string[];
nodesByRig?: Record<string, TestNode[]>;
refreshStatus?: number;
refreshThrows?: boolean;
}): { deps: CompactPlanDeps; requestedPaths: string[]; mutationMethods: Record<string, ReturnType<typeof vi.fn>> } {
const rigs = (overrides?.rigNames ?? ["test-rig"]).map((name, i) => ({ rigId: `rig-${i}`, name }));
const nodesByRig = overrides?.nodesByRig ?? { "rig-0": defaultNodes() };
const requestedPaths: string[] = [];
const mutationMethods = {
post: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
delete: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
postText: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
postExpectText: vi.fn(() => { throw new Error("compact-plan must not mutate daemon state"); }),
};
return {
requestedPaths,
mutationMethods,
deps: {
lifecycleDeps: {} as CompactPlanDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
requestedPaths.push(path);
if (path === "/api/ps") return { status: 200, data: rigs };
if (path.includes("refresh=true")) {
if (overrides?.refreshThrows) throw new Error("statusline unavailable");
return { status: overrides?.refreshStatus ?? 200, data: overrides?.refreshStatus && overrides.refreshStatus >= 400 ? { error: "refresh failed" } : [] };
}
for (const [rigId, nodes] of Object.entries(nodesByRig)) {
if (path === `/api/rigs/${rigId}/nodes`) return { status: 200, data: nodes };
}
return { status: 200, data: [] };
}),
getText: vi.fn(async (path: string) => {
requestedPaths.push(path);
return { status: 200, data: "" };
}),
...mutationMethods,
}) as unknown as ReturnType<CompactPlanDeps["clientFactory"]>,
},
};
}
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
describe("rig compact-plan", () => {
let logs: string[];
let errors: string[];
beforeEach(async () => {
logs = [];
errors = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
(getDaemonStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ state: "running", healthy: true, pid: 1234, port: 7433 });
process.exitCode = undefined;
});
it("is registered on createProgram and runs with injected deps", async () => {
const { deps } = makeDeps();
const program = createProgram({ compactPlanDeps: deps });
program.exitOverride();
await program.parseAsync(["node", "rig", "compact-plan", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.policy.mode).toBe("read_only_plan");
expect(json.candidates[0].session).toBe("orch-lead@test-rig");
});
it("returns read-only JSON policy, candidates, recommended order, and skipped Codex seats", async () => {
const { deps, mutationMethods } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.policy).toEqual({
mode: "read_only_plan",
defaultThresholdTokens: 400_000,
defaultThresholdPercent: 80,
thresholdTokens: 400_000,
thresholdPercent: 80,
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
});
expect(json.summary.requiresAuthorization).toBe(true);
const candidate = json.candidates.find((entry: { session: string }) => entry.session === "orch-lead@test-rig");
expect(candidate.status).toBe("candidate_with_caveats");
expect(candidate.estimatedUsedTokens).toBe(850_000);
expect(candidate.thresholdReason).toBe("above_token_threshold");
expect(candidate.reasons).toContain("above_token_threshold");
expect(candidate.reasons).toContain("authorization_required");
expect(candidate.missingEvidence).toContain("checkpoint_or_restore_packet_verification");
expect(candidate.precompactRequirements).toContain("explicit_operator_authorization");
expect(candidate.seatPolicy).toEqual({
oneSeatAtATime: true,
autoCompactAllowed: false,
explicitAuthorizationRequired: true,
});
expect(candidate.notificationPacket.recipient).toBe("orch-lead@test-rig");
expect(candidate.notificationPacket.text).toContain("No automatic compaction has been run");
expect(json.recommendedOrder).toEqual(["orch-lead@test-rig"]);
const belowThreshold = json.skipped.find((entry: { session: string }) => entry.session === "dev-impl@test-rig");
expect(belowThreshold.reason).toBe("below_threshold");
const codex = json.skipped.find((entry: { session: string }) => entry.session === "dev-qa@test-rig");
expect(codex.reason).toBe("codex_not_managed_by_claude_compact_in_place");
for (const mutate of Object.values(mutationMethods)) {
expect(mutate).not.toHaveBeenCalled();
}
});
it("uses percent fallback only for Claude seats at or above 80 percent when context window is missing", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({
canonicalSessionName: "high-missing-window@test-rig",
contextUsage: {
usedPercentage: 85,
remainingPercentage: 15,
contextWindowSize: null,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "mid.missing",
canonicalSessionName: "mid-missing-window@test-rig",
contextUsage: {
usedPercentage: 79,
remainingPercentage: 21,
contextWindowSize: null,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.candidates.map((entry: { session: string }) => entry.session)).toContain("high-missing-window@test-rig");
const candidate = json.candidates.find((entry: { session: string }) => entry.session === "high-missing-window@test-rig");
expect(candidate.status).toBe("candidate_with_caveats");
expect(candidate.thresholdReason).toBe("above_percent_threshold_missing_window");
expect(candidate.estimatedUsedTokens).toBeNull();
expect(candidate.missingEvidence).toContain("context_window_size");
expect(json.skipped.find((entry: { session: string }) => entry.session === "mid-missing-window@test-rig").reason).toBe("below_threshold");
});
it("honors custom token and percent thresholds in JSON policy and candidate selection", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({
logicalId: "lower.token",
canonicalSessionName: "lower-token@test-rig",
contextUsage: {
usedPercentage: 30,
remainingPercentage: 70,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
node({
logicalId: "lower.percent",
canonicalSessionName: "lower-percent@test-rig",
contextUsage: {
usedPercentage: 75,
remainingPercentage: 25,
contextWindowSize: null,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--threshold-tokens", "250000", "--threshold-percent", "70", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.policy.thresholdTokens).toBe(250_000);
expect(json.policy.thresholdPercent).toBe(70);
expect(json.candidates.map((entry: { session: string }) => entry.session)).toEqual([
"lower-token@test-rig",
"lower-percent@test-rig",
]);
expect(json.candidates.find((entry: { session: string }) => entry.session === "lower-token@test-rig").thresholdReason).toBe("above_token_threshold");
expect(json.candidates.find((entry: { session: string }) => entry.session === "lower-percent@test-rig").thresholdReason).toBe("above_percent_threshold_missing_window");
});
it("rejects invalid threshold options before reading daemon inventory", async () => {
const { deps, requestedPaths } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--threshold-tokens", "0"]);
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("--threshold-tokens");
expect(requestedPaths).toEqual([]);
});
it("surfaces stale context and missing attach or resume evidence as candidate caveats", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [node({
canonicalSessionName: "stale-no-attach@test-rig",
tmuxAttachCommand: null,
resumeCommand: null,
contextUsage: {
usedPercentage: 90,
remainingPercentage: 10,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: staleSample(),
fresh: false,
},
})],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const candidate = json.candidates[0];
expect(candidate.contextFreshness).toBe("stale");
expect(candidate.reasons).toContain("context_stale");
expect(candidate.missingEvidence).toContain("fresh_context_sample");
expect(candidate.missingEvidence).toContain("attach_or_resume_evidence");
});
it("blocks unknown context and missing canonical session, and excludes them from recommended order", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({
logicalId: "unknown.context",
canonicalSessionName: "unknown-context@test-rig",
contextUsage: { usedPercentage: null, remainingPercentage: null, contextWindowSize: null, source: null, availability: "unknown", sampledAt: null, fresh: false },
}),
node({
logicalId: "missing.session",
canonicalSessionName: null,
contextUsage: {
usedPercentage: 90,
remainingPercentage: 10,
contextWindowSize: 1_000_000,
source: "claude_statusline_json",
availability: "known",
sampledAt: freshSample(),
fresh: true,
},
}),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.candidates).toHaveLength(0);
expect(json.recommendedOrder).toEqual([]);
expect(json.blocked.find((entry: { logicalId: string }) => entry.logicalId === "unknown.context").reasons).toContain("context_unknown");
const missingSession = json.blocked.find((entry: { logicalId: string }) => entry.logicalId === "missing.session");
expect(missingSession.reasons).toContain("missing_canonical_session");
expect(missingSession.notificationPacket.text).toContain("cannot safely plan");
expect(missingSession.precompactRequirements).toContain("one_seat_at_a_time_only");
});
it("does not recommend non-running or not-ready Claude seats", async () => {
const { deps } = makeDeps({
nodesByRig: {
"rig-0": [
node({ canonicalSessionName: "stopped@test-rig", sessionStatus: "stopped" }),
node({ canonicalSessionName: "pending@test-rig", startupStatus: "pending" }),
],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.recommendedOrder).toEqual([]);
expect(json.blocked.map((entry: { reasons: string[] }) => entry.reasons).flat()).toContain("seat_not_running_or_ready");
});
it("--rig filters to one rig and unknown rig exits before refresh", async () => {
const { deps, requestedPaths } = makeDeps({
rigNames: ["rig-a", "rig-b"],
nodesByRig: {
"rig-0": [node({ rigId: "rig-0", rigName: "rig-a", canonicalSessionName: "lead@rig-a" })],
"rig-1": [node({ rigId: "rig-1", rigName: "rig-b", canonicalSessionName: "lead@rig-b" })],
},
});
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig", "--rig", "rig-b", "--json"]);
let json = JSON.parse(logs.join(""));
expect(json.candidates.map((entry: { session: string }) => entry.session)).toEqual(["lead@rig-b"]);
logs = [];
errors = [];
process.exitCode = undefined;
const unknownCmd = compactPlanCommand(deps);
await unknownCmd.parseAsync(["node", "rig", "--rig", "missing", "--refresh"]);
expect(process.exitCode).toBe(1);
expect(errors.join(" ")).toContain("not found");
expect(requestedPaths.some((path) => path.includes("refresh=true"))).toBe(false);
});
it("--refresh calls refresh before inventory and hard-fails without printing a plan on refresh failure", async () => {
const success = makeDeps();
const cmd = compactPlanCommand(success.deps);
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
const nodeCalls = success.requestedPaths.filter((path) => path.includes("/nodes"));
expect(nodeCalls[0]).toContain("refresh=true");
expect(nodeCalls[1]).not.toContain("refresh=true");
logs = [];
errors = [];
process.exitCode = undefined;
const failure = makeDeps({ refreshStatus: 502 });
const failCmd = compactPlanCommand(failure.deps);
await failCmd.parseAsync(["node", "rig", "--refresh"]);
expect(process.exitCode).toBe(2);
expect(errors.join("\n")).toContain("Compact-plan refresh failed");
expect(logs.join("\n")).not.toContain("READ-ONLY PLAN");
});
it("daemon down exits nonzero with honest next step copy", async () => {
const { getDaemonStatus } = await import("../src/daemon-lifecycle.js");
(getDaemonStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ state: "stopped", healthy: false });
const { deps } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig"]);
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("Daemon is not running");
expect(errors.join("\n")).toContain("rig daemon start");
});
it("human output is explicit that this is a read-only triage plan, not compact permission", async () => {
const { deps } = makeDeps();
const cmd = compactPlanCommand(deps);
await cmd.parseAsync(["node", "rig"]);
const output = logs.join("\n");
expect(output).toContain("READ-ONLY PLAN");
expect(output).toContain("does not compact");
expect(output).toContain("Thresholds:");
expect(output).toContain("one-seat-at-a-time");
expect(output).toContain("orch-lead@test-rig");
expect(output).toContain("authorization");
expect(output).toContain("checkpoint/restore");
expect(output).toContain("claude-compact-in-place");
expect(output).toContain("No automatic compaction has been run");
});
});
+255
View File
@@ -0,0 +1,255 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { contextCommand, type ContextDeps } from "../src/commands/context.js";
function makeDeps(overrides?: {
daemonState?: string;
rigNames?: string[];
nodesByRig?: Record<string, unknown[]>;
}): ContextDeps {
const rigs = (overrides?.rigNames ?? ["test-rig"]).map((name, i) => ({
rigId: `rig-${i}`, name, nodeCount: 1, runningCount: 1, status: "running", uptime: "1h", latestSnapshot: null,
}));
const nodesByRig = overrides?.nodesByRig ?? {
"rig-0": [
{
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.impl", canonicalSessionName: "dev-impl@test-rig",
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
contextUsage: {
usedPercentage: 85, remainingPercentage: 15, contextWindowSize: 1000000,
source: "claude_statusline_json", availability: "known",
sampledAt: new Date(Date.now() - 60_000).toISOString(), fresh: true,
},
},
{
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.qa", canonicalSessionName: "dev-qa@test-rig",
nodeKind: "agent", runtime: "codex", sessionStatus: "running", startupStatus: "ready",
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
contextUsage: { usedPercentage: null, remainingPercentage: null, contextWindowSize: null, source: null, availability: "unknown", sampledAt: null, fresh: false },
},
{
rigId: "rig-0", rigName: "test-rig", logicalId: "dev.synth", canonicalSessionName: "dev-synth@test-rig",
nodeKind: "agent", runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
restoreOutcome: "n-a", tmuxAttachCommand: null, resumeCommand: null, latestError: null,
contextUsage: {
usedPercentage: 50, remainingPercentage: 50, contextWindowSize: 1000000,
source: "claude_statusline_json", availability: "known",
sampledAt: new Date(Date.now() - 3600_000).toISOString(), fresh: false,
},
},
],
};
return {
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
if (path === "/api/ps") return { status: 200, data: rigs };
for (const [key, nodes] of Object.entries(nodesByRig)) {
if (path.includes(key)) return { status: 200, data: nodes };
}
return { status: 200, data: [] };
}),
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
};
}
// Patch getDaemonStatus + getDaemonUrl for tests
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
describe("rig context", () => {
let logs: string[];
let errors: string[];
beforeEach(() => {
logs = [];
errors = [];
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
process.exitCode = undefined;
});
it("human output includes seat table with status + fleet summary", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig"]);
const output = logs.join("\n");
expect(output).toContain("CONTEXT USAGE");
expect(output).toContain("dev-impl@test-rig");
expect(output).toContain("CRITICAL");
expect(output).toContain("dev-qa@test-rig");
expect(output).toContain("unknown");
expect(output).toContain("dev-synth@test-rig");
expect(output).toContain("stale");
expect(output).toContain("FLEET SUMMARY:");
});
it("JSON output includes seats array + summary with correct fields", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.seats).toBeInstanceOf(Array);
expect(json.seats.length).toBe(3);
expect(json.summary).toBeDefined();
expect(json.summary.total).toBe(3);
const critical = json.seats.find((s: { session: string }) => s.session === "dev-impl@test-rig");
expect(critical.urgency).toBe("critical");
expect(critical.freshness).toBe("fresh");
expect(critical.status).toBe("critical");
expect(critical.usedPercentage).toBe(85);
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
expect(unknown.urgency).toBe("unknown");
expect(unknown.status).toBe("unknown");
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
expect(stale.urgency).toBe("low");
expect(stale.freshness).toBe("stale");
expect(stale.status).toBe("stale");
});
it("sort order: critical > warning > stale > ok > unknown", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const sessions = json.seats.map((s: { session: string }) => s.session);
// critical first, then stale (low+stale), then unknown
expect(sessions).toEqual(["dev-impl@test-rig", "dev-synth@test-rig", "dev-qa@test-rig"]);
});
it("--rig filters to one rig", async () => {
const cmd = contextCommand(makeDeps({ rigNames: ["rig-a", "rig-b"] }));
await cmd.parseAsync(["node", "rig", "--rig", "rig-a", "--json"]);
const output = logs.join("\n");
// Should not error — even if node data is empty for the filtered rig
expect(process.exitCode).toBeUndefined();
});
it("--rig with unknown rig name exits 1", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--rig", "nonexistent"]);
expect(process.exitCode).toBe(1);
expect(errors.join(" ")).toContain("not found");
});
it("--threshold filters seats but keeps unknown and stale visible", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--threshold", "80", "--json"]);
const json = JSON.parse(logs.join(""));
const sessions = json.seats.map((s: { session: string }) => s.session);
// 85% critical (above threshold) + 50% stale (always visible) + unknown (always visible)
expect(sessions).toContain("dev-impl@test-rig");
expect(sessions).toContain("dev-synth@test-rig"); // stale — always visible
expect(sessions).toContain("dev-qa@test-rig"); // unknown — always visible
});
it("--threshold abc rejects with nonzero exit", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--threshold", "abc"]);
expect(process.exitCode).toBe(2);
expect(errors.join(" ")).toContain("integer percentage");
});
it("--threshold -1 rejects with nonzero exit", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--threshold", "-1"]);
expect(process.exitCode).toBe(2);
});
it("low + fresh = ok; low + stale = stale (never ok)", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const stale = json.seats.find((s: { session: string }) => s.session === "dev-synth@test-rig");
// 50% (low urgency) + stale freshness => status must be "stale", NOT "ok"
expect(stale.status).toBe("stale");
expect(stale.displayStatus).toBe("stale");
});
it("unknown seats have honest unknown status", async () => {
const cmd = contextCommand(makeDeps());
await cmd.parseAsync(["node", "rig", "--json"]);
const json = JSON.parse(logs.join(""));
const unknown = json.seats.find((s: { session: string }) => s.session === "dev-qa@test-rig");
expect(unknown.usedPercentage).toBeNull();
expect(unknown.urgency).toBe("unknown");
expect(unknown.status).toBe("unknown");
expect(unknown.fresh).toBe(false);
});
// --- --refresh CLI tests ---
it("--refresh calls /nodes?refresh=true once then fetches inventory normally", async () => {
const requestedPaths: string[] = [];
const deps: ContextDeps = {
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
requestedPaths.push(path);
if (path === "/api/ps") return { status: 200, data: [{ rigId: "rig-0", name: "test-rig" }] };
return { status: 200, data: [] };
}),
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
};
const cmd = contextCommand(deps);
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
// First non-ps call should be refresh, then normal inventory
const nodeCalls = requestedPaths.filter((p) => p.includes("/nodes"));
expect(nodeCalls.length).toBe(2);
expect(nodeCalls[0]).toContain("refresh=true");
expect(nodeCalls[1]).not.toContain("refresh=true");
expect(process.exitCode).toBeUndefined();
});
it("--refresh failure exits 2 with honest error copy", async () => {
const deps: ContextDeps = {
lifecycleDeps: {} as ContextDeps["lifecycleDeps"],
clientFactory: () => ({
get: vi.fn(async (path: string) => {
if (path === "/api/ps") return { status: 200, data: [{ rigId: "rig-0", name: "test-rig" }] };
if (path.includes("refresh=true")) return { status: 502, data: { code: "context_refresh_failed", error: "statusline read failed" } };
return { status: 200, data: [] };
}),
}) as unknown as ReturnType<ContextDeps["clientFactory"]>,
};
const cmd = contextCommand(deps);
await cmd.parseAsync(["node", "rig", "--refresh"]);
expect(process.exitCode).toBe(2);
const errorOutput = errors.join("\n");
expect(errorOutput).toContain("Context refresh failed");
expect(errorOutput).toContain("Fix:");
});
it("--refresh success returns same JSON semantics as non-refresh", async () => {
const deps = makeDeps();
const cmd = contextCommand(deps);
await cmd.parseAsync(["node", "rig", "--refresh", "--json"]);
const json = JSON.parse(logs.join(""));
expect(json.seats).toBeInstanceOf(Array);
expect(json.summary).toBeDefined();
// Should have the same structure as non-refresh output
expect(json.summary.total).toBeDefined();
expect(process.exitCode).toBeUndefined();
});
});
@@ -0,0 +1,557 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sendCommand, type SendDeps } from "../src/commands/send.js";
import { captureCommand, type CaptureDeps } from "../src/commands/capture.js";
import { psCommand, type PsDeps } from "../src/commands/ps.js";
import { whoamiCommand, type WhoamiDeps } from "../src/commands/whoami.js";
import type { CrossHostResult } from "../src/cross-host-executor.js";
import type { HostRegistryLoadResult } from "../src/host-registry.js";
// Capture stdout / stderr / exitCode for command actions.
interface CapturedOutput {
stdoutLines: string[];
stderrLines: string[];
stdoutWrites: string[];
stderrWrites: string[];
exitCode: number | undefined;
}
let captured: CapturedOutput;
let originalLog: typeof console.log;
let originalError: typeof console.error;
let originalStdoutWrite: typeof process.stdout.write;
let originalStderrWrite: typeof process.stderr.write;
beforeEach(() => {
captured = { stdoutLines: [], stderrLines: [], stdoutWrites: [], stderrWrites: [], exitCode: undefined };
originalLog = console.log;
originalError = console.error;
originalStdoutWrite = process.stdout.write.bind(process.stdout);
originalStderrWrite = process.stderr.write.bind(process.stderr);
console.log = (...args: unknown[]) => { captured.stdoutLines.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { captured.stderrLines.push(args.map(String).join(" ")); };
process.stdout.write = ((s: string | Uint8Array) => { captured.stdoutWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stdout.write;
process.stderr.write = ((s: string | Uint8Array) => { captured.stderrWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stderr.write;
process.exitCode = undefined;
});
afterEach(() => {
console.log = originalLog;
console.error = originalError;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
captured.exitCode = process.exitCode;
process.exitCode = undefined;
});
const KNOWN_REGISTRY: HostRegistryLoadResult = {
ok: true,
registry: {
hosts: [{ id: "vm-a", transport: "ssh", target: "vm-a.local" }],
},
};
function deps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): SendDeps & CaptureDeps {
const lifecycleDeps = {} as never;
const clientFactory = (() => ({}) as never) as never;
return {
lifecycleDeps,
clientFactory,
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
};
}
// ---------------------------------------------------------------------------
// `rig send --host <id>`
// ---------------------------------------------------------------------------
describe("send --host (cross-host short-circuit)", () => {
it("happy path: forwards reconstructed argv to the executor and surfaces remote stdout verbatim", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = sendCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: yes\n", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hello world", "--verify"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "send", "dev-impl@my-rig", "hello world", "--verify"]);
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
const stdoutText = captured.stdoutWrites.join("");
expect(stdoutText).toContain("Verified: yes");
expect(process.exitCode).toBeUndefined();
});
it("--verify honesty: SSH success + remote 'Verified: no' surfaces in output, NOT collapsed into success+silence", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: no\n", stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hi", "--verify"], { from: "user" });
const stdoutText = captured.stdoutWrites.join("");
expect(stdoutText).toContain("Verified: no"); // remote authority surfaced
// ssh layer succeeded; CLI does NOT inject Verified: yes.
expect(stdoutText).not.toMatch(/^Verified: yes$/m);
});
it("forwards --force, --wait-for-idle, --json flags into the remote argv", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = sendCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--force", "--json"], { from: "user" });
expect(captureCalls.argv).toContain("--force");
expect(captureCalls.argv).toContain("--json");
});
it("JSON output wraps the executor result in a cross_host envelope", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.result.ok).toBe(true);
});
it("unknown host id surfaces a friendly error with discoverability hint", async () => {
const cmd = sendCommand(deps());
await cmd.parseAsync(["--host", "vm-unknown", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-unknown'");
expect(captured.stderrLines.join("\n")).toContain("vm-a"); // known-id hint
expect(process.exitCode).toBe(1);
});
it("registry load failure surfaces the error", async () => {
const cmd = sendCommand(deps({
registry: { ok: false, error: "host registry not found at /nope" },
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("host registry not found");
expect(process.exitCode).toBe(1);
});
it("ssh-unreachable failure → exit 1 with operator-actionable message", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "ssh: connect to host: Connection refused" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
expect(captured.stderrLines.join("\n")).toContain("Verify SSH access");
expect(process.exitCode).toBe(1);
});
it("permission-gate failure → exit 1 with field-note hint", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("permission/auth gate");
expect(captured.stderrLines.join("\n")).toContain("See keychain doc");
expect(process.exitCode).toBe(1);
});
it("remote-daemon-unreachable → exit 1 with daemon-start hint", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("could not reach the remote daemon");
expect(captured.stderrLines.join("\n")).toContain("rig daemon start");
expect(process.exitCode).toBe(1);
});
it("remote-command-failed → exit 1 with remote stderr surfaced", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "session not found", remoteExitCode: 3 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("remote rig command on host=vm-a failed");
expect(captured.stderrLines.join("\n")).toContain("session not found");
expect(process.exitCode).toBe(1);
});
it("JSON output for failure surfaces failedStep + permission-gate hint under result", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.result.ok).toBe(false);
expect(out.result.failedStep).toBe("permission-gate");
expect(out.result.hint).toBe("See keychain doc");
expect(process.exitCode).toBe(1);
});
});
// ---------------------------------------------------------------------------
// `rig capture --host <id>`
// ---------------------------------------------------------------------------
describe("capture --host (cross-host short-circuit)", () => {
it("happy path: forwards reconstructed argv with session + lines", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = captureCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "pane content here\n", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--lines", "50"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "50"]);
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
expect(captured.stdoutWrites.join("")).toContain("pane content here");
});
it("multi-target form: forwards --rig and --pod when no session is supplied", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = captureCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "--rig", "my-rig", "--pod", "dev", "--json"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "capture", "--rig", "my-rig", "--pod", "dev", "--lines", "20", "--json"]);
});
it("unknown host id surfaces a friendly error", async () => {
const cmd = captureCommand(deps());
await cmd.parseAsync(["--host", "vm-bogus", "s@r"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
expect(process.exitCode).toBe(1);
});
it("ssh-unreachable failure surfaces actionable error", async () => {
const cmd = captureCommand(deps({
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "Connection refused" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
expect(process.exitCode).toBe(1);
});
it("JSON output wraps result in cross_host envelope", async () => {
const cmd = captureCommand(deps({
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.result.ok).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Compat regression: `rig send` and `rig capture` WITHOUT --host MUST NOT add
// the cross-host annotation and MUST NOT call the executor at all.
// ---------------------------------------------------------------------------
describe("compat regression: no --host means no cross-host annotation", () => {
it("send help text mentions --host (so operators discover it)", () => {
const cmd = sendCommand(deps());
expect(cmd.helpInformation()).toContain("--host");
});
it("capture help text mentions --host", () => {
const cmd = captureCommand(deps());
expect(cmd.helpInformation()).toContain("--host");
});
it("send without --host does NOT call the cross-host executor (registry loader untouched, executor untouched)", async () => {
const loaderSpy = vi.fn();
const runSpy = vi.fn();
const cmd = sendCommand({
lifecycleDeps: makeFailingLifecycleDeps(),
clientFactory: () => ({} as never),
hostRegistryLoader: loaderSpy as never,
crossHostRun: runSpy as never,
});
await cmd.parseAsync(["s@r", "msg"], { from: "user" });
// local path short-circuits at "Daemon not running" without consulting cross-host machinery.
expect(loaderSpy).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
});
it("capture without --host does NOT call the cross-host executor", async () => {
const loaderSpy = vi.fn();
const runSpy = vi.fn();
const cmd = captureCommand({
lifecycleDeps: makeFailingLifecycleDeps(),
clientFactory: () => ({} as never),
hostRegistryLoader: loaderSpy as never,
crossHostRun: runSpy as never,
});
await cmd.parseAsync(["s@r"], { from: "user" });
expect(loaderSpy).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
});
});
function makeFailingLifecycleDeps(): never {
// Stub LifecycleDeps that responds with "no daemon" on every probe — the
// local-path branch short-circuits with "Daemon not running" before doing
// anything cross-host.
return {
exists: (_p: string) => false,
readFileText: (_p: string) => "",
spawnDetached: () => ({ pid: 0 }),
isPidAlive: (_p: number) => false,
fetchHealthDetail: async () => ({ ok: false, code: "unreachable" }),
} as never;
}
// ---------------------------------------------------------------------------
// `rig ps --host <id>` (C9b)
// ---------------------------------------------------------------------------
function psDeps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): PsDeps {
return {
lifecycleDeps: {} as never,
clientFactory: (() => ({}) as never) as never,
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
};
}
describe("ps --host (cross-host short-circuit)", () => {
it("argv reconstruction: forwards every shaping flag in operator-declared order", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = psCommand(psDeps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: '[{"rigName":"r1"}]\n', stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync([
"--host", "vm-a", "--nodes", "--full", "--limit", "20",
"--fields", "rigName,status", "--summary", "--filter", "status=running", "--json",
], { from: "user" });
expect(captureCalls.argv).toEqual([
"rig", "ps", "--nodes", "--full", "--limit", "20", "--fields", "rigName,status",
"--summary", "--filter", "status=running", "--json",
]);
});
it("argv reconstruction: bare `--host vm-a` produces ['rig','ps'] with no extra flags", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = psCommand(psDeps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "ps"]);
});
it("verbatim remote stdout passthrough on success — no double-JSON-wrapping", async () => {
const remoteJson = '[{"rigName":"r1","status":"running"},{"rigName":"r2","status":"stopped"}]\n';
const cmd = psCommand(psDeps({
run: async () => ({ ok: true, failedStep: "none", stdout: remoteJson, stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
const stdoutText = captured.stdoutWrites.join("");
// Output is verbatim remote stdout; NOT wrapped in {cross_host:..., result:...}.
expect(stdoutText).toBe(remoteJson);
expect(stdoutText).not.toContain("cross_host");
});
it("ssh-unreachable failure surfaces actionable error and exits 1", async () => {
const cmd = psCommand(psDeps({
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "ssh: connect refused" }),
}));
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
expect(process.exitCode).toBe(1);
});
it("permission-gate failure surfaces hint and exits 1", async () => {
const cmd = psCommand(psDeps({
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
}));
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("permission/auth gate");
expect(captured.stderrLines.join("\n")).toContain("See keychain doc");
expect(process.exitCode).toBe(1);
});
it("remote-daemon-unreachable surfaces daemon-start hint", async () => {
const cmd = psCommand(psDeps({
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
}));
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("could not reach the remote daemon");
expect(process.exitCode).toBe(1);
});
it("remote-command-failed surfaces remote stderr", async () => {
const cmd = psCommand(psDeps({
run: async () => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "Some rig error", remoteExitCode: 3 }),
}));
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("remote rig command on host=vm-a failed");
expect(process.exitCode).toBe(1);
});
it("unknown host id surfaces friendly error with discoverability hint", async () => {
const cmd = psCommand(psDeps());
await cmd.parseAsync(["--host", "vm-bogus"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
expect(process.exitCode).toBe(1);
});
it("JSON output for failure surfaces top-level failedStep envelope", async () => {
const cmd = psCommand(psDeps({
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
}));
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.ok).toBe(false);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.failedStep).toBe("permission-gate");
expect(out.hint).toBe("See keychain doc");
expect(process.exitCode).toBe(1);
});
it("help text mentions --host (operator discoverability)", () => {
const cmd = psCommand(psDeps());
expect(cmd.helpInformation()).toContain("--host");
});
it("compat regression: ps without --host does NOT call registry loader OR executor", async () => {
const loaderSpy = vi.fn();
const runSpy = vi.fn();
const cmd = psCommand({
lifecycleDeps: makeFailingLifecycleDeps(),
clientFactory: () => ({} as never),
hostRegistryLoader: loaderSpy as never,
crossHostRun: runSpy as never,
});
await cmd.parseAsync([], { from: "user" });
expect(loaderSpy).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// `rig whoami --host <id>` (C9b')
// ---------------------------------------------------------------------------
function whoamiDeps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): WhoamiDeps {
return {
lifecycleDeps: {} as never,
clientFactory: (() => ({}) as never) as never,
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
};
}
describe("whoami --host (cross-host short-circuit)", () => {
it("argv reconstruction: forwards --node-id, --session, --json in operator-declared order", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = whoamiCommand(whoamiDeps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync([
"--host", "vm-a", "--node-id", "01ABC", "--session", "foo@rig", "--json",
], { from: "user" });
expect(captureCalls.argv).toEqual([
"rig", "whoami", "--node-id", "01ABC", "--session", "foo@rig", "--json",
]);
});
it("argv reconstruction: bare `--host vm-a` produces ['rig','whoami'] with no extra flags", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = whoamiCommand(whoamiDeps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "whoami"]);
});
it("cross-host runs BEFORE local identity-source resolution (no local --node-id/--session required)", async () => {
// Critical for whoami: the local resolveIdentitySource() walks env vars and
// tmux pane metadata. Cross-host must NOT require any of that — the remote
// rig has its own identity context.
const cmd = whoamiCommand(whoamiDeps({
run: async () => ({ ok: true, failedStep: "none", stdout: '{"rigName":"remote"}\n', stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
// Should NOT have errored on "Cannot determine identity"; should have
// successfully written remote stdout.
expect(captured.stderrLines.join("\n")).not.toContain("Cannot determine identity");
const stdoutText = captured.stdoutWrites.join("");
expect(stdoutText).toContain("remote");
});
it("verbatim remote stdout passthrough on success — no double-JSON-wrapping", async () => {
const remoteJson = '{"resolvedBy":"node_id","identity":{"rigName":"x"}}\n';
const cmd = whoamiCommand(whoamiDeps({
run: async () => ({ ok: true, failedStep: "none", stdout: remoteJson, stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
const stdoutText = captured.stdoutWrites.join("");
expect(stdoutText).toBe(remoteJson);
expect(stdoutText).not.toContain("cross_host");
});
it("ssh-unreachable failure exits 1 with actionable message", async () => {
const cmd = whoamiCommand(whoamiDeps({
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "Connection refused" }),
}));
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
expect(process.exitCode).toBe(1);
});
it("unknown host id surfaces friendly error", async () => {
const cmd = whoamiCommand(whoamiDeps());
await cmd.parseAsync(["--host", "vm-bogus"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
expect(process.exitCode).toBe(1);
});
it("JSON output for failure surfaces top-level failedStep envelope", async () => {
const cmd = whoamiCommand(whoamiDeps({
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
}));
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.ok).toBe(false);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.failedStep).toBe("remote-daemon-unreachable");
expect(process.exitCode).toBe(1);
});
it("help text mentions --host", () => {
const cmd = whoamiCommand(whoamiDeps());
expect(cmd.helpInformation()).toContain("--host");
});
it("compat regression: whoami without --host does NOT call registry loader OR executor", async () => {
const loaderSpy = vi.fn();
const runSpy = vi.fn();
const cmd = whoamiCommand({
lifecycleDeps: makeFailingLifecycleDeps(),
clientFactory: () => ({} as never),
hostRegistryLoader: loaderSpy as never,
crossHostRun: runSpy as never,
});
// Passing --node-id avoids the "cannot determine identity" branch; the
// local daemon-down path runs after that (or partial-result; either way
// does NOT consult the cross-host machinery).
await cmd.parseAsync(["--node-id", "abc"], { from: "user" });
expect(loaderSpy).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,258 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import {
runCrossHostCommand,
classifyResult,
shellQuote,
type SpawnFn,
} from "../src/cross-host-executor.js";
import type { HostEntry } from "../src/host-registry.js";
// ---------------------------------------------------------------------------
// Mock spawn helpers
// ---------------------------------------------------------------------------
interface MockChild extends EventEmitter {
stdin: { write: (s: string) => void; end: () => void };
stdout: EventEmitter;
stderr: EventEmitter;
}
function makeMockChild(): MockChild {
const child = new EventEmitter() as MockChild;
child.stdin = { write: vi.fn(), end: vi.fn() };
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
return child;
}
function mockSpawnFor(opts: {
exitCode: number;
stdout?: string;
stderr?: string;
capture?: { command?: string; args?: readonly string[] };
}): SpawnFn {
return ((command: string, args: readonly string[]) => {
if (opts.capture) {
opts.capture.command = command;
opts.capture.args = args;
}
const child = makeMockChild();
queueMicrotask(() => {
if (opts.stdout) child.stdout.emit("data", Buffer.from(opts.stdout, "utf-8"));
if (opts.stderr) child.stderr.emit("data", Buffer.from(opts.stderr, "utf-8"));
child.emit("close", opts.exitCode);
});
return child as never;
}) as SpawnFn;
}
const HOST: HostEntry = { id: "vm-test", transport: "ssh", target: "vm-test.local" };
const HOST_WITH_USER: HostEntry = { id: "vm-test", transport: "ssh", target: "vm-test.local", user: "ops" };
// ---------------------------------------------------------------------------
// shellQuote unit
// ---------------------------------------------------------------------------
describe("shellQuote", () => {
it("wraps simple strings in single quotes", () => {
expect(shellQuote("hello")).toBe("'hello'");
});
it("escapes embedded single quotes via the standard '\\'' trick", () => {
expect(shellQuote("it's fine")).toBe("'it'\\''s fine'");
});
it("preserves spaces, equals signs, and special chars literally inside the quotes", () => {
expect(shellQuote("a b=c d?e")).toBe("'a b=c d?e'");
});
});
// ---------------------------------------------------------------------------
// classifyResult — unit table
// ---------------------------------------------------------------------------
describe("classifyResult", () => {
it("exit 0 → ok / failedStep=none", () => {
const r = classifyResult(0, "Sent to dev-impl@rig\nVerified: yes\n", "");
expect(r).toEqual({
ok: true,
failedStep: "none",
stdout: "Sent to dev-impl@rig\nVerified: yes\n",
stderr: "",
remoteExitCode: 0,
});
});
it("exit 255 with Permission denied → permission-gate (with field-note hint)", () => {
const r = classifyResult(255, "", "ssh: Permission denied (publickey).");
expect(r.ok).toBe(false);
expect(r.failedStep).toBe("permission-gate");
if (r.failedStep === "permission-gate") {
expect(r.sshStderr).toContain("Permission denied");
expect(r.hint).toContain("Keychain-over-SSH");
}
});
it("exit 255 with Keychain pattern → permission-gate", () => {
const r = classifyResult(255, "", "Could not query Keychain entry for ...");
expect(r.failedStep).toBe("permission-gate");
});
it("exit 255 with unknown stderr → ssh-unreachable", () => {
const r = classifyResult(255, "", "ssh: connect to host vm-test.local port 22: Connection refused");
expect(r.failedStep).toBe("ssh-unreachable");
if (r.failedStep === "ssh-unreachable") {
expect(r.sshStderr).toContain("Connection refused");
}
});
it("exit 1 with Daemon-not-running stderr → remote-daemon-unreachable", () => {
const r = classifyResult(1, "", "Daemon not running. Start it with: rig daemon start\n");
expect(r.failedStep).toBe("remote-daemon-unreachable");
if (r.failedStep === "remote-daemon-unreachable") {
expect(r.remoteExitCode).toBe(1);
}
});
it("exit 2 with daemon-fetch-failure stderr → remote-daemon-unreachable", () => {
const r = classifyResult(2, "", "Failed to fetch rig list from daemon (HTTP 500). Check daemon status with: rig status\n");
expect(r.failedStep).toBe("remote-daemon-unreachable");
});
it("exit non-zero without daemon signal → remote-command-failed", () => {
const r = classifyResult(1, "", "Send failed: session not found\n");
expect(r.failedStep).toBe("remote-command-failed");
if (r.failedStep === "remote-command-failed") {
expect(r.remoteExitCode).toBe(1);
expect(r.stderr).toContain("session not found");
}
});
it("exit -1 (spawn error) → ssh-unreachable", () => {
const r = classifyResult(-1, "", "[spawn error] ENOENT");
expect(r.failedStep).toBe("ssh-unreachable");
});
});
// ---------------------------------------------------------------------------
// runCrossHostCommand — integration with mocked spawn
// ---------------------------------------------------------------------------
describe("runCrossHostCommand", () => {
it("constructs the ssh argv with ConnectTimeout and quoted remote command line", async () => {
const capture: { command?: string; args?: readonly string[] } = {};
const spawn = mockSpawnFor({ exitCode: 0, stdout: "ok\n", capture });
await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hello world", "--verify"], { spawn });
expect(capture.command).toBe("ssh");
expect(capture.args).toEqual([
"-o", "ConnectTimeout=10",
"vm-test.local",
"'rig' 'send' 'dev-impl@rig' 'hello world' '--verify'",
]);
});
it("includes -l <user> when host.user is set", async () => {
const capture: { command?: string; args?: readonly string[] } = {};
const spawn = mockSpawnFor({ exitCode: 0, capture });
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
expect(capture.args?.[0]).toBe("-l");
expect(capture.args?.[1]).toBe("ops");
});
it("happy path returns stdout/stderr verbatim", async () => {
const spawn = mockSpawnFor({
exitCode: 0,
stdout: "Sent to dev-impl@rig\nVerified: yes\n",
stderr: "",
});
const result = await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hello"], { spawn });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.stdout).toContain("Verified: yes");
expect(result.remoteExitCode).toBe(0);
}
});
it("--verify honest pass-through: ssh exit 0 + remote 'Verified: no' surfaces in stdout (NOT collapsed)", async () => {
// This is the slice's load-bearing rule: ssh success is NOT verify success.
const spawn = mockSpawnFor({
exitCode: 0,
stdout: "Sent to dev-impl@rig\nVerified: no\n",
});
const result = await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hi", "--verify"], { spawn });
expect(result.ok).toBe(true); // ssh layer succeeded
if (result.ok) {
// remote verify result is in stdout, not synthesized into ok=false.
// Callers (send command) are responsible for surfacing 'Verified: no' to the user.
expect(result.stdout).toContain("Verified: no");
}
});
it("classifies 255 + Permission denied as permission-gate", async () => {
const spawn = mockSpawnFor({ exitCode: 255, stderr: "ssh: Permission denied (publickey)." });
const result = await runCrossHostCommand(HOST, ["rig", "ps"], { spawn });
expect(result.failedStep).toBe("permission-gate");
});
it("classifies 255 + connection refused as ssh-unreachable", async () => {
const spawn = mockSpawnFor({
exitCode: 255,
stderr: "ssh: connect to host vm-test.local port 22: Connection refused",
});
const result = await runCrossHostCommand(HOST, ["rig", "ps"], { spawn });
expect(result.failedStep).toBe("ssh-unreachable");
});
it("classifies remote daemon-not-running as remote-daemon-unreachable", async () => {
const spawn = mockSpawnFor({
exitCode: 1,
stderr: "Daemon not running. Start it with: rig daemon start\n",
});
const result = await runCrossHostCommand(HOST, ["rig", "send", "x", "y"], { spawn });
expect(result.failedStep).toBe("remote-daemon-unreachable");
});
it("classifies other remote non-zero as remote-command-failed", async () => {
const spawn = mockSpawnFor({ exitCode: 3, stderr: "Some other rig error\n" });
const result = await runCrossHostCommand(HOST, ["rig", "send", "x", "y"], { spawn });
expect(result.failedStep).toBe("remote-command-failed");
if (result.failedStep === "remote-command-failed") {
expect(result.remoteExitCode).toBe(3);
}
});
it("rejects non-ssh transport (defense-in-depth on top of registry validation)", async () => {
const result = await runCrossHostCommand(
{ id: "vm-x", transport: "tailscale" as never, target: "vm-x.local" } as HostEntry,
["rig", "ps"],
{ spawn: mockSpawnFor({ exitCode: 0 }) },
);
expect(result.ok).toBe(false);
if (!result.ok && result.failedStep === "ssh-unreachable") {
expect(result.sshStderr).toContain("not supported");
}
});
it("forwards stdin to the spawned process when provided", async () => {
const writes: string[] = [];
let stdinEnded = false;
const spawn: SpawnFn = ((command: string, _args: readonly string[]) => {
const child = makeMockChild();
child.stdin.write = (s: string) => writes.push(s);
child.stdin.end = () => { stdinEnded = true; };
queueMicrotask(() => child.emit("close", 0));
return child as never;
}) as SpawnFn;
await runCrossHostCommand(HOST, ["cat"], { spawn, stdin: "from-stdin\n" });
expect(writes).toEqual(["from-stdin\n"]);
expect(stdinEnded).toBe(true);
});
it("respects custom connect timeout", async () => {
const capture: { args?: readonly string[] } = {};
await runCrossHostCommand(HOST, ["rig", "ps"], {
spawn: mockSpawnFor({ exitCode: 0, capture }),
connectTimeoutSeconds: 30,
});
expect(capture.args).toContain("ConnectTimeout=30");
});
});
+287 -7
View File
@@ -9,6 +9,7 @@ import {
tailLogs,
getDaemonPath,
resolveDaemonPath,
buildDaemonEnv,
STATE_FILE,
LOG_FILE,
OPENRIG_DIR,
@@ -36,6 +37,22 @@ function mockDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
};
}
function startableDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
let spawned = false;
const spawn = overrides?.spawn ?? vi.fn(() => {
spawned = true;
return { pid: 12345, unref: vi.fn() } as unknown as ChildProcess;
});
return mockDeps({
...overrides,
spawn,
fetch: overrides?.fetch ?? vi.fn(async () => {
if (!spawned) throw new Error("refused");
return { ok: true };
}),
});
}
function writtenState(deps: LifecycleDeps): DaemonState {
const call = (deps.writeFile as ReturnType<typeof vi.fn>).mock.calls.find(
(c: unknown[]) => (c[0] as string).endsWith("daemon.json")
@@ -54,7 +71,7 @@ describe("Daemon Lifecycle", () => {
// Test 2: start constructs exact spawn command with correct env/redirect
it("start: constructs spawn with node, daemon entry, env, and log redirect", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
@@ -73,7 +90,7 @@ describe("Daemon Lifecycle", () => {
// Test 3: start waits for healthz, writes daemon.json with pid+port+db+startedAt
it("start: writes daemon.json with pid, port, db, startedAt after healthz", async () => {
const deps = mockDeps();
const deps = startableDeps();
const result = await startDaemon({ port: 8000, db: "test.sqlite" }, deps);
expect(result.pid).toBe(12345);
@@ -102,6 +119,19 @@ describe("Daemon Lifecycle", () => {
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
});
it("start: recovered running daemon without daemon.json -> throws instead of spawning duplicate", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe("http://127.0.0.1:7433/healthz");
return { ok: true };
}),
});
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
expect(deps.spawn).not.toHaveBeenCalled();
});
// Test 5: stop reads pid from daemon.json, sends SIGTERM, removes daemon.json
it("stop: reads pid, sends SIGTERM, removes daemon.json", async () => {
const state: DaemonState = { pid: 555, port: 7433, db: "openrig.sqlite", startedAt: "2026-01-01T00:00:00Z" };
@@ -126,12 +156,26 @@ describe("Daemon Lifecycle", () => {
it("stop: not running -> does not throw", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
// Should not throw
await expect(stopDaemon(deps)).resolves.toBeUndefined();
});
it("stop: recovered running daemon without daemon.json -> throws honest error", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe("http://127.0.0.1:7433/healthz");
return { ok: true };
}),
});
await expect(stopDaemon(deps)).rejects.toThrow(/state is missing|cannot stop safely/i);
expect(deps.kill).not.toHaveBeenCalled();
});
// Test 7: status reads port from daemon.json, reports running with port
it("status: running daemon (pid alive + healthz ok) -> { state: 'running', port, pid }", async () => {
const state: DaemonState = { pid: 777, port: 9000, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
@@ -157,12 +201,42 @@ describe("Daemon Lifecycle", () => {
it("status: no daemon.json -> { state: 'stopped' }", async () => {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
const status = await getDaemonStatus(deps);
expect(status.state).toBe("stopped");
});
it("status: no daemon.json but configured healthz responds -> running without pid", async () => {
const savedPort = process.env["OPENRIG_PORT"];
const savedHost = process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7555";
process.env["OPENRIG_HOST"] = "127.0.0.1";
try {
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async (url: string) => {
expect(url).toBe("http://127.0.0.1:7555/healthz");
return { ok: true };
}),
});
const status = await getDaemonStatus(deps);
expect(status).toEqual({
state: "running",
host: "127.0.0.1",
port: 7555,
healthy: true,
});
} finally {
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
// Test 9: status stale (daemon.json exists, process dead) -> reports stale, cleans up
it("status: stale (daemon.json exists, pid dead) -> { state: 'stale' }, cleans up", async () => {
const state: DaemonState = { pid: 888, port: 7433, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
@@ -182,7 +256,7 @@ describe("Daemon Lifecycle", () => {
// Test 10: start --port flag stored in daemon.json and forwarded to env
it("start: custom port stored in daemon.json and forwarded to spawn env", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 9999 }, deps);
const state = writtenState(deps);
@@ -232,7 +306,8 @@ describe("Daemon Lifecycle", () => {
const { Command } = await import("commander");
const deps = mockDeps({
exists: vi.fn(() => false), // no daemon.json -> stopped
exists: vi.fn(() => false), // no daemon.json + no healthy daemon -> stopped
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
const program = new Command();
@@ -255,9 +330,74 @@ describe("Daemon Lifecycle", () => {
expect(logs.join("\n")).toMatch(/stopped/i);
});
it("daemonCommand status omits pid when daemon is recovered without state", async () => {
const savedPort = process.env["OPENRIG_PORT"];
const savedHost = process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7555";
process.env["OPENRIG_HOST"] = "127.0.0.1";
try {
const { daemonCommand } = await import("../src/commands/daemon.js");
const { Command } = await import("commander");
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => ({ ok: true })),
});
const program = new Command();
program.addCommand(daemonCommand(deps));
const logs: string[] = [];
const origLog = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await program.parseAsync(["node", "rig", "daemon", "status"]);
} finally {
console.log = origLog;
}
const output = logs.join("\n");
expect(output).toContain("Daemon running on port 7555");
expect(output).not.toContain("pid undefined");
} finally {
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
it("daemonCommand stop surfaces missing-state recovery error instead of claiming success", async () => {
const { daemonCommand } = await import("../src/commands/daemon.js");
const { Command } = await import("commander");
const deps = mockDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => ({ ok: true })),
});
const program = new Command();
program.addCommand(daemonCommand(deps));
const logs: string[] = [];
const origErr = console.error;
const savedExitCode = process.exitCode;
process.exitCode = undefined;
console.error = (...args: unknown[]) => logs.push(args.join(" "));
try {
await program.parseAsync(["node", "rig", "daemon", "stop"]);
} finally {
console.error = origErr;
}
expect(logs.join("\n")).toMatch(/state is missing|cannot stop safely/i);
expect(process.exitCode).toBe(1);
process.exitCode = savedExitCode;
});
// Test 15: start creates ~/.openrig directory if missing
it("start: creates OPENRIG_DIR if missing", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 7433 }, deps);
expect(deps.mkdirp).toHaveBeenCalledWith(OPENRIG_DIR);
@@ -440,6 +580,7 @@ describe("Daemon Lifecycle", () => {
if (p === STATE_FILE) return "NOT VALID JSON {{{";
return null;
}),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
const status = await getDaemonStatus(deps);
@@ -448,7 +589,7 @@ describe("Daemon Lifecycle", () => {
// Test 22: malformed daemon.json -> startDaemon proceeds (treats as no state)
it("malformed daemon.json -> startDaemon proceeds normally", async () => {
const deps = mockDeps({
const deps = startableDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return "GARBAGE";
@@ -500,7 +641,7 @@ describe("Daemon Lifecycle", () => {
// Test 25: start spawns process.execPath, not bare "node"
it("start: spawns process.execPath as the Node binary", async () => {
const deps = mockDeps();
const deps = startableDeps();
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
@@ -638,3 +779,142 @@ describe("resolveDaemonPath", () => {
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
});
});
describe("startDaemon env sanitization", () => {
it("startDaemon spawns with sanitized env that excludes terminal/GUI vars", async () => {
// Pollute process.env temporarily
const saved: Record<string, string | undefined> = {};
const pollutants: Record<string, string> = {
GHOSTTY_BIN_DIR: "/bad",
TERM_PROGRAM: "ghostty",
CMUX_WORKSPACE: "workspace:1",
CMUX_PANEL_ID: "panel:7",
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
CODEX_CI: "1",
COMMAND_MODE: "unix2003",
__CFBundleIdentifier: "com.test",
};
for (const [k, v] of Object.entries(pollutants)) {
saved[k] = process.env[k];
process.env[k] = v;
}
try {
const deps = startableDeps();
await startDaemon({ port: 7433, host: "127.0.0.1", db: "/tmp/test.db" }, deps);
const spawnCall = (deps.spawn as ReturnType<typeof vi.fn>).mock.calls[0];
const spawnEnv = spawnCall![2].env as Record<string, string>;
// Scrubbed vars must NOT be in spawn env
expect(spawnEnv["GHOSTTY_BIN_DIR"]).toBeUndefined();
expect(spawnEnv["TERM_PROGRAM"]).toBeUndefined();
expect(spawnEnv["CMUX_WORKSPACE"]).toBeUndefined();
expect(spawnEnv["CODEX_CI"]).toBeUndefined();
expect(spawnEnv["COMMAND_MODE"]).toBeUndefined();
expect(spawnEnv["__CFBundleIdentifier"]).toBeUndefined();
// cmux context needed for workspace-bound commands should survive
expect(spawnEnv["CMUX_PANEL_ID"]).toBe("panel:7");
expect(spawnEnv["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
// Core vars must be present
expect(spawnEnv["HOME"]).toBeDefined();
expect(spawnEnv["OPENRIG_PORT"]).toBe("7433");
} finally {
// Restore process.env
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
});
});
describe("buildDaemonEnv", () => {
it("strips terminal/GUI vars and preserves core vars", () => {
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/opt/homebrew/bin:/usr/bin:/bin",
USER: "tester",
SHELL: "/bin/zsh",
COLORTERM: "truecolor",
COMMAND_MODE: "unix2003",
CODEX_CI: "1",
CODEX_THREAD_ID: "thread-123",
GHOSTTY_BIN_DIR: "/Applications/Ghostty.app/bin",
GHOSTTY_RESOURCES_DIR: "/Applications/Ghostty.app/resources",
GHOSTTY_SHELL_FEATURES: "cursor,title",
TERM_PROGRAM: "ghostty",
TERM_PROGRAM_VERSION: "1.0",
TERMINFO: "/Applications/Ghostty.app/terminfo",
XPC_FLAGS: "0x0",
XPC_SERVICE_NAME: "0",
__CFBundleIdentifier: "com.cmuxterm.app",
__CF_USER_TEXT_ENCODING: "0x1F5:0x0:0x0",
CMUX_BUNDLE_ID: "com.cmuxterm.app",
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
CMUX_PANEL_ID: "panel:1",
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
CMUX_TAB_ID: "tab:1",
CMUX_WORKSPACE: "workspace:1",
CMUX_WORKSPACE_ID: "workspace:1",
CMUX_SURFACE_ID: "surface:3",
LANG: "en_US.UTF-8",
};
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/tmp/test.db" });
// Core vars preserved
expect(result["HOME"]).toBe("/Users/tester");
expect(result["PATH"]).toBe("/opt/homebrew/bin:/usr/bin:/bin");
expect(result["USER"]).toBe("tester");
expect(result["SHELL"]).toBe("/bin/zsh");
expect(result["LANG"]).toBe("en_US.UTF-8");
// OPENRIG_* set from opts
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_HOST"]).toBe("127.0.0.1");
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
// Scrubbed vars absent
expect(result["COLORTERM"]).toBeUndefined();
expect(result["COMMAND_MODE"]).toBeUndefined();
expect(result["CODEX_CI"]).toBeUndefined();
expect(result["CODEX_THREAD_ID"]).toBeUndefined();
expect(result["GHOSTTY_BIN_DIR"]).toBeUndefined();
expect(result["GHOSTTY_RESOURCES_DIR"]).toBeUndefined();
expect(result["GHOSTTY_SHELL_FEATURES"]).toBeUndefined();
expect(result["TERM_PROGRAM"]).toBeUndefined();
expect(result["TERM_PROGRAM_VERSION"]).toBeUndefined();
expect(result["TERMINFO"]).toBeUndefined();
expect(result["XPC_FLAGS"]).toBeUndefined();
expect(result["XPC_SERVICE_NAME"]).toBeUndefined();
expect(result["__CFBundleIdentifier"]).toBeUndefined();
expect(result["__CF_USER_TEXT_ENCODING"]).toBeUndefined();
expect(result["CMUX_SOCKET_PATH"]).toBeUndefined();
expect(result["CMUX_WORKSPACE"]).toBeUndefined();
expect(result["CMUX_SURFACE_ID"]).toBeUndefined();
// cmux context needed for workspace-bound commands is preserved
expect(result["CMUX_BUNDLE_ID"]).toBe("com.cmuxterm.app");
expect(result["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
expect(result["CMUX_PANEL_ID"]).toBe("panel:1");
expect(result["CMUX_TAB_ID"]).toBe("tab:1");
expect(result["CMUX_WORKSPACE_ID"]).toBe("workspace:1");
});
it("explicit opts override inherited OPENRIG_* from base env", () => {
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/usr/bin",
OPENRIG_PORT: "9999",
OPENRIG_DB: "/old/path.db",
};
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/new/path.db" });
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
});
});
+448
View File
@@ -0,0 +1,448 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import Database from "better-sqlite3";
import {
DESTROY_CONFIRM_TOKEN,
buildBackupPath,
buildDestroyPlan,
executeDestroy,
listManagedTmuxSessionsFromDb,
type DestroyDeps,
} from "../src/destroy-helpers.js";
import { destroyCommand } from "../src/commands/destroy.js";
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
process.exitCode = undefined;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
console.error = (...args: unknown[]) => logs.push(args.join(" "));
try {
await fn();
} finally {
console.log = origLog;
console.error = origErr;
}
const exitCode = process.exitCode;
process.exitCode = origExitCode;
resolve({ logs, exitCode });
});
}
function makeDestroyDeps(overrides?: Partial<DestroyDeps>): DestroyDeps {
return {
stopDaemon: vi.fn(async () => {}),
inspectListener: vi.fn(async () => ({ kind: "unreachable" as const })),
findListeningPid: vi.fn(() => null),
killProcess: vi.fn(),
exists: vi.fn(() => false),
renamePath: vi.fn(),
removePath: vi.fn(),
mkdirp: vi.fn(),
listManagedTmuxSessions: vi.fn(() => []),
killTmuxSession: vi.fn(() => true),
sleep: vi.fn(async () => {}),
now: vi.fn(() => new Date("2026-04-10T08:10:11Z")),
...overrides,
};
}
describe("destroy helpers", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "openrig-destroy-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("buildBackupPath appends timestamp and resolves collisions", () => {
const target = join(tmpDir, ".openrig");
const first = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
expect(basename(first)).toMatch(/^\.openrig\.backup-20260410-\d{6}$/);
mkdirSync(first, { recursive: true });
const second = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
expect(second).toBe(`${first}-2`);
});
it("listManagedTmuxSessionsFromDb returns distinct tmux-backed sessions", () => {
const dbPath = join(tmpDir, "openrig.sqlite");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE nodes (id TEXT PRIMARY KEY);
CREATE TABLE bindings (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
tmux_session TEXT,
tmux_window TEXT,
tmux_pane TEXT,
cmux_workspace TEXT,
cmux_surface TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
attachment_type TEXT NOT NULL DEFAULT 'tmux',
external_session_name TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
session_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n3");
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
.run("b1", "n1", "orch1-lead@demo", "tmux");
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
.run("b2", "n2", "orch1-lead@demo", "tmux");
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
.run("s3", "n3", "dev1-impl@demo");
db.prepare("INSERT INTO bindings (id, node_id, attachment_type, external_session_name) VALUES (?, ?, ?, ?)")
.run("b3", "n3", "external_cli", "ext-cli");
db.close();
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["orch1-lead@demo"]);
});
it("listManagedTmuxSessionsFromDb prefers the current session for a node over historical names", () => {
const dbPath = join(tmpDir, "openrig-current.sqlite");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE nodes (id TEXT PRIMARY KEY);
CREATE TABLE bindings (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
tmux_session TEXT,
tmux_window TEXT,
tmux_pane TEXT,
cmux_workspace TEXT,
cmux_surface TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
attachment_type TEXT NOT NULL DEFAULT 'tmux',
external_session_name TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
session_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
.run("s1", "n1", "old-name@demo", "2026-04-10T08:00:00Z");
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
.run("s2", "n1", "current-name@demo", "2026-04-10T08:05:00Z");
db.close();
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["current-name@demo"]);
});
it("buildDestroyPlan includes external db/transcript targets outside state root", () => {
const stateRoot = join(tmpDir, ".openrig");
const externalDb = join(tmpDir, "custom", "openrig.sqlite");
const externalTx = join(tmpDir, "custom", "transcripts");
mkdirSync(stateRoot, { recursive: true });
mkdirSync(join(tmpDir, "custom"), { recursive: true });
writeFileSync(externalDb, "");
mkdirSync(externalTx, { recursive: true });
const deps = makeDestroyDeps({
exists: (path: string) => existsSync(path),
listManagedTmuxSessions: () => [],
});
const plan = buildDestroyPlan("state", true, {
stateRoot,
dbPath: externalDb,
transcriptsPath: externalTx,
daemonHost: "127.0.0.1",
daemonPort: 7433,
}, deps);
expect(plan.targets.map((target) => target.kind)).toEqual(["state_root", "db_file", "transcripts_dir"]);
expect(plan.warnings.length).toBe(2);
});
it("executeDestroy stops daemon, kills managed tmux sessions, backs up state root, and recreates it", async () => {
const stateRoot = join(tmpDir, ".openrig");
mkdirSync(stateRoot, { recursive: true });
writeFileSync(join(stateRoot, "daemon.json"), "{}");
const dbPath = join(stateRoot, "openrig.sqlite");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE nodes (id TEXT PRIMARY KEY);
CREATE TABLE bindings (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
tmux_session TEXT,
tmux_window TEXT,
tmux_pane TEXT,
cmux_workspace TEXT,
cmux_surface TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
attachment_type TEXT NOT NULL DEFAULT 'tmux',
external_session_name TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
session_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
.run("b1", "n1", "orch1-lead@demo", "tmux");
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
.run("s2", "n2", "dev1-impl@demo");
db.close();
const deps = makeDestroyDeps({
exists: (path: string) => existsSync(path),
renamePath: (from: string, to: string) => renameSync(from, to),
mkdirp: (path: string) => mkdirSync(path, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
killTmuxSession: (sessionName: string) => sessionName !== "dev1-impl@demo",
inspectListener: vi.fn()
.mockResolvedValueOnce({ kind: "openrig" })
.mockResolvedValueOnce({ kind: "unreachable" })
.mockResolvedValueOnce({ kind: "unreachable" }),
findListeningPid: () => 1234,
});
const plan = buildDestroyPlan("all", true, {
stateRoot,
dbPath,
transcriptsPath: join(stateRoot, "transcripts"),
daemonHost: "127.0.0.1",
daemonPort: 7433,
}, deps);
const result = await executeDestroy(plan, deps);
expect(result.daemonStopped).toBe(true);
expect(result.portCleared).toBe(true);
expect(result.stateRecreated).toBe(true);
expect(result.tmuxKilled).toBe(1);
expect(result.tmuxMissing).toBe(1);
expect(result.backupPaths).toHaveLength(1);
expect(existsSync(stateRoot)).toBe(true);
expect(deps.killProcess).toHaveBeenCalledWith(1234);
});
it("executeDestroy leaves state untouched when a local listener is still live but cannot be safely cleared", async () => {
const stateRoot = join(tmpDir, ".openrig-live");
mkdirSync(stateRoot, { recursive: true });
const removePath = vi.fn();
const mkdirp = vi.fn();
const deps = makeDestroyDeps({
exists: () => true,
removePath,
mkdirp,
inspectListener: vi.fn(async () => ({ kind: "openrig" })),
findListeningPid: () => null,
});
const result = await executeDestroy({
scope: "state",
backup: false,
stateRoot,
daemonHost: "127.0.0.1",
daemonPort: 7433,
managedTmuxSessions: [],
targets: [{ path: stateRoot, kind: "state_root" }],
warnings: [],
}, deps);
expect(result.portCleared).toBe(false);
expect(result.stateRecreated).toBe(false);
expect(removePath).not.toHaveBeenCalled();
expect(mkdirp).not.toHaveBeenCalled();
});
it("executeDestroy does not kill a live non-OpenRig HTTP listener", async () => {
const killProcess = vi.fn();
const deps = makeDestroyDeps({
exists: () => true,
removePath: vi.fn(),
mkdirp: vi.fn(),
inspectListener: vi.fn(async () => ({ kind: "other_http", detail: "HTTP 200" })),
findListeningPid: () => 7777,
killProcess,
});
const result = await executeDestroy({
scope: "state",
backup: false,
stateRoot: join(tmpDir, ".openrig-http"),
daemonHost: "127.0.0.1",
daemonPort: 7433,
managedTmuxSessions: [],
targets: [{ path: join(tmpDir, ".openrig-http"), kind: "state_root" }],
warnings: [],
}, deps);
expect(result.portCleared).toBe(false);
expect(killProcess).not.toHaveBeenCalled();
});
it("executeDestroy does not enumerate or kill tmux sessions for --state", async () => {
const stateRoot = join(tmpDir, ".openrig-state-only");
mkdirSync(stateRoot, { recursive: true });
const listManagedTmuxSessions = vi.fn(() => ["orchestrator@demo"]);
const killTmuxSession = vi.fn(() => true);
const deps = makeDestroyDeps({
exists: () => true,
removePath: vi.fn(),
mkdirp: vi.fn(),
listManagedTmuxSessions,
killTmuxSession,
});
const plan = buildDestroyPlan("state", false, {
stateRoot,
dbPath: join(stateRoot, "openrig.sqlite"),
transcriptsPath: join(stateRoot, "transcripts"),
daemonHost: "127.0.0.1",
daemonPort: 7433,
}, deps);
await executeDestroy(plan, deps);
expect(listManagedTmuxSessions).not.toHaveBeenCalled();
expect(killTmuxSession).not.toHaveBeenCalled();
});
it("executeDestroy does not report remote daemon targets as cleared", async () => {
const deps = makeDestroyDeps({
exists: () => false,
mkdirp: vi.fn(),
});
const result = await executeDestroy({
scope: "state",
backup: false,
stateRoot: join(tmpDir, ".openrig-remote"),
daemonHost: "10.0.0.5",
daemonPort: 7433,
managedTmuxSessions: [],
targets: [],
warnings: [],
}, deps);
expect(result.daemonStopped).toBe(false);
expect(result.portCleared).toBe(false);
expect(result.stateRecreated).toBe(false);
});
});
describe("destroy command", () => {
it("requires exactly one destroy scope", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps: makeDestroyDeps(),
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("Specify exactly one destroy scope");
});
it("requires explicit confirmation token", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps: makeDestroyDeps(),
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", "wrong"]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
});
it("requires --yes", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps: makeDestroyDeps(),
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("Destroy requires --yes");
});
it("runs destroy with backup mode and prints plan/result", async () => {
const destroyDeps = makeDestroyDeps({
exists: vi.fn(() => false),
mkdirp: vi.fn(),
});
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
destroyDeps,
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--backup", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBeUndefined();
const output = logs.join("\n");
expect(output).toContain("DESTROY PLAN");
expect(output).toContain("scope: state");
expect(output).toContain("DESTROY RESULT");
expect(output).toContain("fresh state root:");
});
it("falls back to compatibility defaults when config resolution is malformed", async () => {
const destroyDeps = makeDestroyDeps({
exists: vi.fn(() => false),
mkdirp: vi.fn(),
});
const program = new Command();
program.exitOverride();
program.addCommand(destroyCommand({
configStore: { resolve: () => { throw new Error("bad config json"); } },
destroyDeps,
}));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("DESTROY PLAN");
expect(logs.join("\n")).toContain("bad config json");
});
});
+370 -3
View File
@@ -1,6 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { Command } from "commander";
import { doctorCommand, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
const defaultConfig = {
daemon: { port: 7433, host: "127.0.0.1" },
@@ -12,9 +15,16 @@ function makeDeps(overrides?: Partial<DoctorDeps>): DoctorDeps {
return {
exists: () => true,
baseDir: "/install/cli/dist",
exec: () => "tmux 3.4\n",
readFile: () => null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
checkPort: async () => true,
configStore: { resolve: () => defaultConfig },
platform: "darwin",
mkdirp: () => {},
checkWritable: () => {},
...overrides,
@@ -68,20 +78,309 @@ describe("runDoctorChecks", () => {
});
it("tmux available -> pass", () => {
const deps = makeDeps({ exec: () => "tmux 3.4" });
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") return "";
if (cmd === "tmux show-options -gqv mouse") return "on\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const tmuxCheck = checks.find((c) => c.name === "tmux");
expect(tmuxCheck?.status).toBe("pass");
});
it("tmux installed but default control socket unhealthy -> fail with attention guidance", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const tmuxCheck = checks.find((c) => c.name === "tmux");
expect(tmuxCheck?.status).toBe("fail");
expect(tmuxCheck?.message).toContain("control socket");
expect(tmuxCheck?.reason).toContain("server exited unexpectedly");
expect(tmuxCheck?.fix).toContain("restart the default tmux server");
});
it("tmux mouse enabled on macOS -> pass", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux show-options -gqv mouse") return "on\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
expect(mouseCheck?.status).toBe("pass");
expect(mouseCheck?.message).toContain("enabled");
});
it("tmux mouse disabled on macOS -> warn with exact fix", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "tmux show-options -gqv mouse") return "off\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
expect(mouseCheck?.status).toBe("warn");
expect(mouseCheck?.message).toContain("disabled");
expect(mouseCheck?.fix).toContain("tmux set -g mouse on");
expect(mouseCheck?.fix).toContain("~/.tmux.conf");
expect(mouseCheck?.fix).toContain("tmux source-file ~/.tmux.conf");
});
it("tmux missing -> fail with guidance", () => {
const deps = makeDeps({ exec: () => { throw new Error("not found"); } });
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") throw new Error("not found");
return "";
},
});
const { checks } = runDoctorChecks(deps);
const tmuxCheck = checks.find((c) => c.name === "tmux");
expect(tmuxCheck?.status).toBe("fail");
expect(tmuxCheck?.fix).toContain("brew");
});
it("tmux mouse check is omitted on non-macOS hosts", () => {
const deps = makeDeps({
platform: "linux",
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
expect(checks.find((c) => c.name === "tmux_mouse")).toBeUndefined();
});
it("cmux_shell pass when shell capabilities work", () => {
const deps = makeDeps();
const { checks } = runDoctorChecks(deps);
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("pass");
});
it("cmux_shell warn when shell cmux installed but control unavailable", () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") {
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
}
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const { checks } = runDoctorChecks(deps);
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("warn");
expect(cmuxShell?.message).toContain("control unavailable");
expect(cmuxShell?.fix).toContain("cmuxOnly");
expect(cmuxShell?.fix).toContain("socketControlMode");
});
it("cmux_shell warn when cmux missing", () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
throw new Error("command not found: cmux");
},
});
const { checks } = runDoctorChecks(deps);
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("warn");
expect(cmuxShell?.message).toContain("not found");
});
it("shell cmux pass + daemon cmux unavailable -> cmux_daemon warn with mismatch guidance", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false, // port in use = daemon running
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxShell = allChecks.find((c) => c.name === "cmux_shell");
expect(cmuxShell?.status).toBe("pass");
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.message).toContain("daemon cannot control");
expect(cmuxDaemon?.fix).toContain("rig daemon start");
});
it("shell cmux pass + restrictive macOS socket control + daemon cmux unavailable -> cmux_daemon points at socket control mode", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("socketControlMode");
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
expect(cmuxDaemon?.fix).toContain("rig setup");
expect(cmuxDaemon?.fix).toContain("automation");
});
it("shell cmux pass + implicit default cmuxOnly + daemon cmux unavailable -> cmux_daemon points at effective default, not a fake file value", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? null : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("default");
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
expect(cmuxDaemon?.reason).not.toContain("is 'cmuxOnly' in");
});
it("shell cmux pass + unreadable cmux settings -> cmux_daemon warns about parse failure", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n invalid\n" : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("unreadable");
expect(cmuxDaemon?.fix).toContain("~/.config/cmux/settings.json");
});
it("shell cmux pass + compatible password mode + daemon cmux unavailable -> falls back to generic daemon warning", async () => {
const deps = makeDeps({
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false,
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon?.status).toBe("warn");
expect(cmuxDaemon?.reason).toContain("inherited a terminal/session environment");
expect(cmuxDaemon?.reason).not.toContain("socketControlMode");
});
it("daemon not running -> cmux_daemon skipped and does not make doctor unhealthy", async () => {
const deps = makeDeps({
checkPort: async () => true,
fetch: async () => { throw new Error("ECONNREFUSED"); },
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("skipped");
expect(cmuxDaemon?.message).toContain("not reachable");
const healthy = allChecks.every((c) => c.status !== "fail");
expect(healthy).toBe(true);
});
it("daemon cmux available -> cmux_daemon pass", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
return { ok: true } as Response;
},
checkPort: async () => false, // port in use = daemon running
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon?.status).toBe("pass");
});
it("shell cmux missing -> no cmux_daemon check (no misleading signal)", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
throw new Error("command not found: cmux");
},
checkPort: async () => false, // daemon running
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
});
const { checks, asyncChecks } = runDoctorChecks(deps);
const resolved = await Promise.all(asyncChecks ?? []);
const allChecks = [...checks, ...resolved];
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
expect(cmuxDaemon).toBeUndefined();
});
it("Node version check passes on current Node", () => {
const deps = makeDeps();
const { checks } = runDoctorChecks(deps);
@@ -174,6 +473,31 @@ describe("rig doctor", () => {
expect(parsed.checks.length).toBeGreaterThan(0);
});
it("--json stays healthy when cmux is only a warning", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
if (cmd === "tmux -V") return "tmux 3.4\n";
if (cmd === "cmux capabilities --json") {
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
}
if (cmd === "cmux --help") return "cmux help\n";
return "";
},
});
const program = new Command();
program.addCommand(doctorCommand(deps));
const { logs, exitCode } = await captureLogs(() =>
program.parseAsync(["node", "rig", "doctor", "--json"]),
);
const parsed = JSON.parse(logs.join("\n"));
const cmuxShellCheck = parsed.checks.find((check: { name: string }) => check.name === "cmux_shell");
expect(parsed.healthy).toBe(true);
expect(cmuxShellCheck.status).toBe("warn");
expect(exitCode).toBeUndefined();
});
it("--json exits non-zero when writable state paths fail", async () => {
const deps = makeDeps({
checkWritable: () => {
@@ -194,6 +518,49 @@ describe("rig doctor", () => {
expect(exitCode).toBe(1);
});
it("--json surfaces cmux_daemon pass when daemon cmux is available", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
return { ok: true } as Response;
},
});
const program = new Command();
program.addCommand(doctorCommand(deps));
const { logs } = await captureLogs(() =>
program.parseAsync(["node", "rig", "doctor", "--json"]),
);
const parsed = JSON.parse(logs.join("\n"));
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon.status).toBe("pass");
});
it("--json surfaces cmux_daemon warn when shell cmux works but daemon cannot control", async () => {
const deps = makeDeps({
fetch: async (url: string) => {
if (url.includes("/healthz")) return { ok: true } as Response;
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
return { ok: true } as Response;
},
});
const program = new Command();
program.addCommand(doctorCommand(deps));
const { logs } = await captureLogs(() =>
program.parseAsync(["node", "rig", "doctor", "--json"]),
);
const parsed = JSON.parse(logs.join("\n"));
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
expect(cmuxDaemon).toBeDefined();
expect(cmuxDaemon.status).toBe("warn");
expect(parsed.healthy).toBe(true); // warn does not make doctor unhealthy
});
it("wired via createProgram", async () => {
const { createProgram } = await import("../src/index.js");
const program = createProgram();
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { envCommand } from "../src/commands/env.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(): 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),
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
process.exitCode = undefined;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
console.error = (...args: unknown[]) => logs.push(args.join(" "));
try { await fn(); } finally {
console.log = origLog;
console.error = origErr;
}
const exitCode = process.exitCode;
process.exitCode = origExitCode;
resolve({ logs, exitCode });
});
}
describe("rig env", () => {
let server: http.Server;
let port: number;
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
beforeAll(async () => {
server = http.createServer((req, res) => {
const url = req.url ?? "";
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (url === "/api/rigs/summary") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(rigSummary));
return;
}
if (url.includes("/env/logs")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, output: "vault | 2026-04-09 12:00:00 Starting vault server..." }));
return;
}
if (url.includes("/env/down") && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (url.includes("/env")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
ok: true,
hasServices: true,
kind: "compose",
projectName: "my-rig",
receipt: {
kind: "compose",
services: [{ name: "vault", status: "running", health: "healthy" }],
waitFor: [],
capturedAt: "2026-04-09T00:00:00Z",
},
}));
return;
}
res.writeHead(404).end();
});
});
await new Promise<void>((resolve) => { server.listen(0, resolve); });
port = (server.address() as { port: number }).port;
});
afterAll(() => { server.close(); });
function runningDeps(): 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-04-09T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
},
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(envCommand(runningDeps()));
return prog;
}
it("env help explains managed-app usage", () => {
const logs: string[] = [];
const cmd = makeCmd().commands.find((c) => c.name() === "env")!;
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Inspect and control rig environment services");
expect(help).toContain("service-backed rigs and managed");
expect(help).toContain("rig env status secrets-manager");
expect(help).toContain("rig env logs secrets-manager vault");
});
it("env status prints service status", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig"]);
});
const output = logs.join("\n");
expect(output).toContain("vault");
expect(output).toContain("running");
});
it("env status --json returns machine-readable output", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.hasServices).toBe(true);
expect(parsed.receipt.services).toHaveLength(1);
});
it("env logs returns service logs", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "logs", "my-rig"]);
});
expect(logs.join("\n")).toContain("Starting vault server");
});
it("env down stops services", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "env", "down", "my-rig"]);
});
expect(logs.join("\n")).toContain("Services stopped");
});
it("env is wired via createProgram", async () => {
const { createProgram } = await import("../src/index.js");
const program = createProgram();
const envCmd = program.commands.find((c) => c.name() === "env");
expect(envCmd).toBeDefined();
});
});
+22 -8
View File
@@ -154,8 +154,13 @@ describe("rig expand", () => {
expect(parsed.nodes).toBeDefined();
});
// T3a: Partial prints failed nodes + honest relaunch guidance
it("partial result prints failed nodes with relaunch guidance", async () => {
// T3a: Partial prints failed nodes + honest recovery guidance.
//
// Recovery shape mandated by the topology-mutation-add-seat-proof-pass
// slice: pod-aware /launch returns pod_aware_launch_unsupported for any
// node `expand` creates, so the per-node `rig launch <rigId> <logicalId>`
// form is doomed. The honest recovery path is shrink → fix → re-expand.
it("partial result prints failed nodes with shrink+re-expand recovery guidance", async () => {
writeFileSync(fragmentPath, `id: partial-pod\nlabel: Dev\nmembers:\n - id: impl\n runtime: claude-code\n - id: qa\n runtime: codex\nedges: []\n`);
const { logs, exitCode } = await captureLogs(async () => {
@@ -165,7 +170,10 @@ describe("rig expand", () => {
const output = logs.join("\n");
expect(output).toContain("FAIL");
expect(output).toContain("dev.qa");
expect(output).toContain("rig launch rig-123 dev.qa");
expect(output).toContain("Failed nodes cannot be relaunched directly");
expect(output).toContain("rig shrink rig-123 dev"); // dev is podNamespace from PARTIAL_RESPONSE
expect(output).toContain("Re-run: rig expand rig-123");
expect(output).toContain("Failed targets: dev.qa");
expect(exitCode).toBe(1);
});
@@ -181,8 +189,13 @@ describe("rig expand", () => {
expect(output).not.toContain("/launch");
});
// T3b: Human output never suggests 'rig up' or 'rerun expand'
it("partial output never suggests rig up or rerun expand", async () => {
// T3b: Human output NEVER advertises the doomed per-node `rig launch
// <rigId> <logicalId>` form for pod-aware expand failures. The daemon
// /launch route returns pod_aware_launch_unsupported (HTTP 409) for every
// node expand creates because expand always materializes via the pod-aware
// seam. Advertising that retry is identity-honesty failure: telling
// operators to run a command we know will refuse them.
it("partial output does NOT advertise the doomed `rig launch <rigId> <logicalId>` form", async () => {
writeFileSync(fragmentPath, `id: partial-pod\nlabel: Dev\nmembers:\n - id: impl\n runtime: claude-code\n - id: qa\n runtime: codex\nedges: []\n`);
const { logs } = await captureLogs(async () => {
@@ -190,9 +203,10 @@ describe("rig expand", () => {
});
const output = logs.join("\n");
expect(output).not.toContain("rig up");
expect(output).not.toMatch(/rerun.*expand/i);
expect(output).not.toMatch(/rig expand/i);
// The exact doomed form for the failed node MUST NOT appear.
expect(output).not.toContain("rig launch rig-123 dev.qa");
// Defensive: no `rig launch <rigId> <bare-logical-id>` style line at all.
expect(output).not.toMatch(/rig launch rig-123 \w/);
});
// T4: API error -> exit 1
+40 -1
View File
@@ -53,7 +53,10 @@ function runningLifecycleDeps(port: number): LifecycleDeps {
}
function stoppedLifecycleDeps(): LifecycleDeps {
return mockLifecycleDeps({ exists: vi.fn(() => false) });
return mockLifecycleDeps({
exists: vi.fn(() => false),
fetch: vi.fn(async () => { throw new Error("refused"); }),
});
}
function unhealthyLifecycleDeps(): LifecycleDeps {
@@ -434,4 +437,40 @@ describe("rig export + import", () => {
expect(output).toContain("Attach:");
expect(output).toContain("tmux attach -t orch-lead@imported-rig");
});
it("import --instantiate uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const postText = vi.fn(async (
_path: string,
_text: string,
_contentType?: string,
_extraHeaders?: Record<string, string>,
options?: { timeoutMs?: number },
) => {
timeoutMs = options?.timeoutMs;
return {
status: 201,
data: {
rigId: "rig-new",
specName: "imported-rig",
specVersion: "0.1.0",
nodes: [{ logicalId: "orchestrator", status: "launched" }],
attachCommand: "tmux attach -t orch-lead@imported-rig",
},
};
});
const deps: ImportDeps = {
lifecycleDeps: runningLifecycleDeps(port),
clientFactory: () => ({ postText } as unknown as DaemonClient),
readFile: vi.fn(() => 'version: "0.2"\nname: test\npods:\n - id: dev\n label: Dev\n members:\n - id: impl\n agent_ref: "local:agents/impl"\n profile: default\n runtime: claude-code\n cwd: .\n edges: []\nedges: []'),
};
const program = new Command();
program.addCommand(importCommand(deps));
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
expect(timeoutMs).toBe(120_000);
});
});
+313
View File
@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { heartbeatCommand, analyzeHeartbeat } from "../src/commands/heartbeat.js";
const NOW = new Date("2026-04-24T12:00:00Z");
function writeQueue(root: string, rig: string, pod: string, member: string, body: string): string {
const dir = join(root, "rigs", rig, "state", pod);
mkdirSync(dir, { recursive: true });
const path = join(dir, `${member}.queue.md`);
writeFileSync(path, body, "utf8");
return path;
}
function queue(entries: string[]): string {
return [
"---",
"doc: test queue",
"---",
"",
"# queue",
"",
...entries,
].join("\n");
}
function entry(input: {
id: string;
state: string;
tsCreated?: string;
tsUpdated?: string;
blockedOn?: string;
title?: string;
body: string;
}): string {
return [
"---",
`id: ${input.id}`,
`ts-created: ${input.tsCreated ?? "2026-04-24T08:00:00Z"}`,
`ts-updated: ${input.tsUpdated ?? input.tsCreated ?? "2026-04-24T08:00:00Z"}`,
`state: ${input.state}`,
`blocked-on: ${input.blockedOn ?? "null"}`,
"handed-off-to: null",
"---",
"",
`### ${input.title ?? input.id}`,
"",
input.body,
].join("\n");
}
async function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errors: string[]; exitCode: number | undefined }> {
const logs: string[] = [];
const errors: string[] = [];
const originalLog = console.log;
const originalError = console.error;
const originalExitCode = process.exitCode;
process.exitCode = undefined;
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation((...args) => errors.push(args.join(" ")));
try {
await fn();
} finally {
console.log = originalLog;
console.error = originalError;
}
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
return { logs, errors, exitCode };
}
describe("rig heartbeat", () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "openrig-heartbeat-"));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("classifies checked-out, proven-active, stalled, unproven, blocked, parked, and done states", () => {
writeQueue(root, "alpha", "dev", "impl", queue([
entry({
id: "checked",
state: "in-progress",
tsCreated: "2026-04-24T11:00:00Z",
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - in-progress (accepted)",
}),
entry({
id: "unproven",
state: "in-progress",
tsCreated: "2026-04-24T08:00:00Z",
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - in-progress (accepted)",
}),
entry({
id: "proven",
state: "in-progress",
tsCreated: "2026-04-24T08:00:00Z",
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - in-progress\n- 2026-04-24T11:30:00Z - in-progress; proof: wrote `openrig-work/artifact.md`",
}),
entry({
id: "stalled",
state: "in-progress",
tsCreated: "2026-04-24T06:00:00Z",
body: "**State transitions:**\n- 2026-04-24T06:00:00Z - in-progress\n- 2026-04-24T09:30:00Z - in-progress; proof: changed `packages/cli/src/commands/heartbeat.ts`",
}),
entry({
id: "not-proof",
state: "in-progress",
tsCreated: "2026-04-24T07:00:00Z",
body: "**State transitions:**\n- 2026-04-24T07:00:00Z - in-progress\n- 2026-04-24T11:30:00Z - in-progress; proof: read the spec carefully",
}),
entry({
id: "blocked",
state: "blocked",
blockedOn: "missing credentials",
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - blocked (missing credentials)",
}),
entry({
id: "parked",
state: "deferred",
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - deferred (waiting for review)",
}),
entry({
id: "done",
state: "done",
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - done (landed artifact at `out.md`)",
}),
]));
const result = analyzeHeartbeat({
sharedDocsRoot: root,
rig: "alpha",
now: NOW,
includeDone: true,
});
const byId = new Map(result.items.map((item) => [item.id, item]));
expect(byId.get("checked")?.executionState).toBe("checked-out");
expect(byId.get("unproven")?.executionState).toBe("unproven");
expect(byId.get("proven")?.executionState).toBe("proven-active");
expect(byId.get("stalled")?.executionState).toBe("stalled");
expect(byId.get("not-proof")?.executionState).toBe("unproven");
expect(byId.get("blocked")?.executionState).toBe("blocked");
expect(byId.get("blocked")?.blockedOn).toBe("missing credentials");
expect(byId.get("parked")?.executionState).toBe("parked");
expect(byId.get("done")?.executionState).toBe("done");
expect(result.summary).toMatchObject({
total: 8,
checkedOut: 1,
provenActive: 1,
stalled: 1,
unproven: 2,
blocked: 1,
parked: 1,
done: 1,
});
});
it("JSON output is stable and excludes done items by default", async () => {
writeQueue(root, "alpha", "dev", "impl", queue([
entry({
id: "active",
state: "in-progress",
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - in-progress",
}),
entry({
id: "done",
state: "done",
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - done",
}),
]));
const cmd = heartbeatCommand({ sharedDocsRoot: root, now: () => NOW });
const { logs, exitCode } = await captureLogs(async () => {
await cmd.parseAsync(["node", "rig", "--rig", "alpha", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.windows).toEqual({ firstProofSeconds: 7200, heartbeatSeconds: 7200 });
expect(parsed.summary.total).toBe(1);
expect(parsed.summary.done).toBe(0);
expect(parsed.items).toHaveLength(1);
expect(parsed.items[0]).toMatchObject({
id: "active",
rig: "alpha",
owner: "dev.impl",
session: "dev-impl@alpha",
executionState: "checked-out",
});
});
it("--include-done includes terminal done items", async () => {
writeQueue(root, "alpha", "dev", "impl", queue([
entry({
id: "done",
state: "done",
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - done",
}),
]));
const cmd = heartbeatCommand({ sharedDocsRoot: root, now: () => NOW });
const { logs } = await captureLogs(async () => {
await cmd.parseAsync(["node", "rig", "--rig", "alpha", "--json", "--include-done"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.summary.done).toBe(1);
expect(parsed.items[0].executionState).toBe("done");
});
it("does not label pending or unknown queue states as checked out", () => {
writeQueue(root, "alpha", "dev", "impl", queue([
entry({
id: "pending",
state: "pending",
tsCreated: "2026-04-24T08:00:00Z",
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - pending",
}),
entry({
id: "unknown",
state: "triage",
tsCreated: "2026-04-24T08:00:00Z",
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - triage",
}),
entry({
id: "checked",
state: "in-progress",
tsCreated: "2026-04-24T11:00:00Z",
body: "**State transitions:**\n- 2026-04-24T11:00:00Z - in-progress",
}),
]));
const result = analyzeHeartbeat({
sharedDocsRoot: root,
rig: "alpha",
now: NOW,
});
expect(result.items.map((item) => item.id)).toEqual(["checked"]);
expect(result.items[0]?.executionState).toBe("checked-out");
});
it("uses checkout transition then ts-created, not ts-updated churn, for first-proof age", () => {
writeQueue(root, "alpha", "dev", "impl", queue([
entry({
id: "metadata-churn",
state: "in-progress",
tsCreated: "2026-04-24T08:00:00Z",
tsUpdated: "2026-04-24T11:50:00Z",
body: "No in-progress transition was recorded.",
}),
]));
const result = analyzeHeartbeat({
sharedDocsRoot: root,
rig: "alpha",
now: NOW,
});
expect(result.items[0]?.id).toBe("metadata-churn");
expect(result.items[0]?.checkoutAt).toBe("2026-04-24T08:00:00Z");
expect(result.items[0]?.executionState).toBe("unproven");
});
it("--nudge sends informational messages only to stalled and unproven owners", async () => {
writeQueue(root, "alpha", "dev", "impl", queue([
entry({
id: "unproven",
state: "in-progress",
tsCreated: "2026-04-24T08:00:00Z",
body: "**State transitions:**\n- 2026-04-24T08:00:00Z - in-progress",
}),
entry({
id: "stalled",
state: "in-progress",
tsCreated: "2026-04-24T06:00:00Z",
body: "**State transitions:**\n- 2026-04-24T06:00:00Z - in-progress\n- 2026-04-24T09:00:00Z - in-progress; proof: wrote `artifact.md`",
}),
entry({
id: "blocked",
state: "blocked",
body: "**State transitions:**\n- 2026-04-24T09:00:00Z - blocked",
}),
]));
const sends: Array<{ session: string; text: string }> = [];
const cmd = heartbeatCommand({
sharedDocsRoot: root,
now: () => NOW,
send: async (session, text) => {
sends.push({ session, text });
return { ok: true, message: "sent" };
},
});
const { logs } = await captureLogs(async () => {
await cmd.parseAsync(["node", "rig", "--rig", "alpha", "--json", "--nudge"]);
});
expect(sends.map((send) => send.session)).toEqual(["dev-impl@alpha", "dev-impl@alpha"]);
expect(sends.map((send) => send.text).join("\n")).toContain("add a task-specific proof note");
expect(sends.map((send) => send.text).join("\n")).toContain("If blocked, transition to blocked");
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.nudgeResults).toHaveLength(2);
expect(parsed.nudgeResults.map((result: { id: string }) => result.id).sort()).toEqual(["stalled", "unproven"]);
});
});
+208
View File
@@ -0,0 +1,208 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
loadHostRegistry,
resolveHost,
validateHostRegistry,
defaultHostRegistryPath,
} from "../src/host-registry.js";
function withTempFile(name: string, contents: string, fn: (path: string) => void) {
const dir = mkdtempSync(join(tmpdir(), "openrig-host-registry-"));
const path = join(dir, name);
writeFileSync(path, contents, "utf-8");
try {
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("host registry — defaultHostRegistryPath", () => {
it("returns ~/.openrig/hosts.yaml under the canonical OpenRig home", () => {
const path = defaultHostRegistryPath();
expect(path.endsWith("/hosts.yaml")).toBe(true);
expect(path.includes(".openrig")).toBe(true);
});
});
describe("host registry — loadHostRegistry", () => {
it("loads a valid single-host registry", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: wrandom
notes: "Tart VM"
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.registry.hosts).toHaveLength(1);
expect(r.registry.hosts[0]).toEqual({
id: "vm-claude-test",
transport: "ssh",
target: "vm-claude-test.local",
user: "wrandom",
notes: "Tart VM",
});
}
});
});
it("loads a valid multi-host registry with optional fields omitted", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-a
transport: ssh
target: vm-a.local
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
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("wrandom");
}
});
});
it("returns a clear error when the registry file is missing", () => {
const r = loadHostRegistry("/tmp/openrig-non-existent-hosts.yaml");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("host registry not found");
expect(r.error).toContain("transport: ssh");
}
});
it("rejects non-array hosts field", () => {
withTempFile("hosts.yaml", `hosts: "not-an-array"\n`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("'hosts' must be an array");
});
});
it("rejects entry missing required id", () => {
withTempFile("hosts.yaml", `
hosts:
- transport: ssh
target: x.local
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("id: required non-empty string");
});
});
it("rejects entry missing required target", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: ssh
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("target: required non-empty string");
});
});
it("rejects non-ssh transport with v0-scope message", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: tailscale
target: x.local
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("transport: v0 supports 'ssh' only");
expect(r.error).toContain('"tailscale"');
}
});
});
it("rejects duplicate host ids", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: ssh
target: x.local
- id: vm-x
transport: ssh
target: y.local
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("duplicate host id 'vm-x'");
});
});
it("rejects empty user field", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: ssh
target: x.local
user: ""
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("user: optional, but if present must be a non-empty string");
});
});
it("rejects malformed YAML with a clear error", () => {
withTempFile("hosts.yaml", `hosts:\n - id: vm-x\n transport: ssh\n target: x.local\nnot-valid: [`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("failed to parse host registry YAML");
});
});
it("rejects top-level non-object YAML", () => {
const result = validateHostRegistry("just-a-string", "/x");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("must be a YAML object with a 'hosts' array");
});
});
describe("host registry — resolveHost", () => {
const registry = {
hosts: [
{ id: "vm-a", transport: "ssh" as const, target: "vm-a.local" },
{ id: "vm-b", transport: "ssh" as const, target: "vm-b.local", user: "ops" },
],
};
it("resolves a known id", () => {
const r = resolveHost(registry, "vm-b");
expect(r.ok).toBe(true);
if (r.ok) expect(r.host.target).toBe("vm-b.local");
});
it("rejects an unknown id with the supported-list hint", () => {
const r = resolveHost(registry, "vm-unknown");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("unknown host id 'vm-unknown'");
expect(r.error).toContain("vm-a");
expect(r.error).toContain("vm-b");
}
});
it("indicates an empty registry honestly", () => {
const r = resolveHost({ hosts: [] }, "vm-x");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("registry is empty");
});
});
+10
View File
@@ -20,4 +20,14 @@ describe("CLI entrypoint direct-run detection", () => {
const program = createProgram();
expect(program.version()).toBe(CLI_VERSION);
});
it("registers the native heartbeat command", () => {
const program = createProgram();
expect(program.commands.some((cmd) => cmd.name() === "heartbeat")).toBe(true);
});
it("registers the native seat command namespace", () => {
const program = createProgram();
expect(program.commands.some((cmd) => cmd.name() === "seat")).toBe(true);
});
});
+24
View File
@@ -147,6 +147,16 @@ describe("Lifecycle CLI commands", () => {
return;
}
if (req.method === "POST" && req.url === "/api/rigs/rig-1/nodes/dev.pod/launch") {
res.writeHead(409, { "Content-Type": "application/json" });
res.end(JSON.stringify({
ok: false,
code: "pod_aware_launch_unsupported",
error: "Pod-aware node launch via this route bypasses startup orchestration. Use rig up, rig import --instantiate, or rig restore instead.",
}));
return;
}
if (req.method === "POST" && req.url === "/api/sessions/missing/unclaim") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "Claimed session 'missing' not found." }));
@@ -269,6 +279,20 @@ describe("Lifecycle CLI commands", () => {
expect(logs.join("\n")).toContain("dev-qa@test");
});
it("launch surfaces honest pod-aware rejection and exits non-zero", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(launchCommand(runningDeps()));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "launch", "rig-1", "dev.pod"]);
});
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("bypasses startup orchestration");
expect(logs.join("\n")).toContain("rig import --instantiate");
});
it("--json prints raw unclaim payload", async () => {
const program = new Command();
program.exitOverride();
+56 -7
View File
@@ -6,7 +6,7 @@ import { DaemonClient, type DaemonResponse } from "../src/client.js";
function mockClient(overrides?: {
get?: (path: string) => Promise<DaemonResponse>;
post?: (path: string, body?: unknown) => Promise<DaemonResponse>;
post?: (path: string, body?: unknown, options?: { timeoutMs?: number }) => Promise<DaemonResponse>;
postText?: (path: string, text: string, contentType?: string, extraHeaders?: Record<string, string>) => Promise<DaemonResponse>;
}): DaemonClient {
const client = new DaemonClient("http://127.0.0.1:9999");
@@ -81,12 +81,16 @@ describe("MCP Server", () => {
arguments: { sourceRef: "/tmp/rig.yaml", plan: false, autoApprove: true },
});
expect(postFn).toHaveBeenCalledWith("/api/up", {
sourceRef: "/tmp/rig.yaml",
plan: false,
autoApprove: true,
targetRoot: undefined,
});
expect(postFn).toHaveBeenCalledWith(
"/api/up",
{
sourceRef: "/tmp/rig.yaml",
plan: false,
autoApprove: true,
targetRoot: undefined,
},
{ timeoutMs: 120_000 },
);
const content = result.content as Array<{ type: string; text: string }>;
const parsed = JSON.parse(content[0].text);
expect(parsed.status).toBe("completed");
@@ -344,6 +348,7 @@ describe("MCP Server", () => {
});
expect(postFn).toHaveBeenCalled();
expect(postFn.mock.calls[0]?.[2]).toBeUndefined();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
expect(parsed.ok).toBe(true);
@@ -351,6 +356,50 @@ describe("MCP Server", () => {
await cleanup();
});
it("rig_send with waitForIdleSeconds extends request timeout", async () => {
const postFn = vi.fn(async () => ({
status: 200,
data: { ok: true, sessionName: "dev-impl@my-rig" },
}));
await setup({ post: postFn });
const result = await mcpClient.callTool({
name: "rig_send",
arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 30 },
});
expect(postFn).toHaveBeenCalledWith(
"/api/transport/send",
expect.objectContaining({
session: "dev-impl@my-rig",
text: "hello",
waitForIdleMs: 30000,
}),
{ timeoutMs: 35000 },
);
expect(result.isError).toBeUndefined();
await cleanup();
});
it("rig_send rejects waitForIdleSeconds with force before daemon contact", async () => {
const postFn = vi.fn(async () => ({
status: 200,
data: { ok: true },
}));
await setup({ post: postFn });
const result = await mcpClient.callTool({
name: "rig_send",
arguments: { session: "dev-impl@my-rig", text: "hello", waitForIdleSeconds: 30, force: true },
});
expect(result.isError).toBe(true);
expect(postFn).not.toHaveBeenCalled();
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0]!.text);
expect(parsed.error).toContain("cannot be combined");
await cleanup();
});
// T-chatroom-send: rig_chatroom_send returns result
it("rig_chatroom_send returns result", async () => {
const getFn = vi.fn(async () => ({
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_OPENRIG_HOME = process.env.OPENRIG_HOME;
const ORIGINAL_RIGGED_HOME = process.env.RIGGED_HOME;
afterEach(() => {
if (ORIGINAL_OPENRIG_HOME === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = ORIGINAL_OPENRIG_HOME;
if (ORIGINAL_RIGGED_HOME === undefined) delete process.env.RIGGED_HOME;
else process.env.RIGGED_HOME = ORIGINAL_RIGGED_HOME;
vi.resetModules();
vi.restoreAllMocks();
});
describe("openrig-compat", () => {
it("getOpenRigHome prefers OPENRIG_HOME when set", async () => {
process.env.OPENRIG_HOME = "/tmp/custom-openrig-home";
delete process.env.RIGGED_HOME;
const mod = await import("../src/openrig-compat.js");
expect(mod.getOpenRigHome()).toBe("/tmp/custom-openrig-home");
expect(mod.getDefaultOpenRigPath("daemon.json")).toBe("/tmp/custom-openrig-home/daemon.json");
});
it("getOpenRigHome falls back to RIGGED_HOME with warning", async () => {
delete process.env.OPENRIG_HOME;
process.env.RIGGED_HOME = "/tmp/legacy-rigged-home";
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await import("../src/openrig-compat.js");
expect(mod.getOpenRigHome()).toBe("/tmp/legacy-rigged-home");
expect(warnSpy).toHaveBeenCalledWith(
"Warning: RIGGED_HOME is deprecated; use OPENRIG_HOME instead.",
);
});
});
Loaded 100 of 459 files, more files were not shown because too many files have changed in this diff. Show more