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
782 changed files with 8599 additions and 105754 deletions

No files matched your search

-669
View File
@@ -1,669 +0,0 @@
# OpenRig Changelog
Agent-readable release notes for coding agents and human operators installing,
upgrading, or operating OpenRig.
Versioning: pre-1.0 minor releases may include contract additions,
deprecations, and behavioral changes. Breaking changes are called out explicitly.
---
## [0.3.1] - 2026-05-15
**Status**: released. npm `@openrig/[email protected]` (latest); GitHub Release
`v0.3.1`; git tag `v0.3.1`.
### Summary For Installing Agents
- **Package version**: package metadata bumped at release-manager step; CLI
reports the new version after `npm publish`.
- **Migrations**: no schema-breaking migrations in 0.3.1. Existing databases
upgrade by running `rig daemon start`.
- **Node engines**: unchanged from 0.3.0 (CLI accepts Node `>=20`).
- **Backward compatibility**: existing CLI argument shapes, daemon route
paths, RigSpec/AgentSpec schemas, and persisted settings remain backward
compatible. New routes are additive. New ConfigStore keys are opt-in
default-off.
### Claude Auto-Compaction Policy
The headline 0.3.1 feature: operator-configurable Claude session
auto-compaction with safe defaults.
- **Opt-in default-off**: with no policy configured, no behavior change. The
daemon never sends an auto-`/compact` until the operator explicitly enables
the policy.
- **7 new ConfigStore keys** in the `policies.claude_compaction.*` namespace
(lockstep across CLI VALID_KEYS + daemon SETTINGS_VALID_KEYS): `enabled`,
`threshold_percent` (strict integer 1-100), `compact_instruction` (default
empty; appended to `/compact` slash-command args when set),
`message_inline`, `message_file_path`, `pre_compact_instruction`,
`post_restore_audit_instruction`. Strict validation across all source
layers (`set/POST/env/file`) — invalid env values fall back to defaults
with a warning to stderr rather than silently coercing.
- **5 operator-editable prompt surfaces** rendered in the
Settings → Policies UI form: pre-compaction prep, compact instruction,
post-compaction restore (inline), restore file path, post-restore audit.
Daemon-owned wrappers (usage/threshold framing, trust-channel preservation,
marker paths, read-depth enforcement, turn-boundary handshake, dedup +
cooldown) are non-editable.
- **6-stage daemon-to-LLM lifecycle**:
1. Pre-compact prep prompt — full-context Claude writes a mental-model
restore map (annotated ASCII file/folder tree) before compaction
("save game before quit").
2. `/compact` with operator instructions + trust-channel preservation
embedded in args.
3. Post-compact turn-boundary handshake — non-restorative acknowledgment
creates an assistant-turn boundary so the subsequent restore prompt
lands in the correct trust context.
4. Restore prompt — explicit user-request shape; defense-in-depth
fallback chain (marker → JSONL transcript path → session-id → generic).
5. Compliance prompt — forces FULL/PARTIAL/NOT_READ read-depth audit
table; counters Claude's deferred-execution + token-conservation
instincts.
6. Cooldown (10-minute default) — prevents re-fire while restore work
is still consuming context.
- **PreCompact hook + SessionStart/UserPromptSubmit bridge**: the openrig-core
plugin ships a marker-bridge that picks up pending-restore markers
post-compaction and injects restore directives once. Templates live in
the `claude-compaction-restore` skill and ship with the plugin.
- **Safety hardening**: SessionTransport classifies typed prompt drafts as
attention state — auto-`/compact` retries later instead of overwriting
human input. Send-failure does not advance dedup state (transient retry).
Re-arm requires threshold-crossing (session must drop below threshold
before next auto-compact).
### Library Explorer Finishing
The Library destination (skills + plugins + specs) became a fully
operator-facing surface:
- **No duplicate top-level entries**: `> SKILLS` and `> PLUGINS` rows
removed from the top of `SpecsTreeView`. Bottom-row clicks now do
dual-action (navigate to the matching index page + expand the tree).
- **Reorder**: Plugins above Skills.
- **OpenRig-managed skills discovery**: 32 shared skills now visible in
the tree + on `/specs/skills` index. Previously the workspace-relative
path lookup returned empty in production VM environments.
- **Daemon-owned library discovery API** (new):
- `GET /api/skills/library` → consolidated `LibrarySkillPublic[]`
(workspace + openrig-managed sources; absolute paths not leaked).
- `GET /api/skills/:id/files/list?path=<rel>` + `/api/skills/:id/files/read?path=<rel>` —
skill folder browse + content read.
- `GET /api/plugins/:id/files/list?path=<rel>` + `/api/plugins/:id/files/read?path=<rel>` —
plugin folder browse + content read.
- `PluginEntry.skillCount` field added to plugin discovery
serialization.
- **Real file-browser docs-browser on plugin and skill detail pages**:
detail pages mount a `DirectoryTree` + `FileContentPanel` against the
real plugin/skill folder. Markdown auto-renders; non-markdown files
(e.g. `.ts`, `.json`) render as text. Folder navigation works (entering
subfolders + listing files).
- **Rolled-up index pages**: `/specs/skills` lists all skills as flat rows
with source label + file count + entry link. `/specs/plugins` lists all
plugins as rows with version + runtimes + skill-count + entry link.
### Plugin Primitive v0
- **Plugin discovery**: vendored plugins under `$OPENRIG_HOME/plugins/`
(default `~/.openrig/plugins/`) are discovered, validated, and
surfaced through `rig plugin list` + `/api/plugins`.
- **Plugin install (v0)**: explicit operator copy or symlink to
`$OPENRIG_HOME/plugins/<plugin-id>/`. A `rig plugin install
<substrate-path>` verb is deferred to 0.3.2; see `OPENRIG-INSTALL.md`
inside each plugin's source for the documented copy/symlink workflow.
- **CLI**: `rig plugin list` / `show` / `used-by` / `validate` subcommands
available. No `install` subcommand at v0.
- **Plugins shipped as substrate references** (for plugin authors to
copy-install): `gstack` (45 skills), `obra-superpowers` (14 skills).
`openrig-core` ships bundled with the daemon (11 skills).
### Settings Destination Explorer
Settings became a 4-item Explorer destination matching Topology /
Project / Library / For-You pattern:
- `/settings` (general config keys form), `/settings/policies`,
`/settings/log`, `/settings/status`.
- Old top-row tab nav removed.
- Shared `SettingsPageShell` chrome across all 4 sub-routes.
- Policies page is the home for the Claude auto-compaction policy form
(see above).
### CMUX Launcher
- **Launch in CMUX button** on the rig-scope topology tab-bar trailing
slot. Opens a cmux workspace for the rig with appropriate title +
cwd parameters. Powered by new daemon route + cmux adapter
extensions.
### Node-Page Overview + Details
- **Seat overview table** consolidated as 7-column horizontal layout with
vertical grid lines (Claude/Codex agent + status + context + tokens +
uptime + cwd + current-work).
- **Tab consolidation** + activity alignment on the node-detail surface.
- **Alert-only notification banner** (renders only on real-alert states:
`failed`, `attention_required`, or `latestError !== null`); generic
`recoveryGuidance` no longer triggers a banner on every seat.
- **cwd / current-work separation**: factored into a `SeatOverviewSecondary`
primitive below the column table.
### Mobile Drawer Behavior
The Explorer drawer at 375px viewports now layers above the mobile rail
tray for Settings / Project / Library / For-You destinations
(previously hidden behind rail-tray; visible click path didn't register
on Explorer items). Topology mobile drawer is intentionally hidden in
0.3.1 (clicking the hamburger triggered a pre-existing
TopologyTableView renderer cascade); the topology mobile drawer is
scheduled for full restoration in 0.3.2 via a dedicated
TopologyTableView render-path slice.
### Dashboard And For You Visual Refresh
The Dashboard (`/`) and For You (`/for-you`) destinations got a coordinated
visual refresh to a "vellum" surface language — translucent stone-tinted
cards over a paper-grid background, ambient multi-stop shadow, mono+dot
kind indicators, and L-shaped corner-bracket registration marks. The
chrome vocabulary is consistent across destination cards (Dashboard) and
both feed-card systems (For You).
- **Dashboard** — full rewrite of `/` into a thin composition over new
`dashboard/vellum/` primitives (`BackVellumSheet`, `MidLayerContent`,
`TopLayerContent`, `DestinationsLayer`, `VellumDestinationCard`,
`CornerBracket`, `graphics.tsx`, `marks.tsx`). Hero typography ("WELCOME
BACK") at display-lg + headline-bold; tactical instrument-panel stats
line with tabular numerals and a success-token active count. Six
destination cards (Topology / Project / For You / Library / Search /
Settings) share the same numeral-layout treatment. Real-data hooks
thread through (`useRigSummary`, `usePsEntries`, `useSpecLibrary`,
`window.location.hostname`).
- **For You** — both card systems unified to the same vellum recipe:
- Storytelling band: `CardShell` rewritten to bg-stone-100/45 +
backdrop-blur-[10px] + ambient shadow + corner brackets; design-token
leading dots replace prior bg-emerald-50 / bg-amber-50 / etc.
off-brand utilities. Title at 16px headline-bold; body at 12px.
- Queue-item `FeedCard.tsx`: same outer chrome; `KIND_DOT` + `TONE_DOT`
design-token maps; vellum bordered-no-fill action buttons
(Approve/Deny/Route/Hold/Drop/Annotate/Handoff) with hover-invert and
44px touch targets; `TONE_RECEIPT` strip is a subtle bg-stone-50/40
with a leading colored dot.
- **Single source of truth** — `/dashboard` and `/lab/vellum-lab` both
import from `packages/ui/src/components/dashboard/vellum/index.js`.
Future visual changes hit one location.
- **Lab routes** — `/lab/card-previews`, `/lab/vellum-lab`,
`/lab/vellum-bg/{a-large,b-small,c-allover}` are checked-in experiment
surfaces for designer iteration. Reachable in production by direct URL;
not linked from main nav. Useful when iterating on the visual system.
### For You Storytelling Adapter
The storytelling band (top of `/for-you`) now wires four card kinds to
real data:
- **Progress** — from `useMissionDiscovery` (first 2 active missions)
- **Shipped** — from `useSlices` (status = shipped/complete/done; capped at 3)
- **Incident** — from `useSlices` (status = blocked/failed/danger or fallback "info"; capped at 3)
- **Approval** — from `useActivityFeed` + `classifyFeed` (kind === "approval";
capped at 2; qitemId extracted from event payload with snake_case alt
and `FeedCard.id` fallback). Surfaces real queue items waiting on
approval in a high-visibility band.
`ConceptCard` component is preserved in source but not emitted by the
production adapter — a deliberate data-source decision is scheduled for
0.3.2.
### Action Outcome And Inline Error Surface
Queue-item action buttons (`VerbActions` — Approve/Deny/Route/Hold/Drop/
Annotate/Handoff) now render outcomes immediately and surface failures
without silently reverting:
- **Optimistic outcome** — on mutation success, the
`ActionOutcomePanel` ("Approved by X" / "Routed by X to Y") renders
immediately. Audit-log roundtrip reconciles in background. Operator
no longer waits for a query refetch to see what happened.
- **Inline error surface** — on mutation error, a tertiary-bordered
error block renders below the verb buttons with the daemon's error
message; verb-selection state is preserved so the operator can
correct and retry. Replaces the prior silent-revert UX where errors
were never displayed.
- **React-query callback discipline** — `submit()` split into separate
`onSuccess` (optimistic outcome + reset selection) and `onError`
(set error message; do NOT reset). Regression test guards the
silent-revert class explicitly.
### Vendored Skill Provenance
Vendored skills shipped in `packages/daemon/specs/agents/shared/skills/`
now declare their upstream lineage via `metadata.openrig` frontmatter
and (when modifications exist) a companion `OPENRIG.md` sidecar:
- **vendoring_pattern**: `vendored-as-is` | `modify-the-file` |
`add-supplementary-files`
- **vendored_from**: upstream source identifier
- **last_upstream_check**: most recent diff date
- **divergence_notes**: human-readable summary of OpenRig-specific changes
Applied to all 10 process-skill surfaces (agent-browser, executing-plans,
brainstorming, systematic-debugging, test-driven-development,
using-superpowers, verification-before-completion, writing-plans,
frontend-design, dogfood). `OPENRIG.md` sidecars added where the file
has been modified or supplemented (agent-browser + executing-plans +
brainstorming + using-superpowers + writing-plans). Convention is
documented in the `writing-skills-for-openrig` skill.
### Plugins And Skills On The VM (Operator Note)
Operators dogfood-testing 0.3.1 should expect:
- **Stock VM install**: only `openrig-core` plugin is bundled. The
`/specs/plugins` UI list will show one plugin until the operator
installs additional plugins per the v0 copy workflow.
- **Skills**: 32 OpenRig-managed shared skills ship under
`packages/daemon/specs/agents/shared/skills/` (discovered via the
daemon skill-library API; no operator action required).
- **User-installed skills**: skills the operator installs under
`~/.openrig/skills/` or the workspace `.openrig/skills/` directory
surface in the same list.
### Config + Settings
Continues from 0.3.0 with the slice 08 validation pass:
- `rig config get/set/reset/list` remains the canonical CLI surface.
- Lockstep CLI `VALID_KEYS` + daemon `SETTINGS_VALID_KEYS` byte-identical
sets (verified in slice 08).
- Help-drift CI gate from slice 08 honored across all new keys added in
0.3.1.
### SC-29 Exceptions
The SC-29 exception process tracks explicit scope expansions to release
contracts. Exceptions declared in 0.3.1:
- **#10 (slice 24)**: cmux launcher — `POST /api/rigs/:rigId/cmux/launch`
+ CmuxLayoutService + 4 CmuxAdapter RPC methods.
- **#10 (slice 27, numbering collision)**: Claude auto-compaction policy
— 7 `policies.claude_compaction.*` ConfigStore keys. (Numbering
collision with #10 above is a process-only inconsistency; both code
scopes are correctly merged. Canonical `SC29-LEDGER.md` and ledger
hygiene scheduled for 0.3.2.)
- **#11 (slice 28)**: Library Explorer daemon API — 5 new daemon GET
endpoints (`/api/skills/library`, `/api/skills/:id/files/list`,
`/api/skills/:id/files/read`, `/api/plugins/:id/files/list`,
`/api/plugins/:id/files/read`) + 2 response shape additions
(`PluginEntry.skillCount`, `LibrarySkillPublic`).
### Known Carry-Forwards (0.3.2 Candidates)
- **`rig plugin install <substrate-path>` verb**: explicitly deferred.
Documented copy/symlink workflow is the v0 install path.
- **Topology mobile drawer**: hidden in 0.3.1 to avoid a pre-existing
TopologyTableView renderer cascade at 375px viewports. Full
restoration scheduled for 0.3.2 via dedicated render-path slice.
- **Plugin source-label taxonomy**: copy-installed plugins currently
land in the `vendored` source kind; UI label says "No user-installed
plugins" while listing them. Taxonomy refinement scheduled for 0.3.2.
- **`SC29-LEDGER.md`**: canonical SC-29 numbering ledger document.
Authors currently self-assign exception numbers; documented ledger
prevents collisions like the slice 24/27 #10.
- **VM PreCompact hook installer**: the documented install path for the
`claude-compaction-restore` skill is operator-manual at v0; an
automated installer is a 0.3.2 candidate.
- **`docs/DESIGN.md` docs-guard violation**: pre-existing
`npm run test:repo` failure; tracked doc outside allowed paths;
cleanup scheduled for 0.3.2 documentation hygiene pass.
- **Environment-dependent tests**: a small number of vitest suites fail
on developer hosts due to port-conflicts (preflight) or live host
Claude hook state (restore-check); focused-test gates pass these
suites; cumulative-workspace runs surface the gaps. Isolation cleanup
scheduled for 0.3.2.
- **Cross-daemon route awareness**: the VerbActions destination dropdown
on `/for-you` currently lists session names without checking whether
the local daemon can reach them. Routing to a non-local destination
surfaces as an inline error (per the new error surface above) but the
dropdown should ideally filter to local-daemon seats. 0.3.2 candidate.
- **ConceptCard data source**: `ConceptCard` component is preserved in
source but not wired in the production adapter. A 0.3.2 slice will
pick a deliberate data source (likely shaped backlog candidates or
early-stage discovery items).
- **Warm error messages on demo surfaces**: daemon error strings (e.g.,
"queue item not found") surface verbatim through the inline error
surface. Demo-grade polish to humanize these on user-facing surfaces
is scheduled for 0.3.2.
### Banked Discipline Patterns
0.3.1 surfaced a number of canonical agent-software-design patterns
during the Claude compaction iteration cycle and Library Explorer
finishing work. These are banked in operator-skill documentation for
agent-prompt design + daemon-to-LLM trust establishment:
- **Channel model**: normal user message is the only authorized action
surface; hook stdout is informational-only; `/compact` args carry
trust contracts that the post-compact prompt invokes.
- **Turn-boundary handshake**: when a daemon-driven action request
would land too adjacent to local-command output, insert a
non-committing acknowledgment message first to create an
assistant-turn boundary.
- **Save-game-before-quit pattern**: full-context agent writes
restoration breadcrumb before forced context loss; context-loss
agent reads it on restore.
- **Structured-output forces completeness**: ask LLMs for explicit
FULL/PARTIAL/NOT_READ accounting when thoroughness matters; counters
token-conservation instincts.
- **Daemon-owned shared-resource discovery**: skill/plugin discovery
belongs at the daemon layer with HTTP endpoint surfaces; UI consumes
via typed API. Avoids workspace-cwd-relative path-resolution
brittleness.
### Quick Verification Commands
```bash
# Confirm CLI version after the release-manager version bump
rig --version
# Confirm daemon starts cleanly
rig daemon start
# Confirm new Claude compaction policy keys are visible
rig config list | grep policies.claude_compaction
# Confirm plugins discoverable
rig plugin list
# Confirm skills discoverable
curl -s http://localhost:7433/api/skills/library | jq 'length'
```
---
## [0.3.0] - 2026-05-10
**Status**: release candidate for public publish. This entry documents the
changes since `0.2.0`.
### Summary For Installing Agents
- **Package version**: package metadata is still `0.2.0` until the release
manager performs the final version bump. The release contents documented here
are the intended `0.3.0` payload.
- **Migrations**: fresh databases apply migrations through
`039_queue_target_repo`. Existing databases migrate by running
`rig daemon start`.
- **Node engines**: the published CLI accepts Node `>=20`. The root package and
private daemon package remain constrained to active even-numbered Node lines.
- **Specs and primitives**: workflow specs, context packs, agent images,
workspace scaffolds, file browsing, queue observability, context usage, and
runtime skill discovery are all first-class product surfaces.
- **UI shell**: the operator UI has been rebuilt around the V1 shell:
destination rail, explorer, center workspace, detail drawer, vellum surfaces,
topology graph/table/terminal modes, and focused project observability.
- **Starter content**: `0.3.0` ships generic starter workflows and starter rigs.
Project-specific automation recipes are intentionally not shipped in product
source.
- **No schema-breaking release change**: existing CLI argument shapes, daemon
route paths, RigSpec/AgentSpec schemas, and persisted settings remain
backward compatible unless noted below.
### Quick Verification Commands
```bash
# Confirm CLI version after the release-manager version bump
rig --version
# Confirm daemon starts and migrations apply
OPENRIG_DB=/tmp/openrig-030-verify.sqlite rig daemon start
# Confirm settings are readable
rig config list --with-source
# Confirm starter specs are visible
rig specs ls --json
# Confirm runtime identity and loaded context
rig whoami --json
```
If any verification fails, see "Failure Modes And Remediation" below.
---
### V1 Shell And Operator UI
The `0.3.0` UI moves from prototype surfaces to a coherent operator shell:
- Two desktop chrome regions: destination rail and explorer.
- Center workspace for full pages.
- Default-closed detail drawer for previews and referenced content.
- Topology graph/table/terminal modes at a single topology URL.
- Settings rendered as a center workspace page, not as a sidebar panel.
- Vellum surface primitives, 1px region borders, and black-glass terminal
preview styling.
- Shared runtime graphics marks for agent/runtime/tool identity.
- Retired legacy surfaces: old sidebar shell, legacy dashboard page, and
rig-detail drawer patterns.
### Starter Rigs And Workflows
OpenRig now ships starter content designed to be useful on a fresh install
without exposing project-specific automation recipes.
- `product-team` is the primary human-directed starter rig.
- `conveyor` is the primary workflow-oriented starter rig.
- `conveyor` includes two generic workflow specs:
- **Conveyor**: each stage can process queued work independently, so multiple
packets can be in flight at once and natural queue backpressure handles slow
stages.
- **Basic loop**: one packet advances hop-by-hop around a small loop, useful
when the operator wants a slower, easier-to-watch workflow.
- Generic starter workflows use the workflow runtime and queue primitives. They
are examples and building blocks, not a hidden project workflow.
- Project-specific workflow specs can still be installed from a user workspace
or private spec directory; they do not need to be committed to product source.
### Mission-Shaped Workspace Defaults
Fresh installs now have a coherent default workspace path and scaffold:
- `rig config init-workspace` creates the default workspace structure.
- Workspace defaults include missions, slices, specs, proof/evidence locations,
steering files, and user-editable docs.
- Existing installs are rebased at read time so newer defaults become available
without destructive rewrites.
- Read-only mission/slice indexing supports nested mission-shaped workspaces
while preserving compatibility with earlier flat-root layouts.
### Project Observability
Project and queue work is now easier to inspect from the UI:
- For You cards classify queue lifecycle events, shipped work, progress,
observations, and approvals.
- Queue cards hydrate qitem bodies and proof previews.
- Story tabs emphasize qitem body content and paginate long activity streams.
- Queue rows preview bodies and open the detail drawer with full source,
destination, state, tags, and created-time metadata.
- Tests tabs show diagnostics and proof assets when no proof is available.
- Workspace and mission rollup pages include scoped Progress, Artifacts, Queue,
and Topology tabs.
- Current/archive grouping separates active project work from old seed or
completed work.
### Mission Control And Queue Actions
Mission Control remains the operator surface for queue observability and
qitem actions:
- Views include personal queue, human gate, fleet, active work, recent ships,
recent activity, and recent observations.
- Actions include approve, deny, route, annotate, hold, drop, and handoff.
- Mission Control audit outcomes are reflected back into For You cards so
terminal or already-actioned items show evidence instead of stale controls.
- Audit browsing and action history are read-only inspection surfaces.
- Existing daemon endpoints are reused; no extra workflow-specific endpoint is
required for the public starter content.
### Files, Markdown, Progress, And Proofs
The file and proof surfaces were expanded:
- File browser supports allowlisted roots, safe reads, asset reads, and
conflict-checked writes.
- Markdown rendering supports frontmatter, code blocks, tables, images, and
raw/rendered toggles.
- Progress views render status pills, hierarchy, and next-work markers.
- Proof screenshots open in an in-page viewer.
- File drawer headers, proof rows, queue related refs, and story rows use
shared graphics marks for faster scanning.
### Workflow Runtime And Spec Library
Workflow primitives are available as general infrastructure:
- Workflow specs are cached from markdown/YAML sources.
- Workflow instances and step trails are persisted.
- Specs library includes workflow entries and graph preview.
- Slice and project story surfaces can render spec-aware topology when a slice
is bound to a workflow instance.
- Cycle-aware traversal prevents graph rendering from hanging on looping specs.
### Context Packs And Agent Images
OpenRig now has additional reusable primitives for context and session state:
- `context_packs` package related context files into a coherent sendable bundle.
- `agent_images` capture reusable starter state from productive sessions.
- Library review surfaces can inspect these primitives and show safe summaries.
- AgentSpec startup can reference context packs and agent images.
- Resume-token data is redacted at route boundaries.
### Topology, Activity, And Context Usage
Topology is now a working operational surface rather than a static diagram:
- Graph/table/terminal views are available at the topology route.
- Host graphs can show multiple rigs on one canvas.
- Rig groups can expand/collapse, persist that state, and auto-expand for
current rig/pod/seat URLs.
- Agent activity, queue handoffs, token telemetry, and context usage are visible
in topology views.
- Codex context telemetry is read from local session state and reflected in
topology table and terminal views when available.
- Detached Codex sessions can still expose the last readable context sample;
Claude context remains running-session based.
### Terminal Preview
Terminal preview matured across several passes:
- Preview panes use existing session capture primitives.
- Compact terminal popovers are portal-mounted, clamp to viewport edges, and
resize/reposition on scroll or viewport changes.
- The compact view strips unnecessary chrome and uses a black-glass visual
language.
- The proof viewer and terminal preview share consistent drawer/popover
behavior.
### Runtime Skill Discovery
Runtime skill discovery is now part of profile resolution:
- Rig-local skill references still win first.
- Skills can be discovered from runtime-specific and shared user skill roots.
- Structurally invalid `SKILL.md` files are rejected with precise reasons.
- Profile resolution surfaces rejected-skill reasons instead of treating every
broken skill as merely missing.
- The behavior is intentionally strict: a skill must have frontmatter, non-empty
`name`, non-empty `description`, and non-empty body content.
### CLI And Daemon Release Hardening
Several release-blocking polish items landed in the CLI/daemon:
- Transcript capture now uses bounded `tmux capture-pane` polling instead of an
unbounded pipe file.
- `rig send` wraps delivered messages with sender/recipient context and a reply
hint.
- Generated setup markers use OpenRig naming.
- Original runtime environment aliases keep deprecated `RIGGED_*` fallbacks for
compatibility; newer typed settings use `OPENRIG_*` only.
- ConfigStore and SettingsStore remain lockstep for typed settings.
- No new plugin loader ships in `0.3.0`; plugin support is planned for a later
release.
### Removed Or Not Included
- Project-specific workflow recipes are not included in product source.
- The old `demo` rig is no longer the recommended starter; public docs point to
`product-team` and `conveyor`.
- The legacy `mental-model-ha` skill is removed from starter guidance.
- Root runtime projections such as `CLAUDE.md` are intentionally not tracked.
- Package-local `pnpm-lock.yaml` files are intentionally not restored; this repo
uses npm workspaces and the root `package-lock.json` for release installs.
- Internal release artifacts and local dogfood packets are not part of the
public release notes.
---
### Failure Modes And Remediation
**`rig daemon start` fails with an ABI mismatch**
- Cause: native dependencies were compiled against a different Node version.
- Remediation: use an active even-numbered Node release, or rebuild native
dependencies from the installed package directory.
**Files browser or Progress view appears empty on a fresh install**
- Cause: workspace scaffold has not been initialized or the configured root is
not where the operator expects.
- Remediation: run `rig config init-workspace`, then inspect
`rig config list --with-source`.
**Mission Control views are empty**
- Expected on a rig with no queue items.
- Remediation: create or hand off a queue item, then refresh. If still empty,
confirm daemon status and DB path with `rig daemon status` and
`rig config get db.path`.
**A skill reference resolves as rejected**
- Cause: the target `SKILL.md` exists but failed structural validation.
- Remediation: fix the skill frontmatter and body. It must include delimited
YAML frontmatter with non-empty `name` and `description`, followed by non-empty
markdown body content.
**A public starter workflow is too simple for a specialized loop**
- Expected. `0.3.0` ships reusable primitives and generic starter workflows.
Specialized workflow specs should live in user workspace or private rig
packages.
---
## [0.2.0] - 2026-04-22
Baseline for this changelog. Key shipped capabilities at `0.2.0`:
- Pod-aware multi-agent runtime with RigSpec, AgentSpec, pods, and seats.
- Rig-scoped environment management.
- Filesystem-backed spec library for built-in and user roots.
- Daemon-backed stream, queue, project, view, watchdog, and workflow primitives.
- Cross-runtime restore packets for Claude Code and Codex sessions.
- Communication primitives: `rig send`, `rig capture`, `rig broadcast`, durable
rig chat, and transcript inspection.
- Identity and lifecycle commands: `rig whoami`, adoption/bind/materialize
flows, snapshot/restore, and post-command handoff.
- Operator surfaces: `rig ps`, `rig ps --nodes`, queue inspection, and daemon
status.
- 32 SQLite migrations.
---
*This changelog is written for agents and humans. It should describe public
release behavior without depending on local workspace paths or private project
packets.*
+30 -29
View File
@@ -15,28 +15,32 @@ npm install -g @openrig/cli
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
rig setup
# Boot the conveyor starter rig (4 seats, mixed runtimes)
rig up conveyor
# Boot the demo rig (3 pods, 8 nodes, mixed runtimes)
rig up demo
# Open the UI
rig ui open
```
After the conveyor rig boots, open the UI and click **intake.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
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 conveyor rig
### 1. Open the demo rig
When the UI opens, the explorer is already visible on the left. Click the `conveyor` rig to load its live topology.
When the UI opens, the explorer is already visible on the left. Click the `demo` rig to load its live topology.
### 2. Click `CMUX` on `intake.lead`
![Step 1: Click the demo rig in the explorer.](assets/ui/screenshots/launch-happy-path/guide-step-01-open-demo.png)
Once the topology loads, go to the `intake.lead` node and click its `CMUX` button. That opens the intake terminal directly.
### 2. Click `CMUX` on `orch1.lead`
> From here, give the intake agent a packet of work and watch it move through planning, build, review, and closeout.
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
@@ -52,36 +56,33 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
## Starter Rigs
## Demo Rig
OpenRig ships with a conveyor starter rig for learning workflow handoff:
OpenRig ships with a demo rig you can boot in seconds:
```bash
rig specs preview conveyor --kind rig
rig specs preview demo
```
```
conveyor (rig, pod_aware)
Starter workflow rig: a station pipeline that can move multiple
work packets at once, with queue depth acting as natural backpressure.
demo (rig, pod_aware)
Launch-grade starter: a stable full product squad with two
orchestrators, implementation, QA, design, and two independent
reviewers.
Pod: intake (1 member)
Pod: orch1 (2 members)
lead — claude-code
Pod: plan (1 member)
planner — codex
Pod: build (1 member)
builder — claude-code
Pod: review (1 member)
reviewer — codex
peer — codex
Pod: dev1 (3 members)
impl — claude-code
qa — codex
design — claude-code
Pod: rev1 (2 members)
r1 — claude-code
r2 — codex
```
For a larger human-operated product-development topology, inspect `product-team`:
```bash
rig specs preview product-team
```
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
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`
@@ -94,7 +95,7 @@ CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services
Domain services (52)
|
SQLite + tmux + runtime adapters
```
@@ -2,8 +2,8 @@
Source capture:
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
- host-forwarded UI: `http://127.0.0.1:<host-port>`
- guest daemon: `<guest-ip>:<guest-port>`
- host-forwarded UI: `http://127.0.0.1:17439`
- guest daemon: `192.168.64.9:7433`
Purpose:
- README walkthrough
@@ -16,8 +16,8 @@ Annotated README walkthrough:
Archived proof/source bundle:
- raw proof captures and guide source were moved out of the repo to:
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
- `/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.
-346
View File
@@ -1,346 +0,0 @@
# OpenRig Design
This document captures the current OpenRig visual system so the product can be
recreated, extended, or translated into other media without reverse-engineering
the UI from code.
## Essence
OpenRig is a local control plane for multi-agent work. The interface should feel
like an operator's tactical dossier: precise, technical, calm, and legible under
pressure. It is not a consumer SaaS dashboard and not a decorative terminal skin.
It is a paper-and-glass workspace for watching work move through rigs, agents,
queues, projects, files, and proof packets.
The brand should communicate:
- Operator confidence: the human can see what is happening and what needs action.
- Composable systems: workspace, mission, slice, rig, pod, seat, queue, file, and
proof surfaces share primitives instead of bespoke one-off cards.
- Live work: activity, flow, context usage, proofs, and terminal state are visible
without turning the UI into a log dump.
- Local-first control: the app looks like a serious local cockpit, not a cloud
marketing product.
## Design Language
OpenRig currently uses two deliberate surface languages.
### Vellum Paper
Vellum is the default light surface language. It appears on navigation chrome,
dashboard cards, topology cards, project cards, drawers, file viewers, and
metadata containers.
Use vellum for persistent product surfaces:
- Explorer sidebars
- Dashboard cards
- Topology rig, pod, and agent cards
- Project and For You cards
- Drawer sheets and document viewers
- File and markdown surfaces
Core vellum traits:
- Paper-grid background visible behind and sometimes through surfaces
- White translucent fills, usually `bg-white/25` to `bg-white/40`
- `backdrop-blur-[8px]` for layered paper
- 1px outline or ghost border
- Hard shadow only on paper cards, not on floating glass
- Registration marks for framed tactical sheets
- Square corners everywhere except tiny status dots
Vellum should never become fully transparent. If the paper-grid makes text hard
to read, increase opacity before adding heavy borders.
### Black Glass
Black glass is the floating dark surface language. It is currently used for the
topology terminal preview and screenshot/proof overlays.
Use black glass for temporary preview surfaces:
- Terminal popovers
- Image proof viewers
- Other quick-look overlays where the content should float above the canvas
Core black-glass traits:
- Transparent dark fill, currently around `bg-stone-950/65`
- Light text, usually `text-stone-50`
- Optional subtle backdrop blur
- No hard shadow
- No 1px vellum border
- Chrome stripped to the minimum needed for comprehension
Black glass should feel like a smoked sheet above the canvas, not a framed
terminal emulator.
## Color Tokens
The canonical tokens live in `packages/ui/src/globals.css` and are surfaced to
Tailwind in `packages/ui/tailwind.config.ts`.
Paper:
- `--background: 47 20% 97%` - base paper, `#faf9f5`
- `--surface-container-lowest: 0 0% 100%` - card body white
- `--surface-container-low: 80 10% 95%`
- `--surface-container: 80 10% 92%`
- `--surface-container-high: 100 10% 89%`
- `--surface-container-highest: 100 10% 87%`
Ink:
- `--on-surface: 120 8% 19%` - primary text, `#2e342e`
- `--on-surface-variant: 108 5% 36%` - secondary text, `#5b615a`
- `--inverse-surface: 120 10% 4%` - dark strip fills, near black
Technical linework:
- `--secondary: 213 15% 39%` - connectors, handles, registration marks
- `--secondary-container: 220 39% 91%`
Status:
- `--success: 145 63% 35%`
- `--warning: 40 90% 50%`
- `--tertiary: 1 63% 42%` - stamped red / critical
- `--error: 0 43% 44%`
Borders:
- `--outline: 108 4% 47%`
- `--outline-variant: 108 4% 68%`
Usage rules:
- Use `outline-variant` for most structural borders.
- Reserve stone-900 borders for important card edges, active tabs, and hard
tactical emphasis.
- Do not create one-off color systems for each feature. Classify the state first,
then map to the shared neutral, info, success, warning, or danger tones.
## Typography
Tailwind font families:
- `font-body`: Inter for readable body text
- `font-headline`: Space Grotesk for display and stamped headings
- `font-mono`: JetBrains Mono for labels, IDs, queue state, technical metadata,
terminal previews, tabs, chips, and counters
General typography rules:
- Product labels use uppercase mono with letter spacing around `tracking-[0.10em]`.
- Body copy should be sentence case when it is meant for humans to read.
- Code identifiers should be transformed into human-readable labels where
possible, not displayed raw.
- Terminal and transcript content stays mono but should not force the rest of the
product to read like a log.
## Layout
The shell is route-first and explorer-first:
- 48px icon rail on desktop
- Explorer sidebar for route-specific tree context
- Center workspace for destination content
- Right drawer for transient detail and file views
- Preview stack for pinned quick-look panes
Primary destinations:
- `/` Dashboard
- `/topology` host topology
- `/for-you` For You feed
- `/project` workspace/project observability
- `/specs` Library
- `/settings` Settings
Topology and project both use hierarchical scopes:
- Topology: host -> rig -> pod -> seat
- Project: workspace -> mission -> slice
Each lower scope narrows the filter. Each higher scope widens the context.
## Core Primitives
Use these primitives before adding new component styles.
Vellum:
- `VellumCard`
- `VellumSheet`
- `RegistrationMarks`
- `StatusPip`
- `SectionHeader`
- `EmptyState`
Graphics:
- `RuntimeMark`
- `RuntimeBadge`
- `ToolMark`
- `ToolBadge`
- `ActorMark`
- `OperatorMoodMark`
- `runtime-brand.ts`
- `tool-brand.ts`
Project metadata:
- `ProjectPill`
- `EventBadge`
- `QueueStateBadge`
- `TagPill`
- `ActorChip`
- `DateChip`
- `FlowChips`
- `ProofThumbnailGrid`
- `ProofPacketHeader`
Topology:
- `HostMultiRigGraph`
- `HybridAgentNode`
- `HybridPodGroupNode`
- `RigGroupNode`
- `ActivityRing`
- `HotPotatoEdge`
- `TerminalPreviewPopover`
- `TopologyTableView`
- `TopologyTerminalView`
Preview and documents:
- `SessionPreviewPane`
- `ProofImageViewer`
- `FileViewer`
- `MarkdownViewer`
## Graphics System
OpenRig uses a shared graphics package rather than scattering literal icon logic
through product surfaces.
Runtime marks:
- Claude: pixel-style Claude mark, label `Claude`
- Codex: Codex CLI terminal-circle mark, label `Codex`
- Terminal: compact terminal glyph, label `TTY` or `Terminal`
- Unknown: neutral fallback
Tool marks:
- CMUX: blue chevron mark
- tmux: pane-grid mark with green base
- VS Code: blue code mark
- Terminal, file, markdown, config, code, screenshot, proof, transcript, commit,
folder, skill, video, and trace each have a central brand entry
Actor marks:
- Human/operator uses the monochrome climber mask
- Agent/runtime actors reuse runtime marks
Graphics placement rules:
- Use runtime marks where the runtime is the important scan dimension.
- Do not duplicate the same runtime mark in adjacent columns on the same row.
- Use tool marks for actions, not runtime marks.
- Prefer inline marks and open metadata rows over stacking more rectangular pill
badges when a card is already visually dense.
- Keep marks small enough to support scanning. They are landmarks, not mascots.
## Interaction Patterns
Hover-only actions:
- Dense topology cards show CMUX and terminal actions on hover/focus.
- Keyboard focus must reveal the same actions as mouse hover.
- Action clicks must stop propagation so row or node navigation does not also
fire.
Drawers and overlays:
- Drawers close with their close button and by clicking outside the sheet.
- Image viewers and terminal popovers are quick-look surfaces.
- Topology terminal popovers are portaled above React Flow node stacking contexts.
Queue and project:
- Queue is operational: what is pending, actionable, routed, or closed.
- Story is narrative: what happened over time, with body text primary and
metadata secondary.
- For You is attention routing: your turn, approvals, shipped proof, progress,
and observations.
## Motion
Motion should explain state, not decorate.
Current motion vocabulary:
- Activity card pulse for active, needs-input, and blocked states
- Source and target flash for handoff events
- Hot-potato packet animation on topology graph edges
- Rig frame pulse for aggregate activity
- Subtle route and node enter fades
Rules:
- Prefer slow pulses to blinking.
- Make directional work movement obvious enough to see at graph zoom levels.
- Honor `prefers-reduced-motion`: remove pulse/travel animation and keep static
state signals.
## Accessibility
- Icon-only actions need labels or tooltips.
- Decorative marks should use `aria-hidden`.
- Semantic status should use `StatusPip`, not activity animation classes.
- Keyboard focus must expose hover-only controls.
- Do not rely on color alone for action outcomes.
- Keep dense cards readable at the expected topology zoom range.
## Do And Do Not
Do:
- Use vellum and black-glass as distinct surface paradigms.
- Keep square geometry, 1px lines, and hard paper shadows.
- Centralize graphics through brand helpers.
- Translate raw event/state codes into human-readable labels.
- Make proof and terminal previews available where thumbnails or terminal marks
appear.
- Keep hierarchy visible: workspace -> mission -> slice, host -> rig -> pod ->
seat.
Do not:
- Add new one-off card styles when a primitive can do the job.
- Use emojis as product graphics.
- Turn metadata into a wall of rectangular pills.
- Make terminal/log aesthetics dominate human decision surfaces.
- Add decorative gradient blobs or generic SaaS hero styling.
- Add black borders or hard shadows to black-glass popovers.
- Duplicate runtime/tool brand logic outside `runtime-brand.ts`,
`tool-brand.ts`, and `RuntimeMark.tsx`.
## Implementation References
- Tokens: `packages/ui/src/globals.css`
- Tailwind theme: `packages/ui/tailwind.config.ts`
- Shell: `packages/ui/src/components/AppShell.tsx`
- Routes: `packages/ui/src/routes.tsx`
- Vellum primitives: `packages/ui/src/components/ui/`
- Graphics primitives: `packages/ui/src/components/graphics/RuntimeMark.tsx`
- Runtime brands: `packages/ui/src/lib/runtime-brand.ts`
- Tool brands: `packages/ui/src/lib/tool-brand.ts`
- Project metadata primitives: `packages/ui/src/components/project/ProjectMetaPrimitives.tsx`
- Topology graph nodes: `packages/ui/src/components/topology/HybridTopologyNodes.tsx`
- Terminal popover: `packages/ui/src/components/topology/TerminalPreviewPopover.tsx`
- Session preview: `packages/ui/src/components/preview/SessionPreviewPane.tsx`
+43 -107
View File
@@ -3,16 +3,14 @@
Status:
- OpenRig currently ships a pod-aware multi-agent runtime, rig-scoped environment management, a filesystem-backed spec library, managed-app browsing/review surfaces, and the canonical `secrets-manager` + `vault.specialist` agent-managed app example.
- OpenRig v0.2.0 also ships daemon-backed coordination primitives: stream, queue, project, view, watchdog, and workflow runtime.
- Shipped source footprint: `376` source files across the three packages.
- Daemon footprint: `211` source files total, including `126` domain files, `31` route files, `11` adapters, and `36` migrations.
- CLI footprint: `78` source files.
- 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 v0.2.0 release verification:
- root build and package build passing
- daemon focused suite: `171/171` files, `2561/2561` passing
- CLI sanitized suite: `56/56` files, `794/794` passing
- Mode 3 real-agent dual-auth dogfood covered Claude-only, Codex-only, and mixed Claude-to-Codex paths
- Current full-suite verification during this refresh:
- 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`
@@ -34,10 +32,10 @@ Legacy flat-node/package flows remain for backward compatibility.
The stack is:
```text
CLI (53 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
CLI (40 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
v
Hono daemon routes (31 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)
@@ -51,7 +49,7 @@ Hono daemon routes (31 route groups + dedicated health/export/static handlers)
v
Framework-free domain services
|
+-- SQLite state (36 migrations)
+-- SQLite state (20 migrations)
+-- tmux / cmux / resume adapters
+-- runtime adapters (Claude Code / Codex / Terminal)
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
@@ -122,7 +120,7 @@ Important route behaviors:
### CLI commands
`index.ts` currently mounts 53 command groups. Reboot-era commands plus:
`index.ts` currently mounts 40 command groups. Reboot-era commands plus:
**Operator and topology commands:**
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
@@ -174,67 +172,36 @@ The CLI-hosted MCP server exposes 17 tools:
### UI architecture
The current UI is shell-first, route-first, and primitive-driven. See
`docs/DESIGN.md` for the brand/design spec and `docs/as-built/ui.md` for the detailed
as-built UI map.
The UI is now explorer-first, with a shared drawer plus center-workspace route model:
High-level shape:
- `AppShell` wraps every route with a desktop rail, route-aware `Explorer`,
center workspace, shared right drawer, preview stack, and topology overlay
provider.
- Primary destinations are Dashboard (`/`), Topology (`/topology`), For You
(`/for-you`), Project (`/project`), Library (`/specs`), and Settings
(`/settings`).
- Topology uses scoped routes for host, rig, pod, and seat views:
`/topology`, `/topology/rig/$rigId`, `/topology/pod/$rigId/$podName`, and
`/topology/seat/$rigId/$logicalId`.
- Project uses scoped routes for workspace, mission, and slice:
`/project`, `/project/mission/$missionId`, and `/project/slice/$sliceId`.
- Library keeps `/specs` as the route but presents the product label
`Library`, including skills at `/specs/skills/$skillToken` and
`/specs/skills/$skillToken/file/$fileToken`.
Current UI primitives:
- Vellum primitives live in `components/ui`: `VellumCard`, `VellumSheet`,
`RegistrationMarks`, `StatusPip`, empty states, tabs, tables, buttons, and
form controls.
- Graphics primitives live in `components/graphics` and `lib`: `RuntimeMark`,
`RuntimeBadge`, `ToolMark`, `ToolBadge`, `ActorMark`, `runtime-brand.ts`, and
`tool-brand.ts`.
- Project metadata primitives live in `components/project/ProjectMetaPrimitives`
and render event badges, queue state badges, tag pills, actor chips, date
chips, flow chips, proof thumbnails, and proof packet headers.
Current topology UI:
- Host topology is a hybrid React Flow graph with soft rig frames, pod frames,
compact agent cards, context percentage, token totals, activity card tint,
activity rings, hot-potato edge animation, hover/focus CMUX actions, and
terminal preview popovers.
- Topology table and terminal views share the same route-scope model and preserve
graph/table/tree navigation parity to seat detail URLs.
- Terminal preview popovers use black-glass styling and are portaled above the
React Flow canvas to avoid node stacking conflicts.
Current project and feed UI:
- Project scopes render Story, Progress, Artifacts, Tests, Queue, and Topology
tabs filtered by workspace, mission, or slice.
- Story is narrative and body-primary; Queue is operational and actionable.
- For You classifies feed items into action-required, approval, shipped,
progress, and observation cards, with body previews, proof thumbnails, action
outcomes, actor flow, and queue actions.
- Proof images use a reusable black-glass viewer.
Current drawer/viewer model:
- `SharedDetailDrawer` remains the shared transient detail surface.
- Drawer triggers carry root/path provenance for file and queue item viewers.
- The drawer supports outside-click dismissal.
- Skill details use Library Explorer navigation plus `FileViewer` in the center
workspace instead of a nested three-column browser.
- `/` renders `WorkspaceHome` (landing page when no rig selected)
- `AppShell` composes: `Explorer` sidebar + shared drawer selection context + `SharedDetailDrawer` + specs workspace context + activity/system surfaces
- Selecting a rig in the explorer loads its topology graph in the main area
- Clicking a node (in explorer or graph) opens the shared detail drawer
- The shared drawer has five primary surfaces:
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
- **Specs drawer:** 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`
- `/specs/agent`
- `/specs/library/$entryId`
- `/rigs/$rigId/nodes/$logicalId`
---
@@ -361,41 +328,10 @@ Migration boundary:
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Phase D extends the policy enum to four values (Phase C's three plus `workflow-keepalive`); see § Workflow Runtime below.
- `033_workflow_specs.ts`, `034_workflow_instances.ts`, `035_workflow_step_trails.ts` add the PL-004 Phase D Workflow Runtime tables. Migration `036_watchdog_policy_enum_extension.ts` is a documenting no-op that records the Phase D enum extension (Phase C uses application-layer enforcement via `PHASE_D_POLICIES` array, so no DDL is needed).
- `037_mission_control_actions.ts` adds the PL-005 Phase A Mission Control action audit table. Append-only at the API surface; records every operator action through Mission Control with before/after qitem snapshots for forensic reconstruction (see § Mission Control / Queue Observability below).
### Mission Control / Queue Observability (PL-005 Phase A)
Daemon-backed Mission Control surface. Per PRD acceptance criteria: seven views, seven verbs, first-class human seats, recent-ships=10, and a daemon-backed action audit table; no old-dashboard migration/cutover.
**Seven views** (`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`, `recently-active`, `recent-observations`) all return rows in the load-bearing 9-field phone-friendly content model (rig/mission name, current phase, active|idle|attention|blocked|degraded, next-action, pending-human-decision, read-cost, last-update timestamp, confidence/freshness, evidence link). The model is non-negotiable across all 7 views; UI may render compact, JSON preserves all 9.
`MissionControlReadLayer` maps each view to its source-of-truth path:
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004 Phase A `queue_items` via `QueueRepository`.
- `fleet` consumes the per-rig CLI capability cache + queue summary.
- `recently-active` delegates to PL-004 Phase B `ViewProjector.show("recently-active")`.
- `recent-observations` reads PL-004 Phase A `stream_items` via `StreamStore`.
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep) are graceful-degradation aids, NOT the primary path.
**Seven verbs** (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute through the load-bearing `MissionControlWriteContract`. Each verb is one atomic daemon transaction: queue mutation via `QueueRepository.updateWithinTransaction()` (which preserves Phase A hot-potato closure validation) + audit row in `mission_control_actions` + persisted `mission_control.action_executed` event, all in one `db.transaction`. The 4-step `handoff` shape (source-update + destination-create + opt-in best-effort notify + audit-record append) is verified atomic; notify failure does NOT roll back durable mutations (PRD invariant). Failure-injection rolls back source closure + audit row + new qitem together.
**Cross-CLI-version drift** is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance:
1. Per-field availability check (`MissionControlFleetCliCapability.probeRig`).
2. Per-rig capability honesty (each rig surfaces its own capabilities; mixed fleet rendered honestly).
3. Once-per-session-per-rig logging (`MissionControlFleetCliCapability` deduplicates drift events by `(rig, field)` until daemon restart).
4. Fleet-level "rigs running stale CLI" indicator (`FleetView` surfaces `staleCliCount` from `/api/mission-control/cli-capabilities`).
**Human seats are first-class product concepts**: the operator's default `human-operator@kernel` seat is rendered via `HumanSeatCard` showing identity, pending load, blocked count, and verb capabilities. NOT invisible config-layer convention.
**Mission Control is integrated product UI inside the existing shell** — a new top-level route `/mission-control` registered in `packages/ui/src/routes.tsx` mounting `MissionControlSurface`, NOT a new managed app. An earlier prototype that informed Mission Control's view/verb vocabulary was retired in PL-005 Phase B; git history preserves it as evidence.
PL-005 Phase B extends the daemon Mission Control surface with: a tailnet-bind bearer-token middleware (`packages/daemon/src/middleware/auth-bearer-token.ts` — constant-time comparison via `crypto.timingSafeEqual`; daemon refuses non-loopback bind when the bearer is empty; mounted on `POST /api/mission-control/action` and `POST /api/mission-control/notifications/test`); a notification dispatcher with two adapters (`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts` alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env; ntfy.sh recommended for the operator's phone via tailnet); a read-only audit-history browse layer (`audit-browse.ts`) over `mission_control_actions` exposed at `GET /api/mission-control/audit` with filters (`qitem_id`, `action_verb`, `actor_session`, `since`, `until`) and `(limit, before_id)` pagination cursored on SQLite `rowid` (deterministic insertion order; ULID action_id same-ms tail is non-deterministic). Two new RigEvent types (`mission_control.notification_sent`, `mission_control.notification_failed`) cover the notification audit trail. Phase B adds NO migrations — the audit table from Phase A migration `037_mission_control_actions` is the only data source. UI adds an `audit-history` tab to `MissionControlSurface` with filter UI + paginated result table; the existing 7 Phase A views and 7 verbs are unmodified.
PL-005 Phase A extends `RigEvent` with 3 new types: `mission_control.action_executed`, `mission_control.cli_drift_detected`, `mission_control.view_refreshed`. Existing 32 PL-004 events untouched. Localhost-only auth at v0 (matches existing dashboard posture; remote-bind is a separate slice).
### Workflow Runtime (PL-004 Phase D)
Daemon-native Workflow Runtime — declarative workflow specs, live instance state, append-only step trails, and the load-bearing transactional-scribe contract. The PRD § L4 operating model is owner-as-author semantically plus workflow-as-transactional-scribe mechanically.
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.
@@ -796,7 +732,7 @@ Restore now:
### Config and preflight flow
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. The original runtime env keys retain deprecated `RIGGED_*` aliases for upgrade compatibility: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`. New typed config keys use `OPENRIG_*` only.
- `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
- `rig preflight` checks: Node.js ≥ 20, tmux available, writable home/db/transcript dirs, port available
- Auto-preflight runs on `rig up` and daemon start
@@ -1141,4 +1077,4 @@ These are the main intentional limits that still describe the shipped system:
This document is the architecture-level source of truth.
For file-by-file structure across daemon, CLI, and UI, use:
- [codemap.md](./codemap.md)
- [codemap.md](/Users/mschwarz/code/rigged/docs/as-built/codemap.md)
+11 -109
View File
@@ -1,6 +1,6 @@
# OpenRig CLI Reference
Verified against the shipped CLI on 2026-05-15 (v0.3.1) using:
Verified against the shipped CLI on 2026-05-03 (v0.2.0) using:
- `packages/cli/src/index.ts`
- `packages/cli/src/commands/*.ts`
- `packages/cli/src/mcp-server.ts`
@@ -11,7 +11,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
## Overview
- Binary: `rig`
- Top-level command groups: `57`
- Top-level command groups: `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`
@@ -74,10 +74,6 @@ This document reflects the current `rig` surface as shipped. Where live help tex
| `compact-plan` | Plan Claude compact-in-place candidates without compacting anything |
| `heartbeat` | Show workflow execution proof state from queue files |
| `seat` | Inspect OpenRig seat observability state |
| `agent-image` | Browse, snapshot, and manage agent images |
| `context-pack` | Browse, preview, send, and install operator-authored context packs |
| `workspace` | Workspace primitive — typed-kind tooling (frontmatter validation) |
| `plugin` | Inspect plugins (read-only) — list, show, used-by, validate |
## Core Daemon and System Commands
@@ -114,11 +110,10 @@ Subcommands:
### `rig config`
Usage:
- `rig config [--json] [--with-source]`
- `rig config get <key> [--show-source]`
- `rig config [--json]`
- `rig config get <key>`
- `rig config set <key> <value>`
- `rig config reset [<key>]`
- `rig config init-workspace [--root <path>] [--force] [--dry-run] [--json]`
- `rig config reset`
Supported keys:
- `daemon.port`
@@ -126,7 +121,6 @@ Supported keys:
- `db.path`
- `transcripts.enabled`
- `transcripts.path`
- `workspace.root` (and other workspace-rooted paths used by `init-workspace`)
Precedence:
- CLI flag
@@ -134,13 +128,6 @@ Precedence:
- config file
- default
Notes:
- `--with-source` (top-level) and `--show-source` (`get`) report per-key source/default for honest provenance.
- `init-workspace` scaffolds the default workspace at `~/.openrig/workspace/` (or the `--root` override) with mission/slice folders. `--force` overwrites scaffolded files (does NOT remove directories); `--dry-run` previews without writing. New in v0.3.0.
Legacy env compatibility: the original runtime keys still accept deprecated
`RIGGED_*` aliases. New typed config keys use `OPENRIG_*` only.
### `rig preflight`
Usage: `rig preflight [--json]`
@@ -261,8 +248,6 @@ Flags:
Notes:
- When `--snapshot` succeeds, human output includes the restore command.
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
- v0.2.0 known issue: `rig down <name> --delete` can return HTTP 404; use
`rig ps` to find the rig ID and run `rig down <rigId> --delete`.
### `rig env`
@@ -282,7 +267,7 @@ Notes:
Usage:
- `rig ps [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--host <id>]`
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
Notes:
- `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`.
@@ -291,8 +276,7 @@ Notes:
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
- `--filter <key=value>` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do).
- `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture.
- `--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
@@ -505,8 +489,8 @@ members:
ref:
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
value: # ordered list, highest-trust first
- <substrate-shared-docs>/rigs/<rig>/CULTURE.md
- <substrate-shared-docs>/specs/agents/<role>.md
- /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
@@ -628,12 +612,12 @@ hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: your-username # optional
user: wrandom # optional
notes: "Tart VM" # optional
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: your-username
user: wrandom
```
Validation rules:
@@ -864,88 +848,6 @@ Notes:
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
## Mission Control / Queue Observability (PL-005 Phase A)
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only."
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture.md` § Mission Control / Queue Observability. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
## Agent Images, Context Packs, and Workspace (v0.3.0)
Three top-level commands shipped in v0.3.0 for operator-authored library content (agent images and context packs) and the workspace primitive.
### `rig agent-image`
Usage: `rig agent-image <subcommand>` — browse, snapshot, and manage agent images (PL-016).
Subcommands:
- `list [options]` — list all agent images in the library.
- `show <name-or-id> [options]` — show image manifest + statistics.
- `preview <name-or-id> [options]` — show manifest + sized supplementary file metadata + starter snippet.
- `create <source-session> [options]` — capture a productive seat's resumable state into a new agent image.
- `delete <name-or-id> [options]` — delete an agent image (subject to evidence-preservation guard).
- `pin <name-or-id> [options]` — pin an image so prune cannot delete it.
- `unpin <name-or-id> [options]` — unpin an image.
- `prune [options]` — delete evictable images (protected by evidence-preservation guard).
- `sync [options]` — re-walk discovery roots and refresh the library index.
Notes:
- Images are an operator-authored library form; deletion is gated by an evidence-preservation guard so productive seat snapshots are not lost accidentally.
- `pin` / `unpin` are the operator levers for explicit retention; `prune` honours them.
### `rig context-pack`
Usage: `rig context-pack <subcommand>` — browse, preview, send, and install operator-authored context packs.
Subcommands:
- `list [options]` — list all context packs in the library.
- `show <name-or-id> [options]` — show pack manifest + per-file metadata.
- `preview <name-or-id> [options]` — show the assembled bundle (the exact text that would be sent).
- `sync [options]` — re-walk discovery roots and refresh the library index.
- `add <source-dir> [options]` — install a context pack from a local directory into `~/.openrig/context-packs/`.
- `send <name-or-id> <destination-session> [options]` — assemble the pack into one paste-ready bundle and send to a seat.
Notes:
- Context packs are operator-authored bundles of context (manifest + files) intended to prime a managed seat with a coherent starting context.
- `preview` is the canonical way to see exactly what `send` will deliver; useful before priming a live seat.
- `send --dry-run` is supported for preview-then-deliver flows.
### `rig workspace`
Usage: `rig workspace <subcommand>` — Workspace Primitive (PL-007), v0 typed-kind tooling.
Subcommands:
- `validate [root]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`.
Notes:
- v0 surface is intentionally narrow (`validate` only) — read-only frontmatter audit.
- Future versions will add typed-kind authoring/refactor tooling on the same root walker.
- See `rig config init-workspace` to scaffold a fresh default workspace.
## Plugin Inspection (v0.3.1)
One read-only top-level command added in v0.3.1 to inspect plugins
discovered from `$OPENRIG_HOME/plugins/` (default `~/.openrig/plugins/`).
No `install` verb at v0 — installation is explicit operator copy or
symlink per each plugin's `OPENRIG-INSTALL.md`.
### `rig plugin`
Usage: `rig plugin <subcommand>` — read-only plugin inspection.
Subcommands:
- `list [options]` — list discoverable plugins (aggregated across vendored + runtime caches).
- `show <id> [options]` — show plugin manifest + skills + hooks + mcp servers.
- `used-by <id> [options]` — list agents referencing this plugin in their `profile.uses.plugins[]`.
- `validate <path> [options]` — validate plugin manifest + skill frontmatter against the agentskills.io spec.
Notes:
- Plugin discovery aggregates `$OPENRIG_HOME/plugins/` (vendored at runtime by the operator) with the daemon's bundled plugin cache.
- `openrig-core` ships bundled with the daemon (11 skills). Additional plugins (`gstack` — 45 skills; `obra-superpowers` — 14 skills) ship as substrate references for plugin authors to copy-install per the `OPENRIG-INSTALL.md` workflow inside each plugin's source tree.
- A first-class `rig plugin install <substrate-path>` verb is deferred to 0.3.2.
## Commands Not Present
These are not current top-level `rig` commands:
+5 -22
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: 211 source files (126 domain, 31 routes, 11 adapters, 36 migrations, 7 other)
- CLI: 78 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: 376 source files
- total: 286 source files
How to use this doc:
- `Exports` lists the public symbols defined by the file.
@@ -27,7 +27,7 @@ Fast lookup:
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (53 groups)
- CLI 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`
@@ -42,7 +42,7 @@ Fast lookup:
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 36 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, Final Stretch 3 managed-app services, and PL-004 coordination services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
- `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`.
@@ -98,23 +98,6 @@ Fast lookup:
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
- `packages/daemon/src/db/migrations/037_mission_control_actions.ts`: PL-005 Phase A — Mission Control action audit table (append-only; 7-verb enum at app layer; before/after qitem snapshots; FK to queue_items). Exports: missionControlActionsSchema. Related: `packages/daemon/src/db/migrate.js`.
### Domain: Mission Control / Queue Observability (PL-005 Phase A)
- `packages/daemon/src/domain/mission-control/mission-control-action-log.ts`: Append-only writer for mission_control_actions. API surface lacks update/delete/remove (contract enforcement). Validates verb-specific required fields (annotate→annotation, hold/drop→reason). Exports: MissionControlActionLog, MissionControlActionLogError, MissionControlVerb, MISSION_CONTROL_VERBS. Related: `packages/daemon/src/db/migrations/037_mission_control_actions.js`.
- `packages/daemon/src/domain/mission-control/mission-control-write-contract.ts`: LOAD-BEARING. Atomic 7-verb action coordinator. Each verb is one db.transaction: queue mutation via Phase D's QueueRepository.updateWithinTransaction (preserves Phase A hot-potato closure validation; emits queue.updated) + audit row + persisted mission_control.action_executed event. The 4-step handoff shape (source-update + destination-create + opt-in best-effort notify + audit append) is verified atomic; notify failure does NOT roll back durable state. Exports: MissionControlWriteContract, MissionControlWriteContractError, MissionControlActionInput, MissionControlActionResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/mission-control/mission-control-action-log.js`.
- `packages/daemon/src/domain/mission-control/mission-control-read-layer.ts`: Maps 7 Mission Control views to source-of-truth paths. my-queue/human-gate/active-work/recent-ships query queue_items via QueueRepository; fleet uses fleetCliCapability roll-up; recently-active delegates to PL-004 Phase B ViewProjector.show("recently-active"); recent-observations reads stream_items via StreamStore. All rows return the 9-field phone-friendly content model (PRD non-negotiable). Exports: MissionControlReadLayer, MissionControlViewName, CompactStatusRow, MISSION_CONTROL_VIEWS. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/view-projector.js`, `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.js`.
- `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts`: Per-rig CLI capability cache + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: per-field availability check, per-rig honesty, once-per-session-per-rig logging, "rigs running stale CLI" indicator. Synthesizes per-rig activity state (active|idle|attention|blocked|degraded) from queue_items aggregation. Exports: MissionControlFleetCliCapability, FleetRollupRow, FleetRollup, MISSION_CONTROL_DESIRED_FIELDS. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/db/migrations/024_queue_items.js`.
- `packages/daemon/src/routes/mission-control.ts`: Hono route module for `/api/mission-control` (`GET /views` literal list; `GET /views/:view-name` view fetch; `POST /action` 7-verb atomic execute; `GET /sse` + `GET /watch` SSE; `GET /cli-capabilities` fleet drift cache). SSE/literal paths mounted before /views/:view-name catchall per PL-004 Phase A R1 route-order discipline. Exports: missionControlRoutes. Related: `packages/daemon/src/domain/mission-control/*`, `packages/daemon/src/domain/event-bus.js`.
### Product UI: Mission Control / Queue Observability (PL-005 Phase A)
- `packages/ui/src/components/mission-control/MissionControlSurface.tsx`: Top-level Mission Control surface inside the existing product UI shell. Tab navigation across the 7 view names. Mounted at the `/mission-control` route. Exports: MissionControlSurface.
- `packages/ui/src/components/mission-control/views/{MyQueueView,HumanGateView,FleetView,ActiveWorkView,RecentShipsView,RecentlyActiveView,RecentObservationsView}.tsx`: 7 view components. Most wrap a shared GenericListView; FleetView combines CliDriftIndicator + per-row MissingFieldPlaceholder; MyQueueView combines HumanSeatCard + GenericListView with verb actions enabled.
- `packages/ui/src/components/mission-control/components/CompactStatusRow.tsx`: 9-field phone-friendly content-model atom (PRD non-negotiable). Renders compact / expanded density modes; UI may collapse but JSON preserves all 9 fields.
- `packages/ui/src/components/mission-control/components/VerbActions.tsx`: Shared 7-verb action sub-component. Renders verb buttons + per-verb required-field input (destination for route/handoff, annotation for annotate, reason for hold/drop). Submits to `POST /api/mission-control/action`. Invalidates Mission Control view queries on success.
- `packages/ui/src/components/mission-control/components/HumanSeatCard.tsx`: First-class human-seat rendering. Shows session identity, pending load, blocked count, verb capabilities. NOT invisible config-layer convention.
- `packages/ui/src/components/mission-control/components/CliDriftIndicator.tsx`: Cross-CLI-version drift indicator (PRD sub-clause 4). Shows fleet-level "rigs running stale CLI" badge + per-row "field unavailable on this rig's daemon version" placeholders via the MissingFieldPlaceholder export.
- `packages/ui/src/components/mission-control/hooks/{useMissionControlView,useMissionControlAction,useMissionControlCliCapabilities}.ts`: TanStack Query hooks fetching from `/api/mission-control/{views/:name,action,cli-capabilities}`.
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
-238
View File
@@ -1,238 +0,0 @@
# OpenRig UI As-Built
This document describes the current UI architecture and component system. The
brand/design rules live in `docs/DESIGN.md`; this file focuses on how the shipped UI
is assembled.
## Package Shape
UI package: `packages/ui`
Primary implementation areas:
- `src/routes.tsx` - TanStack Router route tree
- `src/components/AppShell.tsx` - global shell, rail, Explorer, drawer, preview stack
- `src/components/ui/` - reusable vellum and base UI primitives
- `src/components/graphics/` - runtime, tool, and actor marks
- `src/components/topology/` - host/rig/pod/seat graph, table, terminal, activity
- `src/components/project/` - workspace/mission/slice observability and project metadata primitives
- `src/components/for-you/` - attention feed and actionable queue cards
- `src/components/specs/` - Library and skill file viewing
- `src/components/preview/` - terminal/session preview panes
- `src/hooks/` - route data hooks and event consumption
- `src/lib/` - classifiers, formatters, layout helpers, brand helpers, activity logic
## Shell Model
`AppShell` wraps every route with:
- Desktop rail
- Route-aware Explorer sidebar
- Center workspace
- Shared detail drawer
- Preview stack
- Topology overlay state provider
- Shared drawer selection context
- Shared discovery placement context
The rail destinations are:
- Dashboard: `/`
- Topology: `/topology`
- For You: `/for-you`
- Project: `/project`
- Library: `/specs`
- Settings: `/settings`
- Advisor and Operator placeholders/settings links
The Explorer is contextual. It changes its tree based on the current destination
rather than behaving as a generic file browser.
## Route Model
Main routes:
- `/` - Dashboard
- `/topology` - host topology scope
- `/topology/rig/$rigId` - rig scope
- `/topology/pod/$rigId/$podName` - pod scope
- `/topology/seat/$rigId/$logicalId` - seat scope with live node details
- `/for-you` - attention feed
- `/project` - workspace project scope
- `/project/mission/$missionId` - mission scope
- `/project/slice/$sliceId` - slice scope
- `/specs` - Library
- `/specs/applications` - application section
- `/specs/skills/$skillToken` - skill viewer defaulting to `skill.md`
- `/specs/skills/$skillToken/file/$fileToken` - specific skill file viewer
- `/settings` - settings center
- `/search` - audit/history view
- `/lab/project-graphics-preview` - graphics preview lab route
Legacy routes remain for compatibility where needed, including package flows,
bootstrap flows, validation flows, and `/rigs/$rigId`.
## Design Primitives
The UI moved from route-specific one-off panels toward reusable primitives.
Base primitives:
- `VellumCard` - paper card with optional dark header, hard shadow, registration marks
- `VellumSheet` - side sheet/drawer with vellum-heavy surface and registration marks
- `RegistrationMarks` - corner registration marks
- `StatusPip` - semantic status only
- `SectionHeader`, `EmptyState`, `Button`, `Tabs`, `Table`, and form controls
Graphics primitives:
- `RuntimeMark` and `RuntimeBadge`
- `ToolMark` and `ToolBadge`
- `ActorMark` and `OperatorMoodMark`
- Runtime and tool normalization in `runtime-brand.ts` and `tool-brand.ts`
Project metadata primitives:
- `EventBadge`
- `QueueStateBadge`
- `TagPill`
- `ActorChip`
- `DateChip`
- `FlowChips`
- `ProofThumbnailGrid`
- `ProofPacketHeader`
These primitives centralize visual language for runtime identity, queue state,
actor flow, event labels, dates, tags, and proof images.
## Topology
Topology is a scoped workspace with graph, table, and terminal views.
Current topology pieces:
- `HostMultiRigGraph` renders the host-level hybrid React Flow graph.
- `HybridAgentNode` renders compact agent cards with runtime badges, context
percentage, token totals, activity state, terminal preview, and CMUX actions.
- `HybridPodGroupNode` renders soft dashed pod frames.
- `RigGroupNode` renders soft rig frames with registration marks and aggregate
activity.
- `ActivityRing` and card activity classes show active, needs-input, and blocked
states.
- `HotPotatoEdge` visualizes directional queue movement through the graph.
- `TopologyTableView` mirrors topology data in a dense table.
- `TopologyTerminalView` shows terminal-oriented topology state.
- `TerminalPreviewPopover` provides black-glass quick terminal preview above the
graph via a portal.
Important topology contracts:
- Graph/table/tree navigation resolves to seat URLs.
- Host-level graph uses cross-rig node ID prefixing.
- Expanded rig state is managed by `TopologyOverlayProvider`.
- Graph data is lazy-fetched for expanded rigs.
- Terminal preview actions are hover/focus visible.
- The terminal popover must escape React Flow stacking contexts and stay within
the viewport.
## Project Observability
Project is organized as a hierarchy:
- Workspace
- Mission
- Slice
The current route scope acts as a filter. Higher scopes show broader rollups;
lower scopes show more specific story, queue, proof, artifact, test, and topology
data.
Current project tabs:
- Story - narrative progress and queue body content
- Progress - status and activity rollup
- Artifacts - files, commits, proof, and outputs
- Tests - proof material, screenshots, and verification detail
- Queue - operational qitems with actions and drawer detail
- Topology - workflow/agent diagram where available
Project primitives translate raw queue and event data into readable badges,
chips, dates, flows, proof thumbnails, and action outcome panels.
## For You
For You is the attention-routing surface. It uses live event feed data plus queue
details to classify cards into:
- Action Required
- Approval
- Shipped
- Progress
- Observation
The card renderer prioritizes readable body content, proof thumbnails, actor
flow, friendly dates, and action affordances. Queue actions reuse existing
mission-control action endpoints.
## Library And Skills
`/specs` is the Library route. It contains specs, applications, context packs,
agent specs, agent images, and skills.
Skills are folder-based. The skill tree in Explorer treats each skill as a
folder. Clicking a skill opens a skill detail route that defaults to `skill.md`
case-insensitively. The skill detail page uses the existing `FileViewer` and
does not add a second in-page file browser; file navigation belongs in the
Explorer tree.
## Drawer And Viewer System
The shared detail drawer is used for transient focused detail:
- Queue item viewer
- File viewer
- Subspec preview
- Other detail selections
Drawer triggers carry root/path provenance so the viewer can fetch and render the
correct content. The drawer supports outside-click dismissal.
Image proof viewing is handled by `ProofImageViewer`, which respects the shell
layout and uses black-glass styling.
## Event And Activity Consumption
UI event consumption is centralized through shared event hooks so the app avoids
spawning duplicate SSE connections for related surfaces.
Current activity consumers include:
- For You feed hydration
- Topology activity rings and hot-potato movement
- Activity feed/system surfaces
- Rig event surfaces where appropriate
Reduced-motion preference is honored for pulse and packet animations.
## Graphics Layer
The graphics package is intentionally centralized:
- Runtime identity: Claude, Codex, Terminal, Unknown
- Tool identity: CMUX, tmux, VS Code, Terminal, file, markdown, config, code,
screenshot, proof, transcript, commit, folder, skill, video, trace
- Actor identity: human/operator, runtime agents, terminal fallback
The goal is to make dense operator surfaces scannable without adding more raw
metadata text.
## Current Design Constraints
- Use the shared primitives before creating new card styles.
- Keep vellum and black-glass separate.
- Keep status semantics separate from activity animation.
- Do not duplicate runtime/tool brand detection outside the central helpers.
- Do not add new daemon shape for UI polish unless the slice explicitly declares
and verifies that boundary.
- Do not make page-level content look like a raw terminal log unless the content
is actually terminal output.
+7 -7
View File
@@ -46,7 +46,7 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging]
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha]
guidance: []
subagents: []
hooks: []
@@ -91,7 +91,7 @@ profiles:
preferences:
runtime: claude-code
uses:
skills: [openrig-user, systematic-debugging, vault-user]
skills: [openrig-user, mental-model-ha, vault-user]
guidance: []
subagents: []
hooks: []
@@ -216,7 +216,7 @@ imports:
- ref: local:../../shared
```
This gives access to the full pool of shared skills (openrig-user, systematic-debugging, development-team, etc.) and agents select the ones they need via profile `uses`.
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`.
---
@@ -230,7 +230,7 @@ profiles:
runtime: claude-code
model: claude-opus-4-6
uses:
skills: [openrig-user, systematic-debugging, vault-user]
skills: [openrig-user, mental-model-ha, vault-user]
guidance: [role]
subagents: []
hooks: []
@@ -258,7 +258,7 @@ The `uses` block selects which resources from the `resources` pool (including im
```yaml
uses:
skills: [openrig-user, systematic-debugging, vault-user]
skills: [openrig-user, mental-model-ha, vault-user]
guidance: [role]
subagents: []
hooks: [pre-commit]
@@ -410,9 +410,9 @@ The only required file is `agent.yaml`. Everything else is referenced by paths i
| Agent | Location | Imports | Profile Skills | Purpose |
|-------|----------|---------|---------------|---------|
| `shared` | `specs/agents/shared/` | none | — (resource pool only) | Shared skill pool for all built-in agents |
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, systematic-debugging, etc. | TDD implementation agent |
| `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, systematic-debugging, vault-user | Vault domain specialist |
| `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 |
+2 -2
View File
@@ -61,7 +61,7 @@ Skills are delivered to agents through two parallel paths:
Both paths are important. The spec projection ensures the files are physically present. The startup instruction ensures the agent knows to read them. This redundancy is intentional — it handles cases where one path fails.
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, systematic-debugging" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
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
@@ -301,7 +301,7 @@ Don't put project documentation inside the role guidance. The role is about the
In a startup file or guidance, include a line like:
```
You have the following skills loaded: openrig-user, test-driven-development, systematic-debugging. Use them.
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.
-102
View File
@@ -1,102 +0,0 @@
# Project Workspace Contract
OpenRig's Project UI is a file-backed view over a workspace root. The folder
shape is intentionally simple so humans and agents can create or repair it
without daemon-internal knowledge.
## Default Shape
`rig config init-workspace` creates the default workspace at
`~/.openrig/workspace` unless `--root` or `workspace.root` points elsewhere.
```text
workspace/
README.md
STEERING.md
missions/
README.md
idea-ledger/
README.md
PROGRESS.md
slices/
capture-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
triage-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
handoff-loop/
README.md
PROGRESS.md
slices/
route-work-packets/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
verify-loop-evidence/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
progress/
field-notes/
specs/
```
## UI Mapping
- `workspace.root` maps to the Project workspace.
- `workspace.root/missions/<mission-id>` maps to a Project mission.
- `workspace.root/missions/<mission-id>/slices/<slice-id>` maps to a Project slice.
- Mission `PROGRESS.md` frontmatter supplies the mission status badge when the
file root is allowlisted.
- Slice `README.md`, `PROGRESS.md`, and `IMPLEMENTATION-PRD.md` frontmatter
supply display name, lifecycle status, mission id, and queue linkage hints.
Mission and slice ids should be stable kebab-case strings. Keep slice ids
unique inside the workspace so `/project/slice/<slice-id>` resolves without
ambiguity.
## Queue Mapping
Queue items attach to a slice when their body or tags mention one of:
- the slice id;
- the mission id;
- the legacy `rail-item` value in slice frontmatter.
For new work, include both mission and slice ids in the queue item body or
tags. Example:
```text
Mission: idea-ledger
Slice: capture-product-ideas
```
This makes Story, Queue, Tests, and Topology tabs line up with the filesystem
slice without adding a separate project database schema.
## Compatibility
The default discovery root is `workspace.slices_root=<workspace.root>/missions`.
The slice indexer also supports legacy flat roots such as
`workspace.slices_root=<workspace.root>/slices`, where each direct child folder
is a slice. Flat roots remain readable, but the mission-aware shape is the
default setup contract.
## Repair Checklist
If Project shows a mission discovery warning:
1. Run `rig config get workspace.root --show-source`.
2. Run `rig config get workspace.slices_root --show-source`.
3. Confirm `workspace.slices_root` points at a folder containing mission
directories with `slices/` children.
4. Confirm `files.allowlist` includes `workspace:<workspace.root>` so the UI
can read mission `PROGRESS.md`.
5. If the workspace is missing, run `rig config init-workspace` after operator
approval.
No daemon restart is required for most config reads. Restart when changing
startup-time roots such as `files.allowlist` or progress scan roots.
+1 -2
View File
@@ -7,8 +7,7 @@ It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.2.0.md`
- `v0.3.0.md`
- `v0.1.13.md`
- and so on
## Why This Exists
-119
View File
@@ -1,119 +0,0 @@
# OpenRig v0.3.0
## Summary
OpenRig v0.3.0 turns the 0.2 coordination substrate into a more polished
operator product. It ships the redesigned shell, generic workflow starters,
mission-shaped workspace support, project observability, curated bundled skills,
runtime graphics, context telemetry, and release-hardening work for a cleaner
fresh install.
This release keeps the reusable primitives for queue handoff, workflow runtime,
watchdog supervision, artifact/proof surfaces, and agent coordination, while
the public starter content is intentionally generic.
## Included In This Release
- V1 shell redesign with the vellum visual system, two desktop chrome regions,
topology graph/table/terminal modes, drawer viewers, Project, For You, Queue,
Library, Settings, and proof-oriented detail surfaces.
- Generic starter workflow specs:
- `conveyor` for station-style work where multiple packets can be active
across the topology at once.
- `basic-loop` for a one-packet-at-a-time walkthrough flow.
- `conveyor` starter rig and matching agent specs for intake/planning/building
and review-oriented work.
- Mission-shaped workspace defaults and read-only nested mission/slice indexing.
- Project observability upgrades across For You, Story, Queue, Tests, Project
rollups, proof screenshots, and qitem drawers.
- Runtime/tool graphics package applied across topology, queue, project metadata,
proof rows, terminal previews, and agent identity marks.
- Terminal preview improvements, activity indicators, handoff edge animation,
and context usage telemetry in topology table/terminal views.
- Runtime skill discovery from filesystem skill roots during profile resolution.
- Pre-release CLI/daemon cleanup: bounded transcript rotation, `rig send`
message envelopes, OpenRig install/init marker naming, and public-release
terminology cleanup.
## User-Facing Changes
### Redesigned Shell
The UI now uses the V1 shell model with a narrow rail, destination-specific
Explorer region, center workspace, and named-trigger drawer. Topology pages have
graph, table, and terminal modes at one stable URL. Drawer viewers handle queue
items, files, sub-spec previews, terminal previews, and proof assets without
turning every reference click into a full-page navigation.
### Starter Workflows And Rigs
The release includes generic workflow examples rather than project-specific
automation recipes. `conveyor` demonstrates station-based flow with natural
queue backpressure. `basic-loop` demonstrates a simple one-token-at-a-time loop.
The `conveyor` rig is the public workflow starter. `product-team` remains the
more advanced human-operator-driven starter topology.
### Project And Proof Visibility
Project surfaces now distinguish current and archived work, show mission/slice
hierarchy, route mission labels to mission pages, and expose story/tests/proof
rollups. For You and Queue cards hydrate qitem bodies, proof images, audit
outcomes, and next-action state. Tests proof screenshots open in an in-page
viewer.
### Topology And Runtime Visibility
Topology now has a hybrid multi-rig graph, expand/collapse controls, activity
rings, animated handoff edges, runtime/tool marks, context percentage/token
displays, and a terminal fallback view. Agent detail surfaces now use the
canonical live node details view.
### Skills And Starter Guidance
Bundled skills were curated for starter coherence. Runtime skill discovery now
finds valid `SKILL.md` files in runtime and workspace skill roots, with
rig-local resources taking precedence over broader user libraries.
## Operator / Setup Notes
- Fresh workspace defaults are mission-shaped and derive from
`workspace.root`. Use `rig config init-workspace` to create the default
workspace structure.
- The repo uses npm workspaces and the tracked root `package-lock.json`.
`pnpm-lock.yaml` files are not release artifacts.
- Root-level runtime projection files such as `CLAUDE.md` are intentionally not
tracked in the product repo.
- Deprecated `RIGGED_*` aliases remain only for the original runtime env keys:
`daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, and
`transcripts.path`. New typed config keys use `OPENRIG_*` only.
- Existing local workflow specs can still be installed from workspace/spec
locations; product source only bundles the generic starter workflows.
## Known Limitations
- Plugin support is not part of 0.3.0. Plugin-packaged skills and external
plugin distribution are planned for a later release.
- The public starter workflows are examples, not a complete packaged automation
methodology.
- Some broad CLI tests are sensitive to local daemon port/config state. Focused
release-path tests and build/lint gates were used for the final push gate.
## Verification Performed
- `npm run lint`
- `npm run build`
- `npm run test:repo`
- Focused CLI, daemon, and UI tests for release hygiene, config boundaries,
starter skill discovery, shell/topology behavior, and project observability.
- Source scans for personal paths, internal attribution phrasing, default
fallback assumptions, and unwanted legacy env aliases on new typed keys.
## Commits Included
This release contains the accumulated product work since v0.2.0, including:
- Shell redesign V1 phases and polish slices.
- Project observability phases A-G plus follow-up fixes.
- Graphics package and broadened runtime/tool marks.
- Pre-release UI and CLI/daemon hardening.
- 0.3.0 starter refresh and release hygiene commits.
-336
View File
@@ -1,336 +0,0 @@
# OpenRig v0.3.1
## Summary
OpenRig v0.3.1 builds on v0.3.0 with operator-facing UX work, the headline
Claude auto-compaction policy, a dashboard + For You visual refresh, the
Plugin Primitive v0 surface (`rig plugin`), Library Explorer finishing
(skills + plugins + specs with real folder browsing), Settings Destination
Explorer, action outcome + inline error UX, and a comprehensive privacy
hygiene pass.
No schema-breaking migrations. Existing v0.3.0 databases upgrade on first
`rig daemon start` (adds migration `040_workflow_specs_diagnostic` — two
new diagnostic columns on `workflow_specs` cache, `ALTER TABLE ADD COLUMN`
with safe defaults).
## Included In This Release
- **Claude Auto-Compaction Policy** — operator-configurable session
auto-`/compact` with safe defaults (opt-in default-off; no behavior
change unless explicitly enabled). 7 new ConfigStore keys; 6-stage
daemon-to-LLM lifecycle (pre-prep prompt → compact → handshake →
restore → compliance audit → cooldown); PreCompact hook +
SessionStart/UserPromptSubmit bridge.
- **Plugin Primitive v0** — `rig plugin` command surface (read-only at
v0: `list`, `show`, `used-by`, `validate`). Plugin discovery from
`$OPENRIG_HOME/plugins/`, vendored-and-provenance pattern. No
`install` verb at v0; deferred to 0.3.2.
- **Library Explorer Finishing** — skills + plugins + specs as a
fully operator-facing surface with real folder browsing. New
daemon-owned library discovery API (`/api/skills/library`,
`/api/skills/:id/files/...`, `/api/plugins/:id/files/...`).
- **Settings Destination Explorer** — settings became a 4-item
Explorer destination matching Topology / Project / Library / For-You
pattern, with `/settings`, `/settings/policies`, `/settings/log`,
`/settings/status` sub-routes.
- **Dashboard And For You Visual Refresh** — both routes adopt a
shared "vellum" surface language (translucent stone-tinted cards,
paper-grid background, ambient shadow, mono+dot kind indicators,
L-shaped corner-bracket registration marks). Single source of truth
between production and `/lab/vellum-lab`.
- **For You Storytelling Adapter** — Progress / Shipped / Incident /
Approval card kinds wired to real data (missions, slices, activity
feed, queue items). ApprovalCard surfaces real queue items waiting
on approval.
- **Action Outcome + Inline Error Surface** — queue-item verb actions
(Approve/Deny/Route/Hold/Drop/Annotate/Handoff) render outcomes
immediately on success and surface failures inline without
silently reverting. Closes a demo-blocking silent-revert bug.
- **Mobile Drawer Behavior** — 375px Explorer drawer now layers above
the rail tray for Settings / Project / Library / For-You. Topology
mobile drawer is intentionally hidden in 0.3.1; scheduled for full
restoration in 0.3.2.
- **Vendored Skill Provenance** — vendored skills declare upstream
lineage via `metadata.openrig` frontmatter and (when modified) a
companion `OPENRIG.md` sidecar. Applied to all 10 process-skill
surfaces.
- **Workflow specs diagnostic surface** — migration `040` adds
`status` + `error_message` columns to the `workflow_specs` cache so
the Library UI can render parse/validation errors as diagnostic
rows instead of hiding them.
- **Privacy hygiene pass** — three filter-repo passes scrubbed
personal data, internal references, and historic versions from
history; commit subjects and bodies polished for public surface.
Backup repos preserved for rollback.
## User-Facing Changes
### Claude Auto-Compaction Policy
Operator-configurable auto-`/compact` for Claude sessions, with safe
defaults:
- **Opt-in default-off**: nothing changes unless you explicitly enable
the policy. With no policy configured, the daemon never sends an
auto-`/compact`.
- **7 new ConfigStore keys** in the `policies.claude_compaction.*`
namespace: `enabled`, `threshold_percent` (strict integer 1-100),
`compact_instruction` (operator-supplied; appended to `/compact`
args when set), `message_inline`, `message_file_path`,
`pre_compact_instruction`, `post_restore_audit_instruction`. Strict
validation across all source layers (`set`/`POST`/env/file). Invalid
env values fall back to defaults with a stderr warning rather than
silently coercing.
- **5 operator-editable prompt surfaces** in Settings → Policies UI:
pre-compaction prep, compact instruction, post-compaction restore
(inline), restore file path, post-restore audit. Daemon-owned
wrappers (usage/threshold framing, trust-channel preservation,
marker paths, read-depth enforcement, turn-boundary handshake,
dedup + cooldown) are non-editable.
- **6-stage lifecycle**: pre-compact prep prompt (save-game-before-quit
mental-model restore map) → `/compact` with operator instructions
→ post-compact turn-boundary handshake → restore prompt with
defense-in-depth fallback chain → compliance audit (forces
FULL/PARTIAL/NOT_READ table) → 10-minute cooldown.
- **Safety hardening**: typed prompt drafts classified as attention
state so auto-`/compact` retries later instead of overwriting human
input. Send-failure does not advance dedup state. Re-arm requires
threshold-crossing.
### New `rig plugin` Command (read-only at v0)
```
rig plugin list # Discoverable plugins across vendored + runtime caches
rig plugin show <id> # Plugin manifest + skills + hooks + mcp servers
rig plugin used-by <id> # Agents referencing this plugin
rig plugin validate <path> # Validate plugin manifest + skill frontmatter
```
No `install` verb at v0 — installation is explicit operator copy or
symlink to `$OPENRIG_HOME/plugins/<plugin-id>/`. See each plugin's
`OPENRIG-INSTALL.md` for the documented copy/symlink workflow. A
`rig plugin install <substrate-path>` verb is deferred to 0.3.2.
### Library Explorer Finishing
The Library destination (skills + plugins + specs) is now fully
operator-facing:
- No duplicate top-level entries (`> SKILLS` / `> PLUGINS` rows removed
from `SpecsTreeView`; bottom-row clicks navigate + expand the tree).
- Plugins above Skills in the tree.
- 32 OpenRig-managed shared skills are now discoverable in production
VM environments (previously empty due to workspace-relative path
lookup).
- Daemon-owned library discovery API: `GET /api/skills/library`,
`GET /api/skills/:id/files/list?path=...` + `/files/read?path=...`,
parallel routes under `/api/plugins/:id/`.
- Real file-browser + docs-browser on plugin and skill detail pages.
Markdown auto-renders; non-markdown files render as text. Folder
navigation works.
- Rolled-up index pages at `/specs/skills` and `/specs/plugins` with
flat-row listings, source labels, file counts, and entry links.
### Settings Destination Explorer
Settings is now a top-level Explorer destination matching the Topology /
Project / Library / For-You pattern:
- `/settings` (general config keys form)
- `/settings/policies` (Claude auto-compaction policy form lives here)
- `/settings/log`
- `/settings/status`
Old top-row tab navigation removed. Shared `SettingsPageShell` chrome
across all 4 sub-routes.
### Dashboard And For You Visual Refresh
A coordinated visual refresh to a shared "vellum" surface language:
- **Dashboard `/`** — full rewrite over new `dashboard/vellum/`
primitives. Hero typography ("WELCOME BACK") at display-lg + headline-
bold. Tactical instrument-panel stats line with tabular numerals.
Six destination cards (Topology / Project / For You / Library /
Search / Settings) share the numeral-layout treatment. Real-data
hooks: `useRigSummary`, `usePsEntries`, `useSpecLibrary`.
- **For You `/for-you`** — both card systems unified to the vellum
recipe: storytelling band (`CardShell` rewritten with bg-stone-100/45
+ backdrop-blur + ambient shadow + corner brackets) and queue-item
`FeedCard.tsx` (same outer chrome; vellum bordered-no-fill action
buttons with hover-invert and 44px touch targets).
- **Single source of truth** between `/dashboard` and
`/lab/vellum-lab` via `packages/ui/src/components/dashboard/vellum/`.
- **Lab routes** — `/lab/card-previews`, `/lab/vellum-lab`,
`/lab/vellum-bg/{a-large,b-small,c-allover}` are checked-in
experiment surfaces for designer iteration. Reachable in production
by direct URL; not linked from main navigation.
### For You Storytelling Adapter
The storytelling band at top of `/for-you` now wires four card kinds to
real data:
- **Progress** — first 2 active missions (`useMissionDiscovery`)
- **Shipped** — slices with status = shipped/complete/done (capped at 3;
from `useSlices`)
- **Incident** — slices with status = blocked/failed/danger (capped at 3)
- **Approval** — queue items waiting on approval, from
`useActivityFeed` + `classifyFeed` (capped at 2)
`ConceptCard` component is preserved in source but not emitted by the
production adapter; a deliberate data-source decision is scheduled for
0.3.2.
### Action Outcome And Inline Error Surface
Queue-item verb actions (Approve/Deny/Route/Hold/Drop/Annotate/Handoff):
- On success, `ActionOutcomePanel` renders immediately ("Approved by X",
"Routed by X to Y") — no waiting for audit-log roundtrip.
- On error, a tertiary-bordered error block renders below the verb
buttons with the daemon's error message; verb-selection state is
preserved so the operator can correct and retry.
- Replaces the prior silent-revert UX. Regression test guards the class
explicitly.
## Operator / Setup Notes
After upgrading the published CLI:
```bash
npm install -g @openrig/[email protected]
rig --version # expect 0.3.1
rig daemon stop
rig daemon start
```
Migration `040_workflow_specs_diagnostic` applies automatically on first
post-upgrade daemon start. It is a non-destructive `ALTER TABLE ADD
COLUMN` with safe defaults (existing rows retroactively get
`status: 'valid'`). No manual action required.
For Claude auto-compaction policy: opt-in default-off. Visit
`/settings/policies` to configure. Recommended for sessions where
context exhaustion is a recurring operational concern. Read the docs
section in the Settings UI before enabling.
For the new `rig plugin` surface: no behavior change unless you install
plugins. Bundled `openrig-core` plugin ships with the daemon (11 skills).
Operators can install `gstack` (45 skills) or `obra-superpowers`
(14 skills) by following each plugin's `OPENRIG-INSTALL.md` workflow.
## Known Limitations
- **`rig down <name>` still returns HTTP 404** — name-based teardown
still doesn't resolve through the daemon down route at v0.3.1. Use
`rig down <rigId> --delete` instead. Carries forward as known issue
to v0.3.2.
- **`rig queue list --json --limit` and `rig view list/show --json`
flag inconsistency** — flags scaffolded but not fully wired. Use
human-readable output for now. Carries forward to v0.3.2.
- **Topology mobile drawer hidden** at 375px viewports in 0.3.1
(intentional; clicking the hamburger triggered a pre-existing
`TopologyTableView` renderer cascade). Full restoration scheduled
for 0.3.2 via a dedicated render-path slice.
- **`ConceptCard` component preserved in source** but not emitted by
the production For You adapter. A deliberate data-source decision is
scheduled for 0.3.2.
- **`/lab/*` routes ship to production builds** intentionally per
founder direction; 0.3.2 adds an index page + per-lab banner +
manifest pattern.
- **Plugin install ergonomics** — v0 plugin install is operator-driven
copy or symlink; first-class `rig plugin install <substrate-path>`
is deferred to 0.3.2.
- **Lifecycle classifications** remain truthful but still rely on
heuristics rather than perfect provider-native primitives in some
recovery paths.
## Verification Performed
- `npm run lint` clean.
- `npm run build` clean across all workspaces.
- `npm run test:repo` green.
- Daemon test suite: 3600 pass, 1 pre-existing host-env-sensitive
failure on `restore-check-routes` reading malformed local Claude
settings (banked as environment-dependent class).
- UI test suite: 1106 pass / 0 fail (post-test-fixes merge; was 1093
pass + 3 failing on stale-test pre-fix).
- TypeScript checks clean on all touched files.
- Code review (review-lead, advisory-clean) on every merge in the
release window.
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
items, 8 feed events, 4 verified `/for-you` action scenarios
including the inline error path).
## Commits Included (highlights)
The full v0.3.0 → v0.3.1 delta is 224 commits / 376 files / +37,391
insertions / −3,153 deletions. The list below groups load-bearing
commits by theme:
### Claude Auto-Compaction Policy
- `feat(daemon,cli)`: Claude auto-compaction policy scaffolding +
ConfigStore keys
- `feat(daemon)`: 6-stage compaction lifecycle (pre-prep → compact →
handshake → restore → compliance → cooldown)
- `feat(daemon)`: PreCompact hook + SessionStart/UserPromptSubmit
bridge for marker restoration
- `feat(daemon)`: SessionTransport draft-attention classification +
send-failure dedup discipline
### Plugin Primitive v0
- `feat(daemon,cli)`: `rig plugin` command + plugin discovery surface
- `feat(daemon)`: plugin manifest + skill provenance metadata schema
- `chore(skills)`: vendoring metadata + `OPENRIG.md` sidecars for 10
process-skill surfaces
### Library Explorer
- `feat(daemon)`: library discovery API
(`/api/skills/library`, `/api/{skills,plugins}/:id/files/list+read`)
- `feat(ui)`: SpecsTreeView dual-action + plugin/skill detail file
browsers
- `feat(ui)`: rolled-up `/specs/skills` and `/specs/plugins` index
pages
### Settings Destination Explorer
- `feat(ui)`: 4-route Settings Explorer destination
(`/settings`/`policies`/`log`/`status`) with shared `SettingsPageShell`
### Dashboard + For You Visual Refresh
- `redesign(dashboard)`: full rewrite over `dashboard/vellum/`
primitives + 6 destination cards
- `redesign(for-you)`: vellum-coherent storytelling cards + queue-item
FeedCard chrome unification
- `refactor(dashboard)`: extract vellum primitives — single source of
truth between `/dashboard` and `/lab/vellum-lab`
- `simplify(dashboard)`: drop back layer + back vellum sheet; add
ambient shadow
- `merge(release)`: storytelling adapter extension — approval cards
wired to real data
- `feat(for-you)`: wire ApprovalCard to real data; remove ConceptCard
from production switch
- `fix(for-you)`: optimistic action outcome + inline error surface in
VerbActions
### Migration 040 (workflow_specs diagnostic)
- `feat(daemon)`: migration 040 adds `status` + `error_message`
diagnostic columns to `workflow_specs` cache
### Final test alignment
- `test(ui)`: fix 3 pre-existing failures — match deliberate design
changes
- `docs(changelog)`: cover 0.3.1 dashboard + for-you visual refresh +
action UX
### Privacy hygiene
- Three filter-repo passes (LOCAL-INSIGHTS personal-data scrub +
commit-body internal-doc reference scrub + file-content historic
versions scrub)
- Commit subject rewrites to remove privacy-scream patterns
- Force-pushed at commit `7e55d9d3` as the privacy-cleaned baseline
See `CHANGELOG.md` for the full agent-readable changelog with quick
verification commands and operator notes.
+7 -42
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.3.1",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.3.1",
"version": "0.2.0",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -2296,26 +2296,6 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.168.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.3.tgz",
@@ -2348,19 +2328,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -7419,7 +7386,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.3.1",
"version": "0.2.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7445,12 +7412,12 @@
"vitest": "^3.0.0"
},
"engines": {
"node": ">=20"
"node": "^20 || ^22 || ^24"
}
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.3.1",
"version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7474,7 +7441,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.3.1",
"version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
@@ -7487,7 +7454,6 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.3",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.10.1",
"autoprefixer": "^10.4.27",
"class-variance-authority": "^0.7.1",
@@ -7498,8 +7464,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.19",
"yaml": "^2.8.3"
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
+3 -5
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.3.1",
"version": "0.2.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -12,12 +12,10 @@
"scripts": {
"build": "npm run build --workspaces",
"test": "npm run test:repo && npm run test:workspaces",
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check",
"test: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",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check"
"build:package": "bash scripts/build-package.sh"
},
"engines": {
"node": "^20 || ^22 || ^24"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.3.1",
"version": "0.2.0",
"type": "module",
"description": "Local control plane for multi-agent coding topologies",
"keywords": [
@@ -35,7 +35,7 @@
"scripts"
],
"scripts": {
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/",
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
"postinstall": "node scripts/check-abi.mjs",
"prepublishOnly": "bash ../../scripts/build-package.sh",
"test": "vitest run",
@@ -61,6 +61,6 @@
"vitest": "^3.0.0"
},
"engines": {
"node": ">=20"
"node": "^20 || ^22 || ^24"
}
}
-401
View File
@@ -1,401 +0,0 @@
// Fork Primitive + Starter Agent Images v0 (PL-016) — `rig agent-image`
// CLI verb family. Parallel to `rig context-pack` shipped in PL-014.
//
// Subcommands:
// create <source-session> --name <name> — capture image from a live seat
// list — list all images
// show <name-or-id> — manifest + stats
// preview <name-or-id> — assembled preview + starter snippet
// delete <name-or-id> [--force] — delete (subject to evidence guard)
// pin <name-or-id> — pin from prune
// unpin <name-or-id> — unpin
// prune [--force] [--dry-run] [--json] — bulk delete evictable images
// sync — re-walk discovery roots
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface AgentImageEntryWire {
id: string;
kind: "agent-image";
name: string;
version: string;
runtime: "claude-code" | "codex";
sourceSeat: string;
sourceSessionId: string;
notes: string | null;
createdAt: string;
sourceType: "user_file" | "workspace" | "builtin";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: Array<{
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}>;
sourceResumeToken: string;
stats: {
forkCount: number;
lastUsedAt: string | null;
estimatedSizeBytes: number;
lineage: string[];
};
lineage: string[];
pinned: boolean;
}
interface PreviewWire extends Omit<AgentImageEntryWire, "sourcePath" | "relativePath" | "updatedAt" | "kind" | "sourceResumeToken" | "sourceType"> {
starterSnippet: string;
}
interface PruneWire {
dryRun: boolean;
forced?: boolean;
protected?: Array<{
imageId: string;
imageName: string;
imageVersion: string;
reasons: string[];
references: string[];
}>;
evictable?: Array<{ imageId: string; imageName: string; imageVersion: string }>;
deleted?: string[];
errors?: Array<{ imageId: string; error: string }>;
}
async function resolveImage(client: DaemonClient, nameOrId: string): Promise<AgentImageEntryWire> {
if (nameOrId.startsWith("agent-image:")) {
const res = await client.get<AgentImageEntryWire>(`/api/agent-images/library/${encodeURIComponent(nameOrId)}`);
if (res.status === 200) return res.data;
if (res.status === 404) throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library/${nameOrId}`);
}
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library`);
const matches = (res.data ?? []).filter((e) => e.name === nameOrId);
if (matches.length === 0) {
throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
}
if (matches.length > 1) {
const versions = matches.map((m) => m.version).join(", ");
throw new Error(`Agent image '${nameOrId}' is ambiguous (versions: ${versions}). Use 'agent-image:${nameOrId}:<version>'.`);
}
return matches[0]!;
}
export function agentImageCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("agent-image")
.description("Browse, snapshot, and manage agent images (PL-016)")
.addHelpText("after", `
Examples:
rig agent-image list
rig agent-image show driver-release-primed
rig agent-image create velocity-driver@openrig-velocity --name driver-release-primed --notes "after review"
rig agent-image preview driver-release-primed
rig agent-image pin driver-release-primed
rig agent-image prune --dry-run
rig agent-image delete driver-release-primed --force
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
cmd.command("list")
.description("List all agent images in the library")
.option("--runtime <runtime>", "Filter by runtime (claude-code | codex)")
.option("--json", "JSON output")
.action(async (opts: { runtime?: string; json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
let entries = res.data ?? [];
if (opts.runtime) entries = entries.filter((e) => e.runtime === opts.runtime);
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No agent images. Capture one with: rig agent-image create <source-session> --name <name>");
return;
}
for (const e of entries) {
const pinned = e.pinned ? " 📌" : "";
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${e.runtime.padEnd(12)} forks: ${String(e.stats.forkCount).padStart(3)} ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType}${pinned}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("show")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Show image manifest + statistics")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Runtime: ${entry.runtime}`);
console.log(`Source: ${entry.sourceSeat}`);
console.log(`Created: ${entry.createdAt}`);
console.log(`Path: ${entry.sourcePath}`);
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
console.log(`Pinned: ${entry.pinned}`);
console.log("");
console.log(`Stats:`);
console.log(` fork count: ${entry.stats.forkCount}`);
console.log(` last used: ${entry.stats.lastUsedAt ?? "never"}`);
console.log(` estimated size: ${entry.stats.estimatedSizeBytes} bytes`);
console.log(` lineage: ${entry.lineage.length === 0 ? "(none)" : entry.lineage.join(" → ")}`);
if (entry.notes) {
console.log("");
console.log("Notes:");
console.log(` ${entry.notes.replaceAll("\n", "\n ")}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("preview")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Show manifest + sized supplementary file metadata + starter snippet")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.get<PreviewWire>(`/api/agent-images/library/${encodeURIComponent(entry.id)}/preview`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const preview = res.data;
if (opts.json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
console.log(`# Preview: ${preview.name} v${preview.version} (${preview.runtime})`);
console.log(`# Source seat: ${preview.sourceSeat}`);
console.log(`# Stats: fork=${preview.stats.forkCount}, last-used=${preview.stats.lastUsedAt ?? "never"}, size=${preview.stats.estimatedSizeBytes}B`);
if (preview.lineage.length > 0) console.log(`# Lineage: ${preview.lineage.join(" → ")}`);
console.log("");
console.log("# Starter snippet (paste into agent.yaml):");
console.log(preview.starterSnippet);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("create")
.argument("<source-session>", "Source session canonical name (e.g., velocity-driver@openrig-velocity)")
.description("Capture a productive seat's resumable state into a new agent image")
.requiredOption("--name <name>", "Image name (used as the directory name and library id)")
// Use --image-version instead of --version because Commander.js
// intercepts the global --version flag (prints CLI version + exits
// 0 silently). Per-command name resolves the collision without
// losing the global --version surface.
.option("--image-version <version>", "Image version (default: 1)")
.option("--notes <text>", "Operator-supplied notes preserved in manifest")
.option("--estimated-tokens <n>", "Operator-supplied token estimate")
.option("--lineage <names...>", "Comma-separated parent image names if forking from another image")
.option("--json", "JSON output")
.action(async (sourceSession: string, opts: {
name: string;
imageVersion?: string;
notes?: string;
estimatedTokens?: string;
lineage?: string[];
json?: boolean;
}) => {
try {
const client = await getClient();
const body: Record<string, unknown> = {
sourceSession,
name: opts.name,
};
if (opts.imageVersion) body["version"] = opts.imageVersion;
if (opts.notes) body["notes"] = opts.notes;
if (opts.estimatedTokens) {
const n = Number(opts.estimatedTokens);
if (Number.isFinite(n)) body["estimatedTokens"] = n;
}
if (opts.lineage && opts.lineage.length > 0) body["lineage"] = opts.lineage;
const res = await client.post<{ imageId: string; imagePath: string; manifest: { name: string; version: string; runtime: string } }>(
"/api/agent-images/snapshot",
body,
);
if (res.status !== 200) {
const data = res.data as Partial<{ error: string; message: string; details: unknown }>;
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
}
const r = res.data;
if (opts.json) {
console.log(JSON.stringify(r, null, 2));
return;
}
console.log(`Captured ${r.manifest.name} v${r.manifest.version} (${r.manifest.runtime}) at ${r.imagePath}`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("delete")
.argument("<name-or-id>", "Image name or library id")
.option("--force", "Override evidence-preservation guard (CATASTROPHIC if active references exist)")
.option("--json", "JSON output")
.description("Delete an agent image (subject to evidence-preservation guard)")
.action(async (nameOrId: string, opts: { force?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const url = `/api/agent-images/library/${encodeURIComponent(entry.id)}${opts.force ? "?force=true" : ""}`;
const res = await client.delete<{ ok: boolean; forced: boolean; error?: string; message?: string; reasons?: string[] }>(url);
if (res.status !== 200) {
const data = res.data as Partial<{ error: string; message: string; reasons: string[] }>;
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
}
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
console.log(`Deleted ${entry.id}${opts.force ? " (forced)" : ""}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("pin")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Pin an image so prune cannot delete it")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/pin`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Pinned ${entry.id}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("unpin")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Unpin an image")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/unpin`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Unpinned ${entry.id}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("prune")
.option("--dry-run", "Preview without deleting (default if not --force)")
.option("--force", "Override evidence-preservation guard (CATASTROPHIC: deletes referenced images)")
.option("--json", "JSON output")
.description("Delete evictable images (protected by evidence-preservation guard)")
.action(async (opts: { dryRun?: boolean; force?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const dryRun = opts.dryRun !== false && !opts.force;
const res = await client.post<PruneWire>("/api/agent-images/prune", {
dryRun,
force: !!opts.force,
});
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
const r = res.data;
if (r.dryRun) {
const protectedList = r.protected ?? [];
const evictable = r.evictable ?? [];
console.log(`(dry-run) ${protectedList.length} protected, ${evictable.length} evictable`);
if (protectedList.length > 0) {
console.log("");
console.log("Protected:");
for (const p of protectedList) {
console.log(` ${p.imageName} v${p.imageVersion}: ${p.reasons.join(", ")}`);
for (const ref of p.references) console.log(` ↪ ${ref}`);
}
}
if (evictable.length > 0) {
console.log("");
console.log("Evictable:");
for (const e of evictable) console.log(` ${e.imageName} v${e.imageVersion}`);
}
} else {
console.log(`Deleted ${(r.deleted ?? []).length} image(s).${r.forced ? " (forced)" : ""}`);
for (const err of r.errors ?? []) console.log(` error: ${err.imageId}: ${err.error}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("sync")
.option("--json", "JSON output")
.description("Re-walk discovery roots and refresh the library index")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: AgentImageEntryWire[] }>(
"/api/agent-images/library/sync",
);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
console.log(`Indexed ${res.data.count} agent image(s).`);
for (const e of res.data.errors) console.log(` error: ${e.source}: ${e.error}`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
@@ -1,613 +0,0 @@
// User Settings v0 — `rig config init-workspace` workspace scaffolding.
//
// Creates the mission-aware default workspace at ~/.openrig/workspace/ (or a
// caller-supplied --root). The same scaffold is also used idempotently by
// daemon startup so a fresh install has a browsable Project workspace before
// the user discovers the explicit command.
//
// Behavior:
// - Reads workspace.root setting (default ~/.openrig/workspace/).
// - Creates canonical subdirs: missions/ progress/ field-notes/ specs/ dogfood-evidence/.
// - Seeds two example missions with multiple slices.
// - Drops workspace README.md + STEERING.md.
// - --dry-run: report what would be created without acting.
// - --force: overwrite existing files (NOT directories — never deletes
// operator content).
// - Idempotent without --force: existing-dir + existing-readme is a no-op.
import { Command } from "commander";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ConfigStore } from "../config-store.js";
export interface InitWorkspaceOpts {
root?: string;
force?: boolean;
dryRun?: boolean;
json?: boolean;
}
export interface InitWorkspaceResult {
root: string;
/** True when this run would (or did) create the root dir; false if root existed. */
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
/** True when --dry-run; nothing was actually written. */
dryRun: boolean;
}
type DefaultSlice = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
};
type DefaultMission = {
id: string;
title: string;
status: "active" | "draft";
objective: string;
slices: DefaultSlice[];
};
const DEFAULT_MISSIONS: DefaultMission[] = [
{
id: "getting-started",
title: "Getting Started",
status: "active",
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
slices: [
{
id: "first-conveyor-run",
title: "First Conveyor Run",
status: "active",
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
},
{
id: "inspect-project-evidence",
title: "Inspect Project Evidence",
status: "draft",
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
},
],
},
];
const WORKSPACE_DIRS = [
"missions",
"artifacts",
"evidence",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
...DEFAULT_MISSIONS.flatMap((mission) => [
`missions/${mission.id}`,
`missions/${mission.id}/slices`,
...mission.slices.map((slice) => `missions/${mission.id}/slices/${slice.id}`),
]),
] as const;
function subdirReadmeContent(subdir: string): string {
switch (subdir) {
case "missions":
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
case "artifacts":
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
case "evidence":
return "# evidence\n\nHuman-readable verification notes live here. Use this for compact proof summaries that are not tied to a screenshot or video packet.\n";
case "progress":
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory recursively for PROGRESS.md files and renders them as a hierarchical tree.\n";
case "field-notes":
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work. OpenRig surfaces these alongside missions and slices for context.\n";
case "specs":
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context packs / skills). OpenRig's Library browses this directory alongside bundled specs.\n";
case "dogfood-evidence":
return "# dogfood-evidence\n\nProof packets live here. Each proof packet folder is matched to a Project slice by folder-name tokens and may contain markdown, screenshots, videos, traces, and other verification artifacts.\n";
default:
return `# ${subdir}\n`;
}
}
const WORKSPACE_README = `# OpenRig Workspace
This workspace is file-backed. The Project UI mirrors this structure:
- \`missions/<mission-id>\` becomes a Project mission.
- \`missions/<mission-id>/slices/<slice-id>\` becomes a Project slice.
- Queue items should mention or tag the mission id and slice id so Project can attach live work to the right slice.
- \`artifacts/\` is for work products that a slice needs to keep.
- \`evidence/\` is for proof notes and verification summaries.
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
`;
const STEERING_PLACEHOLDER = `---
title: Priority Stack
status: placeholder
---
# OpenRig Priority Stack
This file is a placeholder created by \`rig config init-workspace\`. Edit it
to record your top priorities. OpenRig's Steering surface reads this file
alongside your Project workspace.
## Top 3
1. Run the \`conveyor\` starter rig.
2. Move one packet through \`basic-loop\` or \`conveyor\`.
3. Inspect the Project, Queue, Story, and Tests surfaces.
## In Motion
(Active slices land here as you push them through the priority rail.)
## Loop State
(Health gates + loop diagnostics land here.)
`;
function missionReadme(mission: DefaultMission): string {
return `---
title: ${mission.title}
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title}
${mission.objective}
## Slices
${mission.slices.map((slice) => `- [${slice.title}](slices/${slice.id}/README.md)`).join("\n")}
`;
}
function missionProgress(mission: DefaultMission): string {
return `---
title: ${mission.title} Progress
status: ${mission.status}
mission: ${mission.id}
---
# ${mission.title} Progress
- [ ] Keep mission README current.
- [ ] Keep active slices queue-backed with mission and slice ids.
`;
}
// V0.3.1 slice 21 onboarding-conveyor — narrative content for the
// getting-started mission's two slices. PARITY CONTRACT with
// `packages/daemon/src/domain/workspace/getting-started-narrative.ts`:
// the daemon side is the canonical source; this CLI-side mirror MUST
// stay byte-identical. The narrative-parity test in
// `packages/daemon/test/getting-started-narrative-parity.test.ts`
// (added in this slice) asserts byte-identity by reading both this
// file and the daemon module — if either drifts, the test fails.
// If you update content here, update the daemon mirror in lockstep.
const FIRST_CONVEYOR_RUN_README = `# First Conveyor Run
Welcome to OpenRig. This is the first slice in your getting-started mission. It exists to teach you what a **conveyor run** is + how OpenRig moves work through a multi-agent topology.
## What a conveyor run is
A **conveyor** is OpenRig's central work-movement primitive. One conveyor run = one complete flow of work through your topology:
1. **Slice declared** — operator (you) or an agent declares a slice with intent + acceptance criteria
2. **Work routed** — orchestrator picks the slice up + routes it to the right agent
3. **Agents collaborate** — work moves agent-to-agent via durable handoffs (queue items)
4. **Evidence accumulates** — each step leaves a trail: commits, files, proof packets, screenshots
5. **Slice closes** — acceptance criteria met; orchestrator routes the result back
You're looking at a mocked conveyor run RIGHT NOW. The other tabs (Story / Progress / Artifacts / Tests / Queue / Topology) each show a different slice of this same run.
## Walking the tabs
- **Overview** (you're here) — read what a slice intends + how to use the rest of the tabs
- **Story** — narrative timeline of what happened, beat by beat
- **Progress** — acceptance checklist; what's done, what's pending
- **Artifacts** — files produced, commits, proof packets — the durable evidence trail
- **Tests** — pass/fail summaries + screenshots + verification proof
- **Queue** — operational qitems showing how work moved between agents
- **Topology** — the rig graph; which agents touched this slice + which edges fired
## Click through
Try them in order. Each tab teaches itself.
`;
const FIRST_CONVEYOR_RUN_TIMELINE = `# Story — First Conveyor Run
## 2026-04-15 09:00 — Slice declared
Operator request: "Build a CLI tool that lints todo lists." Slice declared in \`getting-started/slices/first-conveyor-run/\`. Acceptance: \`tdl lint <file>\` exits non-zero on malformed entries.
## 09:02 — Orchestrator routes
\`orch-lead@getting-started\` picks up the slice. Routes to \`driver@getting-started\` via \`rig queue handoff\`. Driver receives nudge; opens IMPL-PRD; reads acceptance.
## 09:05 — Driver picks up
Driver claims qitem. Reads slice scope. Surveys existing tdl repo (this is a worked example — imagine the repo is real). Plans: parse YAML; validate entries; exit code per finding.
## 09:30 — First commit
\`feat(lint): parse + validate todo entries\` — driver commits 4 files. Stream event emitted; visible in Artifacts tab.
## 10:15 — Driver hands to reviewer
Acceptance met locally. Driver hands off to \`reviewer@getting-started\`. Reviewer opens diff; reads tests.
## 10:25 — Reviewer flags concern
"Edge case: what about UTF-8 entries with combining characters?" Hands back to driver as \`concerning\`.
## 10:40 — Driver addresses
Driver fixes UTF-8 handling. Re-runs tests; all pass. Hands back.
## 11:00 — Reviewer accepts
\`accept\` decision; closes qitem with \`closure-reason: handed_off_to orch-lead\`. Conveyor proceeds.
## 11:05 — Slice ships
Orchestrator merges. Proof packet generated. Slice marked SHIPPED.
---
**What you just read**: one conveyor run from declaration to ship. ~2 hours wall-clock; 3 agents involved; 1 handoff with concern + remediation; 5 stream events; 1 proof packet.
The Story tab shows this kind of narrative for any real slice. Driver-authored \`timeline.md\` files live in each slice folder; this is yours to read + your operator's to update as work moves.
`;
const FIRST_CONVEYOR_RUN_PROGRESS = `---
title: First Conveyor Run Progress
status: active
mission: getting-started
rail-item: getting-started
slice: first-conveyor-run
---
# Progress — First Conveyor Run
## Acceptance criteria
- [x] Parse YAML todo entries
- [x] Validate per-entry shape (id, title, status, due-date)
- [x] Exit non-zero on malformed entries
- [x] Handle UTF-8 entries with combining characters
- [x] Tests pass (12/12)
- [x] Reviewer accepts
- [x] Slice merged
## Status: SHIPPED
This was a mocked conveyor run — no real code was produced. But the Progress tab works the same way for real work: as acceptance criteria are met, boxes get checked; the operator can scan at a glance whether the slice is on track.
## How the Progress tab works
The tab renders the slice's \`PROGRESS.md\` markdown file. Driver updates it as work progresses. Operator scans it during walks. Founder reviews at slice closure.
Try writing one for a real slice you start. It's just markdown; live updates appear here.
`;
const INSPECT_PROJECT_EVIDENCE_README = `# Inspect Project Evidence
The previous slice (First Conveyor Run) showed you what a conveyor run looks like as it happens. This slice teaches you how to inspect the evidence after a run completes.
## Why inspect evidence?
In OpenRig, agents work autonomously. The operator (you) doesn't watch every keystroke. Instead, the operator:
1. Declares slices with clear acceptance
2. Routes them to agents
3. Inspects evidence when slices close
The Artifacts + Tests + Queue + Topology tabs are your inspection surface. They show you what happened, with enough detail to:
- Verify the acceptance was actually met
- Spot subtle issues a passing test might miss
- Build trust in the agents over time
## Walking the tabs
- **Overview** — you're here; learn what to look for
- **Story** — read the narrative of what happened (high-level)
- **Progress** — acceptance status at a glance
- **Artifacts** — the durable evidence: files, commits, proof packets. **THIS IS THE TAB YOU LEARN MOST FROM.**
- **Tests** — pass/fail + screenshots + verification proof
- **Queue** — who handed what to whom (audit trail of decisions)
- **Topology** — which agents touched this; which edges fired
## Key inspection skills
When walking a finished slice:
1. **Read the Story** for context — what was the operator trying to do?
2. **Check Progress** — did acceptance actually pass? Any TODO boxes?
3. **Open Artifacts → Files** — sample-read 2-3 files; does the code match what the Story claimed?
4. **Open Tests → Proof packets** — do screenshots actually show what was claimed?
5. **Read Queue** — were there concerning decisions? Hand-offs to escalation?
6. **Skim Topology** — were unexpected agents involved?
Trust comes from repeated successful inspections, not from a single proof packet.
`;
const INSPECT_PROJECT_EVIDENCE_TIMELINE = `# Story — Inspect Project Evidence
This slice mirrors the previous one (First Conveyor Run). The same agents shipped the same work. But this view is from the inspector's angle, not the runner's.
## 11:10 — Operator opens project
Operator (you, in the real world) opens \`/project/slice/first-conveyor-run\` after the slice ships. First read: the Progress tab. All boxes checked.
## 11:12 — Read Story
Operator reads the Story narrative. Notices the reviewer's UTF-8 concern + driver's remediation. Confidence-building: there was friction, friction was caught, friction was addressed.
## 11:15 — Open Artifacts
Operator clicks Artifacts. Sees 4 commits, 5 files, 1 proof packet, 2 screenshots. Opens commit \`feat(lint): parse + validate todo entries\`. Reads the diff.
## 11:18 — Read tests
Operator opens Tests tab. 12/12 passing. Opens the proof packet — screenshot shows CLI output \`Error: malformed entry at line 3\`. Visually verifies the acceptance was met.
## 11:22 — Skim Queue
Operator scrolls Queue. 4 qitems in chronological order. Sees the \`concerning\` decision + subsequent \`accept\`. Confidence reinforced.
## 11:25 — Mark complete
Operator marks slice acceptance complete. Closes the inspection.
---
**What you just read**: a clean inspection in ~15 minutes. Operator builds trust through the evidence trail. This is the OpenRig usage pattern at scale: declare → route → inspect.
`;
const INSPECT_PROJECT_EVIDENCE_PROGRESS = `---
title: Inspect Project Evidence Progress
status: active
mission: getting-started
rail-item: getting-started
slice: inspect-project-evidence
---
# Progress — Inspect Project Evidence
## Inspection checklist
- [x] Read Story (context: what was being done)
- [x] Check Progress (acceptance status)
- [x] Sample-read Artifacts files
- [x] Verify Tests proof packets visually
- [x] Skim Queue for concerning decisions
- [x] Skim Topology for unexpected agents
## Status: COMPLETE
This was a mocked inspection — a worked example of the inspection workflow.
## How to apply this
For any real slice that ships in YOUR project: open the slice page, walk the tabs in this order, check each box mentally. Over time you'll develop instincts for what passes the smell test vs what needs deeper investigation.
`;
const GETTING_STARTED_NARRATIVE: Record<
string,
{ readme: string; timeline: string; progress: string }
> = {
"first-conveyor-run": {
readme: FIRST_CONVEYOR_RUN_README,
timeline: FIRST_CONVEYOR_RUN_TIMELINE,
progress: FIRST_CONVEYOR_RUN_PROGRESS,
},
"inspect-project-evidence": {
readme: INSPECT_PROJECT_EVIDENCE_README,
timeline: INSPECT_PROJECT_EVIDENCE_TIMELINE,
progress: INSPECT_PROJECT_EVIDENCE_PROGRESS,
},
};
function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
// V0.3.1 slice 21: getting-started slices ship rich narrative.
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
if (narrative) {
return `---
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
${narrative.readme}`;
}
return `---
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title}
${slice.objective}
## Queue Mapping
Queue items for this slice should mention or tag:
- mission: \`${mission.id}\`
- slice: \`${slice.id}\`
This lets Project attach queue activity to the slice story, queue, tests, and topology tabs.
`;
}
function sliceTimeline(mission: DefaultMission, slice: DefaultSlice): string | null {
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
return narrative ? narrative.timeline : null;
}
function sliceProgress(mission: DefaultMission, slice: DefaultSlice): string {
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
if (narrative) return narrative.progress;
return `---
title: ${slice.title} Progress
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Progress
- [ ] Define the next concrete packet.
- [ ] Attach queue work to this slice id.
- [ ] Capture proof or notes before closing.
`;
}
function slicePrd(mission: DefaultMission, slice: DefaultSlice): string {
return `---
title: ${slice.title} Implementation Notes
status: ${slice.status}
mission: ${mission.id}
rail-item: ${mission.id}
slice: ${slice.id}
---
# ${slice.title} Implementation Notes
## Goal
${slice.objective}
## Acceptance
- [ ] The work is visible in the Project slice.
- [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`.
- [ ] Any proof artifacts are referenced from the slice before closure.
`;
}
export function workspaceScaffoldDirs(): string[] {
return [...WORKSPACE_DIRS];
}
export function workspaceScaffoldFiles(): Array<{ relPath: string; content: string }> {
const files: Array<{ relPath: string; content: string }> = [
{ relPath: "README.md", content: WORKSPACE_README },
{ relPath: "STEERING.md", content: STEERING_PLACEHOLDER },
{ relPath: "missions/README.md", content: subdirReadmeContent("missions") },
{ relPath: "artifacts/README.md", content: subdirReadmeContent("artifacts") },
{ relPath: "evidence/README.md", content: subdirReadmeContent("evidence") },
{ relPath: "progress/README.md", content: subdirReadmeContent("progress") },
{ relPath: "field-notes/README.md", content: subdirReadmeContent("field-notes") },
{ relPath: "specs/README.md", content: subdirReadmeContent("specs") },
{ relPath: "dogfood-evidence/README.md", content: subdirReadmeContent("dogfood-evidence") },
];
for (const mission of DEFAULT_MISSIONS) {
files.push(
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
);
for (const slice of mission.slices) {
files.push(
{ relPath: `missions/${mission.id}/slices/${slice.id}/README.md`, content: sliceReadme(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/PROGRESS.md`, content: sliceProgress(mission, slice) },
{ relPath: `missions/${mission.id}/slices/${slice.id}/IMPLEMENTATION-PRD.md`, content: slicePrd(mission, slice) },
);
// V0.3.1 slice 21: getting-started slices ship timeline.md.
const timeline = sliceTimeline(mission, slice);
if (timeline) {
files.push({
relPath: `missions/${mission.id}/slices/${slice.id}/timeline.md`,
content: timeline,
});
}
}
}
return files;
}
export function initWorkspaceCommand(configPath?: string): Command {
const cmd = new Command("init-workspace")
.description("Scaffold the default workspace at ~/.openrig/workspace/ with mission/slice folders")
.option("--root <path>", "Override workspace root (default: workspace.root setting)")
.option("--force", "Overwrite existing scaffolded files (does NOT remove directories)")
.option("--dry-run", "Show what would be created without writing")
.option("--json", "JSON output")
.action((opts: InitWorkspaceOpts) => {
try {
const effectiveJson = opts.json ?? Boolean(cmd.optsWithGlobals().json);
const result = runInitWorkspace({ ...opts, json: effectiveJson, configPath });
if (effectiveJson) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.dryRun) console.log(`(dry-run) workspace root: ${result.root}`);
else console.log(`workspace root: ${result.root}`);
for (const sub of result.subdirs) {
console.log(` ${sub.created ? "+" : " "} ${sub.name}/`);
}
for (const f of result.files) {
console.log(` ${f.created ? "+" : " "} ${f.relPath}${f.skipped ? ` (skipped: ${f.skipped})` : ""}`);
}
if (result.dryRun) console.log("(dry-run; no files written.)");
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string }): InitWorkspaceResult {
const store = new ConfigStore(opts.configPath);
const root = opts.root ?? (store.get("workspace.root") as string);
const dryRun = !!opts.dryRun;
const force = !!opts.force;
const rootExists = existsSync(root);
const result: InitWorkspaceResult = {
root,
rootCreated: !rootExists,
subdirs: [],
files: [],
dryRun,
};
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
for (const sub of workspaceScaffoldDirs()) {
const subPath = join(root, sub);
const subExists = existsSync(subPath);
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
}
for (const file of workspaceScaffoldFiles()) {
const absPath = join(root, file.relPath);
const exists = existsSync(absPath);
if (exists && !force) {
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
} else {
if (!dryRun) writeFileSync(absPath, file.content, "utf-8");
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
}
return result;
}
+19 -86
View File
@@ -1,78 +1,33 @@
import { Command } from "commander";
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
import { initWorkspaceCommand } from "./config-init-workspace.js";
function formatRow(key: string, value: unknown): string {
return `${key.padEnd(28)} ${value}`;
}
function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting> {
return store.resolveAllWithSource();
}
import { ConfigStore } from "../config-store.js";
export function configCommand(configPath?: string): Command {
const cmd = new Command("config").description("Inspect and change OpenRig configuration");
const store = new ConfigStore(configPath);
cmd
.option("--json", "JSON output for agents (resolved RiggedConfig)")
.option("--with-source", "Include source/default per key (honest provenance)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
rig config # show all resolved config
rig config --json # JSON RiggedConfig (structured)
rig config --json --with-source # JSON per-key with source + default
rig config get daemon.port # read a single key
rig config get workspace.slices_root --show-source
rig config set daemon.port 7434 # change a value
rig config set workspace.slices_root /path # configure a workspace path
rig config reset # delete config file, revert all to defaults
rig config reset workspace.slices_root # clear one key, revert to default
rig config init-workspace # scaffold ~/.openrig/workspace/ with missions + slices
Keys:
daemon.* port, host
db.path
transcripts.* enabled, path, lines, poll_interval_seconds
workspace.* root, slices_root, steering_path, field_notes_root,
specs_root, dogfood_evidence_root, operator_seat_name
files.allowlist name:/abs/path,name:/abs/path
progress.scan_roots name:/abs/path,name:/abs/path
ui.preview.* refresh_interval_seconds, max_pins, default_lines
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
agents.* advisor_session, operator_session
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
runtime.codex.* hooks_enabled
policies.claude_compaction.*
enabled, threshold_percent, compact_instruction,
message_inline, message_file_path
rig config # show all resolved config
rig config --json # JSON output
rig config get daemon.port # read a single key
rig config set daemon.port 7434 # change a value
rig config reset # delete config file, revert to defaults
Keys: daemon.port, daemon.host, db.path, transcripts.enabled, transcripts.path
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean; withSource?: boolean }) => {
.action((opts: { json?: boolean }) => {
try {
if (opts.withSource) {
const all = summarizeSettings(store);
if (opts.json) {
console.log(JSON.stringify(all, null, 2));
} else {
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
}
return;
}
// Default: structured RiggedConfig output (preserves pre-v0
// bare-action shape so existing scripts / tests keep working).
const config = store.resolve();
if (opts.json) {
console.log(JSON.stringify(config, null, 2));
} else {
const all = summarizeSettings(store);
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
console.log(`daemon.port ${config.daemon.port}`);
console.log(`daemon.host ${config.daemon.host}`);
console.log(`db.path ${config.db.path}`);
console.log(`transcripts.enabled ${config.transcripts.enabled}`);
console.log(`transcripts.path ${config.transcripts.path}`);
}
} catch (err) {
console.error((err as Error).message);
@@ -82,20 +37,9 @@ Precedence: CLI flag > environment variable > config file > default`)
const getCmd = new Command("get")
.argument("<key>", "Config key (e.g. daemon.port)")
.option("--json", "JSON output with value + source + default")
.option("--show-source", "Print value + source on a single line")
.description("Read a single config value")
.action((key: string, opts: { json?: boolean; showSource?: boolean }) => {
.action((key: string) => {
try {
if (opts.json || cmd.opts<{ json?: boolean }>().json) {
console.log(JSON.stringify(store.resolveWithSource(key), null, 2));
return;
}
if (opts.showSource) {
const r = store.resolveWithSource(key);
console.log(`${r.value}\t(source: ${r.source})`);
return;
}
console.log(String(store.get(key)));
} catch (err) {
console.error((err as Error).message);
@@ -118,26 +62,15 @@ Precedence: CLI flag > environment variable > config file > default`)
});
const resetCmd = new Command("reset")
.argument("[key]", "Optional config key to reset (omit to reset entire file)")
.description("Clear a config override (or delete the entire file when no key given)")
.action((key: string | undefined) => {
try {
store.reset(key);
if (key) {
console.log(`${key} reset to default (${store.get(key)}).`);
} else {
console.log("Config reset to defaults.");
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
.description("Delete config file and revert to defaults")
.action(() => {
store.reset();
console.log("Config reset to defaults.");
});
cmd.addCommand(getCmd);
cmd.addCommand(setCmd);
cmd.addCommand(resetCmd);
cmd.addCommand(initWorkspaceCommand(configPath));
return cmd;
}
-407
View File
@@ -1,407 +0,0 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family.
//
// Six subcommands parallel to `rig specs`:
// list / show / preview / add / sync / send
//
// Each delegates to /api/context-packs/library/* against the daemon.
// The `add` verb installs a pack from a directory at
// ~/.openrig/context-packs/<name>/ — host-symlink-free contract,
// matches `rig specs add` shape (regular files only; no symlinks).
import { Command } from "commander";
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
} from "node:fs";
import { basename, extname, isAbsolute, join } from "node:path";
import { parse as parseYaml } from "yaml";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface ContextPackEntryWire {
id: string;
kind: "context-pack";
name: string;
version: string;
purpose: string | null;
sourceType: "builtin" | "user_file" | "workspace";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: Array<{
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}>;
}
interface PreviewWire {
id: string;
name: string;
version: string;
bundleText: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
}
interface SendWire {
id: string;
name: string;
version: string;
destinationSession: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
dryRun: boolean;
bundleText?: string;
sent?: boolean;
reason?: string;
error?: string;
}
function assertTreeHasNoSymlinks(root: string): void {
const stack = [root];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absPath = join(current, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Context pack directories must not contain symlinks: ${absPath}`);
}
if (entry.isDirectory()) stack.push(absPath);
}
}
}
const ALLOWED_CONTEXT_PACK_SUFFIXES = new Set([".md", ".markdown", ".yaml", ".yml", ".txt"]);
function validateContextPackManifestForInstall(manifestPath: string): void {
let parsed: unknown;
try {
parsed = parseYaml(readFileSync(manifestPath, "utf-8"));
} catch (err) {
throw new Error(`manifest at ${manifestPath} is not valid YAML: ${(err as Error).message}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`manifest at ${manifestPath} must be a YAML object at the root`);
}
const obj = parsed as Record<string, unknown>;
if (typeof obj["name"] !== "string" || obj["name"].length === 0) {
throw new Error(`manifest at ${manifestPath} is missing required field 'name' (string)`);
}
if (obj["version"] === undefined || obj["version"] === null) {
throw new Error(`manifest at ${manifestPath} is missing required field 'version'`);
}
const files = obj["files"];
if (!Array.isArray(files)) {
throw new Error(`manifest at ${manifestPath} must declare 'files: [...]'`);
}
for (let i = 0; i < files.length; i++) {
const entry = files[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`manifest at ${manifestPath} has malformed entry at files[${i}]`);
}
const file = entry as Record<string, unknown>;
const relPath = file["path"];
if (typeof relPath !== "string" || relPath.length === 0) {
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'path' (string)`);
}
if (relPath.includes("..") || isAbsolute(relPath) || relPath.startsWith("\\")) {
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' must be a relative path inside the pack (no '..' segments, no leading '/')`);
}
if (!ALLOWED_CONTEXT_PACK_SUFFIXES.has(extname(relPath))) {
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' has an unsupported suffix; allowed: ${Array.from(ALLOWED_CONTEXT_PACK_SUFFIXES).join(", ")}`);
}
if (typeof file["role"] !== "string" || file["role"].length === 0) {
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'role' (string)`);
}
}
}
async function resolvePack(client: DaemonClient, nameOrId: string): Promise<ContextPackEntryWire> {
// First try as id (context-pack:<name>:<version>); else search by name.
if (nameOrId.startsWith("context-pack:")) {
const res = await client.get<ContextPackEntryWire>(`/api/context-packs/library/${encodeURIComponent(nameOrId)}`);
if (res.status === 200) return res.data;
if (res.status === 404) throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library/${nameOrId}`);
}
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library`);
const entries = res.data ?? [];
const matches = entries.filter((e) => e.name === nameOrId);
if (matches.length === 0) {
throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
}
if (matches.length > 1) {
const versions = matches.map((m) => m.version).join(", ");
throw new Error(`Context pack '${nameOrId}' is ambiguous (versions: ${versions}). Use the full id 'context-pack:${nameOrId}:<version>'.`);
}
return matches[0]!;
}
export function contextPackCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("context-pack")
.description("Browse, preview, send, and install operator-authored context packs")
.addHelpText("after", `
Examples:
rig context-pack list
rig context-pack show pl-005-phase-a-priming
rig context-pack preview pl-005-phase-a-priming
rig context-pack add ./my-pack
rig context-pack sync
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity --dry-run
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
cmd.command("list")
.description("List all context packs in the library")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No context packs in library. Author one at ~/.openrig/context-packs/<name>/ then run: rig context-pack sync");
return;
}
for (const e of entries) {
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${String(e.files.length).padStart(2)} files ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType} ${e.sourcePath}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("show")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show pack manifest + per-file metadata")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Source: ${entry.sourceType} (${entry.sourcePath})`);
console.log(`Files: ${entry.files.length}`);
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
if (entry.purpose) {
console.log("");
console.log("Purpose:");
console.log(` ${entry.purpose.replaceAll("\n", "\n ")}`);
}
console.log("");
for (const f of entry.files) {
const sizeStr = f.bytes === null ? "(missing)" : `${f.bytes}B`;
const tokenStr = f.estimatedTokens === null ? "—" : `~${f.estimatedTokens} tokens`;
console.log(` ${f.path.padEnd(40)} role=${f.role.padEnd(20)} ${sizeStr.padEnd(12)} ${tokenStr}`);
if (f.summary) console.log(` ${f.summary}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("preview")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show the assembled bundle (the exact text that would be sent)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.get<PreviewWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/preview`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const preview = res.data;
if (opts.json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
if (preview.missingFiles.length > 0) {
console.error(`Warning: ${preview.missingFiles.length} file(s) referenced by manifest are missing on disk:`);
for (const m of preview.missingFiles) console.error(` - ${m.path} (role: ${m.role})`);
console.error("");
}
console.log(`# Preview: ${preview.name} v${preview.version}`);
console.log(`# Bundle: ${preview.bundleBytes} bytes (~${preview.estimatedTokens} tokens), ${preview.files.length} files`);
console.log("# ---");
console.log(preview.bundleText);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("sync")
.description("Re-walk discovery roots and refresh the library index")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>(
"/api/context-packs/library/sync",
);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
console.log(`Indexed ${data.count} context pack(s).`);
if (data.errors.length > 0) {
console.log(`Encountered ${data.errors.length} parse error(s):`);
for (const e of data.errors) console.log(` - ${e.source}: ${e.error}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("add")
.argument("<source-dir>", "Directory containing manifest.yaml + included files")
.description("Install a context pack from a local directory into ~/.openrig/context-packs/")
.option("--name <name>", "Override the install name (defaults to source directory basename)")
.option("--json", "JSON output")
.action(async (sourceDir: string, opts: { name?: string; json?: boolean }) => {
try {
if (!existsSync(sourceDir)) throw new Error(`Source directory not found: ${sourceDir}`);
const stat = lstatSync(sourceDir);
if (stat.isSymbolicLink()) throw new Error(`Source must not be a symlink: ${sourceDir}`);
if (!stat.isDirectory()) throw new Error(`Source must be a directory containing manifest.yaml: ${sourceDir}`);
const manifestPath = join(sourceDir, "manifest.yaml");
if (!existsSync(manifestPath)) {
throw new Error(`Source directory must contain manifest.yaml: ${sourceDir}`);
}
validateContextPackManifestForInstall(manifestPath);
// Read the manifest's name as the canonical install name when
// --name not given. Cheap parse: trust the daemon to validate
// on next sync; here we just need the name.
const installName = opts.name ?? (() => {
try {
const raw = readFileSync(manifestPath, "utf-8");
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
return m?.[1]?.trim() || basename(sourceDir);
} catch {
return basename(sourceDir);
}
})();
assertTreeHasNoSymlinks(sourceDir);
const targetRoot = getDefaultOpenRigPath("context-packs");
mkdirSync(targetRoot, { recursive: true });
const targetDir = join(targetRoot, installName);
if (existsSync(targetDir)) {
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
}
cpSync(sourceDir, targetDir, { recursive: true });
// Sync the daemon library so the new pack appears immediately.
const client = await getClient();
const syncRes = await client.post<{ count: number; errors?: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>("/api/context-packs/library/sync");
if (syncRes.status !== 200) {
// Install succeeded; sync failed → still surface install path.
if (opts.json) console.log(JSON.stringify({ installedAt: targetDir, syncError: `HTTP ${syncRes.status}` }, null, 2));
else console.log(`Installed at ${targetDir}; daemon sync failed (HTTP ${syncRes.status}). Run 'rig context-pack sync' manually.`);
return;
}
const syncError = syncRes.data.errors?.find((e) => e.source === targetDir);
if (syncError) {
throw new Error(`Installed at ${targetDir}, but daemon rejected the pack during sync: ${syncError.error}`);
}
if (opts.json) {
console.log(JSON.stringify({ installedAt: targetDir, count: syncRes.data.count }, null, 2));
} else {
console.log(`Installed at ${targetDir}. Library now has ${syncRes.data.count} context pack(s).`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("send")
.argument("<name-or-id>", "Context pack name or library ID")
.argument("<destination-session>", "Destination session name (e.g., velocity-driver@openrig-velocity)")
.description("Assemble the pack into one paste-ready bundle and send to a seat")
.option("--dry-run", "Show what would be sent without invoking SessionTransport")
.option("--json", "JSON output")
.action(async (nameOrId: string, destinationSession: string, opts: { dryRun?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.post<SendWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/send`, {
destinationSession,
dryRun: opts.dryRun ?? false,
});
if (res.status !== 200) {
// 502/503/etc. — surface daemon error verbatim.
const data = res.data as Partial<SendWire> & { error?: string; reason?: string };
throw new Error(data.error ?? data.reason ?? `Daemon returned HTTP ${res.status}`);
}
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (data.dryRun) {
if (data.missingFiles.length > 0) {
console.error(`Warning: ${data.missingFiles.length} manifest file(s) missing on disk.`);
}
console.log(`(dry-run) ${data.name} v${data.version} → ${data.destinationSession}`);
console.log(`Bundle: ${data.bundleBytes} bytes (~${data.estimatedTokens} tokens), ${data.files.length} files`);
if (data.bundleText) {
console.log("# ---");
console.log(data.bundleText);
}
return;
}
console.log(`Sent ${data.name} v${data.version} (${data.bundleBytes} bytes) to ${data.destinationSession}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
+2 -64
View File
@@ -77,21 +77,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Host to bind on")
.option("--db <path>", "Database path")
// V0.3.1 slice 05 kernel-rig-as-default — skip the kernel auto-boot
// path. Used by test fixtures, headless CI, and operators who want
// a no-kernel daemon for ad-hoc topology work. The daemon proceeds
// and serves its HTTP API normally; just doesn't materialize the
// kernel rig.
.option("--no-kernel", "Skip kernel auto-boot (daemon serves without the kernel rig)")
// V0.3.1 slice 05 kernel-rig-as-default — forward-fix #3 architectural.
// After the daemon's healthz binds (current behavior preserved),
// additionally poll /api/kernel/status until kernel_state is
// ready / partial_ready, or the timeout elapses. Used by operators
// who want a "kernel-ready" signal at start-time rather than the
// weaker "daemon-ready". Default 60s; override with --wait-for-kernel-ms.
.option("--wait-for-kernel", "After daemon binds, also wait for kernel-agent readiness (default timeout 60s)")
.option("--wait-for-kernel-ms <ms>", "Override --wait-for-kernel timeout in milliseconds")
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
.action(async (opts: { port?: string; host?: string; db?: string }) => {
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight } = await import("../system-preflight.js");
@@ -99,14 +85,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const configStore = new ConfigStore();
const config = configStore.resolve();
const effectivePort = opts.port ? parseInt(opts.port, 10) : config.daemon.port;
// bug-fix slice auth-bearer-tailscale-trust: distinguish
// user-explicit from default-fallback so the daemon can
// multi-bind (loopback + tailscale auto-detect) when the operator
// never opted in to a specific host.
const hostResolution = configStore.resolveWithSource("daemon.host");
const hostUserExplicit = opts.host !== undefined || hostResolution.source !== "default";
const effectiveHost = opts.host ?? config.daemon.host;
const hostForDaemon = hostUserExplicit ? effectiveHost : undefined;
// Run preflight before starting
const preflight = new SystemPreflight({
@@ -126,58 +105,17 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
return;
}
// V0.3.1 slice 05 — Commander's --no-kernel inverts to opts.kernel === false.
const skipKernel = opts.kernel === false;
const state = await startDaemon(
{
port: effectivePort,
host: hostForDaemon,
host: effectiveHost,
db: opts.db ?? config.db.path,
transcriptsEnabled: config.transcripts.enabled,
transcriptsPath: config.transcripts.path,
workspaceRoot: config.workspace.root,
// V1 pre-release CLI/daemon Item 1 — project the
// ConfigStore-resolved rotation tunables into the daemon
// process env so file-stored values
// (`rig config set transcripts.lines 500`) actually
// reach the rotation hook.
transcriptsLines: config.transcripts.lines,
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
// V0.3.1 slice 05 kernel-rig-as-default — propagated via
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
// check in startup.ts honors the flag.
skipKernelBoot: skipKernel,
},
getDeps(),
);
console.log(`Daemon started on port ${state.port} (pid ${state.pid})`);
// V0.3.1 slice 05 forward-fix #3 architectural — --wait-for-kernel
// post-bind polling. Kernel boot is fire-and-forget after the
// daemon binds healthz, so without this flag the CLI doesn't
// know whether the kernel itself reached ready. Operators who
// need a kernel-ready signal opt in here.
if (opts.waitForKernel) {
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
const baseUrl = `http://${state.host}:${state.port}`;
const result = await waitForKernelReady(baseUrl, timeoutMs);
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
} else {
// Honest 3-part error per banked discipline.
console.error(
`Error: kernel did not reach ready / partial_ready within ${timeoutMs}ms.\n` +
`Reason: kernel_state=${result.kernelState ?? "unknown"}` +
(result.detail ? `; ${result.detail}` : "") +
"\n" +
"Fix: inspect `rig ps --rig kernel` for stalled agents, or run `claude auth status` / `codex login status` to confirm runtime auth.",
);
process.exitCode = 1;
}
}
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
+5
View File
@@ -1,5 +1,6 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { Command } from "commander";
@@ -292,6 +293,10 @@ function resolveSharedDocsRoot(env: NodeJS.ProcessEnv): string | null {
const value = env[key];
if (value && fs.existsSync(nodePath.join(value, "rigs"))) return value;
}
const home = env["HOME"] ?? os.homedir();
const wellKnown = nodePath.join(home, "code", "substrate", "shared-docs");
if (fs.existsSync(nodePath.join(wellKnown, "rigs"))) return wellKnown;
return null;
}
+3 -8
View File
@@ -27,8 +27,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
.option("--preflight", "Run preflight checks")
.option("--target-rig <rigId>", "Target existing rig for additive materialization")
.option("--rig-root <root>", "Root directory for pod-aware resolution")
.option("--cwd <path>", "Override launch/materialization working directory for all members")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string; cwd?: string }) => {
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string }) => {
const deps = getDeps();
// Read local file first (before daemon check — fail fast on missing file)
@@ -60,11 +59,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
const rigRoot = podAware
? (opts.rigRoot ? nodePath.resolve(opts.rigRoot) : nodePath.dirname(nodePath.resolve(filePath)))
: undefined;
const cwdOverride = opts.cwd ? nodePath.resolve(opts.cwd) : undefined;
const extraHeaders = {
...(rigRoot ? { "X-Rig-Root": rigRoot } : {}),
...(cwdOverride ? { "X-Cwd-Override": cwdOverride } : {}),
};
const extraHeaders = rigRoot ? { "X-Rig-Root": rigRoot } : undefined;
if (opts.instantiate && opts.materializeOnly) {
console.error("Choose either --instantiate or --materialize-only, not both.");
@@ -102,7 +97,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
return;
}
const headers = {
...extraHeaders,
...(extraHeaders ?? {}),
...(opts.targetRig ? { "X-Target-Rig-Id": opts.targetRig } : {}),
};
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import/materialize", yaml, "text/yaml", headers);
-483
View File
@@ -1,483 +0,0 @@
// rig plugin CLI verb family — read-only plugin inspection.
//
// Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + DESIGN.md §6
// (CLI minimalism: 4 read-only inspection commands).
//
// Subcommands at slice 3.4 scope (this commit):
// list — aggregated view of discoverable plugins
// flags: --runtime claude|codex
// --source vendored|claude-cache|codex-cache
// --json
// show <id> — inspect manifest + skills + hooks + mcp servers
// flags: --json
// used-by <id> — agents referencing this plugin
// flags: --json
// validate <path> — local file inspection of a plugin source tree
// (manifest shape + skill frontmatter per agentskills.io)
// flags: --json
//
// list/show consume slice 3.3 daemon HTTP routes (GET /api/plugins[/...]).
//
// All commands ship --json output for agent consumption per banked
// building-agent-software + IMPL-PRD §4.4 HG-4.5.
//
// Wire shapes mirror slice 3.3 PluginDiscoveryService exports verbatim
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132). Types
// here are the wire contract; any drift from daemon source means the
// daemon shape changed and this file must update in lockstep.
//
// Flags NOT supported at v0 (deferred per velocity-guard 3.4.A blocker):
// --used — declared in PRD §4.2 but daemon route doesn't expose a
// "filter to plugins referenced by an agent" filter; would
// require client-side post-filter against /api/plugins +
// /api/plugins/:id/used-by N+1 calls. Not in v0 scope.
// --tree — declared in PRD §4.2 but show output already prints the
// tree structure (manifests + skills + hooks + mcp).
// Distinct tree-only mode is post-v0 polish.
// --source rig-cwd — daemon route's parseSourceFilter doesn't accept
// rig-cwd; rig-cwd scan is enabled via separate ?cwd=<path>
// query (out of scope for v0 since CLI doesn't have a "scan
// this rig's cwd" surface yet).
// --runtime both — daemon parseRuntimeFilter accepts only claude|codex;
// omitting --runtime returns all (the "both" semantic is
// default no-filter).
// Each of these surfaces in a future slice; CLI flag declarations are
// tightly scoped to what the daemon actually supports.
import { Command } from "commander";
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
// Filter-value enums per slice-3.3 daemon route validation
// (packages/daemon/src/routes/plugins.ts L37-58). CLI-side validation
// gives operators clear errors instead of silent pass-through where the
// daemon ignores unknown values.
const VALID_RUNTIMES = ["claude", "codex"] as const;
const VALID_SOURCES = ["vendored", "claude-cache", "codex-cache"] as const;
// ============================================================
// Wire shapes — must mirror PluginDiscoveryService verbatim
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132)
// ============================================================
type PluginRuntime = "claude" | "codex";
type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache" | "rig-cwd";
interface PluginEntryWire {
id: string;
name: string;
version: string;
description: string | null;
source: PluginSourceKind;
sourceLabel: string;
runtimes: PluginRuntime[];
path: string;
lastSeenAt: string | null;
}
interface PluginManifestSummaryWire {
raw: Record<string, unknown>;
name: string | null;
version: string | null;
description: string | null;
homepage: string | null;
repository: string | null;
license: string | null;
}
interface PluginSkillSummaryWire {
name: string;
relativePath: string;
}
interface PluginHookSummaryWire {
runtime: PluginRuntime;
relativePath: string;
events: string[];
}
interface PluginMcpServerSummaryWire {
runtime: PluginRuntime;
name: string;
command: string | null;
transport: string | null;
}
interface PluginDetailWire {
entry: PluginEntryWire;
claudeManifest: PluginManifestSummaryWire | null;
codexManifest: PluginManifestSummaryWire | null;
skills: PluginSkillSummaryWire[];
hooks: PluginHookSummaryWire[];
mcpServers: PluginMcpServerSummaryWire[];
}
// AgentReference per PluginDiscoveryService L134-141 verbatim.
interface AgentReferenceWire {
agentName: string;
sourcePath: string;
profiles: string[];
}
export function pluginCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("plugin");
cmd.description("Inspect plugins (read-only)");
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
// -- rig plugin list --
cmd.command("list")
.description("List discoverable plugins (aggregated across vendored + runtime caches)")
.option("--runtime <runtime>", "Filter by runtime support: claude | codex (omit for all)")
.option("--source <source>", "Filter by source: vendored | claude-cache | codex-cache")
.option("--json", "JSON output")
.action(async (opts: { runtime?: string; source?: string; json?: boolean }) => {
try {
// CLI-side filter-value validation per pre-close punch from velocity-guard
// 3.4.A repair verdict: typos like --source rig-cwd should fail loud,
// not silently be ignored by the daemon route.
if (opts.runtime && !(VALID_RUNTIMES as readonly string[]).includes(opts.runtime)) {
throw new Error(`Invalid --runtime value "${opts.runtime}". Valid: ${VALID_RUNTIMES.join(" | ")} (omit flag for all)`);
}
if (opts.source && !(VALID_SOURCES as readonly string[]).includes(opts.source)) {
throw new Error(`Invalid --source value "${opts.source}". Valid: ${VALID_SOURCES.join(" | ")}`);
}
const client = await getClient();
const params: string[] = [];
if (opts.runtime) params.push(`runtime=${encodeURIComponent(opts.runtime)}`);
if (opts.source) params.push(`source=${encodeURIComponent(opts.source)}`);
const path = `/api/plugins${params.length > 0 ? `?${params.join("&")}` : ""}`;
const res = await client.get<PluginEntryWire[]>(path);
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No plugins discovered.");
return;
}
for (const e of entries) {
const runtimesStr = e.runtimes.join(",");
// Real fields only: id, version, runtimes, sourceLabel, path
console.log(
`${e.id.padEnd(28)} v${String(e.version).padEnd(8)} ` +
`[${runtimesStr.padEnd(13)}] ${e.sourceLabel.padEnd(36)} ` +
`${e.path}`
);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
// -- rig plugin show <id> --
cmd.command("show")
.argument("<id>", "Plugin id (e.g., openrig-core)")
.description("Show plugin manifest + skills + hooks + mcp servers")
.option("--json", "JSON output")
.action(async (id: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<PluginDetailWire>(`/api/plugins/${encodeURIComponent(id)}`);
if (res.status === 404) {
throw new Error(`Plugin not found: "${id}"`);
}
if (res.status !== 200 || !res.data) {
throw new Error(`Daemon returned HTTP ${res.status}`);
}
const detail = res.data;
if (opts.json) {
console.log(JSON.stringify(detail, null, 2));
return;
}
const entry = detail.entry;
console.log(`Id: ${entry.id}`);
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Description: ${entry.description ?? "(none)"}`);
console.log(`Source: ${entry.sourceLabel}`);
console.log(`Path: ${entry.path}`);
console.log(`Runtimes: ${entry.runtimes.join(", ")}`);
if (entry.lastSeenAt) {
console.log(`Last seen: ${entry.lastSeenAt}`);
}
console.log("");
// Manifests — real PluginManifestSummary fields
console.log("Manifests:");
if (detail.claudeManifest) {
const m = detail.claudeManifest;
const parts: string[] = [];
if (m.name) parts.push(`name=${m.name}`);
if (m.version) parts.push(`version=${m.version}`);
if (m.license) parts.push(`license=${m.license}`);
if (m.repository) parts.push(`repo=${m.repository}`);
console.log(` claude: ${parts.join(" ") || "(present)"}`);
}
if (detail.codexManifest) {
const m = detail.codexManifest;
const parts: string[] = [];
if (m.name) parts.push(`name=${m.name}`);
if (m.version) parts.push(`version=${m.version}`);
if (m.license) parts.push(`license=${m.license}`);
if (m.repository) parts.push(`repo=${m.repository}`);
console.log(` codex: ${parts.join(" ") || "(present)"}`);
}
if (!detail.claudeManifest && !detail.codexManifest) {
console.log(" (none)");
}
// Skills — PluginSkillSummary has name + relativePath only
console.log("");
console.log(`Skills (${detail.skills.length}):`);
for (const s of detail.skills) {
console.log(` ${s.name.padEnd(40)} ${s.relativePath}`);
}
// Hooks — PluginHookSummary has runtime + relativePath + events[]
console.log("");
console.log(`Hooks (${detail.hooks.length}):`);
for (const h of detail.hooks) {
const eventList = h.events.length > 0 ? h.events.join(",") : "(none)";
console.log(` ${h.runtime.padEnd(10)} ${String(h.events.length).padStart(2)} events [${eventList}] ${h.relativePath}`);
}
// MCP servers
if (detail.mcpServers.length > 0) {
console.log("");
console.log(`MCP servers (${detail.mcpServers.length}):`);
for (const m of detail.mcpServers) {
const detail2: string[] = [];
if (m.transport) detail2.push(`transport=${m.transport}`);
if (m.command) detail2.push(`command=${m.command}`);
console.log(` ${m.runtime.padEnd(10)} ${m.name.padEnd(28)} ${detail2.join(" ")}`);
}
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
// -- rig plugin used-by <id> --
cmd.command("used-by")
.argument("<id>", "Plugin id (e.g., openrig-core)")
.description("List agents referencing this plugin in their profile.uses.plugins[]")
.option("--json", "JSON output")
.action(async (id: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<AgentReferenceWire[]>(`/api/plugins/${encodeURIComponent(id)}/used-by`);
if (res.status !== 200) {
throw new Error(`Daemon returned HTTP ${res.status}`);
}
const refs = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(refs, null, 2));
return;
}
if (refs.length === 0) {
console.log(`No agents reference plugin "${id}".`);
return;
}
for (const r of refs) {
const profilesStr = r.profiles.length > 0 ? r.profiles.join(",") : "(none)";
console.log(`${r.agentName.padEnd(36)} [${profilesStr.padEnd(20)}] ${r.sourcePath}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
// -- rig plugin validate <path> --
// Local file inspection — no daemon dependency; useful when authoring
// a plugin in a feature branch where the daemon may not yet have
// discovered it. Validates manifest shape (per Claude/Codex specs)
// + skill frontmatter (per agentskills.io: name + description ≤1024 chars).
cmd.command("validate")
.argument("<path>", "Plugin source directory to validate")
.description("Validate plugin manifest + skill frontmatter against agentskills.io spec")
.option("--json", "JSON output ({ valid: boolean, errors: string[] })")
.action((path: string, opts: { json?: boolean }) => {
const errors = validatePluginTree(path);
const valid = errors.length === 0;
if (opts.json) {
console.log(JSON.stringify({ valid, errors }, null, 2));
process.exitCode = valid ? undefined : 1;
return;
}
if (valid) {
console.log(`Plugin at ${path}: valid`);
return;
}
console.error(`Plugin at ${path}: INVALID (${errors.length} error${errors.length === 1 ? "" : "s"})`);
for (const err of errors) {
console.error(` - ${err}`);
}
process.exitCode = 1;
});
return cmd;
}
// ============================================================
// validate plumbing — local file inspection
// ============================================================
function validatePluginTree(pluginPath: string): string[] {
const errors: string[] = [];
if (!existsSync(pluginPath)) {
errors.push(`Plugin path does not exist: ${pluginPath}`);
return errors;
}
if (!statSync(pluginPath).isDirectory()) {
errors.push(`Plugin path is not a directory: ${pluginPath}`);
return errors;
}
const claudeManifestPath = join(pluginPath, ".claude-plugin", "plugin.json");
const codexManifestPath = join(pluginPath, ".codex-plugin", "plugin.json");
const hasClaude = existsSync(claudeManifestPath);
const hasCodex = existsSync(codexManifestPath);
if (!hasClaude && !hasCodex) {
errors.push("No plugin manifest found: expected .claude-plugin/plugin.json and/or .codex-plugin/plugin.json");
return errors;
}
if (hasClaude) {
errors.push(...validateManifest(claudeManifestPath, "claude"));
}
if (hasCodex) {
errors.push(...validateManifest(codexManifestPath, "codex"));
}
// Validate skill frontmatter
const skillsDir = join(pluginPath, "skills");
if (existsSync(skillsDir) && statSync(skillsDir).isDirectory()) {
for (const skillId of readdirSync(skillsDir)) {
const skillMdPath = join(skillsDir, skillId, "SKILL.md");
if (existsSync(skillMdPath)) {
errors.push(...validateSkillFrontmatter(skillMdPath, skillId));
}
}
}
return errors;
}
function validateManifest(manifestPath: string, runtime: "claude" | "codex"): string[] {
const errors: string[] = [];
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
} catch (err) {
errors.push(`${runtime} manifest parse error at ${manifestPath}: ${(err as Error).message}`);
return errors;
}
// Guard non-object/null parse results — JSON.parse('null') returns null,
// JSON.parse('[1,2]') returns an array — both must fail validation, not crash.
if (!isPlainObject(parsed)) {
errors.push(`${runtime} manifest at ${manifestPath} must be a JSON object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
return errors;
}
const raw = parsed;
if (typeof raw["name"] !== "string" || raw["name"].trim().length === 0) {
errors.push(`${runtime} manifest missing required field: name (must be non-empty string)`);
}
if (typeof raw["version"] !== "string" || raw["version"].trim().length === 0) {
errors.push(`${runtime} manifest missing required field: version (must be non-empty string)`);
}
// Codex spec: description is REQUIRED. Claude spec: description is recommended but
// not a hard requirement; we treat missing description as a warning by NOT erroring
// on Claude-only-missing-description while erroring on Codex-missing-description.
if (runtime === "codex" && (typeof raw["description"] !== "string" || raw["description"].trim().length === 0)) {
errors.push(`codex manifest missing required field: description (Codex spec requires it)`);
}
return errors;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function validateSkillFrontmatter(skillPath: string, skillId: string): string[] {
const errors: string[] = [];
let content: string;
try {
content = readFileSync(skillPath, "utf-8");
} catch (err) {
errors.push(`skill "${skillId}": failed to read SKILL.md: ${(err as Error).message}`);
return errors;
}
// Frontmatter: file MUST start with `---\n` and contain a closing `---` line
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) {
errors.push(`skill "${skillId}": SKILL.md missing frontmatter (must open with --- ... ---)`);
return errors;
}
const fmBody = fmMatch[1] ?? "";
// Parse frontmatter as YAML — regex-based validation can pass quoted-empty
// values and miss type errors per velocity-guard 3.4.C BLOCKING-CONCERN.
let parsed: unknown;
try {
parsed = parseYaml(fmBody);
} catch (err) {
errors.push(`skill "${skillId}": invalid YAML frontmatter: ${(err as Error).message}`);
return errors;
}
if (!isPlainObject(parsed)) {
errors.push(`skill "${skillId}": frontmatter must be a YAML object/map (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
return errors;
}
// Required: name (non-empty string after trim)
const name = parsed["name"];
if (typeof name !== "string" || name.trim().length === 0) {
errors.push(`skill "${skillId}": frontmatter missing required field: name (must be non-empty string)`);
}
// Required: description (non-empty string after trim; ≤1024 chars per agentskills.io)
const description = parsed["description"];
if (typeof description !== "string" || description.trim().length === 0) {
errors.push(`skill "${skillId}": frontmatter missing required field: description (must be non-empty string)`);
} else if (description.trim().length > 1024) {
errors.push(`skill "${skillId}": description length ${description.trim().length} chars exceeds agentskills.io limit of 1024`);
}
return errors;
}
+12 -194
View File
@@ -43,16 +43,6 @@ interface NodeEntry {
sampledAt: string;
evidence: string | null;
};
// PL-012: context-usage block surfaced from the daemon's
// /api/rigs/:id/nodes route (already populated). Optional because
// older daemons may not emit it.
contextUsage?: {
availability: "known" | "unknown";
usedPercentage: number | null;
fresh: boolean;
state?: "critical" | "warning" | "low" | "unknown";
sampledAt: string | null;
};
[key: string]: unknown;
}
@@ -65,36 +55,7 @@ const HUMAN_NODE_BUDGET = 100;
// L3-followup: --filter accepts only this allow-list. Unknown keys produce a
// clear error with the supported list (Guard Review Checklist: filter-parser
// security).
//
// PL-019 item 1: extends the allow-list with the nested key `agentActivity.state`.
// This is the only nested filter key v0 supports; the parser checks for the
// dotted form explicitly. Bare `agentActivity` is intentionally NOT a filter
// key — it's an object, not a scalar; project it via --fields if you need it.
const ALLOWED_FILTER_KEYS = new Set([
"status",
"lifecycleState",
"name-prefix",
"name",
"agentActivity.state",
// PL-012: context-usage filters. percent is numeric (>=, >, <=, <, =);
// state is enum (critical / warning / low / unknown — lockstep with
// computeContextHealthSummary tier vocabulary).
"contextUsage.percent",
"contextUsage.state",
]);
// PL-019 item 1: when filter key is `agentActivity.state`, the value is
// validated against the AgentActivityState enum. Invalid values produce a
// three-part error (what failed / what's allowed / what to do).
const ALLOWED_AGENT_ACTIVITY_STATES = new Set(["running", "needs_input", "idle", "unknown"]);
// PL-012: ALLOWED_CONTEXT_USAGE_STATES — must stay lockstep with the
// daemon's computeContextHealthSummary urgency vocab.
const ALLOWED_CONTEXT_USAGE_STATES = new Set(["critical", "warning", "low", "unknown"]);
const NUMERIC_FILTER_KEYS = new Set(["contextUsage.percent"]);
type NumericComparator = ">=" | ">" | "<=" | "<" | "=";
const NUMERIC_OPERATORS: NumericComparator[] = [">=", "<=", ">", "<", "="];
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.
@@ -132,7 +93,6 @@ const ALLOWED_NODE_FIELDS = new Set([
"resumeCommand",
"latestError",
"agentActivity",
"contextUsage",
]);
interface PsCliOptions {
@@ -144,7 +104,6 @@ interface PsCliOptions {
summary?: boolean;
filter?: string;
host?: string;
active?: boolean;
}
export interface PsDeps extends StatusDeps {
@@ -165,34 +124,13 @@ export interface PsDeps extends StatusDeps {
interface ParsedFilter {
key: string;
value: string;
/** PL-012: numeric comparator for keys in NUMERIC_FILTER_KEYS;
* defaults to "=" (equality) for all other keys. */
op: NumericComparator;
/** PL-012: parsed numeric value when op is a numeric comparator. */
numericValue?: number;
}
function parseFilter(filter: string): ParsedFilter | { error: string } {
// PL-012: pre-detect numeric comparators (>=, <=, >, <, =) so callers
// can write `contextUsage.percent>=80`. Order matters: check >= and
// <= before > and < so the longer prefix wins.
let op: NumericComparator = "=";
let opIdx = -1;
for (const candidate of NUMERIC_OPERATORS) {
const i = filter.indexOf(candidate);
if (i !== -1) {
// Prefer the leftmost match; on tie, prefer the longest comparator.
if (opIdx === -1 || i < opIdx || (i === opIdx && candidate.length > op.length)) {
opIdx = i;
op = candidate;
}
}
}
if (opIdx === -1) {
return { error: `--filter must be key<op>value (op = ${NUMERIC_OPERATORS.join(", ")}); got: '${filter}'` };
}
const key = filter.slice(0, opIdx);
const value = filter.slice(opIdx + op.length);
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(", ")}`,
@@ -201,60 +139,7 @@ function parseFilter(filter: string): ParsedFilter | { error: string } {
if (!value) {
return { error: `--filter value is empty for key '${key}'` };
}
// PL-012: numeric-keyed filters validate the value parses as a finite
// number. Three-part error per the existing convention.
if (NUMERIC_FILTER_KEYS.has(key)) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return {
error: `--filter ${key}${op}'${value}' is not a numeric value. ` +
`Allowed: a finite number (e.g., ${key}>=80). ` +
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
};
}
return { key, value, op, numericValue };
}
// PL-012: contextUsage.state enum guard — same shape as agentActivity.state.
if (key === "contextUsage.state" && !ALLOWED_CONTEXT_USAGE_STATES.has(value)) {
return {
error: `--filter contextUsage.state='${value}' is not a valid context state. ` +
`Allowed: ${[...ALLOWED_CONTEXT_USAGE_STATES].sort().join(", ")}. ` +
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
};
}
// PL-019 item 1: agentActivity.state is enum-valued; reject invalid values
// up-front so operators don't get a silent empty result on a typo. Three-part
// shape per `feedback_smart_agents_no_bureaucracy.md`: what failed / what's
// allowed / what to do.
if (key === "agentActivity.state" && !ALLOWED_AGENT_ACTIVITY_STATES.has(value)) {
return {
error: `--filter agentActivity.state='${value}' is not a valid activity state. ` +
`Allowed: ${[...ALLOWED_AGENT_ACTIVITY_STATES].sort().join(", ")}. ` +
`Use 'rig ps --nodes --fields agentActivity --json' to see what daemon is reporting.`,
};
}
// Non-numeric keys must use op = "=".
if (op !== "=") {
return {
error: `--filter ${key}${op}... uses a numeric comparator on a non-numeric key. ` +
`Allowed numeric keys: ${[...NUMERIC_FILTER_KEYS].sort().join(", ")}. ` +
`Use ${key}=<value> for equality.`,
};
}
return { key, value, op };
}
// PL-012: derive a context-usage tier from a numeric percent. Lockstep
// with daemon-side computeContextHealthSummary thresholds.
function deriveContextUsageState(percent: number | null | undefined): "critical" | "warning" | "low" | "unknown" {
if (typeof percent !== "number") return "unknown";
if (percent >= 80) return "critical";
if (percent >= 60) return "warning";
return "low";
return { key, value };
}
// C9a: validate --fields against a per-level allow-list. Mirrors parseFilter's
@@ -287,12 +172,6 @@ function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
if (filter.key === "lifecycleState") return e.lifecycleState === filter.value;
if (filter.key === "name-prefix") return (e.rigName ?? e.name).startsWith(filter.value);
if (filter.key === "name") return (e.rigName ?? e.name) === filter.value;
// PL-019 item 1: agentActivity.state is node-level only; at the rig
// level it has no meaning, so the filter passes everything through and
// the user gets all rigs (the operator can `--nodes` to scope it).
if (filter.key === "agentActivity.state") return true;
// PL-012: contextUsage.* filters are node-level only.
if (filter.key === "contextUsage.percent" || filter.key === "contextUsage.state") return true;
return true;
});
}
@@ -304,28 +183,6 @@ function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[
if (filter.key === "lifecycleState") return n.lifecycleState === filter.value;
if (filter.key === "name-prefix") return n.rigName.startsWith(filter.value);
if (filter.key === "name") return n.rigName === filter.value;
// PL-019 item 1: nested-key traversal for agentActivity.state. Nodes
// without an agentActivity attachment never match a non-unknown filter
// value (the daemon reports `unknown` when it has no signal — explicit).
if (filter.key === "agentActivity.state") return n.agentActivity?.state === filter.value;
// PL-012: contextUsage.percent — numeric comparison. Nodes with no
// sample fail every comparator (operator can filter on
// contextUsage.state=unknown to find them).
if (filter.key === "contextUsage.percent") {
const pct = n.contextUsage?.usedPercentage;
if (typeof pct !== "number" || filter.numericValue === undefined) return false;
switch (filter.op) {
case ">=": return pct >= filter.numericValue;
case ">": return pct > filter.numericValue;
case "<=": return pct <= filter.numericValue;
case "<": return pct < filter.numericValue;
case "=": return pct === filter.numericValue;
}
}
if (filter.key === "contextUsage.state") {
const derived = n.contextUsage?.state ?? deriveContextUsageState(n.contextUsage?.usedPercentage ?? null);
return derived === filter.value;
}
return true;
});
}
@@ -421,17 +278,12 @@ Examples:
rig ps --filter name-prefix=demo Filter by rig-name prefix
rig ps --nodes Per-node detail (human; truncated to ${HUMAN_NODE_BUDGET})
rig ps --nodes --json --limit 50 Bounded per-node JSON envelope
rig ps --nodes --active Show only nodes whose agentActivity.state == running (PL-019)
rig ps --nodes --filter agentActivity.state=running
Same as --active (the explicit form)
rig ps --host vm-claude-test --nodes --json Run on a remote host via single-hop ssh
JSON output entries include both \`name\` and \`rigName\` (alias) for forward
compatibility; agent code should prefer \`rigName\` (matches per-node JSON).
--filter accepts: status, lifecycleState, name-prefix, name, agentActivity.state.
Other keys are rejected. agentActivity.state is node-level (use with --nodes);
allowed values: running, needs_input, idle, unknown.
--filter accepts: status, lifecycleState, name-prefix, name. Other keys are rejected.
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
status, lifecycleState, uptime, latestSnapshot.
@@ -463,8 +315,7 @@ Exit codes:
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
.option("--summary", "Emit aggregate-only output (counts by status/lifecycle)")
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name, agentActivity.state")
.option("--active", "Shortcut for --filter agentActivity.state=running (PL-019)")
.option("--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();
@@ -484,28 +335,9 @@ Exit codes:
// Parse filter once up front so unknown keys/malformed filters surface
// as exit-1 errors before any HTTP call.
//
// PL-019 item 1: --active is sugar for --filter agentActivity.state=running.
// Combining --active with --filter is rejected (composition is
// ambiguous — pick one explicit form). Parity is verified end-to-end
// by the focused test: `--active` and the explicit filter must yield
// identical output on the same fixture.
let effectiveFilter = opts.filter;
if (opts.active) {
if (effectiveFilter) {
console.error(
`--active and --filter cannot be combined. ` +
`--active is sugar for --filter agentActivity.state=running. ` +
`Pick one form, or compose by upgrading to --filter directly.`
);
process.exitCode = 1;
return;
}
effectiveFilter = "agentActivity.state=running";
}
let parsedFilter: ParsedFilter | null = null;
if (effectiveFilter) {
const result = parseFilter(effectiveFilter);
if (opts.filter) {
const result = parseFilter(opts.filter);
if ("error" in result) {
console.error(result.error);
process.exitCode = 1;
@@ -704,7 +536,7 @@ async function handleNodes(
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "CTX", "RESTORE", "ERROR");
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "RESTORE", "ERROR");
console.log(header);
for (const n of humanList as NodeEntry[]) {
const parts = n.logicalId.split(".");
@@ -721,7 +553,6 @@ async function handleNodes(
n.startupStatus ?? "—",
abbrevNodeLifecycle(n.lifecycleState),
formatActivity(n.agentActivity),
formatContextUsage(n.contextUsage),
n.restoreOutcome,
n.latestError ? truncate(n.latestError, 30) : "—",
));
@@ -764,7 +595,7 @@ function padRigRow(rig: string, nodes: string, running: string, status: string,
].join("");
}
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, ctx: string, restore: string, error: string): string {
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, restore: string, error: string): string {
return [
fitCell(rig, 30),
fitCell(pod, 10),
@@ -775,24 +606,11 @@ function padNodeRow(rig: string, pod: string, member: string, session: string, r
fitCell(startup, 10),
fitCell(lifecycle, 11),
fitCell(activity, 12),
fitCell(ctx, 6),
fitCell(restore, 10),
error,
].join("");
}
// PL-012: render context-usage as a 5-char cell — "<percent>%" when
// known + fresh, "<percent>%*" when known but stale, "??" when unknown.
// 4-char width keeps the table compact without truncating two-digit
// percentages (e.g., "98%*" or "5%").
function formatContextUsage(ctx: NodeEntry["contextUsage"]): string {
if (!ctx || ctx.availability !== "known" || typeof ctx.usedPercentage !== "number") {
return "??";
}
const stale = ctx.fresh === false ? "*" : "";
return `${ctx.usedPercentage}%${stale}`;
}
async function runCrossHostPs(
hostId: string,
-12
View File
@@ -68,7 +68,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--tags <tags>", "Comma-separated tags")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
.option("--json", "JSON output for agents")
.action(async (opts: {
@@ -80,7 +79,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
tags?: string;
expiresAt?: string;
id?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -96,7 +94,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
tier: opts.tier,
tags,
expiresAt: opts.expiresAt,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
@@ -181,7 +178,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -192,7 +188,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
priority?: string;
tier?: string;
tags?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -209,7 +204,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
priority: opts.priority,
tier: opts.tier,
tags,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
@@ -228,7 +222,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--priority <priority>", "Override priority for the new qitem")
.option("--tier <tier>", "Override tier for the new qitem")
.option("--tags <tags>", "Comma-separated tags for the new qitem")
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
.option("--no-nudge", "Suppress the default nudge to the new destination")
.option("--json", "JSON output for agents")
.action(async (qitemId: string, opts: {
@@ -239,7 +232,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
priority?: string;
tier?: string;
tags?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -256,7 +248,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
priority: opts.priority,
tier: opts.tier,
tags,
targetRepo: opts.targetRepo,
nudge: opts.nudge,
});
printResult(opts.json ?? false, res.data, res.status);
@@ -330,14 +321,12 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.option("--destination <session>", "Filter by destination session")
.option("--source <session>", "Filter by source session")
.option("--state <state>", "Filter by state (comma-separated for multiple)")
.option("--target-repo <name>", "PL-007: filter qitems by target_repo (exact match)")
.option("--limit <n>", "Result limit", "100")
.option("--json", "JSON output for agents")
.action(async (opts: {
destination?: string;
source?: string;
state?: string;
targetRepo?: string;
limit: string;
json?: boolean;
}) => {
@@ -346,7 +335,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
if (opts.destination) params.set("destinationSession", opts.destination);
if (opts.source) params.set("sourceSession", opts.source);
if (opts.state) params.set("state", opts.state);
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
if (opts.limit) params.set("limit", opts.limit);
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
-681
View File
@@ -1,681 +0,0 @@
// release-0.3.2 slice 12 — `rig scope` CLI primitive.
//
// Command grammar: rig scope <tier> <verb>. v0 ships `mission` +
// `slice` tiers; `project` and `sub-slice` are reserved per the
// substrate convention `conventions/scope-and-versioning/README.md`
// (stage: provisional). The CLI mints stable dot-IDs into created
// mission/slice frontmatter per §1 of that convention.
import fs from "node:fs";
import path from "node:path";
import { Command } from "commander";
import {
CLOSE_REASONS,
MISSION_TEMPLATE_KINDS,
SLICE_TEMPLATE_KINDS,
ScopeCliError,
type CloseReason,
type MissionTemplateKind,
type SliceTemplateKind,
type SliceState,
} from "../lib/scope/types.js";
import {
DEFAULT_PROJECT_PREFIX,
inferMissionDotId,
isMissionDotId,
nextEscapeBandOrdinal,
sliceIdFromMission,
} from "../lib/scope/dot-id.js";
import {
ensureMissionIdPersisted,
findMission,
findSlice,
listMissions,
listSlices,
moveSlice,
nextSliceNN,
pad2,
resolveMissionsRoot,
splitFrontmatter,
todayDateISO,
updateFrontmatter,
} from "../lib/scope/scope-fs.js";
import {
renderMissionTemplate,
renderSliceTemplate,
titleFromSlug,
} from "../lib/scope/templates.js";
// ---------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------
interface Stdout {
write: (text: string) => void;
}
function makeStdout(): Stdout {
return { write: (text: string) => process.stdout.write(text) };
}
function emit(out: Stdout, payload: unknown, json: boolean, lines?: string[]): void {
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
return;
}
if (lines) {
for (const line of lines) out.write(line + "\n");
return;
}
out.write(JSON.stringify(payload, null, 2) + "\n");
}
function fail(err: unknown, json: boolean, out: Stdout): never {
if (err instanceof ScopeCliError) {
if (json) {
out.write(JSON.stringify({
ok: false,
error: { fact: err.fact, consequence: err.consequence, action: err.action },
}, null, 2) + "\n");
} else {
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
}
} else {
const message = err instanceof Error ? err.message : String(err);
process.stderr.write(`Error: ${message}\n`);
}
process.exit(1);
}
function slugify(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
interface RootOpts {
workspace?: string;
}
function getOpts(cmd: Command): RootOpts {
// commander v13 attaches opts on the parent.
let walker: Command | null = cmd;
while (walker) {
const o = walker.opts() as RootOpts;
if (o.workspace) return o;
walker = walker.parent;
}
return {};
}
// ---------------------------------------------------------------------
// rig scope slice ls
// ---------------------------------------------------------------------
function buildSliceLsCommand(): Command {
const cmd = new Command("ls")
.description("List slices in a mission (or across all missions)")
.option("--mission <name>", "Restrict to a single mission")
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
const state = (opts.state as SliceState) ?? "active";
if (!["active", "closed", "shipped", "all"].includes(state)) {
fail(new ScopeCliError({
fact: `Unknown --state value "${state}".`,
consequence: "Command did not run.",
action: "Pick one of: active, closed, shipped, all.",
}), json, out);
}
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const missions = opts.mission
? [findMission(missionsRoot, opts.mission)]
: listMissions(missionsRoot);
const rows: unknown[] = [];
const lines: string[] = [];
for (const mission of missions) {
const slices = listSlices(mission, state);
for (const slice of slices) {
rows.push({
mission: mission.name,
name: slice.name,
nn: slice.nn,
slug: slice.slug,
id: slice.id,
status: slice.status,
path: slice.absPath,
});
lines.push(`${mission.name}/${slice.name} ${slice.id ?? "—"} ${slice.status ?? "—"}`);
}
}
emit(out, { ok: true, count: rows.length, slices: rows }, json, lines.length === 0 ? ["(no slices)"] : lines);
} catch (err) {
fail(err, json, out);
}
});
return cmd;
}
// ---------------------------------------------------------------------
// rig scope slice show
// ---------------------------------------------------------------------
function buildSliceShowCommand(): Command {
return new Command("show")
.description("Inspect a single slice (frontmatter + README + children)")
.argument("<slice-path>", "Slice path (absolute, relative-to-substrate, or NN-slug)")
.option("--mission <name>", "Hint mission when path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const readme = slice.readmePath ? fs.readFileSync(slice.readmePath, "utf8") : null;
const children = fs.readdirSync(slice.absPath, { withFileTypes: true })
.map((e) => ({ name: e.name, kind: e.isDirectory() ? "dir" : "file" as const }));
const payload = {
ok: true,
slice: {
mission: slice.missionName,
name: slice.name,
id: slice.id,
status: slice.status,
path: slice.absPath,
frontmatter: slice.frontmatter,
readme,
children,
},
};
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
} else {
out.write(`Slice: ${slice.missionName}/${slice.name}\n`);
out.write(` id: ${slice.id ?? "—"}\n`);
out.write(` status: ${slice.status ?? "—"}\n`);
out.write(` path: ${slice.absPath}\n`);
out.write(` children: ${children.length}\n`);
if (readme) {
out.write("\n--- README ---\n");
out.write(readme);
if (!readme.endsWith("\n")) out.write("\n");
}
}
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice create
// ---------------------------------------------------------------------
function buildSliceCreateCommand(): Command {
return new Command("create")
.description("Create a new slice in a mission")
.argument("<mission>", "Mission name")
.argument("<slug>", "Short slug (becomes the folder name's suffix)")
.option("--template <kind>", `Template: ${SLICE_TEMPLATE_KINDS.join(" | ")}`, "placeholder")
.option("--title <text>", "Display title (defaults to titlecased slug)")
.option("--json", "Machine-readable output")
.action(async (missionName: string, rawSlug: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const kind = opts.template as SliceTemplateKind;
if (!SLICE_TEMPLATE_KINDS.includes(kind)) {
throw new ScopeCliError({
fact: `Unknown --template kind "${kind}".`,
consequence: "Slice not created.",
action: `Pick one of: ${SLICE_TEMPLATE_KINDS.join(", ")}.`,
});
}
const slug = slugify(rawSlug);
if (!slug) {
throw new ScopeCliError({
fact: `Slug "${rawSlug}" reduces to empty after slugification.`,
consequence: "Slice not created.",
action: "Pick a slug containing letters or digits.",
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const mission = findMission(missionsRoot, missionName);
// Persist the parent mission's id back into its README at the
// SAME moment we mint the child's id (per convention §1
// lazy-adoption rule + guard BC BLOCK 3 — every child-mint site
// must persist the parent's id, not just slice create).
const missionId = ensureMissionIdPersisted(mission, missionsRoot);
const nn = nextSliceNN(mission.absPath);
const sliceFolder = `${pad2(nn)}-${slug}`;
const sliceAbs = path.join(mission.absPath, "slices", sliceFolder);
if (fs.existsSync(sliceAbs)) {
throw new ScopeCliError({
fact: `Slice folder ${sliceAbs} already exists.`,
consequence: "Refusing to overwrite.",
action: "Pick a different slug, or rm -rf the existing folder first.",
});
}
const id = sliceIdFromMission(missionId, nn);
const title = opts.title ?? titleFromSlug(slug);
const body = renderSliceTemplate(kind, {
id,
slice_number: pad2(nn),
slug,
mission: mission.name,
title,
created_date: todayDateISO(),
});
fs.mkdirSync(sliceAbs, { recursive: true });
const readmePath = path.join(sliceAbs, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
const payload = {
ok: true,
slice: {
mission: mission.name,
name: sliceFolder,
id,
path: sliceAbs,
readmePath,
template: kind,
},
};
emit(out, payload, json, [
`Created ${mission.name}/slices/${sliceFolder}`,
` id: ${id}`,
` template: ${kind}`,
` path: ${sliceAbs}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice ship
// ---------------------------------------------------------------------
function buildSliceShipCommand(): Command {
return new Command("ship")
.description("Ship a slice to a release mission (preserves git history)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.argument("<release-mission>", "Target release mission name")
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, releaseMission: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const target = findMission(missionsRoot, releaseMission);
const targetSlicesDir = path.join(target.absPath, "slices");
fs.mkdirSync(targetSlicesDir, { recursive: true });
const newNN = nextSliceNN(target.absPath);
const slug = slice.slug ?? slugify(slice.name);
const newName = `${pad2(newNN)}-${slug}`;
const destAbs = path.join(targetSlicesDir, newName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const targetId = ensureMissionIdPersisted(target, missionsRoot);
const newSliceId = sliceIdFromMission(targetId, newNN);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
updateFrontmatter(newReadme, {
id: newSliceId,
mission: target.name,
status: `shipped-to-${target.name}`,
"shipped-on": todayDateISO(),
"shipped-from": slice.missionName,
});
}
emit(out, {
ok: true,
shipped: {
from: { mission: slice.missionName, name: slice.name, id: slice.id },
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
git: { usedGit, repoRoot },
},
}, json, [
`Shipped ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
` id: ${newSliceId}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice close
// ---------------------------------------------------------------------
function buildSliceCloseCommand(): Command {
return new Command("close")
.description("Close a slice (move to <mission>/closed/, update status)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.requiredOption("--reason <reason>", `Closure reason: ${CLOSE_REASONS.join(" | ")}`)
.option("--note <text>", "Optional closure note")
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const reason = opts.reason as CloseReason;
if (!CLOSE_REASONS.includes(reason)) {
throw new ScopeCliError({
fact: `Unknown --reason "${reason}".`,
consequence: "Slice not closed.",
action: `Pick one of: ${CLOSE_REASONS.join(", ")}.`,
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const mission = findMission(missionsRoot, slice.missionName);
const closedDir = path.join(mission.absPath, "closed");
fs.mkdirSync(closedDir, { recursive: true });
const destName = slice.name;
const destAbs = path.join(closedDir, destName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
const updates: Record<string, unknown> = {
status: `closed-${reason}`,
"closed-on": todayDateISO(),
};
if (opts.note) updates["closure-note"] = opts.note;
updateFrontmatter(newReadme, updates);
}
emit(out, {
ok: true,
closed: {
mission: slice.missionName,
name: destName,
id: slice.id,
reason,
note: opts.note ?? null,
path: destAbs,
git: { usedGit, repoRoot },
},
}, json, [
`Closed ${slice.missionName}/${slice.name} → ${slice.missionName}/closed/${destName}`,
` reason: ${reason}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope slice move
// ---------------------------------------------------------------------
function buildSliceMoveCommand(): Command {
return new Command("move")
.description("Move a slice between missions (re-numbers in destination)")
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
.argument("<dest-mission>", "Destination mission name")
.option("--mission <name>", "Hint source mission when slice-path is just NN-slug")
.option("--json", "Machine-readable output")
.action(async (slicePath: string, destMission: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
const target = findMission(missionsRoot, destMission);
const targetSlicesDir = path.join(target.absPath, "slices");
fs.mkdirSync(targetSlicesDir, { recursive: true });
const newNN = nextSliceNN(target.absPath);
const slug = slice.slug ?? slugify(slice.name);
const newName = `${pad2(newNN)}-${slug}`;
const destAbs = path.join(targetSlicesDir, newName);
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
const targetId = ensureMissionIdPersisted(target, missionsRoot);
const newSliceId = sliceIdFromMission(targetId, newNN);
const newReadme = path.join(destAbs, "README.md");
if (fs.existsSync(newReadme)) {
updateFrontmatter(newReadme, {
id: newSliceId,
mission: target.name,
"moved-on": todayDateISO(),
"moved-from": slice.missionName,
});
}
emit(out, {
ok: true,
moved: {
from: { mission: slice.missionName, name: slice.name, id: slice.id },
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
git: { usedGit, repoRoot },
},
}, json, [
`Moved ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
` id: ${newSliceId}`,
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// rig scope mission ls / show / create
// ---------------------------------------------------------------------
function buildMissionLsCommand(): Command {
return new Command("ls")
.description("List missions (top-level folders with README.md)")
.option("--json", "Machine-readable output")
.action(async (opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const missions = listMissions(missionsRoot);
const rows = missions.map((m) => ({
name: m.name,
id: m.id,
path: m.absPath,
activeSliceCount: m.activeSliceCount,
closedSliceCount: m.closedSliceCount,
}));
emit(out, { ok: true, count: rows.length, missions: rows }, json,
rows.length === 0
? ["(no missions)"]
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
);
} catch (err) {
fail(err, json, out);
}
});
}
function buildMissionShowCommand(): Command {
return new Command("show")
.description("Inspect a single mission")
.argument("<mission>", "Mission name")
.option("--json", "Machine-readable output")
.action(async (missionName: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const mission = findMission(missionsRoot, missionName);
const readme = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null;
const slices = listSlices(mission, "all").map((s) => ({
name: s.name, id: s.id, status: s.status, nn: s.nn,
}));
const payload = {
ok: true,
mission: {
name: mission.name,
id: mission.id,
path: mission.absPath,
activeSliceCount: mission.activeSliceCount,
closedSliceCount: mission.closedSliceCount,
frontmatter: mission.frontmatter,
readme,
slices,
},
};
if (json) {
out.write(JSON.stringify(payload, null, 2) + "\n");
} else {
out.write(`Mission: ${mission.name}\n`);
out.write(` id: ${mission.id ?? "—"}\n`);
out.write(` active slices: ${mission.activeSliceCount}\n`);
out.write(` closed slices: ${mission.closedSliceCount}\n`);
out.write(` path: ${mission.absPath}\n`);
if (readme) {
out.write("\n--- README ---\n");
out.write(readme);
if (!readme.endsWith("\n")) out.write("\n");
}
}
} catch (err) {
fail(err, json, out);
}
});
}
function buildMissionCreateCommand(): Command {
return new Command("create")
.description("Create a new mission (mints a stable dot-ID into frontmatter)")
.argument("<name>", "Mission folder name (e.g., release-0.4.0, backlog-foo)")
.option("--template <kind>", `Template: ${MISSION_TEMPLATE_KINDS.join(" | ")} (auto when name matches release-X.Y.Z)`, "")
.option("--id <dot-id>", "Explicit dot-ID. Overrides name-pattern inference.")
.option("--title <text>", "Display title (defaults to titlecased name)")
.option("--json", "Machine-readable output")
.action(async (rawName: string, opts, command) => {
const out = makeStdout();
const json = Boolean(opts.json);
try {
const name = rawName.trim();
if (!name || /[\\/\s]/.test(name)) {
throw new ScopeCliError({
fact: `Invalid mission name "${rawName}".`,
consequence: "Mission not created.",
action: "Pick a name with no whitespace or path separators.",
});
}
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
const absPath = path.join(missionsRoot, name);
if (fs.existsSync(absPath)) {
throw new ScopeCliError({
fact: `Mission folder ${absPath} already exists.`,
consequence: "Refusing to overwrite.",
action: "Pick a different name, or use `rig scope mission show <name>` to inspect the existing mission.",
});
}
// Resolve template kind: explicit > release-pattern auto > placeholder.
const isReleaseName = /^release-\d+\.\d+(?:\.\d+)?$/.test(name);
let templateKind: MissionTemplateKind = opts.template as MissionTemplateKind;
if (!templateKind) templateKind = isReleaseName ? "release" : "placeholder";
if (!MISSION_TEMPLATE_KINDS.includes(templateKind)) {
throw new ScopeCliError({
fact: `Unknown --template kind "${templateKind}".`,
consequence: "Mission not created.",
action: `Pick one of: ${MISSION_TEMPLATE_KINDS.join(", ")}.`,
});
}
// Mint the dot-ID.
let id: string;
if (opts.id) {
// Tier-aware validation per guard BC verdict (BLOCK 1).
// A mission ID has 2-3 numeric segments after the prefix
// (release X.Y or X.Y.Z; escape-band 99.x.y). Reject
// slice-shaped IDs (4 segments) so the parent identity stays
// unambiguous.
if (!isMissionDotId(opts.id)) {
throw new ScopeCliError({
fact: `Supplied --id "${opts.id}" is not a mission-tier dot-ID.`,
consequence: "Mission not created. A mission ID has the shape <PFX>.<ver> (2-3 numeric segments), not a slice shape <PFX>.<ver>.<n>.",
action: "Use a mission-shaped dot-ID like OPR.0.3.2 (release) or OPR.99.0.1 (escape band). For slice IDs, scope automatically mints them when you create a slice.",
});
}
id = opts.id;
} else if (isReleaseName) {
id = inferMissionDotId(name, null);
} else {
const peers = listMissions(missionsRoot);
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
id = inferMissionDotId(name, ordinal);
}
// Scaffold the mission directory + README.
fs.mkdirSync(absPath, { recursive: true });
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
const body = renderMissionTemplate(templateKind, {
id,
slug: name,
mission: name,
title,
created_date: todayDateISO(),
release_version: releaseVersion,
});
const readmePath = path.join(absPath, "README.md");
fs.writeFileSync(readmePath, body, "utf8");
emit(out, {
ok: true,
mission: {
name,
id,
template: templateKind,
path: absPath,
readmePath,
},
}, json, [
`Created mission ${name}`,
` id: ${id}`,
` template: ${templateKind}`,
` path: ${absPath}`,
]);
} catch (err) {
fail(err, json, out);
}
});
}
// ---------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------
export function scopeCommand(): Command {
const cmd = new Command("scope")
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
.option("--workspace <path>", "Override workspace root (otherwise inferred from cwd or $OPENRIG_WORK_ROOT)");
const slice = new Command("slice").description("Slice-tier commands");
slice.addCommand(buildSliceLsCommand());
slice.addCommand(buildSliceShowCommand());
slice.addCommand(buildSliceCreateCommand());
slice.addCommand(buildSliceShipCommand());
slice.addCommand(buildSliceCloseCommand());
slice.addCommand(buildSliceMoveCommand());
cmd.addCommand(slice);
const mission = new Command("mission").description("Mission-tier commands");
mission.addCommand(buildMissionLsCommand());
mission.addCommand(buildMissionShowCommand());
mission.addCommand(buildMissionCreateCommand());
cmd.addCommand(mission);
return cmd;
}
// Re-exports for tests.
export { DEFAULT_PROJECT_PREFIX, splitFrontmatter };
+1 -35
View File
@@ -6,42 +6,9 @@ import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
import { readOpenRigEnv } from "../openrig-compat.js";
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
const SENDER_FALLBACK = "<unknown sender>";
/**
* Wrap a `rig send` body with an email-style envelope so the recipient
* pane has both the sender's identity and a copy-pasteable reply hint.
* Cross-host sends do NOT wrap locally: the remote rig wraps when it
* runs the same command, and double-wrapping would nest envelopes.
*
* V0.3.1 slice 23 parity contract: `packages/daemon/src/lib/pane-envelope.ts`
* exports `wrapPaneEnvelope` with BYTE-IDENTICAL output for the same
* inputs. The two implementations live in separate packages because
* cli + daemon don't cross-import today. Daemon-side nudges from
* `rig queue create|handoff` use the daemon helper so queue nudges
* render the same envelope as peer-to-peer `rig send`. If you update
* this function, update wrapPaneEnvelope in lockstep.
*/
export function wrapSendBody(sender: string | undefined, recipient: string, body: string): string {
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
return [
`From: ${senderLabel}`,
`To: ${recipient}`,
"---",
body,
"---",
`↩ Reply: rig send ${senderLabel} "..."`,
].join("\n");
}
function resolveSenderSession(): string | undefined {
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
}
export interface SendDeps extends StatusDeps {
/**
* Cross-host hooks. Both default to the production loaders/executors; tests
@@ -119,9 +86,8 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
}
const client = deps.clientFactory(getDaemonUrl(status));
const wrappedText = wrapSendBody(resolveSenderSession(), session, text);
const res = await client.post<Record<string, unknown>>("/api/transport/send", {
session, text: wrappedText, verify: opts.verify, force: opts.force, waitForIdleMs,
session, text, verify: opts.verify, force: opts.force, waitForIdleMs,
}, waitForIdleRequestOptions(waitForIdleMs));
if (opts.json) {
+10 -15
View File
@@ -20,7 +20,7 @@ import type { StatusDeps } from "./status.js";
interface LibraryEntry {
id: string;
kind: "rig" | "agent" | "workflow";
kind: string;
name: string;
version: string;
sourceType: string;
@@ -145,9 +145,8 @@ function installSpecSource(source: AddSpecSource, userRoot: string): string {
export async function resolveLibrarySpec(
client: DaemonClient,
nameOrId: string,
opts?: { kind?: LibraryEntry["kind"] },
): Promise<LibraryEntry> {
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
const res = await client.get<LibraryEntry[]>("/api/specs/library");
const entries = res.data ?? [];
// Try exact ID match first
@@ -159,16 +158,14 @@ export async function resolveLibrarySpec(
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
const candidates = byName.map((e) => ` ${e.id} (${e.kind}) — ${e.sourcePath}`).join("\n");
const scope = opts?.kind ? ` ${opts.kind}` : "";
const candidates = byName.map((e) => ` ${e.id} — ${e.sourcePath}`).join("\n");
throw new Error(
`Spec name '${nameOrId}' is ambiguous — ${byName.length}${scope} entries match.\nUse the ID instead:\n${candidates}`
`Spec name '${nameOrId}' is ambiguous — ${byName.length} entries match.\nUse the ID instead:\n${candidates}`
);
}
const scope = opts?.kind ? ` ${opts.kind}` : "";
throw new Error(
`Spec '${nameOrId}' not found in${scope} library. Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.`
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
);
}
@@ -198,7 +195,7 @@ Examples:
// specs ls
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--kind <kind>", "Filter by kind (rig or agent)")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
@@ -230,12 +227,11 @@ Examples:
cmd.command("show")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show spec metadata and path")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
@@ -258,12 +254,11 @@ Examples:
cmd.command("preview")
.argument("<name-or-id>", "Spec name or library ID")
.description("Show structured spec review, including managed app details")
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const entry = await resolveLibrarySpec(client, nameOrId);
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
if (opts.json) {
+6 -31
View File
@@ -29,9 +29,8 @@ Examples:
.option("--yes", "Auto-approve trusted actions")
.option("--cwd <path>", "Override launch working directory for all members for this run only")
.option("--target <root>", "Target root directory for package installation (.rigbundle only; does not change agent cwd)")
.option("--existing", "Treat <source> as an existing rig name; bypass library-spec name resolution")
.option("--json", "JSON output for agents")
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; json?: boolean }) => {
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; json?: boolean }) => {
const deps = getDepsF();
// Run preflight before auto-start
@@ -40,17 +39,8 @@ Examples:
let resolvedConfig: {
daemon: { port: number; host: string };
db: { path: string };
transcripts: { enabled: boolean; path: string; lines: number; pollIntervalSeconds: number };
workspace: { root: string };
transcripts: { enabled: boolean; path: string };
} | null = null;
// bug-fix slice auth-bearer-tailscale-trust: track whether
// daemon.host was operator-explicit (env or config file) vs
// default-fallback. The daemon's multi-bind path (loopback +
// tailscale auto-detect) only runs when OPENRIG_HOST is NOT
// exported to the child, so we omit it on the default path.
// Hoisted to function scope so the startDaemon block below can
// read it after the preflight try-catch.
let hostForDaemon: string | undefined;
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight } = await import("../system-preflight.js");
@@ -58,10 +48,6 @@ Examples:
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
const configStore = new ConfigStore();
resolvedConfig = configStore.resolve();
const hostResolution = configStore.resolveWithSource("daemon.host");
hostForDaemon = hostResolution.source === "default"
? undefined
: resolvedConfig.daemon.host;
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
const preflight = new SystemPreflight({
@@ -89,13 +75,10 @@ Examples:
try {
await startDaemon({
port: resolvedConfig?.daemon.port,
host: hostForDaemon,
host: resolvedConfig?.daemon.host,
db: resolvedConfig?.db.path,
transcriptsEnabled: resolvedConfig?.transcripts.enabled,
transcriptsPath: resolvedConfig?.transcripts.path,
transcriptsLines: resolvedConfig?.transcripts.lines,
transcriptsPollIntervalSeconds: resolvedConfig?.transcripts.pollIntervalSeconds,
workspaceRoot: resolvedConfig?.workspace.root,
}, deps.lifecycleDeps);
status = await getDaemonStatus(deps.lifecycleDeps);
} catch (err) {
@@ -132,10 +115,10 @@ Examples:
}
return rigSummariesCache;
};
if (isRigName && !opts.existing) {
if (isRigName) {
try {
const { resolveLibrarySpec } = await import("./specs.js");
const entry = await resolveLibrarySpec(client, source, { kind: "rig" });
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 rigSummaries = await fetchRigSummaries();
@@ -171,19 +154,11 @@ Examples:
const summaries = await fetchRigSummaries();
const match = summaries.find((r) => r.name === source);
if (match?.lifecycleState === "recoverable") {
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
console.log(`Recovering rig "${source}" from latest snapshot...`);
} else if (match?.lifecycleState === "stopped") {
console.log(`Turning on rig "${source}"...`);
}
}
} else if (isRigName && opts.existing && !opts.json) {
const summaries = await fetchRigSummaries();
const match = summaries.find((r) => r.name === source);
if (match?.lifecycleState === "recoverable") {
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
} else {
console.log(`Turning on existing rig "${source}"...`);
}
}
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
+4 -6
View File
@@ -198,13 +198,11 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
}
const client = deps.clientFactory(getDaemonUrl(status));
const params = new URLSearchParams();
if (source.nodeId) params.set("nodeId", source.nodeId);
else params.set("sessionName", source.sessionName!);
const targetRepo = readOpenRigEnv("OPENRIG_TARGET_REPO", "RIGGED_TARGET_REPO");
if (targetRepo) params.set("targetRepo", targetRepo);
const query = source.nodeId
? `nodeId=${encodeURIComponent(source.nodeId)}`
: `sessionName=${encodeURIComponent(source.sessionName!)}`;
const res = await client.get<Record<string, unknown>>(`/api/whoami?${params.toString()}`);
const res = await client.get<Record<string, unknown>>(`/api/whoami?${query}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
-41
View File
@@ -140,47 +140,6 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
});
});
// List cached workflow_specs (NOT instances). The existing
// `rig workflow list` lists instances; this is the complementary
// surface for inspecting which specs are registered, including any
// built-in starter(s) shipped at daemon startup.
// Built-in rows display a `(built-in)` indicator in human output and
// an `isBuiltIn: true` field in JSON output.
cmd
.command("specs")
.description("List registered workflow specs; built-in starters tagged with (built-in)")
.option("--json", "JSON output for agents")
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<{ specs: Array<{
name: string; version: string; purpose: string | null;
targetRig: string | null; coordinationTerminalTurnRule: string;
sourcePath: string; cachedAt: string; isBuiltIn: boolean;
}> }>("/api/workflow/specs");
if (opts.json) {
printResult(true, res.data, res.status);
return;
}
if (res.status >= 400) {
printResult(false, res.data, res.status);
return;
}
const rows = res.data.specs ?? [];
if (rows.length === 0) {
console.log("No workflow specs registered.");
return;
}
// Compact human table — name, version, source, indicator.
for (const row of rows) {
const indicator = row.isBuiltIn ? " (built-in)" : "";
console.log(`${row.name} v${row.version}${indicator}`);
if (row.purpose) console.log(` purpose: ${row.purpose.replace(/\n/g, " ").slice(0, 120)}`);
console.log(` source: ${row.sourcePath}`);
}
});
});
cmd
.command("show <instanceId>")
.description("Show one workflow instance")
-127
View File
@@ -1,127 +0,0 @@
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
//
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
// parses each .md file's YAML frontmatter, and emits a structured gap
// report. Advisory only — never modifies. curate-steward consumes the
// report.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import * as path from "node:path";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export interface WorkspaceDeps extends StatusDeps {}
interface ValidationGap {
filePath: string;
relativePath: string;
kind: string;
field: string | null;
message: string;
workspaceKind: string | null;
}
interface ValidationReport {
root: string;
workspaceKind: string | null;
totalFiles: number;
filesWithFrontmatter: number;
gapCount: number;
gaps: ValidationGap[];
}
async function withClient<T>(
deps: WorkspaceDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
const cmd = new Command("workspace").description(
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
);
const getDeps = (): WorkspaceDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("validate [root]")
.description(
"Walk a workspace root, parse each .md file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: cwd.",
)
.option("--kind <kind>", "Workspace kind to validate against: user | project | knowledge | lab | delivery")
.option("--no-recursive", "Do not descend into subdirectories")
.option("--require-frontmatter", "Report a gap for every .md file without a frontmatter delimiter")
.option("--max-files <n>", "Hard cap on .md files walked", "10000")
.option("--json", "JSON output for agents")
.action(
async (
rootArg: string | undefined,
opts: {
kind?: string;
recursive?: boolean;
requireFrontmatter?: boolean;
maxFiles: string;
json?: boolean;
},
) => {
const root = path.resolve(rootArg ?? process.cwd());
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<ValidationReport>("/api/workspace/validate", {
root,
workspaceKind: opts.kind,
recursive: opts.recursive !== false,
requireFrontmatter: opts.requireFrontmatter ?? false,
maxFiles: Number.parseInt(opts.maxFiles, 10),
});
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
process.exitCode = 1;
return;
}
const report = res.data;
if (opts.json) {
console.log(JSON.stringify(report));
} else {
renderHumanReport(report);
}
// Exit non-zero when gaps found — operators chain into hygiene fix loops.
if (report.gapCount > 0) process.exitCode = 1;
});
},
);
return cmd;
}
function renderHumanReport(r: ValidationReport): void {
console.log(`workspace root: ${r.root}`);
console.log(`workspace kind: ${r.workspaceKind ?? "(none — kind-agnostic structural check)"}`);
console.log(`files walked: ${r.totalFiles}`);
console.log(`with frontmatter: ${r.filesWithFrontmatter}`);
console.log(`gaps: ${r.gapCount}`);
if (r.gapCount === 0) {
console.log("\n no gaps — canon is clean against v0 contract.");
return;
}
console.log("\n Gaps:");
for (const g of r.gaps) {
const fieldStr = g.field ? ` [${g.field}]` : "";
console.log(` [${g.kind}] ${g.relativePath}${fieldStr}`);
console.log(` ${g.message}`);
}
}
+45 -629
View File
@@ -1,358 +1,47 @@
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { userInfo } from "node:os";
import {
getCompatibleOpenRigPath,
getDefaultOpenRigPath,
readOpenRigEnv,
} from "./openrig-compat.js";
// User Settings v0 — extends the existing ConfigStore with new namespaces
// (workspace.*, files.*, progress.*) without changing the existing 5
// daemon/db/transcripts keys' behavior. Storage stays single-source-of-
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
export interface RiggedConfig {
daemon: { port: number; host: string };
db: { path: string };
transcripts: {
enabled: boolean;
path: string;
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
// SC-29 EXCEPTION #4 declared in pre-release ACK §5: same allowlist
// shape as the Phase 4 / Phase 5 prior exceptions.
lines: number;
pollIntervalSeconds: number;
};
// User Settings v0 — workspace paths.
// V0.3.1 slice 05 kernel-rig-as-default — operatorSeatName carries the
// workspace's operator seat session name (default
// `operator-${USER}@kernel`). Read by mission-control read layer and
// 2 UI cosmetic sites to replace the legacy hardcoded
// "human-operator@kernel" constant.
workspace: {
root: string;
slicesRoot: string;
steeringPath: string;
fieldNotesRoot: string;
specsRoot: string;
dogfoodEvidenceRoot: string;
operatorSeatName: string;
};
// User Settings v0 — UEP env-var graduation.
// Values are stored as raw named-pair strings ("name:/abs/path,...")
// matching the OPENRIG_FILES_ALLOWLIST / OPENRIG_PROGRESS_SCAN_ROOTS
// formats; decoded helpers (parseNamedPairs) turn them into structured
// arrays.
files: {
allowlist: string;
};
progress: {
scanRoots: string;
};
// Preview Terminal v0 (PL-018) — UI-side preferences for the live
// terminal preview pane.
ui: {
preview: {
refreshIntervalSeconds: number;
maxPins: number;
defaultLines: number;
};
};
recovery: {
autoDriveProviderPrompts: boolean;
providerAuthEnvAllowlist: string;
};
// V1 attempt-3 Phase 4 — Advisor / Operator rail icon V1 placeholders
// per universal-shell.md L82–L84. SC-29 EXCEPTION: allowlist-only
// additions (no schema migrations / new endpoints / event types).
agents: {
advisorSession: string;
operatorSession: string;
};
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles per
// for-you-feed.md L144–L151. SC-29 EXCEPTION declared in Phase 5
// dispatch ACK §5 DRIFT P5-D2: same scope as Phase 4 (allowlist-only;
// no migrations / new endpoints / event types).
feed: {
subscriptions: {
actionRequired: boolean;
approvals: boolean;
shipped: boolean;
progress: boolean;
auditLog: boolean;
};
};
// plugin-primitive Phase 3a slice 3.5 — runtime feature flags. Currently
// single-flag for Codex; extracts to its own primitive workspace if/when
// 3+ flags accumulate (per DESIGN.md §5.8).
runtime: {
codex: {
hooksEnabled: boolean;
};
};
// Slice 27 — Claude auto-compaction policy. Operator-configurable
// pre-compaction trigger: when a Claude seat's context usage crosses
// `thresholdPercent`, daemon sends a pre-compact prep prompt, then
// sends /compact via SessionTransport and passes `compactInstruction`
// as slash-command args for the actual compaction phase. The existing
// PreCompact hook records `messageInline` plus contents of
// `messageFilePath` alongside the standard restore-instructions in
// the post-compact marker/context. The daemon then sends
// `postRestoreAuditInstruction` as the editable read-depth nudge.
//
// Defaults: opt-in default-off (enabled=false). The compaction
// instruction ships as inline text. The post-compaction restore prompt
// defaults to loading the canonical restore skill plus a user-owned
// extra instruction file path for mission-specific reading lists.
policies: {
claudeCompaction: {
enabled: boolean;
thresholdPercent: number;
preCompactInstruction: string;
compactInstruction: string;
messageInline: string;
messageFilePath: string;
postRestoreAuditInstruction: string;
};
};
transcripts: { enabled: boolean; path: string };
}
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
const DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"If You Are About To Compact\" protocol. Create or update the mental-model restore map before compaction. If you are in the middle of a tiny atomic step, finish that step first; otherwise this preparation is the next priority.";
const DEFAULT_CLAUDE_COMPACTION_COMPACT_INSTRUCTION = "";
const DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"If You Just Compacted\" protocol.";
const DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"Required Read-Depth Audit\" protocol.";
const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigPath(
"compaction/post-compact-extra.md",
);
const DEFAULTS = {
const DEFAULTS: RiggedConfig = {
daemon: { port: 7433, host: "127.0.0.1" },
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts"), lines: 1000, pollIntervalSeconds: 2 },
workspace: {
root: DEFAULT_WORKSPACE_ROOT,
slicesRoot: "",
steeringPath: "",
fieldNotesRoot: "",
specsRoot: "",
dogfoodEvidenceRoot: "",
// V0.3.1 slice 05 — empty default; resolve() picks up the derived
// `operator-${USER}@kernel` at read time via the deriveWorkspaceDefault
// helper so the default tracks the OS username without caching at
// module-load time.
operatorSeatName: "",
},
files: { allowlist: "" },
progress: { scanRoots: "" },
ui: {
preview: {
refreshIntervalSeconds: 3,
maxPins: 4,
defaultLines: 50,
},
},
recovery: {
autoDriveProviderPrompts: false,
providerAuthEnvAllowlist: "",
},
// V1 Phase 4 — Advisor default per universal-shell.md L83;
// Operator default empty per L84 ("not configured").
agents: {
advisorSession: "advisor-lead@openrig-velocity",
operatorSession: "",
},
// V1 Phase 5 P5-3 — feed subscription defaults per for-you-feed.md
// L144–L151. action_required forced ON in UI (load-bearing
// human-gate items per L145; cannot be disabled); approvals/
// shipped/progress default ON; audit_log default OFF (verbose;
// opt-in for triage runs).
feed: {
subscriptions: {
actionRequired: true,
approvals: true,
shipped: true,
progress: true,
auditLog: false,
},
},
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag default ON.
runtime: {
codex: {
hooksEnabled: true,
},
},
// Slice 27 — opt-in default-off. Threshold default 80% per spec.
policies: {
claudeCompaction: {
enabled: false,
thresholdPercent: 80,
preCompactInstruction: DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION,
compactInstruction: DEFAULT_CLAUDE_COMPACTION_COMPACT_INSTRUCTION,
messageInline: DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION,
messageFilePath: DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH,
postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION,
},
},
} as const;
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts") },
};
export const VALID_KEYS = [
const VALID_KEYS = [
"daemon.port",
"daemon.host",
"db.path",
"transcripts.enabled",
"transcripts.path",
// V1 pre-release CLI/daemon Item 1 — SC-29 EXCEPTION #4 allowlist
// sub-piece: transcript rotation tunables (line count + poll interval).
"transcripts.lines",
"transcripts.poll_interval_seconds",
"workspace.root",
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"ui.preview.refresh_interval_seconds",
"ui.preview.max_pins",
"ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 Phase 4 SC-29 exception — allowlist-only additions.
"agents.advisor_session",
"agents.operator_session",
// V0.3.1 slice 05 kernel-rig-as-default — operator seat name used by
// mission-control read layer + 2 UI sites; default
// `operator-${USER}@kernel` derived in resolve(). OPENRIG_* only;
// no RIGGED_* legacy alias.
"workspace.operator_seat_name",
// V1 Phase 5 P5-3 SC-29 exception — allowlist-only additions.
"feed.subscriptions.action_required",
"feed.subscriptions.approvals",
"feed.subscriptions.shipped",
"feed.subscriptions.progress",
"feed.subscriptions.audit_log",
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
// 7 ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
"policies.claude_compaction.compact_instruction",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
] as const;
export type ValidKey = typeof VALID_KEYS[number];
type ValidKey = typeof VALID_KEYS[number];
export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
// Only the original runtime keys keep RIGGED_* aliases for upgrade
// compatibility. New typed keys use OPENRIG_* only.
const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
"transcripts.lines": { primary: "OPENRIG_TRANSCRIPTS_LINES" },
"transcripts.poll_interval_seconds": { primary: "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS" },
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT" },
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT" },
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH" },
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT" },
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT" },
"workspace.dogfood_evidence_root": { primary: "OPENRIG_DOGFOOD_EVIDENCE_ROOT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
// OPENRIG_PROGRESS_SCAN_ROOTS become the env override for the new
// typed keys (no breaking change).
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST" },
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS" },
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION" },
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION" },
"workspace.operator_seat_name": { primary: "OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME" },
"feed.subscriptions.action_required": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED" },
"feed.subscriptions.approvals": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS" },
"feed.subscriptions.shipped": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED" },
"feed.subscriptions.progress": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS" },
"feed.subscriptions.audit_log": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG" },
// Net-new key post-rename: OPENRIG_X primary only per the 5-key
// boundary doctrine (no RIGGED_X legacy on net-new keys).
"runtime.codex.hooks_enabled": { primary: "OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED" },
// Slice 27 — Claude auto-compaction policy. OPENRIG_X primary only
// (net-new keys, no legacy).
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
"policies.claude_compaction.pre_compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION" },
"policies.claude_compaction.compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_COMPACT_INSTRUCTION" },
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
};
// Maps dotted-string config keys to the camelCase RiggedConfig path.
// Workspace per-subdir keys are stored on disk as `workspace.slices_root`
// (snake) and exposed in RiggedConfig as `workspace.slicesRoot` (camel)
// to match TS conventions.
const KEY_TO_PATH: Record<ValidKey, string[]> = {
"daemon.port": ["daemon", "port"],
"daemon.host": ["daemon", "host"],
"db.path": ["db", "path"],
"transcripts.enabled": ["transcripts", "enabled"],
"transcripts.path": ["transcripts", "path"],
"transcripts.lines": ["transcripts", "lines"],
"transcripts.poll_interval_seconds": ["transcripts", "pollIntervalSeconds"],
"workspace.root": ["workspace", "root"],
"workspace.slices_root": ["workspace", "slicesRoot"],
"workspace.steering_path": ["workspace", "steeringPath"],
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
"workspace.specs_root": ["workspace", "specsRoot"],
"workspace.dogfood_evidence_root": ["workspace", "dogfoodEvidenceRoot"],
"files.allowlist": ["files", "allowlist"],
"progress.scan_roots": ["progress", "scanRoots"],
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
"agents.advisor_session": ["agents", "advisorSession"],
"agents.operator_session": ["agents", "operatorSession"],
"workspace.operator_seat_name": ["workspace", "operatorSeatName"],
"feed.subscriptions.action_required": ["feed", "subscriptions", "actionRequired"],
"feed.subscriptions.approvals": ["feed", "subscriptions", "approvals"],
"feed.subscriptions.shipped": ["feed", "subscriptions", "shipped"],
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
"policies.claude_compaction.pre_compact_instruction": ["policies", "claudeCompaction", "preCompactInstruction"],
"policies.claude_compaction.compact_instruction": ["policies", "claudeCompaction", "compactInstruction"],
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
};
function isValidKey(key: string): key is ValidKey {
return (VALID_KEYS as readonly string[]).includes(key);
}
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
const parts = key.split(".");
let current: unknown = obj;
for (const part of parts) {
if (current == null || typeof current !== "object") return undefined;
@@ -361,7 +50,8 @@ function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown
return current;
}
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
function setNestedValue(obj: Record<string, unknown>, key: string, value: unknown): void {
const parts = key.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
@@ -373,58 +63,12 @@ function setNestedValue(obj: Record<string, unknown>, parts: string[], value: un
current[parts[parts.length - 1]!] = value;
}
// Per-subdir defaults derived from workspace.root. Steering is a FILE path.
// Slice discovery defaults to the mission-aware workspace/missions contract;
// the indexer remains backward-compatible with flat slice roots when an
// operator sets workspace.slices_root explicitly.
// Files and Progress default to the whole workspace so a fresh
// `rig config init-workspace` install is browsable without extra env wiring.
export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): string {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "missions");
case "workspace.steering_path": return join(workspaceRoot, "STEERING.md");
case "workspace.field_notes_root": return join(workspaceRoot, "field-notes");
case "workspace.specs_root": return join(workspaceRoot, "specs");
case "workspace.dogfood_evidence_root": return join(workspaceRoot, "dogfood-evidence");
case "files.allowlist": return `workspace:${workspaceRoot}`;
case "progress.scan_roots": return `workspace:${workspaceRoot}`;
// V0.3.1 slice 05 — `operator-${USER}@kernel` derived from the
// OS username at resolve() time. Lockstep with the daemon's
// settings-store getDefaultValue("workspace.operator_seat_name")
// case so both sides resolve to the same default.
case "workspace.operator_seat_name": return `operator-${userInfo().username}@kernel`;
default: return "";
}
function getDefaultValue(key: ValidKey): string | number | boolean {
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, key) as string | number | boolean;
}
function deriveLegacyWorkspaceDefault(key: ValidKey, workspaceRoot: string): string | null {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "slices");
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
default: return null;
}
}
const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"workspace.operator_seat_name",
]);
function getDefaultValue(key: ValidKey, workspaceRoot: string): string | number | boolean {
if (WORKSPACE_DERIVED_KEYS.has(key)) {
return deriveWorkspaceDefault(key, workspaceRoot);
}
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, KEY_TO_PATH[key]) as string | number | boolean;
}
function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
const defaultVal = getDefaultValue(key, workspaceRoot);
function coerceValue(key: ValidKey, raw: string): string | number | boolean {
const defaultVal = getDefaultValue(key);
if (typeof defaultVal === "number") {
const n = parseInt(raw, 10);
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
@@ -438,76 +82,6 @@ function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string
return raw;
}
// Slice 27 — strict per-key constraint validators applied AFTER coerceValue
// in `set()`. The generic coerce uses parseInt which accepts partial
// parses (e.g., "80abc" → 80) and truncates fractions ("80.5" → 80); for
// keys with documented range/integer contracts this is unsafe. Per
// banked feedback_static_gates_mirror_runtime_validators, the runtime
// validator is the source of truth and must reject what the contract
// forbids.
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
// Policy threshold: integer in [1, 100]. Documented contract from
// slice 27 README §"What the operator gets" — operator can lower to
// e.g. 50 = compact earlier; range is 1-100 inclusive. A value of 0
// would fire /compact on every poll tick (catastrophic without
// default-off as backstop).
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (typeof coerced !== "number" || !Number.isInteger(coerced)) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
);
}
if (coerced < 1 || coerced > 100) {
throw new Error(
`Invalid value for policies.claude_compaction.threshold_percent: must be in [1, 100], got ${coerced}`,
);
}
},
};
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
const check = KEY_CONSTRAINTS[key];
if (check) check(raw, coerced);
}
// Slice 27 BLOCKING-FIX-2 — shared coerce + validate, used by every
// surface that turns operator-supplied input into a typed config value:
// set() (CLI + daemon write paths), resolveOne env-source branch, and
// (indirectly via validateTypedValue) the resolveOne file-source branch.
// Per banked feedback_audit_every_layer_function_and_module_constants:
// validation must run at every LAYER that ingests untrusted input, not
// just at write-time.
function coerceAndValidate(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
const coerced = coerceValue(key, raw, workspaceRoot);
validateKeyConstraints(key, raw, coerced);
return coerced;
}
// File-source values arrive already typed (JSON-parsed). The constraint
// closure expects (raw, coerced) to drive its regex check too; pass the
// stringified value so a string-typed file value (e.g., "80abc" written
// directly into config.json) still trips the regex.
function validateTypedFileValue(key: ValidKey, value: string | number | boolean): void {
const check = KEY_CONSTRAINTS[key];
if (!check) return;
const raw = typeof value === "string" ? value : String(value);
check(raw, value);
}
export type SettingSource = "env" | "file" | "default";
export interface ResolvedSetting {
value: string | number | boolean;
source: SettingSource;
defaultValue: string | number | boolean;
}
export class ConfigStore {
readonly configPath: string;
@@ -518,192 +92,62 @@ export class ConfigStore {
resolve(): RiggedConfig {
const fileConfig = this.readConfigFile();
// Workspace root must be resolved first so derived per-subdir
// defaults can use it.
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const v = (key: ValidKey) =>
this.resolveOne(key, fileConfig, workspaceRoot).value;
const resolveKey = (key: ValidKey): string | number | boolean => {
// 1. Environment variable
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
return coerceValue(key, envVal);
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, key);
if (fileVal !== undefined && fileVal !== null) {
return fileVal as string | number | boolean;
}
// 3. Default
return getDefaultValue(key);
};
return {
daemon: {
port: v("daemon.port") as number,
host: v("daemon.host") as string,
port: resolveKey("daemon.port") as number,
host: resolveKey("daemon.host") as string,
},
db: {
path: v("db.path") as string,
path: resolveKey("db.path") as string,
},
transcripts: {
enabled: v("transcripts.enabled") as boolean,
path: v("transcripts.path") as string,
lines: v("transcripts.lines") as number,
pollIntervalSeconds: v("transcripts.poll_interval_seconds") as number,
},
workspace: {
root: workspaceRoot,
slicesRoot: v("workspace.slices_root") as string,
steeringPath: v("workspace.steering_path") as string,
fieldNotesRoot: v("workspace.field_notes_root") as string,
specsRoot: v("workspace.specs_root") as string,
dogfoodEvidenceRoot: v("workspace.dogfood_evidence_root") as string,
operatorSeatName: v("workspace.operator_seat_name") as string,
},
files: {
allowlist: v("files.allowlist") as string,
},
progress: {
scanRoots: v("progress.scan_roots") as string,
},
ui: {
preview: {
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
maxPins: v("ui.preview.max_pins") as number,
defaultLines: v("ui.preview.default_lines") as number,
},
},
recovery: {
autoDriveProviderPrompts: v("recovery.auto_drive_provider_prompts") as boolean,
providerAuthEnvAllowlist: v("recovery.provider_auth_env_allowlist") as string,
},
agents: {
advisorSession: v("agents.advisor_session") as string,
operatorSession: v("agents.operator_session") as string,
},
feed: {
subscriptions: {
actionRequired: v("feed.subscriptions.action_required") as boolean,
approvals: v("feed.subscriptions.approvals") as boolean,
shipped: v("feed.subscriptions.shipped") as boolean,
progress: v("feed.subscriptions.progress") as boolean,
auditLog: v("feed.subscriptions.audit_log") as boolean,
},
},
runtime: {
codex: {
hooksEnabled: v("runtime.codex.hooks_enabled") as boolean,
},
},
policies: {
claudeCompaction: {
enabled: v("policies.claude_compaction.enabled") as boolean,
thresholdPercent: v("policies.claude_compaction.threshold_percent") as number,
preCompactInstruction: v("policies.claude_compaction.pre_compact_instruction") as string,
compactInstruction: v("policies.claude_compaction.compact_instruction") as string,
messageInline: v("policies.claude_compaction.message_inline") as string,
messageFilePath: v("policies.claude_compaction.message_file_path") as string,
postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string,
},
enabled: resolveKey("transcripts.enabled") as boolean,
path: resolveKey("transcripts.path") as string,
},
};
}
/**
* Resolve a single key with its source. Used by the daemon HTTP route
* + UI Settings panel for honest provenance display (env / file /
* default).
*/
resolveWithSource(key: string): ResolvedSetting {
get(key: string): string | number | boolean {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
return this.resolveOne(key, fileConfig, workspaceRoot);
}
/** Resolve all valid keys with sources. Convenience for the UI. */
resolveAllWithSource(): Record<ValidKey, ResolvedSetting> {
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const out = {} as Record<ValidKey, ResolvedSetting>;
for (const key of VALID_KEYS) {
out[key] = this.resolveOne(key, fileConfig, workspaceRoot);
}
return out;
}
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
const defaultValue = getDefaultValue(key, workspaceRoot);
// 1. Environment variable — validate. On invalid env, drop the
// override and fall through to file/default (safer-failure than
// crashing or accepting a bad value). Warn so the operator sees
// the misconfigured env on stderr.
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
try {
return { value: coerceAndValidate(key, envVal, workspaceRoot), source: "env", defaultValue };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[openrig-config] env override for ${key} rejected: ${reason}; falling back to file/default\n`,
);
}
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH[key]);
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
const legacyDefault = deriveLegacyWorkspaceDefault(key, workspaceRoot);
if (legacyDefault !== null && fileVal === legacyDefault) {
return { value: defaultValue, source: "default", defaultValue };
}
// Validate the file-source value too. Hand-edited config.json with
// a bad threshold (e.g. thresholdPercent: 0 or "80abc") falls back
// to default rather than poisoning the trigger contract.
try {
validateTypedFileValue(key, fileVal as string | number | boolean);
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
process.stderr.write(
`[openrig-config] file value for ${key} rejected: ${reason}; falling back to default\n`,
);
}
}
// 3. Default
return { value: defaultValue, source: "default", defaultValue };
}
get(key: string): string | number | boolean {
return this.resolveWithSource(key).value;
const config = this.resolve();
return getNestedValue(config as unknown as Record<string, unknown>, key) as string | number | boolean;
}
set(key: string, value: string): void {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const coerced = coerceValue(key, value);
// readConfigFile (not Raw) — throws on malformed config so set doesn't silently overwrite
const fileConfig = this.readConfigFile();
const workspaceRoot = (getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined)
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|| DEFAULT_WORKSPACE_ROOT;
const coerced = coerceAndValidate(key, value, workspaceRoot);
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
setNestedValue(fileConfig, key, coerced);
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
/**
* Clear an override. Without a key argument, deletes the whole config
* file (revert all to defaults). With a key, removes just that key
* from the config file.
*/
reset(key?: string): void {
if (key === undefined) {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
reset(): void {
try {
unlinkSync(this.configPath);
} catch {
// File doesn't exist — that's fine
}
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
if (!existsSync(this.configPath)) return;
const fileConfig = this.readConfigFile();
const parts = KEY_TO_PATH[key];
const parentParts = parts.slice(0, -1);
const leaf = parts[parts.length - 1]!;
const parent = getNestedValue(fileConfig, parentParts) as Record<string, unknown> | undefined;
if (parent && leaf in parent) {
delete parent[leaf];
}
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
private readConfigFile(): Record<string, unknown> {
@@ -717,33 +161,5 @@ export class ConfigStore {
);
}
}
}
// --- User Settings v0: named-pair decoder ---
//
// `files.allowlist` and `progress.scan_roots` are stored as the same
// comma-separated `name:/abs/path` strings UEP introduced via env var.
// This helper decodes the raw string into structured pairs. Invalid
// entries (no colon, empty name/path) are silently skipped per UEP
// convention; duplicate names: last wins.
export interface NamedPair {
name: string;
path: string;
}
export function parseNamedPairs(raw: string): NamedPair[] {
if (!raw || !raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
out.set(name, rawPath);
}
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
}
+8 -168
View File
@@ -3,7 +3,6 @@ import { existsSync } from "node:fs";
import type { ChildProcess } from "node:child_process";
import { ConfigStore } from "./config-store.js";
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
import { workspaceScaffoldDirs, workspaceScaffoldFiles } from "./commands/config-init-workspace.js";
export interface DaemonState {
pid: number;
@@ -32,18 +31,6 @@ export interface StartOptions {
db?: string;
transcriptsEnabled?: boolean;
transcriptsPath?: string;
/** When set, daemon startup idempotently ensures the default workspace exists. */
workspaceRoot?: string;
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
// Threaded through to the daemon process env so the rotation hook
// picks up file-stored ConfigStore values, not just shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
// V0.3.1 slice 05 kernel-rig-as-default — when true, daemon startup
// skips the kernel auto-boot path (kernel rig is not materialized).
// Exposed at the CLI as `rig daemon start --no-kernel`; projected
// into the daemon process env as OPENRIG_NO_KERNEL.
skipKernelBoot?: boolean;
}
export interface LifecycleDeps {
@@ -77,45 +64,6 @@ const HEALTHZ_RETRIES = 20;
const HEALTHZ_DELAY_MS = 250;
const HEALTHZ_PROBE_TIMEOUT_MS = 250;
export interface WorkspaceScaffoldResult {
root: string;
rootCreated: boolean;
subdirs: Array<{ name: string; path: string; created: boolean }>;
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
}
export function ensureWorkspaceScaffold(root: string, deps: LifecycleDeps): WorkspaceScaffoldResult {
const rootExists = deps.exists(root);
if (!rootExists) deps.mkdirp(root);
const result: WorkspaceScaffoldResult = {
root,
rootCreated: !rootExists,
subdirs: [],
files: [],
};
for (const sub of workspaceScaffoldDirs()) {
const subPath = path.join(root, sub);
const existed = deps.exists(subPath);
if (!existed) deps.mkdirp(subPath);
result.subdirs.push({ name: sub, path: subPath, created: !existed });
}
for (const file of workspaceScaffoldFiles()) {
const absPath = path.join(root, file.relPath);
const existed = deps.exists(absPath);
if (existed) {
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
continue;
}
deps.writeFile(absPath, file.content);
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
}
return result;
}
class HealthProbeTimeoutError extends Error {
constructor(url: string) {
super(`healthz probe timed out for ${url}`);
@@ -245,31 +193,7 @@ const ENV_SCRUB_EXACT = new Set([
export function buildDaemonEnv(
baseEnv: Record<string, string>,
opts: {
port: number;
/**
* Operator-explicit bind host. When undefined the CLI is signaling
* "no operator opt-in" so the daemon's index.ts can fall through to
* the default loopback+tailscale-auto multi-bind path (bug-fix slice
* auth-bearer-tailscale-trust). When defined, the daemon takes the
* explicit-host branch and applies the bearer invariant to it.
* If undefined here AND the operator shell already has OPENRIG_HOST
* set in baseEnv, the env-passthrough loop preserves that value
* (their shell-level opt-in still wins).
*/
host?: string;
db: string;
transcriptsEnabled?: boolean;
transcriptsPath?: string;
// V1 pre-release CLI/daemon Item 1 — projected from ConfigStore so
// the rotation hook honors file-stored values, not just inherited
// shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
// V0.3.1 slice 05 — projected via OPENRIG_NO_KERNEL env var so
// the daemon's startup.ts kernel-boot path honors the flag.
skipKernelBoot?: boolean;
},
opts: { port: number; host: string; db: string; transcriptsEnabled?: boolean; transcriptsPath?: string },
): Record<string, string> {
const env: Record<string, string> = {};
@@ -281,9 +205,7 @@ export function buildDaemonEnv(
// Explicit OPENRIG_* overrides — always win over inherited values
env["OPENRIG_PORT"] = String(opts.port);
if (opts.host !== undefined) {
env["OPENRIG_HOST"] = opts.host;
}
env["OPENRIG_HOST"] = opts.host;
env["OPENRIG_DB"] = opts.db;
if (opts.transcriptsEnabled !== undefined) {
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
@@ -291,30 +213,14 @@ export function buildDaemonEnv(
if (opts.transcriptsPath) {
env["OPENRIG_TRANSCRIPTS_PATH"] = opts.transcriptsPath;
}
if (opts.transcriptsLines !== undefined) {
env["OPENRIG_TRANSCRIPTS_LINES"] = String(opts.transcriptsLines);
}
if (opts.transcriptsPollIntervalSeconds !== undefined) {
env["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"] = String(opts.transcriptsPollIntervalSeconds);
}
if (opts.skipKernelBoot) {
env["OPENRIG_NO_KERNEL"] = "1";
}
return env;
}
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? DEFAULT_HOST;
const db = opts.db ?? DEFAULT_DB;
// bug-fix slice auth-bearer-tailscale-trust: preserve the
// user-explicit-vs-default distinction. `opts.host` is undefined when
// the operator never opted in; in that case healthz probes still use
// the loopback default, but buildDaemonEnv must NOT export
// OPENRIG_HOST so the daemon falls through to the multi-bind default
// (loopback + tailscale auto-detect).
const explicitHost = opts.host;
const probeHost = explicitHost ?? DEFAULT_HOST;
// Check if already running
const existing = readState(deps);
@@ -331,7 +237,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${probeHost}:${port}/healthz`);
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
@@ -345,22 +251,16 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
deps.mkdirp(OPENRIG_DIR);
if (opts.workspaceRoot) {
ensureWorkspaceScaffold(opts.workspaceRoot, deps);
}
const logFd = deps.openForAppend(LOG_FILE);
const child = deps.spawn(process.execPath, [daemonEntry], {
env: buildDaemonEnv(process.env as Record<string, string>, {
port,
host: explicitHost,
host,
db,
transcriptsEnabled: opts.transcriptsEnabled,
transcriptsPath: opts.transcriptsPath,
transcriptsLines: opts.transcriptsLines,
transcriptsPollIntervalSeconds: opts.transcriptsPollIntervalSeconds,
skipKernelBoot: opts.skipKernelBoot,
}),
stdio: ["ignore", logFd, logFd],
detached: true,
@@ -370,9 +270,8 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const pid = child.pid!;
// Poll healthz against the configured probe host (loopback default
// when operator didn't opt in to a specific bind).
const healthzUrl = `http://${probeHost}:${port}/healthz`;
// Poll healthz
const healthzUrl = `http://${host}:${port}/healthz`;
let healthy = false;
for (let i = 0; i < HEALTHZ_RETRIES; i++) {
try {
@@ -396,10 +295,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const state: DaemonState = {
pid,
port,
// Persist the probe host (operator-explicit when set, loopback
// default otherwise) so subsequent status reads can reach the
// daemon regardless of whether tailscale was added at multi-bind.
host: probeHost,
host,
db,
startedAt: new Date().toISOString(),
};
@@ -536,59 +432,3 @@ async function fetchDaemonProbe(deps: LifecycleDeps, url: string): Promise<{ ok:
}),
]);
}
// V0.3.1 slice 05 kernel-rig-as-default — forward-fix #3 architectural.
// Poll GET /api/kernel/status until kernel_state reaches ready or
// partial_ready (the two "operator can use the kernel" states) or the
// timeout elapses. Used by `rig daemon start --wait-for-kernel`.
//
// Returns { ok: true, ... } on success; { ok: false, ... } with the
// last observed kernelState + detail for the CLI's 3-part error.
export interface KernelReadyResult {
ok: boolean;
kernelState: string | null;
variant: string | null;
detail: string | null;
}
export async function waitForKernelReady(
baseUrl: string,
timeoutMs: number,
pollIntervalMs = 500,
): Promise<KernelReadyResult> {
const deadline = Date.now() + timeoutMs;
let last: KernelReadyResult = { ok: false, kernelState: null, variant: null, detail: null };
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseUrl}/api/kernel/status`);
if (res.ok) {
const body = (await res.json()) as {
kernel_state?: string;
variant?: string | null;
detail?: string | null;
};
last = {
ok: body.kernel_state === "ready" || body.kernel_state === "partial_ready",
kernelState: body.kernel_state ?? null,
variant: body.variant ?? null,
detail: body.detail ?? null,
};
if (last.ok) return last;
// Terminal non-ready states: don't keep polling.
if (
body.kernel_state === "auth_blocked" ||
body.kernel_state === "spec_missing" ||
body.kernel_state === "bootstrap_failed" ||
body.kernel_state === "degraded" ||
body.kernel_state === "skipped"
) {
return last;
}
}
} catch {
// Transient fetch failure; keep polling until the deadline.
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
return last;
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function defaultHostRegistryPath(): string {
* - id: vm-claude-test
* transport: ssh
* target: vm-claude-test.local
* user: your-username # optional
* user: wrandom # optional
* notes: "Tart VM" # optional
*
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
-15
View File
@@ -17,7 +17,6 @@ import { attachCommand } from "./commands/attach.js";
import { bindCommand } from "./commands/bind.js";
import { adoptCommand, type AdoptDeps } from "./commands/adopt.js";
import { bundleCommand } from "./commands/bundle.js";
import { scopeCommand } from "./commands/scope.js";
import { upCommand } from "./commands/up.js";
import { downCommand } from "./commands/down.js";
import { psCommand } from "./commands/ps.js";
@@ -42,10 +41,6 @@ import { envCommand } from "./commands/env.js";
import { askCommand } from "./commands/ask.js";
import { chatroomCommand } from "./commands/chatroom.js";
import { specsCommand } from "./commands/specs.js";
import { contextPackCommand } from "./commands/context-pack.js";
import { pluginCommand } from "./commands/plugin.js";
import { agentImageCommand } from "./commands/agent-image.js";
import { workspaceCommand, type WorkspaceDeps } from "./commands/workspace.js";
import { whoamiCommand } from "./commands/whoami.js";
import { unclaimCommand } from "./commands/unclaim.js";
import { releaseCommand } from "./commands/release.js";
@@ -98,10 +93,6 @@ export interface ProgramDeps {
askDeps?: StatusDeps;
chatroomDeps?: StatusDeps;
specsDeps?: StatusDeps;
contextPackDeps?: StatusDeps;
pluginDeps?: StatusDeps;
agentImageDeps?: StatusDeps;
workspaceDeps?: WorkspaceDeps;
whoamiDeps?: StatusDeps;
expandDeps?: StatusDeps;
envDeps?: StatusDeps;
@@ -160,10 +151,6 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(askCommand(depsOverride?.askDeps));
program.addCommand(chatroomCommand(depsOverride?.chatroomDeps));
program.addCommand(specsCommand(depsOverride?.specsDeps));
program.addCommand(contextPackCommand(depsOverride?.contextPackDeps));
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
program.addCommand(configCommand(depsOverride?.configPath));
program.addCommand(preflightCommand());
@@ -183,8 +170,6 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
program.addCommand(seatCommand(depsOverride?.seatDeps));
// release-0.3.2 slice 12 — rig scope CLI primitive.
program.addCommand(scopeCommand());
return program;
}
@@ -1,26 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Target
[What is being deprecated]
## Current state
[How it works today; what depends on it]
## Migration
[Path off the deprecated thing]
## Removal
[When/how the deprecated thing gets deleted]
@@ -1,22 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Current state
[How the code/system looks today]
## Cost
[What this debt costs us — surface friction, bug class, perf, etc]
## Proposal
[How to pay it down]
@@ -1,30 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Repro
[Steps to reproduce]
## Expected
[Expected behavior]
## Actual
[Actual behavior]
## Impact
[Who is affected; severity]
## Fix proposal
[Optional: thinking on the fix]
@@ -1,20 +0,0 @@
---
id: {{id}}
mission: {{mission}}
stage: wip
created: {{created_date}}
---
# Mission — {{title}}
## Purpose
[Why this mission exists and what success looks like]
## Slices
[List notable slices and their state]
## Status
[Where the mission is right now]
@@ -1,25 +0,0 @@
---
id: {{id}}
mission: {{mission}}
release: {{release_version}}
stage: wip
created: {{created_date}}
---
# Release {{release_version}} — {{title}}
## Theme
[What this release is about — the coherent bet]
## Scope
[What ships in v{{release_version}} vs what waits]
## Slices
[Ordering + dependencies; PROGRESS.md tracks state]
## Acceptance
[Release-level done definition]
@@ -1,30 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Goal
[1-2 sentence goal.]
## Source material
- [Paths or refs]
## Status
- TODO: [next steps]
## Dependencies
- [Cross-slice / cross-release]
## Acceptance
- [3-5 bullets]
@@ -1,26 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Goal
[What this feature ships and why now]
## Scope
[In / out for v0]
## Acceptance
- [3-5 bullets]
## Risks
[Known unknowns]
@@ -1,22 +0,0 @@
---
id: {{id}}
slice: {{slice_number}}-{{slug}}
mission: {{mission}}
status: placeholder
stage: wip
created: {{created_date}}
---
# Slice {{slice_number}} — {{title}}
## Question
[The thing we are trying to learn]
## Source
[Where the answer is most likely to come from]
## Findings
[Captured as work progresses]
-173
View File
@@ -1,173 +0,0 @@
// release-0.3.2 slice 12 — dot-ID grammar per
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
//
// Grammar (v0 single-project):
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
// mission = <PFX>.<ver>
// slice = <PFX>.<ver>.<n>
// sub = <PFX>.<ver>.<n>.<m>
//
// Version inference from mission folder name:
// release-X.Y[.Z] → "X.Y[.Z]" (release train)
// anything else → "99.0.<m>" escape band (uniform-numeric;
// NO alpha — convention §1 explicit)
//
// The escape-band <m> is assigned by scanning peer non-release missions
// for an existing id and picking max+1. Releases keep their semver.
import type { DotId } from "./types.js";
import { ScopeCliError } from "./types.js";
/** Default project prefix for the OpenRig single-project workspace.
* Multi-project work waits for a real second project (convention §1). */
export const DEFAULT_PROJECT_PREFIX = "OPR";
const RELEASE_NAME_RE = /^release-(\d+\.\d+(?:\.\d+)?)$/;
// Strict positional grammar: every segment is a number except for the
// 2-3 letter project prefix at position 0. Accepts 2-5 numeric
// segments after the prefix (mission has 2-3, slice has 3-4, sub-slice
// has 4-5). Tier-aware parse helpers below split version/n/m correctly.
const DOT_ID_RE = /^([A-Z]{2,3})\.(\d+(?:\.\d+){1,4})$/;
/** Parse a dot-ID string into structured parts; returns null when the
* string doesn't conform to the §1 positional grammar. When `tier`
* is supplied, the trailing segments are peeled off into `n` (slice)
* or `n` + `m` (sub-slice). With no tier, all numeric segments are
* returned as `version` for backwards compatibility. */
export function parseDotId(
raw: string,
tier?: "mission" | "slice" | "sub-slice",
): DotId | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
const m = DOT_ID_RE.exec(trimmed);
if (!m) return null;
const project = m[1]!;
const segments = m[2]!.split(".");
if (!tier) {
return { project, version: segments.join("."), n: undefined, m: undefined };
}
if (tier === "mission") {
return { project, version: segments.join("."), n: undefined, m: undefined };
}
if (tier === "slice") {
if (segments.length < 3) return null; // slice = ver(>=2) + n
const verSegs = segments.slice(0, -1);
const nSeg = segments[segments.length - 1]!;
return { project, version: verSegs.join("."), n: Number(nSeg), m: undefined };
}
// sub-slice
if (segments.length < 4) return null;
const verSegs = segments.slice(0, -2);
const nSeg = segments[segments.length - 2]!;
const mSeg = segments[segments.length - 1]!;
return { project, version: verSegs.join("."), n: Number(nSeg), m: Number(mSeg) };
}
/** Validate a mission-ver segments array against the §1 escape-band
* rule. The convention defines the non-release escape band as
* `<PFX>.99.0.<n>` — the `0` after `99` is FIXED. A version like
* `99.7.8` does NOT conform. Shared between isMissionDotId and the
* parent-version check inside isSliceDotId so both surfaces stay in
* lockstep. */
function isValidMissionVerSegments(segs: string[]): boolean {
if (segs.length < 2 || segs.length > 3) return false;
if (segs[0] === "99") {
// Escape band: exactly [99, "0", "<ordinal>"]. The `0` segment is
// fixed; `<ordinal>` must be a non-empty numeric string.
return segs.length === 3 && segs[1] === "0" && /^\d+$/.test(segs[2] ?? "");
}
return true;
}
/** Depth-based tier discriminator. Per §1 the positional grammar
* is fixed:
* mission = <PFX>.<ver> (ver = 2-3 numeric segments;
* escape band is exactly 99.0.n)
* slice = <PFX>.<ver>.<n> (3-4 numeric segments)
* sub-slice = <PFX>.<ver>.<n>.<m> (4-5 numeric segments)
* Where a mission shape OVERLAPS with a slice shape (e.g. release
* X.Y mission has 2 segments; release X.Y.Z mission has 3 segments
* which is also the slice shape for release X.Y missions) the tier
* is resolved by depth: mission caps at 3 numeric segments; slice
* always has 3-4; sub-slice has 4-5. Escape band is fully unambiguous
* because §1 fixes both the 99 marker AND the 0 segment that follows. */
export function isMissionDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "mission");
if (!parsed) return false;
return isValidMissionVerSegments(parsed.version.split("."));
}
export function isSliceDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "slice");
if (!parsed) return false;
// Slice = mission-ver + 1 ordinal. Parent ver must itself be a
// valid mission shape (including the escape-band exact-[99.0.n]
// rule — a parent like 99.7.8 is not a real mission).
return isValidMissionVerSegments(parsed.version.split("."));
}
/** Render a DotId back to its canonical dot-string. */
export function formatDotId(id: DotId): string {
const parts = [id.project, id.version];
if (id.n !== undefined) parts.push(String(id.n));
if (id.m !== undefined) parts.push(String(id.m));
return parts.join(".");
}
/** Compose a slice ID by appending an ordinal to a parent mission ID. */
export function sliceIdFromMission(missionId: string, n: number): string {
return `${missionId}.${n}`;
}
/** Infer the (project, version) pair from a mission folder name when
* no explicit id is supplied. Release missions use their semver; any
* other name falls into the escape band where the caller supplies the
* next ordinal via the second arg. */
export function inferMissionDotId(
missionFolderName: string,
escapeBandOrdinal: number | null,
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
): string {
const releaseMatch = RELEASE_NAME_RE.exec(missionFolderName);
if (releaseMatch) {
return `${projectPrefix}.${releaseMatch[1]}`;
}
if (escapeBandOrdinal === null) {
throw new ScopeCliError({
fact: `Mission "${missionFolderName}" doesn't match the release-X.Y[.Z] pattern and no escape-band ordinal was supplied.`,
consequence: "No dot-ID could be inferred for this mission.",
action: "Pass an explicit ordinal, or rename the mission to release-X.Y.Z, or supply --id on the CLI.",
});
}
return `${projectPrefix}.99.0.${escapeBandOrdinal}`;
}
/** Pick the next escape-band ordinal by scanning peer mission IDs.
* Looks for `<PFX>.99.0.<m>` shapes and returns max(m)+1, starting
* at 1 when no peer exists. */
export function nextEscapeBandOrdinal(
existingIds: ReadonlyArray<string | null>,
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
): number {
let max = 0;
const re = new RegExp(`^${projectPrefix}\\.99\\.0\\.(\\d+)$`);
for (const id of existingIds) {
if (!id) continue;
const m = re.exec(id);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > max) max = n;
}
return max + 1;
}
/** Does this candidate string parse as a valid §1 dot-ID at ANY
* tier? Prefer the tier-specific isMissionDotId/isSliceDotId at use
* sites where the tier is known — depth alone doesn't disambiguate
* mission vs slice for release IDs with overlapping shapes. */
export function isConformantDotId(raw: unknown): boolean {
return typeof raw === "string" && parseDotId(raw) !== null;
}
-517
View File
@@ -1,517 +0,0 @@
// release-0.3.2 slice 12 — filesystem + git helpers shared across
// `rig scope` verbs. Mission/slice discovery, frontmatter parse/write,
// auto-numbering, git mv wrapper. Keeps slice/mission command files
// thin.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
import type {
MissionInfo,
SliceInfo,
SliceState,
} from "./types.js";
import { ScopeCliError } from "./types.js";
import {
DEFAULT_PROJECT_PREFIX,
inferMissionDotId,
nextEscapeBandOrdinal,
} from "./dot-id.js";
const FRONTMATTER_DELIM = "---\n";
const SLICE_DIRNAME_RE = /^(\d+)-(.+)$/;
// ---------------------------------------------------------------------
// Frontmatter parse + write
// ---------------------------------------------------------------------
/** Split a markdown file into [frontmatter, body]. Returns `[{}, content]`
* when no frontmatter delimiter is present. */
export function splitFrontmatter(content: string): {
frontmatter: Record<string, unknown>;
body: string;
} {
if (!content.startsWith(FRONTMATTER_DELIM)) {
return { frontmatter: {}, body: content };
}
const rest = content.slice(FRONTMATTER_DELIM.length);
const endIdx = rest.indexOf(`\n${FRONTMATTER_DELIM.trim()}\n`);
if (endIdx === -1) {
// Allow trailing `---` without a final newline (last char of file).
const endIdx2 = rest.indexOf(`\n---`);
if (endIdx2 === -1) return { frontmatter: {}, body: content };
const raw = rest.slice(0, endIdx2);
return { frontmatter: parseYamlSafely(raw), body: rest.slice(endIdx2 + 4).replace(/^\n/, "") };
}
const raw = rest.slice(0, endIdx);
const body = rest.slice(endIdx + `\n${FRONTMATTER_DELIM.trim()}\n`.length);
return { frontmatter: parseYamlSafely(raw), body };
}
function parseYamlSafely(raw: string): Record<string, unknown> {
try {
const parsed = YAML.parse(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/** Re-stitch frontmatter + body into a single markdown string. */
export function joinFrontmatter(
frontmatter: Record<string, unknown>,
body: string,
): string {
const yaml = YAML.stringify(frontmatter, { lineWidth: 0 }).trim();
const trailing = body.startsWith("\n") ? "" : "\n";
return `---\n${yaml}\n---\n${trailing}${body}`;
}
/** Read + parse frontmatter from a markdown file. Returns `{}` if the
* file is missing OR has no frontmatter (graceful for placeholder
* missions that pre-date the convention). */
export function readFrontmatter(absPath: string): Record<string, unknown> {
try {
return splitFrontmatter(fs.readFileSync(absPath, "utf8")).frontmatter;
} catch {
return {};
}
}
/** Update specific keys in a markdown file's frontmatter. Existing
* unknown keys are preserved. Generates minimal frontmatter when
* absent. */
export function updateFrontmatter(
absPath: string,
updates: Record<string, unknown>,
): void {
const original = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
const { frontmatter, body } = splitFrontmatter(original);
const merged = { ...frontmatter, ...updates };
fs.writeFileSync(absPath, joinFrontmatter(merged, body), "utf8");
}
// ---------------------------------------------------------------------
// Mission discovery
// ---------------------------------------------------------------------
/** Locate the missions root for the working substrate. Resolution
* order:
* 1. Explicit override (env or arg)
* 2. $OPENRIG_WORK_ROOT/missions
* 3. Search up from cwd for a `missions/` folder
*/
export function resolveMissionsRoot(opts: {
override?: string | null;
cwd?: string;
} = {}): string {
const cwd = opts.cwd ?? process.cwd();
const fromOverride = opts.override ?? process.env.OPENRIG_WORK_ROOT;
if (fromOverride) {
const candidate = path.isAbsolute(fromOverride) ? fromOverride : path.resolve(cwd, fromOverride);
const missions = path.join(candidate, "missions");
if (fs.existsSync(missions) && fs.statSync(missions).isDirectory()) return missions;
if (path.basename(candidate) === "missions" && fs.existsSync(candidate)) return candidate;
}
let dir = cwd;
for (let i = 0; i < 8; i++) {
const candidate = path.join(dir, "missions");
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new ScopeCliError({
fact: `Could not locate a missions/ root from cwd ${cwd}.`,
consequence: "No mission tree to operate on.",
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/substrate/shared-docs/openrig-work.",
});
}
/** List mission folders under the missions root. A mission is any
* top-level folder containing a README.md (per IMPLEMENTATION-PRD
* §2.1 / HG-8). Directories without a README.md are skipped — they
* represent scratch/junk, not declared missions. */
export function listMissions(missionsRoot: string): MissionInfo[] {
if (!fs.existsSync(missionsRoot)) return [];
const entries = fs.readdirSync(missionsRoot, { withFileTypes: true });
const out: MissionInfo[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const absPath = path.join(missionsRoot, entry.name);
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
if (!hasReadme) continue;
const frontmatter = readFrontmatter(readmePath);
const slicesDir = path.join(absPath, "slices");
const closedDir = path.join(absPath, "closed");
const activeSliceCount = countSliceDirs(slicesDir);
const closedSliceCount = countSliceDirs(closedDir);
const id = pickIdFromFrontmatter(frontmatter);
out.push({
name: entry.name,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id,
activeSliceCount,
closedSliceCount,
});
}
// Stable order: by mission name.
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function countSliceDirs(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
.length;
}
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
const candidate = fm.id ?? fm.dotId;
return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
}
/** Resolve a mission by name OR path relative to the missions root.
* Throws ScopeCliError on miss OR when the directory exists but
* lacks a README.md (consistent with listMissions' README gate per
* PRD §2.1 / HG-8 — README-less dirs are scratch/junk, not declared
* missions, so mutation commands must not silently target them). */
export function findMission(missionsRoot: string, identifier: string): MissionInfo {
const candidates = [
path.join(missionsRoot, identifier),
path.isAbsolute(identifier) ? identifier : path.resolve(missionsRoot, identifier),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
const readmePath = path.join(candidate, "README.md");
if (!fs.existsSync(readmePath)) {
throw new ScopeCliError({
fact: `Directory "${identifier}" exists at ${candidate} but contains no README.md.`,
consequence: "It is not a declared mission. Command did not run.",
action: "Create it as a mission with: rig scope mission create " + identifier + ". Or add a README.md if the folder is intended to be a mission.",
});
}
return buildMissionInfo(missionsRoot, candidate);
}
}
throw new ScopeCliError({
fact: `Mission "${identifier}" not found under ${missionsRoot}.`,
consequence: "Command did not run.",
action: "List available missions with: rig scope mission ls",
});
}
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
return {
name: path.basename(absPath),
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
id: pickIdFromFrontmatter(frontmatter),
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
};
}
// ---------------------------------------------------------------------
// Slice discovery
// ---------------------------------------------------------------------
export function listSlices(
mission: MissionInfo,
state: SliceState,
): SliceInfo[] {
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
if (state === "active" || state === "shipped" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
}
if (state === "closed" || state === "all") {
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
}
const out: SliceInfo[] = [];
for (const { root } of dirs) {
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const sliceInfo = buildSliceInfo(mission, root, entry.name);
out.push(sliceInfo);
}
}
if (state === "active") {
// active = not in closed/, but also not shipped status.
return out.filter((s) => {
const st = (s.status ?? "").toLowerCase();
return !st.startsWith("closed") && !st.startsWith("shipped");
});
}
if (state === "shipped") {
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
}
return out;
}
function buildSliceInfo(mission: MissionInfo, sliceRoot: string, dirName: string): SliceInfo {
const m = SLICE_DIRNAME_RE.exec(dirName);
const nn = m ? Number(m[1]) : null;
const slug = m ? m[2]! : null;
const absPath = path.join(sliceRoot, dirName);
const readmePath = path.join(absPath, "README.md");
const hasReadme = fs.existsSync(readmePath);
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
const id = pickIdFromFrontmatter(frontmatter);
const status = typeof frontmatter.status === "string" ? (frontmatter.status as string).toLowerCase() : null;
return {
name: dirName,
absPath,
readmePath: hasReadme ? readmePath : null,
frontmatter,
nn,
slug,
missionName: mission.name,
id,
status,
};
}
/** Resolve a slice path (absolute, relative-to-substrate, or
* relative-to-mission) into a SliceInfo. */
export function findSlice(
missionsRoot: string,
slicePath: string,
hintMission?: string | null,
): SliceInfo {
const candidates: string[] = [];
if (path.isAbsolute(slicePath)) {
candidates.push(slicePath);
} else {
candidates.push(path.resolve(missionsRoot, "..", slicePath));
candidates.push(path.resolve(missionsRoot, slicePath));
if (hintMission) {
candidates.push(path.join(missionsRoot, hintMission, "slices", slicePath));
candidates.push(path.join(missionsRoot, hintMission, "closed", slicePath));
}
}
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
// Walk up to find the owning mission.
const owningMissionPath = findOwningMission(missionsRoot, candidate);
if (!owningMissionPath) {
throw new ScopeCliError({
fact: `Slice path "${slicePath}" resolved to ${candidate} but no parent mission was found.`,
consequence: "Cannot determine mission context for this slice.",
action: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/.",
});
}
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
const sliceRoot = path.dirname(candidate);
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
}
}
throw new ScopeCliError({
fact: `Slice "${slicePath}" not found.`,
consequence: "Command did not run.",
action: "Check the path. List slices in a mission with: rig scope slice ls --mission <name>",
});
}
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
let dir = path.dirname(slicePath);
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
if (path.dirname(dir) === missionsRoot) return dir;
dir = path.dirname(dir);
}
return null;
}
// ---------------------------------------------------------------------
// Auto-numbering
// ---------------------------------------------------------------------
/** Find the next available NN for a mission's slices/ folder. Scans
* BOTH slices/ AND closed/ so numbers are never reused (§3.2). */
export function nextSliceNN(missionAbsPath: string): number {
let max = 0;
for (const subdir of ["slices", "closed"]) {
const root = path.join(missionAbsPath, subdir);
if (!fs.existsSync(root)) continue;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const m = SLICE_DIRNAME_RE.exec(entry.name);
if (!m) continue;
const n = Number(m[1]);
if (Number.isFinite(n) && n > max) max = n;
}
}
return max + 1;
}
/** Mint or read the dot-ID for a mission. If the mission's README has
* an `id:` field, return it. Otherwise infer from the folder name
* (release-X.Y.Z → OPR.X.Y.Z; otherwise escape band). */
export function ensureMissionId(
mission: MissionInfo,
missionsRoot: string,
): string {
if (mission.id) return mission.id;
// Try release-pattern first; if it matches, no peer scan needed.
try {
const id = inferMissionDotId(mission.name, null);
return id;
} catch {
// Fall through to escape-band path.
}
const peers = listMissions(missionsRoot).filter((m) => m.name !== mission.name);
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
return inferMissionDotId(mission.name, ordinal);
}
/** Same as ensureMissionId, but ALSO writes the inferred id back into
* the mission's README frontmatter when it was absent. Use this at
* every site that mints a child id (slice create / ship / move target)
* — per the convention's lazy-adoption rule: when a child is created
* under a pre-existing parent with no id, assign the parent's id
* on-demand at the same time. Narrow, single-parent, create-triggered;
* never mass-migration. */
export function ensureMissionIdPersisted(
mission: MissionInfo,
missionsRoot: string,
): string {
const id = ensureMissionId(mission, missionsRoot);
if (!mission.id && mission.readmePath) {
updateFrontmatter(mission.readmePath, { id });
// Reflect the write on the in-memory MissionInfo so subsequent
// callers in the same command don't re-mint a different ordinal.
mission.id = id;
}
return id;
}
// ---------------------------------------------------------------------
// Git move
// ---------------------------------------------------------------------
/** Return the git toplevel for a path, or null if not inside a repo. */
export function gitTopLevel(absPath: string): string | null {
const dir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory() ? absPath : path.dirname(absPath);
try {
const out = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return out || null;
} catch {
return null;
}
}
/** Refuse to move a path with uncommitted local changes. Catches the
* "dirty working tree" risk in §11.1 / §3.4 — `git mv` would silently
* carry along the user's in-progress edits. */
export function assertCleanWorkingTree(repoRoot: string, relPath: string): void {
let status: string;
try {
status = execFileSync(
"git",
["-C", repoRoot, "status", "--porcelain", "--", relPath],
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
);
} catch (err) {
throw new ScopeCliError({
fact: `git status failed for ${relPath} in ${repoRoot}: ${(err as Error).message}`,
consequence: "Could not verify the slice's working-tree state. Move aborted.",
action: "Check the repository at " + repoRoot + " and retry.",
});
}
if (status.trim().length > 0) {
throw new ScopeCliError({
fact: `Working tree under ${relPath} has uncommitted changes:\n${status.trimEnd()}`,
consequence: "Refusing to git mv — your local edits would silently move with the slice.",
action: "Commit (git commit -m '...') or stash (git stash) and retry.",
});
}
}
/** Move a directory using `git mv` when inside a repo; fall back to
* `fs.renameSync` with a warning when not. Returns true if git was
* used. */
export function moveSlice(srcAbs: string, destAbs: string, opts: {
/** Stage the move (default true). git mv stages by default; we
* preserve that semantic. */
commit?: boolean;
} = {}): { usedGit: boolean; repoRoot: string | null } {
if (!fs.existsSync(srcAbs)) {
throw new ScopeCliError({
fact: `Source path ${srcAbs} does not exist.`,
consequence: "Move did not run.",
action: "Verify the slice path and retry.",
});
}
if (fs.existsSync(destAbs)) {
throw new ScopeCliError({
fact: `Destination path ${destAbs} already exists.`,
consequence: "Refusing to overwrite an existing slice.",
action: "Pick a different destination, or remove the existing folder first.",
});
}
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
const repoRoot = gitTopLevel(srcAbs);
if (!repoRoot) {
fs.renameSync(srcAbs, destAbs);
return { usedGit: false, repoRoot: null };
}
// Normalize symlinks (macOS /var/folders → /private/var/folders) so
// path.relative produces a path INSIDE the repo, not a ../../escape.
const realSrcAbs = fs.realpathSync(srcAbs);
const realDestParent = fs.realpathSync(path.dirname(destAbs));
const realDestAbs = path.join(realDestParent, path.basename(destAbs));
const srcRel = path.relative(repoRoot, realSrcAbs);
assertCleanWorkingTree(repoRoot, srcRel);
const destRel = path.relative(repoRoot, realDestAbs);
try {
execFileSync("git", ["-C", repoRoot, "mv", srcRel, destRel], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (err) {
throw new ScopeCliError({
fact: `git mv ${srcRel} ${destRel} failed: ${(err as Error).message}`,
consequence: "Move aborted; source unchanged.",
action: "Inspect the repo state and retry.",
});
}
if (opts.commit) {
// Future hook; v0 doesn't auto-commit.
}
return { usedGit: true, repoRoot };
}
// ---------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------
export function todayDateISO(): string {
const d = new Date();
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export { DEFAULT_PROJECT_PREFIX };
-82
View File
@@ -1,82 +0,0 @@
// release-0.3.2 slice 12 — template loader. Templates live as
// markdown files alongside the source so they can be edited like any
// other doc. The build copies them to dist/ via tsconfig
// rootDir/files behavior — but since .md isn't a .ts file, we read
// them directly via fileURLToPath to remain robust across local dev
// and the published package layout.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { MissionTemplateKind, SliceTemplateKind } from "./types.js";
import { ScopeCliError } from "./types.js";
const here = path.dirname(fileURLToPath(import.meta.url));
/** Candidate template roots, in order: source tree (dev), dist (built).
* We resolve at call time so the first existing directory wins. */
function candidateRoots(): string[] {
return [
// Dev: packages/cli/src/lib/scope/ → ../scope-templates
path.resolve(here, "..", "scope-templates"),
// Built: dist/lib/scope/ → ../../src/lib/scope-templates (one level up further if
// dist lands at packages/cli/dist/lib/scope).
path.resolve(here, "..", "..", "lib", "scope-templates"),
// Fallback: source tree relative to compiled dist when source is co-shipped.
path.resolve(here, "..", "..", "..", "src", "lib", "scope-templates"),
];
}
function resolveTemplate(filename: string): string {
for (const root of candidateRoots()) {
const candidate = path.join(root, filename);
if (fs.existsSync(candidate)) return candidate;
}
throw new ScopeCliError({
fact: `Template ${filename} could not be located.`,
consequence: "Cannot scaffold the new artifact.",
action: "Reinstall @openrig/cli, or run from a checkout with packages/cli/src/lib/scope-templates/ present.",
});
}
export interface RenderOpts {
id: string;
slice_number?: string; // zero-padded; only for slice templates
slug: string;
mission: string;
title: string;
created_date: string;
release_version?: string;
}
function applyPlaceholders(content: string, opts: RenderOpts): string {
return content
.replace(/\{\{id\}\}/g, opts.id)
.replace(/\{\{slice_number\}\}/g, opts.slice_number ?? "")
.replace(/\{\{slug\}\}/g, opts.slug)
.replace(/\{\{mission\}\}/g, opts.mission)
.replace(/\{\{title\}\}/g, opts.title)
.replace(/\{\{created_date\}\}/g, opts.created_date)
.replace(/\{\{release_version\}\}/g, opts.release_version ?? "");
}
export function renderSliceTemplate(kind: SliceTemplateKind, opts: RenderOpts): string {
const filename = `${kind}.md`;
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
return applyPlaceholders(raw, opts);
}
export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpts): string {
const filename = kind === "release" ? "mission-release.md" : "mission-placeholder.md";
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
return applyPlaceholders(raw, opts);
}
/** Convert a folder-slug to a title-cased display name. */
export function titleFromSlug(slug: string): string {
return slug
.split(/[-_]/g)
.filter(Boolean)
.map((word) => word[0]!.toUpperCase() + word.slice(1))
.join(" ");
}
-97
View File
@@ -1,97 +0,0 @@
// release-0.3.2 slice 12 — scope CLI primitive types.
//
// Aligned with the substrate convention at
// `openrig-work/conventions/scope-and-versioning/README.md` (stage:
// provisional, authored 2026-05-15). The CLI mints stable dot-IDs
// into created mission/slice frontmatter per §1 of that convention.
/** Scope tiers. v0 ships `mission` + `slice`; `project` and `sub-slice`
* exist in the grammar but have no CLI verbs yet. */
export type ScopeTier = "project" | "mission" | "slice" | "sub-slice";
/** Stable reference ID parts. `OPR.0.3.2.12` → project="OPR",
* version="0.3.2", n=12. Escape-band non-release missions look like
* `OPR.99.0.1` → version="99.0.1", n=undefined. */
export interface DotId {
project: string; // 2-3 letter project prefix
version: string; // semver-shaped (release) or escape-band (99.x.y)
n?: number; // slice ordinal
m?: number; // sub-slice ordinal
}
export interface MissionInfo {
/** Mission folder name (e.g., "release-0.3.2", "backlog"). */
name: string;
/** Absolute path to the mission folder. */
absPath: string;
/** Path to the mission's README.md, if present. */
readmePath: string | null;
/** Parsed frontmatter from the README. */
frontmatter: Record<string, unknown>;
/** Minted dot-ID for this mission (read from frontmatter if present,
* otherwise inferred from the folder name pattern). */
id: string | null;
activeSliceCount: number;
closedSliceCount: number;
}
export interface SliceInfo {
name: string; // folder name like "12-rig-slice-cli-primitive"
absPath: string;
readmePath: string | null;
frontmatter: Record<string, unknown>;
/** "NN-slug" → NN. */
nn: number | null;
/** "NN-slug" → slug. */
slug: string | null;
missionName: string;
/** Minted dot-ID, parent.NN form. */
id: string | null;
/** Frontmatter `status` field, lowercased. */
status: string | null;
}
/** 3-part error shape per `building-agent-software` skill §3.6. */
export class ScopeCliError extends Error {
readonly fact: string;
readonly consequence: string;
readonly action: string;
constructor(opts: { fact: string; consequence: string; action: string }) {
super(`${opts.fact}\n${opts.consequence}\n${opts.action}`);
this.name = "ScopeCliError";
this.fact = opts.fact;
this.consequence = opts.consequence;
this.action = opts.action;
}
}
export type SliceState = "active" | "closed" | "shipped" | "all";
export type SliceTemplateKind =
| "placeholder"
| "bug-fix"
| "backlog-deprecation"
| "backlog-tech-debt"
| "release-feature"
| "research";
export const SLICE_TEMPLATE_KINDS: ReadonlyArray<SliceTemplateKind> = [
"placeholder",
"bug-fix",
"backlog-deprecation",
"backlog-tech-debt",
"release-feature",
"research",
];
export type MissionTemplateKind = "placeholder" | "release";
export const MISSION_TEMPLATE_KINDS: ReadonlyArray<MissionTemplateKind> = [
"placeholder",
"release",
];
export type CloseReason = "wontfix" | "deferred" | "superseded" | "stale";
export const CLOSE_REASONS: ReadonlyArray<CloseReason> = [
"wontfix",
"deferred",
"superseded",
"stale",
];
+5 -7
View File
@@ -23,16 +23,14 @@ function warnOnce(key: string, message: string): void {
console.warn(message);
}
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
export function readOpenRigEnv(primary: string, legacy: string): string | undefined {
const primaryValue = process.env[primary];
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
if (legacy) {
const legacyValue = process.env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
}
const legacyValue = process.env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
}
return undefined;
@@ -29,7 +29,7 @@ import type {
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
@@ -29,7 +29,7 @@ import type {
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
-142
View File
@@ -1,142 +0,0 @@
// PL-016 hardening v0+1 — `rig agent-image create` --image-version flag
// regression test.
//
// Pins:
// - --image-version <v> propagates as body.version on the snapshot POST
// - --version on the per-command surface no longer shadows Commander's
// global --version (creating a separate per-command name resolves
// the collision; absence of `--version` as a per-command option is
// the contract this test enforces)
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { agentImageCommand } from "../src/commands/agent-image.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function runningState(port: number): DaemonState {
return { pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" };
}
function runningLifecycleDeps(port: number): LifecycleDeps {
return mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify(runningState(port));
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
});
}
interface CapturedCall {
method: string;
url: string;
body: string;
}
function createMockDaemon(captured: CapturedCall[]) {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
if (url.pathname === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (req.method === "POST" && url.pathname === "/api/agent-images/snapshot") {
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
captured.push({ method: req.method!, url: url.pathname, body });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
imageId: "agent-image:test:2",
imagePath: "/tmp/agent-images/test",
manifest: { name: "test", version: "2", runtime: "claude-code" },
}));
});
return;
}
res.writeHead(404);
res.end();
});
return {
server,
close: () => new Promise<void>((r) => server.close(() => r())),
listen: () => new Promise<number>((r) => {
server.listen(0, () => {
const addr = server.address();
r(typeof addr === "object" && addr ? addr.port : 0);
});
}),
};
}
describe("rig agent-image create — --image-version flag", () => {
const captured: CapturedCall[] = [];
let srv: ReturnType<typeof createMockDaemon>;
let port: number;
beforeAll(async () => {
srv = createMockDaemon(captured);
port = await srv.listen();
});
afterAll(async () => { await srv.close(); });
it("--image-version <v> propagates as body.version on the snapshot POST", async () => {
captured.length = 0;
const program = new Command();
program.exitOverride();
program.addCommand(agentImageCommand({
lifecycleDeps: runningLifecycleDeps(port),
clientFactory: (url) => new DaemonClient(url),
}));
// Suppress console output for the test.
const origLog = console.log;
console.log = () => {};
try {
await program.parseAsync([
"node", "rig", "agent-image", "create", "alice@rigA",
"--name", "test",
"--image-version", "2",
]);
} finally {
console.log = origLog;
}
expect(captured).toHaveLength(1);
const body = JSON.parse(captured[0]!.body);
expect(body.version).toBe("2");
expect(body.name).toBe("test");
expect(body.sourceSession).toBe("alice@rigA");
});
it("the per-command create command does NOT register --version (avoids Commander.js global collision)", () => {
const cmd = agentImageCommand();
const createCmd = cmd.commands.find((c) => c.name() === "create");
expect(createCmd).toBeDefined();
const optionNames = createCmd!.options.map((o) => o.long);
// Must include --image-version (the rename target); must NOT include
// --version (which Commander.js intercepts as the global version flag).
expect(optionNames).toContain("--image-version");
expect(optionNames).not.toContain("--version");
});
});
@@ -1,699 +0,0 @@
// User Settings v0 — extended ConfigStore + init-workspace tests.
//
// Pins the load-bearing behaviors of the v0 extension:
// - new keys parse + validate; legacy 5 keys' behavior preserved
// - per-subdir override resolution (workspace.root / per-subdir overrides / default)
// - env > file > default precedence for new keys
// - UEP env-var graduation: OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS still work
// - reset(key) clears one key; bare reset deletes the file
// - parseNamedPairs decodes the named-pair format
// - init-workspace creates mission-aware workspace files; idempotent
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
ConfigStore,
VALID_KEYS,
parseNamedPairs,
deriveWorkspaceDefault,
} from "../src/config-store.js";
import { configCommand } from "../src/commands/config.js";
import { initWorkspaceCommand, runInitWorkspace } from "../src/commands/config-init-workspace.js";
function clearEnv(): () => void {
const keysToClear = [
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
"OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
"OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS",
"OPENRIG_UI_PREVIEW_MAX_PINS", "OPENRIG_UI_PREVIEW_DEFAULT_LINES",
"OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS",
"OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST",
"OPENRIG_AGENTS_ADVISOR_SESSION", "OPENRIG_AGENTS_OPERATOR_SESSION",
"OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS",
"OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS",
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_COMPACT_INSTRUCTION",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION",
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
];
const saved: Record<string, string | undefined> = {};
for (const k of keysToClear) {
saved[k] = process.env[k];
delete process.env[k];
}
return () => {
for (const k of keysToClear) {
if (saved[k] !== undefined) process.env[k] = saved[k]!;
else delete process.env[k];
}
};
}
describe("ConfigStore — extended namespaces (User Settings v0)", () => {
let tmpDir: string;
let configPath: string;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "config-store-ext-"));
configPath = join(tmpDir, "config.json");
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
it("VALID_KEYS includes legacy, user-settings, ui.preview, recovery, agents, feed, and transcript-rotation keys", () => {
const expected = [
"daemon.port", "daemon.host", "db.path",
"transcripts.enabled", "transcripts.path",
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
"transcripts.lines", "transcripts.poll_interval_seconds",
"workspace.root", "workspace.slices_root", "workspace.steering_path",
"workspace.field_notes_root", "workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist", "progress.scan_roots",
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
"agents.advisor_session", "agents.operator_session",
// V0.3.1 slice 05 kernel-rig-as-default — operator seat name
// read by daemon mission-control + UI; default
// `operator-${USER}@kernel` derived in resolve().
"workspace.operator_seat_name",
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles.
"feed.subscriptions.action_required",
"feed.subscriptions.approvals",
"feed.subscriptions.shipped",
"feed.subscriptions.progress",
"feed.subscriptions.audit_log",
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
"runtime.codex.hooks_enabled",
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
"policies.claude_compaction.enabled",
"policies.claude_compaction.threshold_percent",
"policies.claude_compaction.pre_compact_instruction",
"policies.claude_compaction.compact_instruction",
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
];
expect([...VALID_KEYS]).toEqual(expected);
});
it("workspace.operator_seat_name roundtrip — default derives operator-${USER}@kernel; set → resolve reflects override", () => {
const store = new ConfigStore(configPath);
const before = store.resolve();
// Default cascade derives from OS username at resolve() time.
expect(before.workspace.operatorSeatName).toMatch(/^operator-.+@kernel$/);
store.set("workspace.operator_seat_name", "operator-test@kernel");
const after = store.resolve();
expect(after.workspace.operatorSeatName).toBe("operator-test@kernel");
store.reset("workspace.operator_seat_name");
const reset = store.resolve();
expect(reset.workspace.operatorSeatName).toMatch(/^operator-.+@kernel$/);
});
it("workspace.operator_seat_name env override beats file-stored value", () => {
const store = new ConfigStore(configPath);
store.set("workspace.operator_seat_name", "operator-file@kernel");
process.env.OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME = "operator-env@kernel";
try {
const resolved = store.resolve();
expect(resolved.workspace.operatorSeatName).toBe("operator-env@kernel");
} finally {
delete process.env.OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME;
}
});
it("transcripts.lines + transcripts.poll_interval_seconds roundtrip — set → resolve reflects file-stored values for daemon launch projection", () => {
const store = new ConfigStore(configPath);
// Defaults BEFORE set.
const before = store.resolve();
expect(before.transcripts.lines).toBe(1000);
expect(before.transcripts.pollIntervalSeconds).toBe(2);
store.set("transcripts.lines", "500");
store.set("transcripts.poll_interval_seconds", "5");
const after = store.resolve();
expect(after.transcripts.lines).toBe(500);
expect(after.transcripts.pollIntervalSeconds).toBe(5);
});
it("legacy 5-key behavior preserved: get/set/reset still work", () => {
const store = new ConfigStore(configPath);
store.set("daemon.port", "9999");
expect(store.get("daemon.port")).toBe(9999);
store.set("transcripts.enabled", "false");
expect(store.get("transcripts.enabled")).toBe(false);
store.reset("daemon.port");
expect(store.get("daemon.port")).toBe(7433);
});
it("workspace.root default is ~/.openrig/workspace; per-subdir defaults derive from it", () => {
const store = new ConfigStore(configPath);
const cfg = store.resolve();
expect(cfg.workspace.root).toMatch(/\.openrig[\/\\]workspace$/);
expect(cfg.workspace.slicesRoot).toBe(join(cfg.workspace.root, "missions"));
expect(cfg.workspace.steeringPath).toBe(join(cfg.workspace.root, "STEERING.md"));
expect(cfg.workspace.fieldNotesRoot).toBe(join(cfg.workspace.root, "field-notes"));
expect(cfg.workspace.specsRoot).toBe(join(cfg.workspace.root, "specs"));
expect(cfg.workspace.dogfoodEvidenceRoot).toBe(join(cfg.workspace.root, "dogfood-evidence"));
expect(cfg.files.allowlist).toBe(`workspace:${cfg.workspace.root}`);
expect(cfg.progress.scanRoots).toBe(`workspace:${cfg.workspace.root}`);
});
it("setting workspace.root cascades into per-subdir defaults", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/custom/ws");
const cfg = store.resolve();
expect(cfg.workspace.root).toBe("/custom/ws");
expect(cfg.workspace.slicesRoot).toBe("/custom/ws/missions");
expect(cfg.workspace.steeringPath).toBe("/custom/ws/STEERING.md");
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
expect(cfg.files.allowlist).toBe("workspace:/custom/ws");
expect(cfg.progress.scanRoots).toBe("workspace:/custom/ws");
});
it("treats persisted legacy workspace defaults as default-derived values", () => {
writeFileSync(configPath, JSON.stringify({
workspace: {
root: "/custom/ws",
slicesRoot: "/custom/ws/slices",
steeringPath: "/custom/ws/steering/STEERING.md",
},
}));
const store = new ConfigStore(configPath);
const slices = store.resolveWithSource("workspace.slices_root");
const steering = store.resolveWithSource("workspace.steering_path");
expect(slices).toMatchObject({ value: "/custom/ws/missions", source: "default" });
expect(steering).toMatchObject({ value: "/custom/ws/STEERING.md", source: "default" });
});
it("per-subdir override beats workspace.root cascade", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/custom/slices");
const cfg = store.resolve();
// workspace.root cascade applies to OTHER subdirs:
expect(cfg.workspace.fieldNotesRoot).toBe("/ws/field-notes");
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/ws/dogfood-evidence");
// per-subdir override wins:
expect(cfg.workspace.slicesRoot).toBe("/custom/slices");
});
it("env > file > default for new keys", () => {
const store = new ConfigStore(configPath);
// No file → default
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
// File set → file source
store.set("workspace.slices_root", "/from/file");
expect(store.resolveWithSource("workspace.slices_root").value).toBe("/from/file");
expect(store.resolveWithSource("workspace.slices_root").source).toBe("file");
// Env set → env source
process.env["OPENRIG_WORKSPACE_SLICES_ROOT"] = "/from/env";
try {
const r = store.resolveWithSource("workspace.slices_root");
expect(r.value).toBe("/from/env");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_WORKSPACE_SLICES_ROOT"];
}
});
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST is the env override for files.allowlist", () => {
const store = new ConfigStore(configPath);
process.env["OPENRIG_FILES_ALLOWLIST"] = "ws:/Users/me,docs:/var/docs";
try {
const r = store.resolveWithSource("files.allowlist");
expect(r.value).toBe("ws:/Users/me,docs:/var/docs");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_FILES_ALLOWLIST"];
}
});
it("UEP env-var graduation: OPENRIG_PROGRESS_SCAN_ROOTS is the env override for progress.scan_roots", () => {
const store = new ConfigStore(configPath);
process.env["OPENRIG_PROGRESS_SCAN_ROOTS"] = "main:/code/main";
try {
const r = store.resolveWithSource("progress.scan_roots");
expect(r.value).toBe("main:/code/main");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_PROGRESS_SCAN_ROOTS"];
}
});
it("dogfood evidence root defaults under workspace.root and supports env override", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/custom/ws");
expect(store.resolve().workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"] = "/proof/root";
try {
const r = store.resolveWithSource("workspace.dogfood_evidence_root");
expect(r.value).toBe("/proof/root");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"];
}
});
it("set rejects unknown keys with hint listing valid keys", () => {
const store = new ConfigStore(configPath);
expect(() => store.set("workspace.bogus", "x")).toThrow(/Unknown config key/);
expect(() => store.set("workspace.bogus", "x")).toThrow(/workspace\.root/);
});
it("get rejects unknown keys", () => {
const store = new ConfigStore(configPath);
expect(() => store.get("nope.doesnt.exist")).toThrow(/Unknown config key/);
});
it("reset(key) clears just one key; reset() deletes whole file", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/ws");
store.set("workspace.slices_root", "/ws/slices-custom");
store.reset("workspace.slices_root");
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
expect(store.resolveWithSource("workspace.root").source).toBe("file");
store.reset();
expect(existsSync(configPath)).toBe(false);
});
it("resolveAllWithSource returns every valid key with source + default", () => {
const store = new ConfigStore(configPath);
const all = store.resolveAllWithSource();
for (const key of VALID_KEYS) {
expect(all[key]).toBeDefined();
expect(all[key].source).toBeDefined();
}
});
it("malformed config.json throws with reset hint (preserves legacy behavior)", () => {
writeFileSync(configPath, "{not-json");
const store = new ConfigStore(configPath);
expect(() => store.resolve()).toThrow(/malformed/i);
expect(() => store.resolve()).toThrow(/reset/i);
});
// --- Preview Terminal v0 (PL-018) keys ---
it("ui.preview.refresh_interval_seconds defaults to 3", () => {
const store = new ConfigStore(configPath);
expect(store.get("ui.preview.refresh_interval_seconds")).toBe(3);
});
it("ui.preview.max_pins defaults to 4", () => {
const store = new ConfigStore(configPath);
expect(store.get("ui.preview.max_pins")).toBe(4);
});
it("ui.preview.default_lines defaults to 50", () => {
const store = new ConfigStore(configPath);
expect(store.get("ui.preview.default_lines")).toBe(50);
});
it("ui.preview.* keys coerce numeric values from set", () => {
const store = new ConfigStore(configPath);
store.set("ui.preview.refresh_interval_seconds", "5");
store.set("ui.preview.max_pins", "2");
store.set("ui.preview.default_lines", "100");
expect(store.get("ui.preview.refresh_interval_seconds")).toBe(5);
expect(store.get("ui.preview.max_pins")).toBe(2);
expect(store.get("ui.preview.default_lines")).toBe(100);
});
it("ui.preview.* keys reject non-numeric values", () => {
const store = new ConfigStore(configPath);
expect(() => store.set("ui.preview.refresh_interval_seconds", "soon")).toThrow(/expected a number/);
});
it("OPENRIG_UI_PREVIEW_* env vars override file values", () => {
const store = new ConfigStore(configPath);
store.set("ui.preview.refresh_interval_seconds", "5");
process.env["OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS"] = "10";
try {
const r = store.resolveWithSource("ui.preview.refresh_interval_seconds");
expect(r.value).toBe(10);
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS"];
}
});
it("recovery provider auth env allowlist defaults empty and supports env override", () => {
const store = new ConfigStore(configPath);
expect(store.get("recovery.provider_auth_env_allowlist")).toBe("");
store.set("recovery.provider_auth_env_allowlist", "ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN");
expect(store.resolve().recovery.providerAuthEnvAllowlist).toBe("ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN");
process.env["OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST"] = "OPENAI_API_KEY";
try {
const r = store.resolveWithSource("recovery.provider_auth_env_allowlist");
expect(r.value).toBe("OPENAI_API_KEY");
expect(r.source).toBe("env");
} finally {
delete process.env["OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST"];
}
});
it("RiggedConfig.ui.preview shape exposed", () => {
const store = new ConfigStore(configPath);
const cfg = store.resolve();
expect(cfg.ui.preview.refreshIntervalSeconds).toBe(3);
expect(cfg.ui.preview.maxPins).toBe(4);
expect(cfg.ui.preview.defaultLines).toBe(50);
});
it("recovery.auto_drive_provider_prompts defaults false and coerces booleans", () => {
const store = new ConfigStore(configPath);
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(false);
store.set("recovery.auto_drive_provider_prompts", "true");
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(true);
expect(store.resolve().recovery.autoDriveProviderPrompts).toBe(true);
});
// Slice 27 BLOCKING-FIX-2 — env override + file value validation.
// Bypass-prevention at the RESOLVE path: an env override like
// OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc previously
// parseInt-coerced to 80 and shipped through resolveOne as a "valid"
// env-sourced value. With BLOCKING-FIX-2 the env resolution validates;
// bad env drops the override (falls to file/default) and warns on stderr.
describe("BLOCKING-FIX-2: env + file source validation", () => {
const reject = ["0", "101", "-1", "80abc", "80.5", "NaN", "Infinity"];
for (const raw of reject) {
it(`env=${JSON.stringify(raw)} → resolve returns default 80; warning emitted`, () => {
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = raw;
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const store = new ConfigStore(configPath);
const resolved = store.resolve();
expect(resolved.policies.claudeCompaction.thresholdPercent).toBe(80);
const calls = stderrSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((c) => c.includes("env override for policies.claude_compaction.threshold_percent rejected"))).toBe(true);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
}
});
}
it("discriminator: env=80abc (rejected, source falls to default, warning) vs env=80 (accepted, source=env, no warning)", () => {
// Case A: rejected → source=default, warning emitted
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "80abc";
let stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const a = new ConfigStore(configPath).resolveWithSource("policies.claude_compaction.threshold_percent");
expect(a.value).toBe(80);
expect(a.source).toBe("default");
const warns = stderrSpy.mock.calls
.map((c) => String(c[0]))
.filter((c) => c.includes("env override for policies.claude_compaction.threshold_percent rejected"));
expect(warns.length).toBe(1);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
}
// Case B: accepted → SAME numeric value (80) but source=env, NO warning
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "80";
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const b = new ConfigStore(configPath).resolveWithSource("policies.claude_compaction.threshold_percent");
expect(b.value).toBe(80);
expect(b.source).toBe("env");
const warns = stderrSpy.mock.calls
.map((c) => String(c[0]))
.filter((c) => c.includes("rejected"));
expect(warns.length).toBe(0);
} finally {
stderrSpy.mockRestore();
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
}
});
const fileReject: Array<{ name: string; written: unknown }> = [
{ name: "0", written: 0 },
{ name: "101", written: 101 },
{ name: "-1", written: -1 },
{ name: "80.5 (JSON non-integer)", written: 80.5 },
{ name: '"80abc" (JSON string)', written: "80abc" },
];
for (const { name, written } of fileReject) {
it(`file thresholdPercent=${name} → resolve returns default 80; warning emitted`, () => {
writeFileSync(configPath, JSON.stringify({
policies: { claudeCompaction: { thresholdPercent: written } },
}));
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const resolved = new ConfigStore(configPath).resolve();
expect(resolved.policies.claudeCompaction.thresholdPercent).toBe(80);
const calls = stderrSpy.mock.calls.map((c) => String(c[0]));
expect(calls.some((c) => c.includes("file value for policies.claude_compaction.threshold_percent rejected"))).toBe(true);
} finally {
stderrSpy.mockRestore();
}
});
}
});
// Slice 27 BLOCKING-FIX — strict accept/reject matrix for
// policies.claude_compaction.threshold_percent. The contract is
// integer in [1, 100]. parseInt's permissive coercion would otherwise
// accept "80abc" → 80, "80.5" → 80, and even 0 / 101 / -1 which would
// break the trigger's safety contract (0 = compact every tick).
describe("policies.claude_compaction.threshold_percent strict validation matrix", () => {
const accept = ["1", "2", "50", "80", "99", "100"];
const reject: Array<{ raw: string; reason: RegExp }> = [
{ raw: "0", reason: /must be in \[1, 100\]/ },
{ raw: "101", reason: /must be in \[1, 100\]/ },
{ raw: "-1", reason: /must be in \[1, 100\]/ },
{ raw: "80abc", reason: /expected an integer/ },
{ raw: "abc80", reason: /expected a number|expected an integer/ },
{ raw: "80.5", reason: /expected an integer/ },
{ raw: "", reason: /expected a number|expected an integer/ },
{ raw: " ", reason: /expected a number|expected an integer/ },
{ raw: "NaN", reason: /expected a number|expected an integer/ },
{ raw: "Infinity", reason: /expected a number|expected an integer/ },
];
for (const value of accept) {
it(`accepts ${JSON.stringify(value)}`, () => {
const store = new ConfigStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", value)).not.toThrow();
expect(store.get("policies.claude_compaction.threshold_percent")).toBe(Number(value));
});
}
for (const { raw, reason } of reject) {
it(`rejects ${JSON.stringify(raw)}`, () => {
const store = new ConfigStore(configPath);
expect(() => store.set("policies.claude_compaction.threshold_percent", raw)).toThrow(reason);
});
}
});
});
describe("parseNamedPairs", () => {
it("returns empty array for empty/whitespace input", () => {
expect(parseNamedPairs("")).toEqual([]);
expect(parseNamedPairs(" ")).toEqual([]);
});
it("splits comma-separated name:path pairs", () => {
const out = parseNamedPairs("ws:/abs/path,docs:/var/docs");
expect(out).toEqual([
{ name: "ws", path: "/abs/path" },
{ name: "docs", path: "/var/docs" },
]);
});
it("skips entries without a colon", () => {
expect(parseNamedPairs("just-name,ws:/path")).toEqual([{ name: "ws", path: "/path" }]);
});
it("trims whitespace around each pair + name + path", () => {
expect(parseNamedPairs(" ws : /abs/path , docs:/var/docs ")).toEqual([
{ name: "ws", path: "/abs/path" },
{ name: "docs", path: "/var/docs" },
]);
});
it("dedupes by name (last wins)", () => {
expect(parseNamedPairs("ws:/old,ws:/new")).toEqual([{ name: "ws", path: "/new" }]);
});
});
describe("deriveWorkspaceDefault", () => {
it("returns canonical subpaths under workspace root", () => {
expect(deriveWorkspaceDefault("workspace.slices_root", "/ws")).toBe("/ws/missions");
expect(deriveWorkspaceDefault("workspace.steering_path", "/ws")).toBe("/ws/STEERING.md");
expect(deriveWorkspaceDefault("workspace.field_notes_root", "/ws")).toBe("/ws/field-notes");
expect(deriveWorkspaceDefault("workspace.specs_root", "/ws")).toBe("/ws/specs");
expect(deriveWorkspaceDefault("workspace.dogfood_evidence_root", "/ws")).toBe("/ws/dogfood-evidence");
});
});
describe("init-workspace runner", () => {
let tmpDir: string;
let configPath: string;
let workspaceRoot: string;
let restoreEnv: () => void;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "init-workspace-"));
configPath = join(tmpDir, "config.json");
workspaceRoot = join(tmpDir, "workspace");
restoreEnv = clearEnv();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
restoreEnv();
});
it("--dry-run reports what would be created without writing anything", () => {
const result = runInitWorkspace({ dryRun: true, root: workspaceRoot, configPath });
expect(result.dryRun).toBe(true);
expect(result.subdirs.map((s) => s.name)).toEqual(expect.arrayContaining([
"missions",
"artifacts",
"evidence",
"progress",
"field-notes",
"specs",
"dogfood-evidence",
"missions/getting-started/slices/first-conveyor-run",
"missions/getting-started/slices/inspect-project-evidence",
]));
expect(existsSync(workspaceRoot)).toBe(false);
});
it("command --json emits parseable JSON for agent setup flows", async () => {
const cmd = initWorkspaceCommand(configPath);
const logs: string[] = [];
const origLog = console.log;
console.log = (msg?: unknown) => { logs.push(String(msg)); };
try {
await cmd.parseAsync(["node", "init-workspace", "--root", workspaceRoot, "--dry-run", "--json"]);
} finally {
console.log = origLog;
}
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
expect(parsed.root).toBe(workspaceRoot);
expect(parsed.dryRun).toBe(true);
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/getting-started/slices/first-conveyor-run");
});
it("rig-level --json emits parseable JSON for config init-workspace", async () => {
const cmd = new Command("rig");
cmd.exitOverride();
cmd.option("--json", "emit machine-readable JSON");
cmd.addCommand(configCommand(configPath));
const logs: string[] = [];
const origLog = console.log;
console.log = (msg?: unknown) => { logs.push(String(msg)); };
try {
await cmd.parseAsync([
"node",
"rig",
"--json",
"config",
"init-workspace",
"--root",
workspaceRoot,
"--dry-run",
]);
} finally {
console.log = origLog;
}
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
expect(parsed.root).toBe(workspaceRoot);
expect(parsed.dryRun).toBe(true);
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/getting-started/slices/inspect-project-evidence");
});
it("creates mission-aware workspace files + STEERING placeholder", () => {
const result = runInitWorkspace({ root: workspaceRoot, configPath });
expect(result.dryRun).toBe(false);
expect(existsSync(workspaceRoot)).toBe(true);
for (const sub of ["missions", "artifacts", "evidence", "progress", "field-notes", "specs", "dogfood-evidence"]) {
expect(existsSync(join(workspaceRoot, sub))).toBe(true);
expect(existsSync(join(workspaceRoot, sub, "README.md"))).toBe(true);
}
expect(existsSync(join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md"))).toBe(true);
expect(existsSync(join(workspaceRoot, "missions", "getting-started", "slices", "inspect-project-evidence", "IMPLEMENTATION-PRD.md"))).toBe(true);
const steeringMd = readFileSync(join(workspaceRoot, "STEERING.md"), "utf-8");
expect(steeringMd).toContain("OpenRig Priority Stack");
});
it("is idempotent: running twice without --force is a no-op for existing files", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const sliceReadme = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md");
writeFileSync(sliceReadme, "operator-edited content", "utf-8");
const second = runInitWorkspace({ root: workspaceRoot, configPath });
const sliceFile = second.files.find((f) => f.relPath === "missions/getting-started/slices/first-conveyor-run/README.md");
expect(sliceFile?.skipped).toBe("exists");
expect(readFileSync(sliceReadme, "utf-8")).toBe("operator-edited content");
});
it("--force overwrites existing files but never deletes operator content under directories", () => {
runInitWorkspace({ root: workspaceRoot, configPath });
const operatorFile = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "operator-note.md");
writeFileSync(operatorFile, "my work", "utf-8");
const operatorReadme = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md");
writeFileSync(operatorReadme, "edited", "utf-8");
runInitWorkspace({ root: workspaceRoot, force: true, configPath });
// Operator file under the subdir survives
expect(existsSync(operatorFile)).toBe(true);
expect(readFileSync(operatorFile, "utf-8")).toBe("my work");
// README is overwritten
expect(readFileSync(operatorReadme, "utf-8")).toContain("# First Conveyor Run");
});
it("--root override beats configured workspace.root", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", "/should/not/be/used");
const result = runInitWorkspace({ root: workspaceRoot, configPath });
expect(result.root).toBe(workspaceRoot);
expect(existsSync(workspaceRoot)).toBe(true);
});
it("reads workspace.root from settings when --root is not given", () => {
const store = new ConfigStore(configPath);
store.set("workspace.root", workspaceRoot);
const result = runInitWorkspace({ configPath });
expect(result.root).toBe(workspaceRoot);
expect(existsSync(workspaceRoot)).toBe(true);
});
});
+1 -112
View File
@@ -4,7 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import http from "node:http";
import { Command } from "commander";
import { ConfigStore, VALID_KEYS } from "../src/config-store.js";
import { ConfigStore } from "../src/config-store.js";
import { configCommand } from "../src/commands/config.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
@@ -110,26 +110,13 @@ describe("ConfigStore", () => {
describe("DaemonClient config integration", () => {
let tmpDir: string;
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "client-config-"));
savedEnv = {
OPENRIG_PORT: process.env["OPENRIG_PORT"],
OPENRIG_HOST: process.env["OPENRIG_HOST"],
OPENRIG_DB: process.env["OPENRIG_DB"],
};
delete process.env["OPENRIG_PORT"];
delete process.env["OPENRIG_HOST"];
delete process.env["OPENRIG_DB"];
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("ConfigStore resolves non-default host/port from config file", async () => {
@@ -279,50 +266,6 @@ describe("Config CLI", () => {
expect(logs.join("\n")).toContain("transcripts");
});
it("rig config get <key> --json prints value with source metadata", async () => {
const savedHost = process.env["OPENRIG_HOST"];
delete process.env["OPENRIG_HOST"];
const cmd = configCommand(join(tmpDir, "config.json"));
const prog = new Command();
prog.exitOverride();
prog.addCommand(cmd);
try {
const { logs } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "config", "get", "daemon.host", "--json"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.value).toBe("127.0.0.1");
expect(parsed.source).toBe("default");
expect(parsed.defaultValue).toBe("127.0.0.1");
} finally {
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
it("rig config --json get <key> prints value with source metadata", async () => {
const savedHost = process.env["OPENRIG_HOST"];
delete process.env["OPENRIG_HOST"];
const cmd = configCommand(join(tmpDir, "config.json"));
const prog = new Command();
prog.exitOverride();
prog.addCommand(cmd);
try {
const { logs } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "config", "--json", "get", "daemon.host"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.value).toBe("127.0.0.1");
expect(parsed.source).toBe("default");
expect(parsed.defaultValue).toBe("127.0.0.1");
} finally {
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
}
});
// Test 10
it("rig config --help includes subcommands and examples", () => {
const cmd = configCommand(join(tmpDir, "config.json"));
@@ -338,58 +281,4 @@ describe("Config CLI", () => {
// by checking description and options contain the essential info
expect(coreHelp).toContain("config");
});
// V0.3.1 slice 08 — HG-6: rig config --help text must enumerate every
// top-level dotted-prefix used in VALID_KEYS. The slice 08 verification
// pass caught a drift where ui.preview.* / agents.* / feed.subscriptions.* /
// runtime.codex.* + workspace.dogfood_evidence_root +
// workspace.operator_seat_name + transcripts.lines +
// transcripts.poll_interval_seconds were missing from the help "Keys:"
// section. This regression test extracts every top-level prefix from
// VALID_KEYS and asserts each appears (or its parent group appears) in
// the addHelpText after-text — guards against silent drift when new
// keys are added without updating help text.
it("HG-6: rig config --help enumerates every VALID_KEYS top-level prefix", () => {
const cmd = configCommand(join(tmpDir, "config.json"));
// Commander's `addHelpText("after", ...)` text is NOT included by
// `helpInformation()` — it's emitted via lifecycle hooks at help
// display time. Capture the full displayed help by routing
// `outputHelp()` through a custom writer.
let combined = "";
cmd.configureOutput({
writeOut: (text) => {
combined += text;
},
writeErr: () => {},
});
cmd.outputHelp();
// Top-level prefixes are the first dotted segment OR first-two
// segments for grouped namespaces like `ui.preview.*`,
// `feed.subscriptions.*`, `runtime.codex.*`.
const prefixes = new Set<string>();
for (const key of VALID_KEYS) {
const parts = key.split(".");
// Group sub-namespaces under their second segment when there are
// 3+ segments (e.g. ui.preview.refresh_interval_seconds → "ui.preview").
if (parts.length >= 3) {
prefixes.add(`${parts[0]}.${parts[1]}`);
} else {
prefixes.add(parts[0]!);
}
}
// Single-segment leaves (db.path, files.allowlist, progress.scan_roots)
// also appear in the help; the test allows either bare prefix OR
// the full leaf to satisfy the assertion.
const knownLeaves: Record<string, string[]> = {
"db": ["db.path"],
"files": ["files.allowlist"],
"progress": ["progress.scan_roots"],
};
for (const prefix of prefixes) {
const accepted = [prefix, ...(knownLeaves[prefix] ?? [])];
const found = accepted.some((needle) => combined.includes(needle));
expect(found, `rig config --help text must mention "${prefix}" (or one of ${accepted.join(", ")})`).toBe(true);
}
});
});
-357
View File
@@ -1,357 +0,0 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family tests.
//
// Stands up a small in-memory daemon mock for the /api/context-packs/*
// surface and exercises each subcommand against it.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { contextPackCommand } from "../src/commands/context-pack.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander.exitOverride */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
const FIXTURE_PACK = {
id: "context-pack:smoke:1",
kind: "context-pack" as const,
name: "smoke",
version: "1",
purpose: "Smoke test pack",
sourceType: "user_file" as const,
sourcePath: "/home/op/.openrig/context-packs/smoke",
relativePath: "smoke",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 100,
files: [
{ path: "notes.md", role: "notes", summary: "Smoke notes", absolutePath: "/abs/notes.md", bytes: 50, estimatedTokens: 13 },
],
};
const FIXTURE_PREVIEW = {
id: FIXTURE_PACK.id,
name: FIXTURE_PACK.name,
version: FIXTURE_PACK.version,
bundleText: "# OpenRig Context Pack: smoke v1\n\nSmoke test pack\n\n## File: notes.md (role: notes) — Smoke notes\n\nSmoke body\n",
bundleBytes: 110,
estimatedTokens: 28,
files: [{ path: "notes.md", role: "notes", bytes: 50, estimatedTokens: 13 }],
missingFiles: [] as Array<{ path: string; role: string }>,
};
describe("rig context-pack CLI (PL-014)", () => {
let server: http.Server;
let port: number;
let sendLog: Array<{ id: string; body: unknown }>;
let sendBehavior: "ok" | "fail" = "ok";
beforeAll(async () => {
sendLog = [];
server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
const url = req.url ?? "";
if (url === "/api/context-packs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([FIXTURE_PACK]));
return;
}
if (url === "/api/context-packs/library/sync" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: 1, errors: [], entries: [FIXTURE_PACK] }));
return;
}
const idMatch = url.match(/^\/api\/context-packs\/library\/([^/]+)(\/(preview|send))?$/);
if (idMatch) {
const id = decodeURIComponent(idMatch[1]!);
const sub = idMatch[3];
if (id !== FIXTURE_PACK.id) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Context pack '${id}' not found in library` }));
return;
}
if (sub === "preview" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PREVIEW));
return;
}
if (sub === "send" && req.method === "POST") {
const parsed = JSON.parse(body || "{}") as { destinationSession?: string; dryRun?: boolean };
sendLog.push({ id, body: parsed });
if (sendBehavior === "fail") {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found", reason: "session_missing" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
...FIXTURE_PREVIEW,
destinationSession: parsed.destinationSession,
dryRun: !!parsed.dryRun,
...(parsed.dryRun ? {} : { sent: true }),
}));
return;
}
if (!sub && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PACK));
return;
}
}
res.writeHead(404);
res.end();
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as { port: number }).port;
});
afterAll(() => server.close());
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(contextPackCommand(runningDeps(port)));
return prog;
}
function writePack(manifest: string): string {
const dir = mkdtempSync(join(tmpdir(), "openrig-context-pack-test-"));
writeFileSync(join(dir, "manifest.yaml"), manifest);
return dir;
}
it.each([
[
"path traversal",
`name: invalid-pack
version: 1.0.0
files:
- path: ../secret.md
role: notes
`,
"must be a relative path inside the pack",
],
[
"absolute path",
`name: invalid-pack
version: 1.0.0
files:
- path: /etc/passwd
role: notes
`,
"must be a relative path inside the pack",
],
[
"leading backslash",
`name: invalid-pack
version: 1.0.0
files:
- path: '\\evil.md'
role: notes
`,
"must be a relative path inside the pack",
],
[
"unknown suffix",
`name: invalid-pack
version: 1.0.0
files:
- path: secret.bin
role: notes
`,
"has an unsupported suffix",
],
[
"missing name",
`version: 1.0.0
files:
- path: notes.md
role: notes
`,
"missing required field 'name'",
],
[
"missing version",
`name: invalid-pack
files:
- path: notes.md
role: notes
`,
"missing required field 'version'",
],
[
"missing files",
`name: invalid-pack
version: 1.0.0
`,
"must declare 'files: [...]'",
],
[
"missing path",
`name: invalid-pack
version: 1.0.0
files:
- role: notes
`,
"missing 'path'",
],
[
"missing role",
`name: invalid-pack
version: 1.0.0
files:
- path: notes.md
`,
"missing 'role'",
],
])("rejects invalid context-pack add manifest: %s", async (_name, manifest, expectedError) => {
const dir = writePack(manifest);
try {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "add", dir]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain(expectedError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("list shows discovered packs", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("smoke");
expect(logs.join("\n")).toContain("1 files");
});
it("list --json emits JSON", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list", "--json"]);
});
const parsed = JSON.parse(logs.join("")) as Array<{ name: string }>;
expect(parsed[0]!.name).toBe("smoke");
});
it("show resolves by name", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "smoke"]);
});
expect(logs.join("\n")).toContain("Name: smoke");
expect(logs.join("\n")).toContain("Smoke test pack");
});
it("show fails with helpful error on unknown name", async () => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "missing-pack"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("not found in library");
});
it("preview prints the assembled bundle text", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "preview", "smoke"]);
});
expect(logs.join("\n")).toContain("# OpenRig Context Pack: smoke v1");
expect(logs.join("\n")).toContain("Smoke body");
});
it("send --dry-run does not invoke the daemon send path twice", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig", "--dry-run"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog).toHaveLength(1);
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: true });
expect(logs.join("\n")).toContain("(dry-run)");
});
it("send (real) reports successful delivery", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: false });
expect(logs.join("\n")).toContain("Sent smoke v1");
});
it("send fails fast when daemon returns 502", async () => {
sendLog.length = 0;
sendBehavior = "fail";
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "missing@rig"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("Session not found");
sendBehavior = "ok";
});
it("sync reports indexed count", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "sync"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("Indexed 1 context pack");
});
});
-254
View File
@@ -10,7 +10,6 @@ import {
getDaemonPath,
resolveDaemonPath,
buildDaemonEnv,
ensureWorkspaceScaffold,
STATE_FILE,
LOG_FILE,
OPENRIG_DIR,
@@ -89,25 +88,6 @@ describe("Daemon Lifecycle", () => {
expect(opts.detached).toBe(true);
});
it("start: idempotently scaffolds configured workspace before spawning", async () => {
const created = new Set<string>();
const written = new Map<string, string>();
const deps = startableDeps({
exists: vi.fn((p: string) => created.has(p) || written.has(p)),
mkdirp: vi.fn((p: string) => { created.add(p); }),
writeFile: vi.fn((p: string, content: string) => { written.set(p, content); }),
});
await startDaemon({ port: 7433, db: "openrig.sqlite", workspaceRoot: "/tmp/openrig-workspace" }, deps);
expect(created.has("/tmp/openrig-workspace")).toBe(true);
expect(created.has(path.join("/tmp/openrig-workspace", "missions", "getting-started", "slices", "first-conveyor-run"))).toBe(true);
expect(created.has(path.join("/tmp/openrig-workspace", "artifacts"))).toBe(true);
expect(created.has(path.join("/tmp/openrig-workspace", "evidence"))).toBe(true);
expect(written.get(path.join("/tmp/openrig-workspace", "STEERING.md"))).toContain("Run the `conveyor` starter rig.");
expect(written.get(path.join("/tmp/openrig-workspace", "missions", "getting-started", "slices", "first-conveyor-run", "README.md")))
.toContain("First Conveyor Run");
});
// 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 = startableDeps();
@@ -937,238 +917,4 @@ describe("buildDaemonEnv", () => {
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
});
// bug-fix slice auth-bearer-tailscale-trust forward-fix:
// Default-product-launch must NOT export OPENRIG_HOST so daemon's
// index.ts falls through to the loopback+tailscale multi-bind path.
describe("auth-bearer-tailscale-trust: explicit-vs-default host signal", () => {
it("omits OPENRIG_HOST when opts.host is undefined (default-product-launch)", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
db: "/tmp/test.db",
// host intentionally omitted — operator never set it (default path)
});
expect(result["OPENRIG_HOST"]).toBeUndefined();
// port + db still flow through
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
});
it("sets OPENRIG_HOST when opts.host is explicit (user opt-in)", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
host: "0.0.0.0",
db: "/tmp/test.db",
});
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
});
it("does NOT scrub OPENRIG_HOST from baseEnv when opts.host is undefined (operator shell env wins)", () => {
// If the operator has OPENRIG_HOST set in their shell, that's an
// explicit signal — preserve it through the env-passthrough loop.
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/usr/bin",
OPENRIG_HOST: "0.0.0.0",
};
const result = buildDaemonEnv(baseEnv, {
port: 7433,
db: "/tmp/test.db",
});
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
});
});
// Slice 22 founder-walk-vm-populated-env forward-fix #1: the slice
// intentionally drops a `--openrig-home` CLI flag in favor of using
// the process-env-pattern (each `rig` invocation gets its own
// OPENRIG_HOME via the shell). The slice's "fully isolated state"
// claim depends on the env contract being honored at module load
// time — specifically that getOpenRigHome() returns the current
// env value (not a hardcoded default) and that getDefaultOpenRigPath
// threads it into derived paths. The module-level constants
// (OPENRIG_DIR / STATE_FILE / LOG_FILE) are a single snapshot of
// these functions at load.
//
// These regression tests prove the function contract with distinct-
// value discriminators per banked feedback_poc_regression_must_discriminate.
describe("slice 22: OPENRIG_HOME env contract for per-process state isolation", () => {
it("getOpenRigHome respects process.env.OPENRIG_HOME (distinct values yield distinct paths)", async () => {
const { getOpenRigHome } = await import("../src/openrig-compat.js");
const saved = process.env["OPENRIG_HOME"];
try {
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-blank";
const blank = getOpenRigHome();
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-populated";
const populated = getOpenRigHome();
expect(blank).toBe("/Users/example/.openrig-blank");
expect(populated).toBe("/Users/example/.openrig-populated");
expect(blank).not.toBe(populated);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
it("getDefaultOpenRigPath threads env-resolved OPENRIG_HOME into derived paths (daemon.json + log)", async () => {
const { getDefaultOpenRigPath } = await import("../src/openrig-compat.js");
const saved = process.env["OPENRIG_HOME"];
try {
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-blank";
const blankState = getDefaultOpenRigPath("daemon.json");
const blankLog = getDefaultOpenRigPath("daemon.log");
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-populated";
const populatedState = getDefaultOpenRigPath("daemon.json");
const populatedLog = getDefaultOpenRigPath("daemon.log");
expect(blankState).toBe("/Users/example/.openrig-blank/daemon.json");
expect(populatedState).toBe("/Users/example/.openrig-populated/daemon.json");
expect(blankLog).toBe("/Users/example/.openrig-blank/daemon.log");
expect(populatedLog).toBe("/Users/example/.openrig-populated/daemon.log");
expect(blankState).not.toBe(populatedState);
expect(blankLog).not.toBe(populatedLog);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
it("empty OPENRIG_HOME falls back to homedir default (not the literal empty string)", async () => {
const { getOpenRigHome } = await import("../src/openrig-compat.js");
const { homedir } = await import("node:os");
const saved = process.env["OPENRIG_HOME"];
try {
process.env["OPENRIG_HOME"] = "";
const result = getOpenRigHome();
expect(result).toBe(`${homedir()}/.openrig`);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
// FF2 regression test (per qitem-20260511115845-ee9a4775): the
// prior 3 tests verify the openrig-compat function contract but
// do NOT prove daemon-lifecycle's module-level OPENRIG_DIR /
// STATE_FILE / LOG_FILE constants pick up OPENRIG_HOME at import
// time. Future code could hardcode "~/.openrig" in
// daemon-lifecycle.ts:66-69 and the prior tests would still pass.
//
// This test imports daemon-lifecycle FRESH twice via
// vi.resetModules + dynamic import, with distinct OPENRIG_HOME
// values per import. Discriminator-distinct paths (per banked
// feedback_poc_regression_must_discriminate) prove each module
// load reflects the env that was set at that moment. Will fail
// if a future contributor hardcodes a literal path or otherwise
// bypasses openrig-compat helpers at module level.
it("daemon-lifecycle module-level constants reflect OPENRIG_HOME at import time (slice 22 FF2)", async () => {
const saved = process.env["OPENRIG_HOME"];
try {
// First import — OPENRIG_HOME = blank-slate value
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-vm-blank-fresh";
vi.resetModules();
const blank = await import("../src/daemon-lifecycle.js");
// Second import — OPENRIG_HOME = populated value (distinct dir;
// not equal, not a symlink prefix). vi.resetModules clears the
// module cache so the second import re-evaluates module-level
// constants under the new env.
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-vm-populated-fresh";
vi.resetModules();
const populated = await import("../src/daemon-lifecycle.js");
// OPENRIG_DIR — direct snapshot of getOpenRigHome() at module load
expect(blank.OPENRIG_DIR).toBe("/Users/example/.openrig-vm-blank-fresh");
expect(populated.OPENRIG_DIR).toBe("/Users/example/.openrig-vm-populated-fresh");
expect(blank.OPENRIG_DIR).not.toBe(populated.OPENRIG_DIR);
// STATE_FILE / LOG_FILE — derived as path.join(OPENRIG_DIR, "daemon.{json,log}")
expect(blank.STATE_FILE).toBe("/Users/example/.openrig-vm-blank-fresh/daemon.json");
expect(populated.STATE_FILE).toBe("/Users/example/.openrig-vm-populated-fresh/daemon.json");
expect(blank.STATE_FILE).not.toBe(populated.STATE_FILE);
expect(blank.LOG_FILE).toBe("/Users/example/.openrig-vm-blank-fresh/daemon.log");
expect(populated.LOG_FILE).toBe("/Users/example/.openrig-vm-populated-fresh/daemon.log");
expect(blank.LOG_FILE).not.toBe(populated.LOG_FILE);
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
// Re-reset modules so subsequent tests (and the file-top
// import of daemon-lifecycle at line 4-19) aren't holding
// stale references to a module instance bound to a temp env.
vi.resetModules();
}
});
});
describe("V1 pre-release CLI/daemon Item 1 — transcript rotation tunables projection", () => {
it("projects transcriptsLines + transcriptsPollIntervalSeconds into OPENRIG_* env vars when provided", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
host: "127.0.0.1",
db: "/tmp/test.db",
transcriptsLines: 500,
transcriptsPollIntervalSeconds: 5,
});
expect(result["OPENRIG_TRANSCRIPTS_LINES"]).toBe("500");
expect(result["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"]).toBe("5");
});
it("omits the transcript rotation env vars when the opts are undefined so the rotation hook falls back to its own defaults", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
host: "127.0.0.1",
db: "/tmp/test.db",
});
expect(result["OPENRIG_TRANSCRIPTS_LINES"]).toBeUndefined();
expect(result["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"]).toBeUndefined();
});
it("scrubs inherited OPENRIG_TRANSCRIPTS_* from the base env when opts override is set, so the daemon honors the file-stored ConfigStore value", () => {
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/usr/bin",
OPENRIG_TRANSCRIPTS_LINES: "9999",
OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS: "60",
};
const result = buildDaemonEnv(baseEnv, {
port: 7433,
host: "127.0.0.1",
db: "/tmp/test.db",
transcriptsLines: 500,
transcriptsPollIntervalSeconds: 5,
});
expect(result["OPENRIG_TRANSCRIPTS_LINES"]).toBe("500");
expect(result["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"]).toBe("5");
});
});
});
describe("ensureWorkspaceScaffold", () => {
it("does not overwrite existing workspace files", () => {
const existing = new Set<string>([
"/tmp/ws",
path.join("/tmp/ws", "STEERING.md"),
path.join("/tmp/ws", "missions", "getting-started", "slices", "first-conveyor-run", "README.md"),
]);
const deps = mockDeps({
exists: vi.fn((p: string) => existing.has(p)),
mkdirp: vi.fn((p: string) => { existing.add(p); }),
writeFile: vi.fn(),
});
const result = ensureWorkspaceScaffold("/tmp/ws", deps);
expect(result.rootCreated).toBe(false);
expect(result.files.find((f) => f.relPath === "STEERING.md")?.skipped).toBe("exists");
expect(result.files.find((f) => f.relPath === "missions/getting-started/slices/first-conveyor-run/README.md")?.skipped)
.toBe("exists");
expect(deps.writeFile).not.toHaveBeenCalledWith(
path.join("/tmp/ws", "STEERING.md"),
expect.any(String),
);
});
});
+3 -3
View File
@@ -34,10 +34,10 @@ function makeDeps(overrides?: Partial<DoctorDeps>): DoctorDeps {
describe("runDoctorChecks", () => {
it("monorepo command-dir base resolves via the same daemon root seam as daemon start", () => {
const deps = makeDeps({
baseDir: "/Users/example/code/openrig/packages/cli/src/commands",
baseDir: "/Users/mschwarz/code/rigged/packages/cli/src/commands",
exists: (p) =>
p === "/Users/example/code/openrig/packages/daemon/dist/index.js"
|| p === "/Users/example/code/openrig/packages/ui/dist/index.html",
p === "/Users/mschwarz/code/rigged/packages/daemon/dist/index.js"
|| p === "/Users/mschwarz/code/rigged/packages/ui/dist/index.html",
});
const { checks } = runDoctorChecks(deps);
-22
View File
@@ -124,7 +124,6 @@ function createMockDaemon() {
if (req.method === "POST" && url.pathname === "/api/rigs/import") {
capturedImportHeaders = {
"x-rig-root": req.headers["x-rig-root"],
"x-cwd-override": req.headers["x-cwd-override"],
};
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
@@ -145,7 +144,6 @@ function createMockDaemon() {
capturedImportHeaders = {
"x-rig-root": req.headers["x-rig-root"],
"x-target-rig-id": req.headers["x-target-rig-id"],
"x-cwd-override": req.headers["x-cwd-override"],
};
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
@@ -300,16 +298,6 @@ describe("rig export + import", () => {
expect(capturedImportHeaders["x-target-rig-id"]).toBe("rig-123");
});
it("import --materialize-only with --cwd forwards absolute cwd override", async () => {
capturedImportHeaders = {};
const deps = importDeps(`version: "0.2"\nname: test\npods: []\n`);
const program = new Command();
program.addCommand(importCommand(deps));
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--materialize-only", "--cwd", "relative/project", "--rig-root", "/tmp"]));
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
});
// Test 8: import --instantiate with preflight fail (409)
it("import --instantiate: preflight conflict (409)", async () => {
const deps = importDeps("CONFLICT");
@@ -435,16 +423,6 @@ describe("rig export + import", () => {
expect(capturedImportHeaders["x-rig-root"]).toMatch(/\/my\/project/);
});
it("import --instantiate with --cwd forwards absolute cwd override", async () => {
capturedImportHeaders = {};
const deps = importDeps("schema_version: 1\nname: test\npods:\n - name: pod-a\n");
const program = new Command();
program.addCommand(importCommand(deps));
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate", "--cwd", "relative/project", "--rig-root", "/my/project"]));
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
});
// NS-T14: import --instantiate handoff includes attach command
it("import --instantiate success shows attach command", async () => {
const deps: ImportDeps = {
+4 -4
View File
@@ -35,7 +35,7 @@ hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: tester
user: wrandom
notes: "Tart VM"
`, (path) => {
const r = loadHostRegistry(path);
@@ -46,7 +46,7 @@ hosts:
id: "vm-claude-test",
transport: "ssh",
target: "vm-claude-test.local",
user: "tester",
user: "wrandom",
notes: "Tart VM",
});
}
@@ -62,14 +62,14 @@ hosts:
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: tester
user: wrandom
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.registry.hosts.map((h) => h.id)).toEqual(["vm-a", "laptop-b"]);
expect(r.registry.hosts[0]?.user).toBeUndefined();
expect(r.registry.hosts[1]?.user).toBe("tester");
expect(r.registry.hosts[1]?.user).toBe("wrandom");
}
});
});
-781
View File
@@ -1,781 +0,0 @@
// rig plugin CLI commands (slice 3.4) — list/show/used-by/validate.
//
// Per IMPL-PRD §4 + DESIGN.md §6 (CLI minimalism: read-only inspection only).
// Consumes plugin-discovery-service from slice 3.3 via HTTP routes
// (GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by).
// `validate` is local file inspection — reuses agent-manifest's
// validatePluginResources logic + skill frontmatter checks.
//
// All commands ship --json output for agent consumption per
// banked building-agent-software + IMPL-PRD §4.4 HG-4.5.
//
// Test pattern follows context-pack.test.ts (slice 3.4 mirror) — in-memory
// http.createServer daemon mock + Commander.parseAsync via captureLogs.
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import http from "node:http";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { Command } from "commander";
import { pluginCommand } from "../src/commands/plugin.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander.exitOverride */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
// Fixtures mirror the slice 3.3 PluginDiscoveryService wire shape verbatim
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132). Any drift
// from that source means the daemon contract changed and these fixtures
// must update in lockstep with the CLI wire types in plugin.ts.
const FIXTURE_PLUGIN_ENTRY = {
id: "openrig-core",
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
source: "vendored" as const,
sourceLabel: "vendored:openrig-core",
runtimes: ["claude", "codex"] as const,
path: "/home/op/.openrig/plugins/openrig-core",
lastSeenAt: "2026-05-11T05:00:00Z",
};
const FIXTURE_PLUGIN_DETAIL = {
entry: FIXTURE_PLUGIN_ENTRY,
claudeManifest: {
raw: { name: "openrig-core", version: "0.1.0", description: "OpenRig canonical skills and hooks", license: "Apache-2.0", repository: "github:mvschwarz/openrig-plugins" },
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
homepage: null,
repository: "github:mvschwarz/openrig-plugins",
license: "Apache-2.0",
},
codexManifest: {
raw: { name: "openrig-core", version: "0.1.0", description: "OpenRig canonical skills and hooks", license: "Apache-2.0" },
name: "openrig-core",
version: "0.1.0",
description: "OpenRig canonical skills and hooks",
homepage: null,
repository: null,
license: "Apache-2.0",
},
skills: [
{ name: "openrig-user", relativePath: "skills/openrig-user" },
{ name: "openrig-architect", relativePath: "skills/openrig-architect" },
],
hooks: [
{ runtime: "claude" as const, relativePath: "hooks/claude.json", events: ["SessionStart", "UserPromptSubmit", "Stop", "Notification"] },
{ runtime: "codex" as const, relativePath: "hooks/codex.json", events: ["SessionStart", "UserPromptSubmit", "Stop"] },
],
mcpServers: [],
};
const FIXTURE_USED_BY = [
{
agentName: "advisor-lead",
sourcePath: "/home/op/.openrig/specs/agents/advisor/lead/agent.yaml",
profiles: ["default"],
},
];
describe("rig plugin CLI (slice 3.4)", () => {
let server: http.Server;
let port: number;
let lastQuery: Record<string, string | undefined>;
beforeAll(async () => {
server = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost`);
lastQuery = Object.fromEntries(url.searchParams.entries());
if (url.pathname === "/api/plugins" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([FIXTURE_PLUGIN_ENTRY]));
return;
}
if (url.pathname === "/api/plugins/openrig-core" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PLUGIN_DETAIL));
return;
}
if (url.pathname === "/api/plugins/openrig-core/used-by" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_USED_BY));
return;
}
if (url.pathname === "/api/plugins/unreferenced/used-by" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
return;
}
if (url.pathname === "/api/plugins/missing" && req.method === "GET") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "plugin_not_found" }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
port = (server.address() as { port: number }).port;
resolve();
});
});
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
lastQuery = {};
});
// ============================================================
// rig plugin list (HG-4.1)
// ============================================================
describe("rig plugin list", () => {
it("--json returns the plugin entries from /api/plugins", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as Array<{ id: string }>;
expect(parsed).toHaveLength(1);
expect(parsed[0]?.id).toBe("openrig-core");
});
it("default (table) output prints id + version + runtimes + source label + path (real PluginEntry fields)", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
expect(out).toContain("openrig-core");
expect(out).toContain("0.1.0");
expect(out).toContain("vendored:openrig-core");
// Regression: real path field, not invented rootPath
expect(out).toContain("/home/op/.openrig/plugins/openrig-core");
// Regression: must NOT print invented count fields (they don't exist on PluginEntry)
expect(out).not.toMatch(/\bskillCount\b/);
expect(out).not.toMatch(/\bhookEventCount\b/);
expect(out).not.toMatch(/\bmcpServerCount\b/);
});
it("--runtime claude passes runtime filter to the daemon route", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "claude", "--json"]);
});
expect(lastQuery.runtime).toBe("claude");
});
it("--source vendored passes source filter to the daemon route", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--source", "vendored", "--json"]);
});
expect(lastQuery.source).toBe("vendored");
});
});
// ============================================================
// rig plugin show <id> (HG-4.2)
// ============================================================
describe("rig plugin show <id>", () => {
it("--json returns the plugin detail from /api/plugins/:id", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "show", "openrig-core", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as { entry: { id: string; path: string; lastSeenAt: string | null }; skills: unknown[]; hooks: unknown[] };
expect(parsed.entry.id).toBe("openrig-core");
expect(parsed.entry.path).toBe("/home/op/.openrig/plugins/openrig-core"); // real PluginEntry.path
expect(parsed.skills).toHaveLength(2);
expect(parsed.hooks).toHaveLength(2);
});
it("default (pretty) output uses REAL PluginDetail fields (name + relativePath + events array)", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "show", "openrig-core"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
// PluginEntry fields
expect(out).toContain("openrig-core");
expect(out).toContain("0.1.0");
expect(out).toContain("/home/op/.openrig/plugins/openrig-core"); // entry.path
expect(out).toContain("2026-05-11T05:00:00Z"); // entry.lastSeenAt
// Manifests — real PluginManifestSummary fields
expect(out).toMatch(/claude:/);
expect(out).toMatch(/codex:/);
expect(out).toContain("Apache-2.0");
expect(out).toContain("github:mvschwarz/openrig-plugins");
// Skills — uses skill.name + skill.relativePath (not invented id/path/description)
expect(out).toContain("openrig-user");
expect(out).toContain("openrig-architect");
expect(out).toContain("skills/openrig-user");
expect(out).toContain("skills/openrig-architect");
// Hooks — uses runtime + events array (not invented eventCount alone) +
// relativePath
expect(out).toContain("hooks/claude.json");
expect(out).toContain("hooks/codex.json");
expect(out).toContain("SessionStart");
expect(out).toContain("UserPromptSubmit");
expect(out).toContain("Notification"); // Claude event count = 4, includes Notification
});
it("missing plugin id returns non-zero exit + error to stderr", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "show", "missing"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toMatch(/not.*found|404|missing/i);
});
});
// ============================================================
// rig plugin used-by <id> (HG-4.3)
//
// Reverse query: AgentReference[] = { agentName, sourcePath, profiles[] }
// (PluginDiscoveryService L134-141 verbatim shape).
// Daemon route: GET /api/plugins/:id/used-by
// ============================================================
describe("rig plugin used-by <id>", () => {
it("--json returns the AgentReference[] from /api/plugins/:id/used-by", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "used-by", "openrig-core", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as Array<{ agentName: string; sourcePath: string; profiles: string[] }>;
expect(parsed).toHaveLength(1);
expect(parsed[0]?.agentName).toBe("advisor-lead");
expect(parsed[0]?.sourcePath).toBe("/home/op/.openrig/specs/agents/advisor/lead/agent.yaml");
expect(parsed[0]?.profiles).toEqual(["default"]);
});
it("default (pretty) output prints agentName + sourcePath + profiles for each reference", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "used-by", "openrig-core"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
// Real AgentReference fields verbatim — no invented fields
expect(out).toContain("advisor-lead");
expect(out).toContain("/home/op/.openrig/specs/agents/advisor/lead/agent.yaml");
expect(out).toContain("default");
});
it("empty result (no agents reference the plugin) is honest — does NOT exit non-zero", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
// Use 'unreferenced' which the http server returns [] for
await program.parseAsync(["node", "rig", "plugin", "used-by", "unreferenced"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
// Some honest message acknowledging zero refs (not crash, not silent empty)
expect(out.toLowerCase()).toMatch(/no agent|0|none|not referenced/);
});
it("--json on empty result returns []", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { logs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "used-by", "unreferenced", "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as unknown[];
expect(parsed).toEqual([]);
});
});
// ============================================================
// rig plugin list — filter-value validation (pre-close punch from
// velocity-guard 3.4.A repair verdict)
//
// CLI-side validation so typos like --source rig-cwd don't look
// filtered while the daemon silently returns all plugins.
// ============================================================
describe("rig plugin list — filter validation", () => {
it("rejects invalid --runtime value with clear error + non-zero exit", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "bogus"]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n");
expect(err).toMatch(/--runtime/);
expect(err).toMatch(/bogus/);
// Should name the allowed values so operator can correct
expect(err.toLowerCase()).toMatch(/claude|codex/);
});
it("rejects invalid --source value (rig-cwd typo case) with clear error + non-zero exit", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { errLogs, exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--source", "rig-cwd"]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n");
expect(err).toMatch(/--source/);
expect(err).toMatch(/rig-cwd/);
// Should name the allowed values
expect(err).toMatch(/vendored|claude-cache|codex-cache/);
});
it("accepts valid --runtime + --source values (regression: still passes through correctly)", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand(runningDeps(port)));
const { exitCode } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "claude", "--source", "vendored", "--json"]);
});
expect(exitCode).toBeUndefined();
expect(lastQuery.runtime).toBe("claude");
expect(lastQuery.source).toBe("vendored");
});
});
// ============================================================
// rig plugin validate <path> (HG-4.4)
//
// Local file inspection — does NOT require daemon. Validates:
// - .claude-plugin/plugin.json + .codex-plugin/plugin.json shape
// (name + version required; description required on Codex)
// - At least one manifest dir is present
// - Each skills/<id>/SKILL.md has frontmatter with name (non-empty)
// + description (non-empty, ≤1024 chars per agentskills.io)
//
// Output: error list; exit code 0 when valid, 1 when any errors.
// ============================================================
describe("rig plugin validate <path>", () => {
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "plugin-validate-"));
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
function writeManifest(relativePath: string, contents: Record<string, unknown>) {
const fullPath = join(tmpRoot, relativePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, JSON.stringify(contents, null, 2));
}
function writeSkill(skillId: string, frontmatter: string, body = "# body\n") {
const skillPath = join(tmpRoot, "skills", skillId, "SKILL.md");
mkdirSync(dirname(skillPath), { recursive: true });
writeFileSync(skillPath, `---\n${frontmatter}\n---\n\n${body}`);
}
it("valid dual-manifest plugin tree passes validation (exit 0)", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0", description: "Test",
});
writeManifest(".codex-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0", description: "Test",
});
writeSkill("foo", "name: foo\ndescription: A foo skill");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, logs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n").toLowerCase()).toMatch(/valid|ok|pass/);
});
it("plugin with no manifest dirs at all FAILS validation", async () => {
writeSkill("foo", "name: foo\ndescription: A foo skill");
// No .claude-plugin/ + no .codex-plugin/
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/manifest|plugin\.json/);
});
it("Codex manifest missing description FAILS (Codex requires description)", async () => {
writeManifest(".codex-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
// description MISSING
});
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/codex.*description|description.*codex/);
});
it("manifest missing required name FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", {
version: "1.0.0", description: "Test",
// name MISSING
});
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/name/);
});
it("skill missing frontmatter name FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
writeSkill("bad-skill", "description: a description but no name");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/bad-skill/);
expect(err).toMatch(/name/);
});
it("skill frontmatter description >1024 chars FAILS (agentskills.io limit)", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
const longDescription = "x".repeat(1100);
writeSkill("verbose", `name: verbose\ndescription: ${longDescription}`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/verbose/);
expect(err).toMatch(/1024|description.*length|too long/);
});
it("--json output returns structured { valid, errors } shape", async () => {
writeManifest(".claude-plugin/plugin.json", {
name: "test-plugin", version: "1.0.0",
});
writeSkill("foo", "name: foo\ndescription: A foo skill");
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, logs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot, "--json"]);
});
expect(exitCode).toBeUndefined();
const parsed = JSON.parse(logs.join("\n")) as { valid: boolean; errors: string[] };
expect(parsed.valid).toBe(true);
expect(parsed.errors).toEqual([]);
});
it("non-existent path FAILS with clear error", async () => {
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", join(tmpRoot, "nonexistent")]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n").toLowerCase()).toMatch(/not found|does not exist|enoent/);
});
// ============================================================
// velocity-guard 3.4.C BLOCKING-CONCERN regressions:
// regex-based validation could stamp `name: ""` quoted-empty as
// valid; YAML-parser-based validation catches it. Also null/non-
// object manifest results.
// ============================================================
it("BLOCKING-FIX: skill with quoted-empty name FAILS (regex would have passed)", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
writeSkill("empty-name", `name: ""\ndescription: A real description`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/empty-name/);
expect(err).toMatch(/name/);
});
it("BLOCKING-FIX: skill with quoted-empty description FAILS (regex would have passed)", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
writeSkill("empty-desc", `name: empty-desc\ndescription: ""`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/empty-desc/);
expect(err).toMatch(/description/);
});
it("BLOCKING-FIX: skill with malformed YAML frontmatter FAILS (not parseable as object)", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
// Malformed: unclosed bracket
writeSkill("bad-yaml", `name: foo\ndescription: [unclosed`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/bad-yaml/);
expect(err).toMatch(/yaml|parse|invalid/);
});
it("BLOCKING-FIX: skill with non-object frontmatter (e.g. just a string) FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
// YAML scalar — parses as string, not object
writeSkill("not-object", `just a string`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/not-object/);
expect(err).toMatch(/object|map/);
});
it("BLOCKING-FIX: skill name as non-string (e.g. number) FAILS", async () => {
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
// YAML parses 42 as a number, not string
writeSkill("number-name", `name: 42\ndescription: A skill`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/number-name/);
expect(err).toMatch(/name/);
});
it("BLOCKING-FIX: manifest plugin.json containing null FAILS (not crashes)", async () => {
// Write literal "null" as the manifest body — JSON.parse returns null
const claudeManifestPath = join(tmpRoot, ".claude-plugin", "plugin.json");
mkdirSync(dirname(claudeManifestPath), { recursive: true });
writeFileSync(claudeManifestPath, "null");
writeSkill("foo", `name: foo\ndescription: A skill`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/manifest|object/);
});
it("BLOCKING-FIX: manifest plugin.json containing array FAILS (must be object)", async () => {
const claudeManifestPath = join(tmpRoot, ".claude-plugin", "plugin.json");
mkdirSync(dirname(claudeManifestPath), { recursive: true });
writeFileSync(claudeManifestPath, JSON.stringify(["not", "an", "object"]));
writeSkill("foo", `name: foo\ndescription: A skill`);
const program = new Command();
program.exitOverride();
program.addCommand(pluginCommand());
const { exitCode, errLogs } = await captureLogs(async () => {
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
});
expect(exitCode).toBe(1);
const err = errLogs.join("\n").toLowerCase();
expect(err).toMatch(/manifest|object/);
});
});
});
@@ -1,236 +0,0 @@
// PL-012 Token / Context Usage Surface v0 — `rig ps --nodes --filter
// contextUsage.percent>=N` + `contextUsage.state=…` + `--fields contextUsage`.
// Mirrors the PL-019 mock-daemon harness so this stays independent of
// any other slice in flight.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { psCommand } from "../src/commands/ps.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander throws on exitOverride */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
function nodeAt(logicalId: string, percent: number | null, fresh = true) {
return {
rigId: "rig-1",
rigName: "demo",
logicalId,
podId: "p-1",
canonicalSessionName: `demo-${logicalId}`,
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
contextUsage: {
availability: percent === null ? "unknown" : "known",
usedPercentage: percent,
fresh,
sampledAt: percent === null ? null : "2026-05-04T12:00:00.000Z",
},
};
}
describe("PL-012 ps --filter contextUsage.* + CTX field", () => {
let server: http.Server;
let port: number;
let nodesByRig: Record<string, unknown[]>;
beforeAll(async () => {
nodesByRig = {};
server = http.createServer((req, res) => {
const m = req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes(?:\?.*)?$/);
if (m && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(nodesByRig[decodeURIComponent(m[1]!)] ?? []));
} else if (req.url === "/api/ps" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ rigId: "rig-1", name: "demo", nodeCount: 4, runningCount: 4, status: "running", uptime: "1h", latestSnapshot: null }]));
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as { port: number }).port;
});
afterAll(() => server.close());
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(psCommand(runningDeps(port)));
return prog;
}
it("--filter contextUsage.percent>=80 narrows to critical seats", async () => {
nodesByRig["rig-1"] = [
nodeAt("alpha", 90),
nodeAt("beta", 65),
nodeAt("gamma", 30),
nodeAt("delta", null),
];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent>=80"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["alpha"]);
});
it("--filter contextUsage.percent<60 picks low/ok seats only", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 90), nodeAt("beta", 65), nodeAt("gamma", 30)];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent<60"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["gamma"]);
});
it("--filter contextUsage.percent>=N excludes nodes with no sample (unknown availability)", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 90), nodeAt("delta", null)];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent>=0"]);
});
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["alpha"]);
});
it("--filter contextUsage.state=critical maps tier semantics correctly", async () => {
nodesByRig["rig-1"] = [
nodeAt("alpha", 90), // critical
nodeAt("beta", 65), // warning
nodeAt("gamma", 30), // low
nodeAt("delta", null),// unknown
];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.state=critical"]);
});
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["alpha"]);
});
it("--filter contextUsage.state=unknown picks no-sample seats", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 90), nodeAt("delta", null)];
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.state=unknown"]);
});
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["delta"]);
});
it("--filter contextUsage.percent>=invalid fails fast with three-part error and exit 1", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 90)];
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent>=banana"]);
});
expect(exitCode).toBe(1);
const out = errLogs.join("\n");
expect(out).toContain("contextUsage.percent>='banana'");
expect(out).toContain("a finite number");
expect(out).toContain("rig ps --nodes --fields contextUsage --json");
});
it("--filter contextUsage.state=bogus fails fast with three-part error and exit 1", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 90)];
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.state=lol-no"]);
});
expect(exitCode).toBe(1);
const out = errLogs.join("\n");
expect(out).toContain("contextUsage.state='lol-no'");
expect(out).toContain("critical, low, unknown, warning");
expect(out).toContain("rig ps --nodes --fields contextUsage --json");
});
it("--filter contextUsage.percent>=N rejects equality-only on non-numeric keys cleanly", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 90)];
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "name>=demo"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("numeric comparator on a non-numeric key");
});
it("--fields contextUsage projects only the contextUsage column", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 73)];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--fields", "logicalId,contextUsage"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries[0]).toEqual({ logicalId: "alpha", contextUsage: expect.objectContaining({ usedPercentage: 73 }) });
});
it("CTX column appears in human output", async () => {
nodesByRig["rig-1"] = [nodeAt("alpha", 73), nodeAt("delta", null)];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
});
expect(exitCode).toBeUndefined();
const out = logs.join("\n");
expect(out).toContain("CTX");
expect(out).toContain("73%");
expect(out).toContain("??"); // unknown placeholder
});
});
-267
View File
@@ -1,267 +0,0 @@
// PL-019 item 1: --filter agentActivity.state=<state> + --active sugar.
// Focused tests; the existing ps.test.ts covers the broader filter
// machinery surface. We mirror its mock-daemon harness so this stays
// independent of any other slice in flight.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { psCommand } from "../src/commands/ps.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander throws on exitOverride; logged separately */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
const NODE_RUNNING = {
rigId: "rig-1",
rigName: "demo",
logicalId: "alpha",
podId: "p-1",
canonicalSessionName: "demo-alpha",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "running" as const,
reason: "mid_work_pattern",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: "Working on x",
},
};
const NODE_IDLE = {
rigId: "rig-1",
rigName: "demo",
logicalId: "beta",
podId: "p-1",
canonicalSessionName: "demo-beta",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "idle" as const,
reason: "idle_prompt",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: "$ ",
},
};
const NODE_NEEDS_INPUT = {
rigId: "rig-1",
rigName: "demo",
logicalId: "gamma",
podId: "p-1",
canonicalSessionName: "demo-gamma",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "needs_input" as const,
reason: "approval_pending",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: "Approve?",
},
};
const NODE_UNKNOWN = {
rigId: "rig-1",
rigName: "demo",
logicalId: "delta",
podId: "p-1",
canonicalSessionName: "demo-delta",
nodeKind: "agent" as const,
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready" as const,
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
agentActivity: {
state: "unknown" as const,
reason: "capture_failed",
evidenceSource: "pane_heuristic",
sampledAt: "2026-05-04T12:00:00.000Z",
evidence: null,
},
};
describe("PL-019 ps --filter agentActivity.state + --active", () => {
let server: http.Server;
let port: number;
let nodesByRig: Record<string, unknown[]>;
beforeAll(async () => {
nodesByRig = {};
server = http.createServer((req, res) => {
const m = req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes(?:\?.*)?$/);
if (m && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(nodesByRig[decodeURIComponent(m[1]!)] ?? []));
} else if (req.url === "/api/ps" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ rigId: "rig-1", name: "demo", nodeCount: 4, runningCount: 4, status: "running", uptime: "1h", latestSnapshot: null }]));
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as { port: number }).port;
});
afterAll(() => server.close());
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(psCommand(runningDeps(port)));
return prog;
}
it("--filter agentActivity.state=running narrows to the running node", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=running"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries).toHaveLength(1);
expect(entries[0].logicalId).toBe("alpha");
expect(entries[0].agentActivity.state).toBe("running");
});
it("--active is identical to --filter agentActivity.state=running on same fixture", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
const a = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=running"]);
});
const b = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active"]);
});
expect(a.exitCode).toBeUndefined();
expect(b.exitCode).toBeUndefined();
expect(a.logs.join("")).toBe(b.logs.join(""));
});
it("--filter agentActivity.state=needs_input picks the amber-state node", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=needs_input"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries).toHaveLength(1);
expect(entries[0].logicalId).toBe("gamma");
});
it("--filter agentActivity.state=invalid fails fast with three-part error and exit 1", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING];
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=lol-no"]);
});
expect(exitCode).toBe(1);
const errOutput = errLogs.join("\n");
// What failed
expect(errOutput).toContain("agentActivity.state='lol-no'");
// What's allowed (sorted enum)
expect(errOutput).toContain("idle, needs_input, running, unknown");
// What to do (next-step pointer)
expect(errOutput).toContain("rig ps --nodes --fields agentActivity --json");
});
it("--active combined with --filter is rejected explicitly with exit 1", async () => {
nodesByRig["rig-1"] = [NODE_RUNNING];
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active", "--filter", "status=running"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("--active and --filter cannot be combined");
});
it("--filter agentActivity.state=running excludes nodes whose agentActivity is missing", async () => {
const NODE_NO_ACTIVITY = {
...NODE_RUNNING,
logicalId: "epsilon",
canonicalSessionName: "demo-epsilon",
agentActivity: undefined,
};
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_NO_ACTIVITY];
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active"]);
});
expect(exitCode).toBeUndefined();
const env = JSON.parse(logs.join(""));
const entries = Array.isArray(env) ? env : env.entries;
expect(entries).toHaveLength(1);
expect(entries[0].logicalId).toBe("alpha");
});
});
+1 -3
View File
@@ -616,9 +616,7 @@ describe("Ps CLI", () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--filter", "noequals"]);
});
// PL-012: error message now names the comparator family since
// numeric ops (>=, >, <=, <, =) are accepted.
expect(logs.some((l) => l.includes("--filter must be key") && l.includes("op = "))).toBe(true);
expect(logs.some((l) => l.includes("--filter must be key=value"))).toBe(true);
expect(exitCode).toBe(1);
});
+2 -2
View File
@@ -173,7 +173,7 @@ describe("rig restore-check", () => {
rigId: "rig-1",
rigName: "test-rig",
action: "restore_from_latest_snapshot",
command: "rig up --existing test-rig",
command: "rig restore snap-123 --rig rig-1",
reason: "Rig has a latest snapshot and one or more seats are not running/ready.",
safe: false,
blocking: true,
@@ -189,7 +189,7 @@ describe("rig restore-check", () => {
const output = logs.join("\n");
expect(output).toContain("RECOVERY: ACTIONABLE");
expect(output).toContain("rig up --existing test-rig");
expect(output).toContain("rig restore snap-123 --rig rig-1");
expect(output).toContain("1 rig can be recovered");
});
+47 -47
View File
@@ -52,10 +52,10 @@ function validSummary(): Record<string, unknown> {
source_session_id: "velocity-driver@openrig-velocity",
source_rig: "openrig-velocity",
source_runtime: "claude-code",
source_cwd: "/Users/example/code/projects/openrig-hub",
source_cwd: "/Users/wrandom/code/projects/openrig-hub",
target_rig: "openrig-velocity",
target_runtime: "claude-code",
target_workspace_root: "/Users/example/code/projects/openrig-hub",
target_workspace_root: "/Users/wrandom/code/projects/openrig-hub",
default_target_repo: null,
role_pointer: "rigs/openrig-velocity/state/velocity/driver-role.md",
bounded_latest_transcript: {
@@ -472,11 +472,11 @@ describe("M2b runtime-detect", () => {
describe("M2b codex-jsonl-parser", () => {
it("parses session_meta + response_item messages into StructuredTranscript", () => {
const content = `{"type":"session_meta","payload":{"cwd":"/Users/example/code/projects/openrig-hub","id":"abc-123"}}
const content = `{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"abc-123"}}
{"type":"response_item","timestamp":"2026-05-02T01:00:00Z","payload":{"type":"message","role":"user","content":"Hello world"}}
{"type":"response_item","timestamp":"2026-05-02T01:00:01Z","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"Hi back"}]}}`;
const result = parseCodexJsonl(content);
expect(result.sessionMeta?.cwd).toBe("/Users/example/code/projects/openrig-hub");
expect(result.sessionMeta?.cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
expect(result.sessionMeta?.sessionId).toBe("abc-123");
expect(result.messageCount).toBe(2);
expect(result.messages[0]!.role).toBe("user");
@@ -488,7 +488,7 @@ describe("M2b codex-jsonl-parser", () => {
it("filters reasoning + function_call + custom_tool_call records and counts them", () => {
const content = `{"type":"response_item","payload":{"type":"reasoning","content":"<thinking>"}}
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/example/x.txt\\"}"}}
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/wrandom/x.txt\\"}"}}
{"type":"response_item","payload":{"type":"custom_tool_call","input":"some input"}}
{"type":"response_item","payload":{"type":"message","role":"user","content":"kept"}}`;
const result = parseCodexJsonl(content);
@@ -526,7 +526,7 @@ not valid json garbage line
it("extracts paths from message content and tool args; sorts by frequency", () => {
const content = `{"type":"response_item","payload":{"type":"message","role":"user","content":"see packages/cli/src/index.ts and packages/cli/src/index.ts again"}}
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/example/code/projects/openrig-hub/README.md\\"}"}}`;
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/wrandom/code/projects/openrig-hub/README.md\\"}"}}`;
const result = parseCodexJsonl(content);
expect(result.paths.length).toBeGreaterThan(0);
const indexEntry = result.paths.find((p) => p.path === "packages/cli/src/index.ts");
@@ -549,15 +549,15 @@ describe("M2b claude-transcript-parser", () => {
it("captures sessionMeta from the first record carrying cwd + sessionId", () => {
const content = `{"type":"customTitle","customTitle":"x","sessionId":"sess-2"}
{"type":"user","message":{"role":"user","content":"first"},"cwd":"/Users/example/code","sessionId":"sess-2"}`;
{"type":"user","message":{"role":"user","content":"first"},"cwd":"/Users/wrandom/code","sessionId":"sess-2"}`;
const result = parseClaudeTranscript(content);
expect(result.sessionMeta?.cwd).toBe("/Users/example/code");
expect(result.sessionMeta?.cwd).toBe("/Users/wrandom/code");
expect(result.sessionMeta?.sessionId).toBe("sess-2");
});
it("filters attachments → raw_tool_outputs counter", () => {
const content = `{"type":"user","message":{"role":"user","content":"see attached"},"cwd":"/x","sessionId":"s"}
{"type":"attachment","attachment":{"path":"/Users/example/x.txt","content":"file body"},"cwd":"/x","sessionId":"s"}`;
{"type":"attachment","attachment":{"path":"/Users/wrandom/x.txt","content":"file body"},"cwd":"/x","sessionId":"s"}`;
const result = parseClaudeTranscript(content);
expect(result.messageCount).toBe(1);
expect(result.omittedCounts.raw_tool_outputs).toBe(1);
@@ -658,7 +658,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
{
type: "tool_result",
tool_use_id: "toolu_1",
content: "/Users/example/code/projects/openrig-hub",
content: "/Users/wrandom/code/projects/openrig-hub",
},
],
},
@@ -673,7 +673,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
// messages but the nested tool_result must STILL be counted.)
expect(r.omittedCounts.function_call_output).toBe(1);
expect(r.omittedCounts.raw_tool_outputs).toBe(1);
expect(r.paths.some((p) => p.path === "/Users/example/code/projects/openrig-hub")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
});
it("counts each tool_use part in a multi-tool assistant turn", () => {
@@ -683,7 +683,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
role: "assistant",
content: [
{ type: "tool_use", id: "t1", name: "Bash", input: { cmd: "ls" } },
{ type: "tool_use", id: "t2", name: "Read", input: { path: "/Users/example/code/x.md" } },
{ type: "tool_use", id: "t2", name: "Read", input: { path: "/Users/wrandom/code/x.md" } },
{ type: "text", text: "running both" },
],
},
@@ -692,12 +692,12 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
});
const r = parseClaudeTranscript(content);
expect(r.omittedCounts.function_call_output).toBe(2);
expect(r.paths.some((p) => p.path === "/Users/example/code/x.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/x.md")).toBe(true);
});
it("counts each tool_result part in a multi-result user turn", () => {
// Note: the Velocity-prior-art path pattern is greedy on the
// /Users/example prefix and includes trailing whitespace and word
// /Users/wrandom prefix and includes trailing whitespace and word
// chars until end-of-line; using \n separators here so each path
// is matched cleanly.
const content = JSON.stringify({
@@ -705,8 +705,8 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "t1", content: "/Users/example/code/a.md\nrunning" },
{ type: "tool_result", tool_use_id: "t2", content: "/Users/example/code/b.md\nrunning" },
{ type: "tool_result", tool_use_id: "t1", content: "/Users/wrandom/code/a.md\nrunning" },
{ type: "tool_result", tool_use_id: "t2", content: "/Users/wrandom/code/b.md\nrunning" },
],
},
cwd: "/x",
@@ -714,8 +714,8 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
});
const r = parseClaudeTranscript(content);
expect(r.omittedCounts.raw_tool_outputs).toBe(2);
expect(r.paths.some((p) => p.path === "/Users/example/code/a.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/example/code/b.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/a.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/b.md")).toBe(true);
});
it("mixed text + tool_use: visible text kept; tool_use counted", () => {
@@ -754,7 +754,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
});
const r = parseClaudeTranscript(content);
expect(r.omittedCounts.function_call_output).toBe(1);
// No /Users/example or recognized prefix path in the input.
// No /Users/wrandom or recognized prefix path in the input.
expect(r.paths.length).toBe(0);
});
@@ -787,7 +787,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
type: "tool_result",
tool_use_id: "t1",
content: [
{ type: "text", text: "found at /Users/example/code/projects/openrig-hub/README.md" },
{ type: "text", text: "found at /Users/wrandom/code/projects/openrig-hub/README.md" },
],
},
],
@@ -797,7 +797,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
});
const r = parseClaudeTranscript(content);
expect(r.omittedCounts.raw_tool_outputs).toBe(1);
expect(r.paths.some((p) => p.path === "/Users/example/code/projects/openrig-hub/README.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/projects/openrig-hub/README.md")).toBe(true);
});
it("kept text-only assistant turn (no tools): counters stay at zero", () => {
@@ -846,14 +846,14 @@ describe("M2c-CLI packet-writer atomic emission", () => {
function buildBaselineOpts(targetDir: string): Record<string, unknown> {
return {
targetDir,
structured: parseCodexJsonl(`{"type":"session_meta","payload":{"cwd":"/Users/example/code/projects/openrig-hub","id":"src-session-id"}}
structured: parseCodexJsonl(`{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"src-session-id"}}
{"type":"response_item","timestamp":"2026-05-02T01:00:00Z","payload":{"type":"message","role":"user","content":"Walk through the M1 contract"}}
{"type":"response_item","timestamp":"2026-05-02T01:00:01Z","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"Per § 2.1 the schema requires 21 fields"}]}}`),
sourceRuntime: "codex" as const,
targetRig: "openrig-velocity-claude",
targetRuntime: "claude-code" as const,
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
defaultTargetRepo: "/Users/example/code/projects/openrig-hub/openrig",
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
rolePointer: "rigs/openrig-velocity-claude/state/velocity/driver-role.md",
currentWorkSummary: "Working on Restore-Packet vertical M2c-CLI chunk.",
nextOwner: "self",
@@ -862,7 +862,7 @@ describe("M2c-CLI packet-writer atomic emission", () => {
sourceTrustRanking: ["rig_whoami", "bounded_latest_transcript"],
sourceSessionId: "velocity-driver@openrig-velocity",
sourceRig: "openrig-velocity",
sourceCwd: "/Users/example/code/projects/openrig-hub",
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
generatorVersion: "[email protected]",
includeFullTranscript: false,
};
@@ -1008,7 +1008,7 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
// + reasoning records + a credential-pattern needle for redaction.
function syntheticCodexJsonlFor(role: string): string {
return [
`{"type":"session_meta","payload":{"cwd":"/Users/example/code/projects/openrig-hub","id":"velocity-${role}@openrig-velocity-code"}}`,
`{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"velocity-${role}@openrig-velocity-code"}}`,
`{"type":"response_item","payload":{"type":"reasoning","content":"<thinking>"}}`,
`{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"packages/cli/src/index.ts\\"}"}}`,
`{"type":"response_item","timestamp":"2026-04-23T18:08:00Z","payload":{"type":"message","role":"user","content":"${role}: please review the diff"}}`,
@@ -1038,8 +1038,8 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
sourceRuntime: "codex",
targetRig: "openrig-velocity-claude",
targetRuntime: "claude-code",
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
defaultTargetRepo: "/Users/example/code/projects/openrig-hub/openrig",
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
rolePointer: `rigs/openrig-velocity-claude/state/velocity/${role}-role.md`,
currentWorkSummary: `Synthetic ${role} round-trip for Velocity-shape parity.`,
nextOwner: "self",
@@ -1048,7 +1048,7 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
sourceTrustRanking: ["rig_whoami", "bounded_latest_transcript"],
sourceSessionId: `velocity-${role}@openrig-velocity-code`,
sourceRig: "openrig-velocity-code",
sourceCwd: "/Users/example/code/projects/openrig-hub",
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
generatorVersion: "[email protected]",
includeFullTranscript: true,
});
@@ -1058,7 +1058,7 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
expect(validation.valid, JSON.stringify(validation.errors)).toBe(true);
// Provenance preserved at relevant fields:
expect(summary.source_session_id).toBe(`velocity-${role}@openrig-velocity-code`);
expect(summary.source_cwd).toBe("/Users/example/code/projects/openrig-hub");
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
expect(summary.bounded_latest_transcript.message_count).toBe(2);
// Omitted-class enumeration matches what the parser counted:
expect(summary.omitted_classes).toContain("reasoning_records");
@@ -1101,7 +1101,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
postExpectText: vi.fn(async () => { throw new Error("mock-daemon: postExpectText should not be called"); }),
};
// Synthetic Codex JSONL the mock-daemon serves on the new full-read route.
const fixtureContent = `{"type":"session_meta","payload":{"cwd":"/Users/example/code/x","id":"src-session"}}
const fixtureContent = `{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/x","id":"src-session"}}
{"type":"response_item","payload":{"type":"message","role":"user","content":"hello from session"}}
{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"reply"}]}}`;
@@ -1111,7 +1111,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
get: vi.fn(async (p: string) => {
requestedPaths.push(p);
if (p === "/api/transcripts/src-session/full") {
return { status: 200, data: { content: fixtureContent, cwd: "/Users/example/code/x" } };
return { status: 200, data: { content: fixtureContent, cwd: "/Users/wrandom/code/x" } };
}
return { status: 404, data: { error: "not found" } };
}),
@@ -1163,17 +1163,17 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
{ type: "text", text: "checking now" },
],
},
cwd: "/Users/example/code/projects/openrig-hub",
cwd: "/Users/wrandom/code/projects/openrig-hub",
sessionId: "claude-session-id",
}) + "\n" + JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/example/code/projects/openrig-hub" },
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/wrandom/code/projects/openrig-hub" },
],
},
cwd: "/Users/example/code/projects/openrig-hub",
cwd: "/Users/wrandom/code/projects/openrig-hub",
sessionId: "claude-session-id",
});
const structured = parseClaudeTranscript(content);
@@ -1185,7 +1185,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
sourceRuntime: "claude-code",
targetRig: "openrig-velocity-claude",
targetRuntime: "claude-code",
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
defaultTargetRepo: null,
rolePointer: "rigs/openrig-velocity-claude/state/velocity/driver-role.md",
currentWorkSummary: "Nested-content round-trip for M2c-CLI.",
@@ -1195,7 +1195,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
sourceTrustRanking: ["rig_whoami"],
sourceSessionId: "claude-session-id",
sourceRig: "openrig-velocity-claude",
sourceCwd: "/Users/example/code/projects/openrig-hub",
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
generatorVersion: "[email protected]",
includeFullTranscript: false,
});
@@ -1205,7 +1205,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
expect(summary.omitted_classes).toContain("raw_tool_outputs");
// Touched-files inventory captured the path from the omitted tool_result.
expect(summary.touched_files.top_paths.some((p: { path: string }) =>
p.path === "/Users/example/code/projects/openrig-hub")).toBe(true);
p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
});
});
@@ -1245,7 +1245,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
JSON.stringify({
type: "session_meta",
payload: {
cwd: "/Users/example/code/projects/openrig-hub",
cwd: "/Users/wrandom/code/projects/openrig-hub",
id: "velocity-driver@openrig-velocity",
},
}),
@@ -1314,7 +1314,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
// Bug reproducer's expected values per BLOCK artifact:
expect(summary.source_session_id).toBe("velocity-driver@openrig-velocity");
expect(summary.source_rig).toBe("openrig-velocity");
expect(summary.source_cwd).toBe("/Users/example/code/projects/openrig-hub");
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
// session_session_id MUST NOT be the file path.
expect(summary.source_session_id).not.toContain("/source.jsonl");
expect(summary.source_session_id).not.toContain(tmpRoot);
@@ -1468,7 +1468,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
describe("M2c-Daemon final regression — Velocity 4-pack round-trip via CLI", () => {
let tmpRoot: string;
const VELOCITY_ROOT = "/Users/example/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
const VELOCITY_ROOT = "/Users/wrandom/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
beforeEach(async () => {
const fs = await import("node:fs");
@@ -1641,7 +1641,7 @@ describe("M2c-Daemon final regression — CLI-surface redaction + omitted round-
{ type: "text", text: "checking now" },
],
},
cwd: "/Users/example/code/projects/openrig-hub",
cwd: "/Users/wrandom/code/projects/openrig-hub",
sessionId: "claude-final@openrig-velocity-claude",
}),
JSON.stringify({
@@ -1649,10 +1649,10 @@ describe("M2c-Daemon final regression — CLI-surface redaction + omitted round-
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/example/code/projects/openrig-hub" },
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/wrandom/code/projects/openrig-hub" },
],
},
cwd: "/Users/example/code/projects/openrig-hub",
cwd: "/Users/wrandom/code/projects/openrig-hub",
sessionId: "claude-final@openrig-velocity-claude",
}),
].join("\n"), "utf8");
@@ -1679,10 +1679,10 @@ describe("M2c-Daemon final regression — CLI-surface redaction + omitted round-
// Provenance from session_meta-equivalent (Claude per-record sessionId).
expect(summary.source_session_id).toBe("claude-final@openrig-velocity-claude");
expect(summary.source_rig).toBe("openrig-velocity-claude");
expect(summary.source_cwd).toBe("/Users/example/code/projects/openrig-hub");
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
// Touched-files inventory captured the path from the omitted tool_result.
expect(summary.touched_files.top_paths.some((p: { path: string }) =>
p.path === "/Users/example/code/projects/openrig-hub")).toBe(true);
p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
});
});
@@ -1718,7 +1718,7 @@ describe("M3 restore-packet read + validate", () => {
fs.writeFileSync(src, [
JSON.stringify({
type: "session_meta",
payload: { cwd: "/Users/example/code/projects/openrig-hub", id: "m3-driver@m3-rig" },
payload: { cwd: "/Users/wrandom/code/projects/openrig-hub", id: "m3-driver@m3-rig" },
}),
JSON.stringify({
type: "response_item",
-565
View File
@@ -1,565 +0,0 @@
// release-0.3.2 slice 12 — `rig scope` command-surface integration
// tests. Drives the commander tree end-to-end with a tmp substrate
// fixture so every HG-N gate has direct coverage.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { Command } from "commander";
import { scopeCommand } from "../src/commands/scope.js";
import { readFrontmatter } from "../src/lib/scope/scope-fs.js";
function mktemp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-cmd-"));
}
function writeFile(p: string, content: string): void {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, "utf8");
}
function seedSubstrate(): { root: string; missionsRoot: string } {
const root = mktemp();
const missionsRoot = path.join(root, "openrig-work", "missions");
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.name", "T"], { stdio: "ignore" });
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "01-existing", "README.md"),
"---\nid: OPR.0.3.2.1\nstatus: active\n---\n# existing\n",
);
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
writeFile(
path.join(missionsRoot, "backlog", "slices", "01-debt-foo", "README.md"),
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# debt\n",
);
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
return { root, missionsRoot };
}
interface CaptureResult {
exitCode: number;
stdout: string;
stderr: string;
}
async function run(args: string[], workspace: string): Promise<CaptureResult> {
const stdoutBuf: string[] = [];
const stderrBuf: string[] = [];
const origWrite = process.stdout.write.bind(process.stdout);
const origErrWrite = process.stderr.write.bind(process.stderr);
const origExit = process.exit;
let exitCode = 0;
process.stdout.write = ((chunk: unknown) => { stdoutBuf.push(String(chunk)); return true; }) as typeof process.stdout.write;
process.stderr.write = ((chunk: unknown) => { stderrBuf.push(String(chunk)); return true; }) as typeof process.stderr.write;
process.exit = ((code?: number) => {
exitCode = code ?? 0;
throw new Error(`__EXIT__${exitCode}`);
}) as typeof process.exit;
const program = new Command();
program.addCommand(scopeCommand());
program.exitOverride();
try {
await program.parseAsync(["node", "rig", "scope", ...args, "--workspace", path.dirname(workspace)]);
} catch (err) {
const msg = (err as Error).message ?? "";
if (!msg.startsWith("__EXIT__")) {
stderrBuf.push(msg + "\n");
}
} finally {
process.stdout.write = origWrite;
process.stderr.write = origErrWrite;
process.exit = origExit;
}
return {
exitCode,
stdout: stdoutBuf.join(""),
stderr: stderrBuf.join(""),
};
}
// ---------------------------------------------------------------------
// HG-1 + HG-9: rig scope slice ls --json + state filter
// ---------------------------------------------------------------------
describe("rig scope slice ls", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-1 + HG-9: --json returns active slices from a specific mission", async () => {
const r = await run(["slice", "ls", "--mission", "release-0.3.2", "--state", "active", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slices.map((s: { name: string }) => s.name)).toEqual(["01-existing"]);
expect(parsed.slices[0].id).toBe("OPR.0.3.2.1");
});
it("returns slices from all missions when --mission is omitted", async () => {
const r = await run(["slice", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.count).toBe(2);
});
});
// ---------------------------------------------------------------------
// HG-2: rig scope slice show
// ---------------------------------------------------------------------
describe("rig scope slice show (HG-2)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("renders frontmatter + README + children", async () => {
const r = await run([
"slice", "show", "01-existing",
"--mission", "release-0.3.2",
"--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slice.id).toBe("OPR.0.3.2.1");
expect(parsed.slice.readme).toContain("# existing");
});
});
// ---------------------------------------------------------------------
// HG-3 + HG-4 + HG-15: rig scope slice create
// ---------------------------------------------------------------------
describe("rig scope slice create", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-3: auto-finds next NN, never reuses numbers", async () => {
// Add a closed slice 04 so existing NNs are 01 + 04. Next must be 05.
writeFile(
path.join(env.missionsRoot, "release-0.3.2", "closed", "04-old", "README.md"),
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\n",
);
const r = await run([
"slice", "create", "release-0.3.2", "new-thing", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.slice.name).toMatch(/^05-/);
});
it("HG-4: --template populates the template", async () => {
const r = await run([
"slice", "create", "release-0.3.2", "perf-fix",
"--template", "bug-fix", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
const readme = fs.readFileSync(path.join(parsed.slice.path, "README.md"), "utf8");
expect(readme).toMatch(/## Repro/);
expect(readme).toMatch(/## Expected/);
});
it("HG-15: created slice frontmatter has a conformant dot-ID", async () => {
const r = await run([
"slice", "create", "release-0.3.2", "tdd-foo", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
const fm = readFrontmatter(path.join(parsed.slice.path, "README.md"));
expect(fm.id).toBe("OPR.0.3.2.2");
expect(typeof fm.id === "string" && /^OPR\.0\.3\.2\.\d+$/.test(fm.id as string)).toBe(true);
});
});
// ---------------------------------------------------------------------
// HG-5 + HG-9: rig scope slice ship — git mv + frontmatter update
// ---------------------------------------------------------------------
describe("rig scope slice ship (HG-5)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("git mv + frontmatter status: shipped-to-release-X.Y", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.shipped.git.usedGit).toBe(true);
// Source removed; target present.
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
expect(fs.existsSync(parsed.shipped.to.path)).toBe(true);
const fm = readFrontmatter(path.join(parsed.shipped.to.path, "README.md"));
expect(fm.status).toBe("shipped-to-release-0.3.2");
expect(fm.mission).toBe("release-0.3.2");
});
});
// ---------------------------------------------------------------------
// HG-6: rig scope slice close --reason
// ---------------------------------------------------------------------
describe("rig scope slice close (HG-6)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("moves to closed/ + sets status: closed-<reason>", async () => {
const r = await run([
"slice", "close", "01-debt-foo",
"--mission", "backlog",
"--reason", "wontfix",
"--note", "obsolete by design",
"--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo"))).toBe(true);
const fm = readFrontmatter(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo", "README.md"));
expect(fm.status).toBe("closed-wontfix");
expect(fm["closure-note"]).toBe("obsolete by design");
});
it("rejects unknown --reason values", async () => {
const r = await run([
"slice", "close", "01-debt-foo",
"--mission", "backlog",
"--reason", "fishy",
"--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
});
});
// ---------------------------------------------------------------------
// HG-7: rig scope slice move
// ---------------------------------------------------------------------
describe("rig scope slice move (HG-7)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("moves between missions; auto-renumber; frontmatter.mission updates", async () => {
const r = await run([
"slice", "move", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.moved.to.name).toMatch(/^02-/); // 01 was taken
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
const fm = readFrontmatter(path.join(parsed.moved.to.path, "README.md"));
expect(fm.mission).toBe("release-0.3.2");
});
});
// ---------------------------------------------------------------------
// HG-8: rig scope mission ls
// ---------------------------------------------------------------------
describe("rig scope mission ls (HG-8)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("--json lists missions with id + slice counts", async () => {
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
expect(names).toEqual(["backlog", "release-0.3.2"]);
});
});
describe("rig scope mission show", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("renders frontmatter + README + slice inventory", async () => {
const r = await run(["mission", "show", "release-0.3.2", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toBe("OPR.0.3.2");
expect(parsed.mission.slices.length).toBe(1);
});
});
// ---------------------------------------------------------------------
// HG-14 + HG-15: rig scope mission create (PROMOTED to v0 per Amendment §B)
// ---------------------------------------------------------------------
describe("rig scope mission create (HG-14 + HG-15)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("HG-14: mission create is invocable with the SAME pattern as slice create", async () => {
// Symmetry test: both verbs take (positional name) + (--template) +
// (--json) and emit { ok, [tier]: { name, id, template, path } }.
const r = await run([
"mission", "create", "release-0.4.0", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(0);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
expect(parsed.mission.name).toBe("release-0.4.0");
expect(parsed.mission.path).toBeTruthy();
expect(parsed.mission.template).toBe("release");
});
it("HG-15: created mission frontmatter has a conformant dot-ID per §1", async () => {
const r = await run(["mission", "create", "release-0.5.0", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toBe("OPR.0.5.0");
const fm = readFrontmatter(path.join(parsed.mission.path, "README.md"));
expect(fm.id).toBe("OPR.0.5.0");
});
it("HG-15: non-release names get an escape-band ID (uniform-numeric)", async () => {
const r = await run([
"mission", "create", "experiments-foo", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.mission.id).toMatch(/^OPR\.99\.0\.\d+$/);
});
it("HG-15: accepts an explicit --id override and rejects malformed ones", async () => {
const ok = await run([
"mission", "create", "weird-thing",
"--id", "OPR.99.0.42", "--json",
], env.missionsRoot);
expect(JSON.parse(ok.stdout).mission.id).toBe("OPR.99.0.42");
const bad = await run([
"mission", "create", "another-weird-thing",
"--id", "OPR.A.1", "--json",
], env.missionsRoot);
expect(bad.exitCode).toBe(1);
});
it("BLOCK 1 discriminator: mission create rejects slice-depth --id (OPR.0.3.2.12)", async () => {
// Per guard BC verdict: a 4-segment slice-shape must NOT pass
// mission --id validation. Without tier-aware validation this
// would succeed and create a mission with a slice-shaped id.
const r = await run([
"mission", "create", "wrong-tier",
"--id", "OPR.0.3.2.12", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/not a mission-tier dot-ID/);
});
it("BLOCK 1 follow-up: mission create still accepts valid mission shapes (escape band + release)", async () => {
const eb = await run([
"mission", "create", "exp-band",
"--id", "OPR.99.0.42", "--json",
], env.missionsRoot);
expect(eb.exitCode).toBe(0);
expect(JSON.parse(eb.stdout).mission.id).toBe("OPR.99.0.42");
const rel = await run([
"mission", "create", "release-0.6.0", "--json",
], env.missionsRoot);
expect(rel.exitCode).toBe(0);
expect(JSON.parse(rel.stdout).mission.id).toBe("OPR.0.6.0");
});
it("BC-2 BLOCK 1 discriminator: rejects escape-band with non-zero middle segment (OPR.99.7.8)", async () => {
const r = await run([
"mission", "create", "bad-eb",
"--id", "OPR.99.7.8", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(JSON.parse(r.stdout).ok).toBe(false);
});
});
// ---------------------------------------------------------------------
// BC-2 BLOCK 2: README-less dirs are rejected at every mutation surface
// ---------------------------------------------------------------------
describe("BC-2 BLOCK 2 — README-less dirs are not declared missions at any mutation surface", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => {
env = seedSubstrate();
// Sneak a README-less directory into missions/ to verify every
// surface rejects it consistently with listMissions.
fs.mkdirSync(path.join(env.missionsRoot, "no-readme"), { recursive: true });
});
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("mission ls omits no-readme (already in HG-8 above; sanity check here)", async () => {
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.missions.map((m: { name: string }) => m.name)).not.toContain("no-readme");
});
it("mission show no-readme exits 1 with the 3-part 'not a declared mission' error", async () => {
const r = await run(["mission", "show", "no-readme", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/no README\.md|not a declared mission/);
});
it("slice create no-readme exits 1 + does NOT create slices/foo/", async () => {
const r = await run([
"slice", "create", "no-readme", "foo", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(path.join(env.missionsRoot, "no-readme", "slices", "01-foo"))).toBe(false);
});
it("slice ship to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
expect(fs.existsSync(srcAbs)).toBe(true);
const r = await run([
"slice", "ship", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
// Source unchanged.
expect(fs.existsSync(srcAbs)).toBe(true);
});
it("slice move to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
const r = await run([
"slice", "move", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(srcAbs)).toBe(true);
});
});
// ---------------------------------------------------------------------
// BLOCK 3 discriminator: ship + move persist target.id back to target README
// ---------------------------------------------------------------------
describe("BLOCK 3 — ship/move persist target mission id into target README", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => {
env = seedSubstrate();
// Replace release-0.3.2/README.md with an ID-LESS variant so we
// can observe the lazy-adopt-and-persist behavior.
fs.writeFileSync(
path.join(env.missionsRoot, "release-0.3.2", "README.md"),
"# release-0.3.2\n\n(no id frontmatter yet)\n",
"utf8",
);
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", env.root, "commit", "-m", "strip-target-id", "-q"], { stdio: "ignore" });
});
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("slice ship: target mission gains a persisted id; child id = targetId.NN", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
// Target README now has id: OPR.0.3.2 written back.
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
expect(targetFm.id).toBe("OPR.0.3.2");
// Child id matches parent.NN.
expect(parsed.shipped.to.id).toBe("OPR.0.3.2.2");
});
it("slice move: target mission gains a persisted id; child id = targetId.NN", async () => {
const r = await run([
"slice", "move", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(true);
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
expect(targetFm.id).toBe("OPR.0.3.2");
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
});
it("sibling missions without id remain UNCHANGED after a ship/move (narrow lazy adoption, not mass-migrate)", async () => {
// Spawn an unrelated id-less mission alongside; assert it survives
// untouched through a ship to a different mission.
fs.mkdirSync(path.join(env.missionsRoot, "untouched-mission"), { recursive: true });
fs.writeFileSync(
path.join(env.missionsRoot, "untouched-mission", "README.md"),
"# untouched\n",
"utf8",
);
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", env.root, "commit", "-m", "seed-sibling", "-q"], { stdio: "ignore" });
const before = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
await run([
"slice", "ship", "01-debt-foo", "release-0.3.2",
"--mission", "backlog", "--json",
], env.missionsRoot);
const after = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
expect(after).toBe(before);
expect(readFrontmatter(path.join(env.missionsRoot, "untouched-mission", "README.md")).id).toBeUndefined();
});
});
// ---------------------------------------------------------------------
// HG-10: 3-part error shape
// ---------------------------------------------------------------------
describe("3-part error shape (HG-10)", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => { env = seedSubstrate(); });
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("ship to nonexistent target mission yields fact/consequence/action", async () => {
const r = await run([
"slice", "ship", "01-debt-foo", "release-9.9.9",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toBeTruthy();
expect(parsed.error.consequence).toBeTruthy();
expect(parsed.error.action).toBeTruthy();
});
});
// ---------------------------------------------------------------------
// HG-12: --help exists for every command
// ---------------------------------------------------------------------
describe("--help is present on every command (HG-12)", () => {
it("every tier and verb has help text", () => {
const cmd = scopeCommand();
expect(cmd.description()).toBeTruthy();
const tiers = cmd.commands;
expect(tiers.map((c) => c.name()).sort()).toEqual(["mission", "slice"]);
for (const tier of tiers) {
expect(tier.description()).toBeTruthy();
for (const verb of tier.commands) {
expect(verb.description()).toBeTruthy();
}
}
});
});
-177
View File
@@ -1,177 +0,0 @@
// release-0.3.2 slice 12 — dot-ID grammar unit tests.
//
// Aligned with `openrig-work/conventions/scope-and-versioning/README.md` §1.
import { describe, expect, it } from "vitest";
import {
formatDotId,
inferMissionDotId,
isConformantDotId,
isMissionDotId,
isSliceDotId,
nextEscapeBandOrdinal,
parseDotId,
sliceIdFromMission,
} from "../src/lib/scope/dot-id.js";
describe("dot-ID — parse + format", () => {
it("parses a slice ID like OPR.0.3.2.12", () => {
const id = parseDotId("OPR.0.3.2.12");
expect(id).not.toBeNull();
expect(id!.project).toBe("OPR");
expect(id!.version).toBe("0.3.2.12");
});
it("parses an escape-band ID like OPR.99.0.1", () => {
const id = parseDotId("OPR.99.0.1");
expect(id).not.toBeNull();
expect(id!.project).toBe("OPR");
expect(id!.version).toBe("99.0.1");
});
it("rejects alpha segments (§1 explicit: uniform-numeric escape band)", () => {
expect(parseDotId("OPR.A.1")).toBeNull();
expect(parseDotId("OPR.0.3.A")).toBeNull();
});
it("rejects malformed prefixes", () => {
expect(parseDotId("opr.0.3.2")).toBeNull();
expect(parseDotId("O.0.3.2")).toBeNull();
expect(parseDotId("OPRX.0.3.2")).toBeNull();
});
it("formats a DotId back to canonical string", () => {
expect(formatDotId({ project: "OPR", version: "0.3.2" })).toBe("OPR.0.3.2");
expect(formatDotId({ project: "OPR", version: "0.3.2", n: 12 })).toBe("OPR.0.3.2.12");
});
});
describe("inferMissionDotId — release pattern + escape band", () => {
it("infers OPR.X.Y.Z from release-X.Y.Z folder names", () => {
expect(inferMissionDotId("release-0.3.2", null)).toBe("OPR.0.3.2");
expect(inferMissionDotId("release-0.4.0", null)).toBe("OPR.0.4.0");
expect(inferMissionDotId("release-1.0", null)).toBe("OPR.1.0");
});
it("requires an escape-band ordinal for non-release names", () => {
expect(() => inferMissionDotId("backlog", null)).toThrow();
expect(inferMissionDotId("backlog", 5)).toBe("OPR.99.0.5");
expect(inferMissionDotId("bug-fix", 3)).toBe("OPR.99.0.3");
});
});
describe("nextEscapeBandOrdinal", () => {
it("returns 1 when no peer IDs exist", () => {
expect(nextEscapeBandOrdinal([])).toBe(1);
expect(nextEscapeBandOrdinal([null, null])).toBe(1);
});
it("returns max+1 across existing escape-band peers", () => {
expect(nextEscapeBandOrdinal(["OPR.99.0.1", "OPR.99.0.3", null])).toBe(4);
});
it("ignores non-matching IDs (releases) when picking ordinal", () => {
expect(nextEscapeBandOrdinal(["OPR.0.3.2", "OPR.99.0.2"])).toBe(3);
});
});
describe("sliceIdFromMission", () => {
it("appends the slice ordinal to the mission ID", () => {
expect(sliceIdFromMission("OPR.0.3.2", 12)).toBe("OPR.0.3.2.12");
expect(sliceIdFromMission("OPR.99.0.1", 4)).toBe("OPR.99.0.1.4");
});
});
describe("isConformantDotId", () => {
it("accepts conformant IDs across all tiers", () => {
expect(isConformantDotId("OPR.0.3.2")).toBe(true);
expect(isConformantDotId("OPR.0.3.2.12")).toBe(true);
expect(isConformantDotId("OPR.99.0.1")).toBe(true);
});
it("rejects non-conformant inputs", () => {
expect(isConformantDotId("")).toBe(false);
expect(isConformantDotId(42)).toBe(false);
expect(isConformantDotId("opr.0.3.2")).toBe(false);
});
});
describe("BLOCK 1 — tier-aware validation (isMissionDotId / isSliceDotId)", () => {
it("isMissionDotId accepts release-shaped IDs (2-3 numeric segments)", () => {
expect(isMissionDotId("OPR.0.3.2")).toBe(true);
expect(isMissionDotId("OPR.1.0")).toBe(true);
});
it("isMissionDotId accepts the escape-band shape (99.x.y, exactly 3 segments)", () => {
expect(isMissionDotId("OPR.99.0.1")).toBe(true);
expect(isMissionDotId("OPR.99.0.42")).toBe(true);
});
it("isMissionDotId REJECTS slice-shaped IDs (4+ numeric segments) — guard BC discriminator", () => {
// The crux of BLOCK 1: a slice-depth ID like OPR.0.3.2.12 must
// not pass mission validation, even though it parses as a valid
// dot-ID at SOME tier.
expect(isMissionDotId("OPR.0.3.2.12")).toBe(false);
expect(isMissionDotId("OPR.99.0.1.5")).toBe(false);
});
it("isMissionDotId rejects malformed inputs (alpha / wrong prefix shape)", () => {
expect(isMissionDotId("OPR.A.1")).toBe(false);
expect(isMissionDotId("opr.0.3.2")).toBe(false);
expect(isMissionDotId(42 as unknown)).toBe(false);
expect(isMissionDotId("OPR.99.0")).toBe(false); // escape band must be exactly 3
});
it("isSliceDotId accepts slice-shaped IDs (3-4 numeric segments)", () => {
expect(isSliceDotId("OPR.0.3.2.12")).toBe(true); // release-X.Y.Z slice
expect(isSliceDotId("OPR.1.0.5")).toBe(true); // release-X.Y slice
expect(isSliceDotId("OPR.99.0.1.7")).toBe(true); // escape-band slice
});
it("isSliceDotId admits the inherent depth-3 overlap (release X.Y.Z mission shape = slice for an X.Y parent)", () => {
// The convention's positional grammar can't distinguish a
// release-X.Y.Z mission from a slice of a release-X.Y mission
// by depth alone. Both shapes are accepted by isSliceDotId; the
// CLI disambiguates by tier at use sites (mission create uses
// isMissionDotId; slice IDs are minted from a parent + ordinal,
// never user-typed).
expect(isSliceDotId("OPR.0.3.2")).toBe(true); // release X.Y.Z OR slice of X.Y
// Escape-band IDs are UNAMBIGUOUS by depth: an escape-band mission
// is depth-3 (OPR.99.0.1); an escape-band slice is depth-4
// (OPR.99.0.1.5). Depth-3 with leading 99 is mission-only.
expect(isSliceDotId("OPR.99.0.1")).toBe(false);
// The strictly-impossible-slice case is depth 2 (X.Y only) — no
// parent can have a sub-NN-segment of "Y".
expect(isSliceDotId("OPR.1.0")).toBe(false);
});
it("parseDotId(s, 'slice') peels the last segment off as `n`", () => {
// Per guard discriminator: parsing OPR.0.3.2.12 as a slice must
// yield version=0.3.2 and n=12, not version=0.3.2.12.
const id = parseDotId("OPR.0.3.2.12", "slice");
expect(id).not.toBeNull();
expect(id!.version).toBe("0.3.2");
expect(id!.n).toBe(12);
});
it("parseDotId(s, 'mission') keeps all numeric segments as version", () => {
const id = parseDotId("OPR.0.3.2", "mission");
expect(id).not.toBeNull();
expect(id!.version).toBe("0.3.2");
expect(id!.n).toBeUndefined();
});
it("BC-2 BLOCK 1: escape band rejects [99, !0, n] mission ver (only [99, 0, n] is valid)", () => {
// §1 fixes the escape band as <PFX>.99.0.<n> — the `0` is FIXED.
// Previously the depth-3 check accepted OPR.99.7.8 as a mission;
// now require the middle 0.
expect(isMissionDotId("OPR.99.7.8")).toBe(false);
expect(isMissionDotId("OPR.99.1.0")).toBe(false);
expect(isMissionDotId("OPR.99.0.42")).toBe(true); // still valid
expect(isMissionDotId("OPR.99.0.1")).toBe(true); // still valid
});
it("BC-2 BLOCK 1: escape-band slice parent must also satisfy the [99,0,n] rule", () => {
expect(isSliceDotId("OPR.99.7.8.1")).toBe(false); // parent=99.7.8 invalid
expect(isSliceDotId("OPR.99.0.42.7")).toBe(true); // parent=99.0.42 valid
});
});
-339
View File
@@ -1,339 +0,0 @@
// release-0.3.2 slice 12 — scope-fs helpers + frontmatter parser tests.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import {
ensureMissionId,
findMission,
findSlice,
listMissions,
listSlices,
moveSlice,
nextSliceNN,
readFrontmatter,
resolveMissionsRoot,
splitFrontmatter,
updateFrontmatter,
} from "../src/lib/scope/scope-fs.js";
function mktemp(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-test-"));
}
function writeFile(p: string, content: string): void {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content, "utf8");
}
describe("frontmatter parser", () => {
it("returns {} when there is no frontmatter delimiter", () => {
const { frontmatter, body } = splitFrontmatter("# heading\n\nbody");
expect(frontmatter).toEqual({});
expect(body).toBe("# heading\n\nbody");
});
it("parses simple key/value pairs", () => {
const src = "---\nid: OPR.0.3.2.12\nstatus: active\n---\nbody";
const { frontmatter, body } = splitFrontmatter(src);
expect(frontmatter).toEqual({ id: "OPR.0.3.2.12", status: "active" });
expect(body).toBe("body");
});
it("preserves unknown keys on update", () => {
const dir = mktemp();
const p = path.join(dir, "README.md");
writeFile(p, "---\nstatus: active\ncustom: keep-me\n---\nbody\n");
updateFrontmatter(p, { status: "shipped" });
const fm = readFrontmatter(p);
expect(fm.status).toBe("shipped");
expect(fm.custom).toBe("keep-me");
});
it("generates minimal frontmatter when absent", () => {
const dir = mktemp();
const p = path.join(dir, "README.md");
writeFile(p, "body only\n");
updateFrontmatter(p, { id: "OPR.0.3.2" });
const fm = readFrontmatter(p);
expect(fm.id).toBe("OPR.0.3.2");
});
});
describe("resolveMissionsRoot", () => {
it("uses an explicit override path when present", () => {
const root = mktemp();
fs.mkdirSync(path.join(root, "missions"));
expect(resolveMissionsRoot({ override: root })).toBe(path.join(root, "missions"));
});
it("throws ScopeCliError when no missions/ root is found", () => {
const empty = mktemp();
expect(() => resolveMissionsRoot({ override: empty, cwd: empty })).toThrow();
});
});
describe("listMissions + listSlices + nextSliceNN", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
// mission with 2 active + 1 closed slice
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "01-foo", "README.md"),
"---\nid: OPR.0.3.2.1\nstatus: active\n---\nbody\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "02-bar", "README.md"),
"---\nid: OPR.0.3.2.2\nstatus: active\n---\nbody\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "closed", "04-baz", "README.md"),
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\nbody\n",
);
// mission with no README — should be skipped as "no slice count basis"
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it("lists missions sorted by name with slice counts; SKIPS dirs without README.md (HG-8)", () => {
const missions = listMissions(missionsRoot);
// no-readme/ has no README.md and must NOT appear (HG-8 / PRD §2.1).
expect(missions.map((m) => m.name)).toEqual(["release-0.3.2"]);
const r = missions.find((m) => m.name === "release-0.3.2")!;
expect(r.activeSliceCount).toBe(2);
expect(r.closedSliceCount).toBe(1);
expect(r.id).toBe("OPR.0.3.2");
});
it("HG-8 discriminator: 3 dirs / 1 without README → exactly 2 missions listed", () => {
// Seed a third mission alongside the existing fixture for a clear
// make-it-fail-first signal per guard BC verdict.
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
const missions = listMissions(missionsRoot);
expect(missions.map((m) => m.name).sort()).toEqual(["backlog", "release-0.3.2"]);
// The README-less directory survives on disk but does not show up.
expect(fs.existsSync(path.join(missionsRoot, "no-readme"))).toBe(true);
});
it("listSlices active filter excludes closed/shipped (HG-1)", () => {
const mission = findMission(missionsRoot, "release-0.3.2");
const active = listSlices(mission, "active");
expect(active.map((s) => s.name).sort()).toEqual(["01-foo", "02-bar"]);
const closed = listSlices(mission, "closed");
expect(closed.map((s) => s.name)).toEqual(["04-baz"]);
});
it("nextSliceNN skips numbers already used in slices/ AND closed/ (HG-3)", () => {
const missionAbs = path.join(missionsRoot, "release-0.3.2");
// existing: 01, 02, 04 → next should be 5 (not 3 — numbers never reused).
expect(nextSliceNN(missionAbs)).toBe(5);
});
});
describe("findSlice + resolution variants", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\nbody",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "slices", "07-target", "README.md"),
"---\nid: OPR.0.3.2.7\nstatus: active\n---\nbody",
);
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("resolves a mission-relative path with --mission hint", () => {
const slice = findSlice(missionsRoot, "07-target", "release-0.3.2");
expect(slice.name).toBe("07-target");
expect(slice.id).toBe("OPR.0.3.2.7");
});
it("resolves an absolute path", () => {
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
const slice = findSlice(missionsRoot, abs);
expect(slice.missionName).toBe("release-0.3.2");
});
it("3-part error when slice not found (HG-10)", () => {
expect(() => findSlice(missionsRoot, "99-missing", "release-0.3.2")).toThrow(/not found/);
});
});
describe("BC-2 BLOCK 2 — findMission rejects README-less directories", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n",
);
// README-less directory next to a real mission — scratch/junk.
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("findMission throws 3-part error when target directory has no README.md", () => {
expect(() => findMission(missionsRoot, "no-readme")).toThrow(/not a declared mission|no README/);
});
it("findMission still resolves declared missions normally", () => {
expect(findMission(missionsRoot, "release-0.3.2").name).toBe("release-0.3.2");
});
});
describe("ensureMissionId", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("returns existing frontmatter id when present", () => {
writeFile(
path.join(missionsRoot, "weird-name", "README.md"),
"---\nid: OPR.99.0.7\n---\n",
);
const m = findMission(missionsRoot, "weird-name");
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.99.0.7");
});
it("derives from release-X.Y.Z pattern when no id present", () => {
writeFile(path.join(missionsRoot, "release-0.4.0", "README.md"), "");
const m = findMission(missionsRoot, "release-0.4.0");
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.0.4.0");
});
it("falls into escape band for non-release names", () => {
writeFile(path.join(missionsRoot, "backlog", "README.md"), "");
const m = findMission(missionsRoot, "backlog");
const id = ensureMissionId(m, missionsRoot);
expect(id).toMatch(/^OPR\.99\.0\.\d+$/);
});
});
// ---------------------------------------------------------------------
// git mv path — uses real git in a tmp repo (HG-5, HG-11)
// ---------------------------------------------------------------------
function initRepo(root: string): void {
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "config", "user.name", "Tester"], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "--allow-empty", "-m", "init", "-q"], { stdio: "ignore" });
}
describe("moveSlice — git mv preserves history (HG-5) + refuses dirty tree (HG-11)", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
initRepo(root);
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "backlog", "README.md"),
"---\nid: OPR.99.0.1\n---\n# backlog\n",
);
writeFile(
path.join(missionsRoot, "backlog", "slices", "01-foo", "README.md"),
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# foo\n",
);
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n# release\n",
);
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("uses git mv inside a repo and preserves the file via git log --follow", () => {
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "01-foo");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(true);
execFileSync("git", ["-C", root, "commit", "-m", "ship", "-q"], { stdio: "ignore" });
const log = execFileSync("git", [
"-C", root, "log", "--follow", "--pretty=format:%s", "--", path.relative(root, path.join(dest, "README.md")),
], { encoding: "utf8" });
expect(log).toMatch(/seed/);
expect(log).toMatch(/ship/);
});
it("refuses to move when slice has uncommitted local edits (HG-11)", () => {
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
fs.writeFileSync(path.join(src, "README.md"), "dirty\n", "utf8");
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "02-foo");
expect(() => moveSlice(src, dest)).toThrow(/uncommitted/);
});
it("falls back to fs.rename outside a git repo", () => {
const dir = mktemp();
const src = path.join(dir, "src");
const dest = path.join(dir, "dest");
fs.mkdirSync(src);
fs.writeFileSync(path.join(src, "README.md"), "body");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(false);
expect(fs.existsSync(dest)).toBe(true);
fs.rmSync(dir, { recursive: true, force: true });
});
it("BLOCK 4 discriminator: handles symlinked src/dest paths without escaping the repo (macOS /var↔/private/var class)", () => {
// Build a parallel tree of symlinks pointing at the real repo
// tree. Without realpath normalization inside moveSlice, path.relative
// would resolve to ../.. and git mv would reject with "outside
// repository". With normalization, git mv succeeds and history
// follows the move via --follow.
const linkParent = mktemp();
const linkRoot = path.join(linkParent, "linked-substrate");
fs.symlinkSync(root, linkRoot);
// Sanity: the symlinked tree resolves to a different absolute path
// than the real tree (covers the /var↔/private/var class on macOS
// and any other path-resolving symlink class on other OSes).
expect(fs.realpathSync(linkRoot)).not.toBe(linkRoot);
const src = path.join(linkRoot, "missions", "backlog", "slices", "01-foo");
const dest = path.join(linkRoot, "missions", "release-0.3.2", "slices", "01-foo");
const result = moveSlice(src, dest);
expect(result.usedGit).toBe(true);
expect(result.repoRoot).toBe(fs.realpathSync(root)); // git returned the realpath
// git mv staged a rename; history follows.
execFileSync("git", ["-C", root, "commit", "-m", "ship-via-symlink", "-q"], { stdio: "ignore" });
const log = execFileSync("git", [
"-C", root, "log", "--follow", "--pretty=format:%s", "--",
"missions/release-0.3.2/slices/01-foo/README.md",
], { encoding: "utf8" });
expect(log).toMatch(/seed/);
expect(log).toMatch(/ship-via-symlink/);
fs.rmSync(linkParent, { recursive: true, force: true });
});
});
-41
View File
@@ -1,41 +0,0 @@
import { describe, it, expect } from "vitest";
import { wrapSendBody } from "../src/commands/send.js";
describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)", () => {
it("renders From / To / body / reply hint with both session names", () => {
const out = wrapSendBody("driver-3@my-rig", "guard-3@my-rig", "Status: ready.");
expect(out).toContain("From: driver-3@my-rig");
expect(out).toContain("To: guard-3@my-rig");
expect(out).toContain("Status: ready.");
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
});
it("preserves the original body verbatim between the dash separators", () => {
const body = "Multi-line\nbody with\nthree lines.";
const out = wrapSendBody("a@r", "b@r", body);
const segments = out.split("\n---\n");
expect(segments).toHaveLength(3);
expect(segments[1]).toBe(body);
});
it("wraps cleanly when the body is empty", () => {
const out = wrapSendBody("a@r", "b@r", "");
expect(out).toContain("From: a@r");
expect(out).toContain("To: b@r");
expect(out).toContain("---\n\n---");
expect(out).toContain('↩ Reply: rig send a@r "..."');
});
it("falls back to a marker when the sender is undefined or empty", () => {
const undef = wrapSendBody(undefined, "b@r", "hi");
expect(undef).toContain("From: <unknown sender>");
expect(undef).toContain('↩ Reply: rig send <unknown sender> "..."');
const blank = wrapSendBody(" ", "b@r", "hi");
expect(blank).toContain("From: <unknown sender>");
});
it("uses the literal recipient string in the To header so cross-rig addresses survive", () => {
const out = wrapSendBody("from@a", "to@b", "x");
expect(out).toMatch(/^From: from@a\nTo: to@b\n---\n/);
});
});
+1 -5
View File
@@ -137,15 +137,11 @@ describe("Send CLI", () => {
"/api/transport/send",
expect.objectContaining({
session: "dev-impl@my-rig",
text: expect.stringContaining("hello"),
text: "hello",
waitForIdleMs: 30000,
}),
{ timeoutMs: 35000 },
);
const sentText = postFn.mock.calls[0]?.[1] as { text: string } | undefined;
expect(sentText?.text).toContain("To: dev-impl@my-rig");
expect(sentText?.text).toContain("---\nhello\n---");
expect(sentText?.text).toContain('↩ Reply: rig send');
});
it("send without wait-for-idle uses default client timeout path", async () => {
+3 -39
View File
@@ -146,46 +146,18 @@ describe("Specs CLI", () => {
expect(exitCode).toBe(1);
});
it("specs show --kind resolves same-name rig/workflow entries", async () => {
const dupServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/specs/library?kind=rig") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ ...LIBRARY_ENTRIES[0], id: "rig-same", kind: "rig", name: "conveyor" }]));
} else {
res.writeHead(404).end();
}
});
await new Promise<void>((resolve) => { dupServer.listen(0, resolve); });
const dupPort = (dupServer.address() as { port: number }).port;
const prog = new Command();
prog.exitOverride();
prog.addCommand(specsCommand(runningDeps(dupPort)));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "specs", "show", "conveyor", "--kind", "rig"]);
});
dupServer.close();
const output = logs.join("\n");
expect(output).toContain("Kind: rig");
expect(output).toContain("/builtin/review-rig.yaml");
expect(exitCode).toBeUndefined();
});
it("specs show not-found error points people back to managed apps in the library", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "show", "missing-app"]);
});
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.");
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, and managed apps.");
expect(exitCode).toBe(1);
});
it("specs preview --json returns structured review", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--kind", "rig", "--json"]);
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--json"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.kind).toBe("rig");
@@ -205,14 +177,12 @@ describe("Specs CLI", () => {
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
// Use temp OpenRig home to avoid polluting real ~/.openrig/specs/
// Use temp dir as HOME to avoid polluting real ~/.openrig/specs/
const tmpDir = mkdtempSync(join(tmpdir(), "specs-add-"));
const specPath = join(tmpDir, "test-spec.yaml");
writeFileSync(specPath, 'name: test-spec\nversion: "0.2"\npods: []\nedges: []\n');
const savedHome = process.env["HOME"];
const savedOpenRigHome = process.env["OPENRIG_HOME"];
process.env["HOME"] = tmpDir;
process.env["OPENRIG_HOME"] = join(tmpDir, ".openrig");
const addServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
@@ -250,8 +220,6 @@ describe("Specs CLI", () => {
expect(existsSync(join(tmpDir, ".openrig", "specs", "test-spec.yaml"))).toBe(true);
process.env["HOME"] = savedHome;
if (savedOpenRigHome !== undefined) process.env["OPENRIG_HOME"] = savedOpenRigHome;
else delete process.env["OPENRIG_HOME"];
rmSync(tmpDir, { recursive: true, force: true });
const output = logs.join("\n");
@@ -307,9 +275,7 @@ describe("Specs CLI", () => {
].join("\n"));
const savedHome = process.env["HOME"];
const savedOpenRigHome = process.env["OPENRIG_HOME"];
process.env["HOME"] = tmpDir;
process.env["OPENRIG_HOME"] = join(tmpDir, ".openrig");
const addServer = http.createServer((req, res) => {
const url = decodeURIComponent(req.url ?? "");
@@ -354,8 +320,6 @@ describe("Specs CLI", () => {
} finally {
addServer.close();
process.env["HOME"] = savedHome;
if (savedOpenRigHome !== undefined) process.env["OPENRIG_HOME"] = savedOpenRigHome;
else delete process.env["OPENRIG_HOME"];
rmSync(tmpDir, { recursive: true, force: true });
}
});
+3 -165
View File
@@ -618,100 +618,6 @@ describe("Up CLI", () => {
expect(clientBaseUrl).toBe("http://127.0.0.1:7461");
});
// bug-fix slice auth-bearer-tailscale-trust forward-fix #2: `rig up`
// auto-start path was the second product launch path materializing
// the default daemon.host into OPENRIG_HOST. The discriminator below
// proves source=default → omit (so daemon multi-binds); source=env
// → preserve (operator opt-in).
describe("auth-bearer-tailscale-trust: up auto-start respects daemon.host source", () => {
function makeAutoStartDeps(captureSpawn: (env: Record<string, string>) => void): StatusDeps {
let daemonState: DaemonState | null = null;
let daemonStarted = false;
return {
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn((p: string) => p === STATE_FILE ? daemonState !== null : false),
readFile: vi.fn((p: string) => p === STATE_FILE && daemonState ? JSON.stringify(daemonState) : null),
writeFile: vi.fn((p: string, content: string) => {
if (p === STATE_FILE) daemonState = JSON.parse(content) as DaemonState;
}),
openForAppend: vi.fn(() => 3),
mkdirp: vi.fn(),
spawn: vi.fn((cmd, args, opts) => {
daemonStarted = true;
captureSpawn(opts.env as Record<string, string>);
return { pid: 999, unref: vi.fn() } as never;
}),
fetch: vi.fn(async (url: string) => {
if (!daemonStarted) throw new Error(`refused:${url}`);
return { ok: url.includes("/healthz") };
}),
},
clientFactory: () => ({
post: vi.fn(async () => ({
status: 201,
data: { status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
})),
} as unknown as DaemonClient),
};
}
it("default (no env, no config) does NOT export OPENRIG_HOST so daemon multi-binds", async () => {
const savedHost = process.env["OPENRIG_HOST"];
const savedPort = process.env["OPENRIG_PORT"];
delete process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7471";
let spawnedEnv: Record<string, string> = {};
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
expect(spawnedEnv["OPENRIG_HOST"]).toBeUndefined();
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7471");
});
it("env-explicit OPENRIG_HOST is preserved into spawn env (distinct value discriminator)", async () => {
const savedHost = process.env["OPENRIG_HOST"];
const savedPort = process.env["OPENRIG_PORT"];
// Use a distinct value so the discriminator can't pass vacuously:
// 100.64.55.66 is a tailscale CGNAT address — operator-explicit
// bind would normally be honored as-is.
process.env["OPENRIG_HOST"] = "100.64.55.66";
process.env["OPENRIG_PORT"] = "7472";
let spawnedEnv: Record<string, string> = {};
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
expect(spawnedEnv["OPENRIG_HOST"]).toBe("100.64.55.66");
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7472");
});
});
it("up surfaces the real daemon auto-start failure instead of a generic hint", async () => {
const savedPort = process.env["OPENRIG_PORT"];
process.env["OPENRIG_PORT"] = "7463";
@@ -784,74 +690,6 @@ describe("Up CLI", () => {
expect(exitCode).toBe(1);
});
it("up resolves a same-name rig and workflow to the rig library entry", async () => {
const origListeners = server.listeners("request");
let lastBody: Record<string, unknown> = {};
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/specs/library?kind=rig") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
} else if (url === "/api/rigs/summary") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "planned", runId: "r", stages: [], errors: [] }));
} else {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${url}` }));
}
});
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "conveyor", "--plan"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
expect(lastBody.sourceRef).toBe("/specs/rigs/conveyor/rig.yaml");
expect(lastBody.cwdOverride).toBe(process.cwd());
expect(exitCode).toBeUndefined();
});
it("up --existing bypasses library-name ambiguity and posts the rig name", async () => {
const origListeners = server.listeners("request");
let lastBody: Record<string, unknown> = {};
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r1", name: "alpha", nodeCount: 1, lifecycleState: "recoverable" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "restored", rigId: "r1", rigName: "alpha", rigResult: "restored", nodes: [], warnings: [] }));
} else {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${req.url}` }));
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--existing"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
expect(lastBody.sourceRef).toBe("alpha");
expect(logs.join("\n")).toContain('Recovering rig "alpha" from latest snapshot or current DB state');
expect(exitCode).toBeUndefined();
});
// L2 wording divergence: rig name in recoverable state -> "Recovering ..."; stopped -> "Turning on ..."
it("up <rig-name> prints 'Recovering ... from latest snapshot' when lifecycleState=recoverable", async () => {
const origListeners = server.listeners("request");
@@ -883,7 +721,7 @@ describe("Up CLI", () => {
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain('Recovering rig "stale-velocity" from latest snapshot or current DB state');
expect(output).toContain('Recovering rig "stale-velocity" from latest snapshot');
expect(output).not.toContain('Turning on rig "stale-velocity"');
expect(exitCode).toBeUndefined(); // 0
});
@@ -1063,8 +901,8 @@ describe("Up CLI", () => {
// stage with the resolved starter ResolvedStartupFile shape; the CLI's
// --json mode renders the full daemon response, so the starter content
// surfaces verbatim in plan output. M2 R1 only asserted argument
// forwarding, which proved nothing about plan visibility of starter
// resolution.
// forwarding — Guard Finding 3 — which proved nothing about plan
// visibility of starter resolution.
it("M2 R2: rig up --plan --json against a starter_ref fixture spec surfaces resolved starter contents", async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = await import("node:fs");
@@ -1,18 +1,6 @@
#!/usr/bin/env node
"use strict";
// OpenRig activity-relay hook script.
// Reads a hook event payload from stdin, normalizes it, and POSTs to the
// OpenRig daemon's /api/activity/hooks endpoint for real-time UI seat-status.
// Best-effort only: 1.5s timeout, errors swallowed, never blocks the agent loop.
//
// Required environment (injected by the OpenRig daemon when launching the agent):
// OPENRIG_SESSION_NAME or RIGGED_SESSION_NAME - tmux session id
// OPENRIG_NODE_ID or RIGGED_NODE_ID - node id in the rig topology
// OPENRIG_RUNTIME or RIGGED_RUNTIME - "claude-code" | "codex" | etc.
// OPENRIG_URL or RIGGED_URL - daemon base URL
// OPENRIG_ACTIVITY_HOOK_TOKEN or RIGGED_ACTIVITY_HOOK_TOKEN - bearer auth
async function readStdin() {
return new Promise((resolve) => {
let data = "";
@@ -1,15 +0,0 @@
{
"name": "openrig-core",
"version": "0.1.0",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
"hooks": "./hooks/claude.json"
}
@@ -1,24 +0,0 @@
{
"name": "openrig-core",
"version": "0.1.0",
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
"author": {
"name": "Mike Schwarz",
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
"hooks": "./hooks/codex.json",
"interface": {
"displayName": "OpenRig Core",
"shortDescription": "Canonical OpenRig skills and hooks",
"longDescription": "Cross-runtime agent topology primitive shipping coordination, continuity, and operating-discipline skills, plus activity-tracking hooks. Use as the baseline plugin for any OpenRig-managed agent.",
"developerName": "Mike Schwarz",
"category": "Productivity",
"capabilities": ["Read"],
"websiteURL": "https://github.com/mvschwarz/openrig-plugins"
}
}
@@ -1,200 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or Derivative Works thereof,
You may choose to offer, and charge a fee for, acceptance of support,
warranty, indemnity, or other liability obligations and/or rights
consistent with this License. However, in accepting such obligations,
You may act only on Your own behalf and on Your sole responsibility,
not on behalf of any other Contributor, and only if You agree to
indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Mike Schwarz
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -1,42 +0,0 @@
# openrig-core
Canonical OpenRig skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.
## What this plugin ships
**Skills (8):** the canonical OpenRig operating knowledge — how to use the `rig` CLI, how to design rig topologies, how to recover from compaction, how to hand work off durably between agents, and how to maintain seat continuity across handovers and restarts.
**Hooks:** activity-tracking hooks (Claude Code: 4 events; Codex: 3 events) that POST agent state to the OpenRig daemon for real-time UI seat-status updates.
## Runtimes
This plugin ships dual-manifest packaging — both `.claude-plugin/` and `.codex-plugin/`. Use it with:
- **Claude Code** — install via `/plugin` after the OpenRig daemon vendors `openrig-core/` to `~/.openrig/plugins/openrig-core/` on first run, or install the plugin directly through Claude Code's own plugin commands when remote distribution is available
- **Codex CLI** — install via `/plugins` after the same vendoring step, or directly through Codex's own plugin commands
One skill targets Claude Code's compaction behavior specifically:
- `claude-compaction-restore` — used to rebuild a Claude Code agent's working mental model after `/compact`, from JSONL transcripts and touched files
This is NOT Codex-self-targeting (Codex's compaction is handled internally and doesn't need rebuild SOPs). But Codex agents acting as orchestrators frequently invoke this skill when restoring a peer Claude that has compacted — that's exactly when it's needed. Both runtimes ship it.
All other skills are cross-runtime by design.
## Distribution
**At v0 (today):** the vendored copy inside the OpenRig npm package is the source of truth. When you install `@openrig/cli` (or run the daemon), `openrig-core/` is copied to `~/.openrig/plugins/openrig-core/` on first run. This is the offline-safe baseline and is always available.
**Auto-fetch from GitHub at v0:** the OpenRig daemon probes `github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz` on launch with a 5s timeout. **At v0 the success path only logs the response and does NOT extract a tarball, compare versions, or update the local copy** — extraction + version-compare + update are scoped to a later marketplace-consumption phase (mission slice 3.6). 404 (the expected normal-state response while the GitHub repo is empty), network errors, and timeouts are all tolerated silently with the vendored copy remaining authoritative.
**Future (slice 3.6, marketplace-consumption phase):** when the GitHub releases workflow is set up and authorization to publish lands, the daemon will start extracting fetched tarballs + comparing versions + updating the local copy as the "hot update" path that skips OpenRig CLI version bumps.
You can also install directly via Claude Code or Codex's own plugin commands if you prefer to manage plugins outside OpenRig.
## License
Apache License 2.0. See `LICENSE`.
## Source
- Plugin repo: https://github.com/mvschwarz/openrig-plugins
- OpenRig CLI: https://github.com/mvschwarz/openrig
@@ -1,78 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
},
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
"timeout": 5
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
]
}
}
@@ -1,37 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
"timeout": 5
}
]
}
]
}
}
@@ -1,172 +0,0 @@
#!/usr/bin/env node
"use strict";
// OpenRig Claude compaction restore bridge.
//
// PreCompact writes a pending marker under OPENRIG_HOME. SessionStart
// (matcher=compact) and UserPromptSubmit can then inject one restore
// directive into Claude context via hookSpecificOutput.additionalContext.
// PostCompact uses the same script as a cheap marker timestamp hook.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
async function readStdin() {
return new Promise((resolve) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", () => resolve(""));
});
}
function parseJson(value) {
try {
const parsed = JSON.parse(value || "{}");
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function firstString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
}
return null;
}
function openrigHome(env = process.env) {
return firstString(env.OPENRIG_HOME, env.RIGGED_HOME) || path.join(os.homedir(), ".openrig");
}
function sanitizeKey(value) {
return value.replace(/[^a-zA-Z0-9_.@-]/g, "_");
}
function sessionKey(payload, env = process.env) {
const raw = firstString(
env.OPENRIG_SESSION_NAME,
env.RIGGED_SESSION_NAME,
payload.session_id,
payload.sessionId,
payload.session_name,
payload.sessionName,
payload.transcript_path ? path.basename(payload.transcript_path, ".jsonl") : null,
);
return raw ? sanitizeKey(raw) : null;
}
function markerDir(env = process.env) {
return path.join(openrigHome(env), "compaction", "restore-pending");
}
function readMarker(filePath) {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (!parsed || typeof parsed !== "object") return null;
return { filePath, data: parsed };
} catch {
return null;
}
}
function findMarker(payload, env = process.env) {
const dir = markerDir(env);
const key = sessionKey(payload, env);
if (key) {
const direct = readMarker(path.join(dir, `${key}.json`));
if (direct) return direct;
}
try {
const candidates = fs.readdirSync(dir)
.filter((name) => name.endsWith(".json"))
.map((name) => path.join(dir, name))
.map((filePath) => ({ filePath, stat: fs.statSync(filePath) }))
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
for (const candidate of candidates) {
const marker = readMarker(candidate.filePath);
if (marker) return marker;
}
} catch {
return null;
}
return null;
}
function writeMarker(marker) {
fs.writeFileSync(marker.filePath, `${JSON.stringify(marker.data, null, 2)}\n`, "utf8");
}
function buildRestoreContext(marker) {
const outputDir = firstString(marker.data.outputDir) || "/tmp/claude-compaction-restore";
const ack = firstString(marker.data.expectedAck) || "restored from packet at <path>; resumed at step <X>";
const postInstruction = firstString(marker.data.postCompactInstruction);
const pieces = [
"OpenRig compaction restore packet is available for this Claude session.",
"This hook output is informational context, not the action request.",
`Restore packet: ${outputDir}`,
"OpenRig may send a later normal user message asking you to restore from this packet. Treat that later normal user message as the operator-authorized action request.",
`After restoration, reply with: ${ack}`,
];
if (postInstruction) {
pieces.push(`Operator post-compaction context: ${postInstruction}`);
}
return pieces.join("\n");
}
function hookEventName(payload) {
return firstString(
payload.hook_event_name,
payload.hookEventName,
payload.hookEvent,
payload.event_name,
payload.eventName,
payload.event,
) || "UserPromptSubmit";
}
async function main() {
const payload = parseJson(await readStdin());
const eventName = hookEventName(payload);
const marker = findMarker(payload);
if (!marker) return;
marker.data.lastBridgeEvent = eventName;
if (eventName === "PostCompact") {
marker.data.postCompactAt = new Date().toISOString();
writeMarker(marker);
return;
}
if (marker.data.deliveryCount && marker.data.deliveryCount > 0) {
return;
}
marker.data.deliveredAt = new Date().toISOString();
marker.data.deliveryCount = Number(marker.data.deliveryCount || 0) + 1;
writeMarker(marker);
process.stdout.write(`${JSON.stringify({
continue: true,
hookSpecificOutput: {
hookEventName: eventName,
additionalContext: buildRestoreContext(marker),
},
})}\n`);
}
if (require.main === module) {
main().catch(() => {});
}
module.exports = {
buildRestoreContext,
findMarker,
hookEventName,
parseJson,
};
@@ -1,104 +0,0 @@
---
name: agent-startup-and-context-ingestion
description: Use when designing or auditing how an agent becomes useful after launch — AGENTS.md overlays, role files, skills, rig specs, workflow specs, startup checklists, refocus messages, "rig context" surface. Covers the 4 failure modes that make startup context fail (old rig spec misses current operating mode; current agents never told about new guidance; startup file as dumping ground; orchestrator transmits implementation without preserving product intent).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Cross-runtime restore/reentry packet standard + externalized memory
surfaces convention. Load at agent start; revisit after compaction.
sibling_skills:
- claude-compaction-restore
- seat-continuity-and-handover
transfer_test: pending
---
# Agent Startup and Context Ingestion
How an agent becomes useful after launch: **AGENTS.md overlays, role
files, skills, rig specs, workflow specs, startup checklists, refocus
messages**, and any future "rig context" command or skill-librarian
surface.
It is the current concrete startup path inside the broader
context-engineering-and-retrieval primitive. Startup gets a seat into
the right initial shape; context engineering is the larger question of
how a seat gets the right context for the work it is doing right now.
**Most coordination failures are not tool failures; they are context
failures.** Agents need to know their role, operating mode, coordination
convention, boundaries, and current product intent. If startup context
is scattered or stale, agents execute the wrong thing very efficiently.
## Use this when
- Authoring a new agent's startup files (role / culture / startup-context)
- Refreshing a seat that's been running on stale guidance
- Auditing whether agents got the current operating mode (not just what's in old files)
- Designing the orchestrator → next-agent context-transmission shape
- Building a startup map for OpenRig-building rigs
## Don't use this when
- The agent is being created via Agent Starter — the starter's manifest carries startup context
- The work is artifact-backed mental-model rebuild from a packet — that's `claude-compaction-restore`
- The intent is to ship reusable startup content as a skill — write the skill against the agentskills.io specification, not as inline startup prose
## Failure modes (4)
1. **A new agent starts from an old rig spec and misses the current operating mode.** Specs go stale; current state must be visible at startup, not just historical config.
2. **Guidance is written to a file that future agents read, but current agents are never told.** File edits don't propagate to running sessions. Cultural rollout (broadcast + fleet-changes-feed) is needed alongside file edits.
3. **A startup file becomes a dumping ground and loses the map-to-canonical-sources role.** Startup should point AT canonical sources; it shouldn't TRY to be one.
4. **The orchestrator transmits implementation instructions without preserving product intent.** Instructions decay; intent travels.
## Proof standard
Startup proof should:
1. **Launch or refresh** a seat
2. **Inspect** what it actually read
3. **Verify** it can state the current role, mode, active constraints, and next handoff convention **in its own words**
## Cross-runtime startup paths (5; do not collapse)
When a seat's startup is artifact-backed mental-model rebuild (active-work
reentry case, distinct from reusable Agent Starters / priming packs):
- See the **cross-runtime restore/reentry packet standard v0**
- Source-trust ranking applies: **`rig whoami` > target rigspec > bounded latest transcript > full transcript > touched-files > `restore-summary.json`**
## Memory surfaces consumed at startup
The primary surfaces a startup ingestion path reads:
- AGENTS / role / CULTURE / startup overlays (the per-seat identity layer)
- startup replay context (what the agent did before the current boot)
- restore / reentry packets and Agent Starters (cross-runtime startup path)
Authority-rank + read-vs-write posture governs which surfaces a
startup-ingestion path can write vs only read; treat overlays as
write-restricted unless your role explicitly authors them.
## Startup files vs skills (the distinction)
Per `agent-startup-guide.md` (product reference doc) and the team handbook:
| Startup files | Skills |
|---|---|
| Rig-specific, role-specific identity | Reusable SOPs / methodology / knowledge |
| Tell agent WHO it is, WHAT it's working on, HOW this team operates | Tell agent HOW to do something (transferable across rigs) |
| Examples: `role.md`, `CULTURE.md`, `startup/context.md` | Examples: `openrig-user`, `test-driven-development`, `dogfood` |
| Authored per-rig | Authored once, used everywhere |
Don't put skill content in startup files. Don't put identity content
in skills. The 7-layer additive startup model (agent / profile / rig /
culture / pod / member / operator) handles the layering.
## See also
- `forming-an-openrig-mental-model` skill — orientation for new agents
- `claude-compaction-restore` skill — restore-time startup ingestion after compaction
- `seat-continuity-and-handover` skill — packet shape that survives seat resets
- https://agentskills.io/specification — the cross-runtime skill standard (what skill content should and shouldn't be)
@@ -1,131 +0,0 @@
---
name: claude-compaction-restore
description: Use when a Claude Code session has just compacted, is about to compact, reached context limit, resumed after /compact, or needs to rebuild its working mental model from Claude JSONL transcripts and touched files.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Load-bearing for any OpenRig seat that may compact. PreCompact hook
is wired into Claude Code's settings (e.g. ~/.claude/settings.json)
so the restore packet is generated automatically before compaction.
sibling_skills:
- agent-startup-and-context-ingestion
- seat-continuity-and-handover
---
# Claude Compaction Restore
Use this skill to preserve continuity before Claude Code compacts and to
restore continuity after compaction. Do the work described here; do not merely
explain the protocol.
## If You Are About To Compact
Prepare durable continuity before the context boundary.
1. Identify the active task, queue item, mission/slice, branch or commit, and
current working directory.
2. Record the current state: decisions made, files changed, commands/tests run,
evidence produced, blockers, caveats, and the next concrete step.
3. Create or update a durable mental-model restore map. This map is the main
artifact future-you will use to rebuild context after compaction.
4. In the restore map, write an ASCII file/folder tree of every path that
mattered to your working mental model during this session. Include:
- the active queue item or mission packet;
- mission notes, progress, decisions, and evidence files;
- Claude memory/project notes you used or wrote, especially when the memory
folder is shared by many agents;
- files with active edits or recently inspected source;
- root instructions such as `AGENTS.md`, `CLAUDE.md`, or `README.md`;
- as-built docs, codemaps, conventions, skills, and product docs needed
before code/review work;
- source files, tests, scripts, UI evidence, screenshots, logs, or reports
that shaped your current state.
5. For every file or folder in the tree, add a short note explaining why it
matters and whether it is required reading after compaction.
6. Write any important glue context that is not already on disk into the
handoff/restore map. This includes assumptions, partial conclusions, failed
paths, and why the listed files fit together.
7. In the compaction summary, include the restore map path and the top required
reading paths from that map.
## If You Just Compacted
Treat your memory as unreliable until you restore from durable evidence.
Restoration is your current task. Do not wait for a future user request or a
new task assignment before reading the required files.
1. Read any restore prompt, marker path, packet path, transcript path, restore
map, or extra instruction file named by the operator or OpenRig.
2. Run the restore script from this skill:
```bash
node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore
```
If the prompt names a specific Claude JSONL transcript, pass it explicitly:
```bash
node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs /path/to/session.jsonl --out /tmp/claude-compaction-restore
```
3. Read the generated `restore-instructions.md`.
4. Read the generated `touched-files.md`.
5. Find and read the mental-model restore map if one exists. Treat it as the
highest-value guide to which memory files, mission files, source files, and
docs mattered before compaction.
6. Identify the important files for the active task. The touched-file list is
a triage aid, not a complete inventory.
7. Read every important file in full. Prioritize the restore map, Claude memory
files named in the map, Markdown state/planning files, queue/mission packets,
source files with active edits, root instruction files, and as-built or
codemap docs.
8. After the required reads are complete, state:
```text
restored from packet at <path>; resumed at step <X>
```
Include the main files you read in full when you make that statement.
## Required Read-Depth Audit
After the first restore pass, audit yourself before continuing.
1. List every file, packet, marker, restore map, instruction file, and source
document you were asked to read during restore.
2. Mark each item as `FULL`, `PARTIAL`, or `NOT_READ`.
3. You will be given a task where all of these files are required reading in
order to understand the task.
4. Do not optimize for token conservation.
5. Read every `PARTIAL` or `NOT_READ` item in full now.
6. Report the final read-depth table before doing any substantive task work.
## Guardrails
- Do not silently launch fresh after compaction.
- Do not continue from memory when restore evidence exists.
- Do not defer required restore reading until a later user task. The restore is
the current task.
- Do not skip root instructions, as-built docs, or codemaps before product
code/review work.
- Do not treat the generated touched-file list as exhaustive.
- Do not mark a file `FULL` unless you actually read the full file content
after compaction.
- Do not resume task work until the restore sentinel and read-depth audit are
complete.
## Failure Modes To Avoid
1. **Confidently-wrong restoration**: claiming restoration after reading only
the touched-file list or summary.
2. **Partial restore**: reading the first few files, then continuing before the
full reading list is complete.
3. **Skipping project instructions**: missing `AGENTS.md`, `CLAUDE.md`,
`README.md`, as-built docs, or codemaps that govern the task.
4. **Treating the packet as exhaustive**: ignoring mission or workspace files
that are important but were not discovered by the script.
5. **Waiting for the next task**: treating restore reading as conditional on a
future user assignment instead of completing it immediately.
@@ -1,174 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const skillRoot = path.resolve(new URL("..", import.meta.url).pathname);
const restoreScript = path.join(skillRoot, "scripts", "restore-from-jsonl.mjs");
const outRoot = "/tmp/claude-compaction-restore";
const defaultRestoreInstruction =
"Read the claude-compaction-restore skill and follow its \"If You Just Compacted\" protocol.";
function emit(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
}
function readHookInput() {
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) return {};
return JSON.parse(raw);
}
function getOpenRigHome() {
return process.env.OPENRIG_HOME || process.env.RIGGED_HOME || path.join(os.homedir(), ".openrig");
}
function expandInstructionPath(filePath) {
if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2));
if (filePath.startsWith("${OPENRIG_HOME}/")) {
return path.join(getOpenRigHome(), filePath.slice("${OPENRIG_HOME}/".length));
}
if (filePath.startsWith("$OPENRIG_HOME/")) {
return path.join(getOpenRigHome(), filePath.slice("$OPENRIG_HOME/".length));
}
return filePath;
}
function readInstructionFile(filePath) {
const expanded = expandInstructionPath(filePath);
if (!fs.existsSync(expanded)) return "";
return fs.readFileSync(expanded, "utf8");
}
function sessionKey(input) {
const raw = [
process.env.OPENRIG_SESSION_NAME,
process.env.RIGGED_SESSION_NAME,
input.session_id,
input.sessionId,
input.session_name,
input.sessionName,
input.transcript_path ? path.basename(input.transcript_path, ".jsonl") : "",
].find((value) => typeof value === "string" && value.trim().length > 0) || "unknown-session";
return raw.replace(/[^a-zA-Z0-9_.@-]/g, "_");
}
function pendingMarkerPath(input) {
return path.join(getOpenRigHome(), "compaction", "restore-pending", `${sessionKey(input)}.json`);
}
function writePendingRestoreMarker(input, parsed, restoreInstruction, customMessage) {
const markerPath = pendingMarkerPath(input);
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
const payload = {
version: 1,
createdAt: new Date().toISOString(),
sessionName: process.env.OPENRIG_SESSION_NAME || process.env.RIGGED_SESSION_NAME || null,
sessionId: input.session_id || input.sessionId || null,
transcriptPath: input.transcript_path || null,
cwd: input.cwd || null,
outputDir: parsed.outputDir,
restoreInstruction,
postCompactInstruction: customMessage || "",
expectedAck: "restored from packet at <path>; resumed at step <X>",
deliveredAt: null,
deliveryCount: 0,
};
fs.writeFileSync(markerPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
return markerPath;
}
// Slice 27 — read the OpenRig config directly (no daemon HTTP dependency
// so the hook still works when the daemon isn't running or isn't
// reachable from this process). Returns "" for either field when the
// config is missing, malformed, or the policy isn't set. If the policy is
// enabled but restore text has not been written yet, use OpenRig's default
// instruction to load the canonical restore skill. Inline instructions and
// file-path content are both included when both are configured.
function readClaudeCompactionMessage() {
const configPath = path.join(getOpenRigHome(), "config.json");
let inline = "";
let filePath = "";
let inlineConfigured = false;
let filePathConfigured = false;
let policyEnabled = false;
try {
if (!fs.existsSync(configPath)) return "";
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw);
const policy = parsed?.policies?.claudeCompaction;
if (policy && typeof policy === "object") {
policyEnabled = policy.enabled === true;
if (typeof policy.messageInline === "string") {
inlineConfigured = true;
inline = policy.messageInline;
}
if (typeof policy.messageFilePath === "string") {
filePathConfigured = true;
filePath = policy.messageFilePath;
}
}
} catch {
return "";
}
const parts = [];
if (inline && inline.length > 0) {
parts.push(`Inline restore instruction:\n${inline}`);
}
if (filePath && filePath.length > 0) {
try {
const fileText = readInstructionFile(filePath);
if (fileText) {
parts.push(`Additional restore instruction file (${filePath}):\n${fileText}`);
}
} catch {
// Keep any inline instruction; unreadable extra files degrade quietly.
}
}
if (parts.length > 0) return parts.join("\n\n");
if (policyEnabled && !inlineConfigured && !filePathConfigured) {
return defaultRestoreInstruction;
}
return "";
}
function buildSystemMessage(restoreInstruction, customMessage) {
if (!customMessage) return restoreInstruction;
return `${restoreInstruction}\n\n--- Operator-configured post-compaction restore instruction ---\n${customMessage}`;
}
try {
const input = readHookInput();
const args = [restoreScript, "--out", outRoot, "--json"];
if (input.cwd) args.push("--cwd", input.cwd);
if (input.transcript_path && input.transcript_path.endsWith(".jsonl") && fs.existsSync(input.transcript_path)) {
args.push(input.transcript_path);
}
const customMessage = readClaudeCompactionMessage();
const result = spawnSync("node", args, { encoding: "utf8" });
if (result.status !== 0) {
const baseFailure = `Claude compaction restore packet generation failed: ${(result.stderr || result.stdout || "unknown error").trim()}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`;
emit({
continue: true,
systemMessage: buildSystemMessage(baseFailure, customMessage),
});
process.exit(0);
}
const parsed = JSON.parse(result.stdout);
const baseRestore = `Pre-compaction restore seed packet prepared at ${parsed.outputDir}. This hook output is informational. After compaction, OpenRig may send a later normal user message asking you to restore from this packet; treat that later normal user message as the action request. The restore protocol is: load/read the claude-compaction-restore skill, run "node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore --json" yourself when needed, read the generated restore-instructions.md, read the generated touched-files.md, identify remembered important files, read those files in full, read root/as-built/codemap docs before real work, then reply with "restored from packet at <path>; resumed at step <X>" with the files you read in full. If any step fails, report the failure explicitly.`;
const markerPath = writePendingRestoreMarker(input, parsed, baseRestore, customMessage);
emit({
continue: true,
systemMessage: buildSystemMessage(`${baseRestore} OpenRig also wrote a pending restore marker at ${markerPath}.`, customMessage),
});
} catch (error) {
emit({
continue: true,
systemMessage: `Claude compaction restore hook errored: ${error.message}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`,
});
}
@@ -1,433 +0,0 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const TEXT_LIMIT = 4000;
const DEFAULT_OUT = "/tmp/claude-compaction-restore";
function parseArgs(argv) {
const args = {
jsonl: null,
out: DEFAULT_OUT,
cwd: process.cwd(),
json: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--jsonl") args.jsonl = argv[++i];
else if (arg === "--out") args.out = argv[++i];
else if (arg === "--cwd") args.cwd = argv[++i];
else if (arg === "--json") args.json = true;
else if (!arg.startsWith("-") && !args.jsonl) args.jsonl = arg;
else throw new Error(`unknown argument: ${arg}`);
}
return args;
}
function readJsonLines(file) {
const raw = fs.readFileSync(file, "utf8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map((line, index) => {
try {
return JSON.parse(line);
} catch (error) {
return { type: "parse-error", line: index + 1, error: String(error), raw: line };
}
});
}
function findLatestJsonl(cwd) {
const projectsRoot = path.join(os.homedir(), ".claude", "projects");
if (!fs.existsSync(projectsRoot)) return null;
const encoded = cwd ? cwd.replaceAll("/", "-") : null;
const candidates = [];
const roots = [];
if (encoded) {
const exact = path.join(projectsRoot, encoded);
if (fs.existsSync(exact)) roots.push(exact);
}
roots.push(projectsRoot);
for (const root of roots) {
const result = spawnSync("find", [root, "-name", "*.jsonl", "-maxdepth", root === projectsRoot ? "4" : "2"], {
encoding: "utf8",
});
if (result.status !== 0 && !result.stdout) continue;
for (const file of result.stdout.split(/\r?\n/).filter(Boolean)) {
try {
const stat = fs.statSync(file);
candidates.push({ file, mtimeMs: stat.mtimeMs });
} catch {
// Ignore raced/deleted transcript files.
}
}
if (candidates.length) break;
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates[0]?.file ?? null;
}
function stringifyContent(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block?.type === "text") return block.text ?? "";
if (block?.type === "tool_use") return `[tool_use:${block.name}] ${JSON.stringify(block.input ?? {})}`;
if (block?.type === "tool_result") return `[tool_result] ${truncate(stringifyContent(block.content), TEXT_LIMIT)}`;
return JSON.stringify(block);
})
.join("\n");
}
if (content == null) return "";
return JSON.stringify(content);
}
function truncate(text, max) {
const s = String(text ?? "");
if (s.length <= max) return s;
return `${s.slice(0, max)}\n[...truncated ${s.length - max} chars...]`;
}
function walkStrings(value, acc = []) {
if (typeof value === "string") acc.push(value);
else if (Array.isArray(value)) value.forEach((item) => walkStrings(item, acc));
else if (value && typeof value === "object") Object.values(value).forEach((item) => walkStrings(item, acc));
return acc;
}
function normalizeFile(candidate, cwd) {
let file = candidate.trim().replace(/[),.;:'"`\]\}>]+$/g, "").replace(/^[('"`<\[]+/g, "");
if (!file || file.includes("\n")) return null;
if (/\.(?:md|mdx|txt|json|jsonl|yaml|yml|toml|ts|tsx|js|jsx|mjs|cjs|py|sh|rs|go|sql|css|scss|html|xml|svg|csv|log|lock)\//i.test(file)) return null;
if (file.startsWith("~")) file = path.join(os.homedir(), file.slice(1));
let normalized = null;
if (file.startsWith("/")) normalized = path.normalize(file);
else if (file.startsWith("./") || file.startsWith("../")) normalized = path.normalize(path.resolve(cwd, file));
else if (/^[A-Za-z0-9_@.+-][A-Za-z0-9_@.+/\-:]*\.[A-Za-z0-9]+$/.test(file)) {
normalized = path.normalize(path.resolve(cwd, file));
} else if (/^(CLAUDE|AGENTS|README|DESIGN|CULTURE|MEMORY)\.md$/.test(file)) {
normalized = path.normalize(path.resolve(cwd, file));
}
if (!normalized) return null;
try {
if (fs.existsSync(normalized) && fs.statSync(normalized).isDirectory()) return null;
} catch {
// Keep the candidate if stat races; the restore agent can decide.
}
return normalized;
}
function extractPaths(text, cwd) {
const paths = new Set();
const s = String(text ?? "");
const absolute = /(?:^|[\s"'(<\[])(\/(?:Users|private|tmp|var|opt|Volumes)\/[^\s"'()\]<>]+)/g;
const relative = /(?:^|[\s"'(<\[])((?:\.{1,2}\/)?[A-Za-z0-9_@.+-][A-Za-z0-9_@.+/\-:]*\.(?:md|mdx|txt|json|jsonl|yaml|yml|toml|ts|tsx|js|jsx|mjs|cjs|py|sh|rs|go|sql|css|scss|html|xml|svg|csv|log|lock))(?:$|[\s"')>\],.;:])/g;
const namedMarkdown = /(?:^|[\s"'(<\[])((?:CLAUDE|AGENTS|README|DESIGN|CULTURE|MEMORY)\.md)(?:$|[\s"')>\],.;:])/g;
for (const regex of [absolute, relative, namedMarkdown]) {
let match;
while ((match = regex.exec(s)) !== null) {
const normalized = normalizeFile(match[1], cwd);
if (normalized) paths.add(normalized);
}
}
return [...paths];
}
function createRegistry() {
const files = new Map();
let sequence = 0;
return {
add(file, kind, source) {
if (!file) return;
const existing = files.get(file) ?? {
path: file,
kinds: {},
sources: new Set(),
firstSeen: sequence,
lastSeen: sequence,
};
existing.kinds[kind] = (existing.kinds[kind] ?? 0) + 1;
existing.sources.add(source);
existing.lastSeen = sequence;
files.set(file, existing);
},
tick() {
sequence += 1;
},
values() {
return [...files.values()].map((entry) => ({
...entry,
sources: [...entry.sources].slice(0, 8),
}));
},
};
}
function toolKind(name, input) {
if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name)) return "written";
if (name === "Read") return "read";
if (["Grep", "Glob", "LS"].includes(name)) return "discovered";
if (name === "Bash") {
const command = input?.command ?? "";
if (/\b(apply_patch|tee|touch|mkdir|mv|cp|rm)\b|(^|[^>])>{1,2}[^>]/.test(command)) return "shell-write";
return "shell-mentioned";
}
return "mentioned";
}
function scoreFile(entry) {
const ext = path.extname(entry.path).toLowerCase();
let score = 0;
if (entry.kinds.written) score += 40;
if (entry.kinds["shell-write"]) score += 30;
if (entry.kinds["file-history"]) score += 35;
if (ext === ".md" || ext === ".mdx") score += 20;
if (entry.kinds.read) score += 10;
if (entry.kinds.discovered) score += 4;
if (/\/docs\/as-built\//.test(entry.path)) score += 25;
if (/(codemap|architecture|CLAUDE|AGENTS|README|DESIGN|CULTURE|session\.log|queue\.md)/i.test(entry.path)) score += 15;
score += Math.min(10, Object.values(entry.kinds).reduce((a, b) => a + b, 0));
return score;
}
function discoverDocs(cwd) {
const candidates = [
"CLAUDE.md",
"AGENTS.md",
"README.md",
"docs/as-built",
"docs",
];
const docs = new Set();
for (const candidate of candidates) {
const full = path.resolve(cwd, candidate);
if (!fs.existsSync(full)) continue;
const stat = fs.statSync(full);
if (stat.isFile()) docs.add(full);
if (stat.isDirectory()) {
const result = spawnSync("find", [full, "-maxdepth", candidate === "docs" ? "2" : "1", "-type", "f", "-name", "*.md"], {
encoding: "utf8",
});
for (const file of result.stdout.split(/\r?\n/).filter(Boolean)) {
if (/as-built|codemap|architecture|README|overview/i.test(file)) docs.add(path.normalize(file));
}
}
}
return [...docs].sort();
}
function analyze(jsonlPath, cwd) {
const records = readJsonLines(jsonlPath);
const registry = createRegistry();
const transcript = [];
const cwdCounts = new Map();
let sessionId = null;
for (const record of records) {
registry.tick();
if (record.sessionId || record.session_id) sessionId = record.sessionId ?? record.session_id;
if (record.cwd) {
cwdCounts.set(record.cwd, (cwdCounts.get(record.cwd) ?? 0) + 1);
cwd = record.cwd;
}
if (record.type === "file-history-snapshot") {
const backups = record.snapshot?.trackedFileBackups ?? {};
for (const file of Object.keys(backups)) registry.add(path.normalize(file), "file-history", "file-history-snapshot");
}
const message = record.message;
if (!message) continue;
const role = message.role ?? record.type ?? "unknown";
const content = message.content;
if (typeof content === "string") {
transcript.push(`\n## ${role}\n\n${content}`);
for (const file of extractPaths(content, cwd)) registry.add(file, "mentioned", `${role}:text`);
continue;
}
if (!Array.isArray(content)) continue;
const parts = [];
for (const block of content) {
if (block?.type === "text") {
parts.push(block.text ?? "");
for (const file of extractPaths(block.text ?? "", cwd)) registry.add(file, "mentioned", `${role}:text`);
} else if (block?.type === "tool_use") {
const name = block.name ?? "unknown";
const input = block.input ?? {};
parts.push(`\n[tool_use:${name}]\n${JSON.stringify(input, null, 2)}`);
const kind = toolKind(name, input);
if (input.file_path) registry.add(normalizeFile(input.file_path, cwd), kind, `tool:${name}:file_path`);
if (input.path) registry.add(normalizeFile(input.path, cwd), kind, `tool:${name}:path`);
if (input.notebook_path) registry.add(normalizeFile(input.notebook_path, cwd), kind, `tool:${name}:notebook_path`);
for (const text of walkStrings(input)) {
for (const file of extractPaths(text, cwd)) registry.add(file, kind, `tool:${name}:input`);
}
} else if (block?.type === "tool_result") {
const resultText = stringifyContent(block.content);
parts.push(`\n[tool_result]\n${truncate(resultText, TEXT_LIMIT)}`);
for (const file of extractPaths(resultText, cwd)) registry.add(file, "mentioned", "tool_result");
}
}
if (parts.length) transcript.push(`\n## ${role}\n\n${parts.join("\n")}`);
}
const rankedFiles = registry
.values()
.map((entry) => ({ ...entry, score: scoreFile(entry), markdown: [".md", ".mdx"].includes(path.extname(entry.path).toLowerCase()) }))
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
const cwdRanked = [...cwdCounts.entries()].sort((a, b) => b[1] - a[1]);
const effectiveCwd = cwdRanked[0]?.[0] ?? cwd;
return {
sessionId,
jsonlPath,
cwd: effectiveCwd,
records: records.length,
transcript: transcript.join("\n"),
files: rankedFiles,
docs: discoverDocs(effectiveCwd),
};
}
function markdownFileList(files) {
const lines = [];
lines.push("# Touched Files");
lines.push("");
lines.push("Files are ranked for restore relevance. Markdown and written files are the first candidates to read in full.");
lines.push("");
const sections = [
["Highest-priority Markdown/state files", (f) => f.markdown && (f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
["Other written/tracked files", (f) => !f.markdown && (f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
["Read/discovered/mentioned files", (f) => !(f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
];
for (const [title, predicate] of sections) {
const group = files.filter(predicate);
if (!group.length) continue;
lines.push(`## ${title}`);
lines.push("");
for (const file of group) {
const kinds = Object.entries(file.kinds).map(([kind, count]) => `${kind}:${count}`).join(", ");
lines.push(`- score ${file.score} — \`${file.path}\` — ${kinds}`);
}
lines.push("");
}
return lines.join("\n");
}
function restoreInstructions(summary, outputPaths) {
const lines = [];
lines.push("# Claude Compaction Restore Instructions");
lines.push("");
lines.push("You have just been compacted or are restoring a compacted Claude Code seat. Your mental model is compressed and unreliable.");
lines.push("");
lines.push(`- JSONL transcript: \`${summary.jsonlPath}\``);
lines.push(`- Reconstructed transcript: \`${outputPaths.transcript}\``);
lines.push(`- Touched-file triage: \`${outputPaths.touchedFiles}\``);
lines.push(`- Working directory inferred: \`${summary.cwd}\``);
lines.push("");
lines.push("## Required Sequence");
lines.push("");
lines.push("1. Read this file and `touched-files.md`.");
lines.push("2. Ask yourself: which files do I recognize as important to the work and project state?");
lines.push("3. Mark those files mentally or in a short note.");
lines.push("4. Read every important Markdown/state/planning file in full.");
lines.push("5. Read project root docs in full when present: `CLAUDE.md`, `AGENTS.md`, `README.md`.");
lines.push("6. Read as-built docs and codemaps in full before product work, code review, or architecture decisions.");
lines.push("7. State exactly: `restored from packet at <path>; resumed at step <X>`.");
lines.push("");
lines.push("## Documentation Candidates");
lines.push("");
if (summary.docs.length) {
for (const doc of summary.docs) lines.push(`- \`${doc}\``);
} else {
lines.push("- No root/as-built/codemap candidates were found automatically. Search manually before resuming code/review work.");
}
lines.push("");
lines.push("## Top File Candidates");
lines.push("");
for (const file of summary.files.slice(0, 30)) {
lines.push(`- \`${file.path}\` — score ${file.score}`);
}
lines.push("");
lines.push("Do not continue from fuzzy memory alone.");
return lines.join("\n");
}
function writeOutputs(summary, outRoot) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const base = path.join(outRoot, `${summary.sessionId ?? "unknown-session"}-${stamp}`);
fs.mkdirSync(base, { recursive: true });
const outputPaths = {
dir: base,
transcript: path.join(base, "transcript.txt"),
touchedFiles: path.join(base, "touched-files.md"),
instructions: path.join(base, "restore-instructions.md"),
summary: path.join(base, "restore-summary.json"),
};
fs.writeFileSync(outputPaths.transcript, summary.transcript || "(no message transcript reconstructed)\n");
fs.writeFileSync(outputPaths.touchedFiles, markdownFileList(summary.files));
fs.writeFileSync(outputPaths.instructions, restoreInstructions(summary, outputPaths));
fs.writeFileSync(outputPaths.summary, JSON.stringify({ ...summary, transcript: undefined, outputPaths }, null, 2));
return outputPaths;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const cwd = path.resolve(args.cwd);
const jsonlPath = args.jsonl ? path.resolve(args.jsonl) : findLatestJsonl(cwd);
if (!jsonlPath) throw new Error("could not find a Claude JSONL transcript; pass one explicitly");
if (!fs.existsSync(jsonlPath)) throw new Error(`JSONL transcript not found: ${jsonlPath}`);
const summary = analyze(jsonlPath, cwd);
const outputPaths = writeOutputs(summary, path.resolve(args.out));
const result = {
ok: true,
jsonlPath,
outputDir: outputPaths.dir,
transcript: outputPaths.transcript,
touchedFiles: outputPaths.touchedFiles,
instructions: outputPaths.instructions,
fileCount: summary.files.length,
topFiles: summary.files.slice(0, 12).map((file) => file.path),
};
if (args.json) console.log(JSON.stringify(result, null, 2));
else {
console.log(`Restore packet: ${result.outputDir}`);
console.log(`Instructions: ${result.instructions}`);
console.log(`Touched files: ${result.touchedFiles}`);
console.log(`Transcript: ${result.transcript}`);
}
}
try {
main();
} catch (error) {
console.error(`claude-compaction-restore failed: ${error.message}`);
process.exit(1);
}
@@ -1 +0,0 @@
Optional. Leave blank to use Claude Code's native compaction summary behavior; OpenRig's pre-compaction preparation and post-compaction restore prompts carry the durable continuity protocol.
@@ -1 +0,0 @@
Read the claude-compaction-restore skill and follow its "If You Just Compacted" protocol.
@@ -1,305 +0,0 @@
---
name: forming-an-openrig-mental-model
description: |
Use when an agent is newly oriented to OpenRig and needs to form an accurate mental model of the system fast — what rigs are, how skills load, what the topology shapes mean, what the product loop is. For agents booting into a new seat or returning to OpenRig work after time away. NOT for HA-pair compaction recovery (that's the pair-of-seats coordination pattern; orientation is a solo task) or for specific operational procedures.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Authored 2026-05-02 in response to founder-observed pattern: agents
Distinct from HA-pair mental-model preservation (which is a
pair-of-seats coordination pattern). This skill serves the legitimate
need to form an initial mental model of OpenRig as a system.
sibling_skills:
- openrig-user
- openrig-operator
- openrig-architect
transfer_test: pending
---
# Forming an OpenRig Mental Model
You're new to OpenRig — or returning after time away — and you need to
quickly understand what kind of system this is, what your seat is, and what
the moves are. This skill is the fast on-ramp.
For depth, read the canonical reference docs the skill points to. This
skill's job is to get you *oriented* — accurate enough to operate, fast
enough to be useful — not to replace the canonical docs.
---
## The 60-second mental model
OpenRig is a **local control plane for multi-agent coding topologies**. You
declare a topology of agents in YAML, boot it with one command, and OpenRig
manages tmux sessions, harness lifecycles, transcripts, snapshots, and
restoration. When the system goes down, OpenRig snapshots; when it comes
back, agents resume their conversations.
The product loop:
```
down (auto-snapshot) → up <rig-name> (auto-restore) → work → repeat
```
The unit of work is the **rig** — a topology of agents working together as
a single system.
---
## The four-layer model (where you live)
Everything in agent engineering happens at one of four layers. **OpenRig
operates at Layer 3.**
| Layer | Name | Analogy | What it is |
|---|---|---|---|
| L0 | Model | CPU | Foundation model — Claude, GPT, Gemini. Stateless tokens-in/tokens-out. |
| L1 | Agent Core | Process loop | The reason-and-act cycle: observe, plan, choose, act, repeat. |
| L2 | Harness | Container / OS | Tools, memory, lifecycle around the model. Examples: Claude Code, Codex CLI. |
| L3 | Rig | Docker Compose / Terraform | Multi-agent topology — what agents exist, how they relate. **OpenRig.** |
You are an agent at L1 inside an L2 harness, configured by L3 OpenRig.
OpenRig manages your harness; the harness wraps the model; the model
generates your tokens.
---
## Three pillars of context
OpenRig is built on three context-engineering pillars. When you're oriented,
you should know which pillar you're operating in:
| Pillar | What it is | Where it lives |
|---|---|---|
| **Ontology** | What exists. Curated knowledge — facts, code maps, as-built docs. | Corpus (planned future system). Today: substrate prose docs. |
| **Epistemology** | Why an agent believes what it believes — reasoning, instincts, decisions. | Transcripts (auto-captured). Session logs. ADRs. |
| **Topology** | How agents are connected — pods, edges, communication paths. | OpenRig itself. RigSpec YAML. |
OpenRig **manages the topology pillar**. The other two are filled by Corpus
(future) and transcripts (now). Most of your work probably touches multiple
pillars; knowing which one you're operating in helps you reach for the right
artifacts.
---
## The core vocabulary (read these terms literally)
| Term | What it means |
|---|---|
| **Rig** | A topology of agents working together as a single system. Defined in YAML (RigSpec). The top-level object. |
| **Pod** | A bounded context group within a rig. Members of a pod share a context domain and continuity responsibility. Think Kubernetes pod for knowledge. |
| **Member / Node** | A single agent (or terminal-node service) within a pod. |
| **Edge** | A relationship between members or pods. Kinds: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`. |
| **Topology** | The shape of the rig — how agents are grouped into pods, how edges connect them, how the whole thing fits together. |
| **AgentSpec** | A reusable agent blueprint. Defines skills, guidance, hooks, profiles, startup. File: `agent.yaml`. |
| **RigSpec** | The topology YAML. Defines pods, members, edges, culture. File: `rig.yaml`. |
| **RigBundle** | A portable archive of a RigSpec + vendored AgentSpecs. Move topologies across machines. |
| **Agent Starter** | A named, reusable starting context bundle. RigSpec member can declare `starter_ref`. |
| **Skill** | A markdown file with frontmatter that an agent loads at boot or on activation. Cross-runtime standard at `agentskills.io`. |
| **Profile** | A named configuration within an AgentSpec. The rig spec's member field selects which profile to use. |
| **Culture** | Rig-wide constitution — how the team communicates, what "done" means, escalation rules. File: `CULTURE.md`. |
| **Snapshot** | Point-in-time capture of a rig — sessions, conversations, state. Restorable. |
| **Session name** | `{pod}-{member}@{rig}`. The canonical address for tmux sessions and agent-to-agent messaging. |
The session-name format `{pod}-{member}@{rig}` is your address. When you
run `rig whoami --json`, you get back your full topology context: rig name,
pod, member, peers, edges, transcript path.
---
## Rig classes (what kind of rig am I in?)
OpenRig has five rig classes. The class determines authoring discipline,
supervision, and lifecycle policy.
| Class | Purpose | Lifecycle |
|---|---|---|
| **kernel** | Host-level supervision, intake, authoring. One per host. | Always on; never auto-hibernated. |
| **project** | Long-lived team bound to a codebase. | Stays hot when active; hibernates on explicit request. |
| **ephemeral** | Short-lived mission (research, build, migration, spike). | Spawn → work → retire. |
| **infra-build** | Subclass of ephemeral whose output becomes permanent infrastructure. | Retired only after output verified in place. |
| **managed-app** | Services-backed rig with specialist agents (e.g., a vault specialist, a skill librarian). | Long-lived; accessed by other rigs. |
You're probably in a project rig or managed-app rig if you're doing
substantive work. Knowing your class helps you understand the supervisory
expectations on your seat.
---
## How skills load (the most important thing to get right)
Skills are an **established cross-runtime standard** at
`https://agentskills.io/specification`. Both Claude Code and Codex build on it.
### The shape
A skill is a directory containing `SKILL.md` (uppercase). The SKILL.md has
YAML frontmatter (`name`, `description`) and a Markdown body. Optional
sibling directories: `references/`, `scripts/`, `assets/`.
### Progressive disclosure (why skills scale)
The harness reads frontmatter cheaply at boot — names + descriptions of all
available skills. Body content loads only when a skill activates. This is
**ambient awareness** — you know all the skills exist; you only pay token
cost when you reach for one.
### Where skills come from in OpenRig
- **Per-agent loadout:** your AgentSpec's `profile.uses.skills: [...]`
determines what skills get projected into your runtime skill folder
(`.claude/skills/` or `.agents/skills/`) before your harness boots. This
is the **structural composition** layer.
- **Cross-pod sharing:** AgentSpecs can `imports: [shared]` to access a
shared skill pool. Built-in agents commonly do this.
- **Belt-and-suspenders:** the spec projects skill files; startup guidance
also tells you to load specific skills. Both paths matter — if the
projection silently fails, the guidance still tells you what to read.
### Where skills live (sources of truth)
| Home | Purpose |
|---|---|
| `<rig-cwd>/.claude/skills/`, `<rig-cwd>/.agents/skills/` | Where the harness actually loads from. Populated by `rig up`. |
| `~/.claude/skills/`, `~/.agents/skills/` | Your personal/global skills + the OpenRig bootstrap set (openrig-user, openrig-operator, openrig-architect, forming-an-openrig-mental-model, plus the rest of the openrig-core plugin skills). |
| openrig-core plugin skills | Product built-in skills that ship with OpenRig (installed via the openrig-core plugin). |
| `~/.openrig/skills/` | Runtime install home for OpenRig-shipped skills. |
The harness only sees the first two. Other locations are shipping and
source-of-truth — they reach the harness via projection or NPM install,
not directly.
---
## The product loop (your day-to-day)
```
rig up <rig-name> # boot or restore the topology
rig ps --nodes # see what's running
rig whoami --json # know who you are
rig send <session> "msg" # talk to a peer
rig capture <session> # see a peer's terminal
rig transcript <session> # read a peer's history
rig down <rigId> # snapshot and tear down
rig up <rig-name> # restore from snapshot
```
The first command in any new seat is `rig whoami --json`. It tells you your
rig, pod, member, peers, edges, and transcript path. **Treat it as ground
truth — your CLAUDE.md or AGENTS.md startup overlay can be wrong; whoami
is authoritative.**
---
## Cultural posture (how to behave)
OpenRig has a few load-bearing cultural principles. Internalize these:
- **Honesty over convenience.** If resume fails, say so loudly. Don't
silently launch fresh.
- **The agent is the power user.** The CLI is designed for a 10x staff
engineer at the terminal. You're that user.
- **CLI is context engineering.** Every error message and help text gives
you information to act on. Read errors carefully.
- **Convention over invention.** Follow docker/git/kubectl patterns. Agent
muscle memory is real.
- **Semi-deterministic is OK.** Core contracts are solid; edge cases are
agent-handled.
- **Pets, not cattle (today).** OpenRig is currently optimized for long-lived
agents that develop instincts over sessions. Cattle support is on the
roadmap.
---
## What you should do in your first 10 minutes
If you're booting into a new seat in an OpenRig rig:
1. **`rig whoami --json`** — recover identity. Know your rig, pod, member,
peers.
2. **Read your role guidance** — typically delivered via startup files.
`guidance/role.md` for your specific seat.
3. **Read the rig's `CULTURE.md`** if it has one — the team operating
manual.
4. **Check what skills you have** — list `.claude/skills/` or
`.agents/skills/` in your cwd. Each skill has a frontmatter description
that tells you when to reach for it.
5. **Check your peers** — `rig capture <peer-session>` to see what they're
doing.
6. **Check the transcripts** if you're returning to an in-flight workstream
— `rig transcript <session> --tail 100` for recent context.
7. **Ask `rig ask <rig> "<question>"`** if you need cross-cutting evidence
from the rig's transcripts and chat.
You're now oriented enough to start doing useful work.
---
## Going deeper (canonical references)
For real depth, these are the load-bearing canonical docs:
| Reference | What it covers |
|---|---|
| `openrig/docs/as-built/architecture.md` | Daemon architecture; system overview; package boundaries |
| `openrig/docs/as-built/cli-reference.md` | The full `rig` CLI surface with all subcommands and flags |
| `openrig/docs/reference/rig-spec.md` | The RigSpec YAML format — pods, members, edges, all fields |
| `openrig/docs/reference/agent-spec.md` | The AgentSpec YAML format — resources, profiles, imports |
| `openrig/docs/reference/agent-startup-guide.md` | The 7-layer startup layering model; delivery hints |
| `https://agentskills.io/specification` | The cross-runtime skill standard |
If your team maintains a host-level topology doc (rig classes, context
patterns, authoring SOPs), read its rig-authoring section before
touching YAML for high-stakes rigs.
---
## What this skill is NOT for
- **HA pair compaction recovery.** Forming an initial mental model is a
solo orientation task — different from the pair-of-seats coordination
pattern that preserves a shared mental model across compactions.
- **Operating a specific rig.** Specific rigs have their own DESIGN.md and
CULTURE.md. Read those.
- **Authoring a new rig.** Use the `openrig-architect` skill for that.
- **Day-to-day OpenRig operation.** Use `openrig-user` for that.
- **Administering an OpenRig install.** Use `openrig-operator`.
This skill exists to **form your initial mental model of OpenRig as a
system**. Once oriented, reach for the role-specific or task-specific skills
that fit your actual work.
---
## Common misorientations to avoid
| Misorientation | Reality |
|---|---|
| "OpenRig is a chat interface or assistant" | No. OpenRig is a control plane that *manages* your harness sessions. The chat happens inside the harness; OpenRig is around it. |
| "Pods are workflow groups" | No. Pods are **context domains** — agents that share working context. If two agents communicate every turn, they should be in one pod; if they communicate rarely, they shouldn't be. |
| "Edges represent reporting hierarchy" | No. Edges describe *coordination shape* — who delegates to whom, who observes whom. Avoid hierarchy interpretations; they distort behavior. |
| "I should manage Codex's compaction the way I manage Claude's" | No. Codex auto-compacts cleanly; Claude doesn't. Different runtimes, different lifecycles. |
| "MEMORY.md auto-loads, so I don't need to read it" | Maybe. Sometimes MEMORY.md auto-loads via system reminders; sometimes not. Don't assume. If your work touches the topics it covers, read it explicitly. |
| "Skills inherit from a parent or compose like classes" | No. Skills are flat artifacts; composition happens via AgentSpec `profile.uses.skills` (structural) or soft cross-references in skill bodies (advisory). Not via OO-style inheritance. |
| "Any folder I keep skill files in is the canonical runtime path" | No. The harness only loads from `<rig-cwd>/.claude/skills/`, `<rig-cwd>/.agents/skills/`, and the runtime-installed plugin skills. Other folders are authoring or source-of-truth. |
---
## Disaster-recovery test for this skill
If you read only this skill, can you:
1. State what kind of system OpenRig is, in one sentence?
2. Name the four layers and where you live?
3. Run `rig whoami --json` and interpret the output?
4. Find your role guidance and your peers?
5. Identify what kind of rig you're in (kernel / project / ephemeral / etc.)?
6. Know where to look for a skill body (which folder)?
7. Know what to read next for depth (the canonical references)?
If yes — you're oriented. If no — tell your peer or the human; missing
context is fixable, but only if surfaced.
@@ -1,361 +0,0 @@
---
name: openrig-architect
description: Use when designing multi-agent topologies that run ON OpenRig — authoring RigSpec and AgentSpec files for new rigs, creating agent startup content (guidance / skills / culture), or diagnosing why a launched rig's agents aren't behaving as intended. NOT for changing OpenRig itself (work in the openrig product repo); NOT for ordinary CLI operation of an existing rig (use openrig-user). Covers the full authoring lifecycle from user intent to validated, launchable rig.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/) for users authoring their own rigs.
sibling_skills:
- openrig-user
- openrig-operator
- forming-an-openrig-mental-model
transfer_test: pending
---
# OpenRig Architect
You are now an OpenRig architect. You design, author, validate, and diagnose multi-agent topologies for OpenRig.
Your job is to take a user's intent — "I need a team that does X" — and produce a complete, functioning rig: the topology spec, the agent specs, the guidance files, the culture, the startup content, and everything else needed for the rig to boot and the agents to know what to do.
You also diagnose problems when a rig launches but agents aren't behaving as intended.
## Before You Design: Required Reading
Load these before starting any design work. The quality of your output depends on the depth of knowledge you bring.
**Required (read all of these):**
1. **`openrig-user` skill** — full OpenRig CLI surface. You must know the operator primitives. If your runtime supports skills, load it by name. Otherwise, look for it at `~/.openrig/skills/openrig-user/SKILL.md` (the runtime install dogfood mirror — packaged skills land here) or inside the OpenRig installation under `packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md`. The `~/.openrig/reference/` directory holds reference docs (rig-spec.md, agent-spec.md, etc.), NOT skills.
2. **OpenRig reference docs** — these are installed at `~/.openrig/reference/` when the daemon starts. Read all of them:
- `~/.openrig/reference/rig-spec.md` — canonical RigSpec YAML reference. Every field, validation rule, default.
- `~/.openrig/reference/agent-spec.md` — canonical AgentSpec YAML reference. Same depth.
- `~/.openrig/reference/agent-startup-guide.md` — how to think about what goes into agent startup. Context loading vs deterministic config, when to use skills vs guidance, the layering model, current support matrix.
- `~/.openrig/reference/edge-types.md` — what edges do today vs what they're intended to do.
If `~/.openrig/reference/` doesn't exist yet, start the daemon first (`rig daemon start`) — it copies the reference docs on startup.
**Read as worked examples:**
3. **Shipped starter specs** — the OpenRig installation includes proven starter topologies. Find them by running `rig specs ls`. Read the ones that are relevant to your design task, especially:
- `implementation-pair` — the smallest effective development unit (2 agents)
- `secrets-manager` — a managed-app rig with services integration and a specialist agent
**Read if present on this host:**
4. **Host-level doctrine file** (if your team maintains one — e.g., a HOST-TOPOLOGY doc that defines canonical rig classes, context-sharing patterns, and authoring SOPs for high-stakes rigs). If present, it supersedes the baseline process below for complex or high-stakes rigs (≥4 members, HA, managed-app, or shared/copied). Solo operators and small/focused rigs use the baseline below directly.
5. **Agent-facing software design principles** if your rig ships a new CLI, service, or managed app that agents will operate. Treat the operating surface as a context-engineering problem: every error message and help text gives the agent information to act on.
**Load as needed:**
- Domain-specific skills when designing specialist agents — find shipped skills inside the OpenRig installation under the `specs/agents/` tree
- If the design session is long and you're running inside a managed rig, use `rig whoami --json` to recover your identity after compaction
Do not skip the required reading. A rig architect who doesn't know the spec format will produce specs that don't validate. An architect who doesn't know the startup layering model will produce agents that boot without knowing their role. An architect who doesn't check for host-level doctrine will reinvent conventions the host has already established.
## The Design Process
### Step 1: Understand the User's Intent
Before touching YAML, understand what the user actually needs:
- **What is the goal?** Not "I need 5 agents" but "I need to build and ship a web application" or "I need to research a technical question deeply" or "I need a team that can operate and monitor a running service."
- **What are the workflows?** How does work flow from intent to completion? Who does what? Where are the handoffs?
- **What is the project?** What codebase, what tech stack, what domain? This shapes agent specialization and startup content.
- **What runtimes are available?** Does the user have Claude Code? Codex? Both? Runtime availability constrains topology design.
- **How autonomous should it be?** Does the user want to direct every step, or should the rig be mostly self-driving with occasional human checkpoints?
Ask clarifying questions if the intent is ambiguous. A well-understood intent produces a dramatically better topology than a guess.
### Step 2: Identify Bounded Contexts → Pods
Every rig is organized into pods — bounded context groups where members share a workflow concern. The question is: what are the natural groupings?
**Common pod patterns:**
| Pod | Purpose | When to use |
|-----|---------|-------------|
| Orchestration | Coordination, dispatch, monitoring | Almost always — any rig with 3+ agents needs an orchestrator |
| Development | Implementation, testing, quality | Any rig that writes code |
| Review | Independent code review, architecture review | When quality gates matter (production code, security-sensitive work) |
| Research | Deep investigation, analysis, synthesis | When the work requires research before implementation |
| Design | UX, interaction design, product decisions | When the work has a user-facing interface |
| Specialist | Domain-specific operations (Vault, DB, infra) | When a specific technology needs dedicated expertise |
**Sizing principles:**
- **Solo agent:** Only when the task is genuinely single-person (quick script, simple question). No rig needed.
- **Pair (2 agents):** The minimum effective unit for quality work. One does, one verifies. The `implementation-pair` pattern.
- **Small team (3-5 agents):** Orchestrator + one or two working pods. Good starting point for focused projects.
- **Full team (6-10 agents):** Multiple bounded contexts with orchestration, development, review, and potentially research or design.
- **Large team (10-40+ agents):** Complex projects with many concerns. Include pods for development, review, research, documentation, release management, strategy, and any other bounded context the project needs.
**Important:** Agents do NOT all need to be busy at the same time. A rig is a network, not an assembly line. Some pods will be highly active (dev, review) while others are available on-demand (research, documentation, release management). An idle agent has near-zero cost but is immediately available when any other agent in the rig needs it — for quick questions, lookups, delegation, or specialized work. Design for availability, not constant utilization.
**Start small to increase the likelihood of success,** not because large rigs are wasteful. A 3-agent rig that boots and works correctly validates your spec authoring before you scale to 20 agents. Once the core topology works, expand with additional pods as needed.
### Step 3: Design Agent Roles → Members
Each pod member needs a clear role. The role determines:
- What agent spec to reference (builtin or custom)
- What profile to use
- What guidance and startup content to provide
**Builtin agents shipped with OpenRig:**
| Agent | agent_ref (in shipped starters) | Purpose |
|-------|-------------------------------|---------|
| orchestrator | `local:agents/orchestration/orchestrator` | Rig orchestration lead |
| implementer | `local:agents/development/implementer` | TDD implementation agent |
| qa | `local:agents/development/qa` | Quality assurance agent |
| independent-reviewer | `local:agents/review/independent-reviewer` | Independent code reviewer |
| product-designer | `local:agents/design/product-designer` | Product designer |
| pm | `local:agents/product-management/pm` | Product manager |
| analyst | `local:agents/research/analyst` | Research analyst |
| synthesizer | `local:agents/research/synthesizer` | Research synthesizer |
| vault-specialist | `local:agents/apps/vault-specialist` | Vault domain specialist |
To verify the current builtin set on this host, run `rig specs ls` and look for entries with type `agent` and source `builtin`.
**Path resolution:** The `local:` prefix means relative to the rig spec file's directory. In shipped starters, these paths resolve against the builtin specs directory inside the OpenRig installation. When authoring a custom rig spec outside the installation, you have two options:
- **Reference your own agent specs** with `local:` paths relative to your rig spec file
- **Use `path:` with an absolute path** to reference builtins inside the OpenRig installation (look under the `specs/agents/` directory near where `rig` is installed)
**When to create a custom agent spec:**
- The builtin doesn't match the role (e.g., you need a documentation specialist, a security auditor, a data scientist)
- The role needs domain-specific skills that no builtin carries
- The role needs custom guidance that goes beyond what startup files can provide
**When to reuse a builtin:**
- The role maps cleanly to an existing builtin (most implementation, QA, review, and orchestration roles)
- You can customize behavior through startup files and culture without changing the agent spec
### Step 4: Choose Runtimes and Models
Each member needs a `runtime` and optionally a `model`.
**Runtime selection:**
- `claude-code` — Claude Code. Best for: complex reasoning, architecture, code review, orchestration. Supports `/loop` for recurring tasks, rich hooks system, MCP servers.
- `codex` — Codex. Best for: parallel work, implementation, testing. Different approval model. Less reliable for recurring tasks.
- `terminal` — Infrastructure nodes. Servers, log tails, build watchers. Not an agent — a process.
**Runtime diversity is valuable.** Using both Claude Code and Codex in the same rig gives you different reasoning perspectives. The `product-team` starter uses Claude Code for the lead/impl/design/r1 roles and Codex for peer/qa/r2 roles. This is deliberate — model diversity catches different classes of issues.
**Model selection** is optional. The runtime's default model is usually fine. Override only when you have a specific reason (e.g., a complex architecture agent might benefit from a specific model).
### Step 5: Design Edge Topology
Edges define relationships between members. See `~/.openrig/reference/edge-types.md` for the full reference.
**Practical rules:**
- Every working pod should have at least one `delegates_to` edge from the orchestrator
- Review pods should have `can_observe` edges to the pods they review
- Within a pod, the primary workflow direction should be expressed as `delegates_to` (e.g., impl → qa)
- `delegates_to` and `spawned_by` affect launch order. Use them for dependency chains.
- `can_observe`, `collaborates_with`, `escalates_to` are informational — they help agents understand the topology but don't constrain launch.
**Start simple.** You can always add edges later. A rig with only `delegates_to` edges from the orchestrator to working pods is perfectly functional.
### Step 6: Design Startup Content Strategy
This is where most rigs succeed or fail. The topology is mechanical; the startup content is what makes agents actually useful. See `~/.openrig/reference/agent-startup-guide.md` for the full guide.
**Minimum for every rig:**
1. Each agent has a `guidance/role.md` — who they are, what they do
2. The rig has a `CULTURE.md` — how the team works together
3. Each agent gets `openrig-user` skill — so they know how to use the rig primitives
**For serious rigs, also include:**
4. `startup/context.md` per agent — boot-time grounding (project info, environment details)
5. Pod SOP skills — how each pod operates (implementation-pair SOP, review-pair SOP, etc.)
6. Project-specific documentation in rig-level startup files
**The key principle:** An agent that boots without knowing its role, its team's culture, and its project context will produce generic, unhelpful work. The startup content IS the product value. Invest in it.
### Step 7: Services Integration (If Needed)
If the rig needs managed software (databases, API servers, etc.), add a `services` block. See `~/.openrig/reference/rig-spec.md` for the full services reference.
**When to add services:**
- The agents operate ON software (not just write code)
- The project needs a local dev environment (Postgres, Redis, etc.)
- You're building a managed-app rig (software + specialist agent)
**Services boot before agents.** If health checks fail, no agents start. This is the hard gate — the environment must be healthy before agents can work.
## Authoring: The File Creation Workflow
### Directory Layout
```
my-rig/
rig.yaml # The RigSpec — required
culture/
CULTURE.md # Rig-wide culture — strongly recommended
agents/
my-custom-agent/
agent.yaml # AgentSpec — if custom agent needed
guidance/
role.md # Role guidance
startup/
context.md # Boot-time context
skills/
my-skill/
SKILL.md # Custom skill if needed
docker-compose.yaml # Only if services block is used
```
For rigs that reuse builtin agents, the agents directory is often unnecessary — the rig spec references the builtins directly.
### Workflow
1. **Write the rig spec** (`rig.yaml`) — define pods, members, edges, optionally services
2. **Write or reference agent specs** — builtins for standard roles, custom for specialized roles
3. **Write CULTURE.md** — the team operating manual
4. **Write role guidance** for each custom agent — who they are, what they do
5. **Write startup context** for agents that need environment grounding
6. **Validate:** `rig spec validate rig.yaml` and `rig agent validate agents/*/agent.yaml`
7. **Confirm the runtime cwd** — do not assume agents should work from the directory where the rig spec is stored. The spec root controls file resolution; the runtime cwd controls trust, project guidance, permissions, and repo context.
8. **Launch:** `rig up rig.yaml --cwd /path/to/project`
9. **Verify:** `rig ps --nodes` — all agents ready? Check `rig capture` on each agent.
### Validation Is Non-Negotiable
Always validate before launching:
```bash
rig spec validate rig.yaml
rig agent validate agents/my-agent/agent.yaml
```
If validation fails, fix the errors. Do not try to launch an invalid spec — it will fail with a less helpful error.
## Diagnosis: When Things Go Wrong
### Agent doesn't know its role
**Symptom:** Agent produces generic output, doesn't follow team conventions.
**Root cause:** Missing or insufficient `guidance/role.md`.
**Fix:** Write a clear role guidance file. Include responsibilities, working rhythm, and principles. Reference it in both `resources.guidance` and `startup.files`.
### Agent can't coordinate with peers
**Symptom:** Agent tries raw tmux commands instead of `rig send`, doesn't know peer session names.
**Root cause:** Agent didn't receive `openrig-user` skill or `openrig-start` overlay.
**Fix:** Ensure the agent's profile `uses.skills` includes `openrig-user`. Verify via `rig ps --nodes` that the agent shows expected startup status; check installed skills via direct startup/capture/transcript evidence or the UI node detail (the `rig ps --nodes` projection does not expose installed-resource counts).
### Agent hits approval prompts on rig commands
**Symptom:** Agent stalls on `rig whoami`, `rig send`, etc.
**Root cause:** Claude Code permissions not configured for rig commands.
**Fix:** Describe the required permissions in startup context. The agent should configure `~/.claude/settings.json` with allowlisted rig commands. See `~/.openrig/reference/agent-startup-guide.md` for the current support matrix.
### Agents idle — topology doesn't engage the team
**Symptom:** Orchestrator works with one or two agents, others sit idle.
**Root cause:** Missing `CULTURE.md` or pod SOP content that describes how the full team coordinates.
**Fix:** Write a culture file that explicitly describes the coordination protocol. Include delegation patterns, review gates, and when each pod should be engaged.
### Services don't boot
**Symptom:** `rig up` fails before agents launch with a service health error.
**Root cause:** Docker Compose issue, health check failure, or port conflict.
**Fix:** Check `docker compose up` manually with the compose file. Verify health check URLs are correct. Check for port conflicts.
### Agent boots from the wrong project context
**Symptom:** Agent misses expected guidance, trust settings, permissions, or repo context even though the spec validates.
**Root cause:** The rig spec directory was treated as the agent's runtime cwd by assumption.
**Fix:** Confirm the intended cwd before launch. The spec can live in a rig/spec shelf while the agent works from the project or hub directory that carries the relevant `AGENTS.md`, `CLAUDE.md`, trust, and permissions. Use member `cwd` or `rig up --cwd` deliberately.
### Startup content not delivered
**Symptom:** Agent is missing expected guidance/skills.
**Root cause:** File paths in the spec don't resolve, or `delivery_hint` is wrong.
**Fix:** Verify file paths resolve relative to their owning artifact — AgentSpec resource paths are relative to the agent spec directory; RigSpec startup, culture, compose, cwd, and `local:` agent-ref paths are relative to the rig root. Check `delivery_hint` — use `guidance_merge` for pre-boot content, `send_text` for post-boot instructions.
### Agent startup delivered but agent doesn't use skills
**Symptom:** Skills are projected but agent doesn't invoke them.
**Root cause:** Agent wasn't told to load them.
**Fix:** In the startup context or role guidance, explicitly tell the agent which skills to load. The belt-and-suspenders pattern: project the skills via the spec AND tell the agent to read them in the guidance.
## Pattern Catalog
### The Implementation Pair
**2 agents, 1 pod.** The smallest effective development unit. One implements (TDD), one does QA. The implementer proposes, QA approves or rejects, then the implementer commits.
```yaml
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: "local:agents/development/implementer"
runtime: claude-code
profile: default
cwd: "."
- id: qa
agent_ref: "local:agents/development/qa"
runtime: codex
profile: default
cwd: "."
edges:
- kind: delegates_to
from: impl
to: qa
```
**Use when:** Focused feature work, bug fixes, small-to-medium implementation tasks.
### The Orchestrated Team
**5-7 agents, 3 pods.** Orchestration + development + review. The orchestrator dispatches work, the dev pair implements, the review pair validates independently.
**Use when:** Production-quality work that needs coordination and independent review.
### The Research Team
**3 agents, 2 pods.** Orchestrator + research pair (analyst + synthesizer). The analyst investigates deeply, the synthesizer consolidates findings.
**Use when:** Technical research, competitive analysis, architecture exploration.
### The Managed App
**1+ agents, 1 pod, services block.** Software infrastructure (Docker Compose) plus a specialist agent who knows how to operate it.
```yaml
services:
kind: compose
compose_file: docker-compose.yaml
wait_for:
- url: http://127.0.0.1:8200/v1/sys/health
pods:
- id: vault
label: Vault
members:
- id: specialist
agent_ref: "local:agents/apps/vault-specialist"
runtime: claude-code
profile: default
cwd: "."
edges: []
```
**Use when:** The work involves operating software, not just writing code.
### The Full Product Team
**7 agents, 3 pods.** The kitchen-sink topology: orchestration pair, development pod (impl + qa + design), review pair. See the `product-team` starter spec for the complete worked example.
**Use when:** Full product development with design, implementation, QA, and independent review. Requires strong culture and SOP content to keep all agents engaged.
## Final Notes
**Start simple, add complexity when needed.** A working implementation pair is better than a broken full team. Launch with the minimum viable topology, verify it works, then expand.
**Culture is not optional for team rigs.** Any rig with 3+ agents needs a CULTURE.md. Without it, agents will default to generic behavior and the topology will underperform.
**Validate early and often.** Run `rig spec validate` after every change. Run `rig agent validate` after every agent spec edit. Fix errors immediately — don't accumulate them.
**The startup content IS the product.** The YAML topology is scaffolding. What makes a rig actually useful is the guidance, culture, skills, and startup context that agents receive. Invest your authoring time there.
@@ -1,222 +0,0 @@
---
name: openrig-operator
description: |
Use when debugging host-side OpenRig runtime issues: daemon reachability, Codex permission or writable-root failures, command approval friction, rate limits/account switches, helper cleanup, or topology health confusion. NOT for ordinary CLI operation (use openrig-user) or for changing OpenRig itself (work in the openrig product repo).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/).
Companion to openrig-user (daily CLI surface) — this skill is host-side troubleshooting.
transfer_test: pending
sibling_skills:
- openrig-user
- openrig-architect
- forming-an-openrig-mental-model
---
# OpenRig Operator
## Overview
This skill covers host/runtime/operator triage around OpenRig itself.
Use it when the problem may be the daemon, the shell/runtime surface, or stale helper processes rather than the product workflow you are trying to run.
> **CANONICAL SURFACE NOTE (2026-05-11)** — for durable queue routing while doing
> operator triage, use `rig queue` (daemon-backed SQLite). `rigx queue` is
> recovery-only fallback; qitems written there are invisible to daemon-backed
> reads and should not be used for new substantive work.
## When to Use
Use this skill when you see:
- `rig whoami --json` returning partial identity
- `rig ps --nodes --json` failing while some other `rig` commands still work
- `Sent to ...` plus `Verified: no`
- repeated unified-exec-process warnings
- suspicion that stale helper processes are accumulating
- Codex seats hit `Operation not permitted`, command approval friction, or stale writable roots
- Codex seats report usage-limit/rate-limit or need a ChatGPT account switch
Do not use this skill for normal product workflow routing, queue handling, or ordinary peer communication. Use `openrig-user` for that.
Do not use this skill to decide how to change OpenRig behavior. Changes
to the OpenRig product (CLI, daemon, UI, specs) happen in the openrig
product repo — operate that as a normal software project, not via
this troubleshooting skill.
## First Checks
Start with the minimum truthful operator read:
```bash
rig whoami --json
rig daemon status
rig ps --nodes --json
```
Interpret them together, not in isolation:
- partial `whoami` can mean identity is inferable while daemon-backed surfaces are degraded
- `daemon status` tells you whether the host daemon is up, not whether every seat can reach it cleanly
- `ps --nodes --json` is the best machine-readable topology check when it works
## Verification Drift Vs Send Failure
For `rig send`:
- `Sent to ...` + `Verified: yes` = strong positive delivery evidence
- `Sent to ...` + `Verified: no` = ambiguous delivery, not automatic failure
- no `Sent to ...` line or a hard error = send failure
When verification is ambiguous, check:
- direct reply
- `rig capture <session>`
- transcript evidence
- queue/outbox state if the message asked for a durable handoff
Do not blindly retry until you have checked one of those.
## Unified Exec Warning
If you see:
- `Warning: The maximum number of unified exec processes you can keep open is 60 ...`
treat it first as a host/tooling-layer warning, not as automatic proof that the OpenRig topology is unhealthy.
This warning can coexist with a healthy live topology.
## Safe Process Triage
Inspect the process surface first:
```bash
ps -axo pid,ppid,command | rg 'tmux send-keys|rig queue create|tmux attach|codex|claude'
```
Think in layers:
- host/tooling layer: stale one-shot wrappers, session bookkeeping, helper shells
- topology layer: live `tmux attach` seats, live `codex` / `claude` runtimes, daemon health
Do not diagnose topology failure from tooling-layer warnings alone.
## Safe Cleanup Boundary
Usually safe to reap when clearly orphaned / one-shot:
- `tmux send-keys ...`
- short-lived shell wrappers created only to enqueue or send one message
Do not mass-kill:
- `tmux attach ...`
- `codex ...`
- `claude ...`
- other long-lived daemon/runtime processes
The point is to remove garbage, not workers.
## Terminal Node Escalation
Use a terminal node when evidence shows a seat-level sandbox/profile cannot perform required host work, but another approved operator surface can. This is an explicit operator lane, not a silent permission bypass.
Good fits:
- Codex/Claude seat cannot access Tart, SSH, tmux, queue directories, or host files needed for a proof
- a VM/test-proof or current-host-operator task needs real host capability
- another seat or terminal/sysadmin node has approved access and can return command evidence
Do not use this lane to bypass repo safety, dirty-worktree boundaries, review gates, or destructive-action approval. If the command would stop live rigs, delete data, reset git state, or mutate VM state beyond the accepted plan, require explicit authorization and record it.
Protocol:
1. Classify the original failure as seat/profile-specific if possible, not product failure.
2. Write or cite a packet with objective, lane, exact commands, cwd, expected outputs, stop conditions, and artifact path.
3. Route to an existing sysadmin/infrastructure/terminal node when available; otherwise provision a named terminal node through the topology/config layer instead of using a hidden ad hoc shell.
4. Terminal node runs only the packeted commands and returns command log, exit codes, cwd/env notes, and artifacts.
5. Original agent keeps task ownership and proof classification; terminal node supplies host capability.
Field lesson: if tester sees `Operation not permitted` for Tart/SSH while driver or another approved seat can reach the VM, treat it as permission/profile variance and route a terminal-node/operator remediation before changing product code.
## Codex Permission Policy
Use the `security-and-consequence-boundary-policy` skill for the security model.
OpenRig gates consequence boundaries, not ordinary work inside the intended boundary.
Treat Codex permissions as two independent layers:
- command approval rules decide which shell commands can run outside the sandbox
- filesystem writable roots decide which paths a `workspace-write` seat can mutate
Codex `auto_review`, `--full-auto`, and approval-bypass modes are not normal fleet defaults. They
can burn quota catastrophically and do not widen filesystem roots for an already-running seat. Full
access scope with `approvals_reviewer = "user"` is the intended Codex default here.
Claude Code auto permissions are different and are the preferred Claude default on this host when
the consequence-boundary rules still apply.
Current host policy:
- default profile is `fleet`
- `fleet` uses `sandbox_mode = "danger-full-access"`, `approval_policy = "on-request"`, `approvals_reviewer = "user"`
- top-level `[sandbox_workspace_write].writable_roots` intentionally includes `~/code` and tool dotdirs: `.codex`, `.claude`, `.openrig`, `.agents`, `.config`, `.cache`, `.local`, `.docker`, `.npm`, `.nvm`, `.pnpm-store`
- `permissions.fleet.filesystem` mirrors those writes and denies `.ssh`, `.gnupg`, and project env files
- `default.rules` broadly allows `rig` and `rigx`; `rigx` is fully permitted for now because it is the fast-moving config-layer overlay
- `default.rules` still prompts for destructive/publishing surfaces such as `rm`, `mv`, `chmod`, `git push`, PR mutation, `sudo`, process kills, daemon lifecycle, and destructive Docker/Brew/rig commands
## Codex Account / Usage-Limit Refresh
When Codex seats hit usage limits or need a ChatGPT/OpenAI account rotation, use the focused
`codex-seat-auth-refresh` skill instead of improvising.
Key reminder: host auth can switch while already-running Codex TUIs keep the old account. Refresh
only the scoped seats, preserve stable seat names, and record auth-seat registry rows sequentially.
Known failure modes this policy prevents:
- Codex seats assuming a task is impossible when the real issue is stale launch roots
- agents creating workaround slices/features for what is actually a sandbox/config problem
- writing load-bearing canon into `state/`, `/tmp`, or a nearby writable folder because the intended durable path was blocked
- trusting labels like `full access`; verify actual roots and command policy instead
- changing `config.toml` and expecting already-running seats to pick it up without restart
When `Operation not permitted` appears:
1. Identify whether it is command approval, filesystem root, macOS privacy, or stale session.
2. Check effective roots with `codex -p fleet debug prompt-input <probe-name>`.
3. Verify with a tiny direct write probe in an allowed target and a negative probe in a protected target such as `.ssh`.
4. If `config.toml` changed, restart one seat and re-run the probes before fleet rollout.
5. If the target should be durable and is not writable, stop and escalate; do not invent fallback storage.
Field note: as of Codex CLI `0.125.0`, top-level `[sandbox_workspace_write]` was the shape reflected by `debug prompt-input`; profile-scoped writable roots did not show in the effective prompt. Re-test this if Codex changes.
## Durable Write Escalation
If the task requires writing load-bearing knowledge or behavior and the intended target is not writable, stop and escalate. Do not silently write to `state/`, `/tmp`, or a nearby writable folder.
Use the intended durable home for the kind of content:
- `skills/` (e.g. `~/.claude/skills/`, `~/.agents/skills/`, or your team's
shared skill folder) for operating rules and refocus behavior
- Your team's workstream notebook (field notes, lab experiments, mission
packets, etc.) for durable observations and PM canon
- The product repo for shipped OpenRig daemon/CLI/config/spec/test behavior
A runtime mirror under a rig `state/` path may be used only as a temporary live patch, must be labeled as non-canonical, and must have a canonical sync follow-up.
## Common Mistakes
- treating `Verified: no` as if it proves the message did not land
- treating the unified-exec warning as if it proves the rig is overloaded
- killing live seats when only stale helper wrappers needed cleanup
- concluding "daemon down" from one seat's failure without checking host-level daemon status
- assuming Codex config changes apply to already-running seats without a restart/probe
- confusing command approval with filesystem write permission
- calling a VM or product path broken before comparing another approved seat or terminal-node probe
- assuming a label like `full access` proves effective write/network capability; verify command approval and filesystem writable-root coverage separately
## Practical Rule
Clean the smallest safe surface that matches the evidence.
If the warning or failure remains after stale-wrapper cleanup, re-check:
```bash
rig daemon status
rig ps --nodes --json
```
If those remain healthy, the residual issue may still be in the host/tool/session layer rather than in OpenRig topology state.
@@ -1,597 +0,0 @@
---
name: openrig-user
description: Use when operating OpenRig with the `rig` CLI and you need the shipped command surface for identity, inventory, communication, lifecycle, specs, recovery, or agent-facing JSON output. NOT for debugging host-side runtime issues (use openrig-operator) or for changing OpenRig itself (work in the openrig product repo).
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/) so agents have it at every boot.
sibling_skills:
- openrig-operator
- openrig-architect
- forming-an-openrig-mental-model
transfer_test: pending
notes: |
Description was already correct (starts with "Use when..."; lists triggering domains without summarizing workflow). No frontmatter rewrite needed.
2026-05-04 sync: body content updated for OpenRig v0.2.0 release — adds Runtime-Gated Coordination Primitives section (PL-004 Phase A-D commands: rig stream / rig queue / rig project / rig view / rig watchdog / rig workflow), default posture for daemon vs substrate coordination commands, capture discipline section. Frontmatter metadata.openrig.* preserved across the sync.
---
# OpenRig User
This is an as-built guide to the shipped `rig` CLI.
Use current code and `rig ... --help` as ground truth if anything here ever conflicts with older planning docs.
This is the daily-driving CLI guide for an OpenRig operator. It is not
a guide to OpenRig internals or to changing OpenRig behavior — for that,
work in the openrig product repo.
## Runtime-Gated Coordination Primitives
OpenRig v0.2.0 is published publicly as `@openrig/[email protected]` and GitHub Release
`v0.2.0`. It includes the bundled PL-004 Coordination Primitive System: Phase A
`rig stream` / `rig queue`, Phase B `rig project` / `rig view`, Phase C
`rig watchdog`, and Phase D `rig workflow` / `workflow-keepalive`.
Do not confuse public package truth with this host's active daemon truth. As of 2026-05-04, the
human reports the production daemon has been upgraded to v0.2.0; a local check showed `rig
--version` and the product package version at `0.2.0`, with the daemon running on port 7433.
Default posture on this host now:
- Treat daemon `rig queue`, `rig stream`, `rig project`, `rig view`, `rig watchdog`, and
`rig workflow` as active-host coordination surfaces for PL-004-backed work.
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) is the
canonical surface for queue routing since the 2026-05-11 host-CLI fix landed. Use
`rig queue create / handoff / update / show / list` for all substantive work.
- Use substrate `rigx queue`, `rigx stream`, `rigx project`, and `rigx view-proto` only where the
current workflow is still explicitly operating on the temporary substrate coordination layer or
a legacy artifact has not migrated. For queue specifically, `rigx queue` is recovery-only
fallback; qitems written via `rigx queue` are invisible to daemon-backed reads and break
fleet-wide routing discipline.
- If a daemon-backed coordination command fails, debug the command/runtime/schema edge directly;
do not fall back to stale pre-upgrade assumptions about this host.
- Do not run daemon stop/start, wrapper mutation, production DB copy/mutation, git push, tags, npm
publish, or GitHub release unless a human grants the specific bounded gate. The public release
already exists; further release operations need explicit scope.
## Core Loop
Most work in OpenRig reduces to this loop:
- recover identity: `rig whoami --json`
- inspect inventory: `rig ps --nodes --json`
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
## Identity and Recovery
Start here after launch, compaction, or confusion:
```bash
rig whoami --json
```
What it gives you today:
- identity: rig, logical ID, pod/member, session name, runtime
- peers and directional edges
- transcript info
- `contextUsage` when available
Flags:
```bash
rig whoami --session <name>
rig whoami --node-id <id>
```
If the daemon is unreachable but identity can still be inferred, `--json` may return a partial result instead of crashing.
## Inventory and Monitoring
```bash
rig ps
rig ps --json
rig ps --nodes
rig ps --nodes --json
```
Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for:
- session name
- runtime
- session/startup status
- restore outcome
- attach/resume commands
- latest error
Other health surfaces:
```bash
rig status
rig daemon status
rig config
rig preflight
rig doctor
```
## Transcript and Communication
### Transcript access
```bash
rig transcript <session> --tail 100
rig transcript <session> --grep "pattern"
rig transcript <session> --json
```
### Send to one session
```bash
rig send <session> "message"
rig send <session> "message" --verify
rig send <session> "message" --force
rig send <session> "message" --json
```
Use `--verify` when you want delivery evidence. Use `--force` only when you intentionally want to bypass activity-risk checks.
Observed operator nuance for `--verify`:
- `Sent to ...` + `Verified: yes` = strong positive delivery evidence.
- `Sent to ...` + `Verified: no` = ambiguous delivery, not automatic failure. We have repeatedly seen the message still land; treat this as verification drift until disproven.
- no `Sent to ...` line or a hard error = send failure.
When you get `Verified: no`, do not immediately retry blindly. First check one of:
- a direct reply from the target
- `rig capture <session>`
- transcript evidence
- queue/outbox state if the message asked for a durable handoff
### Capture terminal output
```bash
rig capture <session>
rig capture <session> --lines 50
rig capture --rig <name>
rig capture --pod <name> --rig <name>
rig capture --rig <name> --json
```
Capture discipline:
- Default to bounded captures: `rig capture <session> --lines 80`.
- Use `--lines 120` when the latest activity is ambiguous.
- Use `--lines 200` or more only for explicit recovery/debug reconstruction, such as post-compaction restore, context-wall diagnosis, or a failed handoff.
- Prefer targeted `rig transcript <session> --tail ...`, `rig transcript <session> --grep ...`, queue files, or status commands over large pane captures.
- Do not use large captures as routine monitoring; they waste operator context and can hide the actual latest state in old scrollback.
### Broadcast
```bash
rig broadcast --rig <name> "message"
rig broadcast --pod <name> "message"
rig broadcast "message"
rig broadcast --rig <name> "message" --json
```
Without `--rig` or `--pod`, broadcast targets all running sessions.
### Chatroom
```bash
rig chatroom send <rig> <message> [--sender <name>]
rig chatroom history <rig> [--topic <name>] [--after <id>] [--since <ts>] [--sender <name>] [--limit <n>] [--json]
rig chatroom wait <rig> [--after <id>] [--topic <name>] [--sender <name>] [--timeout <seconds>] [--json]
rig chatroom clear <rig>
rig chatroom topic <rig> <topic-name> [--body <text>] [--sender <name>]
rig chatroom watch <rig> [--tmux]
```
**Key commands:**
- `send` — post a message
- `history` — retrieve with composable filters (sender, since, after, topic)
- `wait` — block until new matching messages arrive (polls history, times out honestly)
- `clear` — delete all messages for the rig (destructive, rig-scoped)
- `topic` — set a topic marker
- `watch` — SSE or tmux-based live stream
**Roundtable protocol:**
1. Inspect old room: `rig chatroom history my-rig --limit 5`
2. Save if needed: `rig chatroom history my-rig --json > /tmp/old-room.json`
3. Clear if needed: `rig chatroom clear my-rig`
4. Set topic: `rig chatroom topic my-rig "ROUND START"`
5. Post: `rig chatroom send my-rig "position..." --sender <session>`
6. Monitor: `rig chatroom wait my-rig --timeout 120`
7. Close: `rig chatroom topic my-rig "ROUND CLOSED"`
See `docs/planning/roadmaps/chatroom-roundtable-protocol.md` for the full protocol.
### `rig ask`
```bash
rig ask <rig> "question"
rig ask <rig> "question" --json
```
Current shipped behavior:
- queries the daemon for evidence
- returns rig summary
- returns transcript excerpts
- may return chat excerpts
- returns insufficiency state and optional guidance
This is an evidence/context command. It is not a hidden second-LLM call.
## Lifecycle
### Bring a rig up
```bash
rig up <source>
rig up <source> --plan
rig up <source> --yes
rig up <source> --json
```
`<source>` can be:
- a rig spec path
- a `.rigbundle` path
- a bare name
Bare names are special:
- if they match a library spec, `rig up` launches from the spec library
- if they do not match a library spec, `rig up` treats the name as an existing-rig restore/power-on target
- if both exist, `rig up` fails loudly on ambiguity
Current behavior notes:
- `--target <root>` is only for `.rigbundle` / package installation. It does not change agent cwd.
- `local:` `agent_ref` values resolve relative to the rig spec directory, not your shell cwd.
- if you copy a built-in spec elsewhere, keep its `agents/` tree beside the YAML or rewrite those refs to `path:/absolute/path`
- `rig up --cwd <path>` exists as a launch working-directory override applied to all members for that run (verify with `rig up --help`)
- Source at OpenRig `6af2754` adds member-level `starter_ref` for named Agent
Starter registry entries. Active host availability still depends on the
running daemon; `rig-real-17812d5` does not resolve `starter_ref`.
Legacy/spec-specific surfaces still ship too:
```bash
rig bootstrap <spec> [--plan] [--yes] [--json]
rig requirements <spec> [--json]
```
### Agent Starter `starter_ref`
Source `>= 6af2754` lets a RigSpec member set:
```yaml
starter_ref:
name: my-team-starter--claude-code
```
The daemon resolves the named entry from the Agent Starter registry, applies the
credential scan, and prepends the starter content as `guidance_merge` on fresh
launch. It supports Claude and Codex members, rejects terminal members, rejects
`starter_ref + session_source.mode: fork`, and allows
`starter_ref + session_source.mode: rebuild`.
Runtime-truth rule: verify active daemon/runtime provenance before depending on
this. If the live daemon is still `rig-real-17812d5`, `starter_ref` is source
truth only and should not be used against the active control plane. After a
runtime at or after `6af2754` is active, `rig up --plan --json` should expose a
`resolve_starter` stage with `detail.starterContent`.
### Tear a rig down
```bash
rig down <rigId>
rig down <rigId> --snapshot
rig down <rigId> --delete
rig down <rigId> --force
rig down <rigId> --json
```
Known v0.2.0 public-release caveat: `rig down <name> --delete` can return HTTP 404 even when
`rig ps --json` exposes the rig name. Use `rig ps` to find the rig ID, then run
`rig down <rigId> --delete` until `rig-down-name-lookup-404` is fixed.
If `--snapshot` succeeds, human output includes the restore hint.
### Release management without killing live claimed sessions
```bash
rig release <rigId>
rig release <rigId> --delete
rig release <rigId> --json
```
Use `rig release` for adopted/claimed-session rigs when you want OpenRig to stop managing the rig but leave the tmux sessions alive.
This is the safe recovery/reset surface for the "sessions still exist, management is broken or stale" case.
If the rig contains OpenRig-launched nodes, `rig release` refuses loudly instead of pretending the mixed rig is safe to detach.
### Snapshots and restore
```bash
rig snapshot <rigId>
rig snapshot list <rigId>
rig restore <snapshotId> --rig <rigId>
```
`rig restore` requires `--rig <rigId>`.
Claude Code autonomy note:
- unattended `rig whoami` on boot may require the local permission allow list to include `Bash(rig:*)`
### Import/export and bundles
```bash
rig export <rigId> -o rig.yaml
rig import <path> [--instantiate] [--materialize-only] [--preflight] [--target-rig <rigId>] [--rig-root <root>]
rig bundle create <spec> -o out.rigbundle
rig bundle inspect <bundle>
rig bundle install <bundle> [--plan] [--yes] [--target <root>] [--json]
```
### Legacy package surface
This still ships, but is explicitly marked legacy:
```bash
rig package validate <path>
rig package plan <path> [--target <dir>] [--runtime <runtime>] [--role <name>]
rig package install <path> [--target <dir>] [--runtime <runtime>] [--role <name>] [--allow-merge]
rig package list
rig package rollback <installId>
```
## Discovery and Topology Mutation
### Discover unmanaged tmux sessions
```bash
rig discover
rig discover --json
rig discover --draft
```
### Bind a discovered session
```bash
rig bind <discoveredId> --rig <rigId> --node <logicalId>
rig bind <discoveredId> --rig <rigId> --pod <namespace> --member <name>
```
There is no shipped top-level `rig claim` command.
The current adoption surface is `discover`, `bind`, `adopt`, and `unclaim`.
### Self-attach the current shell or agent
```bash
rig attach --self --rig <rigId> --node <logicalId>
rig attach --self --rig <rigId> --node <logicalId> --print-env
rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime>
```
Use `rig attach --self` when the current agent should attach itself directly instead of going through `discover` + `bind`.
Current proven behavior:
- inside `tmux`: attaches as a normal tmux-backed node, preserving inbound `rig send` / `rig capture`
- outside `tmux`: attaches as `external_cli`
- `--print-env` prints the `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME` exports for the current shell
Recommended flow:
```bash
rig attach --self --rig <rigId> --node <logicalId> --print-env > /tmp/openrig-self-attach.env
. /tmp/openrig-self-attach.env
rig whoami --json
```
Notes:
- for tmux-backed self-attach, `rig whoami --json` is the right verification
- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface
- if the current shell is outside tmux, pass `--display-name <name>` when you want a stable human session label recorded
### Adopt a topology and bind live sessions
```bash
rig adopt <path> --bind <logicalId=tmuxSessionOrDiscoveryId>
rig adopt <path> --bind <logicalId=...> --bind <logicalId=...> --json
rig adopt <path> --bindings-file <bindings.yaml>
rig adopt <path> --bind <logicalId=...> --target-rig <rigId> --rig-root <root>
```
Use `rig adopt` when the sessions already exist and you want OpenRig to start managing them.
A bindings file is the durable map from authored logical IDs to live sessions. Shape:
```yaml
bindings:
dev1.impl2: dev1-impl2@rigged-buildout
dev1.qa: dev1-qa@rigged-buildout
```
Spec + bindings is the proven recovery pair for adopted rigs.
Spec gives OpenRig the intended topology. Bindings tells OpenRig which discovered live session belongs in each logical node.
### Proven adopted-rig recovery workflow
This workflow is proven for the case where the external tmux sessions are still alive:
```bash
rig release <rigId> --delete
rig discover --json
rig adopt <spec.yaml> --bindings-file <bindings.yaml>
```
What this does:
- removes OpenRig management without killing the sessions
- re-discovers those same sessions as unmanaged
- re-attaches them to the topology defined by the spec + bindings
Important limits:
- this is for `sessions still alive`
- spec alone is not enough for adopted rigs; you also need bindings
- this does not yet mean OpenRig can recreate dead external sessions from nothing
### Add unmanaged pods into an existing rig
This is the proven workflow when a rig is already managed, but a new pod was created outside OpenRig and you want to add it later:
```bash
rig adopt <pod-fragment.yaml> --bindings-file <pod.bindings.yaml> --target-rig <rigId>
```
Use this when:
- the target rig already exists
- the new sessions are live and visible in `rig discover --json`
- you want additive topology growth, not a full rebuild
What to prepare:
- a pod fragment spec with only the new pod
- a bindings file mapping the new logical IDs to the live session names
Verification loop:
```bash
rig discover --json
rig adopt <fragment.yaml> --bindings-file <bindings.yaml> --target-rig <rigId>
rig ps --nodes --json
rig export <rigId> -o rig.yaml
```
Success looks like:
- the new sessions stop appearing in `rig discover`
- the new logical IDs appear in `rig ps --nodes --json`
- `rig export` includes the new pod
### Mixed-origin rigs are allowed
One rig can contain both:
- adopted nodes bound from already-running sessions
- OpenRig-launched nodes created later with `rig expand` / `rig launch`
Current safety rule:
- `rig release` is for claimed/adopted-only rigs
- if a rig contains launched nodes, `rig release` fails with `contains_launched_nodes`
### Manager-assisted recovery
The proven operator pattern is:
- keep one OpenRig manager session outside the rig it manages
- address the target by rig name, not cached rig ID
- resolve the current owner from fresh `rig ps --nodes --json`
- send the manager the spec path, bindings path, and verification steps with `rig send`
This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert.
### Add/remove running topology parts
```bash
rig expand <rig-id> <pod-fragment-path> [--rig-root <path>] [--json]
rig launch <rigId> <nodeRef> [--json]
rig remove <rigId> <nodeRef> [--json]
rig shrink <rigId> <podRef> [--json]
rig unclaim <sessionRef> [--json]
```
## Specs and Validation
### Validate specs
```bash
rig spec validate <path> [--json]
rig spec preflight <path> [--rig-root <root>] [--json]
rig agent validate <path> [--json]
```
### Spec library
```bash
rig specs ls [--kind <kind>] [--json]
rig specs show <name-or-id> [--json]
rig specs preview <name-or-id> [--json]
rig specs add <yaml-or-directory> [--json]
rig specs sync [--json]
rig specs remove <name-or-id> [--json]
rig specs rename <name-or-id> <new-name> [--json]
```
`rig specs add <directory>` installs a full spec tree when the directory contains `rig.yaml` or `agent.yaml`.
## MCP
```bash
rig mcp serve [--port <port>]
```
Current shipped MCP tools:
- `rig_up`
- `rig_down`
- `rig_ps`
- `rig_status`
- `rig_snapshot_create`
- `rig_snapshot_list`
- `rig_restore`
- `rig_discover`
- `rig_bind`
- `rig_bundle_inspect`
- `rig_agent_validate`
- `rig_rig_validate`
- `rig_rig_nodes`
- `rig_send`
- `rig_capture`
- `rig_chatroom_send`
- `rig_chatroom_watch`
## Troubleshooting and Weird States
When the CLI behaves strangely, use the smallest truthful check first:
```bash
rig whoami --json
rig daemon status
rig ps --nodes --json
```
Specific operator rules:
- `Sent to ...` + `Verified: no` is ambiguous delivery, not automatic failure. Check reply, `rig capture`, transcript evidence, or queue/outbox state before retrying.
- partial `rig whoami --json` can happen when identity is still inferable but the daemon-backed path is degraded.
- the unified-exec-process warning is a host/tooling-layer signal, not automatic proof that the OpenRig topology is unhealthy.
If you hit the unified-exec warning, inspect for stale one-shot helpers before touching live seats:
```bash
ps -axo pid,ppid,command | rg 'tmux send-keys|rig queue create|tmux attach|codex|claude'
```
Safe cleanup target:
- orphaned one-shot wrappers like `tmux send-keys ...`
Do not mass-kill:
- `tmux attach ...`
- `codex ...`
- `claude ...`
For deeper host/runtime triage, use the companion `openrig-operator` skill.
## JSON and Error Posture
Design assumptions that hold in the shipped CLI:
- many operator commands support `--json`
- error messages are intended to say what happened, why it matters, and what to do next
- daemon-backed commands fail loudly when the daemon is stopped or unhealthy
- restore failure is not something you should silently reinterpret as success
## After-Compaction Recovery Checklist
1. `rig whoami --json`
2. `rig transcript <your-session> --tail 100`
3. `rig ps --nodes --json`
4. `rig chatroom history <rig> --limit 50`
## Commands That Do Not Exist
Do not assume these exist unless the shipped help starts listing them:
- `rig claim`
- `rig blame`
- `rig replay`
`rig env` IS shipped (was previously listed as not-present). It exposes `status`, `logs`, and `down` for service-backed rigs and managed apps; verify with `rig env --help`.
@@ -1,122 +0,0 @@
---
name: queue-handoff
description: Use when ending a turn, finishing a slice, blocked on another agent's work, or escalating to a human — durable work handoff via queue items so the system keeps moving across compactions, missed messages, and interruptions. Covers the hot-potato terminal-turn-rule (active work ends by passing the ball, not by going idle), default-nudge semantics, and when --no-nudge or --notify is required.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Daemon-backed `rig queue` shipped in v0.2.0 (PL-004 Phase A) with handed-off-to / handed-off-from / state field shape. `rig queue` is canonical for new work; `rigx queue` is recovery-only fallback. The daemon enforces hot-potato strict-rejection at the API.
sibling_skills: []
transfer_test: pending
---
# Queue Handoff
> **CANONICAL SURFACE NOTE (2026-05-11)** — `rig queue` (daemon-backed SQLite) is the
> canonical surface for all substantive work routing. `rigx queue` (filesystem v0
> prototype) is **recovery-only fallback**; qitems written via `rigx queue` are
> invisible to daemon-backed reads and break fleet-wide routing discipline.
Durable work handoff via queue items. Lets the system keep moving
through compactions, missed messages, and interruptions by passing the
ball forward instead of leaving work suspended in chat or in-flight
without an owner.
## Use this when
- **Ending a turn on substantive work.** Active work should end by
passing the ball to an owner or to the human — never by going idle
with the rig appearing dormant.
- **Finishing a slice that has a clear next step.** Default-nudge:
receiver gets a wake-ping plus the durable queue item.
- **Blocked on another agent's work.** Park the qitem with
`closure_reason: blocked_on` and the blocker qitem id.
- **Escalating to the human.** Make the escalation a durable attention
item, not just a chat message.
## Don't use this when
- The work is genuinely complete and there's no follow-on owner. Use
`closure_reason: no-follow-on` (terminal completion) or
`canceled`/`denied` as appropriate.
- The handoff would be too small and turn work into bureaucracy. Bundle
the work into a coherent slice instead of decomposing every step.
- The handoff would be too broad and lose ownership/proof/closure
criteria. Shape the qitem so the receiver knows the expected next
action and closure evidence.
## The hot-potato terminal-turn-rule
Active work ends by passing the ball to a named next owner or to the
human. The qitem state machine enforces this:
`pending → in-progress → done` requires `closure_reason` from one of:
- `handed_off_to` — work continues at a different seat (target = new owner)
- `blocked_on` — parked pending another qitem (target = blocker qitem id)
- `denied` — receiver rejected the work
- `canceled` — sender or receiver withdrew
- `no-follow-on` — terminal completion, nothing else needed
- `escalation` — kicked up to a higher tier (target = escalation target)
Three of those (`handed_off_to`, `blocked_on`, `escalation`) additionally
require `closure_target`. The daemon enforces this at the domain layer;
every surface (CLI, MCP, future UI) inherits the same guarantee.
## Default-nudge semantics (the syntax footgun)
| Command | Nudges by default? | When to use |
|---|---|---|
| `rig queue create` | yes | New qitem created from scratch |
| `rig queue handoff` | yes | Transactional close-as-handed-off + create-new |
| `rig queue handoff-and-complete` | **no — requires `--notify`** | Inside a self-driving loop where motion matters |
| `rigx queue handoff` (filesystem v0 prototype; **recovery-only fallback since 2026-05-11**) | yes | Legacy artifact; qitems invisible to daemon-backed reads. Use `rig queue handoff` for all new substantive work. |
**Footgun**: `handoff-and-complete` is cold unless `--notify` is passed.
In a self-driving loop, an agent that closes with
`handoff-and-complete` (no `--notify`) writes a durable qitem but does
NOT wake the next owner — the rig stalls silently while the qitem sits
in inbox.
**Rule**: in a live loop, use `handoff-and-complete --notify` OR send
a separate verified manual nudge. `--no-nudge` exists but should be
used intentionally (e.g., explicit park or human-gate signal), not as
an accidental stall.
## Failure modes (5; verbatim)
1. Agent ends a turn without a handoff, so the rig appears idle.
2. Agent creates a queue item but suppresses or forgets the nudge when immediate motion was intended. This includes `handoff-and-complete` without `--notify` inside a live loop.
3. Queue item is too small and turns work into bureaucracy.
4. Queue item is too broad and loses ownership, proof, or closure criteria.
5. Human escalation happens in chat but not as a durable attention item.
## Durable handoff field shape
Every qitem carries:
- `handed_off_to` — destination session (qualified `pod-member@rig` form)
- `handed_off_from` — source session
- `state` — one of: `pending | in-progress | done | blocked | failed | denied | canceled | handed-off`
- `closure_reason` + `closure_target` — set on terminal closure per hot-potato rule
The same field shape exists in legacy `rigx queue` artifacts and the daemon-shipped
`rig queue` surface, but new queue reads and writes should use `rig queue`. Watchdog
policies and workflow runtime project new owners off these fields.
## Two surfaces (same shape)
| Surface | Status | When to use |
|---|---|---|
| `rig queue ...` (daemon-shipped, v0.2.0) | Active host coordination surface | Daemon-backed PL-004 work; SQLite-canonical |
| `rigx queue ...` (filesystem v0 prototype) | Recovery-only fallback | Legacy recovery for artifacts that have not migrated; not for new substantive work because daemon-backed reads cannot see those qitems |
Default posture: prefer daemon `rig queue` for new work. If a
daemon-backed coordination command fails, debug the command/runtime/schema
edge directly — don't fall back to stale pre-upgrade assumptions.
## See also
- `rig queue --help` — full CLI surface for queue items, handoffs, and closure-reason discipline
@@ -1,137 +0,0 @@
---
name: seat-continuity-and-handover
description: Use when replacing a seat's occupant (rebuild/handover/swap), reasoning about stable-seat-identity vs fluid-occupant-identity, choosing an old-occupant disposition (retire/advise/shadow), or recording provenance for an occupant change. Two independent outcomes (continuityOutcome + seatBindingOutcome) and the 5 failure modes that prevent silent dishonesty.
metadata:
openrig:
stage: factory-approved
last_verified: "2026-05-04"
distribution_scope: product-bound
source_evidence: |
Option A rebuild MVP shipped 2026-04-30. Option B (full code-direct
seat handover via SeatHandoverService daemon module) is a future
follow-on.
sibling_skills:
- claude-compaction-restore
- agent-startup-and-context-ingestion
transfer_test: pending
---
# Seat Continuity and Handover
A pair of primitive families that separate *who is sitting in a seat* from
*what the seat itself is*:
1. **Occupant-creation primitives** — `resume`, `fork`, `rebuild`, `fresh` — produce a candidate new occupant. Answer: "where did the new occupant come from?"
2. **Seat-binding primitives** — `seat handover`, later `seat transfer` / `seat retire` / `seat swap` — bind a candidate occupant into the topology. Answer: "what happened to the stable seat identity?"
Core architectural decision: **stable seat identity, fluid occupant
identity, explicit provenance.** Today's `lead2`/`lead3`/`lead4`/`lead5`
pattern encodes successor lineage into seat names — that's the wrong
shape. Stable seat name + separately-recorded provenance trail is the
right shape.
## Use this when
- Replacing a seat's occupant via rebuild, fork, fresh, or future seat-handover
- Choosing old-occupant disposition: retire / advise / shadow
- Reasoning about whether a seat's lineage is stable or has drifted
- Reading or writing the provenance record for a seat
- Designing or auditing topology stability across an occupant change
## Don't use this when
- The seat is freshly created (no occupant to replace) — use `rig launch` / `rig expand` directly
- The intent is to change topology shape (add/remove seats), not replace an occupant — use topology-mutation primitives
## The two-outcome honesty model
Every seat-binding operation produces two **independent** outcomes:
```yaml
continuityOutcome: rebuilt | resumed | forked | fresh | failed
seatBindingOutcome: handed_over | partial | failed | unchanged
```
These can disagree honestly. Examples:
- `continuityOutcome: failed` + `seatBindingOutcome: unchanged` — new occupant didn't materialize; seat correctly retains old occupant.
- `continuityOutcome: rebuilt` + `seatBindingOutcome: failed` — candidate created OK; bind failed mid-flight; provenance records the gap.
**Don't collapse these into one outcome.** The system can describe what
actually happened only if the two are recorded independently.
## Provenance record (durable, queryable)
Every handover writes:
- seat id
- old occupant id
- new occupant id
- creation mode (`resume`/`fork`/`rebuild`/`fresh`)
- source artifacts used
- whether old occupant remains alive as advisor/shadow
- operator or loop that initiated the motion
- timestamp
- result (`handed_over` / `partial` / `failed`)
This is the system's truth-source for "how did the current occupant get
there." Without it, the control plane shows the current occupant but
not the legitimacy of the transition.
## State models — independent
### Occupant-creation state (per candidate)
1. **Requested** — input to rebuild/fork/fresh/resume
2. **Realized** — runtime/artifact path produced an occupant with managed-seat shape
3. **Failed** — candidate didn't materialize; `continuityOutcome: failed`
### Seat-binding state (per seat)
1. **Stable** — current occupant attached, no in-flight binding
2. **Binding** — handover in progress
3. **Bound** — handover succeeded; provenance record written
4. **Unchanged** — bind failed before completion; seat retains old occupant
A seat stays `Stable` even if multiple candidate-occupants were produced and discarded.
## Failure modes (5)
1. **Candidate creation failed** — `rebuild` couldn't synthesize from artifacts; `fork` couldn't resolve `session_source`; `fresh` couldn't launch. **Action**: bind operation does not begin; seat unchanged; provenance records the failed candidate-creation step.
2. **Old occupant cannot be detached cleanly** — runtime hung, tmux locked, etc. **Action**: bind halts mid-flight; seat enters `Binding` state with explicit "halted" sub-status; operator alerted. **Do NOT auto-rollback by reattaching old-occupant if detach didn't complete cleanly.**
3. **Bind succeeded but provenance write failed** — disk/db error. **Action**: not durable until provenance writes; treat as `Binding` halted, not `Bound`.
4. **Old occupant disposition unfulfillable** — operator requested `advise` (keep alive as advisor) but runtime can't keep old alive. **Action**: degrade to `retire` with explicit notification, OR fail if operator passed strict-disposition flag.
5. **Concurrent handover attempts** — two operations target the same seat. **Action**: serialize by seat-id lock; second attempt refuses with clear error.
## Hard boundaries (do-not list; verbatim)
- **Do NOT collapse `rebuild` and `seat handover` into one primitive.** The design specifically separates them so the system can describe what actually happened.
- **Do NOT introduce successor-suffix seat names** (`lead2`/`lead3`). Stable seat identity is the architectural goal.
- **Do NOT report `seatBindingOutcome: handed_over`** if the provenance record didn't write durably.
- **Do NOT auto-rollback a half-completed handover** by re-attaching the old occupant unless detach completed cleanly first.
## Composition: seat handover over fork
Once `session_source` fork v1 lands (already shipped at openrig
`c7b6df1`), the next composition is **seat handover over fork**:
candidate occupant created via fork, bound into the existing seat via
handover. Continuity outcome is `forked`; binding outcome is independent.
## Currently shipped vs deferred
- **Option A (rebuild) shipped** at openrig `578bd5c` (2026-04-30): `session_source.mode: rebuild` with `ref.kind: artifact_set`; identity-honesty bedrock at 4 layers (schema dispatch, orchestrator threading, SQLite read-back, negative-grep on resolver output). 26/26 Tier 1 cases green; full daemon regression 2146/2146 PASS.
- **Option B partially shipped, partially deferred to Mode 3**: the `rig seat handover` CLI exists as a planning/observability surface — `status <seat>` reads the seat-handover observability tables (migration `021`); `handover <seat>` plans a safe two-phase handover sequence with actual execution flowing through existing seat-launch surfaces under operator gating. Full code-direct `SeatHandoverService` daemon module + `seat-binding-outcome` provenance record + nodes-table provenance migration remain deferred to Mode 3. MVP composition `seat handover over fork` is highest-leverage v1.
## Why load-bearing for RSI
Any recursive seat-refresh loop must be able to replace an occupant
while keeping topology stable. Without these primitives, RSI loops will
either accumulate suffixed seat names (lineage leaking into identity) or
destabilize topology references on each cycle. Provenance must be
durable AND queryable so RSI loops can decide whether a seat is fresh
enough to receive new work or needs re-handover.
## See also
- `claude-compaction-restore` skill — packet-driven restore after Claude compaction
- `agent-startup-and-context-ingestion` skill — ingestion path for restore packets at agent boot
@@ -1,48 +0,0 @@
# VM Preview Fixtures
Sample data the Tart preview VM uses to populate the "populated" daemon with
a representative "lived-in" environment. See
`<substrate-shared-docs>/openrig-work/conventions/vm-preview/README.md` for the
operator workflow and the rationale behind the two-daemon model.
## Contents
- `workflows/` — workflow_spec YAML files. The operator copies these into the
populated daemon's `<workspace.specs_root>/workflows/` directory; slice 11's
folder-discovery scanner auto-discovers them on the next
`GET /api/specs/library` call.
- `queue-items/` — placeholder for future qitem fixture content (TODO; slice
follow-up will add).
- `rigs/` — placeholder for future rig-instance fixtures (TODO; slice
follow-up will add). For v0, the operator manually instantiates a sample
rig via `rig up product-team` against the populated daemon (see bootstrap
script).
## Conventions
Fixture YAML files use the canonical placeholder paths per banked
`feedback_test_fixture_example_path_placeholder.md`:
- Username-bearing paths: `/Users/example/...` (never a real contributor
username)
- Substrate-relative refs: `<substrate-shared-docs>/...`
## v0 scope
Slice 22 (release-0.3.1) ships the INFRASTRUCTURE for the two-daemon model:
- **Process-env pattern**: `OPENRIG_HOME=<dir> rig daemon start ...` — each
`rig` invocation gets its own state directory at process import time.
No CLI flag (an earlier attempt at `--openrig-home` was dropped because
it only threaded into the spawned child, not the parent-side lifecycle
bookkeeping; see substrate convention doc for the architecture note).
- Bootstrap script (`scripts/vm-bootstrap/two-daemon-start.sh`)
- This fixtures directory + a sample workflow YAML
- Convention doc in substrate
Authoring rich populated content (~13-node openrig-velocity rig + 20 qitems
+ getting-started mission narrative) is deferred to follow-up content slices
that have clearer ownership (slice 21 onboarding-conveyor will likely
contribute getting-started mission content).
@@ -1,79 +0,0 @@
# VM-preview fixture for slice 22 founder-walk-vm-populated-env.
# Derived from the 0.3.0 basic-loop starter; renamed to avoid colliding
# with the built-in spec when this fixture file is dropped into a
# populated daemon's workspace.specs_root/workflows/ directory.
# Slice 11's folder-discovery scanner auto-discovers it on next list.
workflow:
id: vm-preview-basic-loop
version: 1
objective: >
Walk one work packet through the conveyor rig slowly enough for a new user
to inspect each handoff.
target:
rig: conveyor
entry:
role: intake
coordination_terminal_turn_rule: hot_potato
roles:
intake:
skill_refs: [openrig-user, backlog-capture]
preferred_targets: [intake-lead@conveyor]
planner:
skill_refs: [openrig-user, writing-plans]
preferred_targets: [plan-planner@conveyor]
builder:
skill_refs: [openrig-user, executing-plans]
preferred_targets: [build-builder@conveyor]
reviewer:
skill_refs: [openrig-user, review-team]
preferred_targets: [review-reviewer@conveyor]
closer:
skill_refs: [openrig-user, verification-before-completion]
preferred_targets: [intake-lead@conveyor]
steps:
- id: intake
actor_role: intake
objective: Restate the objective and hand off one packet.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [planner]
- id: plan
actor_role: planner
objective: Produce a short plan for one build turn.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [builder]
- id: build
actor_role: builder
objective: Build or draft the planned output.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [reviewer]
- id: review
actor_role: reviewer
objective: Check the output and clear it for close.
allowed_exits: [handoff, waiting, failed]
next_hop:
mode: require
suggested_roles: [closer]
- id: close
actor_role: closer
objective: Close the walkthrough packet.
allowed_exits: [done, failed]
next_hop:
mode: forbid
invariants:
continuation_required: true
preserve_lineage: true
closure_required: true
allowed_exits: [handoff, waiting, done, failed]
loop_guards:
max_hops: 10
spawn_budget: 0
closure:
success: Walkthrough packet closed.
degraded: Walkthrough stopped with a named blocker.
failed: Walkthrough failed with evidence.
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "@openrig/daemon",
"version": "0.3.1",
"version": "0.2.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "tsc && node scripts/copy-workflow-specs.mjs",
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"dev": "tsx watch src/index.ts"
@@ -1,19 +0,0 @@
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { extname, join } from "node:path";
const sourceDir = join(process.cwd(), "src", "builtins", "workflow-specs");
const targetDir = join(process.cwd(), "dist", "builtins", "workflow-specs");
const workflowSpecExtensions = new Set([".yaml", ".yml"]);
rmSync(targetDir, { recursive: true, force: true });
mkdirSync(targetDir, { recursive: true });
if (!existsSync(sourceDir)) {
process.exit(0);
}
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (!workflowSpecExtensions.has(extname(entry.name).toLowerCase())) continue;
copyFileSync(join(sourceDir, entry.name), join(targetDir, entry.name));
}
@@ -11,9 +11,10 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, systematic-debugging, verification-before-completion, vault-user]
skills: [using-superpowers, openrig-user, mental-model-ha, systematic-debugging, verification-before-completion, vault-user]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -28,6 +28,7 @@ You have the following skills loaded:
- `using-superpowers` — general agent capabilities
- `openrig-user` — OpenRig CLI and topology operations
- `mental-model-ha` — compaction and restore discipline
- `systematic-debugging` — structured debugging approach
- `verification-before-completion` — evidence before claims
- `vault-user` — Vault-specific operations and domain knowledge
Loaded 100 of 782 files, more files were not shown because too many files have changed in this diff. Show more