Compare commits

..
Author SHA1 Message Date
mvschwarzandClaude Opus 4.7 a7e56382bf fix(cli): slice 12 BC-2 fixes — escape-band exact-[99.0.n] + findMission README gate
Addresses BLOCKING-CONCERN re-verdict on 79a3099a (guard qitem
qitem-20260516072314-9c3dd1e5; 2 specific findings).

BLOCK 1 — escape-band positional grammar under-enforced.
  Per `conventions/scope-and-versioning/README.md` §1 the non-release
  escape band is exactly <PFX>.99.0.<n> — the `0` segment after `99`
  is FIXED, not just "starts with 99". The BC-1 fix only checked
  "depth-3 starting with 99", which accepted OPR.99.7.8 and
  OPR.99.7.8.5 as conformant.

  Fix: extracted isValidMissionVerSegments(segs) shared between
  isMissionDotId and isSliceDotId's parent-version check. When the
  first segment is 99, require length===3 AND segs[1]==="0" AND
  segs[2] is a non-empty digit string. Both helpers now reject
  OPR.99.7.8 / OPR.99.7.8.1 while still accepting OPR.99.0.42 /
  OPR.99.0.42.7.

  Discriminators (5 new):
    - isMissionDotId rejects OPR.99.7.8 + OPR.99.1.0
    - isMissionDotId still accepts OPR.99.0.42 + OPR.99.0.1
    - isSliceDotId rejects OPR.99.7.8.1; accepts OPR.99.0.42.7
    - CLI: rig scope mission create bad-eb --id OPR.99.7.8 exits 1

BLOCK 2 — findMission did not share listMissions' README gate.
  After BC-1, listMissions skipped README-less directories per HG-8,
  but findMission still returned them as MissionInfo with
  readmePath:null. That left mission show / slice create / slice ship
  / slice move able to silently target scratch/junk dirs and bypass
  the lazy-parent-id-persist contract.

  Fix: findMission now throws a 3-part ScopeCliError when the target
  directory exists but has no README.md, naming the case explicitly
  and pointing operators at `rig scope mission create <name>` to
  declare the dir. All mutation surfaces inherit the guard via the
  shared findMission/findSlice path.

  Discriminators (6 new):
    - findMission(no-readme) throws
    - CLI: rig scope mission show no-readme → exits 1 with
      `not a declared mission` / `no README.md` in error.fact
    - CLI: rig scope slice create no-readme foo → exits 1; slices/01-foo
      NOT created on disk
    - CLI: rig scope slice ship 01-debt-foo no-readme → exits 1;
      source 01-debt-foo unmoved
    - CLI: rig scope slice move 01-debt-foo no-readme → exits 1;
      source unmoved

Verification:
- packages/cli npx tsc --noEmit: clean
- packages/cli npx vitest run on slice 12 files: 74/74 PASS
  (was 64/64 on 79a3099a; +10 BC-2 discriminators)

Stable mint path unchanged: inferMissionDotId already produces
OPR.99.0.<n>, so existing escape-band mission auto-mint flows through
the tightened validator without any new errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 00:25:57 -07:00
mvschwarzandClaude Opus 4.7 79a3099a63 fix(cli): slice 12 BC fixes — tier-aware ID + README gate + persist parent-id + symlink test
Addresses BLOCKING-CONCERN verdict on 0b77cba4 (guard qitem
qitem-20260516071333-0a9e7184; 4 specific findings, each with fix
recipe + make-it-fail-first discriminator).

BLOCK 1 — mission --id validation is not tier-aware.
  Added isMissionDotId + isSliceDotId helpers that enforce the
  positional depth invariants:
    mission   = 2-3 numeric segments; escape-band is exactly 3 with
                first segment 99
    slice     = 3-4 numeric segments; same escape-band exact-3 rule
                on the parent-ver portion
    sub-slice = parseDotId(s, "sub-slice") peels n + m
  parseDotId now accepts an optional `tier` arg ("mission"|"slice"|
  "sub-slice"); without it returns all segments as version
  (backward-compat). mission --id validation switched from the
  tier-agnostic isConformantDotId to isMissionDotId so a slice-shaped
  --id (OPR.0.3.2.12) is rejected with a 3-part error that names
  mission-shape vs slice-shape explicitly.

  Discriminators:
    - parseDotId("OPR.0.3.2.12", "slice") yields version=0.3.2, n=12
    - parseDotId("OPR.0.3.2", "mission") yields version=0.3.2, n=undefined
    - isMissionDotId rejects OPR.0.3.2.12 / OPR.99.0.1.5 / OPR.A.1 /
      OPR.99.0 (escape band must be exactly 3 segments)
    - CLI: `rig scope mission create wrong-tier --id OPR.0.3.2.12`
      exits 1 with `error.fact: not a mission-tier dot-ID`

BLOCK 2 — mission ls included no-README dirs contrary to HG-8.
  listMissions now skips directories without a README.md per PRD §2.1.
  The existing fixture test's assertion was inverted (it asserted the
  no-README dir WAS listed); corrected and added a fresh discriminator
  test seeding 3 dirs / 1 without README → only 2 listed.

BLOCK 3 — lazy parent-id adoption was persisted only for slice create.
  Added ensureMissionIdPersisted(mission, missionsRoot) helper that
  returns the mission id and, when mission.readmePath exists and
  mission.id is absent, writes it back to the target README in the
  same call. Threaded through slice create + slice ship + slice move
  so every child-mint site adopts the parent's id consistently
  (per convention §1 lazy-adoption rule). Sibling missions without
  id remain untouched — narrow create-triggered, not mass migrate.

  Discriminators (3 new tests):
    - slice ship to an id-less release-0.3.2 → target README gains
      `id: OPR.0.3.2`; child id = OPR.0.3.2.NN
    - slice move with the same setup
    - sibling mission "untouched-mission" without id remains
      byte-for-byte identical after the ship completes

BLOCK 4 — macOS /var realpath proof was overclaimed.
  Added a focused symlink regression test that:
    1. mktemp a parallel symlink tree pointing at the real repo
    2. asserts realpathSync(linkRoot) !== linkRoot (covers the
       /var↔/private/var class AND any other path-resolving symlink)
    3. invokes moveSlice with src/dest paths flowing through the
       symlinked tree
    4. asserts result.usedGit is true, result.repoRoot equals the
       git-returned realpath, and git log --follow on the dest
       picks up the seed history
  The test fails if scope-fs.ts is reverted to compute path.relative
  against an un-normalized repo root.

Verification:
- packages/cli npx tsc --noEmit: clean
- packages/cli npx vitest run on slice 12 files: 64/64 PASS
  (was 49/49 on 0b77cba4; +15 BC discriminators)
- packages/cli full vitest: 1018/1020 PASS — same 2 pre-existing
  preflight failures present on main; not introduced here

No production behavior regression: existing scope tests still pass
verbatim. New behavior is strictly additive (tier-aware validation
on a path that was previously over-permissive) or strictly more
correct (mission ls README gate; persisted parent-id) or strictly
more covered (symlink discriminator).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 00:20:48 -07:00
mvschwarzandClaude Opus 4.7 0b77cba4c5 feat(cli): rig scope CLI primitive — mission + slice tiers (release-0.3.2 slice 12)
Implements the `rig scope <tier> <verb>` command family per
`openrig-work/missions/release-0.3.2/slices/12-rig-slice-cli-primitive/IMPLEMENTATION-PRD.md`
(Amendment §A–E, 2026-05-15) and `conventions/scope-and-versioning/README.md`
(provisional, §1 dot-ID grammar).

v0 ships the mission + slice tiers (project + sub-slice reserved). The
CLI mints stable dot-IDs into created mission/slice frontmatter — IDs
live in frontmatter, never in folder names ("names are prompts; IDs
are the stable handle"). Mission creation is first-class in v0 per
Amendment §B (the rename's whole rationale: making a mission must
feel as light as making a slice).

Commands:
  rig scope slice ls [--mission <m>] [--state active|closed|shipped|all] [--json]
  rig scope slice show <slice-path> [--mission <m>] [--json]
  rig scope slice create <mission> <slug> [--template <kind>] [--json]
  rig scope slice ship <slice-path> <release-mission> [--json]
  rig scope slice close <slice-path> --reason <wontfix|deferred|superseded|stale> [--note <text>] [--json]
  rig scope slice move <slice-path> <dest-mission> [--json]
  rig scope mission ls [--json]
  rig scope mission show <mission> [--json]
  rig scope mission create <name> [--template <kind>] [--id <dot-id>] [--json]

New code:
  packages/cli/src/commands/scope.ts                       — command surface
  packages/cli/src/lib/scope/types.ts                      — scope tier types + ScopeCliError
  packages/cli/src/lib/scope/dot-id.ts                     — §1 grammar parse/mint
  packages/cli/src/lib/scope/scope-fs.ts                   — fs+git+frontmatter helpers
  packages/cli/src/lib/scope/templates.ts                  — template loader
  packages/cli/src/lib/scope-templates/{placeholder,bug-fix,backlog-deprecation,backlog-tech-debt,release-feature,research}.md
  packages/cli/src/lib/scope-templates/{mission-placeholder,mission-release}.md

Tests (49 new):
  packages/cli/test/scope-dot-id.test.ts                   — grammar parse/format/mint
  packages/cli/test/scope-fs.test.ts                       — frontmatter, discovery, auto-numbering, git mv (incl. macOS /var symlink normalization)
  packages/cli/test/scope-commands.test.ts                 — end-to-end command surface covering HG-1..HG-15

Hard gates verified (HG-1..HG-15):
  HG-1  slice ls --state filter        ✓ scope-commands.test.ts
  HG-2  slice show renders fm + README ✓
  HG-3  create auto-finds next NN; numbers never reused ✓
  HG-4  --template populates content   ✓
  HG-5  ship uses git mv; history preserved via --follow ✓ scope-fs.test.ts
  HG-6  close --reason updates status  ✓
  HG-7  move re-numbers in destination ✓
  HG-8  mission ls lists missions      ✓
  HG-9  --json output on every command ✓
  HG-10 3-part error shape             ✓
  HG-11 dirty working tree refused     ✓
  HG-12 --help present on every command ✓
  HG-13 typecheck + vitest green       ✓ (49 new pass; 0 new regressions; 2 pre-existing preflight failures present on main)
  HG-14 mission create symmetry        ✓ (same positional name + --template + --json shape)
  HG-15 dot-ID minted into frontmatter ✓ (release-pattern + escape-band + explicit --id)

Sequencing note (Amendment §E): built against
`conventions/scope-and-versioning/` stage:provisional. The convention's
§4 mass-migration of stage/verified frontmatter is OUT of scope here
(separate slice-02 adoption step). New artifacts created by this CLI
mint id + stage: wip + status by design; older artifacts remain
untouched.

build pipeline: package.json build script now copies
src/lib/scope-templates/*.md to dist/lib/scope-templates/ so the
template loader resolves the same files at runtime whether running
from source (dev) or dist (published).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-16 00:05:05 -07:00
mvschwarzandClaude Opus 4.7 86da3ce8a7 docs: de-number domain-services in README architecture diagram
The architecture diagram annotated "Domain services (52)" — a count
from the 2026-04-10 launch README rewrite, pre-0.2.0. The actual
domain-module count is now ~132 (the daemon grew substantially across
0.2.0/0.3.0/0.3.1). A precise count in an architecture diagram is a
maintenance liability that re-rots every release; the diagram conveys
the layering without it. Dropped the parenthetical rather than
chasing the number.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-15 02:48:30 -07:00
mvschwarzandClaude Opus 4.7 d8a5d398d4 Release 0.3.1
See docs/releases/v0.3.1.md and CHANGELOG.md for the full release narrative.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 22:55:16 -07:00
mvschwarzandClaude Opus 4.7 4d88d98d62 merge: align 3 stale tests with deliberate design changes
Updates three UI test assertions to match shipped product behavior:
- topology table action button is exposed directly rather than gated on
  hover (slice 14 walk-item 16 — operators kept missing it off-mouse).
- slice detail page lands on overview rather than story so README +
  readiness are the first thing operators see (slice 12 walk-item 1).
- reduced-motion CSS scan now covers all media-query blocks instead of
  matching only the first one — fixes a test-side regex bug; production
  CSS unchanged.

Test-only diff. 1106 pass / 0 fail on full UI suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 22:39:03 -07:00
mvschwarzandClaude Opus 4.7 71d52bb9ec test(ui): fix 3 pre-existing failures — match deliberate design changes
Three test failures had accumulated since the iter-30/iter-32 design
iterations. Each fix follows the dispatch rule: prefer fixing the code
when the test describes intended product behavior, rewrite the test
only when the original assertion is genuinely stale. All three are the
latter — production behavior is the deliberate intent; tests captured
the rejected alternative.

1. p5-3-activity-ring-hot-potato.test.tsx — 'source scan keeps
   activity and packet layers production-reachable'
   Assertion: TopologyTableView source must match /group-hover:opacity-100/
   Root cause: slice 14 walk-item 16 deliberately REMOVED the
   hover-gated `opacity-0` + `group-hover:!opacity-100` chain from
   action-column buttons — operators kept missing the cmux launcher
   when it was hidden off-mouse. The comment at
   TopologyTableView.tsx:80-83 documents the removal.
   Fix: replace the hover-class match with a positive assertion that
   the cmux action button data-testid is present (the actual
   production-reachability claim).

2. p5-topology-terminal.test.tsx — 'globals.css honors
   prefers-reduced-motion for terminal-card-active'
   Assertion: a @media (prefers-reduced-motion: reduce) block must
   include .terminal-card-active.
   Root cause: TEST BUG, not code regression. globals.css has multiple
   reduced-motion blocks (one per animation cluster); .terminal-card-
   active IS inside one of them (line 539). The prior single-match
   regex picked the FIRST block (vellum-scroll-x only) and failed.
   Fix: scan ALL reduced-motion blocks via matchAll + Array.some;
   assert that any block contains .terminal-card-active.

3. p5-slice-scope.test.tsx — 'default landing tab is story; mounts
   TimelineTab with events + phaseDefinitions'
   Assertion: project-tab-story has data-active='true' on initial
   load.
   Root cause: slice 12 walk-item 1 deliberately changed the default
   tab from 'story' to 'overview' so the first thing the operator
   sees on a slice is its README (current step + readiness), not a
   metric grid. The comment at ScopePages.tsx:1189-1192 documents
   the rationale.
   Fix: rename the test + assert overview is the default; verify
   story tab still activates TimelineTab when clicked (preserved
   coverage of the navigation path).

Verification:
- Each test file: green individually
- Full UI suite: 112 files / 1106 passing / 1 skipped / 0 failures
  (was 1093 passing + 3 failing on baseline; +13 net green, no new
  failures uncovered)
- tsc --noEmit: clean on touched files

Each fix carries an inline comment with the slice + walk-item that
made the design change, so future archeology stays cheap.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 22:25:10 -07:00
mvschwarzandClaude Opus 4.7 7697f5ceef docs(changelog): cover 0.3.1 dashboard + for-you visual refresh + action UX
Adds 4 new sections to the 0.3.1 changelog covering work that landed
between the original release-notes draft and ship:

- Dashboard And For You Visual Refresh — vellum surface language across
  the Dashboard / route and both feed-card systems on /for-you
- For You Storytelling Adapter — ApprovalCard wired to real data;
  ConceptCard preserved for 0.3.2
- Action Outcome And Inline Error Surface — VerbActions optimistic
  outcome on success + inline error block on failure; replaces silent-
  revert UX
- Vendored Skill Provenance — frontmatter metadata + OPENRIG.md
  sidecars on all 10 process-skill surfaces

Also extends the 0.3.2 carry-forward list with cross-daemon route
awareness, ConceptCard data source, and warm error messages on demo
surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 22:21:10 -07:00
mvschwarzandClaude Opus 4.7 72b0a910ec merge: for-you action outcome + error surface
Adds an immediate inline confirmation when a queue-item action succeeds
and a visible inline message when one fails, so the operator sees what
happened without waiting for any background refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 21:54:39 -07:00
mvschwarzandClaude Opus 4.7 8bd0d8e4c3 fix(for-you): optimistic action outcome + inline error surface in VerbActions
Demo-blocking regression caught on the VM founder walk: clicking Route
on a queue-item card silently reverted — no confirmation if success,
no error if failure. Root cause: submit() used a single onSettled
callback that reset the verb selection on BOTH success AND error, and
the only outcome render path waited on a /api/mission-control/audit
re-fetch that doesn't run when the mutation errors.

Fix 1 (optimistic outcome):
- VerbActions gains an `onOptimisticOutcome?: (outcome: FeedActionOutcome) => void`
  prop, fired on mutation success with verb/actor/destination/reason +
  actedAt = new Date().toISOString().
- FeedCard threads the callback through (qitemId-scoped wrapper) so
  the parent can stash the outcome.
- Feed.tsx owns a `Map<qitemId, FeedActionOutcome>` of optimistic
  outcomes; the FeedCard `actionOutcome` lookup is now
  optimistic-first, audit-derived second. Audit reconciliation runs in
  the background unchanged.

Fix 2 (inline error surface):
- VerbActions submit() now uses split onSuccess / onError callbacks
  instead of onSettled. On success: reset + optimistic emit. On error:
  setErrorMessage(err.message) and DON'T reset — the selection stays
  visible so the operator can fix and retry.
- New `errorMessage` state cleared on verb selection + manual reset +
  Cancel click.
- mc-verb-error block moved out of the activeVerb conditional so it
  renders below the verb-button row regardless of selection state.
  Style is vellum-coherent (bg-stone-50/40 + border-tertiary +
  font-mono uppercase tracking-[0.12em]) with role="alert" + title
  attr for full-message tooltip on truncation.

New test file verb-actions-optimistic-error.test.tsx (+7 tests):
- onOptimisticOutcome fires with correct shape on Approve success
- onOptimisticOutcome carries destinationSession on Route success
  (drives the manual-entry path; jsdom controlled <select> is flaky)
- successful mutation resets selection
- mc-verb-error renders with server message on failure; role="alert"
- error path does NOT reset (silent-revert regression guard)
- error clears when a different verb is selected
- error clears when Cancel is clicked

51/51 touched-file tests pass; tsc --noEmit clean on touched files.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 21:40:58 -07:00
mvschwarzandClaude Opus 4.7 6a569a77ac merge: storytelling adapter extension — approval cards wired to real data
Extends the storytelling band to render approval queue items alongside
the existing progress / shipped / incident kinds. Approval card data
flows from the existing activity feed through the same classifier the
queue-item rows below already use.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 21:10:14 -07:00
mvschwarzandClaude Opus 4.7 14234dcf8b feat(for-you): wire ApprovalCard to real data; remove ConceptCard from production switch
The storytelling adapter (buildStorytellingFeedItems) now accepts a 4th
argument: already-classified FeedCard[] from useActivityFeed +
classifyFeed. It filters to kind === "approval", maps each to
ApprovalCardSource (qitemId extracted from event payload; fallback to
FeedCard.id), and caps at 2 entries to match the band's tight preview
budget.

The ConceptCard component is preserved, but its branch is removed from
StorytellingFeed's switch — the adapter never emits "concept" so the
branch was dead. A 0.3.2 slice will pick a deliberate data source and
re-add the branch.

Feed.tsx now passes its existing rawCards (classifyFeed output) as the
new 4th arg. No new state or hooks; the upstream pipeline already
existed for the long-form FeedCard list.

Tests added to storytelling-feed-adapter.test.ts:
- approval-kind FeedCards → ApprovalCard items with qitemId from
  payload (qitemId AND qitem_id resolve; fallback to FeedCard.id when
  neither key is present)
- approvals capped at 2
- non-approval FeedCards are ignored (Shipped/Progress/Incident come
  from missions+slices, not from feedCards)
- output never contains concept-kind items
- title / oneLiner / bodyPreview / drillInHref shape verified

All storytelling tests green (52/52). Pre-existing tsc errors in
SpecsTable + TopologyTableView (missing @tanstack/react-table) are
unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 21:00:38 -07:00
mvschwarzandClaude Opus 4.7 ce01c226cc merge: for-you feed card visual refresh — matching chrome
Aligns the queue-item feed cards with the surrounding storytelling cards
so /for-you reads as a single coherent surface. Same outer recipe and
type scale as the rest of the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 20:30:53 -07:00
mvschwarzandClaude Opus 4.7 60280390c4 merge: storytelling cards visual refresh
Updates the storytelling card surfaces and a matching preview gallery so
they share the same outer chrome and design tokens as the rest of the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 20:30:16 -07:00
mvschwarzandClaude Opus 4.7 12caa7d739 redesign(for-you): vellum-coherent storytelling cards + clean preview gallery
Sibling to for-you-feedcard-vellum-coherent branch (a870c9e9). Both
must merge together so /for-you doesn't render inconsistently — this
branch updates the storytelling band (5 kinds via CardShell), the
sibling branch updates the queue-item card chrome.

storytelling-cards.tsx (CardShell + per-kind action buttons):
  - Replaced bg-white/35 + hard-shadow + border-l-4 accent-stripe
    with vellum recipe: bg-stone-100/45 + backdrop-blur-[10px] +
    ambient 3-stop box-shadow + 4 L-shaped CornerBracket components
  - Replaced colored pill (bg-emerald-50 etc) with mono-uppercase
    kind tag + leading colored dot using design tokens
    (success/warning/tertiary/secondary)
  - Title bumped to text-[16px] font-headline bold; body text-[12px]
    leading-relaxed for legibility (north star = human attention queue)
  - ApprovalCard Approve/Deny + ProgressCard Mark-complete buttons
    restyled to vellum bordered-no-fill mono uppercase with
    hover-invert; 44px touch targets preserved

CardPreviewsLab.tsx:
  - Removed outer VellumCard wrapper around each gallery example
    (was card-on-card visual). Each card now stands alone on the
    page with section labels above as plain marginalia headings

Verified at /lab/card-previews; live /for-you preview captured by
velocity-qa during the FeedCard handoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 20:28:36 -07:00
mvschwarzandClaude Opus 4.7 a870c9e926 refactor(for-you): bring FeedCard into vellum-coherent design alongside storytelling cards
Per founder dispatch 2026-05-14 (qitem-20260515025211-b78ef3b2 +
spec doc for-you-feedcard-redesign-spec-2026-05-14.md). Closes the
visual-inconsistency where /for-you rendered two card systems
side-by-side (storytelling cards in the new vellum chrome above,
queue-item FeedCard.tsx in the old chrome below).

PURE refactor — no API changes, no removed rich content. Only the
chrome around the existing rendering changes.

CHANGES PER SPEC:

1. Outer chrome — replaced VellumCard + KIND_ACCENT/TONE_ACCENT
   left-stripe with the vellum recipe used by CardShell:
     bg-stone-100/45 + backdrop-blur-[10px] + ambient 3-stop box-shadow
     + 4 CornerBracket marks imported from ../dashboard/vellum/index.js
   No left-stripe; no outline border. The 3-stop shadow defines card
   edges through the vellum surface.

2. Kind indicator — replaced KIND_TOKEN + TONE_TEXT colored-pill
   chrome with mono+leading-dot pattern. New KIND_DOT map binds
   each FeedCardKind to a design token (bg-tertiary / bg-warning /
   bg-success / bg-secondary / bg-stone-500). New TONE_DOT map does
   the same for ProjectMetaTone. Off-brand utilities (bg-emerald-50,
   bg-rose-50, bg-amber-50, text-emerald-800, etc.) are gone.

3. Typography bump — title goes to font-headline text-[16px] font-bold
   leading-tight (was font-mono text-sm). Body / hint copy goes to
   font-body text-[12px] leading-relaxed (was 11px). Legibility north
   star: For You is the human attention queue.

4. VerbActions buttons — vellum-style bordered-no-fill with hover-
   invert. min-h-[44px] touch target. Design-token mapping:
     approve  → border-success    / hover bg-success
     deny     → border-tertiary   / hover bg-tertiary
     route    → border-stone-700  / hover bg-stone-900
     hold     → border-warning    / hover bg-warning
     drop     → border-stone-700  / hover bg-stone-900
     annotate → border-stone-700  / hover bg-stone-900
     handoff  → border-stone-700  / hover bg-stone-900
   Active state inverts to filled. mono text-[10px] uppercase
   tracking-[0.12em].

5. TONE_RECEIPT strip — replaced per-tone bg/border/text recipe (each
   tone had its own off-brand color block) with vellum-coherent
   pattern: subtle bg-stone-50/40 on all tones + a leading colored dot
   (TONE_DOT) indicates the tone. The strip stays readable and small;
   tone is conveyed by the dot, not the whole strip.

6. PRESERVED rich content — all of these render exactly as before,
   just inside the new chrome:
     ProofThumbnailGrid (proof packet image thumbnails)
     ProofPacketHeader
     ProofImageViewer (image lightbox)
     QueueItemTrigger drawer integration
     AuthorAgentTag
     formatFriendlyDate timestamps
     compactSessionLabel session display
     eventToken / queueStateToken / actionVerbToken data tokens
     TagPill (unchanged)
     ActionOutcomePanel (restyled receipt; same data shape)
     All data-testid attributes intact
     All data props + interfaces unchanged

ALSO: VerbActions.tsx in packages/ui/src/components/mission-control/
components/ was the only consumer of the per-verb color recipe.
Refactored its verbToneClass map to the design-token bordered-no-fill
pattern + bumped the button min-h to 44px touch-target.

TESTS:

test/slice-16-typography-vellum.test.tsx — replaced 4 stale source-
level assertions (old VellumCard + bg-white/35 + 11px body) with 7
new ones covering the vellum-coherent shape:
  1. Outer chrome uses bg-stone-100/45 + backdrop-blur-[10px] (no
     VellumCard; no border-l-4 left-stripe; CARD_SHADOW_STYLE +
     3-stop shadow values present)
  2. CornerBracket imported from dashboard/vellum barrel
  3. Title is 16px font-headline bold
  4. Qitem body paragraph is 12px font-body (negate old shape)
  5. ActionOutcomePanel sentence stays 12px font-body
  6. Your turn hint is 12px font-body (bumped from 11px)
  7. KIND_DOT map uses design-token colors; old colored-pill
     utilities (bg-emerald-50 / bg-rose-50 / bg-amber-50 /
     text-emerald-800 / etc) are ABSENT

Verification:
- 47/47 focused tests pass across slice-16-typography-vellum +
  feed-card-dismiss + storytelling-cards + storytelling-feed-adapter
  + storytelling-wires
- pnpm tsc --noEmit clean on touched files (pre-existing
  @tanstack/react-table errors in unrelated files unchanged from main)
- Full UI suite at baseline: 824 pass + 1 skipped + 53 pre-existing
  fails (banked AppShell global-polls + vite:css @xyflow/react carry-
  forward; orthogonal to this refactor)

LINE-COUNT IMPACT:
- FeedCard.tsx: 507 → 442 (-65 lines from dropping 4 stale chrome maps
  + simplifying receipt panels via TONE_DOT shared map)
- VerbActions.tsx: 258 → 263 (+5 lines from longer comments)
- slice-16-typography-vellum.test.tsx: 59 → 81 (+22 from 4→7 tests)

FOLLOW-ON FLAG FOR ORCH:

The dispatch states "storytelling cards (top, redesigned this
session) use the new vellum chrome". On inspection at the dispatch's
base commit, storytelling-cards.tsx (CardShell) still uses the OLD
chrome (border + bg-white/35 hard-shadow + colored pills with
bg-emerald-50 / etc). The redesign of storytelling-cards.tsx that
the dispatch references has not landed on any branch I can find via
`git log --oneline --all`. After this commit, the inversion will be
visible on the live VM: FeedCard will be vellum-coherent; storytelling
cards will still be old.

Per the dispatch's explicit OUT OF SCOPE ("Storytelling-cards.tsx
(already done)"), I did NOT modify storytelling-cards.tsx. Flagging
for orch routing: storytelling-cards.tsx likely needs a follow-up
slice to apply the same vellum-coherent pattern (CardShell can be
swapped to the same vellum recipe; ACCENTS map collapses to a
KIND_DOT-shaped table).

POST-HANDBACK GATE PLAN (per dispatch):
- Gate 1: orch reviews file structure / tests / no regression
- Gate 2: velocity-qa spins up seeded /for-you + captures screenshot
- Gate 3: design-reviewer reviews screenshot, approves or iterates
- Only merge AFTER design-reviewer approval

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 20:23:03 -07:00
mvschwarzandClaude Opus 4.7 1063cdd89a simplify(dashboard): drop back layer + back vellum sheet; add ambient shadow
Per founder dispatch 2026-05-15: simplified the vellum showcase to
match the rest of the site better while preserving the tactical-document
character.

Removed:
  - BackLayerContent (bold-black OPERATOR / OS·Ø tactical anchors)
  - BackVellumSheet (heavy translucent paper sheet)

Added:
  - Ambient shadow on all 6 destination cards (shadow="ambient")
    multi-stop box-shadow defining card edges on all 4 sides

Preserved:
  - WELCOME BACK hero + classification eyebrow + footer marginalia
  - Mid layer marginalia (Field Report + Data Streams + scattered marks)
  - EYES EVERYWHERE + AUTHOR REDACTED + glitch artifact marks
  - 6 schematic-layout destination cards
  - Corner brackets, route slugs, inky text utilities
  - Lab override props for /lab/vellum-bg/* experiment routes

Result: cards float on the cream paper-grid page background with
clear ambient shadow definition. Lighter, cleaner, still distinctive.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 18:32:08 -07:00
mvschwarzandClaude Opus 4.7 b359e59e99 merge: dashboard surface extraction — single source of truth between routes
Refactors the dashboard `/` route and the `/lab/vellum-lab` lab route
to compose the same primitives from a new shared subdirectory under
packages/ui/src/components/dashboard/vellum/. Production was rendering
a different layout than the lab; both now share the same source.

Pure refactor — no design changes. Production layout aligns to the
lab reference as a side effect.

Net delta: -603 lines after deduplication across the 3 main files.
5/5 tests passing on packages/ui/test/dashboard-card-vellum.test.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 17:40:25 -07:00
mvschwarzandClaude Opus 4.7 ac21bfc999 refactor(dashboard): extract vellum primitives — single source of truth between /dashboard and /lab/vellum-lab
Per founder dispatch 2026-05-14 (qitem-20260515000643-21f2001b) after
~32 design iterations approved at git tag vellum-iter-32-clean. PURE
refactor — visual state locked; no design changes.

EXTRACTED from VellumLab.tsx (~1050 lines) and Dashboard.tsx (~636
lines, mostly duplicated) into packages/ui/src/components/dashboard/
vellum/ — 10 focused files totaling 974 lines:

Layer composers:
- BackLayerContent.tsx    bold-black tactical anchors (OPERATOR /
                          RIG·OS(s*) / 07/?? / OS·Ø / etc); accepts
                          optional hostname prop
- BackVellumSheet.tsx     bg-white/40 + backdrop-blur-[20px] stagger-
                          inset sheet
- MidLayerContent.tsx     Field Report + Data Streams + scattered
                          marks; accepts hostname
- TopLayerContent.tsx     classification eyebrow + WELCOME BACK hero
                          + stats line + EYES EVERYWHERE + AUTHOR
                          REDACTED + footer; accepts hostname +
                          totalRigs + totalAgents + activeAgents
- DestinationsLayer.tsx   6-card grid container; accepts librarySize

Card primitive:
- VellumDestinationCard.tsx
                          Link wrapper with vellum surface (translucent
                          tint + backdrop-blur) + 5 layout dispatchers
                          (NumeralLayout / SchematicLayout /
                          HeadlineLayout / StatLayout / CoordinateLayout)
                          + Callout helper + CornerBracket use. Production
                          dashboard pins layout="numeral" per the iter-15
                          founder-approved reference visual.

SVG graphics:
- graphics.tsx            TreeGraphic / StratigraphicGraphic /
                          PulseGraphic / SphereGraphic / MagnifierGraphic
                          / GearGraphic — small technical wireframes
                          ~60×60 viewBox

Helpers:
- CornerBracket.tsx       L-shaped 90° corner brackets (4 positions)
- marks.tsx               FloatingTopMarks + ScatteredMarks shared
                          glitch-artifact text positioning

Barrel:
- index.ts                Single import surface for consumers

REFACTORED CONSUMERS:

Dashboard.tsx: 636 → 49 lines. Imports from ./vellum/index.js,
composes the 5 layers, wires the 4 real-data inputs (useRigSummary →
totalRigs/totalAgents; usePsEntries → activeAgents; useSpecLibrary →
librarySize; window.location → hostname). Drops all duplicated layer
content + graphic SVG + CornerBracket. NOTE: production dashboard now
uses the numeral layout via DestinationsLayer (matches the iter-15-
clean reference image); previously the prod Dashboard rendered the
schematic layout which had drifted from the founder-approved visual.

VellumLab.tsx: 1050 → 51 lines. Imports the same primitives. Default
prop values render the hardcoded lab placeholders (hostname=127.0.0.1,
totalRigs=1, totalAgents=4, activeAgents=4). The
backLayerOverride / vellumSheetOverride prop scaffolding stays so the
/lab/vellum-bg/* background experiment routes keep working.

TESTS:

dashboard-card-vellum.test.tsx — 3 source-level tests rewritten as 5
tests covering both layers of the refactor:
1. Dashboard.tsx imports the vellum primitives (single-source-of-truth
   structural proof)
2. Dashboard.tsx wires real-data hooks for stats + library + hostname
3. DestinationsLayer declares all 6 destination Links with correct
   routes (/topology /project /for-you /specs /search /settings) +
   num props (01-06)
4. VellumDestinationCard renders numeral layout per iter-15 reference
   (DestinationsLayer pins layout="numeral"; card includes NumeralLayout
   + CornerBracket + bg-stone-100/45 + backdrop-blur-[10px])
5. TopLayerContent carries the classification + WELCOME BACK chrome
   (Welcome back / (s*) / Operator / OpenRig · 0.3.1 / Field Station /
   Eyes Everywhere / backdrop-blur-[6px] on the eyebrow strip)

VERIFICATION:
- vitest test/dashboard-card-vellum.test.tsx → 5/5 PASS
- tsc --noEmit on touched files clean (pre-existing
  @tanstack/react-table errors in untouched SpecsTable.tsx +
  TopologyTableView.tsx remain — orthogonal)
- Full UI suite at baseline (53 pre-existing fails from vite:css +
  AppShell global-polls baseline; banked feedback_appshell_global_polls_
  test_pollution carry-forward)

LINE-COUNT IMPACT:
- Dashboard.tsx: 636 → 49 (-93%)
- VellumLab.tsx: 1050 → 51 (-95%)
- Net total across all dashboard/lab/vellum files: ~600 lines removed
  via deduplication (was 1686, now 1074)

PIXEL PARITY:
- /lab/vellum-lab uses VellumLab.tsx → primitives with default props
  → same visual as the reference (founder-approved iter-15-clean)
- / uses Dashboard.tsx → same primitives with real-data props →
  identical visual except for dynamic stat numbers (which is the
  intended real-data wiring)

NEXT GATES (per dispatch):
- Gate 1: orch visual-parity check vs reference screenshot
- Gate 2: velocity-qa test/tsc + VM dogfood
- Gate 3: founder spot-check on http://100.95.124.51

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 17:17:24 -07:00
mvschwarzandClaude Opus 4.7 a4ed59b93a snapshot: vellum showcase iter-32 (iter-15 restored, clean)
Rolled back from iter-30 (topo-flow) to iter-15 design state per
founder rollback. State at this commit:
  - /lab/vellum-lab: cards bg-stone-100/45 + backdrop-blur-[10px]
    no shadow, mixed numeral layouts, sphere wireframe restored
    in mid layer, original BackLayerContent + staggered vellum sheet
  - Removed [*] uniform per-card mark (read as UI, not glitch)
  - Removed [?] at top-[28%] left-[28%] that covered topology
    card's tree diagram
  - Other floating marks (▪ 03°, **, +, (A), ▪ 06°, [?] bottom-left)
    preserved as scattered glitch artifacts
  - /lab/vellum-bg/* lab routes still available for back-layer
    animation experiments (topo flow / line-art / etc)

Production Dashboard.tsx unchanged from iter-30 mirror — needs
separate decision on whether to revert production to match iter-15
or keep the iter-19-port + clean-up the iter-30 changes.

Tag for rollback target: vellum-iter-32-clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 17:03:00 -07:00
mvschwarzandClaude Opus 4.7 b9895ac78e snapshot: vellum showcase iter-30 (topo flow + see-thru cards)
Save point per founder request before reverting back to iter-15
design. State at this commit:
  - Lab + production dashboard ported with vellum showcase design
  - /lab/vellum-bg/c-allover: scrolling topo lines + heavy diffuse
    vellum + see-thru cards (stone-100/8) + ambient shadow
  - Animation: vellum-scroll-x (60s seamless horizontal loop)
  - Sphere wireframe removed from MidLayerContent
  - Cards have backdrop-blur-[2px] (was [10px]) for see-thru effect
  - Multiple shadow approaches available via shadow prop on
    VellumDestinationCard

Tag: vellum-iter-30-topo-flow-snapshot

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-14 16:37:14 -07:00
mvschwarzandClaude Opus 4.7 6ffa1b4ea9 chore(daemon): trim discriminator test + adjust starter-spec count
Drops a test that asserted a specific shipped spec's projection shape;
the loader's generic tests already cover the behavior. Updates the
expected loaded-spec list to match the current shipped set.

Daemon suite at baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 19:56:46 -07:00
mvschwarzandClaude Opus 4.7 9ab716b137 chore(skills): vendoring metadata for frontend-design + dogfood
Apply the writing-skills-for-openrig vendoring convention to the two remaining
process skills not covered by the prior sweeps.

Both are vendored-as-is (byte-identical to upstream):
- frontend-design — Anthropic (Apache 2.0; LICENSE.txt sibling ships with the
  skill). Diff vs ~/.claude/skills/frontend-design = identical.
- dogfood — Vercel agent-browser ecosystem. Diff vs ~/.agents/skills/dogfood
  = identical. Bundles references/ + templates/ siblings from upstream.

Frontmatter only (no OPENRIG.md sidecar) per the refined convention: sidecar
required only when modifications or supplements exist.

Closes the process/ skills surface for vendoring metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 19:36:10 -07:00
mvschwarzandClaude Opus 4.7 8f89dcacd4 chore(skills): vendoring metadata for remaining 6 obra-superpowers process skills
Apply the writing-skills-for-openrig vendoring convention to the 6 obra-
superpowers-derived process skills that weren't covered in 647864fe.

Diff'd each against the plugin source (pulled 2026-05-11):

Vendored-as-is (front-matter only):
- systematic-debugging — byte-identical to upstream
- test-driven-development — byte-identical to upstream
- verification-before-completion — byte-identical to upstream

Modified (front-matter + OPENRIG.md sidecar):
- brainstorming — removed Visual Companion step, spec self-review, user-
  reviews-spec step; changed plan-doc path to docs/plans/. Process gates
  handled at rig/orchestration layer in OpenRig.
- using-superpowers — removed multi-platform sections (Copilot CLI,
  Gemini CLI, Platform Adaptation), Instruction Priority block, and
  SUBAGENT-STOP block. OpenRig targets Claude Code + Codex with its own
  precedence layer.
- writing-plans — removed Scope Check + File Structure sections,
  subagent-driven-development pointer, checkbox-tracking syntax; changed
  plan-doc path to docs/plans/.

Extends the vendoring_pattern enum from {modify-the-file, add-supplementary-
files} (writing-skills-for-openrig SKILL.md §"Vendored skills") with a third
value vendored-as-is so unmodified vendored skills are explicitly labeled
rather than carrying no marker.

Convention reaffirmed (per founder direction 2026-05-13): OPENRIG.md
sidecar is required when an OpenRig-specific modification or supplement
exists; front-matter alone is sufficient for vendored-as-is.

Mirrored to skills/_canonical/. Daemon skill-discovery focused tests at
baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 18:52:37 -07:00
mvschwarzandClaude Opus 4.7 647864fe19 chore(skills): vendoring metadata + OPENRIG sidecars for two process skills
Apply the existing writing-skills-for-openrig doctrine (SKILL.md §"Vendored
skills") to the two process skills where modifications + supplementary content
were already in place but undocumented.

agent-browser: add-supplementary-files pattern; the LOCAL-INSIGHTS.md sibling
captures field-tested gotchas (command-compatibility matrix, etc) that the
upstream Vercel skill doesn't cover. SKILL.md body had a pointer to it
already; frontmatter now declares the vendoring relationship and a new
OPENRIG.md sidecar narrates the relationship in human-readable form.

executing-plans: modify-the-file pattern; OpenRig restructured upstream's
all-tasks-at-once shape into batch-with-checkpoint (default 3 tasks/batch +
report + continue cycle) to fit the driver/guard/qa rhythm. Removed the
upstream subagent-alternative pointer. OPENRIG.md captures the diff and the
why.

Establishes OPENRIG.md as the per-skill sidecar convention for vendored
skills with non-trivial OpenRig relationship. Empty for openrig-native
skills; minimal for vendored-unchanged; narrative for vendored-modified or
vendored-supplemented.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 18:05:37 -07:00
mvschwarz 7e55d9d373 chore: doc tweak 2026-05-13 15:04:30 -07:00
mvschwarz 7d2159b887 chore: docs and fixtures tidy 2026-05-13 15:03:06 -07:00
mvschwarz 45361c27d1 merge(0.3.1): slice 29 Library content cleanup + sidebar UX
Finishes slice 28 dogfood — removes content that doesn't belong in shipped
product (mis-imported specs as skills + internal-only doctrine + redundant
duplicates + internal-team-only process skills); adjusts internal-doc
references (<internal-docs> / <work-tree> / internal docs refs) from all
remaining plugin + canonical skill bodies; switches sidebar UX from
flat skill list to category-folder grouping (core / pm / pods / process)
with single-row skill entries; surfaces on-disk source path on skill
detail page.

6-commit checkpoint chain:
- A: T1 deletions (10 dirs: 4 plugin + 6 canonical) + agent-spec edits
  (shared/agent.yaml + 3 kernel/qa specs lose 5 capability declarations)
  + UI category-folder tree + skill detail file-path display
- B: plugin-skills doc polish (8 SKILL.md files)
- C: canonical-skills doc polish (~26 SKILL.md files)
- BC repair: close remaining broken-ref blockers from cross-file refs
- BC repair-2: close remaining description + table refs
- QA-1 repair: plugin README skill-count + reference fix

Deletions (10 dirs):
- canonical: claude-compact-in-place, rig-architect, process/{containerized-e2e,
  control-plane-queue, intake-routing, local-sysadmin}
- plugin: claude-compact-in-place, session-compaction-and-restore,
  permission-posture, openrig-compaction-instructions (empty leftover)

Three-gate clean: driver complete (post-OPT-Z1 scope expansion to include
agent-spec edits, surfaced via pre-scaffold forensic-trace) + qa CLEAR
HG-1..12 VM dogfood (26 canonical skills remain across core 6 / pm 7 /
pods 3 / process 10; openrig-core 8 skills + gstack 45 + obra-superpowers
14 in plugin list; category-folder sidebar; on-disk paths visible on
skill detail; folder navigation preserved).

SC-29: NONE (UI changes + content edits + directory deletions; no new
ConfigStore keys; no new daemon endpoints; reduction of declared
agent capabilities).

Banked patterns applied (cited by name in earlier commits):
- feedback_pre_dispatch_grep_load_bearing_claims (driver forensic-traced
  agent-yaml + kernel-spec dependencies before scaffolding; caught 5
  hard-dep blockers that would have broken daemon boot)
- feedback_audit_every_layer_function_and_module_constants (declaration
  layer + consumer-prose layer)
- feedback_finalization_means_complete_specced_not_minimum_viable
  (founder direction: "finishing it is 0.3.1 work")
- feedback_external_rpc_contract_drift_verify_live (VM walk verified
  live daemon-boot semantics after agent.yaml edits)

Defers to 0.3.2 (per founder direction):
- Canonical → plugin migration (the openrig-architect/operator/user
  overlap between canonical core/* and plugin top-level)
- Host skill-folder vision (operators add their own folders for
  managed skill distribution)
2026-05-13 01:05:44 -07:00
mvschwarzandClaude Opus 4.7 033c492fd7 fix(slice 29): plugin README skill-count + reference (QA-1)
velocity-qa HG-12 VM walk on bb087c3 found:
1. README.md line 7: "Skills (11)" — stale; should be 8 post-slice-29
2. README.md line 20: still names claude-compact-in-place — a slice 29 deletion

The plugin docs-browser auto-selects README.md on /plugins/openrig-core
entry (per slice 28 HG-6 wins), so this is OPERATOR-VISIBLE content.
My prior HG-6/HG-7/HG-8 greps focused on SKILL.md frontmatter +
sibling_skills + body. README.md is a separate operator-visible doc
in the plugin tree and was missed.

REPAIR:
- README.md line 7: skill-count corrected 11 → 8; description tightened
  to operator-readable summary of remaining skills (CLI, rig topology
  design, compaction recovery, durable handoff, seat continuity)
- README.md line 18-22: removed claude-compact-in-place bullet from
  the "compaction-targeting skills" paragraph; rewritten to describe
  the single remaining claude-compaction-restore skill (singular,
  not plural)

VERIFICATION:
- /usr/bin/grep -E 'Skills \(11\)|claude-compact-in-place|session-
  compaction-and-restore|permission-posture|openrig-compaction-
  instructions' packages/daemon/assets/plugins/openrig-core/README.md
  → only "Skills (8)" match remains (the new corrected line)
- Broader sweep: /usr/bin/grep -rn 'Skills \(11\)|11 skills|11
  expected skills' packages/daemon/ → 0 hits
- /usr/bin/find packages/daemon/{assets/plugins/openrig-core,specs/
  agents/shared/skills} -type f \( -name '*.md' -o -name '*.json' \)
  -exec grep -l '(deleted-skill alternation)' {} + → 0 hits

BANKED LESSON:
HG-6/HG-7/HG-8 grep gates must include non-SKILL.md operator-visible
docs (README.md, LICENSE, manifest fields). The plugin docs-browser
surfaces README.md as the auto-selected default — so it carries the
same content-class requirements as SKILL.md bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 01:02:34 -07:00
mvschwarzandClaude Opus 4.7 bb087c3ddb fix(slice 29 BC repair-2): close remaining HG-8 description + table refs
Second guard verdict (qitem-20260513074951-02713944) caught remaining
HG-8 broken-ref class: my earlier grep used backticked-only pattern
and missed references in `description:` frontmatter fields + tables
(no backticks). Comprehensive grep without the backtick constraint
caught the remaining 7 instances.

GUARD'S 4 SURFACED INSTANCES (+ 3 driver-discovered) ALL ADDRESSED:

1. plugin/openrig-user/SKILL.md:3 description "(use openrig-builder)"
   → "(work in the openrig product repo)"
2. plugin/openrig-architect/SKILL.md:3 description same pattern → same fix
3. plugin/openrig-operator/SKILL.md:4 description same → same fix
4. plugin/forming-an-openrig-mental-model/SKILL.md:4 description
   "(use mental-model-ha for that)" → rewritten path-agnostic
   ("HA-pair compaction recovery is the pair-of-seats coordination
   pattern; orientation is a solo task")
5. plugin/forming-an-openrig-mental-model/SKILL.md:168 bootstrap-
   table cell named openrig-builder/openrig-upgrade/mental-model-ha
   → rewritten to list the shipped bootstrap set (openrig-user,
   openrig-operator, openrig-architect, forming-an-openrig-mental-
   model + rest of openrig-core plugin skills)
6. canonical/core/openrig-architect/SKILL.md:3 (mirror) → same fix
7. plugin/openrig-user/SKILL.md:266 starter_ref name example
   `openrig-builder-base--claude-code` → renamed to
   `my-team-starter--claude-code` (generic example name)

DRIVER-DISCOVERED INSTANCES (same class; same fix applied):

8. plugin/agent-startup-and-context-ingestion/SKILL.md:74 prose
   "Per the externalized-memory-surfaces convention's 13-row inventory"
   → rewritten to describe the 3 primary memory surfaces inline
   without naming a non-shipped convention/skill
9. plugin/agent-startup-and-context-ingestion/SKILL.md:91 example-
   cells in startup-files-vs-skills table → replaced unshipped
   `vault-user` with shipped `dogfood`
10. canonical/process/writing-plans/SKILL.md:110 SUB-SKILL ref to
    `superpowers:subagent-driven-development` → rewritten path-agnostic
    ("Pair with whatever subagent-orchestration skill your runtime
    provides; the Obra Superpowers plugin ships one if you have that
    plugin installed")

VERIFICATION (re-run per guard's checklist):

1. HG-8 broad-grep (no backtick constraint):
   /usr/bin/find packages/daemon/{assets/plugins/openrig-core/skills,
     specs/agents/shared/skills} skills/_canonical -name SKILL.md
     -exec /usr/bin/grep -l -E '\b(mental-model-ha|scope-recovery|
     writing-skills-for-openrig|openrig-builder|openrig-upgrade|
     building-agent-software|looping-workflows|... + 30 more tokens|
     vault-user)\b' {} +
   → 0 hits across all 3 trees (plugin + canonical + skills/_canonical
   mirror; previously hits ranged across descriptions, tables, prose).

2. HG-6 + HG-7 reference grep:
   → 0 hits (preserved from BC-repair commit 35ac9f2).

3. npm run mirror-skills:check
   → exit 0.

4. Daemon focused suite (openrig-core-plugin + skill-library-routes +
   plugins-routes + plugin-files-routes):
   → 73/73 PASS. Frontmatter validity (HG-9) preserved including the
   description-field rewrites.

5. git diff --check 1a6cc52..HEAD → clean.

LESSON LEARNED (banked-feedback class):

The prior repair grep used \`backticked\` pattern only — missed
description-field plain-text refs. Guard caught the gap by running a
broader bare-name grep. Banked feedback_specific_review_recommendations_
get_acted_on applies here at a meta level: when guard surfaces 4
instances of a pattern, the REPAIR scope is the entire pattern class,
not just the 4 instances. The first repair committed only the
backtick-pattern subset; this repair-2 closes the description-
field + table-cell subset that the bare-name grep finds.

Banked as guidance for future cleanup-class repairs: when grep'ing for
ref-cleanup, use the BARE NAME pattern (\b<name>\b) not the backtick-
constrained one. Backticks are a style convention for body refs;
descriptions and prose use bare names. The bare-name grep is the
canonical HG-8 closure check.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 00:53:16 -07:00
mvschwarzandClaude Opus 4.7 35ac9f294d fix(slice 29 BC repair): close HG-8 broken-ref blockers across plugin + canonical surfaces
Guard verdict on qitem-20260513073618-c42951df (BLOCKING-CONCERN at
baacfe8) identified 4 specific HG-8 violations + the broader pattern
of dangling skill cross-references that point at skills not shipped
in either tree. This commit closes both layers.

GUARD-SURFACED ISSUES (4; all addressed):

1. plugin/seat-continuity-and-handover/SKILL.md:139 See Also pointed
   at `mental-model-ha` (not a shipped skill). Removed from See Also +
   removed from sibling_skills.

2. plugin/agent-startup-and-context-ingestion/SKILL.md:102 See Also
   pointed at `writing-skills-for-openrig` (not shipped). Rewritten
   to reference https://agentskills.io/specification (the cross-runtime
   skill standard) — an operator-reachable public URL.

3. plugin/forming-an-openrig-mental-model/SKILL.md:267 NOT-for section
   said HA pair compaction recovery is `mental-model-ha`. Rewritten
   to describe the pattern path-agnostically (solo orientation vs.
   pair-of-seats coordination).

4. plugin/forming-an-openrig-mental-model/SKILL.md:15 frontmatter
   carried an authoring breadcrumb (`lab/skill-management-research/
   cycles/...`). Removed; replaced with a path-agnostic distinction
   between mental-model-of-OpenRig and HA-pair preservation.

BROADER HG-8 REPAIR (sibling_skills + body refs to unshipped skills):

Comprehensive sweep using the actual shipped-skills list (8 plugin +
26 canonical = 34 shipped) found additional dangling references the
guard would have flagged on next review. Cleaned:

plugin sibling_skills lists trimmed to shipped peers only:
- openrig-user: removed openrig-builder, openrig-upgrade, ai-dev-workflows
- openrig-architect: removed openrig-builder, openrig-upgrade, ai-dev-workflows;
  also dropped two N-internal `notes:` and `naming_note:` blocks that
  referenced internal "Phase 3c graduation" + product-source-rename
  coordination (internal authoring history, no operator value)
- openrig-operator: removed openrig-builder, openrig-upgrade, ai-dev-workflows
- queue-handoff: sibling_skills emptied (all entries pointed at
  unshipped control-plane-* / workflow-runtime / attention-queue
  / dispatching-parallel-agents / etc.)
- claude-compaction-restore: removed mental-model-ha, scope-recovery
- agent-startup-and-context-ingestion: removed mental-model-ha, scope-recovery
- seat-continuity-and-handover: removed mental-model-ha, scope-recovery

canonical sibling_skills lists trimmed:
- core/openrig-architect: same trim as plugin mirror (removed builder/
  upgrade/ai-dev-workflows + dropped the two N-internal notes blocks)
- core/rig-lifecycle: replaced 7 unshipped refs (topology-mutation-and-
  seat-management, seat-scaling-and-specialization, cross-host-rig-
  commands, sidecar-operator, rig-bundles-and-shareable-artifacts,
  specification-system, extension-and-user-workspace) with 3 shipped
  peers (openrig-user, openrig-operator, seat-continuity-and-handover)

body content cleanup (See Also + prose):
- plugin/openrig-user: "Use control-plane-capabilities for rigx" +
  "Use openrig-builder when changing OpenRig behavior" → rewritten as
  "not a guide to OpenRig internals; for that, work in the openrig
  product repo"
- plugin/queue-handoff See Also: removed `looping-workflows` entry
  (skill not shipped) — only `rig queue --help` remains
- plugin/openrig-architect: building-agent-software bullet rewritten
  as path-agnostic prose about agent-facing software design
- plugin/openrig-operator: "Use openrig-builder for build lane
  selection" → "Changes to the OpenRig product happen in the openrig
  product repo — operate that as a normal software project"
- plugin/agent-startup-and-context-ingestion: Don't-use-this-when
  bullet about writing-skills-for-openrig rewritten to reference the
  agentskills.io specification directly
- canonical/core/openrig-architect: same body cleanup as plugin mirror
- canonical/core/rig-lifecycle See Also: removed unshipped entries
  (session-source-fork, permission-and-capability-preflight, rig-
  lifecycle-as-primitive-dossier)

VERIFICATION:

HG-6 + HG-7 grep (must return 0 hits):
- /usr/bin/find ... -exec grep -l "<internal-docs>|<work-tree>|internal docs|
  claude-compact-in-place|session-compaction-and-restore|permission-
  posture|openrig-compaction-instructions" ... → 0 hits both plugin
  + canonical trees.

HG-8 grep (body refs to unshipped skill names):
- /usr/bin/find ... -exec grep "\`(mental-model-ha|scope-recovery|
  writing-skills-for-openrig|openrig-builder|openrig-upgrade|...)
  \`" ... → 0 hits both trees.

mirror-skills:check → exit 0 (canonical edits propagated to
skills/_canonical/).

Daemon focused suite (openrig-core-plugin + skill-library-routes +
plugins-routes + plugin-files-routes) → 73/73 PASS. Frontmatter
validity preserved (HG-9).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 00:47:45 -07:00
mvschwarzandClaude Opus 4.7 baacfe808d feat(slice 29 Checkpoint C): canonical-skills doc polish (HG-7 + HG-8)
Adjusts the 4 canonical SKILL.md files that
still contained <internal-docs> / <work-tree> / internal docs / substrate
references after Checkpoints A + B. The full canonical tree
(packages/daemon/specs/agents/shared/skills/) ended up cleaner than
the slice spec estimated — only 4 hits (not ~27) because most
canonical bodies had already been written path-agnostically.

Files touched:

core/openrig-user-settings: workspace-override example commands
rewritten from `/Users/me/code/<work-tree>/...` paths to
generic `/path/to/your/workspace/...` placeholders. Per banked
feedback_test_fixture_example_path_placeholder doctrine.

core/openrig-installer: V0.3.1 migration ceremony rewritten to be
path-agnostic. References to `~/code/substrate/<internal-docs>/rigs/kernel/`
replaced with "wherever you kept your own rigs/kernel/ directory" or
`/path/to/old/kernel-spec` placeholders. Migration steps still work
for both internal substrate-rooted operators and external users with
any non-default kernel location.

core/openrig-architect: source_evidence shortened to the operator-
relevant bootstrap line (was 3 lines including substrate-cross-ref +
import-history). Host-level doctrine paragraph rewritten path-agnostic
(matches the Checkpoint B fix to the plugin-side mirror of this skill).

core/rig-lifecycle: source_evidence one-liner rewritten to drop the
substrate-path translation breadcrumb + specific openrig SHAs.

Mirror skills propagated to skills/_canonical/ (4 files).

VERIFICATION:
- /usr/bin/find … SKILL.md … grep -c "<internal-docs>|<work-tree>|internal docs"
  → 0 hits across both canonical tree AND plugin tree. HG-6 + HG-7
  fully closed.
- Daemon focused suite: 73/73 green (openrig-core-plugin +
  skill-library-routes + plugin-files-routes + plugins-routes).
- Daemon full suite at baseline: 3600 pass + 1 skipped + 2 pre-existing
  fails (precompact-hook HG-7 OPENRIG_HOME + restore-check hook-cwd;
  verified pre-existing on pristine main pre-slice-29).
- All adjusted skills preserve frontmatter validity (HG-9 — skill
  discovery validation tests green).

HG-8 (See Also operator-reachable) now closed end-to-end:
- Plugin side (Checkpoint B): 8 files; broken refs to deleted skills
  (claude-compact-in-place / session-compaction-and-restore /
  permission-posture) replaced with peer-skill or rig-command pointers.
- Canonical side (this checkpoint): 4 files; substrate-path
  references in See Also lists replaced with path-agnostic prose.

Following: velocity-qa HG-12 VM cumulative walk pending guard
ADVISORY-CLEAN on Checkpoint C.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 00:35:42 -07:00
mvschwarzandClaude Opus 4.7 e522d959e9 feat(slice 29 Checkpoint B): plugin-skills doc polish (HG-6 + partial HG-8)
Adjusts the 8 plugin SKILL.md bodies that
slice 29 spec called out (per founder-walk cumulative review):
queue-handoff + openrig-architect + openrig-user + claude-compaction-restore
+ openrig-operator + agent-startup-and-context-ingestion +
forming-an-openrig-mental-model + seat-continuity-and-handover.

Patterns adjusted (per HG-6 / HG-8):

(a) Internal authoring breadcrumbs (frontmatter source_evidence):
    "Translated from <work-tree>/primitives/..." /
    "Synced 2026-05-04 from substrate/<internal-docs>/..." /
    "Graduated 2026-05-04 from <work-tree>/skills/workspace/..." /
    "Originally imported from openrig/packages/daemon/..."
    → kept only what helps a public operator (e.g. "Bootstrap skill —
    NPM install lands this in ~/.claude/skills/").

(b) sibling_skills entries pointing to slice 29 T1 deletions:
    "claude-compact-in-place", "session-compaction-and-restore",
    "permission-posture" → removed everywhere they appeared as soft
    cross-references (HG-8 broken-See-Also fix for plugin side).

(c) Body prose references to internal substrate paths:
    "<work-tree>/primitives/..." /
    "<work-tree>/field-notes/..." /
    "substrate/<internal-docs>/HOST-TOPOLOGY.md" /
    "<work-tree>/skills/FACTORY-DESIGN.md" /
    "substrate/<internal-docs>/rigs/skill-library/agents/shared/skills/" /
    "substrate/<internal-docs>/SUBSTRATE-CONVENTIONS.md" /
    "internal docs" mentions
    → rewritten path-agnostically. E.g., "If your team maintains a
    host-level topology doc..." instead of naming a specific filesystem
    path. Operators reading these don't have substrate at their host.

(d) Tables / lists naming substrate-specific paths replaced with
    cross-runtime defaults (openrig-core plugin skills + ~/.openrig/skills/)
    or removed where the reference was purely internal.

Detail:
- agent-startup-and-context-ingestion: source_evidence shortened;
  sibling_skills trimmed (4 deleted-skill entries + 4 internal-skill
  entries removed); "session-compaction-and-restore" in Don't-use
  section pointed to claude-compaction-restore instead; See Also list
  trimmed to operator-reachable peers.
- claude-compaction-restore: source_evidence + transfer_test_notes
  collapsed to the operator-relevant single line ("Load-bearing for any
  OpenRig seat that may compact; PreCompact hook is wired"); sibling_skills
  trimmed.
- forming-an-openrig-mental-model: skill-paths table replaced
  substrate authoring-workspace row with openrig-core plugin row +
  ~/.openrig/skills/ runtime row; canonical references table dropped
  substrate doctrine rows; misorientations table rewritten as path-
  agnostic.
- openrig-architect: source_evidence shortened; host-level doctrine
  paragraph rewritten as "if your team maintains a host-level
  topology doc...".
- openrig-operator: source_evidence shortened; Durable Write Escalation
  rewritten to point at operator-reachable skill folders + product
  repo (instead of <work-tree>/field-notes/lab/missions paths).
- openrig-user: source_evidence shortened to single bootstrap line.
- queue-handoff: source_evidence shortened; sibling_skills trimmed
  (intake-routing removed since that skill is deleted); See Also list
  swapped substrate-path entries for "rig queue --help".
- seat-continuity-and-handover: source_evidence shortened; sibling_skills
  trimmed; See Also rewritten to peer-skill list (no substrate field-note).

VERIFICATION:
- /usr/bin/grep -c -E "<internal-docs>|<work-tree>|internal docs|claude-compact-in-place|session-compaction-and-restore|permission-posture|openrig-compaction-instructions" → 0 hits across all 8 files (HG-6).
- Daemon openrig-core-plugin.test.ts: 23/23 PASS (frontmatter validity
  preserved per HG-9; the 'ships exactly 8 skills' assertion holds).

HG-7 (canonical-skills doc polish) lands in Checkpoint C.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 00:31:55 -07:00
mvschwarzandClaude Opus 4.7 543621fea3 feat(slice 29 Checkpoint A): T1 deletions + agent-spec edits + UI category-folder tree + skill file-path display
Per OPT-Z1 orch authorization (qitem-20260513070935-97823b4a). Closes
HG-1 + HG-3 + HG-4 + HG-5 + HG-9 + HG-11 of slice 29.

DELETIONS (10 directories):

packages/daemon/specs/agents/shared/skills/ (mirror auto-propagates via npm run mirror-skills):
- claude-compact-in-place/ — was a spec, not an operator skill
- rig-architect/ — redundant with core/openrig-architect
- process/containerized-e2e/
- process/control-plane-queue/
- process/intake-routing/
- process/local-sysadmin/

packages/daemon/assets/plugins/openrig-core/skills/:
- claude-compact-in-place/
- session-compaction-and-restore/ — replaced by claude-compaction-restore
- permission-posture/ — internal-only doctrine
- openrig-compaction-instructions/ — empty leftover from slice 27

AGENT-SPEC EDITS (R2 scope expansion per OPT-Z1):

packages/daemon/specs/agents/shared/agent.yaml:
- Removed 6 skill declarations: claude-compact-in-place,
  containerized-e2e, control-plane-queue, intake-routing,
  local-sysadmin, rig-architect (top-level dir)

packages/daemon/specs/rigs/launch/kernel/agents/queue/worker/:
- agent.yaml: skills list trimmed (control-plane-queue,
  intake-routing removed)
- guidance/role.md + startup/context.md: prose refs adjusted

packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/agent.yaml:
- local-sysadmin removed from skills list

packages/daemon/specs/agents/development/qa/guidance/role.md:
- containerized-e2e prose ref removed

Surviving canonical pod skills with cross-references to deleted skills:
- pods/development-team/SKILL.md: containerized-e2e mention removed
- pods/orchestration-team/SKILL.md: 2 claude-compact-in-place mentions
  rewritten to be path-agnostic (preserve marshal-acceptance doctrine
  without dangling skill-link)

Plugin-side soft cross-references (claude-compaction-restore +
agent-startup-and-context-ingestion + seat-continuity-and-handover
reference deleted skills) remain for Checkpoint B doc polish.

UI CHANGES:

packages/daemon/src/domain/skill-library-discovery.ts:
- LibrarySkillPublic now surfaces absolutePath (HG-4). Type alias
  simplified to LibrarySkill (was Omit-based). listLibrarySkillsPublic()
  returns the full shape directly.

packages/ui/src/hooks/useLibrarySkills.ts:
- LibrarySkillEntry adds `absolutePath: string` field.

packages/ui/src/components/specs/SkillDetailPage.tsx:
- Header chrome surfaces skill.absolutePath as a font-mono row beneath
  the source badge (HG-4). Operators see exactly where each skill lives
  on disk (bundle / plugin / workspace).

packages/ui/src/components/specs/SpecsTreeView.tsx:
- Skills section restructured: category-folder grouping (HG-3).
  Top-level entries: category folders ("core", "pm", "pods",
  "process", "workspace", or "(uncategorized)") extracted from the
  skill id's path segment.
- Each category expands to a list of skill rows. Skill rows are
  SINGLE-ROW (NOT folder-expandable into files). File drill-down
  removed from the tree per HG-3 ("that's the detail page's job").
- New SkillsTree component encapsulates the category-group + skill-row
  pattern; replaces the prior per-skill expand-into-files logic.
- Plugin tree rows remain expandable (HG-5; intentional asymmetry —
  plugins legitimately contain N skills; canonical skills don't).
- Removed unused librarySkillFileToken import.

TESTS UPDATED:

packages/daemon/test/openrig-core-plugin.test.ts:
- EXPECTED_SKILLS reduced from 11 to 8 (claude-compact-in-place +
  session-compaction-and-restore + permission-posture deleted).
  "ships exactly the N expected" updated to 8.

packages/daemon/test/spec-library-starters.test.ts:
- expectedSharedSkills list trimmed (5 deleted skill names removed).
- Removed the claude-compact-in-place SKILL.md content assertion
  block (skill no longer ships).

packages/daemon/test/skill-library-routes.test.ts:
- absolutePath assertions inverted (was "stripped"; now "included");
  HG-4 surfacing test added.

packages/ui/test/library-page.test.tsx:
- "auto-expands the skills explorer" updated for new category-folder
  shape: asserts skills-category-workspace testid + specs-leaf-<id>
  (no longer asserts specs-skill-file-SKILL.md since file drill-in
  removed from the tree).

VERIFICATION:
- Daemon focused tests: 49 plugin-related + 20 skill-library = 69 green;
  full daemon suite at baseline (3600 pass + 1 skipped + 2 pre-existing
  fails — precompact-hook HG-7 OPENRIG_HOME + restore-check hook-cwd).
- UI focused tests: 47 across 5 slice-28+29 test files (12 specs-tree +
  11 index + 13 detail + 4 plugin-detail + 5 library + 2 minor); full
  UI suite at baseline (823 pass + 1 skipped + 52 pre-existing).
- Typecheck clean on touched files (UI tsc still has pre-existing
  @tanstack/react-table errors in untouched SpecsTable + TopologyTableView).
- Mirror script ran successfully; skills/_canonical/ propagated.

FOLLOW-ONS:
- Checkpoint B: plugin-side SKILL.md doc polish (8 files including
  the soft cross-references to deleted skills)
- Checkpoint C: canonical SKILL.md doc polish (~25 files)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-13 00:23:32 -07:00
mvschwarz 1a6cc52c57 docs(changelog): author 0.3.1 release notes
Replaces the in-flight draft (slice 08 config-validation only) with
comprehensive operator-facing release notes covering:

- Claude auto-compaction policy (headline feature; 7 ConfigStore keys
  + 6-stage daemon-to-LLM lifecycle + PreCompact hook + bridge)
- Library Explorer finishing (daemon-owned skill/plugin library
  discovery + docs-browser detail pages + rolled-up index pages)
- Plugin primitive v0 (vendored discovery + manual copy install
  workflow; rig plugin install verb deferred to 0.3.2)
- Settings destination Explorer (4 sub-routes)
- CMUX launcher (Launch in CMUX button on rig-scope)
- Node-page Overview + Details (seat overview table + tab
  consolidation + alert-only banner)
- Mobile drawer fixes (Settings/Project/Library/For-You above
  rail-tray; Topology drawer hidden in 0.3.1)
- SC-29 exceptions ledger: #10 (slice 24 cmux) + #10 (slice 27
  compaction; numbering collision documented) + #11 (slice 28
  library API)
- Known carry-forwards (0.3.2 candidates): plugin install verb,
  topology mobile, source-label taxonomy, SC29-LEDGER.md, env-dep
  tests
- Banked discipline patterns: channel model, turn-boundary
  handshake, save-game-before-quit, structured-output completeness,
  daemon-owned shared-resource discovery

Status flipped from "in-flight" to "release candidate" pending
lifecycle-rig staging + host upgrade approval.
2026-05-12 22:50:47 -07:00
mvschwarz 1b1d09bdfb merge(0.3.1): slice 28 Library Explorer finishing
Finishes slice 18 — removes top SKILLS/PLUGINS duplicates in
SpecsTreeView; bottom rows do dual-action (navigate + expand);
reorders Plugins above Skills; fixes OpenRig-managed skills
discovery (now 32 skills visible); refactors plugin + skill detail
pages to real file-browser docs-browser (uses new daemon endpoints
against actual plugin/skill source folders).

7-commit checkpoint chain:
- A: tree cleanup (HG-1..4)
- B: skills discovery recursion into category folders
- C: docs-browser detail pages first attempt (held)
- C-1: daemon plugin files endpoints + PluginEntry.skillCount
- C-2: UI rewire for real plugin/skill file browsing (HG-6..8, HG-10)
- C-3: daemon skill-library API (closes HG-5 VM root cause; same
  class as plugins — daemon owns shared-skills discovery)
- C-4: UI migrates useLibrarySkills + SkillDetailPage to skill-library API

Three-gate clean: driver complete + velocity-guard ADVISORY-CLEAN +
velocity-qa CLEAR HG-1..12 VM dogfood (32 skills visible; plugin
docs-browser with 11/14/45 skill counts; folder navigation works;
non-markdown file content renders).

SC-29 EXCEPTION #11: 5 new daemon endpoints (plugin files: list/read;
skill library: index/list/read) + 2 response shape additions
(PluginEntry.skillCount; LibrarySkillPublic).

Banked patterns applied across the cycle:
- feedback_external_rpc_contract_drift_verify_live (mocked tests
  passed; live VM diverged for both plugins + skills — same class)
- feedback_finalization_means_complete_specced_not_minimum_viable
  (founder direction: "finishing it is 0.3.1 work, not 0.3.2")
- feedback_audit_every_layer_function_and_module_constants
  (daemon-side path resolution does not depend on workspace-cwd)
2026-05-12 22:24:15 -07:00
mvschwarzandClaude Opus 4.7 64a3593359 feat(ui): slice 28 Checkpoint C-4 — migrate useLibrarySkills + SkillDetailPage to daemon skill-library API
Builds on C-3 daemon endpoints (11c2162) to close HG-5 (and the
cascading HG-7 / HG-8-skill-side / HG-9) on the founder-walk VM:

useLibrarySkills:
- Replaces the multi-fetch probe (each allowlist root × 3 candidate
  paths + recursive walker) with a single fetch to /api/skills/library.
  Daemon-owned discovery handles shared-skills resolution via its own
  install path, independent of operator allowlist configuration.
- LibrarySkillEntry shape consolidates to id + name + source + files.
  Drops root + directoryPath (no longer needed; daemon owns the
  abstraction). LibrarySkillFile.path is now relative-to-skill-root
  (e.g., "SKILL.md" or "examples/basic.md") instead of absolute-from-
  allowlist.

useSkillFiles (NEW):
- Wraps /api/skills/:id/files/{list,read} symmetric with usePluginFiles
  (slice 28 C-1). 15s staleTime; enabled-gating on skillId + path.

SkillDetailPage:
- Migrates from /api/files/list + /api/files/read (workspace-allowlist-
  based) to useSkillFiles (daemon-owned). All-files tree (HG-7) +
  subfolder navigation (HG-8) preserved.
- fileToken resolution: requestedRelPath is already relative-to-skill-
  root post-C4 (token decodes directly without prefix stripping). useEffect
  pattern preserved for async data resolution.
- SKILL.md auto-select default (no fileToken case) preserved.
- Header strip + breadcrumbs + viewer panel rendering unchanged from C-2.

Test updates:
- DELETED packages/ui/test/library-skills-discovery.test.tsx (6 tests).
  Recursion logic moved daemon-side; covered by
  packages/daemon/test/skill-library-routes.test.ts (8 discovery tests
  with deeper coverage including workspace + consolidation + depth-cap +
  absent shared-dir + public-shape).
- specs-tree-view-sidebar.test.tsx: mocks /api/skills/library instead of
  /api/files/list probes; plugin fixture adds skillCount field.
- slice-28-index-pages.test.tsx: mockSkillsFetch consumes
  /api/skills/library; skill IDs follow new format
  (openrig-managed:<name>).
- slice-28-detail-pages.test.tsx: skill mocks use /api/skills/:id/files/
  endpoints; skill ID format updated to openrig-managed:core/openrig-user.
- library-page.test.tsx: all 4 skill-related tests migrated to
  /api/skills/library mock shape + new ID format. Plugin test (5th)
  unchanged.

Cumulative slice 28 tests: 47 UI focused tests across 5 files (was 53
across 6 with C-shell; -6 from deleted discovery test; rest preserved/
adapted). Daemon-side has 20 skill-library tests + 18 plugin-files
tests + 31 pre-existing plugin tests. Full UI suite at baseline: 823
pass + 1 skipped + 52 pre-existing fails.

Per banked feedback_external_rpc_contract_drift_verify_live: the C-3
+ C-4 architecture removes the class of failure where mocked tests
pass but live VM diverges — daemon owns canonical discovery; UI mocks
the same /api/skills/library endpoint the live daemon serves.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 22:14:36 -07:00
mvschwarzandClaude Opus 4.7 11c2162862 feat(daemon): slice 28 Checkpoint C-3 — skill-library API (closes HG-5 root cause on VM)
QA BLOCKING verdict (qitem-20260513045711-39ccfdf3) on slice 28 C-2
diagnosed HG-5 not operationally closed on founder-walk VM: the
shared-skills tree on disk at /Users/admin/code/openrig/packages/
daemon/specs/agents/shared/skills (32 SKILL.md files) is NOT under
the operator's OPENRIG_FILES_ALLOWLIST workspace root. Therefore the
prior useLibrarySkills 3-path probe over /api/files/list could not
reach it, and /specs/skills showed 'No skills visible'.

Orch authorized OPT-Y2 (qitem-20260513042155-f31c11c3-followon... actually
qitem-20260513045940-b215d1d2 routing decision): new daemon skill-
library API symmetric with the plugin pattern shipped in C-1
(c44a69d). Daemon owns shared-skills discovery via its own install
location; UI consumes a single canonical endpoint.

SC-29 EXCEPTION #11 cumulative scope (verbatim block at
packages/daemon/src/routes/plugins.ts header references this skill
extension):
  Slice 28 daemon surface =
    3 plugin file endpoints (C-1) + PluginEntry.skillCount + 3 skill
    endpoints (this commit) + new LibrarySkill shape.

DAEMON CHANGES:

packages/daemon/src/domain/skill-library-discovery.ts (NEW):
- SkillLibraryDiscoveryService with listLibrarySkills / getSkill /
  listLibrarySkillsPublic methods.
- Discovery sources: sharedSkillsDir (daemon install-relative,
  resolved by startup via import.meta.url) + workspace allowlist
  roots probing `.openrig/skills/`.
- Recursive walker (MAX_NESTING_DEPTH=1) handles flat + category-
  nested skill layouts (mirrors the original slice 28.B fix shape;
  now executed daemon-side instead of via UI multi-fetch).
- Public response shape strips absolutePath; routes use it
  internally for the per-skill file endpoints.

packages/daemon/src/routes/skills.ts (NEW):
- GET /library                          → LibrarySkillPublic[]
- GET /:id/files/list?path=<rel>        → file listing (dir-then-file
                                          sort; all file types)
- GET /:id/files/read?path=<rel>        → content + FILE_READ_TRUNCATION_BYTES
                                          cap from files.ts
- skillRootAllowlist helper builds synthetic single-root allowlist
  scoped to skill.absolutePath; same pattern as pluginRootAllowlist
  in plugins.ts (C-1). fs.realpathSync normalization handles
  macOS /var/folders → /private/var/folders symlink chain.
- pathSafetyErrorResponse mirror of files.ts error→status mapping.

packages/daemon/src/server.ts:
- AppDeps adds optional skillLibraryDiscoveryService.
- Context-injection in route-middleware.
- /api/skills router mounted alongside /api/plugins.

packages/daemon/src/startup.ts:
- SkillLibraryDiscoveryService constructed AFTER decodeAllowlist so
  filesAllowlist is passed in. sharedSkillsDir resolves to
  `<daemon-src>/specs/agents/shared/skills` via import.meta.url
  pattern matching bootKernelIfNeeded specsDir resolution.

TESTS:

packages/daemon/test/skill-library-routes.test.ts (NEW; 20 tests):
- Discovery (8): flat depth-1 + nested depth-2 HG-5 fix + mixed
  layout + depth-cap (MAX_NESTING_DEPTH=1) + workspace + consolidation
  (workspace + managed) + absent shared dir + public shape strips
  absolutePath.
- GET /library (2): consolidated list response + 503 unprovisioned.
- GET /:id/files/list (5): root + nested + 404 unknown + .. escape
  + symlink escape.
- GET /:id/files/read (5): SKILL.md + nested file + path_required +
  404 stat_failed + .. escape.

Pre-existing test failures (orthogonal to slice 28):
- test/precompact-hook.test.ts HG-7 OPENRIG_HOME expansion
- test/restore-check-routes.test.ts hook-cwd test — verified failing
  on pristine main via checkout-and-test cycle (host's
  ~/.claude/settings.json is empty / malformed; not caused by C-3
  per banked feedback_verify_release_state_against_runtime_not_
  breadcrumb).

Daemon full suite: 3603 pass + 1 skipped + 2 pre-existing fails (was
3584/1/1 at C-1; +19 passes from C-3 tests; +1 fail count
re-verified pre-existing via on-main checkout test).

Following C-4 UI migration: useLibrarySkills + SkillDetailPage
migrate to consume the new endpoints (single fetch instead of
3-path probe; per-skill file endpoints instead of /api/files/).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 22:08:37 -07:00
mvschwarzandClaude Opus 4.7 1c605f9109 feat(ui): slice 28 Checkpoint C-2 — UI rewire for real plugin/skill file browsing (HG-6 + HG-7 + HG-8 + HG-10 full)
Builds on C-1 daemon endpoints (c44a69d) to close all 4 BLOCKING
spec-aligned gaps the velocity-guard verdict identified
(qitem-20260513042015-d15bd90d):

HG-6 — PluginDetailPage real file-browser
  Replaces the C-shell virtual-structured-data tree with a real
  file-browser tree backed by the new /api/plugins/:id/files/list +
  /read endpoints (usePluginFiles hook). Operator can browse the
  plugin's on-disk folder structure: manifest files
  (.claude-plugin/plugin.json, .codex-plugin/plugin.json), skills/
  subfolders with their SKILL.md content, hooks/{claude,codex}.json,
  README.md, LICENSE, etc. README.md auto-selected at the plugin
  root on entry. Markdown renders via MarkdownViewer; JSON/YAML/etc
  via SyntaxHighlight; other files as plain-text. Header strip
  preserves structured metadata (name + version + runtimes + source
  + skill-count + used-by-count).

HG-7 — SkillDetailPage all-files tree
  Replaces the markdown-filtered file list (useLibrarySkills.files)
  with a real in-page directory tree using /api/files/list directly.
  Now shows ALL skill folder contents (config.json, fixture.yaml,
  examples/ subdirs, etc), not just .md/.mdx. SKILL.md auto-selected
  at skill root on entry; fileToken deep-links land operator on the
  right folder.

HG-8 — Folder navigation
  Both pages support subfolder entry + `..` back-navigation:
  clicking a directory entry sets currentPath; the tree fetches the
  subfolder listing. Skill folder subdirs (examples/, fixtures/,
  etc) navigable; plugin folder subdirs (.claude-plugin/, skills/,
  hooks/) navigable down through arbitrary nesting.

HG-10 — PluginsIndexPage skill-count column
  Each plugin row now renders skill-count from the new
  PluginEntry.skillCount field (populated by daemon detectPlugin
  via readdir of <plugin>/skills/). Pluralizes correctly (1 skill /
  N skills). Column ordered: version → runtimes → skill-count →
  source.

UI CHANGES:

NEW:
- packages/ui/src/hooks/usePluginFiles.ts — usePluginFilesList +
  usePluginFilesRead, react-query wrappers over the new daemon
  endpoints. Same shape as useFilesList/useFilesRead (15s staleTime).

MODIFIED:
- packages/ui/src/hooks/usePlugins.ts — PluginEntry extends with
  skillCount: number field. Comment cross-references SC-29 #11.
- packages/ui/src/components/specs/PluginDetailPage.tsx — full
  rewrite to real file-browser pattern. Tree state currentPath +
  selectedFile; defaultPicked flag handles auto-select on first
  load (README.md or first markdown). Breadcrumbs navigate up the
  plugin path. PluginFileContent renders markdown/code/text per
  extension. Truncation marker displayed when daemon caps content
  at 1 MB.
- packages/ui/src/components/specs/SkillDetailPage.tsx — full
  rewrite paralleling PluginDetailPage. Uses existing /api/files/
  surface (skill folder lives under operator allowlist roots).
  fileToken deep-link resolution via useEffect (data is async; init
  cannot read it on first render).
- packages/ui/src/components/specs/PluginsIndexPage.tsx — adds
  skill-count column between runtimes and source.

TESTS:

UPDATED:
- packages/ui/test/slice-28-detail-pages.test.tsx — rewritten for
  the real-file-tree shape. Mocks /api/files/list + /read for skill
  side; /api/plugins/:id/files/list + /read for plugin side. Covers
  ALL-files tree (HG-7 discriminator: non-markdown files like
  config.json + fixture.yaml visible), SKILL.md/README.md auto-
  select, subfolder navigation (HG-8: enter + '..' back), file
  content rendering. 13 tests.
- packages/ui/test/plugin-detail-page.test.tsx — loading + not-
  found preserved. Content tests rewritten for real-file-tree.
  Empty-folder graceful rendering. Folder navigation regression.
  5 tests.
- packages/ui/test/slice-28-index-pages.test.tsx — adds HG-10
  skill-count column assertion + pluralization regression
  (1 skill / 0 skills). Existing rows-rendering tests adapted to
  include skillCount in fixture data.

Cumulative slice 28 focused tests: 53 across 6 files (was 52 with
C-shell; rewrites + skill-count tests net +1). Full UI suite at
baseline: 829 pass + 1 skipped + 52 pre-existing AppShell global-
polls fails (unchanged).

Daemon work landed in C-1 (c44a69d); this completes Checkpoint C
end-to-end. All 4 guard-BLOCKING blockers closed:
- HG-6 PluginDetailPage → real file browser (was: virtual SHELL)
- HG-7 SkillDetailPage → all files (was: .md/.mdx only)
- HG-8 folder navigation → subfolder + `..` (was: flat list)
- HG-10 PluginsIndexPage → skill-count column (was: omitted)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 21:46:06 -07:00
mvschwarzandClaude Opus 4.7 c44a69dfc2 feat(daemon): slice 28 Checkpoint C-1 — plugin files endpoints + PluginEntry.skillCount enrichment
Orch authorized OPT-A (qitem-20260513042155-f31c11c3): full
implementation of plugin/skill docs-browser semantics. Crosses the
original 'fleet — UI source changes' posture into daemon source for
the bounded plugin file-listing surface that guard's BLOCKING-CONCERN
identified as required to satisfy HG-6/HG-10 per the slice spec.

SC-29 EXCEPTION #11 declared (per banked inline-ledger discipline;
verbatim block at packages/daemon/src/routes/plugins.ts header):
  (a) two additive read-only HTTP routes:
      GET /api/plugins/:id/files/list?path=<rel>
      GET /api/plugins/:id/files/read?path=<rel>
  (b) one PluginEntry shape extension:
      skillCount: number (subdirectory count under <plugin>/skills/)

DAEMON CHANGES:

packages/daemon/src/domain/plugin-discovery-service.ts:
- PluginEntry adds `skillCount: number` field.
- detectPlugin() counts subdirectories under <plugin>/skills/ at
  detection time (one readdir; same enumeration as getPlugin's
  PluginSkillSummary[] collection so list-view + detail-view match).
- Comment block on the new field references the SC-29 #11 inline
  ledger for auditability.

packages/daemon/src/routes/plugins.ts:
- Two new routes mounted BEFORE the bare /:id catchall (matching the
  /:id/used-by route-order discipline from Phase A R1 banked SSE
  lesson; route-order test added to plugin-files-routes.test.ts).
- pluginRootAllowlist(absolutePath) helper: builds a synthetic
  single-root AllowlistRoot scoped to the discovered plugin's
  absolute path. Normalizes via fs.realpathSync (matches
  decodeAllowlist semantics so containment checks hold on macOS
  /var/folders → /private/var/folders symlink chains).
- pathSafetyErrorResponse helper: copies the error→status mapping
  from files.ts (could be extracted as a shared util in a follow-up
  refactor; inline for now to avoid layering changes).
- /files/list reuses resolveAllowedDirectory + same response shape as
  /api/files/list (entries with name/type/size/mtime + dir-before-file
  sort).
- /files/read reuses resolveAllowedFile + FILE_READ_TRUNCATION_BYTES
  cap from files.ts. ContentHash via existing sha256Hex helper.
  Plugin folders are read-only at v0 so the hash is informational.

TESTS:

packages/daemon/test/plugin-files-routes.test.ts (NEW; 18 tests):
- PluginEntry.skillCount enrichment (4 tests): zero-count, N-count,
  detail-endpoint parity, stray-file ignore (only subdirs counted).
- /files/list (6 tests): root listing + nested listing + 404 unknown
  plugin + `..` escape rejection + absolute-path rejection + symlink
  escape rejection (realpath outside plugin folder).
- /files/read (6 tests): README.md + nested SKILL.md + path_required
  + 404 unknown plugin + 404 stat_failed + `..` escape rejection.
- Route-order discipline (2 tests): /files/list + /files/read do NOT
  get caught by the bare /:id catchall (discriminator: response shape
  includes `entries`/`content` not `entry`/`skills`).

Pre-existing daemon tests (plugins-routes.test.ts + plugin-discovery-
service.test.ts) continue to pass with the added skillCount field;
no breaking changes to existing assertions.

Daemon full suite: 3584 pass + 1 skipped + 1 pre-existing fail
(test/precompact-hook.test.ts HG-7 OPENRIG_HOME expansion — verified
identical pre/post via stash-and-recheck; orthogonal to slice 28).

Following Checkpoint C-2: UI rewire — usePluginFiles hook +
PluginDetailPage real file-browser tree + SkillDetailPage all-files +
PluginsIndexPage skill-count column.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 21:28:43 -07:00
mvschwarzandClaude Opus 4.7 ea8994b05a feat(ui): slice 28 Checkpoint C — docs-browser detail pages + rolled-up index rows (HG-6 + HG-7 + HG-8 + HG-9 + HG-10)
Five-target Checkpoint C closes the operator-facing UX gap founder
flagged in the slice 18 follow-up walk:

HG-9 — SkillsIndexPage rolled-up flat rows
  Previously grouped skills by source via LibraryTopLevelEntry (sparse
  user-defined bucket; openrig-managed bucket). Now: a single flat
  list, name + source label + file-count + entry link per row. Sorted
  alphabetically. Empty state preserved.

HG-10 — PluginsIndexPage rolled-up flat rows
  Previously grouped plugins by source (vendored / claude-cache /
  codex-cache). Now: flat list, name + version + runtime badges +
  source-label + entry link per row. Skill-count column is structurally
  available via /api/plugins/:id (PluginDetail.skills.length) but the
  /api/plugins list endpoint returns summaries without skill-count.
  Driver decision: omit from v0 to keep slice UI-only; flag as small
  daemon-API enrichment in the handoff if founder wants it surfaced.

HG-7 — SkillDetailPage docs-browser
  Previously rendered only the FileViewer for the selected file; tree
  was external (rail SpecsTreeView). Now: in-page two-pane shell.
    aside `skill-detail-tree` — list of skill files; SKILL.md auto-
      selected; clicking another file navigates via fileToken route.
    main `skill-detail-viewer` — existing FileViewer for selected file.
  Layout responsive (stacks vertically on mobile).

HG-6 — PluginDetailPage docs-browser shell
  Previously rendered a grid of section cards (Manifest / Skills /
  Hooks / MCP / Used-by). Now: two-pane docs-browser shell with virtual
  tree (built from PluginDetail's structured data — no daemon changes
  required) + right viewer panels per selection:
    manifest             — manifest fields panel (claude + codex)
    skills/ root         — skills count + roll-up list
    skill:<name>         — per-skill metadata + open-library link
    hooks/ root          — hooks count summary
    hook:<runtime>       — declared events panel
    mcp servers root     — only renders when MCP servers declared
    mcp:<runtime>:<name> — server config (command + transport)
    used-by              — agent reference list
  Tree state: default-selected = manifest; clicking a root expands the
  branch + switches the viewer. Selection drives both the active row
  highlight and the viewer panel choice.

HG-8 — Folder navigation
  Plugin docs-browser tree supports expanding skills/ + hooks/ + mcp
  roots to reveal child entries, switching the viewer accordingly.
  Skill docs-browser is flat (skill folders typically contain SKILL.md
  + a few sibling .md files; useLibrarySkills returns these as a flat
  files array).

Removed:
  packages/ui/src/components/specs/LibraryTopLevelEntry.tsx — the
  grouped-folder primitive used only by SkillsIndexPage + PluginsIndex
  Page. With the slice 28 founder direction toward flat rows the
  primitive is unused; deleting per the no-dead-code rule.
  packages/ui/test/library-top-level-entry.test.tsx — test for the
  deleted primitive.

Tests:
  test/slice-28-index-pages.test.tsx (NEW; 11 tests):
    - SkillsIndexPage: row testids + Link href + source + filecount
      columns + discriminator (legacy `library-top-level-skills` /
      `library-folder-<source>` ABSENT) + empty state.
    - PluginsIndexPage: row testids + Link href + version + runtimes +
      source columns + discriminator + empty state + count badge.

  test/slice-28-detail-pages.test.tsx (NEW; 14 tests):
    - SkillDetailPage: docs-browser shell rendering + tree per file +
      SKILL.md auto-selection + non-default file navigation + viewer
      content.
    - PluginDetailPage: docs-browser shell + tree contents + default
      manifest selection + folder-navigation expand-on-select (skills
      / hooks / mcp roots → child entries → child view) + MCP gating
      (only renders when servers declared) + grid-section discriminator
      (pre-slice-28 testids ABSENT).

  test/plugin-detail-page.test.tsx (UPDATED):
    - Loading + not-found tests preserved.
    - Manifest + skills + hooks + MCP + used-by visibility tests
      rewritten for the docs-browser shape (tree expand + view switch).
    - Empty-state tests rewritten for the new tree (0-counts + empty
      copy via root selection).

Cumulative slice 28 test layers (all passing): 12 specs-tree-view +
6 library-skills-discovery + 11 slice-28-index-pages + 14 slice-28-
detail-pages + 4 plugin-detail-page + 5 library-page = 52 focused
tests. Full UI suite baseline preserved (828 pass + 1 skipped + 52
pre-existing AppShell global-polls fails).

SCOPE-QUESTION SURFACED IN HANDOFF (not closed in this checkpoint):

Founder spec §PluginDetailPage refactor wants "browse the plugin
folder structure" — true file-content browsing of the plugin's
on-disk folder (manifest files + READMEs + skills/ subfolders). This
requires either:
  (a) Operator-configured allowlist root covering ~/.openrig/plugins/
      via OPENRIG_FILES_ALLOWLIST (existing daemon mechanism); OR
  (b) New daemon endpoints `/api/plugins/:id/files/list` + `/read`
      that wrap file-allowlist machinery scoped to the plugin's
      absolute path. ~50-80 lines daemon-side.
The HG-6 docs-browser SHELL ships in this checkpoint (uses structured
PluginDetail data; no daemon changes). True file-content browsing of
plugin path is recommended for orch routing — either (a) as operator
guidance, or (b) as a small daemon delta in this slice or 0.3.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 21:17:40 -07:00
mvschwarzandClaude Opus 4.7 62b4fdd1ad feat(ui): slice 28 Checkpoint B — skills discovery recurses into category folders (HG-5)
useLibrarySkills pre-slice-28 listed each candidate skills base path
(.openrig/skills + packages/daemon/specs/agents/shared/skills +
node_modules/@openrig/daemon/specs/agents/shared/skills), treated
every top-level dir under each as a candidate skill folder, and
dropped any dir that did not have markdown files at its immediate
level. That worked for flat layouts like
`packages/daemon/specs/agents/shared/skills/claude-compact-in-place/SKILL.md`
but missed every NESTED skill (e.g.
`packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md`).
`core/` had no .md files at its immediate level, so the entire `core/`
category was skipped — and so were the 6 skills inside it. Same for
pm/ (7 skills), pods/ (3 skills), process/ (12 skills), rig-architect/
etc. On the VM operator saw "No skills yet" despite the shared-skills
tree being fully populated.

HG-5 fix: useLibrarySkills now recurses ONE level when a candidate
folder has no markdown but has subdirs. A new pure helper
`collectSkillsFromListing` walks the tree, producing a skill leaf when
a directory contains markdown files, and recursing into the directory
otherwise (when depth < MAX_NESTING_DEPTH). MAX_NESTING_DEPTH=1
matches the observed-on-disk structure (categories one level deep);
prevents pathological runaway on unexpected directory shapes.

Tests: 6 new pure-fetch-mock tests in library-skills-discovery.test.tsx:
- depth-1 flat discovery (claude-compact-in-place baseline)
- depth-2 nested discovery (HG-5 ROOT CAUSE FIX discriminator — pre-fix
  would return []; post-fix returns 2 nested skills)
- mixed layout (flat + nested in the same base path; category folder
  name itself does NOT appear as a skill)
- depth-cap regression (MAX_NESTING_DEPTH=1 prevents depth-3 discovery)
- source-label integrity (nested skill resolves with `openrig-managed`
  source + correct id + directoryPath shape)
- empty base path (returns [])

23/23 green across discovery + library-page + specs-tree files. Full
UI suite at baseline (816 pass + 1 skipped + 52 pre-existing).

The existing library-page.test.tsx flat-fixture test (operator-skill +
openrig-user at depth-1) continues to pass — backward compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 21:04:38 -07:00
mvschwarzandClaude Opus 4.7 360239238c feat(ui): slice 28 Checkpoint A — tree cleanup (HG-1 + HG-2 + HG-3 + HG-4)
Founder-walk feedback flagged 4 issues with the slice 18 Library
Explorer tree shape; this checkpoint closes them:

HG-1 — Remove the top-level `> SKILLS` and `> PLUGINS` Links that sat
above the grouped tree as duplicate sidebar entries. The grouped
sections below already carry these entries; the duplicates were
confusing.

HG-2 — SKILLS section label becomes dual-action: clicking the label
navigates to /specs/skills AND expands the section subtree below.
Chevron-button on the left stays as expand-only affordance.

HG-3 — PLUGINS section label becomes dual-action: clicking the label
navigates to /specs/plugins AND expands the section subtree below.
Same chevron-button + Link split as SKILLS.

HG-4 — Reorder bottom sections so PLUGINS appears above SKILLS
(skills list will be larger; plugins is user-priority per founder
direction).

Section component refactor: adds optional `navigateTo` + `onNavigate`
props. When `navigateTo` is set (typed as `/specs/skills | /specs/plugins`
for route-type safety), the section header splits into a chevron
toggle-button + a label Link. When unset, the section renders the
legacy single-button full-row toggle (rig-specs, agent-specs, etc.
unaffected).

The inline-special-cased skills branch (which renders nested skill +
file sub-trees) gets the same dual-action treatment inline.

Tests: rewrite specs-tree-view-sidebar.test.tsx for slice 28 contract:
- 2 HG-1 discriminator tests (legacy testids ABSENT — would fail vs
  slice 18 shape)
- 1 HG-4 DOM-order test (compareDocumentPosition asserts plugins
  precedes skills)
- 4 HG-2 SKILLS section tests (link href + collapsed-by-default +
  label-click expands + chevron-click expands)
- 4 HG-3 PLUGINS section tests (parity with HG-2)
- 1 slice 19 density follow-up test preserved unchanged

12 tests in this file (was 7). Full UI suite at baseline (810 pass +
1 skipped + 52 pre-existing AppShell global-polls).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 20:58:00 -07:00
mvschwarz ddb68b80a9 feat(compaction): expose editable prep and audit prompts
Add operator-editable settings for the pre-compaction preparation message and post-restore audit nudge while keeping daemon-owned wrappers for usage/threshold, trust-channel framing, restore markers, and read-depth enforcement.

Make compact_instruction blank by default and keep it as an optional advanced override. The default pre, restore, and audit messages now point at the canonical claude-compaction-restore skill instead of duplicating the whole protocol in settings text.

Update the Policies UI to show all five prompt surfaces: pre-compaction prep, optional compact instruction, post-compaction restore, extra restore file path, and post-restore audit. Keep CLI/daemon config key maps in lockstep and adjust focused tests.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer.test.ts user-settings-store.test.ts user-settings-routes.test.ts precompact-hook.test.ts openrig-core-plugin.test.ts
- npm --workspace packages/cli test -- config-store-extended.test.ts
- npm --workspace packages/ui test -- claude-compaction-policy-form.test.tsx settings-pages.test.tsx
- npm run mirror-skills:check
- npm run lint
- git diff --check
2026-05-12 18:46:40 -07:00
mvschwarz 63fae7c99f fix(compaction): make restore reading unconditional
Harden the Claude restore prompt and shipped restore skill so post-compaction reading cannot be interpreted as optional setup for a future task. Restoration is now framed as the current task, with explicit no-deferral language before required files are read.

Add test coverage for the runtime restore prompt to keep the no-deferral wording and restore-map audit language from drifting.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer.test.ts openrig-core-plugin.test.ts precompact-hook.test.ts
- npm run mirror-skills:check
- git diff --check
2026-05-12 18:24:42 -07:00
mvschwarz c93b16da37 feat(compaction): add pre-compact restore map prompt
Add a pre-compaction user-channel preparation step before /compact so Claude can load the canonical restore skill and write a durable mental-model restore map while it still has full context.

The prep prompt includes current usage and configured threshold, asks for an annotated ASCII file/folder tree of required context, and preserves the restore-map path through the compact and restore flow. Update UI copy, default compact instructions, shipped skill text, and focused tests for the new prep -> compact -> restore -> audit sequence.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer.test.ts user-settings-store.test.ts openrig-core-plugin.test.ts precompact-hook.test.ts
- npm --workspace packages/ui test -- claude-compaction-policy-form.test.tsx
- npm --workspace packages/cli test -- config-store-extended.test.ts
- npm run mirror-skills:check
- npm run lint
- git diff --check
2026-05-12 18:09:35 -07:00
mvschwarz 31712a877d docs(compaction): make restore skill operational
Rewrite claude-compaction-restore as direct instructions for Claude during pre-compaction preparation and post-compaction restoration. Move policy and hook explanation out of the shipped skill surface so the skill stays focused on what the runtime agent should do.
2026-05-12 17:43:20 -07:00
mvschwarz 8a70922aaf fix(compaction): restore via canonical skill and audit prompt
Keep Claude compaction hooks as lifecycle evidence/context, but move the actual restore behavior to normal user-channel prompts. Defaults now point inline restore at claude-compaction-restore and use a user-owned extra instruction file for mission-specific reading lists.

Remove the extra openrig-compaction-instructions skill, add the post-restore read-depth audit prompt, and create the default extra-instructions placeholder on daemon startup.
2026-05-12 17:19:39 -07:00
mvschwarz f2ed240c16 feat(compaction): ship file-backed default instructions
Add the openrig-compaction-instructions skill to the vendored openrig-core
plugin, with compact and restore instruction templates. Default Claude
compaction restore settings now point at the shipped restore file while
leaving inline restore text as an explicit override.

Also teach the PreCompact hook to resolve OPENRIG_HOME-relative instruction
paths and fall back to the shipped file when policy is enabled without custom
restore text.
2026-05-12 16:22:07 -07:00
mvschwarz d7bb7ea2bb fix(compaction): cool down after restore prompt
Prevent Claude auto-compaction from immediately re-arming while the post-compact restore prompt is still being handled. Low-threshold dogfood showed the restore work itself can raise usage back to the test threshold and queue a second /compact before continuity is restored.

Add a post-restore cooldown state and a regression test for the restore-prompt-then-usage-rises path.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer
- npm --workspace packages/daemon test -- claude-compaction-enforcer context-monitor precompact-hook openrig-core-plugin
- npm run lint
- npm --workspace packages/daemon run build
- npm run mirror-skills:check
- git diff --check
2026-05-12 16:02:29 -07:00
mvschwarz ba64498b56 fix(compaction): insert post-compact turn boundary
Live founder-walk testing showed the restore request still landed too adjacent
to the /compact local-command output, so Claude classified it as part of that
same trust boundary even when the text identified itself as a normal user
message.

Change the post-compact state machine from one prompt to two polling ticks:
- first below-threshold tick sends a minimal turn-boundary handshake
- later below-threshold tick sends the actual restore prompt

The handshake is intentionally non-restorative. Its job is to create an
assistant turn between /compact stdout and the restore request, matching the
trust model observed in the live pane.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer
- npm --workspace packages/daemon test -- claude-compaction-enforcer context-monitor precompact-hook openrig-core-plugin
- npm run lint
- npm --workspace packages/daemon run build
- npm run mirror-skills:check
- git diff --check
2026-05-12 15:47:57 -07:00
mvschwarz a3683766b6 fix(compaction): preserve restore trust channel through compact
Live founder-walk testing showed the post-compact restore prompt was delivered as
a normal user message, but the compacted Claude summary carried forward a rule
that treated the restore request as an injection pattern. The failure was not
terminal delivery; it was the trust contract preserved during /compact.

Update the compaction path so the compact summary preserves the intended channel
split:
- later normal user restore messages from OpenRig are operator-authorized
- local-command stdout and hook output remain informational unless a later normal
  user message asks the agent to act
- the post-compact prompt names itself as that normal user restore request

Also soften hook/bridge wording from command-shaped "report exactly" to "reply
with" and make the PreCompact hook explicitly informational.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer precompact-hook openrig-core-plugin
- npm --workspace packages/daemon test -- claude-compaction-enforcer context-monitor precompact-hook openrig-core-plugin
- npm run lint
- npm --workspace packages/daemon run build
- npm run mirror-skills:check
- git diff --check
2026-05-12 15:40:45 -07:00
mvschwarz dc9adb868e fix(compaction): make post-compact restore prompt explicit
Live founder-walk evidence showed the daemon-delivered restore prompt reached
Claude as a user message, but Claude answered "No response requested" instead
of restoring. The prior wording was declarative and authority-shaped:
"OpenRig post-compaction restore is required... report exactly...".

Make the prompt an explicit user request:
- ask Claude to respond now by restoring after compaction
- keep marker + JSONL fallback behavior unchanged
- phrase the sentinel as "reply with" rather than "report exactly"

This keeps the one-message restore design and avoids a brittle wake-then-command
sequence.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer
- npm --workspace packages/daemon test -- claude-compaction-enforcer context-monitor precompact-hook openrig-core-plugin
- npm run lint
- git diff --check
2026-05-12 15:01:43 -07:00
mvschwarz 18b01d9b8b fix(compaction): include restore fallbacks in post-compact prompt
When the daemon actively wakes a Claude seat after auto-compaction, include
seat-specific recovery fallbacks in the restore prompt:

- pass the Claude JSONL transcript path and session id from ContextMonitor
- point at the durable pending marker first, then JSONL/session fallback
- carry the configured operator post-compaction instruction inline, or point at
  the configured instruction file when inline text is empty

This keeps restoration actionable even when an older user-level PreCompact hook
is installed and no OpenRig pending marker was written.

Verification:
- npm --workspace packages/daemon test -- claude-compaction-enforcer context-monitor precompact-hook openrig-core-plugin
- npm run lint
- npm --workspace packages/daemon run build
- npm --workspace packages/ui run build
- npm --workspace packages/cli run build
- npm run mirror-skills:check
- git diff --check
2026-05-12 14:27:09 -07:00
mvschwarz b7123ae28d fix(compaction): send post-compact restore prompt
After a successful auto /compact send, keep a pending post-compact restore action for that session. Once context usage drops below the configured threshold, send one normal restore prompt through SessionTransport pointing at the pending restore marker and restore packet fallback.

This closes the live dogfood gap where PreCompact prepared restore context but Claude returned to an idle prompt without proactively running the restore protocol. Hooks still prepare context; the daemon now creates the post-compact assistant turn.

Verification:
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/daemon test -- session-transport claude-compaction-enforcer user-settings-store user-settings-routes precompact-hook openrig-core-plugin
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/ui test -- claude-compaction-policy-form
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/cli test -- config-store-extended config
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm run lint
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/daemon run build
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/ui run build
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/cli run build
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm run mirror-skills:check
- git diff --check
2026-05-12 14:19:57 -07:00
mvschwarz 1a2e0b8b21 fix(compaction): avoid prompt-draft auto compact
Treat typed prompt drafts above an idle Claude footer as attention so automatic compaction does not paste over human input. Threshold crossing remains retryable because failed sends do not arm dedup.

Ship continuity-shaped default compact and post-restore instructions, expose them in the policy form, and include template files with the claude-compaction-restore skill. The precompact hook now uses the default restore instruction when the policy is enabled but restore text has not been configured yet.

Verification:
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/daemon test -- session-transport claude-compaction-enforcer user-settings-store user-settings-routes precompact-hook openrig-core-plugin
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/ui test -- claude-compaction-policy-form
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/cli test -- config-store-extended config
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm run lint
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/daemon run build
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/ui run build
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm --workspace packages/cli run build
- PATH="<user-home>/.nvm/versions/node/v22.22.1/bin:$PATH" npm run mirror-skills:check
- git diff --check

Caveat: npm run test:repo still fails on pre-existing tracked docs/DESIGN.md docs-guard violation.
2026-05-12 13:54:45 -07:00
mvschwarz 4f59688a3e fix(compaction): split compact instruction and restore bridge
Add a dedicated Claude compaction instruction setting that the daemon sends
as /compact slash-command args, so operator control of the actual summary
phase uses the correct channel instead of relying on PreCompact stdout.

Keep the existing message fields as post-compaction restore guidance. The
PreCompact hook now writes a pending restore marker under OPENRIG_HOME and
the Claude plugin ships a SessionStart/UserPromptSubmit bridge that injects
one restore directive after compaction. The enforcer also re-arms only after
usage drops below threshold, preventing repeated /compact sends every dedup
window while usage remains high.

Fix the policy form threshold input by keeping a string draft and validating
on submit, so operators can type/backspace a percent directly.

Test hygiene: clear OpenRig daemon env in a CLI config fixture test so it is
reproducible from OpenRig-managed shells.
2026-05-12 12:16:15 -07:00
mvschwarz ecef38e6de test(ui): update Policies page tests for merged slice 26 + 27 shape
PoliciesPage now wraps ClaudeCompactionPolicyForm inside slice 26's
SettingsPageShell — assertions updated to match the merged structure
(no more empty-state placeholder; intro paragraph + form). QueryClient
wrapper required for the form's useSettings/useSetSetting hooks.
2026-05-12 11:19:30 -07:00
mvschwarz 05e85ce2c0 merge(0.3.1): slice 27 Claude auto-compaction policy
Operator-configurable Claude pre-compaction trigger: when a Claude
seat's context usage crosses a configurable percentage threshold, the
daemon sends /compact to the seat. The existing PreCompact hook fires
and injects an operator-configured custom message into the compacted
agent's systemMessage alongside the standard restore-instructions.

Opt-in default-off. Configurable threshold (1-100 strict integer).
Custom message via inline string OR file path (inline wins). 60s
per-seat dedup window. Runtime-filtered to claude-code seats only.

SC-29 EXCEPTION #10: 4 new ConfigStore + SettingsStore keys in
policies.claude_compaction.* namespace (set/env/file resolve paths all
strict-validated via shared coerceAndValidate helper).

Three-iteration guard recheck loop: write-path → env-path → file-path
validation all closed via shared helper (banked
feedback_audit_every_layer_function_and_module_constants applied).
QA HG-11 live walk PASSED all 7 scenarios on VM after correcting the
install to include the full claude-compaction-restore helper tree.

CONCERNING note carried forward: install runbook needs correction —
copying only precompact-hook.mjs fails at runtime (MODULE_NOT_FOUND
for sibling helpers). Full skill-tree install is required. Repeatable
VM installer scheduled for 0.3.2.

# Conflicts:
#	packages/ui/src/components/system/PoliciesPage.tsx
#	packages/ui/src/routes.tsx
2026-05-12 11:18:37 -07:00
mvschwarz cc98ba1059 merge(0.3.1): slice 26 Settings Explorer + Policies page scaffold
Settings becomes a 4th Explorer-destination peer (Topology / Project /
Library / For-You / Settings); 4 sub-routes (Settings / Policies / Log /
Status) with shared SettingsPageShell. Policies page renders empty-state
scaffold for slice 27 Claude auto-compaction policy form.

8-commit fix chain (5 chrome + 4 HG-8 mobile-drawer iterations):
- B/C3/D3 iterations debugged a pre-existing renderer-spin in Topology
  mobile path
- OPT-E (final) hides mobile-menu-toggle on /topology at narrow
  viewports; cross-destination z-40 fix preserved for Settings / Project
  / Specs / For-You

Three-gate clean: driver complete + design-reviewer SHIP +
velocity-qa CLEAR (cross-destination preserved + Topology peg path
closed via toggle hidden).

Topology mobile drawer full restoration scheduled for 0.3.2
(dedicated TopologyTableView render-path slice).
2026-05-12 04:56:47 -07:00
mvschwarzandClaude Opus 4.7 d102ee485a fix(ui): hide mobile-menu-toggle on Topology mobile (slice 26 HG-8 OPT-E repair)
OPT-D3 mount-suppression at 94e7155 prevented Explorer mount on
Topology mobile (proven by velocity-qa's explorerBefore=false DOM
probe). But the hamburger click STILL pegged the renderer at ~100%
CPU. Forensic pinpointed the root cause: the toggle's onClick handler
flips explorerOpen state, and that state change alone re-renders
AppShellInner's children — including the Topology mobile render path
(TopologyTableView + TopologyTreeView) which has a pre-existing
render-cost that pegs the browser on cascade. The peg trigger is the
state-flip, not the Explorer mount.

OPT-E removes the toggle button entirely on the Topology mobile
surface so the state-flip cannot fire. Reuses shouldSuppressExplorerMount
predicate (same underlying carve-out condition as OPT-D3: topology &&
!isWideLayout). One conditional JSX render in AppShell.tsx; no new
helper, no API surface change. OPT-D3 stays in place as defense in
depth — even without the toggle, Explorer mount on Topology mobile
remains independently suppressed.

UX: Topology mobile loses the (non-functional) drawer entry. The
degraded table render remains visible + navigable; brand-home-link in
the topbar provides navigation out. Pre-slice-26 baseline had no
useful Explorer affordance on Topology mobile anyway. 0.3.2 dedicated
slice will fix the Topology mobile render-path; this carve-out reverts
at that time.

Tests: 7 OPT-E JSX-shape tests added (MenuToggleProbe mirrors AppShell's
conditional render). Topology + narrow asserts toggle ABSENT; Topology
+ wide + 4 other surfaces + 'none' all assert toggle PRESENT. Total
suite: 43/43 across slice-26 focused files (36 prior + 7 OPT-E); full
UI suite at baseline (800 pass + 1 skipped + 52 pre-existing).

Cumulative slice 26.B/D/E layers: OPT-B (z-40 across 4 destinations) +
OPT-D3 (Topology mobile mount-suppression) + OPT-E (Topology mobile
toggle hidden). All three preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 04:49:26 -07:00
mvschwarzandClaude Opus 4.7 94e7155f92 fix(ui): suppress Topology Explorer mount at narrow viewports (slice 26 HG-8 OPT-D3 repair)
OPT-C3 z-index carve-out (1d935da) was invalidated by velocity-qa
recheck: pinning Topology mobile Explorer to z-20 hides it visually
but does NOT prevent mount; the pre-existing TopologyTableView +
TopologyTreeView renderer-spin pegs the browser when the Explorer
component mounts on hamburger click, independent of whether it is
visible.

OPT-D3 reverts the surface-conditional z-index back to uniform
opaque-mode z-40 (matching all 4 other Explorer-bearing destinations)
and instead gates the Explorer MOUNT on viewport for the Topology
surface. The rule lives in a new pure helper
`shouldSuppressExplorerMount(surface, isWideLayout)` exported from
Explorer.tsx; AppShell composes it with the existing explorerVisible
gate. At narrow viewports (< lg breakpoint) the Topology Explorer is
not rendered at all — the peg trigger is never armed.

Cross-destination preservation: settings / project / specs / for-you
mount normally regardless of viewport; the carve-out is Topology-
specific. The 0.3.2 Topology mobile render-path fix will remove this
suppression.

Tests: pure-predicate tests for shouldSuppressExplorerMount cover 5
surfaces × 2 viewports + the "none" surface; replaces the 2 OPT-C3
class-contract tests that asserted z-20 for Topology opaque-mode.
Other 13 tests (cross-destination z-40 contract for 4 surfaces +
overlay-mode z-30 preservation) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 04:38:26 -07:00
mvschwarzandClaude Opus 4.7 1d935dae5e fix(ui): surface-conditional Topology mobile z-index carve-out (slice 26 OPT-C3 repair)
velocity-qa caught at /topology + 375px (slice 26 OPT-B recheck):
hamburger tap pegged the headless browser renderer. Reproduced
twice; stuck renderers killed to avoid host CPU burn.

Forensic finding (qitem-20260512112436-347b6c98):
pure CSS z-index swap does NOT trigger React reconciliation work;
TopologyTableView (443L) + TopologyTreeView (264L) use standard
hook patterns; mounted pre-OPT-B too (just covered by rail-tray).
Strong evidence: pre-existing renderer-spin in Topology mobile
render path; OPT-B z-index change just made the click path
reachable to QA.

Repair (OPT-C3 per orch routing at qitem-20260512112530-0de8d3c1):
surface-conditional z-index. Topology surface pinned to z-20
(pre-OPT-B baseline; covered by rail-tray on mobile; unreachable;
click path doesn't reach the peg trigger). Other 4 destinations
(settings / project / specs / for-you) keep z-40 (OPT-B fix
preserved; cross-destination chrome consistency for 4/5).

Smallest-correct path: 1-block className conditional, no React
state changes, no new components, no daemon work. Topology mobile
drawer remains broken-by-overlap (legacy state) but does not peg
the renderer.

0.3.2 plans a Topology mobile render-path fix (per banked
feedback_usestate_not_usemutation_for_fire_and_forget — Table-hang
renderer-spin class; useRef + imperative DOM escape-hatch
territory). This carve-out reverts at that time.

Tests added (slice-26.C OPT-C3 carve-out regressions):
- POSITIVE: opaque-mode Topology Explorer className contains z-20
- NEGATIVE: z-40 absent for Topology opaque mode (discriminates
  the carve-out from the other-destinations OPT-B path)
- NUMERIC LAYERING INVARIANT: extract z-N as integer + assert
  N < MOBILE_RAIL_TRAY_Z (30); Topology Explorer must be BELOW
  rail-tray to remain covered + prevent peg trigger

Preserved (OPT-B regression coverage; all from earlier commit):
- 4 opaque surfaces (project/specs/for-you/settings) still assert
  z-40 + not-z-20 + N > 30
- Topology overlay-mode still asserts z-30 (graph canvas float)

Test counts:
- explorer-mobile-zindex-layering: 15 tests (13 prior + 2 NEW
  Topology carve-out regressions)
- settings-explorer: 8/8 unchanged
- settings-pages: 4/4 unchanged
- Full UI suite: 784 pass + 1 skipped + 52 pre-existing fails
  (baseline identical; +2 net pass / 0 new fail)
- Touched files typecheck clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 04:26:59 -07:00
mvschwarzandClaude Opus 4.7 125c747c5d fix(ui): bump opaque-mode Explorer mobile drawer z-index above rail-tray (slice 26 HG-8 OPT-B repair)
velocity-qa caught at 375px: Settings Explorer mobile drawer was
covered by AppShell mobile-rail-tray. Click hits registered on
rail-tray items, not Explorer items. HG-8 BLOCKING.

Root cause (pre-existing on main; exposed by slice 26):
- packages/ui/src/components/Explorer.tsx (lines ~302-322) Explorer
  aside `fixed top-14 bottom-0 left-0 ... w-72 max-w-[80vw]` at
  z-20 (opaque mode) or z-30 (overlay mode)
- packages/ui/src/components/AppShell.tsx (lines 557-569)
  mobile-rail-tray at z-30, same left-0 attach, same width
- Both share `explorerOpen` state — open together on mobile
- Pre-slice-26: Settings had surface=none → Explorer not rendered
  → no conflict on /settings. Topology/Project/Library/For-You had
  surface != none — same overlap existed but was never QA'd at 375px
- Slice 26 added Settings as 5th Explorer-bearing destination, which
  triggered QA's HG-8 mobile drawer check and surfaced the bug

Repair (OPT-B per orch routing decision at qitem-20260512110641-be875819):
- Bump opaque-mode Explorer mobile drawer from z-20 to z-40
- Overlay-mode (Topology graph) stays z-30; equal to rail-tray z-30
  but DOM render order resolves Explorer above (AppShell renders
  Explorer aside AFTER the rail-tray div)
- Single-line z-index swap fixes ALL 5 Explorer-bearing destinations
  on mobile, not just Settings — pre-existing-bug fix beyond slice 26

UX trade-off (documented in code comment): rail-tray remains
reachable via backdrop dismissal on mobile. Decoupling the two
drawer states + adding a dedicated mobile Explorer-open affordance
is deferred to 0.3.2 mobile-drawer-refactor (orch decision; OPT-C
not pursued in slice 26).

Discriminating regression tests (slice 26.B HG-8 OPT-B):
packages/ui/test/explorer-mobile-zindex-layering.test.tsx (13 tests):
- POSITIVE: Explorer aside className includes z-40 (across 4 opaque
  surfaces: project / specs / for-you / settings)
- NEGATIVE: Explorer aside className does NOT include z-20
  (pre-repair broken value absent — would re-introduce the bug)
- NUMERIC LAYERING INVARIANT: extract z-N from className and assert
  N > MOBILE_RAIL_TRAY_Z (30) — pulls the value as a number to
  catch any future regression away from z-40 toward z-20-or-below
- OVERLAY-mode PRESERVATION: surface=topology keeps z-30 (Topology
  graph canvas float behavior unchanged)

Tests verify the cross-destination scope of the fix — applying to
all 5 destinations because the change is in Explorer.tsx (a single
choke point).

Test counts:
- explorer-mobile-zindex-layering: 13 NEW (+1 overlay-preservation)
- settings-explorer: 8/8 unchanged
- settings-pages: 4/4 unchanged
- Full UI suite: 782 pass + 1 skipped + 52 pre-existing fails
  (same AppShell-pollution baseline). Net vs prior tip: +13 pass /
  0 new fail.
- Touched files typecheck clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 04:08:45 -07:00
mvschwarzandClaude Opus 4.7 394c68b5f1 fix(daemon,cli): validate threshold at env + file resolve paths (slice 27 BLOCKING-FIX-2)
BLOCKING-FIX-1 (efb0aa1) closed the WRITE paths: ConfigStore.set,
SettingsStore.set, /api/config POST, and the enforcer's defense-in-depth
all reject invalid threshold input. The READ paths still leaked:
resolveOne's env-source branch called coerceValue without
validateKeyConstraints, so:

  OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc → 80
  OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80.5  → 80
  OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=0     → 0
  OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=101   → 101

The 0/101 cases would survive resolve, then the enforcer's range check
would catch them. But the parseInt-partial cases (80abc → 80) produced
an in-range integer indistinguishable from a clean "80" — the enforcer
cannot detect after coerce. The READ path itself had to validate.

This is the layered-validation pattern banked at
feedback_audit_every_layer_function_and_module_constants — write
validators are not enough when read paths bypass them.

Refactor: shared `coerceAndValidate` helper invoked from
- ConfigStore.set (replaces inline coerce + validate)
- SettingsStore.set (same)
- ConfigStore.resolveOne env-source branch (NEW — catches bad env, drops
  override, falls through to file/default with a stderr warning)
- SettingsStore.resolveOne env-source branch (same)

File-source validation via `validateTypedFileValue` for already-typed
JSON values:
- ConfigStore.resolveOne file-source branch (NEW)
- SettingsStore.resolveOne file-source branch (NEW)

Rejection at resolve time does NOT throw (would crash daemon on bad
config); it drops the override, warns to stderr, and falls through to
the next source.

Tests:
- CLI env-source: 7 reject cases (0, 101, -1, 80abc, 80.5, NaN, Infinity)
  → resolveOne returns default 80; stderr warning observed
- CLI file-source: 5 reject cases (0, 101, -1, 80.5, "80abc")
  → resolveOne returns default 80; stderr warning observed
- CLI discriminator: env=80abc (source=default, warning) vs env=80
  (source=env, no warning) — proves the validator did the right thing
  and didn't quietly accept bad input
- Daemon settings-store: same 7+5+1 matrix (lockstep)
- Enforcer integration: real SettingsStore + env=0 + file enabled at 80
  + seat at 79% → no trigger (env rejected, fell to file 80, seat below
  threshold); env=80abc + file 80 + seat 99% → trigger (env rejected,
  file 80 wins via fallback, 99 >= 80). Per banked
  feedback_specific_review_recommendations_get_acted_on, the
  discriminators catch both directions of regression.

Full daemon suite 3552/3553 (1 skipped, 0 fails). Full CLI suite 956/956.

Source verdict: qitem-20260512101826-23358a21 (redo-guard-2 BLOCKING
on prior fix). Driver-fix-2 dispatch: qitem-20260512101905-2f1b68e7.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 03:23:40 -07:00
mvschwarzandClaude Opus 4.7 efb0aa14ed fix(daemon,cli): strict threshold_percent validation (slice 27 BLOCKING-FIX)
threshold_percent contract is integer in [1, 100]. parseInt's permissive
coercion accepted invalid input at write paths (rig config set, /api/config
POST, env override, direct ConfigStore.set):

- 0 (would fire /compact every poll tick — catastrophic if opt-in net
  ever weakens)
- 101, -1 (out of documented range)
- "80abc" (partial parse → 80)
- "80.5" (truncated → 80)

Two layers added (per banked feedback_static_gates_mirror_runtime_validators
+ feedback_permission_layer_is_foot_gun_dont_patch_reactively):

1. WRITE-path validators in both ConfigStore (cli) + SettingsStore
   (daemon) — a per-key constraint map runs after coerceValue in set();
   strict regex `^-?\d+$` + Number.isInteger + range check. The
   /api/config POST handler already maps thrown errors to 400 via the
   existing try/catch in routes/config.ts; no new route surface.

2. ENFORCER defense-in-depth — ClaudeCompactionEnforcer treats
   out-of-contract policy as `invalid_policy` (no send) so a hand-edited
   ~/.openrig/config.json with bad values cannot trigger /compact.
   Mirrors the write-path constraint without crashing the daemon on
   bad config.

Tests: accept/reject matrices at each layer (CLI 16 cases, daemon
settings-store 16 cases, /api/config POST 9 reject + 1 accept,
enforcer 6 invalid_policy cases). 48 new tests; full daemon + cli
suites green.

Per banked feedback_pre_dispatch_grep_load_bearing_claims: grep
confirmed threshold_percent is the only numeric key in the
policies.claude_compaction.* namespace — enabled is bool;
message_inline / message_file_path are strings.

Source verdict: qitem-20260512100940-49cd26f4 (redo-guard-2 BLOCKING)
Driver-fix dispatch: qitem-20260512101023-f75668ac
Original slice ship: fe50ce7 (slice 27 claude auto-compaction policy)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 03:15:09 -07:00
mvschwarz b07d87fc14 merge(0.3.1): slice 25 follow-on 2 — seat overview polish
Alert-only banner, grid lines on overview table, separated cwd /
current-work rows. Three-gate clean at bc5af3f (redo3-guard-3 CLEAR +
design-reviewer SHIP + velocity-qa VM PASS on persistent-rsync-refresh).
2026-05-12 03:02:29 -07:00
mvschwarzandClaude Opus 4.7 fe50ce7759 feat(daemon,ui): Claude auto-compaction policy (slice 27)
Operator-configurable pre-compaction trigger. When a Claude seat's
context usage crosses a configured threshold, the daemon dispatches
`/compact` via SessionTransport and the existing PreCompact hook
appends an operator-provided message to the post-compaction system
message alongside the standard restore-instructions.

Architecture:
- 4 new ConfigStore + SettingsStore keys
  (policies.claude_compaction.{enabled,threshold_percent,
  message_inline,message_file_path}) in lockstep across the CLI +
  daemon allowlists.
- ClaudeCompactionEnforcer encapsulates policy + dedup + send so the
  trigger path is unit-testable independently from ContextMonitor's
  scheduler concern.
- ContextMonitor's polling loop calls the enforcer after each
  observation persists; enforcer respects runtime filter (claude-code
  only), threshold gating, opt-in default-off, and a per-seat 60s
  dedup window. Send-failure does NOT advance the dedup timestamp,
  so a transient failure can retry on the next tick.
- precompact-hook.mjs reads $OPENRIG_HOME/config.json directly (no
  daemon-HTTP dependency). Inline message wins when both inline + file
  path are set; missing file degrades gracefully (no append, no fail).
- /settings/policies route + ClaudeCompactionPolicyForm shipped as a
  temporary mount until slice 26's Settings explorer integrates the
  form into the canonical Policies destination.

Hard-gate coverage:
- HG-1 settings round-trip via SettingsStore set/resolveOne
- HG-2 threshold-check fires at + above threshold
- HG-3 SessionTransport.send dispatched with canonical session name
- HG-4 dedup window blocks repeat triggers; resumes after interval
- HG-5 opt-in default-off regression (compaction lifecycle blast-radius
       guard per banked permission-layer foot-gun rule)
- HG-6 inline message appended to systemMessage
- HG-7 file-path message read + appended when inline empty
- HG-8 neither set: standard restore-instructions preserved
- HG-9 form renders + validates + persists each key
- HG-10 resolveClaudeCompactionPolicy rereads config.json without
        daemon restart (single resolve call picks up live edits)
- HG-11 / HG-12 land at velocity-qa VM dogfood + final SC-29 verify

Test counts: enforcer 8/8, settings-store +5/+5, hook 6/6, UI form 4/4
(23 new tests; full daemon + cli + ui suites green minus 2 pre-existing
UI flakes + 1 bootstrap-orchestrator ordering flake — verified on main).

SC-29 EXCEPTION #10 declared: 4 new ConfigStore + SettingsStore keys in
the policies.claude_compaction.* namespace. Lockstep CLI + daemon
allowlists; no schema migrations / new endpoints / new event types
beyond reusing the existing /api/config POST surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 03:01:32 -07:00
mvschwarzandClaude Opus 4.7 a8c7ff1366 fix(ui): PoliciesPage empty-state copy + test header (slice 26 26.B carry-forwards)
velocity-guard 26.B ADVISORY-OK-WITH-CONCERNS carry-forwards (both
non-blocking; addressed before slice close per banked discipline):

1. PoliciesPage empty-state copy mentioned "lands in slice 27" — that
   slice-number token is internal release-tracking detail and should
   not leak into product-facing UI. Operators see this string in the
   browser; they don't know what "slice 27" means.

   Rewritten copy:
     "Policies control automated agent behaviors — when to compact a
      Claude session, how to escalate stale work, and similar runtime
      rules. Configured policies will be listed here and editable in
      place."

   - Factual + future-tense, no version/slice/release internals
   - Still mentions Claude-session compaction as the canonical policy
     example so it's not just generic boilerplate
   - Test tightened: new regression assertion
     `expect(empty.textContent).not.toMatch(/slice\s*\d+/i)` proves
     the rule structurally (would catch any future re-introduction
     of "slice N" copy in this empty-state).

2. settings-pages.test.tsx file header docstring previously claimed
   coverage of LogPage + StatusPage. Tests only import PoliciesPage
   + SettingsCenter. Header now reads:
     "Covers PoliciesPage (HG-5 empty-state) + SettingsCenter
      refactor (HG-7 top-row tabs removed discriminator). LogPage +
      StatusPage route mounts are verified by static route
      registration in routes.tsx + structural inspection; their
      component-level rendering is QA walk scope."

   No overclaim risk in the slice ACK.

Test counts unchanged: 12/12 settings-{pages, explorer} green. Full
UI suite numbers unchanged (no failures introduced; no test count
delta beyond the prior +4 from Checkpoint B).

SC-29 EXCEPTION declared: NONE (UI copy + test docstring).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 02:56:56 -07:00
mvschwarzandClaude Opus 4.7 db30eda71f feat(ui): Settings sub-routes + tabs removal + PoliciesPage scaffold (slice 26 Checkpoint B)
Per slice 26 README §Tab → Explorer-item mapping: refactors the
3-tab SettingsCenter into 4 route-driven page components with the
shared SettingsPageShell chrome. Adds 3 new sub-routes + the new
Policies destination scaffold. Removes the legacy top-row tab
navigation (HG-7).

NEW packages/ui/src/components/system/SettingsPageShell.tsx:
- Common chrome wrapper (eyebrow "Configuration" + page title +
  content slot) reused by every Settings sub-route
- Replaces the per-page chrome duplication that would otherwise
  arise from the SettingsCenter split

NEW packages/ui/src/components/system/PoliciesPage.tsx (HG-5):
- Empty-scaffold page at /settings/policies
- EmptyState placeholder: "NO POLICIES CONFIGURED" + description
  pointing at slice 27 Claude session compaction policy as the
  first-landing entry

NEW packages/ui/src/components/system/LogPage.tsx (HG-6 + HG-9):
- Mounted at /settings/log; lifts the LogPanel content that used
  to live inside SettingsCenter's inline "log" tab
- Same activity feed wire (useActivityFeed); same EmptyState +
  truncated-list rendering as before

NEW packages/ui/src/components/system/StatusPage.tsx (HG-6):
- Mounted at /settings/status; renders the existing
  SettingsSystemStatusPanel inside the shared shell

MODIFIED packages/ui/src/components/system/SettingsCenter.tsx (HG-7):
- Inline 3-tab state + tab-nav block removed
- Now renders just the config-keys form (SettingsTab) inside the
  shared shell
- testid="settings-center" preserved for back-compat with any
  AppShell-level assertion

MODIFIED packages/ui/src/routes.tsx:
- New createRoute definitions: settingsPoliciesRoute (/settings/policies),
  settingsLogRoute (/settings/log), settingsStatusRoute
  (/settings/status)
- All three added to routeTree.addChildren next to settingsRoute
- Imports for the three new page components

MODIFIED packages/ui/src/components/Explorer.tsx (guard 26.A-repair
carry-forward):
- Stale source comments at L190-192 + L258 updated. The "Surface
  none" branch now correctly says "Dashboard only — Settings has
  its own Explorer surface as of slice 26"

NEW packages/ui/test/settings-pages.test.tsx (4 tests):
- PoliciesPage renders Policies title chrome
- PoliciesPage empty-state mentions compaction/policy
- SettingsCenter renders chrome (post-refactor)
- HG-7 DISCRIMINATOR: SettingsCenter no longer renders
  settings-tab-nav / settings-tab-* testids / role=tablist labeled
  "Settings sections". Each assertion would fail against the pre-
  refactor SettingsCenter shape.

Test counts:
- settings-pages: 4/4 NEW
- settings-explorer: 8/8 (unchanged)
- Full UI suite: 769 pass + 1 skipped + 52 pre-existing fails on
  main HEAD baseline. Net vs Checkpoint A repair tip: +4 pass / 0
  new fail.
- Touched files typecheck clean

Slice 26 completes 9 HGs at driver level:
- HG-1 Settings has Explorer sidebar: Checkpoint A
- HG-2 4-item Explorer: Checkpoint A
- HG-3 each item is its own route: Checkpoint B (registered here)
- HG-4 /settings default = config keys form: Checkpoint B
- HG-5 /settings/policies empty-state: Checkpoint B
- HG-6 /settings/log + /settings/status render existing content:
       Checkpoint B (lifted into LogPage + StatusPage)
- HG-7 top-row tabs removed: Checkpoint B (discriminating regression)
- HG-8 mobile drawer pattern: Explorer.tsx auto-inherits
- HG-9 UI focused suite green / no new failures: verified

HG-1/HG-2/HG-3 user flow: click rail Settings icon → /settings →
Explorer sidebar with 4 items → click any item → URL navigates →
content updates per route. Mobile (375px) drawer toggle pattern
inherited from the existing Explorer wrapper that Topology / Project
/ Library / For-You use.

SC-29 EXCEPTION declared: NONE (UI presentation only; no daemon
route / no ConfigStore key / no schema migration / no event type).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 02:50:10 -07:00
mvschwarzandClaude Opus 4.7 bc5af3f201 feat(topology): seat overview polish — alert-only banner, grid lines, separated cwd/current-work
V0.3.1 slice 25 second follow-on (builds on f27da1a). Five UI polish
changes to the seat detail Overview tab based on VM walk feedback.
UI-only; SC-29 NONE.

1) Banner is alert-only.

The follow-on-1 banner triggered on `recoveryGuidance` alone, which
is set on every NodeInventoryEntry as generic recovery documentation
— it's NOT an alert. The banner now renders ONLY when one of these
real-alert states is set:
  - startupStatus === "failed"
  - startupStatus === "attention_required"
  - latestError !== null

Generic recoveryGuidance alone no longer triggers the banner.
recoveryGuidance is still surfaced as supplementary content INSIDE
the banner when one of the alert states is also present.

2) Section-header row removed from SeatOverviewTable.

The "OVERVIEW" section-header div above the column headers was
removed. Column headers are now the first row of the table; no
preamble row.

3) Vertical grid lines between column cells.

Header + data rows carry `border-r border-outline-variant/55` on
every column cell except the last. Clearer cell boundaries; matches
existing table-grid pattern in product (Explorer + AppShell use the
same `border-r border-outline-variant` convention).

4) cwd + current-work moved into a separate primitive.

New component `SeatOverviewSecondary` renders cwd + current-work as
labeled key-value rows in its own section below the column table.
Previously rendered via `colSpan` full-width rows inside the same
table primitive; now visually distinct from the column table above.

The Overview stack is now:
  1. SeatNotificationBanner (real-alert-only)
  2. SeatOverviewTable (7-column header + data row, vertical grid)
  3. SeatOverviewSecondary (cwd + current-work, separated)
  4. InlineTerminal
  5. RecentEventsSection

5) "total tokens" → "tokens" column header label.

Conserves horizontal width on the 7-column row. Underlying field key
("total-tokens") + the testid (`seat-overview-cell-total-tokens`) are
preserved so existing assertions keep working.

Tests updated (packages/ui/test/live-node-details.test.tsx):

- HG-1: column headers + single data row + no OVERVIEW row + tokens
  label (not "total tokens") + cwd/current-work moved OUT of the
  column table.
- HG-2: Overview DOM order now asserts notification → table →
  secondary → terminal → recent events (5-element chain via
  compareDocumentPosition).
- HG-2 (new): banner does NOT render for normal seat with generic
  recoveryGuidance only — guards the regression class the dispatch
  surfaced.
- HG-2 (new): banner renders for startupStatus=failed alone, and for
  latestError alone, even without recoveryGuidance.
- HG-3 (preserved): existing banner-renders-with-attention_required
  test continues to pass.
- HG-3b: current-work cell wires live and renders em-dash in the
  secondary primitive (testid suffix updated).
- HG-3d: cwd cell renders with truncate + title tooltip in the
  secondary primitive (testid suffix updated).
- HG-4 (new): vertical grid-lines test asserts `border-r
  border-outline-variant` on every column cell except the last for
  BOTH header + data rows.
- HG-5 (new): seat-overview-secondary primitive is distinct from
  seat-overview-table primitive (neither contains the other).
- HG-6 (new): "tokens" column header label, not "total tokens".
- PL-019 (preserved): activity column cell + current-work in
  secondary primitive surface their data.

Forensic-trace findings:

- "OVERVIEW" row source: the section-header `<div>` at the top of
  SeatOverviewTable. Removed in the rewrite.
- Alert source: NodeDetailData.{startupStatus, latestError,
  recoveryGuidance}. recoveryGuidance is generic on every seat;
  alert-state membership is determined by startupStatus + latestError
  alone. Confirmed by reading the daemon response shape + matching
  what StatusSection in Details already uses for the showFailure
  branch.
- Grid-line pattern: Explorer + AppShell + vellum-sheet use
  `border-r border-outline-variant` for vertical separators. Reused
  the same class (with the /55 alpha modifier to match the
  follow-on-1 table's horizontal row borders).

Verification:

- live-node-details.test.tsx: 29/29 PASS.
- Full UI suite: 1023 passed / 1 skipped / 3 failed (same 3
  pre-existing failures as main f27da1a; zero new).

Hard gates:

- HG-1 banner hidden for normal seats with recoveryGuidance: PASS.
- HG-2 banner renders for failed / attention_required / latestError:
  PASS.
- HG-3 "OVERVIEW" row removed: PASS.
- HG-4 vertical grid lines on column cells: PASS.
- HG-5 cwd + current-work in separate primitive below the table:
  PASS.
- HG-6 column label is "tokens" not "total tokens": PASS.
- HG-7 UI focused suite green; no new failures: PASS.
- HG-8 mobile responsive (column table overflow-x-auto preserved;
  secondary primitive uses flex layout for narrow viewports): manual
  VM walk pending.
- HG-9 design-reviewer V1-bar audit: DEFERRED to gate.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 02:48:50 -07:00
mvschwarzandClaude Opus 4.7 dca81f1f7b test(ui): update AppShell Settings-surface test for slice 26 Explorer contract (slice 26 Checkpoint A repair)
velocity-guard 26.A BLOCKING-CONCERN at e6b20d0: AppShell test
contract still asserted Settings has surface=none / no Explorer, but
my Checkpoint A change made surfaceForPath map /settings to "settings"
(Explorer renders). The stale test contract would either fail under
the repo's runner or leave conflicting executable documentation.

Repair (test/app-shell.test.tsx):
- Comment at file top: "Surface routing — Explorer renders for
  tree/lens destinations AND Settings (slice 26: settings became a
  4-destination Explorer peer to Topology / Project / Library /
  For-You). Only Dashboard remains surface=none."
- Settings-surface test renamed + rebody-ied:
    Old: "Settings surface (/settings) renders rail but NO Explorer
         (surface=none)" — expected explorer null
    New: "Settings surface (/settings) renders rail AND Explorer
         (surface=settings)" — expects:
      - data-testid="app-rail" truthy (unchanged)
      - data-testid="explorer" truthy (NEW; was null assertion)
      - data-testid="settings-explorer" truthy (NEW; integration proof
        that SettingsExplorer mounts when surface="settings")
- Dashboard-surface test ("/ renders rail but NO Explorer") unchanged.

This is the integration proof guard asked for: at the AppShell level,
not just isolated SettingsExplorer test-router.

Test runner caveat (same blocker guard hit on their side):
app-shell.test.tsx fails to RUN in isolation under the local vitest
invocation due to a pre-existing vite:css plugin issue parsing
@xyflow/react/dist/style.css. 36 of 38 tests in this file fail with
that runner-level error regardless of my changes; the failure count
is IDENTICAL pre- and post-this-commit (verified via stash-and-recheck:
52 failed / 765 passed / 1 skipped on the full UI suite at both
states). The same 36 failures appear in the full-UI-suite baseline
recorded across slices 18 / 24 / 26 — orthogonal to slice 26 per
banked feedback_appshell_global_polls_test_pollution.

The test edit is verified via grep: `surface=settings` + the new
expect-Explorer-truthy + expect-settings-explorer-truthy assertions
appear at the expected lines (95-100 post-edit). When the CI/test
environment can run app-shell.test.tsx (i.e., once the CSS plugin
issue lands a fix), the contract is in place.

SC-29 EXCEPTION declared: NONE (test-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 02:44:08 -07:00
mvschwarzandClaude Opus 4.7 e6b20d082e feat(ui): Settings destination Explorer plumbing (slice 26 Checkpoint A)
Adds the "settings" surface to ExplorerSurface dispatch + new
SettingsExplorer sidebar component + path-to-surface mapping so the
Settings destination joins Topology / Project / Library as a true
Explorer-bearing peer.

NEW packages/ui/src/components/system/SettingsExplorer.tsx:
- Flat sidebar list of 4 items (Settings / Policies / Log / Status)
  matching the dispatch's Explorer-item mapping
- Each item is a TanStack Router Link to its sub-route:
    /settings, /settings/policies, /settings/log, /settings/status
- Active state derived from current pathname via useRouterState;
  /settings is the bare-match for Settings (does NOT trigger sub-route
  active state)
- Stable testid surface: settings-explorer, settings-explorer-heading,
  settings-explorer-item-{settings|policies|log|status}

MODIFY packages/ui/src/components/Explorer.tsx:
- ExplorerSurface union extends with "settings"
- treeForSurface() dispatch adds `if (surface === "settings") return
  <SettingsExplorer />` branch alongside topology/project/specs/for-you
- Existing "none" fallthrough preserved for Dashboard

MODIFY packages/ui/src/components/AppShell.tsx:
- surfaceForPath() adds `if (pathname.startsWith("/settings")) return
  "settings"` line. Prior "none" return reachable only for Dashboard.

Tests (TDD; 8 leaf tests):
- 4 items render (HG-2)
- Each item is a Link with the expected href (HG-3)
- Per-route active-state derivation: /settings only activates
  Settings; /settings/policies activates Policies; /settings/log
  activates Log; /settings/status activates Status
- Explorer root testid + item labels match dispatch

Test infrastructure: tests use createAppTestRouter (vs createTestRouter)
to register all 4 sub-routes so the TanStack Link href + active-state
derivation work at any starting path. waitFor() wraps the synchronous
queries since the test router mounts asynchronously.

Typecheck: touched files clean.

WHAT THIS CHECKPOINT DOES NOT DO (Checkpoint B):
- Register the 3 new routes (/settings/policies, /settings/log,
  /settings/status) in routes.tsx
- Refactor SettingsCenter to be route-driven (remove inline tab state
  + render content per active route segment)
- Create PoliciesPage empty-scaffold component
- Remove the top-row tabs from Settings center

SC-29 EXCEPTION declared: NONE (UI presentation-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 02:37:36 -07:00
mvschwarzandClaude Opus 4.7 f27da1a48a merge(0.3.1): slice 25 follow-on — Overview re-orient + notification banner + Details re-order
--no-ff merge from slice-25-followon-reorient (single commit 4a60ac6).

Seat detail page (/topology/seat/$rigId/$logicalId) refined:

Overview tab stack (top-to-bottom):
1. NotificationBanner — reuses existing components/ui/alert.tsx primitive; sources NodeDetailData.{latestError, recoveryGuidance, startupStatus} (same fields StatusSection already reads — no parallel pipeline); renders null when no message; "Startup failed" / "Attention required" / "Error" headlines per state
2. SeatOverviewTable — RE-ORIENTED from row-per-field to COLUMN-HEADERS + single data row (7 columns: runtime / model / profile / spec / activity / context % / total tokens); cwd + current-work as full-width rows below the data row in the SAME table primitive via colSpan; overflow-x-auto wrapper for 375px mobile horizontal scroll; bg-white/30 column row vs bg-white/15 full-width rows for subtle visual separation within cohesive single table
3. InlineTerminal (black-glass) — unchanged
4. RecentEventsSection — moved to BOTTOM of Overview (was between terminal and end)

LiveNodeCurrentState card REMOVED from Overview (activity is now a column in the table; current-work is a full-width row in the same table; standalone card was redundant).

Details tab RE-ORDER:
1. Startup (TOP) — operator triage priority
2. AgentSpec (agents only)
3. Edges
4. Peers
5. ContextUsage
6. Transcript (bottom)

Fixes prior priority inversion where Startup was buried at position 4 behind relational/topology context.

Triple-gate + design-reviewer:
- design-reviewer SHIP (V1-bar professional-grade tactical-vellum-paper aesthetic met; cohesive single table; Alert primitive reused with ZERO new design tokens; vertical rhythm maps to operator triage flow)
- redo3-guard-3 CLEAR (functional + ACK hygiene; banner sources canonical NodeDetailData fields; LiveNodeCurrentState removal exhaustive)
- velocity-qa PASS on persistent-rsync-refresh founder-walk VM (banner present for advisor.lead recoveryGuidance + hidden for idle seats; 375px overflow-x-auto verified; reduced-motion honored via topology-table-active-shimmer animationName=none; console clean)

24/24 focused tests PASS. UI suite 1023 passed / 1 skipped / 2 failed (same baseline pre-existing failures; zero new).

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 02:32:06 -07:00
mvschwarzandClaude Opus 4.7 4a60ac6e9f feat(topology): re-orient seat overview table + notification banner
V0.3.1 slice 25 follow-on (builds on 84b4888). Five UI changes on the
seat detail page; UI-only; no daemon source changes; SC-29 NONE.

1) Re-orient SeatOverviewTable info table to column-headers.

The 7 compact key-value rows (runtime / model / profile / spec /
activity / context% / total tokens) flip from one-field-per-row to a
single column-header row plus a single data row spanning columns.
Tighter horizontal layout; more glanceable in-place. The cwd and
current-work full-width rows remain in the same table primitive
(not separate cards) below the data row. Renders an
`overflow-x-auto` wrapper around the column-header + data row so a
narrow viewport (375px) scrolls horizontally instead of mashing 7
cells.

2) Add SeatNotificationBanner above the info table.

New component at packages/ui/src/components/SeatNotificationBanner.tsx
surfaces detailed activity / attention messages front-and-center on
Overview. Source-of-truth fields on NodeDetailData:
  - latestError (string | null)
  - recoveryGuidance ({summary, commands[], notes[]} | null)
  - startupStatus ("attention_required" | "failed" | ...)

Same fields drive the StatusSection card in the Details tab; this
banner is a slim Alert-primitive version for the Overview tab.
Renders nothing when there is no active message. Reuses the existing
Vellum `<Alert>` primitive from components/ui/alert.tsx (no new
design tokens).

3) Remove LiveNodeCurrentState card from Overview.

Activity is now a column in the SeatOverviewTable; current-work is a
full-width row in the same table. The standalone card was redundant.
Grep confirmed no external callers beyond the Overview tab
invocation; the function definition was removed alongside the
invocation. Two helper functions (activityLabelFromVisual,
activityTextClassFromVisual) and the activity-visuals imports that
were only used inside LiveNodeCurrentState were removed in the same
pass.

4) Recent Events stays at the bottom of Overview.

Overview stack order is now: notification banner (optional) → info
table → inline terminal → Recent Events. Recent Events surfaces a
distinct shape of data from anything in the table, so it stays per
the dispatch spec.

5) Re-order Details tab.

Old order (slice 25 baseline): Edges → Peers → ContextUsage →
AgentSpec → Startup → Transcript.

New order:
  1. Startup (top — operator's go-to triage view)
  2. AgentSpec (for agents only)
  3. Edges
  4. Peers
  5. ContextUsage detail
  6. Transcript (at bottom)

No content changes; just reorder. PreviewPane stays absent from
Startup (preserved invariant from slice 25; terminal lives in
Overview).

Tests updated (packages/ui/test/live-node-details.test.tsx):

- HG-1: column-headers + data row + 2 full-width rows (replaces
  HG-3 9-row-shape assertion).
- HG-2: Overview DOM order asserted via compareDocumentPosition:
  notification → table → terminal → recent events. Also asserts
  LiveNodeCurrentState testid is absent.
- HG-3: notification banner renders on attention_required +
  latestError + recoveryGuidance set; renders nothing when status is
  ready and no error / guidance.
- HG-4 (preserved): model cell renders em-dash when null; no literal
  "undefined".
- HG-6: Details tab DOM order asserted: Startup → AgentSpec →
  Edges → Peers → Transcript via compareDocumentPosition; PreviewPane
  testid absent.
- PL-019 (preserved): activity column cell + current-work full-width
  row carry their respective data; LiveNodeCurrentState testid
  absent.

Forensic-trace findings:

- Notification source-of-truth: NodeDetailData.{latestError,
  recoveryGuidance, startupStatus}. Same fields the
  StatusSection card already reads in Details tab. No new
  endpoint; no parallel pipeline.
- Vellum alert primitive: packages/ui/src/components/ui/alert.tsx
  exists with variant: "default" | "destructive". Reused directly;
  no new primitive.
- LiveNodeCurrentState callers: ONLY the Overview tab invocation in
  LiveNodeDetails.tsx (grep verified). Safe to remove the function
  entirely.

Verification:

- live-node-details.test.tsx: 24/24 PASS.
- Full UI suite: 1022 passed / 1 skipped / 3 failed (same 3
  pre-existing failures as main 1629b70; cross-checked; zero new).

Hard gates:

- HG-1 column-headers + data row + 7 fields: PASS.
- HG-2 cwd + current-work full-width below data row in same table:
  PASS.
- HG-3 notification banner renders when active message + hidden when
  none: PASS.
- HG-4 LiveNodeCurrentState card removed from Overview: PASS.
- HG-5 Recent Events at bottom of Overview: PASS.
- HG-6 Details order (Startup → AgentSpec + Edges + Peers →
  Transcript): PASS via compareDocumentPosition.
- HG-7 UI focused suite green; no new failures: PASS.
- HG-8 mobile responsive: column wrapper has overflow-x-auto at 375px
  (manual VM walk pending).
- HG-9 design-reviewer V1-bar visual audit on column density:
  DEFERRED to gate.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-12 02:14:34 -07:00
mvschwarz bc8e938ab8 refactor(ui): remove unused rig cmux hook
Delete the unused useRigCmuxLaunch hook and its hook-only test after the table-freeze fix moved the production launcher to the plain launchRigCmux helper.

Choice rationale: launchRigCmux is the actual production API; retaining a dead React hook would invite future maintainers back into the render-state path this slice intentionally avoided.

Verification:
- rg -n "useRigCmuxLaunch" packages/ui/src packages/ui/test || true
- npm --workspace packages/ui test -- launch-cmux-button.test.tsx p5-1-table-cmux.test.tsx rig-graph.test.tsx
- npm --workspace packages/ui run build
- git diff --check
2026-05-12 02:09:44 -07:00
mvschwarz 1629b70187 docs(ui): align cmux launcher helper comment
Update the LaunchCmuxButton doc-comment after the launcher refactor from the hook wrapper to the plain launchRigCmux helper.

Verification:
- git diff --check
2026-05-12 01:53:11 -07:00
mvschwarz f9dac1f679 fix(ui): isolate cmux launch button from table render loop
Keep the rig-level CMUX launcher's transient pending/status UI out of React
state. In the rig Table tab, scheduling React state from this sibling launcher
can send Chromium into a renderer spin; the launch action is an external side
effect, so a small uncontrolled status island keeps the table mounted without
pulling it into the click path.

The endpoint, response parsing, and honest error text are unchanged.

Verification:
- npm --workspace packages/ui test -- launch-cmux-button.test.tsx use-rig-cmux-launch.test.tsx p5-1-table-cmux.test.tsx rig-graph.test.tsx
- npm --workspace packages/ui run build
- git diff --check
- VM founder-walk proof on Table tab: Launch in CMUX no longer freezes the page
2026-05-12 01:49:19 -07:00
mvschwarz 12f1565fb0 fix(ui): keep cmux launcher state local
Avoid React Query mutation notifications for the rig-level CMUX launcher. The
Table view can freeze when Launch in CMUX updates shared query state while the
large topology table is mounted; the launcher only needs local request status.

Preserve the same endpoint, response, and error behavior.

Verification:
- npm --workspace packages/ui test -- launch-cmux-button.test.tsx use-rig-cmux-launch.test.tsx p5-1-table-cmux.test.tsx rig-graph.test.tsx
- npm --workspace packages/ui run build
2026-05-12 01:29:37 -07:00
mvschwarz 05f77dfbd9 docs(ui): align author cmux launcher comment
Update AuthorAgentTag's stale useCmuxLaunch endpoint comment after the shared hook moved from focus-only behavior to the open-or-focus /open-cmux launcher path.
2026-05-12 00:20:53 -07:00
mvschwarz 914eaef5a1 fix(ui): open cmux table actions through launcher
Route shared node cmux launch actions through /open-cmux instead of the stale focus-only endpoint. This fixes table and hybrid graph buttons for nodes that do not already have a cmux surface binding.

Also add pending affordance on those buttons and update table regression coverage to assert the open-or-create endpoint.
2026-05-12 00:16:41 -07:00
mvschwarz a88ad0aae6 fix(daemon): send cmux workspace title for launcher
cmux 0.63.2 accepts `workspace.create` params containing `name`, but ignores it for the visible workspace title and creates a default-titled workspace. The CLI docs and live probe show the visible field is `title`.

Keep OpenRig's adapter API as createWorkspace(name, cwd), but translate it to the cmux RPC payload `{ title, cwd }` so Launch in CMUX creates workspaces named after the rig.

Verification:
- Live cmux probe: workspace.create with `name` produced default title; workspace.create with `title` produced requested title; both temp workspaces closed.
- npm --workspace packages/daemon test -- cmux-adapter-layout-methods.test.ts cmux-transport-layout-methods.test.ts rig-cmux-launch-route.test.ts
- npm --workspace packages/daemon run build
2026-05-11 23:59:38 -07:00
mvschwarz 1734cf77a7 test(cli): cover config parent json flag
Add a regression test for the parent-option form:
  rig config --json get daemon.host

Verification:
- npm --workspace packages/cli test -- config.test.ts -t "rig config --json get <key> prints value with source metadata" PASS
- Temporarily removed cmd.opts().json fallback; filtered test FAILed as expected with plain-text output
- Restored source; filtered test PASS
- env -u OPENRIG_PORT -u OPENRIG_HOST -u OPENRIG_DB -u OPENRIG_TRANSCRIPTS_ENABLED -u OPENRIG_TRANSCRIPTS_PATH npm --workspace packages/cli test -- config.test.ts PASS (18/18)
2026-05-11 23:35:01 -07:00
mvschwarzandClaude Opus 4.7 2912899f3b merge(0.3.1): slice 24 cmux-workspace-launch — Launch in CMUX button + daemon route + adapter extensions
--no-ff merge from slice-24-cmux-workspace-launch (15 commits: 4 TDD red-then-green checkpoints + 3 repair commits + 2 carry-forward test tightenings + 1 QA fix at tip).

Adds a "Launch in CMUX" button to the rig-scope topology tab bar (`/topology/rig/$rigId`) that programmatically creates a cmux workspace + populates it with the rig's agents in a 2-column max grid, one panel per agent, with multi-workspace overflow at 12 agents.

Architecture (OPT-B per pre-scaffold spike against cmux RPC):
- Extended existing packages/daemon/src/adapters/cmux.ts CmuxAdapter with new RPC methods routed through cmux's `cmux rpc <method>` generic CLI subcommand: splitSurface, listPaneSurfaces, createWorkspace, closeWorkspace
- NEW packages/daemon/src/domain/cmux-layout-service.ts: computeLayout(N) + chunkAgents(agents) + buildWorkspace(name, cwd, agents) + launchForRig(rigId); cmux timing gotchas honored (OP_DELAY_MS=500 after splits; FINAL_SETTLE_MS=1000; listSurfaces retry-with-backoff)
- NEW daemon route POST /api/rigs/:rigId/cmux/launch (returns workspaces + agents + blanks)
- pickNonCollidingName auto-suffix for both existing-workspace and N>12 multi-workspace cases
- NodeInventory filter: sessionStatus="running" + tmux-attachability
- Column-major fill order
- LaunchCmuxButton mounted via TopologyViewModeTabs trailing-slot (sibling-not-nested for tablist a11y)
- No-truncate error wrapping for honest 3-part error visibility

Configurability: MAX_COLS=2 + MAX_PER_WORKSPACE=12 as constants in CmuxLayoutService with TODO(0.3.2) markers for graduation to settings keys cmux.workspace_columns + cmux.workspace_max_panels.

QA fix (20820b2 at tip): existing-workspace suffix edge case exposed live cmux workspace.list response shape incompatibility; daemon-only normalization fix; reverified suffix-proof -> suffix-proof-2 via VNC + browser agent.

Triple-gate verdicts:
- velocity-guard ADVISORY-OK across 4 checkpoints (incremental review during repair commits)
- velocity-qa CLEAR via VNC + browser-agent lane on founder-walk VM: 6-agent grid -> 2x3 layout AND 13-agent grid -> multi-workspace -2 suffix both visually verified; cmux-unavailable 503 + recovery guidance captured. HG-11/12/13 all green (visual gates unblocked by VNC + cmux-install in VM lane).

Daemon cmux-focused suite: 143/143 PASS host + VM. UI focused suite: 18/18 PASS.

SC-29 EXCEPTION declared: #10. Adds POST /api/rigs/:rigId/cmux/launch + CmuxLayoutService domain surface + 4 CmuxAdapter RPC method extensions routed through `cmux rpc <method>` generic CLI subcommand. No new ConfigStore key, env var, schema migration, or event type.

Follow-ons (NOT this slice; tracked for 0.3.2):
- Graduate MAX_COLS + MAX_PER_WORKSPACE constants to user-configurable settings keys
- Snake_case-vs-camelCase param convention audit on existing CmuxAdapter methods (potential latent bug class)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Codex <[email protected]>
2026-05-11 23:03:59 -07:00
mvschwarz 20820b235a fix(daemon): normalize cmux workspace list responses
Dogfood against cmux 0.63.2 showed `cmux list-workspaces --json`
can return plain text even though the flag is accepted. Prefer the RPC
workspace.list surface when available, normalize ref/title rows to the
adapter's id/name shape, and keep a plaintext fallback for older command
surfaces.

Tests:
- npm --workspace packages/daemon test -- cmux-transport.test.ts cmux-transport-layout-methods.test.ts rig-cmux-launch-route.test.ts
- npx vitest run test/cmux-adapter-layout-methods.test.ts test/cmux-transport-layout-methods.test.ts test/cmux-adapter.test.ts test/cmux-transport.test.ts test/cmux-layout-service.test.ts test/rig-cmux-launch-route.test.ts
- npm --workspace packages/daemon run build
2026-05-11 22:55:26 -07:00
mvschwarz 19b7a6f51e test(topology): reset shared activity store in event hub tests
Add the topology activity singleton reset to the P5.3 event hub test
beforeEach so cross-file Vitest order cannot leak activity state from
LiveNodeDetails or other consumers.

Verification:
- npm --workspace packages/ui test -- p5-3-topology-event-hub.test.tsx live-node-details.test.tsx
- npm --workspace packages/ui test -- live-node-details.test.tsx p5-3-topology-event-hub.test.tsx
- npm --workspace packages/ui run build
- git diff --check
2026-05-11 22:06:06 -07:00
mvschwarzandClaude Opus 4.7 84b4888ccf merge(0.3.1): slice 25 node-page Overview/Details — info table + tab consolidation + activity alignment
--no-ff merge from slice-25-node-page-overview-details (4 commits: f48ea03 + 46b5132 + 594b0a2 + 8e9868d).

Seat detail page (/topology/seat/$rigId/$logicalId) restructured:
- Two tabs total: Overview (default; renamed from Identity) + Details
- Terminal tab removed; black-glass smoked terminal moves inline into Overview
- Overview composition: 9-row info table -> inline terminal -> Activity card + Recent Events card
- Info table has two row shapes:
  - Compact 2-col rows (7): runtime / model / profile / spec / activity / context% / total tokens
  - Full-width rows (2): cwd (truncate-with-ellipsis + tooltip) / current work
- Activity state surfaces via shared useTopologyActivity (same source the topology graph + table read; no parallel pipeline); state names align with topology vocabulary (active / needs_input / idle / blocked); shimmer animation on "active" reuses topology-table-shimmer.css; prefers-reduced-motion honored
- Current-work row reads existing data.currentQitems[0]; em-dash when no in-progress qitem; updates via existing useNodeDetail 30s refetch interval
- Details tab composes Edges + Peers + AgentSpec + Startup (preview pane removed; terminal lives in Overview) + Transcript
- Mobile: 44px tap targets at 375px viewport; no horizontal overflow

HG-7 source-pinning discriminator: anchored Tab union regex requires trailing semicolon + negative assertion rejecting 3+ member unions; verified discriminator-first by injecting a fake third tab and confirming the test FAILS (prior prefix-matcher would have silently passed).

Triple-gate verdicts:
- redo3-guard-3 CLEAR (functional + commit-body hygiene; discriminator verified by independent injection test)
- velocity-qa PASS on persistent-rsync-refresh founder-walk VM (Overview default; 9-row table; topology activity sync; CWD tooltip + truncate; Details composition; mobile responsive; reduced-motion honored)

SC-29 EXCEPTION declared: NONE.

UI focused suite: 1001/1006 (4 pre-existing failures unchanged from baseline; zero new).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 22:01:10 -07:00
mvschwarzandClaude Opus 4.7 8e9868d9cc test(topology): anchor slice-25 Tab union discriminator
V0.3.1 slice 25 forward-fix-1. The earlier source-pinning regex at
packages/ui/test/node-selection-migration.test.tsx:96 only anchored
the prefix of the Tab union literal:

  /type\s+Tab\s*=\s*"overview"\s*\|\s*"details"/

That regex matches both shapes:

  type Tab = "overview" | "details";                  // intended
  type Tab = "overview" | "details" | "terminal";     // silent regress

It does NOT discriminate a reintroduced third tab — the test would
have passed equally for both, defeating its purpose as a regression
guard for the 2-tab slice-25 invariant.

Fix combines two assertions so the union shape is pinned exactly:

(1) Positive — anchored declaration end:
    /type\s+Tab\s*=\s*"overview"\s*\|\s*"details"\s*;/
    Requires the trailing `;` immediately after `"details"`, so the
    union cannot extend silently.

(2) Negative — no 3-or-more-member string union for the Tab
    declaration:
    !/type\s+Tab\s*=\s*"[^"]+"\s*\|\s*"[^"]+"\s*\|\s*"[^"]+"/
    Catches any future silent reintroduction with a 3rd member.

Discriminator verified by temporarily injecting a fake 3rd tab into
LiveNodeDetails.tsx (`type Tab = "overview" | "details" | "terminal";`)
and re-running the test: it FAILS with the expected mismatch on the
positive regex. Restored to the intended 2-member shape: test PASSES.

Non-blocking cleanup bundled: the LiveNodeDetails.tsx file header +
two internal comments referenced the pre-amendment "7-field" info
table; refreshed to "9-field" matching the scope-amendment landed at
46b5132. (These refreshes were folded in alongside the subsequent
topology-activity-state alignment commit 594b0a2; current source
reads "9-field" in all three positions.)

Verification:
- node-selection-migration.test.tsx: 10/10 PASS.
- Discriminator verified to fail-on-regression by injecting fake 3rd
  member into source.
- live-node-details.test.tsx: 22/22 PASS (post-594b0a2; topology
  activity wiring landed cleanly).
- Full UI suite: 1001 passed / 1 skipped / 4 failed — same 4
  pre-existing failures as main; zero new from the forward-fix.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:57:55 -07:00
mvschwarzandClaude Opus 4.7 a2bc4ba337 fix(ui): honest error must not truncate + tablist a11y structure (slice 24 Checkpoint D repair)
velocity-guard 24.D BLOCKING-CONCERN at 1be8e67. Two repairs:

PRIMARY (error truncation violates HG-10/HG-13 honest-error contract):
Prior LaunchCmuxButton status span had `max-w-md truncate` for both
success AND error states. Daemon's honest 3-part error like "cmux is
not available on this host — can't launch workspace — install cmux
from https://cmux.io and run: cmux ping" would visually truncate to
just the first part. Operators see the FACT but lose the ACTION
guidance.

Repair (LaunchCmuxButton.tsx):
- Status span no longer uses `truncate` or `overflow-hidden`
- Width-bound the wrapper to max-w-2xl (wider than before) + use
  `whitespace-normal break-words leading-relaxed` so the full message
  wraps onto multiple lines instead of clipping
- Applied to both success and error states for consistency (success
  multi-workspace case can also be long)

SECONDARY (tablist a11y — trailing inside role=tablist):
Prior TopologyViewModeTabs put the optional trailing slot INSIDE the
role="tablist" div. Per ARIA, tablist children should only be tabs;
mixing non-tab interactive elements confuses keyboard navigation +
screen readers.

Repair (TopologyViewModeTabs.tsx):
- Without trailing: render just the tablist (no outer wrapper) —
  preserves prior shape exactly so test-id contracts stay backward
  compatible with non-rig-scope pages
- With trailing: render an outer flex wrapper `<div
  data-testid="${prefix}-tab-bar">` containing the tablist AND the
  trailing slot as SIBLINGS. Tablist scoped to tabs only; trailing
  slot has ml-auto for far-right placement.

Tests added (7 new):

LaunchCmuxButton (3 regressions):
- "error status does NOT have truncate class" — discriminating;
  asserts className lacks `\btruncate\b` and `\boverflow-hidden\b`;
  asserts presence of `\bwhitespace-normal\b|\bwhitespace-pre-line\b`
- "error status text content includes 'cmux ping' action phrase" —
  verifies the full daemon message reaches the DOM (not just the fact)
- "error status text content includes 'rig up <name>' action phrase" —
  cross-check for the rig_not_running case

TopologyViewModeTabs trailing a11y (4 tests in new test file):
- "without trailing: renders just the tablist (no outer wrapper)" —
  back-compat assertion; prevents future regression where trailing
  becomes default
- "with trailing: tablist is INSIDE outer tab-bar but trailing is
  OUTSIDE the tablist" — DISCRIMINATING for the a11y repair; uses
  Element.contains() to prove the structural relationship
- "trailing slot has ml-auto class" — placement test (HG-7 component
  coverage strengthened per guard's secondary note)
- "tablist contains ONLY the tab buttons (no non-tab role=tab
  children)" — pins the tablist invariant; would fail under prior
  impl where the trailing button was a tablist descendant

Total UI test counts:
- launch-cmux-button.test.tsx: 10 (was 7; +3 truncation regressions)
- topology-view-mode-tabs-trailing.test.tsx: 4 NEW
- use-rig-cmux-launch.test.tsx: 4 (unchanged)
- Total new this slice: 18 (was 11; +7 regressions across both
  concerns)

Full UI suite: 768 pass + 1 skipped + 52 pre-existing fails (same
baseline). +7 net pass vs prior tip; zero new failures.

Typecheck: touched files clean.

SC-29 EXCEPTION declared: NONE at this commit
(UI hardening on existing surfaces; SC-29 #10 declared at Checkpoint C
a18e997.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:53:42 -07:00
mvschwarzandClaude Opus 4.7 1be8e675c4 feat(ui): LaunchCmuxButton + useRigCmuxLaunch hook + rig-scope tab-bar trailing slot (slice 24 Checkpoint D)
UI surface for the "Launch in CMUX" feature, completing slice 24's
4-checkpoint chain.

NEW packages/ui/src/hooks/useRigCmuxLaunch.ts:
- useMutation wrapper around POST /api/rigs/:rigId/cmux/launch
- Returns workspaces: CmuxLaunchedWorkspace[] on success
- Throws Error carrying the daemon's honest 3-part message on 4xx/5xx
  (falls back to "HTTP <status>" when body is unparseable)
- Distinct from existing useCmuxLaunch.ts which targets the per-NODE
  focus endpoint POST /api/rigs/:rigId/nodes/:logicalId/focus

NEW packages/ui/src/components/topology/LaunchCmuxButton.tsx:
- Renders button labeled "Launch in CMUX" (per README §Button label
  DECIDED); "Launching…" while in-flight; disabled during pending
- Inline status surface below the button: success message names the
  workspace(s) + agent count; error message surfaces the daemon's
  honest 3-part error verbatim (status persists for 6s success /
  8s error, then auto-clears via setTimeout)
- Mobile-hide via Tailwind `hidden lg:inline-flex` per README §Mobile

MODIFY packages/ui/src/components/topology/TopologyViewModeTabs.tsx:
- New optional `trailing?: ReactNode` prop rendered with ml-auto inside
  the tablist flex container. Allows scope pages to mount tab-bar-far-
  right elements (e.g., the slice 24 Launch button per README §Button
  placement Option C). Other scope pages that don't pass trailing
  render unchanged.
- Existing test-id contracts preserved; new `${testIdPrefix}-trailing`
  data-testid on the slot wrapper.

MODIFY packages/ui/src/components/topology/ScopePages.tsx:
- RigScopePage's <TopologyViewModeTabs> now passes trailing={<LaunchCmuxButton rigId={rigId} />}
- HostScopePage + PodScopePage + SeatScopePage unchanged (no trailing
  slot consumed); button is rig-scope-only per README §Button placement.

Tests added:
- 4/4 useRigCmuxLaunch hook tests (POST shape, success workspaces[],
  error path, network failure path)
- 7/7 LaunchCmuxButton tests (label, mobile-hide class, click POSTs,
  loading state, success toast with workspace + agent count, honest
  3-part error toast, error contains actionable hint)

Total UI tests: 765 pass + 1 skipped + 52 pre-existing fails on main
HEAD baseline (verified via stash-and-recheck: failure count identical
with and without this commit's UI changes; AppShell global-polls
leakage per banked feedback_appshell_global_polls_test_pollution).
Net: +11 passing tests; zero new failures.

Typecheck: all 4 touched UI files clean.

This completes slice 24's UI scope:
- Checkpoint A: CmuxAdapter RPC method extensions + transport mapping
- Checkpoint B: CmuxLayoutService (algorithm + chunking + ordering +
  buildWorkspace with timing gotchas)
- Checkpoint C: POST /api/rigs/:rigId/cmux/launch + NodeInventory wiring
- Checkpoint D: UI button + hook + tab-bar trailing slot (this commit)

Next: slice-close ACK + walk packets + final hand-off DM to orch-lead
+ velocity-qa dispatch for Tart VM clean-install proof (HG-11/12/13
remain founder-host QA per README §QA-lane).

SC-29 EXCEPTION declared: NONE at this checkpoint
(UI consumes the SC-29 #10 daemon endpoint declared at Checkpoint C
a18e997; no new vector added.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:49:53 -07:00
mvschwarz 594b0a27a5 fix(topology): align seat overview activity with graph state
Seat Overview now reads the same topology-activity visual state used by graph and table views instead of only the static node-detail activity snapshot. The topology activity hook keeps recent node activity across graph/table to seat-page navigation, so a clicked active row stays active on the destination page.

- Pass topology activity visual state into SeatOverviewTable and LiveNodeCurrentState
- Share the topology activity store across hook remounts
- Cover graph/table activity warmup followed by seat detail render

Verification:
- npm --workspace packages/ui test -- live-node-details.test.tsx p5-3-topology-event-hub.test.tsx
- npm --workspace packages/ui run build
2026-05-11 21:47:45 -07:00
mvschwarzandClaude Opus 4.7 52c70320fa test(ui): failing tests for useRigCmuxLaunch hook + LaunchCmuxButton (slice 24 Checkpoint D)
useRigCmuxLaunch (4 tests):
- POSTs to /api/rigs/:rigId/cmux/launch with method POST
- Returns workspaces array on success
- Throws with daemon error message on 4xx response
- Throws on network failure

LaunchCmuxButton (7 tests):
- Renders button with "Launch in CMUX" label
- Has lg:inline + hidden classes (mobile-hide per README)
- Click POSTs to the daemon route
- Shows loading state during in-flight request (button disabled +
  text changes to "Launching" or similar)
- Shows success toast with workspace name + agent count
- Shows honest 3-part error toast on 4xx response
- Error toast contains actionable hint per banked
  building-agent-software doctrine

Tests fail because src/hooks/useRigCmuxLaunch.ts and
src/components/topology/LaunchCmuxButton.tsx do not exist yet.
Implementation lands in the next commit.

SC-29 EXCEPTION declared: NONE
(UI hook + component only; consumes the SC-29 #10 daemon endpoint
declared at Checkpoint C a18e997.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:46:17 -07:00
mvschwarzandClaude Opus 4.7 d7aa02346c test(daemon): discriminate partial-workspace claim in mid-flight failure response (slice 24.C carry-forward)
velocity-guard 24.C-repair ADVISORY-OK-WITH-CONCERNS carry-forward:
"partial-workspace claim from Checkpoint C is still not proven by
the test shape unless it was tightened elsewhere ... add/assert a
failure-after-first-workspace case before close."

Prior partial-info test used failOn="splitSurface" which fails on
the FIRST workspace's split (chunk 0), so partial[] was always
empty in that scenario — didn't discriminate whether the route
actually accumulates successfully-built workspaces before a later
chunk's failure.

New test "500 partial-workspace info DISCRIMINATING: chunk 0
succeeds + chunk 1 fails":
- 13 agents → 2 chunks ("fanout" with 12 + "fanout-2" with 1)
- New makeMockAdapter option `failOnWorkspaceName`: createWorkspace
  fails ONLY for the matching name
- Configured failOnWorkspaceName="fanout-2" → chunk 0 builds fully
  (createWorkspace + 11 splits + 12 sends), chunk 1 fails immediately
  at createWorkspace
- Asserts response.partial has length 1 with name="fanout",
  agents.length=12, blanks=0 (full chunk-0 success info)
- Under a broken impl that doesn't accumulate partial, partial would
  be undefined or empty and the assertion would fail

13/13 route tests green (was 12; +1 discriminating partial test).
140 + 1 = 141 cmux family total.

SC-29 EXCEPTION declared: NONE (test only; tightens prior #10
declaration's behavior contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:44:39 -07:00
mvschwarzandClaude Opus 4.7 e3b2a7dd57 fix(daemon): filter NodeInventory on sessionStatus="running" + tmux attachability (slice 24 Checkpoint C repair)
velocity-guard 24.C BLOCKING-CONCERN at a18e997: /api/rigs/:rigId/cmux/
launch passed canonicalSessionName-presence as the running gate, but
getNodeInventory emits a canonicalSessionName for the LATEST recorded
session regardless of sessionStatus. A node with sessionStatus="exited"
or "detached" would still have a name, the route would push it into
orderedSessions, and the daemon would send "tmux attach -t <stale>"
into the cmux panel — failing silently inside the panel while the
route returned 200. Violates README HG-6 / R5 honest-failure contract.

Repair (packages/daemon/src/routes/rig-cmux.ts):
- NodeInventoryStubEntry interface extended with sessionStatus +
  attachmentType fields.
- New launchableByLogical filter: requires
    canonicalSessionName != null
    AND sessionStatus === "running"
    AND (attachmentType == null || attachmentType === "tmux")
  Non-tmux attachmentType (e.g., "ssh", future "remote") falls through
  to filtered-out — can't `tmux attach -t` over those.
- orderedSessions now sourced from launchableByLogical instead of
  raw canonicalSessionName map.

Server.ts wiring is unchanged: getNodeInventory(deps.rigRepo.db, rigId)
already returns NodeInventoryEntry with sessionStatus +
attachmentType, so the route receives the data it needs. The bug was
purely in the route's filter logic.

Tests added (3 new regressions in test/rig-cmux-launch-route.test.ts):
- "412 rig_not_running when nodes have canonicalSessionName but
  sessionStatus is 'exited'/'detached'" — direct discriminator for the
  bug class. Without the sessionStatus filter, route would return 200
  and try to send tmux-attach to a stale session.
- "mixed stale+running: only running sessions are attached" — proves
  the filter cuts stale entries OUT while keeping running ones IN. Under
  the prior impl, "b@mixed-rig" (sessionStatus=exited) would appear in
  the agents array and the equality assertion would fail.
- "412 rig_not_running when attachmentType is non-tmux even if
  sessionStatus is running" — defensive: a hypothetical future
  attachmentType="ssh" would still get filtered since cmux can't
  tmux-attach to it.

Test stub updated: makeNodeInventoryStub now mirrors getNodeInventory's
behavior — emits one entry per node, sessionStatus defaults to
"running" when canonicalSessionName is set (preserves existing
happy-path tests) and "null" otherwise. Per-node overrides for new
regressions.

Total: 12/12 route tests green (was 9; +3 regressions). 137 + 3 = 140
cmux family total green; zero regression on prior tests.

SC-29 EXCEPTION declared: #10 (still provisional; same number as 24.C
pre-repair; this commit doesn't add a new SC-29 vector — it tightens
filter logic in the route added at 24.C).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:42:32 -07:00
mvschwarzandClaude Opus 4.7 a18e99728f feat(daemon): POST /api/rigs/:rigId/cmux/launch + CmuxLayoutService wiring (slice 24 Checkpoint C)
Per-rig CMUX workspace launcher route. Coordination:
  1. rigRepo.getRig(rigId)         → 404 rig_not_found if missing
  2. cmuxAdapter.isAvailable()     → 503 cmux_unavailable if not connected
  3. nodeInventoryFn(rigId)        → map logicalId → canonicalSessionName
  4. order agents by rig.nodes DB order (ORDER BY created_at =
     pod-then-member from rig spec per README §52)
  5. filter to running agents (has canonicalSessionName)
                                   → 412 rig_not_running if none running
  6. CmuxLayoutService.chunkAgents() into MAX_PER_WORKSPACE chunks
  7. pickNonCollidingName() — auto-append -2/-3/... per README R2
     when the rig's name or a generated suffix already exists in cmux
  8. cmuxLayoutService.buildWorkspace() per chunk sequentially
                                   → 500 build_workspace_failed with
                                     partial: workspaces[] on mid-flight failure
  9. 200 { ok, workspaces: Array<{ name, agents, blanks }> }

Honest 3-part error messages per banked building-agent-software
doctrine:
- "Rig '<id>' not found — can't launch cmux workspace — try: rig ps"
- "cmux is not available on this host — can't launch workspace —
   install cmux from https://cmux.io and run: cmux ping"
- "Rig '<name>' has no running tmux sessions — can't attach to
   anything — run: rig up <name>"

Wiring (packages/daemon/src/server.ts):
- import rigCmuxRoutes + CmuxLayoutService + getNodeInventory
- Per-request context: c.set("cmuxLayoutService", new
  CmuxLayoutService(deps.cmuxAdapter)) and c.set("nodeInventoryFn",
  (rigId) => getNodeInventory(deps.rigRepo.db, rigId))
- app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes)

Tests: 9/9 route tests green (TDD-first at 324f9f9 → impl in this
commit). Cmux family total: 137/137 (CmuxAdapter + transport +
layout-service + route).

Typecheck: clean for new files (rig-cmux.ts + cmux-layout-service.ts);
server.ts typecheck passes after wiring.

SC-29 EXCEPTION declared: #10 (provisional)

This slice adds a new daemon endpoint POST /api/rigs/:rigId/cmux/launch
+ a new domain service CmuxLayoutService + four new RPC method wrappers
on CmuxAdapter routed through cmux's `rpc <method>` CLI subcommand.
Boundary: new endpoint surface + new service + new adapter methods +
new generic-RPC transport path. No new ConfigStore allowlist keys; no
new env vars; no schema migration; no new event type.

The cumulative ledger reference (best-effort at commit-time; confirm
at slice close against authoritative ledger):
- #1-4 kernel + shell-redesign-v1
- #5/#6 project-observability
- #8 plugin-primitive slice 3.3 (plugin-discovery + GET routes)
- #9 plugin-primitive slice 3.2 (vendored plugin assets + PluginVendorService)
- (no #10 declared yet that I can find in release-0.3.1 slices)
- This slice → provisional #10

Driver will confirm the exact number against the cumulative SC-29
ledger at slice close ACK packet authoring.

Carry-forward from prior checkpoints (non-blockers):
- rpc-help-gating in cmux-transport.ts buildCommand (QA scope)
- Live response-shape proof for cmux RPC methods (QA scope)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:39:07 -07:00
mvschwarzandClaude Opus 4.7 46b5132e62 feat(topology): seat-overview info table 9-field + 2-row-shape
V0.3.1 slice 25 scope amendment (delta to attempt-1 at f48ea03).
Adds two info-table fields + a row-shape convention without
restructuring the slice's broader Overview/Details architecture.

Two row-shape conventions in one cohesive table:

- COMPACT (2-col key/value) — 7 rows: runtime / model / profile /
  spec / activity / context% / total tokens. Same shape as
  attempt-1.
- FULL-WIDTH (single cell with inline label) — 2 rows: cwd / current
  work. New shape; cwd was a compact row in attempt-1.

New "activity" compact row (HG-3a):

- Reads from data.agentActivity via getActivityState(); same source
  LiveNodeCurrentState uses on the same page, same baseline the
  topology graph/table read. No parallel source of truth.
- State "running" maps to the label "active" so the seat page agrees
  with the topology graph/table naming convention.
- HG-3c shimmer reuse: applies the slice-14
  .topology-table-active-shimmer CSS class on the value span when
  state === "running". The class honors prefers-reduced-motion. Slice
  14's CSS module is imported into SeatOverviewTable directly; no
  copy.

New "current work" full-width row (HG-3b):

- Reads from data.currentQitems[0] — already exposed by NodeDetailData
  via /api/rigs/<rigId>/nodes/<logicalId>; same source
  LiveNodeCurrentState's Current Work pane uses. No new daemon
  endpoint; no parallel fetch.
- Renders qitem id + body excerpt when present; em-dash placeholder
  when no in-progress qitem.
- Real-time refresh: react-query's existing useNodeDetail refetch
  cadence on this page propagates qitem changes; no additional SSE
  wiring needed at this layer.

CWD becomes full-width (HG-3d):

- Was a compact 2-col row in attempt-1 with the path crushed into the
  value cell. Now a full-width row with truncate-ellipsis on the
  value + title attribute carrying the full path for tooltip-on-hover
  inspection of long paths.

Tests added (extend live-node-details.test.tsx):

- HG-3: shape now asserts 9 fields total with explicit data-row-shape
  (compact for 7, full-width for cwd + current-work).
- HG-3a: activity row shows "active" + shimmer class when
  agentActivity.state="running".
- HG-3a (idle): activity row shows "idle" without shimmer when
  state="idle".
- HG-3b: current-work row surfaces qitem id + body excerpt when
  in-progress.
- HG-3b (empty): current-work renders em-dash when currentQitems[]
  is empty.
- HG-3d: cwd row is data-row-shape="full-width" + title attribute set
  + value cell carries truncate class.

Forensic-trace findings (per scope-amendment dispatch):

- Activity source: NodeDetailData.agentActivity, read via
  getActivityState() in lib/activity-visuals.ts. Same source the
  existing LiveNodeCurrentState pane uses. The topology graph + table
  use the same baseline fused with stream events via
  useTopologyActivity; the seat detail page reads the baseline only,
  which matches LiveNodeCurrentState's existing behavior on this
  surface (no new subscription wiring required for slice 25 scope).
- Current-work source: NodeDetailData.currentQitems[], already shaped
  by the /api/rigs/<rigId>/nodes/<logicalId> endpoint. LiveNodeCurrentState
  already reads it. Reusing in-place.
- Shimmer source: packages/ui/src/components/topology/topology-table-shimmer.css
  declares .topology-table-active-shimmer + the prefers-reduced-motion
  override. Imported directly into SeatOverviewTable for class reuse.

Verification:

- live-node-details.test.tsx: 21/21 PASS (was 16; +5 amendment tests).
- Full UI suite: 1000 passed / 1 skipped / 4 failed (same 4
  pre-existing failures as main; zero new failures from this slice).

Hard gates (revised):

- HG-3 9 fields total: PASS.
- HG-3a activity wired live: PASS.
- HG-3b current-work wired live: PASS.
- HG-3c shimmer on active: PASS (slice-14 class reused).
- HG-3d cwd full-width with truncate: PASS.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:38:37 -07:00
mvschwarzandClaude Opus 4.7 324f9f907c test(daemon): failing tests for POST /api/rigs/:rigId/cmux/launch route (slice 24 Checkpoint C)
9 tests pin the route's coordination contract from slice 24 README §Daemon-side:

- 404 rig_not_found when rig doesn't exist
- 503 cmux_unavailable when CmuxAdapter not connected (honest 3-part error)
- 412 rig_not_running when no NodeInventory entries have a canonicalSessionName
- 200 happy path: 3 running agents → 1 workspace named after rig
- 200 multi-workspace: 13 agents → workspaces "<rig>" + "<rig>-2"
- 200 conflict-resolve: existing cmux workspace "<rig>" → new workspace "<rig>-2"
- 200 sequential-suffix-resolve: existing "<rig>"+"-2"+"-3" → new "<rig>-4"
- 500 build_workspace_failed with partial info when buildWorkspace fails mid-flight
- pod-then-member ordering preserved (rig.nodes DB ORDER BY created_at = spec order)

Tests fail at transform because src/routes/rig-cmux.ts does not yet
exist. Implementation lands in the next commit.

SC-29 EXCEPTION declared at this slice: yes
(new daemon endpoint POST /api/rigs/:rigId/cmux/launch + new
CmuxLayoutService surface; exact SC-29 number TBD against cumulative
ledger at slice close.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:35:43 -07:00
mvschwarzandClaude Opus 4.7 f48ea03040 feat(topology): consolidate seat detail to Overview + Details
V0.3.1 slice 25 node-page Overview/Details consolidation.

The seat detail page at /topology/seat/$rigId/$logicalId restructures
from a 5-tab (identity / agent-spec / startup / transcript / terminal)
layout to a 2-tab (overview / details) layout. Overview is the
default and answers the at-a-glance questions operators ask when
landing from topology; Details holds everything else.

Tab structure changes:

- Default tab: overview (analogous to slice 12's project-scope
  default-tab flip from story to overview).
- Tab list: ["overview", "details"]. Terminal / identity / agent-spec /
  startup / transcript no longer exist as named tabs.
- Overview body order: SeatOverviewTable (dense 7-field info table)
  -> InlineTerminal (black-glass smoked terminal embedded directly)
  -> LiveNodeCurrentState (Activity + Current Work) ->
  RecentEventsSection.
- Details body: EdgesSection -> PeersSection -> ContextUsageSection ->
  AgentSpecSection (for agents only) -> StartupContent (without
  PreviewPane; preview is now the inline terminal in Overview) ->
  TranscriptContent.

New component: packages/ui/src/components/SeatOverviewTable.tsx

- Dense 7-row info table. Density anchor: TopologyTableView; same
  compact-mono cells, 1px outline-variant cell borders, vellum
  surface chrome.
- Rows: runtime (RuntimeBadge), model, profile, spec (name@version),
  cwd, context% (from contextUsage.usedPercentage when availability
  is "known"), total tokens (sumTokenCounts via the same helpers
  TopologyTableView uses).
- Graceful absence: any null/undefined/empty cell renders an em-dash
  placeholder. Model in particular is commonly absent on rig-spec
  members that don't declare one; the row stays visible.
- Per-row testids (seat-overview-row-<key>) + per-cell testids
  (seat-overview-cell-<key>) for HG-3 + HG-4 verification.

Modified: packages/ui/src/components/LiveNodeDetails.tsx

- Tab union literal: "overview" | "details" (was 5-key).
- Default activeTab: "overview" (was "identity").
- Old IdentityTab + IdentitySummary + IdentityField + TerminalTab
  removed. Old StartupTab refactored into StartupContent (drops the
  PreviewPane; everything else preserved). Old TranscriptTab
  refactored into TranscriptContent (same body).
- New OverviewTab composes SeatOverviewTable + InlineTerminal +
  LiveNodeCurrentState + RecentEventsSection.
- New DetailsTab composes EdgesSection + PeersSection +
  ContextUsageSection + AgentSpecSection (gated on isAgent) +
  StartupContent + TranscriptContent.
- Dropped unused PreviewPane import (preview no longer rendered on
  the seat detail page; the inline terminal is the operator-facing
  preview).

Tests updated:

- packages/ui/test/live-node-details.test.tsx fully rewritten to
  target the 2-tab layout. 16 it() blocks cover HG-1 (default
  Overview), HG-2 (Overview DOM order via compareDocumentPosition),
  HG-3 (7 fields), HG-4 (graceful em-dash for missing model), HG-5
  (inline black-glass terminal in Overview), HG-6 (Details
  composition + PreviewPane absent), HG-7 (legacy tabs gone),
  infrastructure node parity (no agent-spec card), Startup file
  trigger preserved, Transcript surfaced inside Details, slice 3.3
  Plugins section preserved, PL-019 activity + qitems preserved,
  resume action glyph preserved.
- packages/ui/test/node-selection-migration.test.tsx — source
  pinning updated: assert Tab union literal is "overview" | "details"
  and the legacy 5-tab literal is gone.

Forensic-trace findings (pre-scaffold per dispatch):

- Seat page owner: LiveNodeDetails.tsx (mounted from SeatScopePage in
  components/topology/ScopePages.tsx). Confirmed.
- Pre-slice tabs: ["identity", "agent-spec", "startup", "transcript",
  "terminal"] for agents; same minus agent-spec for infra. Default:
  "identity".
- Black-glass terminal: was already rendered inline via
  SessionPreviewPane (variant="compact-terminal"); the popover-only
  concern from the dispatch is moot — slice migrates the existing
  inline pattern from a tab body to the Overview tab body. No new
  primitive needed.
- NodeDetailEntry.model: present + already wired (the old
  IdentitySummary used data.model). SeatOverviewTable reads it
  directly. No new accessor required.
- Activity card + Recent Events card: existing components
  (LiveNodeCurrentState + RecentEventsSection); reused as-is.

Verification:

- live-node-details.test.tsx: 16/16 PASS.
- node-selection-migration.test.tsx: 10/10 PASS.
- Full UI suite: 994 passed / 1 skipped / 4 failed (same 4
  pre-existing failures as main; zero new failures from this slice).

Hard gates:

- HG-1 default tab is Overview: PASS.
- HG-2 Overview DOM order (table -> terminal -> activity): PASS.
- HG-3 7 fields rendered: PASS.
- HG-4 model graceful absence: PASS (em-dash placeholder).
- HG-5 inline black-glass terminal in Overview: PASS.
- HG-6 Details composition + no preview in Startup: PASS.
- HG-7 Terminal + legacy tabs gone: PASS.
- HG-8 UI focused suite green (zero new regressions): PASS.
- HG-9 design-reviewer V1-bar visual audit: DEFERRED.
- HG-10 Activity + Recent Events render correctly in new Overview
  position: PASS (existing components reused; tests cover Activity).

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:34:31 -07:00
mvschwarzandClaude Opus 4.7 c66df02aeb test(daemon): tighten N=3 + add N=4 column-major fill-order discriminators (slice 24 24.B carry-forward)
velocity-guard 24.B re-verdict at f724739: ADVISORY-OK-WITH-CONCERNS.
Primary timing blocker cleared. Carry-forward concern: the N=3 fill-
order test asserted text (agent name) for sends[1] + sends[2] but not
their destination surface IDs, so it would have passed under row-major
fill (where agent-2 and agent-3 land on swapped surfaces but the
text-contains check still matches). Guard ACKed proceeding to
Checkpoint C with the tightening carried; this commit lands it
before Checkpoint C ships rather than carrying it across phase
boundaries.

N=3 test now asserts both the split-call sequence AND the destination
surface ID per send:
- agent-1 → "surface:initial" (col 0 / row 0)
- agent-2 → "surface:102" (the FIRST down-split surface created in
  the for-loop; this is col 0 / row 1 under column-major)
- agent-3 → "surface:101" (the right-split surface; col 1 / row 0
  under column-major)

Under row-major fill these would flip (agent-2 → 101, agent-3 → 102)
and the assertions would fail. Test now strictly discriminates the
column-major contract.

N=4 fill-order test added: extends the discrimination to a fully-
populated 2×2 grid. Expected send-order surface IDs (column-major):
  ["surface:initial", "surface:102", "surface:101", "surface:103"]
Under row-major it would be:
  ["surface:initial", "surface:101", "surface:102", "surface:103"]
Tests asserts the exact equality of the column-major sequence.

Total: 45/45 service tests green (was 44; +1 N=4 discriminator). The
original N=3 test was tightened in-place rather than added as a new
test, so the test count delta is +1 (the new N=4 case).

SC-29 EXCEPTION declared: NONE at this commit (test tightening only).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:32:38 -07:00
mvschwarzandClaude Opus 4.7 f724739df2 fix(daemon): honor cmux timing gotchas + column-major fill order in CmuxLayoutService (slice 24 Checkpoint B repair)
velocity-guard 24.B BLOCKING-CONCERN at 4e65fdd. Two repairs:

PRIMARY (timing) — README §94 + skill §5 require:
- sleep 0.5s between cmux ops (surface-not-ready-for-input window)
- sleep 1s final settle (last-send-key race)
Prior impl had no delays + no retry + no injected clock; would
produce flaky/empty panels in real desktop app.

Repair:
- CmuxLayoutService constructor accepts CmuxLayoutServiceOptions
  { sleep?: SleepFn } (defaults to setTimeout-based real sleep).
- Constants OP_DELAY_MS=500 + FINAL_SETTLE_MS=1000 +
  LIST_SURFACES_RETRY_DELAY_MS=100 + LIST_SURFACES_MAX_ATTEMPTS=5
  exposed for callers + tests.
- buildWorkspace awaits sleep(OP_DELAY_MS) after every splitSurface.
- After last sendText, awaits sleep(FINAL_SETTLE_MS) — guarantees
  terminal flushes the Enter character (skill §5 gotcha 2).
- listSurfaces is now wrapped in discoverInitialSurface helper that
  retries up to LIST_SURFACES_MAX_ATTEMPTS with
  LIST_SURFACES_RETRY_DELAY_MS backoff if cmux daemon hasn't yet
  attached the workspace's default surface (skill §5 gotcha 3).
- Returns request_failed with attempts count after retry exhaustion.

SECONDARY (fill order) — README §52 specifies "top-to-bottom,
left-to-right" which is COLUMN-MAJOR. Prior impl filled rows outer,
cols inner (row-major). Visual ordering of agents in a 2×N grid
matters: column-major puts the first 6 agents in column 0 top-down,
last 6 in column 1.

Repair:
- buildWorkspace's send loop swapped to outer-cols inner-rows order.
- For N=3 in a 2×2: agent-1 → col0/row0 (default surface),
  agent-2 → col0/row1 (down-split off default), agent-3 → col1/row0
  (right-split off default); blank at col1/row1.

Tests added (8 new):
- awaits sleep(OP_DELAY_MS) once per splitSurface (verified for N=4)
- awaits sleep(FINAL_SETTLE_MS) exactly once after last sendText
- final settle is the LAST sleep called (no further ops after)
- retries listSurfaces with backoff when initial response is empty
- returns request_failed after LIST_SURFACES_MAX_ATTEMPTS empty lists
- exposes timing constants
- N=3 column-major fill order: agent-1 on initial, agent-2 on down-
  split, agent-3 on right-split (discriminating regression for the
  row-major-to-column-major switch)
- N=2 column-major fill: agent-1 on initial, agent-2 on right-split

Total: 44/44 service tests green (was 36; +8 regressions). All 8 new
tests discriminate the timing/fill-order bug class — without the
sleep injection or column-major swap they would fail.

CmuxAdapter / CmuxTransport tests unchanged (orthogonal). 127/127
cmux family green expected on re-run.

SC-29 EXCEPTION declared: NONE at this checkpoint
(timing strategy + fill-order fix in domain service; declaration
lands at Checkpoint C with the route).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:29:12 -07:00
mvschwarzandClaude Opus 4.7 4e65fdd79f feat(daemon): CmuxLayoutService for cmux workspace launch (slice 24 Checkpoint B)
Algorithmic core of the "Launch in CMUX" feature. Three pure helpers +
one coordinated method:

- CmuxLayoutService.computeLayout(N) → { rows, cols, blanks }
  - N=1 → 1×1 (special); N=2..12 → 2-col-grid (rows = ceil(N/2))
  - Throws on N <= 0 / non-integer / N > MAX_PER_WORKSPACE
  - Matches slice 24 README §Layout algorithm table verbatim

- CmuxLayoutService.chunkAgents(agents) → T[][]
  - Splits into MAX_PER_WORKSPACE-sized chunks preserving order
  - 13 → [12, 1]; 24 → [12, 12]; 30 → [12, 12, 6]; empty → []

- CmuxLayoutService.orderAgentsFromRigSpec(rigSpec) → string[]
  - Pod-then-member ordering from rig spec (preserves declaration order;
    no name-based sorting)
  - Returns "<podId>.<memberId>" identifiers; deterministic across runs

- CmuxLayoutService.buildWorkspace(name, cwd, agents) → CmuxResult
  - Calls cmuxAdapter.createWorkspace → listSurfaces (discover default
    surface) → splitSurface N-1 times (1 right + (rows-1)*cols downs)
    → sendText("tmux attach -t <session>\n") N times in top-to-bottom
    left-to-right order
  - Returns { workspaceId, workspaceName, agents[], blanks } on success
  - Propagates first adapter error encountered

Constants MAX_COLS=2 + MAX_PER_WORKSPACE=12 exposed at top of file with
TODO(0.3.2) marker for graduation to settings keys
(cmux.workspace_columns + cmux.workspace_max_panels). Slice 08 settings
infra is ready; deferred per slice 24 README §Configurability posture.

Build sequence for an R×C grid (C=1 or C=2):
1. Start with workspace's default surface = col 1 / row 1
2. (if C==2) split right → creates col 2 / row 1
3. For rows 2..R: split down off previous row's surface in each col
4. Send tmux-attach to surfaces 1..N in top-bottom-left-right order
   (any unpopulated surfaces in the last row remain as "blanks")

Total splits per workspace = R*C - 1 (matches README "Splits" column).
For N=12 (2×6): 11 splits + 12 sends.

Tests: 36/36 new CmuxLayoutService tests green. 119/119 cmux family
total (zero regression in adapter or transport).

What this checkpoint does NOT do (per per-checkpoint plan):
- Daemon route POST /api/rigs/:rigId/cmux/launch → Checkpoint C
- NodeInventory + rig spec wiring (launchForRig orchestration) → Checkpoint C
  via the route handler; pure service layer stays decoupled from those
  cross-cutting deps
- UI button + useRigCmuxLaunch hook + toasts → Checkpoint D

SC-29 EXCEPTION declared: NONE at this checkpoint
(new domain service only; SC-29 lands at Checkpoint C with the route.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:25:19 -07:00
mvschwarzandClaude Opus 4.7 f563aaa0f2 test(daemon): failing tests for CmuxLayoutService (slice 24 Checkpoint B)
40+ tests pin the contract from slice 24 README §Layout algorithm + §Daemon-side architecture:

computeLayout(N) — pure: returns { rows, cols, blanks }
- 12 cases N=1..12 per the README table (1×1, 2×1, 2×2 ... 2×6)
- Throws on N=0 / negative N / N > MAX_PER_WORKSPACE (caller should chunk first)

chunkAgents(agents) — pure: splits into chunks of up to MAX_PER_WORKSPACE
- 1 / 12 / 13 (12+1) / 24 (12+12) / 30 (12+12+6)
- Empty input → empty array
- Preserves order across chunk boundaries

orderAgentsFromRigSpec(rigSpec) — pure: returns deterministic agent order
- Pod-then-member ordering from rig spec
- Preserves pod order from spec (not alphabetical)
- Handles empty / one-pod-one-member cases

buildWorkspace(name, cwd, agents, adapter) — coordinates via CmuxAdapter:
- N=1: no splits; 1 send tmux attach
- N=2: 1 split right; 2 sends
- N=4 (2×2): 3 splits (right + 2 downs); 4 sends
- N=12 (2×6): 11 splits (1 right + 10 downs); 12 sends
- Odd N returns blanks=1 in result
- Error paths: createWorkspace failure / split failure propagate

Constants MAX_COLS=2 + MAX_PER_WORKSPACE=12 exposed for callers.

Tests fail because src/domain/cmux-layout-service.ts does not exist.
Implementation lands in the next commit.

SC-29 EXCEPTION declared: NONE at this checkpoint
(service is new domain logic only; SC-29 declaration lands at Checkpoint C with the new daemon route.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:24:03 -07:00
mvschwarzandClaude Opus 4.7 88ccb68ada fix(daemon): route new CmuxAdapter layout methods through cmux rpc CLI subcommand (slice 24 Checkpoint A repair)
velocity-guard 24.A BLOCKING-CONCERN at cd3383b: new CmuxAdapter
methods (splitSurface / createWorkspace / closeWorkspace /
listPaneSurfaces) were unreachable through the concrete production
transport. cmux-transport.ts buildCommand had no mapping for these
four method names; the production daemon's createCmuxCliTransport
would throw "Unknown cmux method: surface.split" on invocation. The
prior checkpoint's tests stubbed CmuxTransport directly so they
proved adapter-wrapper behavior but not the daemon transport path.

Repair: extend buildCommand to route the four layout methods through
`cmux rpc <method> '<json-params>'` — a CLI subcommand cmux exposes
for direct daemon-RPC pass-through (verified via slice 24 pre-scaffold
spike: `cmux rpc workspace.create '{"name":"my-rig"}'` works against
the live cmux daemon). The generic-RPC path is cleaner than per-method
CLI mapping for methods that have 1:1 RPC correspondence; the existing
version-adaptive buildCommand branches (surface.list, workspace.agentPIDs)
remain unchanged for legacy methods that have CLI-only shapes.

Snake_case parameter convention is preserved end-to-end: adapter calls
transport.request("surface.split", { surface_id, direction, workspace_id }),
transport JSON-encodes those params + shell-quotes them, cmux daemon
receives them as the snake_case it expects. Spike confirmed cmux's
modern RPC rejects camelCase for these methods.

Regression coverage (test/cmux-transport-layout-methods.test.ts, 9 tests):
- surface.split: command shape + snake_case JSON params + JSON response parse
- workspace.create: name + optional cwd / no cwd
- workspace.close: workspace_id (snake_case)
- pane.surfaces: pane_id + workspace_id + surfaces array response
- Unknown method still throws (regression guard)

Test counts:
- 9/9 new transport-layout tests green (discriminate the unreachability bug — without buildCommand mapping these would throw "Unknown cmux method")
- 15/15 adapter-layout tests still green
- 33/33 existing adapter tests green
- 26/26 existing transport tests green
- Total cmux tests: 83/83 (zero regression)

Carry-forward concern (orthogonal to slice 24): existing adapter
methods (surface.sendText, surface.focus, surface.create with
workspaceId) pass camelCase params; concrete transport's buildCommand
reads camelCase params for them; they shell out to specific cmux CLI
flags (e.g., `cmux focus-panel --panel ...`) not to `cmux rpc`. So the
camelCase-at-adapter-boundary convention IS consistent end-to-end for
those legacy methods — they don't reach the snake_case-strict RPC
layer. Verified via inspection of buildCommand surface.* + workspace.*
branches. No silent breakage. Carry forward as a tidiness concern, not
a correctness one.

SC-29 EXCEPTION declared: NONE at this checkpoint
(transport extension only; declaration lands at Checkpoint C with the
new daemon route).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:21:12 -07:00
mvschwarzandClaude Opus 4.7 cd3383b2df feat(daemon): CmuxAdapter layout RPC method extensions (slice 24 Checkpoint A)
Adds four new RPC method wrappers to CmuxAdapter powering the slice 24
layout flow per OPT-B (extend existing RPC adapter; chosen after
pre-scaffold spike confirmed cmux RPC exposes all required primitives):

- splitSurface(surfaceId, direction, workspaceId?): CmuxResult<string>
  Calls surface.split RPC; returns new surface handle. Direction is
  "left" | "right" | "up" | "down". Defensive handling of multiple
  response field name variants (created_surface_ref / created_surface_id
  / surface_ref / surface_id / id) via shared normalizeHandle helper.

- createWorkspace(name, cwd?): CmuxResult<string>
  Calls workspace.create RPC; returns new workspace handle. Optional
  cwd is omitted from params when not provided (cmux daemon picks a
  default).

- closeWorkspace(workspaceId): CmuxResult<void>
  Calls workspace.close RPC for cleanup paths (e.g., conflict-resolution
  retry, test teardown).

- listPaneSurfaces(paneId, workspaceId?): CmuxResult<CmuxSurface[]>
  Calls pane.surfaces RPC; returns surfaces in a pane. Used by the
  layout flow to discover the pane ID a new surface belongs to after
  surface.split (cmux returns surface handle; pane lookup is a separate
  call per the SOP).

Parameter convention: snake_case (surface_id, workspace_id, pane_id,
direction, name, cwd). Verified via slice 24 pre-scaffold spike against
live cmux daemon — `cmux rpc workspace.close { "workspace_id": ... }`
succeeds; camelCase `workspaceId` returns "Missing or invalid
workspace_id". Existing CmuxAdapter methods (sendText, focus, etc.)
use camelCase params and are unchanged at this checkpoint; orthogonal
to slice 24 contract.

Tests: 15/15 new layout-method tests green; 33/33 existing CmuxAdapter
tests still green (zero regression).

SC-29 EXCEPTION declared: NONE at this checkpoint
(adapter extension only; no new ConfigStore key, env var, daemon
endpoint, schema migration, or event type. SC-29 will land at slice 24
Checkpoint C when the new POST /api/rigs/:rigId/cmux/launch route is
added; declared in that commit body + slice ACK.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:17:21 -07:00
mvschwarzandClaude Opus 4.7 2835a283b3 test(daemon): failing tests for CmuxAdapter layout RPC methods (slice 24 Checkpoint A)
15 tests pin the contract for four new RPC method wrappers powering
the slice 24 layout flow:

- splitSurface(surfaceId, direction, workspaceId?) — calls surface.split RPC
- createWorkspace(name, cwd?) — calls workspace.create RPC
- closeWorkspace(workspaceId) — calls workspace.close RPC
- listPaneSurfaces(paneId, workspaceId?) — calls pane.surfaces RPC

Tests pin: success path returns handle / array; param naming is
snake_case (verified via spike against live cmux daemon — cmux RPC
rejects camelCase for these methods); unavailable error when not
connected; request_failed propagation when RPC throws; defensive
handling of missing surface handles in response.

The snake_case-params contract is the load-bearing assumption from the
slice 24 pre-scaffold spike: `cmux rpc workspace.close { "workspace_id": "..." }`
works; camelCase `workspaceId` returns "Missing or invalid workspace_id".
New methods adopt snake_case explicitly; existing CmuxAdapter methods
using camelCase params remain unchanged at this checkpoint (orthogonal
concern flagged in dispatch surfacing).

Tests fail because the four methods do not yet exist on CmuxAdapter.
Implementation lands in the next commit.

SC-29 EXCEPTION declared: NONE (this commit adds tests only; SC-29 declaration lands in the impl + ACK packet)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 21:16:34 -07:00
mvschwarzandClaude Opus 4.7 159433a163 merge(0.3.1): slice 08 settings-config-validation — help-text drift fix + 0.3.1 CHANGELOG + json output
--no-ff merge from slice-08-settings-config-validation (commits b252137 + 8f521d0).

Driver work (b252137):
- rig config --help now visibly lists all 29 VALID_KEYS top-level prefix groups (ui.preview.*, agents.*, feed.subscriptions.*, runtime.codex.*, transcripts.lines, transcripts.poll_interval_seconds, workspace.dogfood_evidence_root, workspace.operator_seat_name)
- New HG-6 regression test asserts every VALID_KEYS prefix appears in rendered help text (real drift discriminator; verified by reverting config.ts and observing the test fail)
- CHANGELOG.md new [0.3.1] - unreleased section + Config + settings subsection

QA-owned fix (8f521d0):
- rig config get <key> --json now returns the documented value/source/default payload instead of the raw scalar (Commander was attaching --json to the parent config command)
- packages/cli/src/commands/config.ts narrow source fix
- packages/cli/test/config.test.ts regression coverage for the json-output contract

Lockstep audit clean: CLI VALID_KEYS and daemon SETTINGS_VALID_KEYS are identical (29 keys each).
RIGGED_* legacy verified pre-rename for the 5 original runtime keys; zero cargo-cult findings; no backlog-slice-01 entries surfaced.

Triple-gate verdicts:
- redo3-guard-3 CLEAR (discriminator-verified; pre-existing failures cross-checked on baseline 2b5b86b)
- velocity-qa CLEAR after applying the json-output fix on VM dogfood

SC-29 EXCEPTION declared: NONE.

Pre-existing CLI failures unchanged from baseline (DaemonClient config integration host/port + rig preflight --json + rig preflight check marks); these predate slice 08 and are tracked separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Codex <[email protected]>
2026-05-11 20:02:26 -07:00
mvschwarz 8f521d071f fix(cli): honor json output for config get
Ensure `rig config get <key> --json` emits the documented value/source/default payload even when Commander records the option on the parent `config` command.

Verification:
- npm --workspace packages/cli test -- config.test.ts -t "rig config get <key> --json"
- npm --workspace packages/cli run build

Full `config.test.ts` still has the pre-existing driver-declared failure in `DaemonClient config integration > ConfigStore resolves non-default host/port from config file`.
2026-05-11 19:55:46 -07:00
mvschwarzandClaude Opus 4.7 b252137a2e feat(config): refresh rig config --help keys list + 0.3.1 release notes
V0.3.1 slice 08 settings-config-validation. Light verification + docs
pass; one drift fix surfaced during the audit.

What this slice ships:

(1) HG-6 drift fix — `rig config --help` text was missing several key
namespaces relative to the actual VALID_KEYS list (the help still
enumerated only the original-5 + a partial workspace.* + recovery.*
shape; missing ui.preview.*, agents.*, feed.subscriptions.*,
runtime.codex.*, transcripts.lines, transcripts.poll_interval_seconds,
workspace.dogfood_evidence_root, workspace.operator_seat_name).
Refreshed the "Keys:" section in
`packages/cli/src/commands/config.ts` to enumerate every top-level
namespace currently in VALID_KEYS.

(2) HG-6 regression guard — new test in
`packages/cli/test/config.test.ts` extracts every top-level dotted-
prefix from VALID_KEYS and asserts each appears in the rendered help
output (captured via Commander outputHelp() through a custom writer
so the after-help text is included). Guards against silent help-text
drift when new keys are added.

(3) 0.3.1 release notes section drafted in CHANGELOG.md — adds an
`[0.3.1] - unreleased` section with a "Config + settings" subsection
documenting: VALID_KEYS / SETTINGS_VALID_KEYS lockstep, the
OPENRIG_<KEY> > config-file > default precedence, the original-5
RIGGED_<KEY> legacy aliases, the help-text refresh, and the no-new-
SC-29 posture for this slice.

Verification (no other code changed):

- HG-1: every VALID_KEYS entry round-trips through
  `resolveAllWithSource`. Existing `resolveAllWithSource returns every
  valid key with source + default` test in config-store-extended.test.ts
  covers this.
- HG-2: CLI `VALID_KEYS` and daemon `SETTINGS_VALID_KEYS` are byte-
  identical sets (29 keys each; diff returned empty). ENV_MAP entries
  are byte-identical between CLI and daemon (29 entries each).
- HG-3: env > file > default discriminator with distinct values per
  layer. Covered in CLI (`env > file > default for new keys`) and
  daemon (`env > file > default for daemon.port`) test files; no new
  test needed.
- HG-4: 5 RIGGED_<KEY> legacy aliases (PORT, HOST, DB,
  TRANSCRIPTS_ENABLED, TRANSCRIPTS_PATH) — verified pre-rename via
  `git log -S "RIGGED_<KEY>"` first-occurrence check. All 5 first
  appear pre-rename-merge. No cargo-culted post-rename entries. Zero
  findings to surface to backlog slice 01-rigged-legacy-env-vars-
  cleanup.
- HG-5: slice 05 already shipped; `workspace.operator_seat_name`
  end-to-end roundtrip is covered by existing tests in
  config-store-extended.test.ts and user-settings-store.test.ts.
- HG-6: see (1) + (2) above.
- HG-7: CLI suite 922 passed / 3 failed (3 pre-existing failures on
  main 2b5b86b; verified by cross-checking the same tests against
  origin/main; not introduced by this slice). Daemon
  user-settings-store: 16/16 PASS. No new typecheck or build errors.
- HG-8: CHANGELOG.md section drafted; see (3).

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 19:46:27 -07:00
mvschwarz e2f58fbd1e chore(skills): canonical mirror catch-up for 0.3.1 lifecycle gate
Run the canonical skills mirror generator so skills/_canonical reflects the current daemon shared skill specs.

This is a mechanical mirror sync that adds the missing canonical mirror files and carries the packaged openrig-user queue-surface callout into the mirror.

Checks: npm run mirror-skills
Checks: npm run mirror-skills:check
Checks: staged diff hygiene scan for restricted path/attribution/verbatim patterns

SC-29: NONE
2026-05-11 18:26:59 -07:00
mvschwarz d6e9065196 fix(skills): mark rig queue as canonical in packaged guidance
Sync the 2026-05-11 queue-surface callout into daemon-shipped skill assets and shared agent skill specs.

The packaged guidance now treats rig queue as the canonical daemon-backed SQLite surface for substantive queue routing and rigx queue as recovery-only fallback.

Tests: npm --workspace packages/daemon test -- openrig-core-plugin.test.ts
Tests: npm --workspace packages/daemon run build
Checks: grep verified canonical callout in all six target packaged skill files
Checks: diff hygiene scan for restricted path/attribution/verbatim patterns

SC-29: NONE
2026-05-11 18:15:02 -07:00
mvschwarz f9e48c4b1c merge(bug-fix): topology-perf — React.memo + custom equality on RigGroupNode/HybridPodGroupNode/HybridAgentNode + StatusCell/ContextCell/TokenCell to break re-render cascade from 1Hz activity bumps
--no-ff merge from bugfix-topology-perf (84d6e54; single commit; parent main 2b5b86b; safety tag temp/topology-perf-pre-ff preserves prior tip 8d0ae2e pre-forward-fix).

Pre-existing 0.3.0 bug: production-scale topology Table view hangs the page + crashes Chrome; Graph view at workspace scope (post slice 12.5 swap to HostMultiRigGraph) shows significant CPU. Scale-dependent (manifests at 13+ seats; not on small VM topology).

Root cause hypothesis (verified at code-level; needs production-scale Chrome trace to fully confirm): useTopologyActivity spreads new data objects every 1Hz bump + stream events. Without memoization, all node components + table cells re-render every tick. React.memo with custom equality breaks the cascade.

Memo additions:
- RigGroupNode: equality on visible/render fields
- HybridPodGroupNode: equality on label fields
- HybridAgentNode: equality on all visible content (agentActivity reference-compared; conservative)
- StatusCell / ContextCell / TokenCell: equality on the specific fields each reads

NEW packages/ui/test/topology-perf-table-large-n.test.tsx: N=20 seats × 4 rigs production-scale fixture; 3 tests. Honest scope: large-N smoke + bounded DOM cardinality + stable testid skeleton across re-renders. NOT a memo proof — happy-dom doesn't enable per-component render-count instrumentation without exporting test-only API into production source.

PARTIAL fix scope explicit per ACK: HG-1/HG-2 (Table loads/switches without hang at production scale) UNVERIFIED at driver tooling — lifecycle requires Chrome DevTools profile at production scale. Surfaced follow-ups if first commit insufficient: useTopologyActivity 1Hz coalescing via RAF; activeNodes/activityData per-node ref cache pattern.

Tests: topology-perf-table-large-n 3/3; p5-2-host-multi-rig-graph 17/17 (memoization preserves all contracts); full UI 988 PASS / 4 pre-existing fails unchanged.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR after forward-fix (redo3-guard-3 narrow re-review of attribution adjustment + honest test scope); ACK doc-amend confirmed; substrate-only meta-rule references retained explicitly.
2026-05-11 16:56:29 -07:00
mvschwarzandClaude Opus 4.7 84d6e547b1 perf(topology): memoize node + table cell components
Bug-fix slice topology-perf-table-hang-and-graph-cpu — partial fix:
graph CPU + table render churn at scale via React.memo on the hot
node/cell components. Production VM walk 2026-05-11 observed page hang
+ Chrome crash on /topology Table at scale (13+ seats); also observed
significant CPU usage on workspace-scope /topology Graph (post slice
12.5 swap from flat ScopeTopologyRollup to HostMultiRigGraph).

Honest scope: the Table hang is a pre-existing 0.3.0 bug, not a 0.3.1
regression. Smaller topologies (~4 seats) do not reproduce. Without a
Chrome DevTools Performance trace at production scale, this commit
does NOT claim to fix the Table hang root cause. What it does:

(1) Memoize HostMultiRigGraph node components (slice 12.5-introduced
graph CPU): RigGroupNode, HybridPodGroupNode, HybridAgentNode wrapped
with React.memo + custom equality on the fields the renderers read.
useTopologyActivity bumps every 1s + per stream event; before this
commit each bump rebuilt activeNodes via .map(...) which made all
node-component instances re-render despite stable visible content. At
workspace scope this multiplied by N rigs × M seats; slice 12.5's
swap made this acute. Memoization skips re-render when the equality
function reports content-equal data.

(2) Memoize TopologyTableView cell components: StatusCell + ContextCell
+ TokenCell wrapped with React.memo. Same root cause: activityData is
rebuilt every bump via data.map(...) which spreads new objects; React
Table sees new row.original references on every cell render. Each
cell now compares the fields it reads (status/activityState;
contextUsage; token totals) and skips re-render if content-stable.
This is a valid scale-perf win regardless of whether it fully resolves
the Table hang.

(3) Synthetic large-N smoke fixture (N=20 seats × 4 rigs) at
packages/ui/test/topology-perf-table-large-n.test.tsx: asserts initial
render completes in <2s under happy-dom + cell counts are bounded
(one per row, no double-mounts) + parent rerender with stable props
leaves the table testid skeleton stable. Honestly scoped: this is a
SMOKE GATE, not a memoization discriminator (the test would still pass
if the memo wrappers were removed; happy-dom does not enable per-
component memo discrimination without exporting internal cells +
instrumenting them).

Surfaced findings (not shipped here, recommended follow-up):
- useTopologyActivity bumps every 1s globally; could be debounced
  per-node OR coalesced if multiple stream events arrive within one
  RAF.
- HostMultiRigGraph.activeNodes recomputes EVERY node spread even when
  topologyActivity.getNodeActivity returns same visual; consider caching
  per-node references in a ref so the data prop reference stays stable
  across bumps. This would let React.memo skip via shallow equality.
- TopologyTableView activityData same pattern; same potential fix.
- Real Chrome DevTools trace at production scale (13+ seats) still
  needed to confirm Table hang root cause.

Verification:
- packages/ui/test/topology-perf-table-large-n.test.tsx: 3/3 PASS.
- packages/ui/test/p5-2-host-multi-rig-graph.test.tsx: 17/17 PASS
  (memoization preserves all existing contracts).
- Full UI suite: 988 passed / 1 skipped / 4 failed (same 4 pre-existing
  failures as main 2b5b86b: graphics-runtime-mark, p5-1-table-cmux,
  p5-3-activity-ring-hot-potato, p5-slice-scope -- not introduced by
  this slice).

Hard gates:
- HG-1 (Table view loads without hang/crash on Chrome): UNVERIFIED at
  production scale; QA verification needed on production host repro.
- HG-2 (Switch to Table + back without crash): UNVERIFIED at scale.
- HG-3 (Graph view CPU at workspace scope is reasonable idle): Code-
  level fix applied; live measurement deferred to QA.
- HG-4 (Animation respects prefers-reduced-motion): UNCHANGED — pre-
  existing CSS gating retained in topology-table-shimmer.css +
  activity-ring CSS.
- HG-5 (No new test regressions): PASS — 0 new failures.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 16:49:36 -07:00
mvschwarz fed5114612 fix(ui): share project status tone primitives
Export the project status-dot class map and migrate MissionProgressHeatmap off its duplicate local cell-tone and heatmap-tone helpers.

This keeps heat-map cells, status dots, and slice-status tone resolution on the same primitive, including case-insensitive Active/in-flight handling.

Tests: npm --workspace packages/ui test -- mission-progress-heatmap.test.tsx p5-mission-discovery.test.tsx project-workspace-overview.test.tsx

Tests: npm --workspace packages/ui run build

SC-29: NONE
2026-05-11 16:40:10 -07:00
mvschwarz cd6d592a19 fix(ui): refine library and project density
Keep plugin detail routes in the Library explorer surface so the sidebar stays mounted while browsing plugin details.

Remove visible version/source metadata from Library Explorer and Library page rows while keeping sidebar tree metadata in title and aria-label where useful.

Replace Project slice-row prose metadata with compact queue-count and status-dot icons, with slice names allowed to wrap for longer labels.

Tests: npm --workspace packages/ui test -- app-shell.test.tsx specs-tree-view-sidebar.test.tsx library-page.test.tsx library-page-workflow-diagnostic.test.tsx p5-mission-discovery.test.tsx project-workspace-overview.test.tsx

Tests: npm --workspace packages/ui run build

SC-29: NONE
2026-05-11 16:34:07 -07:00
mvschwarz 2b5b86b105 merge(0.3.1): slice 13.5 mission-progress-artifacts-heatmap — new MissionProgressHeatmap component on Mission Progress tab (slice × acceptance-item grid; DESIGN status-token color mapping)
--no-ff merge from slice-13-5-mission-progress-heatmap (6dbfb99; single commit; parent main 8d9d86f; safety tag temp/slice-13-5-pre-ff preserves prior tip 162babb pre-forward-fix).

NEW packages/ui/src/components/project/MissionProgressHeatmap.tsx (237 lines): slice × acceptance-item grid renders BEFORE PROGRESS.md markdown + ScopeProgressRollup. Cells = filled (done) / outline (not done); done cells take slice status tone via heatmapTone + doneCellClass single-source-of-truth resolver. heatmapTone overrides "active" → info/sky so canonical SliceStatus values render distinct cell colors. Color palette (cellToneClass) cousins the existing toneClass in ProjectMetaPrimitives: emerald (active/done) / sky (progress) / amber (warning) / rose (blocked) / outline-variant (not done). No new color system; reuses canonical DESIGN status tokens via stateTone with denser fill for checkbox-feel.

Slice × acceptance-item (not slice × phase) — driver chose this structure because acceptance items are durable per-slice PROGRESS.md content while phases require workflow_spec binding not every slice has. Variable density handled via flex-wrap on cells container.

Empty-state graceful degradation: per-slice no-items "No acceptance items declared yet"; mission no-slices EmptyState "NO MISSION SLICES"; loading state "LOADING PROGRESS". Blocked-slice-with-done-cells edge case: tinted danger (rose) not false-success emerald — partial-but-blocked reads correctly.

Tests: mission-progress-heatmap.test.tsx 10/10 (8 original + 2 forward-fix regressions for DOM order + legend↔cell color parity); project-workspace-overview.test.tsx 6/6 sanity; 4 pre-existing UI failures unchanged baseline.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR after forward-fix (redo3-guard-3 narrow re-review).
design-reviewer verdict: SHIP — V1-bar professional-grade met. Slice × acceptance-item is V1-bar correct answer; color discipline uses canonical DESIGN tokens via stateTone+cellToneClass parallel palette; gestalt-shape differentiation from Artifacts tab (dense color-grid vs sparse 4-cell metric-card) accomplished.
2026-05-11 16:10:45 -07:00
mvschwarzandClaude Opus 4.7 6dbfb99219 feat(project): Mission Progress acceptance heat-map
V0.3.1 slice 13.5 mission-progress-artifacts-heatmap.

Mission scope /project/mission/<id> Progress and Artifacts tabs
previously shared the same 4-cell metric-grid primitive shape and
looked too similar at-a-glance. Slice 12 generalized README rendering
for both tabs but left the visual gestalt undifferentiated. This slice
adds a new MissionProgressHeatmap rendered as the FIRST surface on the
Progress tab (above PROGRESS.md markdown + per-slice rollup), giving
Progress its own visual gestalt without touching Artifacts.

Heat-map shape: one row per slice; one cell per acceptance-item in
that slice's PROGRESS.md checklist (driver picked slice x acceptance
over slice x phase because acceptance items are the durable progress
unit the operator already authors per slice -- phases require a
workflow_spec binding that not every slice has). Filled cells take
the slice's status tone via the heat-map-local heatmapTone() resolver
(which wraps stateTone and overrides "active" -> "info" so canonical
SliceStatus values render distinct cell colors at a glance); not-done
cells render outline-only. Leading column is slice name + state pill
(drill-in link); trailing column is the done/total tally + percentage.
Legend renders the canonical state -> tone mapping using the SAME
doneCellClass() resolver the cells use, so the legend swatch class is
always byte-identical with the cell class it represents.

All colors derive from existing DESIGN.md status tokens via the
ProjectMetaPrimitives stateTone + a parallel cellToneClass map. No
new color system; no new daemon endpoints; no daemon mutation.

Scope (strictly mission-scope per IMPL-PRD):
- New `packages/ui/src/components/project/MissionProgressHeatmap.tsx`.
- MissionScopePage Progress tab renders heat-map FIRST, then
  PROGRESS.md markdown, then ScopeProgressRollup (composition, not
  replacement).
- Artifacts tab untouched -- visual differentiation comes from
  Progress's new heat-map, not Artifacts changes.
- Workspace + slice scope Progress tabs untouched -- mission scope
  only per IMPL-PRD OUT clause.

Tests (`packages/ui/test/mission-progress-heatmap.test.tsx`):
- HG-1: N rows x M cells grid shape.
- HG-2: done cells take slice tone (active -> info/sky;
  done -> success/emerald; blocked -> danger/rose); not-done cells
  outline-only.
- HG-2 (legend): legend renders all 5 state -> color samples.
- HG-2 (legend <-> cell parity): legend swatch className === actual
  cell className for active/done/blocked SliceStatus values; warning
  swatch shape published for forward-compat.
- HG-3: heat-map + scope-progress-rollup both render on Progress tab.
- HG-3 (DOM order): heat-map precedes PROGRESS.md markdown +
  scope-progress-rollup via Node.compareDocumentPosition; guards
  against silent reordering.
- HG-4: Artifacts tab does NOT mount the heat-map.
- Empty-state for missions with no scoped slices.
- Tally rendering done/total + percentage.

Verification:
- Heat-map test file: 10/10 PASS.
- project-workspace-overview.test.tsx: 6/6 PASS (touched component is
  not used at workspace scope; verifying no collateral regression).
- Full UI suite delta vs main 8d9d86f: 0 new failures (4 pre-existing
  failures unrelated to this slice: graphics-runtime-mark,
  p5-1-table-cmux, p5-3-activity-ring-hot-potato, p5-slice-scope).
- HG-1 through HG-4 PASS; HG-5 + HG-6 design-reviewer V1-bar visual
  audit deferred to the gate.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 16:08:24 -07:00
mvschwarz 16d31d0cc8 fix(ui): densify library explorer spec leaves
Collapse Library Explorer sidebar spec/plugin/context/image/application leaves into one row, with metadata inline and preserved through title plus aria-label.

Tests: npm --workspace packages/ui test -- specs-tree-view-sidebar.test.tsx library-page.test.tsx library-page-workflow-diagnostic.test.tsx

Tests: npm --workspace packages/ui run build

SC-29: NONE
2026-05-11 15:59:12 -07:00
mvschwarz 8d9d86f53f fix(ui): close wave 3b QA coverage gaps
Restore the Files workspace route, make the slice Docs browser stack on phone widths, lower Storytelling card vellum opacity, and hide slice-derived feed cards for completed missions.

Tests: npm --workspace packages/ui test -- slice-20-mobile-density.test.tsx slice-16-typography-vellum.test.tsx storytelling-mark-complete.test.tsx storytelling-cards.test.tsx

Tests: npm --workspace packages/ui run build

SC-29: NONE
2026-05-11 15:17:33 -07:00
mvschwarz 9757bceb93 merge(0.3.1): slice 16 typography-and-vellum — DESIGN.md §Typography enforcement + for-you vellum opacity to range
--no-ff merge from founder-walk-16-typography-vellum (b07dfb4; single commit; parent main 6f7d9c0 post slice-12.5 + slice-18 merges).
Prose typography flipped to font-body Inter on 3 surfaces: FeedCard.tsx qitem body + outcome sentence + hint copy; TimelineTab.tsx event body <pre> + empty-state wrapper (inner code-identifier spans preserved as font-mono); ScopePages.tsx proof-packet markdown body. Metadata + IDs + state badges stay font-mono JetBrains.

For-you vellum opacity bg-white/50 → bg-white/35 (within DESIGN §Vellum 25-40 range; resolves founder-walk "too bright" observation). FeedCard now joins DashboardCard + ProjectCard vellum family while preserving slight attention-surface prominence (/35 vs anchor /30). Paper-grid backdrop bleed-through reads as intended at lower opacity.

≥10 surfaces audited: 3 fixed (above) + 7 already-compliant verified (SpecsLibraryPage, ProjectTreeView, ScopePages workspace-overview slice rows, ProjectMetaPrimitives, MarkdownViewer, SharedDetailDrawer, AppShell).

NEW slice-16-typography-vellum.test.tsx 7-case source-scan regression suite asserts font-body on 3 fixed surfaces + bg-white/35 on FeedCard VellumCard + discriminator verified (flipping bg back to /50 fails test).

Tests: 44/44 across slice-16 affected surfaces (slice-16-typography-vellum 7 + activity-feed 25 + feed-card-dismiss 12); UI tsc clean; npm run build PASS; npm run lint PASS at repo root.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR (redo-guard-2; mode-2-velocity; all 3 driver judgment calls accepted).
design-reviewer verdict: SHIP — V1-bar professional-grade met. Dichotomy enforcement surgical + well-documented + reverse-test-discriminated. Vellum opacity aligns with DESIGN range + anchor surfaces.
2026-05-11 15:01:14 -07:00
mvschwarzandClaude Opus 4.7 b07dfb4ac5 feat(ui): typography + vellum enforcement (slice 16)
release-0.3.1 slice 16 — enforce DESIGN.md §Typography contract +
fix for-you vellum opacity to the §Vellum range.

DESIGN.md §Typography dichotomy:
- font-body (Inter) → prose: qitem bodies, narratives, descriptions,
  notes, markdown body content, "your turn" hint copy
- font-mono (JetBrains) → metadata: tags, IDs, queue state badges,
  timestamps, terminal/transcript, labels, chips, code identifiers

DESIGN.md §Vellum range: bg-white/25 – bg-white/40 + paper-grid
backdrop visible. Prior FeedCard surface ran at bg-white/50 (too
bright vs the dashboard + project card neighbors).

Surfaces fixed:

1. packages/ui/src/components/for-you/FeedCard.tsx
   - VellumCard surface: bg-white/50 → bg-white/35 (vellum-range)
   - qitem body <p>: font-mono → font-body (prose)
   - ActionOutcomePanel outcome sentence <p>: font-mono → font-body
   - "Your turn" hint copy <p>: font-mono → font-body

2. packages/ui/src/components/slices/tabs/TimelineTab.tsx
   - event body <pre>: font-mono → font-body (keeps whitespace-pre-wrap
     for newline preservation; only the font family changes)
   - empty-state container: font-mono → font-body on the prose
     wrapper; preserved font-mono on the embedded code-identifier
     spans (file paths, frontmatter tokens, fenced-block markers)

3. packages/ui/src/components/project/ScopePages.tsx
   - proof-packet primaryMarkdown.content <p>: font-mono → font-body

Surfaces audited as already-compliant (no change required):
- Library specs / applications / agent specs / workflow specs lists
  (SpecsLibraryPage; meta is inline-mono on single-row, already
  follows the dichotomy)
- Plugins library (single-row name + version-as-meta-mono)
- Project mission/slice rows in sidebar tree (slice 19 already moved
  meta to inline-mono on flex rows)
- Workspace-overview slice rows (slice 19)
- VerbActions mission-control prose (already font-body where
  appropriate)
- Topology table primitives (already meta-mono via ProjectMetaPrimitives)

Tests:
- packages/ui/test/slice-16-typography-vellum.test.tsx (new) —
  7-case source-scan suite covering each fixed surface. Anchors paths
  via fileURLToPath(import.meta.url) for cwd-independence.
- Discriminator verified locally: flipping FeedCard vellum back to
  bg-white/50 caused the vellum-opacity test to fail with the expected
  diagnostic; restored → all 7 pass.
- packages/ui/test/activity-feed.test.tsx: 25/25 unchanged
- packages/ui/test/feed-card-dismiss.test.tsx: 12/12 unchanged
- UI tsc clean

Hard gates per IMPL-PRD §5:
- HG-1 prose qitem body content renders in font-body: PASS (FeedCard
  body <p> + TimelineTab event <pre> + ScopePages proof markdown)
- HG-2 metadata stays font-mono: PASS (no regressions; embedded
  code-identifier spans in TimelineTab empty state explicitly kept
  font-mono)
- HG-3 FeedCard opacity in vellum range: PASS (bg-white/35)
- HG-4 paper-grid backdrop visible: PASS (VellumCard already wires
  the backdrop; opacity drop lets it read through)
- HG-5 audit-grep regression test: PASS (slice-16 source-scan suite)
- HG-6 design-reviewer V1-bar visual audit: PENDING (routed)

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:55:19 -07:00
mvschwarz 6f7d9c0707 merge(0.3.1): slice 12.5 workspace-topology-hostmultirig — swap workspace /topology renderer from ScopeTopologyRollup (flat) to HostMultiRigGraph (rigs as distinct visual clusters)
--no-ff merge from slice-12-5-workspace-topology-hostmultirig (905229e; single commit; parent main eb197a9; prior tip 9d2292d preserved at safety tag temp/slice-12-5-pre-ff).
WorkspaceScopePage topology tab now renders HostMultiRigGraph inside a sized container (flex-1 min-h-0 relative h-[60vh]). MissionScopePage topology fallback still uses ScopeTopologyRollup when no specGraph is declared. Rig-scoped routes (HostScopePage/RigScopePage/PodScopePage/SeatScopePage) untouched. HostMultiRigGraph + RigGroupNode + HybridPodGroupNode + HybridAgentNode chrome unchanged (reuse only).

Compounds with slice 05 kernel-rig-as-default: workspaces are multi-rig by default; flat session-name aggregation lost the rig structure when kernel agents joined the named rig. Workspace cluster rendering preserves the canonical workspace→rigs→seats hierarchy + clarifies which agents belong to which rig.

Tests: project-workspace-overview 6/6 (4 original + 2 new HG-2/HG-3 cluster discriminators: rig-group-node-rig-1 + rig-group-node-rig-2 present at N=2; single cluster + no flat rollup at N=1). p5-2-host-multi-rig-graph 17/17 (primitive contract suite unchanged). 4 pre-existing UI failures verified baseline-pre-existing via main checkout cross-check.

Walk-back path: single UI swap; no migration/schema/API change — git revert <merge> reverts cleanly.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR after forward-fix (redo3-guard-3 narrow re-review).
design-reviewer verdict: SHIP — V1-bar professional-grade met. Primitive reuse (HostMultiRigGraph unchanged); cluster boundary clarifies workspace→rigs hierarchy at N=2+ without becoming noisy at N=1.
2026-05-11 14:47:14 -07:00
mvschwarzandClaude Opus 4.7 905229eb05 feat(project): swap workspace /topology to HostMultiRigGraph
V0.3.1 slice 12.5 workspace-topology-hostmultirig.

Project workspace /topology tab previously rendered via the flat
session-name aggregator (ScopeTopologyRollup -> TopologyTab from
aggregateTopology). With slice 05 (kernel-rig-as-default) shipping
multi-rig workspaces by default, the flat aggregation loses the rig
structure as soon as the kernel agents land. Swap to HostMultiRigGraph,
which already exists in the topology primitives + draws each rig as a
distinct visual cluster (workspace -> rigs -> seats hierarchy).

Scope (strictly workspace scope per IMPL-PRD):
- WorkspaceScopePage topology tab swaps ScopeTopologyRollup ->
  HostMultiRigGraph wrapped in a sized container for ReactFlow.
- Rig-scoped routes (/topology/rig/$rigId, /topology/pod/...,
  /topology/seat/...) are untouched -- they were already rig-scoped.
- MissionScopePage topology fallback (when no workflow_spec specGraph
  is declared) still uses ScopeTopologyRollup -- mission scope is a
  separate scope from workspace per IMPL-PRD OUT clause; not in scope.
- HostMultiRigGraph already exists; no new primitive code.
- TopologyOverlayProvider already wraps AppShell, so the swap mounts
  the graph inside the existing provider scope -- no new context.

Tests:
- packages/ui/test/project-workspace-overview.test.tsx
  - Existing workspace topology assertion updated: scope-topology-rollup
    -> workspace-topology-hostmultirig + host-multi-rig-graph.
  - HG-3: new test for N=1 rig fixture asserts rig-group-node-rig-1
    renders + scope-topology-rollup does NOT render at workspace scope.
  - HG-2: new test for N=2 rig fixture asserts rig-group-node-rig-1 +
    rig-group-node-rig-2 both render as distinct clusters.
  - Helper installFetchMock parameterized with psEntries override; mocks
    /api/ps + /api/rigs/<id>/graph endpoints the graph fetches.
  - TopologyOverlayProvider moved inside the test rootRoute component
    so HostMultiRigGraph's useRouterState dependency resolves.

Verification:
- Workspace test file: 6/6 PASS (4 original + 2 new HG-2/HG-3).
- Full UI suite delta vs main: 0 new failures (4 pre-existing failures
  unrelated to this slice).
- HG-1 PASS: workspace /topology renders via HostMultiRigGraph.
- HG-2 PASS: N=2 rigs -> 2 RigGroupNode instances.
- HG-3 PASS: N=1 rig -> 1 RigGroupNode + no flat rollup.
- HG-4 covered by existing topology contract tests
  (p5-2-host-multi-rig-graph.test.tsx -- not touched by this slice).
- HG-5: rig-scoped routes not modified; manual smoke deferred to QA.
- HG-6: design-reviewer V1-bar visual audit deferred to the gate.

Walk-back path: single UI swap, no migration/schema/API change --
git revert <merge> reverts cleanly.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:45:18 -07:00
mvschwarz 870f190e6a merge(0.3.1): slice 18 for-you-dismiss + LibraryTopLevelEntry primitive + skills/plugins pages + getting-started complete-and-hide
--no-ff merge from founder-walk-18-for-you-dismiss-and-skills-and-plugins (f609d06; parent main 2c41c4f; 12 commits — 5 checkpoints + 3 repair commits + 4 TDD red commits).

NEW LibraryTopLevelEntry primitive (157 lines; generic <T extends LibraryItem>) reused by Skills + Plugins pages with identical signature. SkillsIndexPage at /specs/skills + PluginsIndexPage at /specs/plugins; folder grouping (Skills by source: User-defined / OpenRig managed / Other; Plugins by source: Vendored / Claude cache / Codex cache / Other); isUserDefined predicate enables emptyState-when-only-builtins (renders guidance + browsable built-ins simultaneously). SpecsTreeView sidebar gets new top-level Skills + Plugins entries (additive; legacy in-tree section preserved).

For-you dismiss UX: 3 input modes — touch swipe-right (50% threshold; deltaX > 0 directional gating), desktop hover-X (opacity-0 group-hover:opacity-100), keyboard Backspace/Delete (focus-guarded to article-itself to prevent inner-button bubbling). UndoToast bottom-center 5s recovery (black-glass aesthetic; consumedRef race-safe between click-undo + auto-expire). useDismissedSeqs hook (localStorage-backed; auto-prune older entries; soft per-event-seq).

Getting-started complete-and-hide: NEW POST /api/missions/:id/complete writes status:complete to README frontmatter (durable). NEW GET /api/missions/:id surfaces parsed status. UI uses defense-in-depth filter (durable status === 'complete' AND optimistic localStorage Set) — instant feedback without sacrificing durability.

Tests: 73 NEW (10 daemon endpoint + 63 UI across 7 new test files). 88/88 across 8 slice-18-touched UI test files; 10/10 daemon missions-complete; full UI 735 pass + 49 pre-existing AppShell-pollution fails (orthogonal per banked feedback_appshell_global_polls_test_pollution).

SC-29 EXCEPTION declared: NONE (POST endpoint writes frontmatter; no new ConfigStore key, env var, daemon event type, DB migration, schema mutation).

guard verdict: CLEAR — consolidated slice-close verdict; per-checkpoint inline review rolled up after repair passes for 18.B, 18.C, 18.E.
design-reviewer verdict: SHIP — V1-bar professional-grade met. Primitive composability sound for future Library surfaces; consumer signature consistent; defense-in-depth optimistic+durable filter; black-glass UndoToast canon-consistent with terminal popover.
2026-05-11 14:41:55 -07:00
mvschwarzandClaude Opus 4.7 f609d060b1 fix(daemon,ui): durable status-backed storytelling filter + commit Checkpoint E test repair (slice 18 Checkpoint E repair)
Two blockers from velocity-guard 18.E at ac3df45.

BLOCKER 1 — committed test was broken at ac3df45:
packages/daemon/test/missions-complete-endpoint.test.ts had six
indexer.scan() calls; SliceIndexer has no scan() method. The local
worktree had the fix but it was never committed at ac3df45, so the
"7/7 daemon tests green" claim in the prior dispatch was from a dirty
state. The scan() removals + the durable-status-roundtrip regression
test below are now committed atomically with the field surfacing.

BLOCKER 2 — durable complete-and-hide contract not wired:
PRD T8 requires status: complete frontmatter to hide the mission
durably. Prior Checkpoint E only filtered by the local
completedMissionIds localStorage set, so a mission with
status: complete in its README would reappear after a browser /
localStorage reset.

REPAIR:

Daemon side:
- GET /api/missions/:missionId now includes `status` field, parsed
  from the mission README's frontmatter via readMissionStatus().
  Returns null when README missing or status absent.

UI side:
- AdapterMissionRow gains optional `status?: string | null` field.
- buildStorytellingFeedItems filter logic now applies BOTH:
  (a) m.status === "complete" → hidden (durable; survives localStorage)
  (b) completedMissionIds.has(m.name) → hidden (instant optimistic)
- New hook useMissionStatuses(missionIds): fans out
  GET /api/missions/:id per discovered mission via useQueries,
  returns Map<missionId, status>. 30s staleTime.
- Feed.tsx threads discovered mission ids into useMissionStatuses
  and decorates each AdapterMissionRow with the resolved status
  before passing to buildStorytellingFeedItems.

Regression tests added:

Daemon (test/missions-complete-endpoint.test.ts, +3 tests):
- GET /api/missions/:id returns status: "complete" when frontmatter has it
- GET returns status: null when frontmatter has no status field
- After POST complete, subsequent GET returns status: complete
  (durable round-trip — proves the daemon write IS the source of truth)

UI (test/storytelling-mark-complete.test.tsx, +4 tests in new describe block):
- Filters out missions with status === "complete" even when localStorage empty
- Missions with status "active" / "draft" / null / undefined are NOT filtered
- status === "complete" filter applies even when missionId is NOT in
  the localStorage set (survives localStorage clear)
- BOTH filters compose correctly when localStorage AND status are
  both used (status: complete OR localStorage hide both work)

All 4 status-backed regressions would fail without the status check
in the eligibleMissions filter — they discriminate the bug class.

VERIFICATION:
- 10/10 daemon missions-complete tests (was 7; +3 status surfacing)
- 11/11 storytelling Mark-complete + filter tests (was 7; +4 status-backed)
- Full UI suite: 735 pass + 1 skipped + 49 pre-existing AppShell
  pollution fails. Net vs prior tip: +4 pass / 0 new fail.
- My touched files typecheck clean.

CARRIED CONCERN (per guard's secondary note, called out per PRD honesty):
- mission.completed event emission from §3.5 SKIPPED at v0. Frontmatter
  write IS now the durable source of truth (UI reads it back via
  GET /api/missions/:id). Event emission can layer on in v0.3.2 if
  cross-process observers need to react.

SC-29 EXCEPTION declared: NONE
(status field surfacing reads existing frontmatter; new useMissionStatuses
hook calls existing daemon route; no new schema migration, no
ConfigStore key, no new event type.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:33:02 -07:00
mvschwarzandClaude Opus 4.7 ac3df45ff8 feat(daemon,ui): getting-started complete-and-hide (slice 18 Checkpoint E)
DAEMON:
- POST /api/missions/:missionId/complete writes status: complete to
  mission README.md frontmatter. Idempotent. Handles three cases:
  - Updates existing status: X line in place
  - Adds status: complete when frontmatter has no status field
  - Creates a fresh frontmatter block when README has no frontmatter
- Returns 200 { missionId, status: "complete" } on success;
  404 mission_not_found when no matching slices in the indexer;
  503 slices_indexer_unavailable when indexer not wired;
  500 with message on filesystem error.
- Helper writeMissionStatusComplete preserves unrelated frontmatter
  fields when updating status.

UI:
- New hook useCompletedMissions (localStorage-backed; mirrors
  useDismissedSeqs pattern). markCompleted(id) is idempotent;
  unmarkCompleted(id) is no-op on never-completed. Recovers from
  malformed JSON / non-array values.
- buildStorytellingFeedItems gains optional 3rd param
  completedMissionIds: Set<string>; missions whose name appears in
  the set are filtered out before the slice(0, 2) cap.
- StorytellingFeed accepts onMarkMissionComplete prop and threads it
  to ProgressCard.
- ProgressCard accepts optional onMarkComplete; when provided,
  renders a "Mark complete" button in the inlineActions slot
  (top-right of the collapsed card view). Click fires
  onMarkComplete(missionId) without toggling expand state.
- Feed.tsx wires the full flow: useCompletedMissions provides the
  filter set; handleMarkMissionComplete calls markCompleted (instant
  local hide) AND best-effort POSTs to the daemon endpoint for the
  audit trail (network errors swallowed; local state is the
  user-visible truth).

T9 (getting-started seeds status: active on fresh install): already
satisfied by packages/daemon/src/domain/workspace/default-workspace-
scaffold.ts default-mission scaffold (verified by grep; no scaffold
change needed in this slice).

Tests (TDD-first):
- 7/7 daemon POST /api/missions/:missionId/complete tests
- 7/7 useCompletedMissions hook tests
- 7/7 storytelling Mark-complete tests (ProgressCard render + click;
  buildStorytellingFeedItems filter — including 4-test discriminating
  battery on completedMissionIds behavior)
- Full UI suite: 731 pass + 1 skipped + 49 pre-existing AppShell
  pollution fails. Net vs prior Checkpoint D tip: +14 pass / 0 new fail.
- Daemon missions tests: 16/16 (7 new + 9 existing GET tests).

DESIGN DECISIONS / DIVERGENCES from IMPL-PRD:
- §3.5 mentioned "emits mission.completed event" as part of the daemon
  endpoint. Skipped at v0 — event emission would couple to the
  daemon's event bus and pull in more SC-29-adjacent surface. The
  frontmatter write is the durable audit trail; events can layer on
  in v0.3.2 if needed.
- Daemon write is best-effort from the UI; if it 5xx's or the
  network is unavailable, the local hide still takes effect. This
  prefers UX over strict consistency at v0; v0.3.2+ can reconcile
  by reading status: complete back from the daemon on mount.

SC-29 EXCEPTION declared: NONE
(per IMPL-PRD §8: the new POST endpoint writes a frontmatter field
to a markdown file. Not a schema-changing migration, not a
ConfigStore key, not a new event type. No SC-29 vector.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:27:10 -07:00
mvschwarzandClaude Opus 4.7 ccb901a45a test(daemon): failing tests for POST /api/missions/:missionId/complete (slice 18 Checkpoint E)
7 tests pin the endpoint contract from IMPLEMENTATION-PRD §3.5:
- Updates existing status: active to status: complete in README.md frontmatter
- Adds status: complete when frontmatter has no status field
- Creates a frontmatter block when README has no frontmatter at all
- Idempotent (calling twice still 200; only one status: complete line)
- Returns 404 when mission doesn't exist (no matching slices in indexer)
- Returns 503 when SliceIndexer is unavailable
- Preserves unrelated frontmatter fields when updating status

Tests fail because the POST handler does not exist yet. Implementation
lands in the next commit.

SC-29 EXCEPTION declared: NONE
(per IMPL-PRD §8: this endpoint writes frontmatter to a markdown file,
not a schema-changing migration or ConfigStore key — no SC-29 vector.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:22:46 -07:00
mvschwarzandClaude Opus 4.7 8ddecbe7f7 feat(ui): PluginsIndexPage via LibraryTopLevelEntry + Plugins sidebar (slice 18 Checkpoint D)
Second consumer of the LibraryTopLevelEntry primitive shipped in
Checkpoint C. Mirrors the Skills shape from §3.4 of the IMPL-PRD:

NEW packages/ui/src/components/specs/PluginsIndexPage.tsx:
- Reads usePlugins (from slice 3.3 plugin discovery)
- folderField="source"; groups by vendored / claude-cache / codex-cache
  with prettified folder labels
- iconKind="plugin" → ToolMark on each row
- isUserDefined={(p) => p.source !== "vendored"} so the empty-state
  guidance surfaces when only built-in vendored plugins are present
- Empty-state copy directs operator to install into ~/.claude/plugins
  or ~/.codex/plugins
- Click row → navigate to existing /plugins/$pluginId detail route

MODIFY packages/ui/src/routes.tsx:
- specsPluginsIndexRoute at /specs/plugins mounts PluginsIndexPage
- Existing /plugins/$pluginId detail route unchanged
- Added to routeTree.addChildren above specsSkillRoute (mirrors Skills)

MODIFY packages/ui/src/components/specs/SpecsTreeView.tsx:
- Top-level Plugins <Link> in the sidebar block above the Section tree,
  with onClick that sets expanded.plugins=true (same expand-on-click
  pattern shipped for Skills in Checkpoint C repair)
- New "plugins" entry in the SectionDef array (mirrors Skills section
  shape) so the click-expanded body has content to render
- TreeEntry extended with optional pluginId; Section render branch
  renders <Link to="/plugins/$pluginId"> when pluginId is set
- expanded state default includes plugins: false
- usePlugins added to hooks block; plugins+pluginsLoading threaded
  through useMemo deps

MODIFY packages/ui/src/components/specs/LibraryTopLevelEntry.tsx:
- Top-of-file comment tightened to describe the isUserDefined-aware
  emptyState semantics (was stale after Checkpoint C repair; flagged
  as non-blocker by velocity-guard on 0fb4bf8, addressed here as
  piggyback hygiene fix)

Tests (test/specs-tree-view-sidebar.test.tsx): 3 new Plugins sidebar
regression tests mirror the Skills tests:
- Plugins top-level link renders
- Plugins section collapsed by default (placeholder absent)
- After click on sidebar-plugins-top-level: placeholder visible →
  section expanded. Without onClick handler, this test fails.

Test counts:
- specs-tree-view-sidebar: 6/6 (was 3 Skills; +3 Plugins)
- LibraryTopLevelEntry: 13/13 (unchanged from C-repair)
- Full UI suite: 717 pass + 1 skipped + 49 pre-existing AppShell
  pollution fails. Net vs prior tip: +3 pass / 0 new fail.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:18:53 -07:00
mvschwarzandClaude Opus 4.7 0fb4bf86d5 fix(ui): user-defined empty-state semantics + sidebar expand-on-click (slice 18 Checkpoint C repair)
Two blockers from velocity-guard 18.C at a523c56.

Blocker 1 — emptyState contract drift:
IMPLEMENTATION-PRD §1.5 + T5 + HG-7 require the empty-state guidance
to render when no user-defined items exist, even if built-ins are
present. Prior commit only rendered emptyState when items.length === 0,
so an operator with only built-in skills never saw the "add a skill to
.openrig/skills/..." guidance.

Repair:
- LibraryTopLevelEntry adds isUserDefined?: (item: T) => boolean prop.
  When supplied, emptyState renders whenever NO item satisfies the
  predicate. Folder list still renders alongside so built-ins remain
  browsable (emptyState becomes a guidance banner above the list).
  Without the predicate, the prior items.length===0 fallback applies
  (back-compat for consumers who don't care about the distinction).
- SkillsIndexPage passes isUserDefined={(s) => s.source === "workspace"}.

Regressions (would all fail without isUserDefined semantics):
- emptyState renders when ALL items are built-ins (no user-defined)
- folder list STILL renders alongside emptyState in that case
- emptyState hidden when at least one user-defined item present
- without isUserDefined predicate, emptyState hidden if items non-empty (back-compat)

Blocker 2 — sidebar Skills link does not expand the Skills section:
PRD §3.3 + T7 require "click expands list below + opens page". Prior
commit added a plain Link to /specs/skills; clicking navigated but
left the Skills section in SpecsTreeView collapsed.

Repair:
- SpecsTreeView sidebar Skills link adds onClick that calls
  setExpanded((prev) => ({ ...prev, skills: true })). TanStack Router's
  Link runs onClick before its default navigation, so click both
  expands AND navigates.

Regression (specs-tree-view-sidebar.test.tsx):
- Skills section collapsed by default (no "No skills yet." placeholder)
- After click on sidebar-skills-top-level, the "No skills yet."
  placeholder appears inside the now-expanded section. Without the
  onClick handler this test fails because section state stays false.

Test counts after repair:
- LibraryTopLevelEntry: 13/13 (was 9; +4 isUserDefined regressions)
- SpecsTreeView sidebar: 3/3 NEW
- Full UI suite: 714 pass + 1 skipped + 49 pre-existing AppShell pollution fails. Net vs prior tip: +7 passing, 0 new failures.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:14:58 -07:00
mvschwarz eb197a9c07 merge(0.3.1): slice 21 onboarding-conveyor — getting-started narrative content + /lab/card-previews route; daemon canonical + CLI mirror parity test
--no-ff merge from founder-walk-21-onboarding-conveyor (960217e; post forward-fix; prior tip 469f68b preserved at safety tag temp/slice-21-pre-ff).
New getting-started-narrative.ts canonical source emits README + timeline + PROGRESS bodies for both getting-started slices (first-conveyor-run + inspect-project-evidence). default-workspace-scaffold + config-init-workspace CLI mirror both emit byte-identical scaffold output enforced by getting-started-narrative-parity.test.ts (9 tests). New CardPreviewsLab + /lab/card-previews route renders 6 card-kind variants imported directly from feed/cards/storytelling-cards (gallery=feed parity by construction).

Driver scope-cut DEFERRED (with founder-walk validation): mock qitems / proof packets / screenshots for Artifacts/Tests/Queue/Topology tabs out-of-scope for M-effort (file-only scaffold path; DB seeding + binary asset emission heavyweight). Narrative content addresses empty-tabs by explicitly teaching tab purposes + meta-framing ("mocked teaching run") — empty tabs read as "this is where evidence accumulates" not "why is it empty."

Tests: daemon 3394 + 1 new parity test + UI typecheck clean.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR after forward-fix (redo3-guard-3 narrow re-review; mode-2-velocity).
design-reviewer verdict: SHIP — V1-bar professional-grade met (narrative voice faithful to advisor draft within editorial tolerance; gallery=feed parity by construction; empty-tabs deferral appropriately scoped).
2026-05-11 14:11:35 -07:00
mvschwarzandClaude Opus 4.7 a523c56f0e feat(ui): LibraryTopLevelEntry primitive + SkillsIndexPage + sidebar (slice 18 Checkpoint C)
LibraryTopLevelEntry (new primitive at packages/ui/src/components/specs/
LibraryTopLevelEntry.tsx) is the reusable Library navigation primitive
per IMPLEMENTATION-PRD §1.5:
- generic over T extends { id: string; name: string }
- folderField: keyof T groups items into folders sorted alphabetically
- emptyState slot rendered when items.length === 0
- onItemClick(item) consumer-supplied navigation
- iconKind: string → ToolMark tool prop on each row
- formatFolderLabel optional override for folder header text
- isLoading optional surfaces the "loading" badge in folder headers

SkillsIndexPage (new at packages/ui/src/components/specs/SkillsIndexPage.tsx)
is the first consumer:
- Reads useLibrarySkills
- Groups by source ("workspace" = user-defined; "openrig-managed" =
  built-ins) with formatted folder labels
- Empty state copy directs operator to .openrig/skills/<skill-name>/
- Click row → navigate to existing /specs/skills/$skillToken detail route

Routes wire:
- /specs/skills now mounts SkillsIndexPage (specsSkillsIndexRoute)
- Existing /specs/skills/$skillToken detail route unchanged
- Added to routeTree.addChildren above specsSkillRoute

SpecsTreeView sidebar wire:
- New top-level <Link to="/specs/skills"> entry above the existing
  Section tree (Skills + Plugins top-level slot; Plugins lands in
  Checkpoint D in the same <ul> block)

Tests: 9/9 LibraryTopLevelEntry leaf tests green
- displayName render, folder grouping, same-key merge, click handler,
  emptyState toggle, alpha sort, formatFolderLabel, slug-anchored
  data-testid

Full UI suite baseline confirmed: 49 pre-existing AppShell global-polls
failures (per banked feedback_appshell_global_polls_test_pollution) are
identical to the pre-Checkpoint-C tip; this checkpoint adds +9 passing
tests with ZERO new failures.

Typecheck on my touched files: clean. Pre-existing 34 branch typecheck
errors carry forward, identical to raw main.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:10:04 -07:00
mvschwarz 3b551e8dcc merge(0.3.1): slice 20 mobile — vertical rail layout on mobile; artifacts viewer responsive two-pane; tap targets 44px on mobile / 40px desktop
--no-ff merge from founder-walk-20-mobile (c135f49; single commit; parent main 2c41c4f; safety tag temp/slice-20-pre-ff preserves prior tip 9f60569 pre-forward-fix).
AppShell mobile slide-over Rail swapped to vertical orientation (one icon per row); rail icon tap target h-11 w-11 (44px iOS HIG) with lg:h-10 lg:w-10 (40px desktop scope preserves prior hitbox). FilesWorkspace outer two-pane flex-col sm:flex-row; tree aside w-full max-h-48 mobile + sm:w-72 sm:max-h-none desktop. Border placement swaps mobile (border-b under tree) and desktop (border-r right of tree) via sm:-prefix folding.

Tests: app-shell 37/37 (+2 slice-20 cases asserting flex-col mobile rail + h-11/lg:h-10 dual-class tap targets) + slice-20-mobile-density 4/4 (NEW source-scan suite with cwd-independent fileURLToPath anchoring); 41/41 PASS post-merge. UI build clean.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR after forward-fix (redo-guard-2 narrow re-review; mode-2-velocity).
design-reviewer verdict: SHIP — V1-bar professional-grade met (uniform 44px tap target + vertical-stack artifacts viewer both correct calls per Fitts's law + readability assessment).
2026-05-11 14:09:24 -07:00
mvschwarzandClaude Opus 4.7 960217e9fa feat(workspace,ui): getting-started narrative + /lab/card-previews route
V0.3.1 slice 21 onboarding-conveyor. Two coupled deliverables:

(1) Conveyor narrative content for the getting-started mission's two
slices (`first-conveyor-run` + `inspect-project-evidence`). Fresh-install
operator now gets click-through-to-learn tab content: opening Overview /
Story / Progress in each slice teaches both "what a conveyor run is" and
"what each Project tab does." Mocked worked-example based on a realistic
3-agent topology (orch + driver + reviewer) shipping a CLI lint tool
through declaration -> route -> build -> review with concern ->
remediation -> ship.

- New `packages/daemon/src/domain/workspace/getting-started-narrative.ts`
  exports the canonical README / timeline / PROGRESS bodies.
- `packages/daemon/src/domain/workspace/default-workspace-scaffold.ts`
  honors a narrative override per slice and emits `timeline.md` when the
  narrative defines it (other slices keep boilerplate).
- `packages/cli/src/commands/config-init-workspace.ts` mirrors the same
  narrative inline because cli + daemon don't cross-import today.
- New parity test
  `packages/daemon/test/getting-started-narrative-parity.test.ts`
  asserts byte-identical cli/daemon scaffold output for the
  getting-started slices so the mirror cannot drift.

(2) New `/lab/card-previews` route + `CardPreviewsLab` component
rendering all for-you card kind variants with sample data (Shipped /
Incident / Progress / Approval / Concept / EmptyState). Imports the same
card components the live feed uses, so visual divergence is impossible
without editing the components themselves. Doubles as a designer
reference + a regression surface.

Scope cuts surfaced (deferred follow-up if needed):

- Mock qitems / files / commits / proof packets / screenshots for the
  Artifacts / Tests / Queue / Topology tabs are NOT seeded by this
  slice. The scaffold writes files only; seeding DB-backed qitems +
  binary assets requires a parallel scaffold path materially out of
  M-effort scope. Overview + Story + Progress tabs carry the
  conveyor-teaching weight per dispatch primary goal. If empty Queue /
  Tests / Topology tabs read as confusing on the walk, file a follow-up
  slice scoped to DB-seeded mock content.

Test evidence:

- Daemon suite: 3394 passed / 1 skipped / 0 failed (246 files; +1 new
  parity test).
- daemon-lifecycle.test.ts: 56/56 PASS (CLI scaffold tests unchanged;
  additions are additive to file emission).
- UI typecheck on touched files: zero NEW errors. Pre-existing
  @tanstack/react-table errors persist on main; unrelated.
- git diff --check: clean.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:09:05 -07:00
mvschwarzandClaude Opus 4.7 c1968b2272 test(ui): failing tests for LibraryTopLevelEntry primitive (slice 18 Checkpoint C)
9 leaf tests pin the primitive contract from IMPLEMENTATION-PRD §1.5:
- Renders page title from displayName
- Renders items grouped by folderField
- Same folder values merge into one section
- Click item → onItemClick called with item
- Empty state slot renders when items.length === 0
- No empty state when items has values
- Folder groups sorted alphabetically by key
- formatFolderLabel customizes header text
- data-testid="library-top-level-{slug}" root anchor

Tests fail because src/components/specs/LibraryTopLevelEntry.tsx
does not exist yet. Implementation lands in the next commit.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:07:06 -07:00
mvschwarzandClaude Opus 4.7 c135f4973f feat(ui): mobile responsive shell + files viewer (slice 20)
release-0.3.1 slice 20 — two responsive layout fixes for narrow
viewports (phone-class):

1. AppShell mobile hamburger menu now renders the slide-over Rail
   vertically (one icon per row) instead of the prior horizontal scroll
   layout. Rail's existing vertical mode (`flex-col`) is invoked at
   the slide-over call site by flipping `vertical={false}` → `vertical`.

2. Rail icon tap targets are 44px on mobile (default `h-11 w-11`) to
   meet the iOS HIG minimum, and restore the original 40px hitbox at
   `lg:` (desktop) via `lg:h-10 lg:w-10`. Mobile-first cascade: the
   larger square only paints at < lg: viewports where thumbs are the
   input model, not mouse precision.

3. FilesWorkspace two-pane shape stacks vertically at narrow viewports
   (default `flex-col`) and restores the horizontal `w-72` tree column
   at `sm:` and wider. The mobile tree pane is capped at `max-h-48` so
   the user can scroll past it to the document panel. Border placement
   swaps too (bottom border on mobile; right border on desktop).

Surfaces audited per IMPL-PRD HG list:
- HG-1 artifacts/files readable at 375px: FilesWorkspace stacks; doc
  pane claims full width on mobile.
- HG-2 hamburger menu vertical at 375px: Rail in mobile slide-over now
  `flex-col`, no `overflow-x-auto`. Regression tests assert.
- HG-3 desktop layouts unchanged: `sm:` prefix gates the new FilesWorkspace
  responsive branches; rail tap-target reverts to 40px at `lg:`. Desktop
  CSS is byte-equivalent to before.
- HG-4 tap targets ≥44px on mobile: `h-11 w-11` default; verified via
  className assertion in app-shell.test.tsx.
- HG-5 document panel readable: FilesWorkspace mobile lays out tree
  above content, content takes full width.
- HG-6 design-reviewer V1-bar mobile audit: pending design-reviewer.

Tests:
- packages/ui/test/app-shell.test.tsx: 37/37 (was 35; +2 slice-20 cases
  for vertical mobile rail orientation + tap-target shape with both
  mobile-default and lg:-override classes asserted)
- packages/ui/test/slice-20-mobile-density.test.tsx: new 4-case
  source-scan suite. Source-scan discriminator verified locally —
  flipping the Rail invocation back to `vertical={false}` causes the
  AppShell-orientation test to fail; restored, all 4 pass.
- UI tsc clean.

SC-29 EXCEPTION declared: NONE.

Walk-back posture: UI-only swap; no data migration; revert-via-merge
is the cheapest class if mobile audit surfaces a regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:06:46 -07:00
mvschwarzandClaude Opus 4.7 895a0b242d fix(ui): gate keyboard dismiss to article focus only (slice 18 Checkpoint B repair)
Per velocity-guard 18.B BLOCKING-CONCERN at ddb1c47: FeedCard onKeyDown
fired on Backspace/Delete regardless of which descendant was focused,
so the same keys typed into nested interactive controls (dismiss
button itself, VerbActions buttons, QueueItemTrigger, proof
thumbnails, etc.) would bubble up to the article handler and
soft-dismiss the whole card. The stated contract is Backspace/Delete
on the focused card, not on any child control.

Repair: handleKeyDown guards on event.target === event.currentTarget
before honoring Backspace/Delete; keypresses bubbled from nested
descendants are ignored.

Regression tests added (both would fail without the guard):
- Backspace from feed-card-dismiss button does NOT trigger card dismiss
- Delete from feed-card-dismiss button does NOT trigger card dismiss

Existing tests still green: 51/51 across useDismissedSeqs (10) +
UndoToast (4) + FeedCard dismiss (12; was 10, +2 regressions) +
activity-feed (25).

Typecheck: my touched files clean; 34 pre-existing branch errors
remain identical to raw main (orthogonal).

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:03:46 -07:00
mvschwarzandClaude Opus 4.7 ddb1c4724f feat(ui): FeedCard dismiss surfaces + UndoToast + Feed wire (slice 18 Checkpoint B)
Surfaces added (per IMPLEMENTATION-PRD §3.1 + HG-1/2/3/4):
- FeedCard now accepts onDismiss?: (seq: number) => void prop
  - Hover-revealed X button top-right of card header (HG-2)
  - Backspace + Delete keys on focused card (HG-3); tabIndex=0 when interactive
  - Touch swipe-right past 50% width threshold (HG-1); leftward swipes + below-threshold no-op
  - All surfaces no-op when onDismiss is omitted (full backward compat)
- New UndoToast component — single-action transient surface
  - durationMs param drives onExpire timer; onUndo click suppresses onExpire (HG-4)
  - Fixed bottom-center placement, role="status" + aria-live="polite"
- Feed.tsx wires onDismiss → dismiss(seq) + sets pending undo seq → renders UndoToast
  - Undo click calls undismiss(seq) + clears pending
  - Toast keyed on pendingUndoSeq so each new dismiss resets the 5s timer

Tests (TDD-first; lands green at this commit):
- 4/4 UndoToast leaf tests (fake-timer driven onExpire/onUndo races)
- 10/10 FeedCard dismiss surface tests (button, keyboard, touch threshold + leftward)
- 25/25 prior activity-feed tests still green (no regression)

Switched gesture from pointer events to touch events: jsdom does not
reliably forward pointerType through fireEvent, and touch events are
the semantically correct mobile-only API anyway. Mouse-on-desktop now
uses the X button exclusively (no mouse drag interpretation).

Note on integration test scope: Feed.tsx full-stack integration tests
would require mocking SSE + /api/config + queue items + audit + slices +
missions + router. Per banked feedback_appshell_global_polls_test_pollution
(2026-05-05 Phase E hygiene rule), direct-render leaf tests are
preferred and full-stack tests pollute jsdom state. Feed wire proof is
the typecheck-clean diff itself; live Feed integration defers to
QA/dogfood window.

Typecheck: 34 pre-existing errors on branch IDENTICAL to raw main HEAD;
useDismissedSeqs.ts + UndoToast.tsx + FeedCard.tsx + Feed.tsx all
typecheck clean — zero new errors.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 14:01:12 -07:00
mvschwarzandClaude Opus 4.7 229d48ba91 test(ui): failing tests for UndoToast + FeedCard dismiss surfaces (slice 18 Checkpoint B)
UndoToast (new component) — 4 tests:
- Renders Undo control + label
- Click Undo calls onUndo
- onExpire fires after durationMs (fake timers)
- onExpire suppressed when onUndo fired first

FeedCard dismiss surfaces — 10 tests:
- Dismiss control absent when onDismiss omitted (backward compat)
- Dismiss button rendered when onDismiss provided
- Click dismiss → onDismiss(card.source.seq)
- Backspace + Delete key on focused card → onDismiss
- Other keys are no-ops
- article tabIndex=0 when onDismiss provided
- Touch swipe-right past 50% width threshold → onDismiss
- Touch swipe-right below threshold → no dismiss
- Mouse pointerType swipe → no dismiss (mouse uses button only)

Tests fail because UndoToast component does not exist + FeedCard does
not yet accept onDismiss prop. Implementation lands in the next commit.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 13:58:11 -07:00
mvschwarzandClaude Opus 4.7 c7aac15a0c feat(ui): useDismissedSeqs hook + Feed.tsx soft-dismiss filter (slice 18 Checkpoint A)
New hook localStorage-persists per-event-seq dismissed state and auto-
prunes entries whose seq has aged out of the activity buffer (seq <
min(currentSeqs)). Hook contract per IMPLEMENTATION-PRD §2:
- dismissedSeqs: Set<number>
- dismiss(seq) idempotent, persists to localStorage
- undismiss(seq) no-op on never-dismissed, persists
- Auto-prune on currentSeqs change keeps the stored set bounded
- Recovers gracefully from malformed JSON / non-array values

Feed.tsx threads rawCards' seqs into the hook and filters the final
cards list through the dismissed set. Subscription filter runs first,
then lens filter, then dismiss filter — so dismissing never resurrects
items the operator already excluded by subscription or lens.

UI surfaces (gesture, hover-X, keyboard, Undo toast) land in
Checkpoint B; this checkpoint is the data-layer floor only.

Tests: 10/10 useDismissedSeqs unit tests; 25/25 activity-feed Feed
integration tests still green (no regression).

Pre-existing typecheck errors (34 total in this branch, identical to
raw main HEAD; clustered in TopologyTableView.tsx + a few other
unrelated surfaces) are not introduced by this slice — useDismissedSeqs.ts
and Feed.tsx both typecheck clean.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 13:54:58 -07:00
mvschwarzandClaude Opus 4.7 a4139e9fe3 test(ui): failing tests for useDismissedSeqs hook (slice 18 Checkpoint A)
10 tests pin the contract from IMPLEMENTATION-PRD §2 + T1+T3:
- starts empty when localStorage has no entry
- reads existing dismissedSeqs from localStorage on mount
- recovers gracefully from malformed JSON or non-array values
- dismiss(seq) adds to set + persists; idempotent on repeat
- undismiss(seq) removes + persists; no-op on never-dismissed
- auto-prunes entries with seq < min(currentSeqs) when currentSeqs change
- does NOT prune when currentSeqs is empty

Tests fail at this commit because src/hooks/useDismissedSeqs.ts does
not exist yet. Implementation lands in the next commit.

SC-29 EXCEPTION declared: NONE

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 13:53:35 -07:00
mvschwarz 2c41c4fdf5 merge(0.3.1): slice 19 founder-walk-library-and-project-density — single-row density on Project sidebar slice rows + workspace-overview slice rows; version moves to drawer/title/aria-label
--no-ff merge from founder-walk-19-library-project-density (1c4b892; single commit; parent main b1e04b8).
ProjectTreeView slice rows + ScopePages workspace-overview mission-card slice rows collapsed from 2-row (block + meta sub-row) to single-row (flex items-baseline gap-2 + min-w-0 truncate name + shrink-0 meta + title + aria-label). 8 list surfaces enumerated (5 pre-existing single-row anchors + 2 changed + 1 changed elsewhere); ≥5 audit per HG-4.

Identical density-class composition across both changed surfaces. py-1 leaf padding preserves hierarchy step from py-0.5 parent mission rows. Vellum + typography tokens preserved (font-mono Link; text-stone-500 + text-[9px] uppercase tracking-[0.12em] meta).

A11y: title + aria-label added on changed rows; meta data-testid preserved for assertion backward-compat.

Tests: p5-mission-discovery 13/13 (+1 slice-19 case) + project-workspace-overview 4/4 (+1 case). 3 pre-existing UI failures baseline-confirmed (p5-1-table-cmux / p5-3-activity-ring-hot-potato / p5-slice-scope default-tab); not slice-19 regressions.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR (redo-guard-2, mode-2-velocity).
design-reviewer verdict: SHIP — V1-bar professional-grade met. No polish/rework needed.
2026-05-11 13:36:47 -07:00
mvschwarz 9fa8dc8819 merge(0.3.1): slice 23 founder-walk-queue-handoff-envelope — daemon wrapPaneEnvelope + queue.maybeNudge integration; envelope parity with rig send wrapSendBody
--no-ff merge from founder-walk-23-queue-handoff-envelope (abe6551; single-commit slice).
Daemon-side pane-envelope renderer (verbatim parity with CLI wrapSendBody body + cross-reference docstrings + 6 mirrored tests in pane-envelope.test.ts). queue-repository.maybeNudge wraps body via wrapPaneEnvelope; mission-control-write-contract threads actorSession through to nudge for route/handoff verbs.

Backwards-compat preserved: canonical "Queue handoff: qitem-X - check your queue." bare-line remains as substring inside envelope; 4 substring-anchored skills audited + unaffected; explicit regression test guards future drift.

SC-29 EXCEPTION declared: NONE.

guard verdict: CLEAR (redo3-guard-3, mode-1-guarded-pair).
2026-05-11 13:25:59 -07:00
mvschwarzandClaude Opus 4.7 1c4b892130 feat(ui): single-row density for project slice list items (slice 19)
release-0.3.1 slice 19 — collapse Project page list items to single-row
(icon + name + meta inline). Removes the stacked-block sub-row that
duplicated meta info on a second line in font-mono muted text.
Mirrors the at-a-glance density patterns Library list surfaces already
use (SpecsLibraryPage, plugins list, agent-images list).

Surfaces audited (≥5 per IMPL-PRD HG-4):
- Library specs list (SpecsLibraryPage LibraryRowContent) — already
  single-row; no change needed
- Library applications list — same component
- Library agent specs list — same component
- Library workflow specs list — same component
- Plugins library — already single-row; no change needed
- Project mission list (ProjectTreeView mission rows) — already
  single-row
- Project slice list (ProjectTreeView slice rows) — CHANGED to
  single-row
- Workspace-overview slice list (ScopePages mission card) — CHANGED to
  single-row

Density choice for the changed surfaces:
- Removed `block truncate` stacked spans
- Added `flex items-baseline gap-2` parent with `min-w-0 flex-1 truncate`
  name span and `shrink-0 text-[9px] uppercase` meta span on the same row
- Preserved meta `data-testid` so existing assertions on
  `project-slice-<name>-meta` and `workspace-overview-slice-<name>-meta`
  text content keep working

Discoverability + a11y (HG-3 + HG-5):
- Added `title` attribute carrying `${displayName} — ${meta}` for hover
  discovery (cursor reveals the row's full content)
- Added `aria-label` carrying the same content for screen-reader narration
- Click target unchanged: still routes to /project/slice/$sliceId where
  the detail drawer shows the full slice info (qitems, status, proof,
  artifacts)

Files changed:
- packages/ui/src/components/project/ProjectTreeView.tsx — slice row
  rendering inside the mission sub-tree
- packages/ui/src/components/project/ScopePages.tsx — slice row
  rendering inside the workspace-overview mission card

Tests:
- packages/ui/test/p5-mission-discovery.test.tsx: new "slice 19" case
  asserts ProjectTreeView slice rows are flex (not block), meta still
  testid-addressable, title + aria-label carry the meta string. 13/13
  tests in the file green.
- packages/ui/test/project-workspace-overview.test.tsx: new "slice 19"
  case asserts ScopePages slice rows mirror the same density + a11y
  shape. 4/4 tests in the file green.
- UI full sweep: pre-existing 3 unrelated failures unchanged
  (p5-1-table-cmux, p5-3-activity-ring-hot-potato, p5-slice-scope:
  default landing tab — baseline-confirmed at b1e04b8 via git-stash
  baseline run).
- UI tsc clean.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 13:19:42 -07:00
mvschwarzandClaude Opus 4.7 abe65518eb feat(daemon,cli): rig queue nudges use same From/To envelope as rig send
V0.3.1 slice 23 founder-walk-queue-handoff-envelope. `rig queue create`
/ `rig queue handoff` nudges previously emitted a bare body line into
the recipient's tmux pane:

  Queue handoff: qitem-X - check your queue.

No sender info, no reply affordance. Symmetric with `rig send` (which
already wraps with From / To / --- / body / --- / ↩ Reply since the
pre-release CLI/daemon Item 2 fix).

This slice wraps queue nudges with the same envelope so the recipient
pane renders identically:

  From: <source>
  To: <destination>
  ---
  Queue handoff: qitem-X - check your queue.
  ---
  ↩ Reply: rig send <source> "..."

Implementation:

- New `packages/daemon/src/lib/pane-envelope.ts :: wrapPaneEnvelope`
  produces BYTE-IDENTICAL output to CLI's `wrapSendBody`. Parity
  contract documented at both sites; if either implementation
  changes, both must update in lockstep. (cli + daemon don't
  cross-import today — the contract is enforced via test parity in
  `packages/daemon/test/pane-envelope.test.ts` mirroring the CLI's
  `send-header.test.ts` assertions.)
- `QueueRepository.maybeNudge` gains an optional `sourceSession`
  param. Bare body is wrapped with the envelope before sending via
  the transport.
- Callers thread sourceSession through:
  - `create()` passes `input.sourceSession`
  - `handoff()` + `handoffAndComplete()` pass `input.fromSession`
  - `MissionControlWriteContract` passes `input.actorSession`
  - Workflow callers (workflow-runtime, workflow-projector) fall
    through to `<unknown sender>` for v0 (internal-system nudges;
    out-of-scope for this slice's user-facing surface).

Backwards-compatibility (per IMPL-PRD §2 BC):

The canonical bare-line "Queue handoff: qitem-X - check your queue."
is PRESERVED VERBATIM inside the envelope between the `---`
separators. Substring-grep parsers in skills + agent specs continue
to find it (audit grep found no parser anchoring on message-start;
parsers match on the body substring which is unchanged). Test
`wraps the canonical ... bare-body without altering it` is the
explicit regression gate.

Tests:
- pane-envelope.test.ts (NEW, 6 tests): mirrors send-header.test.ts
  assertions + adds backwards-compat substring test.
- queue-repository.test.ts: 25/25 unchanged (nudge wrapping is
  transparent to the existing assertions).
- send-header.test.ts: 5/5 unchanged (CLI envelope renderer
  unmodified except parity-cross-reference docstring).
- Full daemon suite: 3385 passed / 1 skipped / 0 failed.
- git diff --check clean.

SC-29 EXCEPTION declared: NONE.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 13:19:02 -07:00
mvschwarz b1e04b8750 merge(0.3.1): slice 15 founder-walk-file-link-primitive — FileLink primitive + image-rendering via canonical FileViewer.inferKind path
Closes walk items 6 (images show as binary) + 11 (referenced files universally not clickable). WAVE 2 CLOSE — slices 12 + 13 + 14 + 15 all merged.

What ships:
- NEW `<FileLink>` primitive at `packages/ui/src/components/ui/FileLink.tsx` — thin wrapper over existing FileReferenceTrigger with simpler API (path + optional root/absolutePath/kind/readPath). Default testId; default children = path string.
- ScopePages SliceArtifactsTab file rows wrap each `entry.type='file'` in FileLink with absolutePath = `${detail.slicePath}/${entry.relPath}`. Directories stay inert (no drawer trigger).
- Walk-item-6 fix: image extensions auto-detect via FileViewer.inferKind (already shipped); callsite routing through FileLink → drawer makes the canonical image-rendering path the default. NO new viewer kind; NO new SharedDetailDrawer selection type.

Scope-cuts (driver-judgment; endorsed):
- ProofImageViewer rename DEFERRED — multiple callers tightly coupled to `proofAssetUrl(sliceName, relPath)`. FileViewer image-kind path is the canonical going-forward surface; ProofImageViewer remains for proof-asset-specific modal contexts. Founder note ("Proof" misleading for non-proof images) flagged for follow-up if usage warrants.
- DocsTab intentionally NOT converted — split-view local-tree + right-pane viewer is the correct UX for that surface; replacing with drawer-trigger would worsen affordance. Documented in slice-walk packet.
- Exhaustive audit-and-replace DEFERRED — primitive + highest-impact surface (ScopePages SliceArtifactsTab) ships; remaining audit-list captured in slice-walk.md for incremental pattern application.

Test coverage:
- file-link.test.tsx (NEW): 6/6 PASS — default-testId; custom children; custom testId; click→setSelection; absolutePath+readPath+kind threading; walk-item-6 no-spurious-kind discriminator (verifies FileLink does NOT inject kind; FileViewer.inferKind owns image detection)
- Full daemon suite: 3379/3379 passed / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors
- git diff --check clean

Branch commits:
- be2ea65 feat(ui): FileLink primitive + ScopePages SliceArtifactsTab integration

Triple-guard:
- redo3-driver-3 driver (Phase D + scope-cut judgment calls flagged transparently)
- redo3-guard-3 guard (1 narrow re-review pass; CLEAR; walk-item-6 discriminator coverage real)
- velocity-qa VM dev-server walk: deferred to founder host walk on refreshed VM for visual HG-3 (image renders as image) + HG-5 (artifacts files clickable opens drawer)

HG ledger: HG-1/2/3/5/6/7 PASS code-level; HG-4 (DocsTab) judgment-call NOT CONVERTED (split-view correct as-is).

Walk-item phrasing discipline applied; slice id token retained as stable identifier.

WAVE 2 STATE POST-MERGE:
- ✅ Slice 12 (mission/slice Overview+Progress content) at 08017ab
- ✅ Slice 13 (per-mission topology spec graph) at 5973140
- ✅ Slice 14 (table active/idle + terminal click) at 82d4e9f
- ✅ Slice 15 (FileLink primitive + image rendering) at <this commit>
- All 4 wave-2 slices merged. Wave 3 dispatches next: slices 16 (typography-and-vellum) + 18 (for-you-dismiss-and-skills bundled with Plugins-sidebar per founder-walk amendment) + 19 (library-and-project-density).
2026-05-11 09:50:19 -07:00
mvschwarzandClaude Opus 4.7 be2ea657a1 feat(ui): FileLink primitive + slice artifacts file references clickable
V0.3.1 slice 15 walk-items 6 + 11 from the 2026-05-11 synthesis.

(1) New FileLink primitive at packages/ui/src/components/ui/FileLink.tsx:
- Thin wrapper over the existing FileReferenceTrigger with a simpler
  API (just `path` + optional `root` / `absolutePath` / `kind` /
  `readPath` instead of a fully-constructed FileViewerData).
- Default `testId="file-link"`; `children` falls back to `path`.
- FileViewer's existing inferKind() handles image-extension auto-
  detection at render time (drawer-viewers/FileViewer.tsx:44 covers
  .png/.jpg/.jpeg/.gif/.webp/.svg → "image" → renders <img>). Walk-
  item 6 ("images show as binary") is therefore resolved by routing
  image-typed file references through this primitive instead of
  rendering inert filename text — no new viewer kind or drawer
  selection type needed.

(2) ScopePages slice-artifacts file list (highest-impact replacement):
- SliceArtifactsTab now wraps each FILE entry in <FileLink> with
  the slice's absolutePath threaded through. Directory entries stay
  inert (no read-as-file).
- Click on a screenshot/image now opens the drawer with the image
  rendered correctly; click on a markdown / text / yaml file opens
  with MarkdownViewer / plain text accordingly.

Scope cuts (endorsed by orch at pre-work ACK):
- ProofImageViewer rename deferred — multiple callers tightly coupled
  to proofAssetUrl(sliceName, relPath); the FileViewer image-kind path
  is the canonical going-forward surface. Flagged in driver ACK for
  follow-up.
- Exhaustive audit-and-replace deferred — slice ships the primitive +
  highest-impact surface; remaining call sites (DocsTab in-tab viewer
  is actually well-shaped today; other potential sites identified via
  pre-work audit) iterate as founder-walk surfaces additional gaps.

Tests:
- file-link.test.tsx (NEW): 6 tests — render with default testId +
  path-as-label; custom children; custom testId; click →
  setSelection({ type: "file", data }); absolutePath + readPath +
  kind threaded through; walk-item-6 fix (no spurious kind injection
  so downstream inferKind path is unblocked).
- Full daemon suite: 3379 passed / 1 skipped / 0 failed.
- UI typecheck on touched files: zero new errors.
- git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 09:46:13 -07:00
mvschwarz 82d4e9f8c5 merge(0.3.1): slice 14 founder-walk-topology-table-and-terminal — table active/idle status sub-states + always-visible actions + terminal cards click-through
Closes walk items 15 + 16 + 17 from the 2026-05-11 founder-walk synthesis.

Walk item 15 (table status sub-states + shimmer): TopologyTableView StatusCell maps `running` → `active`/`idle` via row.activityRing.state (existing data signal from topology-activity.ts:152-158; no backend extension). New CSS class with background-clip:text gradient sweep (2.4s loop) on active label only; dot stays static. @media (prefers-reduced-motion: reduce) suppresses animation + restores solid text-success. StatusPip API extended with optional labelClassName prop (scoped to label span only; non-breaking for existing callers).

Walk item 16 (action column always-visible): CmuxButton drops opacity-0 + group-hover gating. Action cell renders cmux + TerminalPreviewPopover side-by-side in a flex container; header text changes from blank to 'Actions'.

Walk item 17 (terminal cards click-through + smaller font): SeatTerminalCard header text-[10px] → text-[8px] (20% reduction). Card body wrapped with absolute-inset trigger button that dispatches openrig:topology-terminal-preview event. Per-card TerminalPreviewPopover mounted with `renderTrigger={false}` (NEW PROP) — wrapper + event listener + portal popover still mount, but no internal focusable button is rendered. Eliminates the duplicate keyboard tab stop (a11y).

Forward-fix iteration on this slice:
- 5f92ecc feat(topology): active/idle status sub-states + always-visible actions + terminal cards click-through
- 31050c7 fix(topology): TerminalPreviewPopover renderTrigger=false prop kills duplicate tab stop (guard CONCERNING a11y catch)

Test coverage:
- p5-topology-terminal.test.tsx: 11/11 PASS (+1 a11y discriminator test asserting 2 card-level trigger buttons + ZERO popover-trigger buttons for 2 seats)
- Full daemon: 3379/3379 PASS / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors (pre-existing worktree @tanstack/react-table + Vite alias issues persist; unrelated infrastructure)
- git diff --check clean

Discrimination verified via banked hardcoded-probe-in-source technique: temporarily reverted ONLY TopologyTerminalView to buttonClassName=sr-only (kept new prop + test in place) → test FAILS with 4 total tab stops for 2 cards; restored renderTrigger={false} → 11/11 PASS.

Triple-guard:
- redo3-driver-3 driver (Phase D + FF1 a11y resolution)
- redo3-guard-3 guard (2 reverify passes: CONCERNING on duplicate tab stop, CLEAR on renderTrigger external-trigger mode)
- velocity-qa VM dev-server visual walk: deferred for HG-1/2/3/4/7 (shimmer, reduced-motion, card click → popover); founder host walk catches if needed

Visual HGs PENDING (deferred to founder-walk VM dev-server walk on refreshed main):
- HG-1: active label renders with shimmer
- HG-2: idle label renders without shimmer
- HG-3: shimmer scoped to active label only
- HG-4: prefers-reduced-motion honored (DevTools rendering emulation)
- HG-7: terminal card click → TerminalPreviewPopover opens

Walk-item phrasing discipline applied; slice id token retained.

WAVE 2 progress: slice 13 + slice 14 of 3 wave-2 slices closed (13 + 14 + 15); 15 held for next driver opening.
2026-05-11 08:51:05 -07:00
mvschwarzandClaude Opus 4.7 31050c7ae2 fix(topology): TerminalPreviewPopover renderTrigger=false prop kills duplicate tab stop
Forward-fix #1 on 5f92ecc. Guard a11y finding: each TerminalView card
exposed TWO focusable controls — the full-card absolute-inset trigger
button AND the popover's internal trigger button (rendered with
buttonClassName="sr-only" so visually hidden but still keyboard-
reachable). Sighted keyboard users would hit two tab stops for the
same action; sensible-tab-order check failed.

Resolution per guard recommendation (a): teach TerminalPreviewPopover
an external-trigger mode.

- New optional prop `renderTrigger?: boolean` on
  TerminalPreviewPopover (default true preserves graph + table view
  callers). When false, the popover skips its internal button
  entirely; the wrapper div + event-listening logic + portal popover
  still mount so the card's own trigger can dispatch
  TERMINAL_PREVIEW_EVENT and have this instance render the positioned
  popover.
- SeatTerminalCard updated from buttonClassName="sr-only" →
  renderTrigger={false}. Same anchor positioning via wrapperClassName;
  zero focusable button claim.

Regression test added in p5-topology-terminal.test.tsx:
'each terminal card exposes exactly one tab-reachable trigger button
(no duplicate sr-only popover button)' — renders 2 seats, asserts:
- 2 card-level triggers (button[data-testid^='terminal-card-trigger-'])
- 0 popover-trigger buttons (button[data-testid$='-terminal-open'])

Discrimination verified: temporarily reverted ONLY
TopologyTerminalView.tsx back to buttonClassName="sr-only" (kept
TerminalPreviewPopover + new test) → the assertion fails (2 popover
trigger buttons render). Restored renderTrigger={false} → 11/11
PASS.

Tests:
- p5-topology-terminal.test.tsx: 11/11 PASS (1 new).
- Full daemon suite: 3379 passed / 1 skipped / 0 failed.
- git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 08:48:36 -07:00
mvschwarzandClaude Opus 4.7 5f92eccc6e feat(topology): active/idle status sub-states + always-visible actions + terminal cards click-through
V0.3.1 slice 14 walk-items 15, 16, 17 from the 2026-05-11 synthesis.

Walk-item 15 — TopologyTableView status sub-states:
- New StatusCell maps `running` status into "active" / "idle" labels
  based on `row.activityRing.state`. Non-running statuses (starting /
  stopped / failed / unknown) keep their raw label.
- Active label gets a subtle left-to-right shimmer via a new
  `topology-table-active-shimmer` class. Implemented as a
  background-clip:text gradient sweep so only the label text shimmers
  while the green status pip dot stays static.
- `@media (prefers-reduced-motion: reduce)` block suppresses the
  animation and falls back to the same green text color, preserving
  visual weight per DESIGN.md §Motion.
- Data signal verified shipped: `ActivityRingState` enum at
  `packages/ui/src/lib/topology-activity.ts:15` already produces
  active/idle from `agentActivity.state === "running"` OR
  `currentQitems.length > 0`. No backend changes needed.

Walk-item 16 — Action column always-visible:
- CmuxButton drops the `opacity-0` + `group-hover:!opacity-100` chain
  and renders unconditionally. Action column header gains "Actions"
  text.
- New cell renders cmux + terminal-preview side-by-side in a
  flex container. TerminalPreviewPopover provides the
  terminal-preview button via the same component the graph view uses.

Walk-item 17 — TerminalView cards click-through + smaller font:
- SeatTerminalCard header `text-[10px]` → `text-[8px]` (20%
  reduction via the existing type scale).
- Card body wrapped with an absolute-inset trigger button that
  dispatches `TERMINAL_PREVIEW_EVENT` with the seat's key. The
  TerminalPreviewPopover (mounted per card with sr-only button +
  wrapperClassName anchoring top-right) listens for the matching
  key and renders the positioned popover. Same black-glass popover
  the graph view shows.

StatusPip API extension:
- New optional `labelClassName` prop applied to the label `<span>`
  only (NOT the dot). Used by TopologyTableView to scope the
  shimmer animation to the label text while keeping the green dot
  static.

Tests:
- Full daemon suite: 3379 passed / 1 skipped / 0 failed.
- UI typecheck on touched files: zero NEW errors. Pre-existing
  `@tanstack/react-table` errors in TopologyTableView persist on
  main; unrelated.
- UI broader suite hits the worktree-PostCSS-tailwind infra issue
  banked from slice 12 (unrelated to this slice).
- git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 08:40:12 -07:00
mvschwarz 5973140b24 merge(0.3.1): slice 13 founder-walk-topology-per-scope-mapping — per-mission spec graph via workflow_spec frontmatter + openrig-velocity builtin
Closes walk item 7 (topology defined where; same graph everywhere).

What ships:
- WorkflowSpecRef type + workflowSpec field on SliceRecord/SliceListEntry; parseWorkflowSpecRef helper exported as single-source-of-truth parser
- GET /api/missions/:missionId extended with workflow_spec (parsed from mission README frontmatter) + topology.specGraph (projectSpecGraph(spec, null) when cached; null when undeclared or uncached)
- UI MissionScopePage Topology tab renders projected spec graph via TopologyTab when missionData.topology.specGraph exists; falls back to ScopeTopologyRollup otherwise
- Convention spread: <work-tree>/missions/release-0.3.1/README.md frontmatter declares workflow_spec: [email protected]
- NEW builtin workflow spec: packages/daemon/src/builtins/workflow-specs/openrig-velocity.yaml (6 steps modeling slice lifecycle: orch-dispatch → advisor refine → driver implement → guard review → qa verify → orch-merge; roles preferred_targets point at real seats)

Forward-fix iterations on this slice:
- df79077 feat(daemon,ui): per-mission topology spec graph via workflow_spec frontmatter
- b6f3114 fix(workflow): revert SliceWorkflowBinding nullability — drop unused declaration path (guard #1 caught speculative declaration-fallback as dead code)
- dcf1994 feat(workflow-spec): ship [email protected] built-in spec for mission Topology (VM verify caught missing spec; aspirational declaration didn't exist in cache)
- ac7d199 fix(workflow-spec): unique roles per step in openrig-velocity + projection-shape test (guard re-review caught projector deduplication-by-role; orch split into orch-dispatch + orch-merge with same preferred_targets; linear chain 6 nodes / 5 edges / no orphans)

Test coverage:
- slice-indexer: 32/32 (+3: workflow_spec parsing path)
- slice-workflow-binding: 9/9 (live-binding only after FF1 dropped speculative declaration-fallback)
- missions-routes: 9/9 (+4: workflow_spec / null absent / specGraph projected / specGraph null when uncached)
- starter-spec-loader: 8/8 (+1 discriminator test that loads shipped openrig-velocity spec + projects via projectSpecGraph + asserts EXACT edge chain + entry [dispatch] + terminal [merge] + no orphans; discrimination verified via hardcoded-probe-in-source on pre-fix YAML)
- conveyor-starter: 5/5 (updated to include openrig-velocity in shipped builtin list)
- Full daemon: 3379/3379 PASS / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors
- git diff --check clean

VM verify path: velocity-qa first verify at b6f3114 caught the missing-spec gap (topology.specGraph null; fallback worked); FF2 added the spec; FF3 fixed projector deduplication shape. Final cumulative ac7d199 ready for VM verify on populated env (founder-walk VM at 17459 tracks main).

Triple-guard:
- redo3-driver-3 driver (Phase D + FF1 + FF2 + FF3; methodical 4-iteration arc with discriminator-verified tests at each)
- redo3-guard-3 guard (3 reverify passes: BLOCKING #1 on dead-code nullable type widening, BLOCKING #2 on shape-not-coherent under projector contract, CLEAR on linear-chain reshape)
- velocity-qa VM partial: pre-FF2 caught spec-missing gap; post-merge VM verify follows on refreshed founder-walk VM

Banking-worthy lesson (from FF3): when authoring workflow_specs that get rendered via projectSpecGraph, each step must have a UNIQUE actor_role for the projector's role→step resolution to produce a coherent graph. Two steps with the same actor_role collapse: the projector keeps the first and the second becomes orphaned. Same-human-different-graph-role is the right split (e.g., orch-dispatch + orch-merge both prefer the same operator seat; the split is purely graph hygiene). Companion to projector single-frontier contract (banked feedback_workflow_spec_validate_against_projector).

Walk-item phrasing discipline applied throughout. Slice id token retained.

WAVE 2 progress: slice 13 of 3 wave-2 slices closes (13 + 14 + 15); 14 + 15 held for next cap opening.
2026-05-11 08:29:37 -07:00
mvschwarzandClaude Opus 4.7 ac7d199450 fix(workflow-spec): unique roles per step in openrig-velocity + projection-shape test
Forward-fix #3 on dcf1994. Guard re-review found the dcf1994 spec was
not coherent under the projector contract. projectSpecGraph
deduplicates by actor_role (keeps the FIRST step per role); the
prior YAML had TWO steps with actor_role: orch (dispatch + merge).
Result: vm-verify's `next_hop.suggested_roles: [orch]` resolved to
dispatch (the kept step) → loop-back instead of forward to merge.
refine had no incoming edge (orphan) and merge had no incoming edge
(orphan). 6 steps, 4 edges, 2 orphans, 1 loop-back.

Resolution: split the orch role into two uniquely-named roles —
`orch-dispatch` (entry) and `orch-merge` (terminal) — both with
preferred_targets pointing at orch-lead@openrig-velocity since the
same human seat performs both in practice. vm-verify now routes to
orch-merge.

Resulting graph (6 nodes, 5 edges, no orphans):
  dispatch → refine → implement → guard-review → vm-verify → merge

Discriminator test added in starter-spec-loader.test.ts: loads the
shipped spec, calls projectSpecGraph(spec, null), asserts the EXACT
edge chain + entry placement (dispatch) + terminal placement (merge)
+ zero orphans (every non-entry node has incoming + every non-terminal
node has outgoing). The prior "has guard role" assertion was a weak
discriminator; this shape-test catches the role-collision class
banked feedback_poc_regression_must_discriminate calls out.

Verified: pre-fix YAML (dcf1994) fails the new discriminator test
(temporarily reverted only the YAML, kept the new test; FAIL on that
case; restored YAML to PASS).

Full daemon suite: 3379 passed / 1 skipped / 0 failed on clean run.
git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 08:26:44 -07:00
mvschwarzandClaude Opus 4.7 dcf1994567 feat(workflow-spec): ship [email protected] built-in spec for mission Topology
Forward-fix #2 on b6f3114. Velocity-qa VM verify caught the data gap:
release-0.3.1/README.md's frontmatter declares
`workflow_spec: [email protected]` but no such spec existed in the
workflow-spec cache, so `topology.specGraph` came back null and the
mission Topology tab fell back to session-name aggregation. Code is
correct (null-fallback when uncached IS the intended behavior); the
acceptance gap was substrate/data, not code.

Per guard recommendation (a) — built-in shipped fixture path matches
the slice 22 / basic-loop / conveyor pattern:

- New `packages/daemon/src/builtins/workflow-specs/openrig-velocity.yaml`.
  6 steps modeling one slice end-to-end through the openrig-velocity
  rig (orch dispatch → advisor refine → driver implement → guard review
  → qa verify → orch merge). Roles map to the actual seats this product
  uses (orch-lead@openrig-velocity, advisor-lead, redo3-driver-3,
  redo3-guard-3, velocity-qa). Single frontier (v1 projector contract
  per banked `feedback_workflow_spec_validate_against_projector`).
- defaultBuiltinSpecsDir() resolution picks up the new file at boot;
  loadStarterWorkflowSpecs caches it via WorkflowSpecCache.

Tests:
- starter-spec-loader.test.ts: new test asserts the shipped builtins
  directory contains [email protected] + the spec has a `guard`
  role (discriminator vs basic-loop/conveyor). 8/8 PASS.
- conveyor-starter.test.ts: existing "ships exactly the generic
  public starter workflow specs" assertion updated to include
  openrig-velocity in the expected list. 5/5 PASS.

Acceptance verified: release-0.3.1 mission Topology tab now has a
cached spec to project. Walk-item 7 fully covered.

Full daemon suite: 3379 passed / 1 skipped / 0 failed.
git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 08:17:43 -07:00
mvschwarzandClaude Opus 4.7 b6f3114d4c fix(workflow): revert SliceWorkflowBinding nullability — drop unused declaration path
Forward-fix #1 on df79077. Guard re-review caught daemon tsc errors:
widening `SliceWorkflowBinding.instanceId` + `createdAt` to nullable
broke SliceDetailProjector at slice-detail-projector.ts:199 + 212
because the downstream projector + WorkflowBindingPayload contract
still expects non-null values.

Per guard option (a) recommendation: keep synthetic declaration
bindings OUT of SliceDetailProjector. The declaration-fallback path
was speculative — no caller actually passed the declaration param.
The missions route (the actual walk-item 7 acceptance surface) calls
`projectSpecGraph(row.spec, null)` DIRECTLY in routes/missions.ts;
it never went through findSliceWorkflowBinding.

Resolution:
- `SliceWorkflowBinding.instanceId` + `createdAt` back to non-null
  (preserves SliceDetailProjector + WorkflowBindingPayload contract).
- `findSliceWorkflowBinding` signature reverts to live-only:
  `(db, qitemIds) => SliceWorkflowBindingResult`. No declaration
  param, no synthetic binding path.
- 4 declaration-fallback tests removed from slice-workflow-binding.test.ts
  (the surface they tested no longer exists).

The walk-item-7 acceptance is unaffected — the mission Topology tab
renders the projected spec graph via the missions route's
projectSpecGraph(row.spec, null) call, which doesn't depend on the
synthetic-binding pathway.

If a future use case needs synthetic-binding-in-slice-detail (e.g.,
slice-scope Topology consumes declared workflow_spec), broaden the
WorkflowBindingPayload contract end-to-end (route + UI payload + tests)
in that slice rather than the partial widening this commit reverts.

Tests: 50/50 targeted (slice-indexer 32 + slice-workflow-binding 9 +
missions-routes 9). Daemon tsc clean. Full daemon suite: 3378 passed /
1 skipped / 0 failed on clean run. git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 07:37:06 -07:00
mvschwarzandClaude Opus 4.7 df79077f31 feat(daemon,ui): per-mission topology spec graph via workflow_spec frontmatter
V0.3.1 slice 13 walk-item 7. Mission Topology tab now projects the
declared workflow spec graph instead of unioning session-name
aggregations with a hardcoded specGraph: null. Slice frontmatter
gets the same `workflow_spec: <name>@<version>` declaration so
downstream slice-scope Topology work (slice 14) has a hook ready.

(1) Slice-indexer frontmatter parsing:
- New WorkflowSpecRef type + workflowSpec field on SliceRecord +
  SliceListEntry.
- parseWorkflowSpecRef helper exported from slice-indexer so the
  missions route can reuse the same parser (single source of truth).
- Malformed values that don't match `<name>@<version>` return null;
  no exception path.

(2) findSliceWorkflowBinding extension:
- Optional WorkflowSpecDeclaration param. When passed AND no live
  instance binds, returns a synthetic binding with
  { instanceId: null, currentStepId: null, status: "declared" } so
  downstream projectSpecGraph(spec, null) can render the graph.
- Live instances always win over declarations (declared metadata is
  a fallback, not an override).
- SliceWorkflowBinding.instanceId + createdAt widened to nullable for
  the synthetic case.

(3) Missions route extension (GET /api/missions/:missionId):
- Returns workflow_spec parsed from <missionPath>/README.md
  frontmatter via parseWorkflowSpecRef.
- Returns topology: { specGraph } when both declaration + spec cache
  present; topology: { specGraph: null } when declared but uncached;
  topology: null when nothing declared.
- Uses WorkflowSpecCache.getByNameVersion + projectSpecGraph(row.spec,
  null) for projection.

(4) UI mission Topology tab:
- When missionData.topology.specGraph exists, render via TopologyTab
  (same component the slice scope uses) with affectedRigs: [] +
  totalSeats: 0 since the spec graph is the relevant projection.
- Fall back to existing ScopeTopologyRollup when no declaration /
  no cached spec.
- useMission response type extended with workflow_spec +
  topology fields.

Tests:
- slice-indexer.test.ts: 3 new (parse declared, null when absent,
  null when malformed). Total 32/32.
- slice-workflow-binding.test.ts: 4 new (synthetic when declared +
  no live; live wins over declaration; no decl + no live still null;
  empty qitemIds + declaration). Total 13/13.
- missions-routes.test.ts: 4 new (workflow_spec parsed when
  declared, null when absent; specGraph projected when spec cached;
  topology.specGraph null when declared-but-uncached). Total 9/9.

Convention spread (per IMPL-PRD §4 — v0 scope):
- <work-tree>/missions/release-0.3.1/README.md frontmatter
  declares `workflow_spec: [email protected]`. Slice-level READMEs
  deferred pending walk feedback (dispatch's "list which READMEs"
  question answered conservatively).

Full daemon suite: 3382 passed / 1 skipped / 0 failed on clean
re-run (banked flake-on-first-run pattern hit; resolved on retry).
UI typecheck on touched files: zero new errors. git diff --check
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 07:27:21 -07:00
mvschwarz 53e34b0c30 merge(0.3.1): slice 17 founder-walk-workspace-state-correctness — typed slice-tag preference + Explorer refresh + queue DESC sort + cache-bypass on focus
Closes walk items 3, 8, 10 from the 2026-05-11 founder-walk synthesis.

Walk item 3 (qitems-per-slice filter): slice-indexer.matchQitems prefers typed `slice:<name>` tag over missionId substring fallback; resolves the qitem-over-match bug per banked feedback_slice_indexer_substring_grep_on_qitem. Default rail item no longer leaks mission-only qitems (FF3 refinement).

Walk item 8 (explorer auto-show): new POST /api/slices/refresh route + Explorer manual refresh button + react-query refetchOnWindowFocus configured. FF2 changed both useSlices + useFilesList from `true` to `'always'` (string variant bypasses staleTime gate per banked feedback_refetchOnWindowFocus_staleness_gated_use_always_string). FF3 adds GET /api/slices cache-bypass option (`bypassCache=1` query param) wired into useSlices focus-refetch path.

Walk item 10 (slice queue descending order): backend matchQitems sorts qitemIds DESC by ts_created with qitem-id lex DESC fallback at the source-of-truth read site (slice-indexer.ts:463). All consumers (Queue tab, rollup, downstream API) honor the order. Frontend rollup sort kept as belt-and-suspenders.

Test coverage:
- slice-indexer: 28/28 (+1 over-match regression with 3-distinct-value discriminator + 1 mission-queue-leakage regression added in FF3)
- slices-routes: 21/21 + cache-bypass route test added in FF3 = 22/22
- focus-refetch: 2/2 (focusManager.setFocused(false/true) discriminator; pre-fix FAILS on boolean `true`)
- Full daemon: 3364/3364 PASS / 1 skipped / 0 failed

VM dogfood (Mode 3 isolated Tart, two passes):
- velocity-qa @ 131bf22 CONCERNING — refocus path didn't refresh visible Explorer list (root cause: refetchOnWindowFocus boolean true is staleness-gated). Walk items 3 + manual refresh PASS.
- velocity-qa @ c9578b3 VM verify exposed 2 additional bugs: mission-only qitem leakage in default rail item + daemon cache staleness on refocus. velocity-qa applied 2 source-tier fixes (189112a + 047bcc7) with test coverage during dogfood; final candidate 047bcc7 CLEAR — browser proof green for Queue tab DESC typed-slice filtering, manual refresh, refocus refresh; no JS errors.
- Evidence: vm-verify-c9578b3-20260511T1343Z/REPORT.md

Branch commits:
- 1341572 feat(slices,ui): typed slice-tag preference + Explorer refresh + queue DESC sort (Phase 3 — walk items 3+8+10 initial)
- 131bf22 fix(slices): matchQitems returns DESC by ts_created + walk-item phrasing (forward-fix #1 — walk item 10 backend sort + attribution adjustment)
- c9578b3 fix(ui): refetchOnWindowFocus='always' so Explorer refresh ignores staleTime (forward-fix #2 — closes refocus subset of walk item 8)
- 189112a fix(slice-17): avoid mission queue leakage for default rail item (VM-applied FF3 fix; walk item 3 refinement)
- 047bcc7 fix(slice-17): bypass slice cache on focus refetch (VM-applied FF4 fix; walk item 8 daemon cache layer)

Triple-guard: redo3-driver-3 driver (Phase 3 + FF1 + FF2); redo3-guard-3 guard (3 reverify passes: BLOCKING on DESC sort applied to wrong path + commit-body attribution → CLEAR after backend sort consolidation + neutral phrasing; CLEAR on FF2 staleness-gating fix); velocity-qa VM (2 passes; second-pass after their FF3+FF4 fixes landed source-tier with test coverage during dogfood).

Walk-item phrasing discipline applied throughout (banked feedback_no_founder_attribution_in_commits). Slice id token retained as stable identifier;
2026-05-11 07:11:22 -07:00
mvschwarz 047bcc71ed fix(slice-17): bypass slice cache on focus refetch 2026-05-11 06:53:21 -07:00
mvschwarz 189112a3de fix(slice-17): default rail item filter 2026-05-11 06:48:12 -07:00
mvschwarz 79745de48e merge(0.3.1): slice 22 founder-walk-vm-populated-env — two-daemon-env VM-preview pattern; process-env isolation; module-level constants regression coverage
Closes the foundational precondition for verifying slices 12/13/14/17/18/21 in a populated environment. Resolves founder gate #8 (VM preview architecture) + founder item 20 (load VM with 0.3.1 dummy data).

Architecture:
- Two daemons in a single Tart VM: blank-slate (default port 7433) + populated (port 7434) with fully-isolated OPENRIG_HOME directories
- Process-env pattern: each `rig` invocation gets its own OPENRIG_HOME at module load (no per-invocation flag plumbing through start/status/stop/logs/preflight)
- UI hard-switch via two-bookmarks model (multi-port; operator picks URL); zero UI code change
- Idempotent bootstrap script: refuses to clobber existing daemons + prints explicit reset commands

What ships:
- Bootstrap script `scripts/vm-bootstrap/two-daemon-start.sh` — spawns both daemons with OPENRIG_HOME=<blank|populated>; refuse-to-clobber idempotency guard with reset guidance on rerun
- Sample fixture `packages/daemon/assets/vm-preview-fixtures/workflows/sample-basic-loop.yaml` (slug `vm-preview-basic-loop` to avoid built-in collision); README documenting fixture v0 scope + manual seed paths for richer state
- Convention doc `<work-tree>/conventions/vm-preview/README.md` — operator workflow, architecture-note on module-level constants vs function-level usage, OQ resolutions, scope cuts, future evolution
- Module-level OPENRIG_DIR / STATE_FILE / LOG_FILE constants verified to honor process.env.OPENRIG_HOME at import time via vi.resetModules + dynamic-import regression test

Test coverage (TDD red→green):
- daemon-lifecycle.test.ts: 56/56 (+4 OPENRIG_HOME helpers + 1 module-load regression via vi.resetModules + dynamic import asserting distinct OPENRIG_DIR/STATE_FILE/LOG_FILE per import; discriminator verified by hardcoded-probe-in-source which makes the test FAIL on the bug class)
- Full CLI+daemon sweep: 4269/4271 (one pre-existing env fail: cli/dist/index.js missing in worktree — same fail seen on auth-bearer/plugin-discovery slices; unrelated)
- CLI tsc clean

VM dogfood (Mode 3 isolated Tart, source-backed build/link, two passes):
- velocity-qa @ 89ee6b0 found one small UX bug (rerun error message lacked reset instructions); fixed locally with e00ef7c added explicit reset commands + reverified
- velocity-qa @ e00ef7c CLEAR — bootstrap starts blank 7433 + populated 7434; both healthz 200; OPENRIG_HOME state isolated; populated `rig up product-team` succeeds + /api/rigs shows product-team while blank is empty; vm-preview-basic-loop fixture in populated /api/specs/library only (slice 11 auto-discovery + state isolation both verified); rerun errors with explicit reset commands; documented reset + rebootstrap returns clean state; reseeding product-team after reset succeeds
- Evidence: vm-dogfood-e00ef7c-20260511T1332Z/REPORT.md

Branch commits:
- 3d9a6f6 feat(slice-22): --openrig-home flag for multi-instance daemon state (Phase B; semantics partially reverted by FF1)
- 5a14029 feat(slice-22): two-daemon VM-preview bootstrap + sample fixture (Phase D)
- acc6afc fix(slice-22): drop --openrig-home flag; use process-env pattern (forward-fix #1; addresses module-level constants vs function-level usage)
- 89ee6b0 test(slice-22): module-load regression for OPENRIG_HOME → constants (forward-fix #2; closes guard-flagged test-coverage gap with vi.resetModules + dynamic import)
- e00ef7c fix(slice-22): print reset guidance on bootstrap rerun (VM dogfood UX fix)

Triple-guard: redo-driver-2 driver (forward-fix-1 + forward-fix-2 closed both guard BLOCKING + CONCERNING in tight loop; proactively applied banked forensic-trace lesson at audit-every-LAYER scope); redo-guard-2 guard (3 reverify passes — BLOCKING on flag-vs-process-env scope; CONCERNING on test-coverage of module-level constants; CLEAR after vi.resetModules + dynamic-import added); velocity-qa VM dogfood (2 passes; second-pass after their narrow UX fix landed).

Banking-worthy lessons (driver-flagged; will bank as feedback memories):
1. Audit-every-LAYER: when a CLI exposes a scope flag intended to isolate per-invocation state, the surface includes BOTH function-level call sites AND module-level constants reading the same env at import time. A flag threaded only into spawned children — without refactoring parent-process bookkeeping — IS NOT real isolation.
2. Module-level-constant regression tests require fresh-import isolation (vi.resetModules + dynamic import); helper-function contract tests are necessary but not sufficient to prove the module-load snapshot honors them.

Unblocks subsequent walk slices that need populated env for verification (slices 12 follow-up VM, 13, 14, 17 follow-up VM, 18, 21).
2026-05-11 06:42:01 -07:00
mvschwarz e00ef7cd39 fix(slice-22): print reset guidance on bootstrap rerun 2026-05-11 06:37:23 -07:00
mvschwarzandClaude Opus 4.7 c9578b381f fix(ui): refetchOnWindowFocus=always so Explorer refresh ignores staleTime
Forward-fix #2 on walk-item 8 refocus path. velocity-qa VM verify on
131bf22 showed the manual refresh button worked but synthetic + real
refocus did NOT update the Explorer sidebar after a new slice folder
was created.

Root cause: `refetchOnWindowFocus: true` gates the refetch on the
react-query staleness predicate. With `staleTime: 15_000` on
useFilesList and `staleTime: 30_000` on useSlices, a refocus inside
that window returns cached data — no refetch fires. The VM operator's
refocus tests landed within seconds of the initial fetch, well inside
the stale window.

Fix: switch both hooks to `refetchOnWindowFocus: "always"`. The
'always' variant bypasses staleness and refetches on every focus
event, which is the actual intent of the walk-item 8 contract — the
operator SHOULD see slice / file folders they just created when they
come back to the tab, even if the list was fetched moments ago.

Hooks touched:
- useFilesList (the load-bearing hook for Explorer sidebar; consumed
  by ProjectTreeView via useMissionDiscovery)
- useSlices (drives the slice rail in Explorer + Project surfaces)

Regression test at packages/ui/test/use-files-list-focus-refetch.test.tsx:
- Uses react-query's focusManager.setFocused(false/true) to simulate
  blur/focus deterministically in jsdom.
- Asserts a second fetch fires on focus toggle, within the staleTime
  window. With the pre-fix `true` value, the test fails (one call
  only); with `'always'`, the test passes (two calls).

Discrimination verified by temporarily reverting both hooks back to
`true` and re-running: both tests FAIL. Restored to `'always'`, both
tests PASS. 2/2.

Full daemon suite: 3352 passed / 1 skipped / 0 failed (one of the
banked flaky-createDaemon-timeout cases hit on first run; resolved
on clean re-run pattern; not caused by this slice).
git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 06:37:11 -07:00
mvschwarz 08017abe59 merge(0.3.1): slice 12 founder-walk-project-scope-content — mission + slice README/PROGRESS rendered via scope-markdown hook; slice default Overview tab
Closes walk items 1, 2, 4 from the 2026-05-11 founder-walk synthesis.

What ships:
- Generalized useScopeMarkdown(absScopePath, filename) hook (drop-in for the slice-06 useSliceTimelineMarkdown which becomes a backward-compat shim; slice-06 TimelineTab callsites unchanged)
- NEW GET /api/missions/:missionId daemon route returning { missionId, missionPath, slices[] }; 200 + 404 (mission_not_found) + 503 (indexer-unavailable / slices_root_not_configured) envelopes
- NEW useMission react-query hook with refetchOnWindowFocus
- MissionScopePage Overview tab: renders mission README via useScopeMarkdown above compact slice rail (replaces 4-col metric-card duplication with dense status-line strip)
- MissionScopePage Progress tab: renders mission PROGRESS.md above ScopeProgressRollup
- SliceScopePage Overview tab: renders slice README above Current Step + Readiness; drops Primary Docs filename-duplication block
- Slice default tab flipped from 'story' to 'overview' at ScopePages.tsx:1076

Deferred (orch-approved scope-cut; tracked for follow-up):
- Workspace Topology swap to HostMultiRigGraph snapshot (snapshot-data-feed choice needs dev-server walk feedback)
- Mission Progress vs Artifacts heat-map further visual differentiation (current implementation differentiates via content + rendered PROGRESS.md; further visual diff depends on walk feedback)

Both will iterate as slice 12.5 / 13.5 follow-up.

Test coverage:
- missions-routes: 5/5 (200/404/503 envelope shapes)
- use-scope-markdown: 6/6 (allowlist-root resolution + filename parameterization)
- use-slice-timeline-markdown: 7/7 (shim preserves slice-06 callsites)
- Full daemon suite: 3364 passed / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors
- Known unrelated UI package tsc issue (@tanstack/react-table missing in worktree dependency install)

Branch commits:
- fccd819 feat(daemon,ui): render mission + slice README/PROGRESS via scope-markdown hook

Triple-guard: redo3-driver-3 driver; redo3-guard-3 guard (2 reverify passes — narrow #1 CONCERNING on phrasing-scan in useMission.ts:51 comment + commit body; narrow #2 CLEAR after reset-and-cherry-pick body rewrite). Functional gates clear across both passes; only style adjustment required.

VM dogfood: deferred to populated env (slice 22 dependency); blank-slate dev-server walk acceptable for merge ship per dispatch. Founder host walk will provide visual confirmation; velocity-qa queues post-slice-22.

Walk-item phrasing discipline applied upfront (post-slice-17 lesson): comments + commit body use walk-item terminology; no chain-of-authorization attribution. Slice id token retained as stable identifier.
2026-05-11 06:35:32 -07:00
mvschwarzandClaude Opus 4.7 fccd819694 feat(daemon,ui): render mission + slice README/PROGRESS via scope-markdown hook
V0.3.1 slice 12 walk-items 1, 2, 4 from the 2026-05-11 synthesis.
Slice default tab is now Overview (README + Current Step + Readiness),
mission tabs render their README / PROGRESS content, and the data
layer is generalized so any project scope can read any markdown file
through one hook.

(1) Generalized hook (walk-item 1 foundation):
- `useScopeMarkdown(absoluteScopePath, filename)` at
  packages/ui/src/hooks/useScopeMarkdown.ts. Same allowlist-root
  resolution + relative-path conversion as the slice-06 era hook;
  the filename is now a parameter so callers pick README.md /
  PROGRESS.md / timeline.md / etc.
- `useSliceTimelineMarkdown(slicePath)` is now a backward-compat
  shim that calls `useScopeMarkdown(slicePath, "timeline.md")`.
  Slice-06 TimelineTab callsites continue to work unchanged.

(2) Daemon mission data layer (walk-items 2 + 4 foundation):
- New `GET /api/missions/:missionId` route returns
  { missionId, missionPath, slices: SliceListEntry[] } filtered by
  missionId. Uses the existing SliceIndexer; no new persistence.
  503 when indexer not wired / not ready; 404 when no slices.
- `useMission(missionId)` react-query hook wraps the route with
  refetch-on-window-focus.

(3) Slice scope (walk-item 1):
- SliceScopePage default tab flipped to "overview" (was "story").
- SliceOverviewTab now renders the slice README via
  useScopeMarkdown above Current Step + Readiness. Primary Docs
  filename-duplication section dropped (the README + Docs tab tree
  cover the same surface).

(4) Mission scope (walk-items 2 + 4):
- MissionScopePage Overview tab renders mission README via
  useScopeMarkdown above a compact slice rail (per-slice metrics
  collapsed from a 4-column grid to a single dense status line).
- MissionScopePage Progress tab renders mission PROGRESS.md via
  useScopeMarkdown above the existing ScopeProgressRollup.

Phrasing discipline applied: comments + commit body use walk-item
terminology; no chain-of-authorization attribution. Slice id tokens
retained (stable identifiers).

Tests:
- missions-routes.test.ts (NEW): 5 cases — 200 with missionPath +
  filtered slices; 404 mission_not_found; 503 indexer-unavailable;
  503 slices_root_not_configured; exact-id match no substring leak.
- use-scope-markdown.test.tsx (NEW): 6 cases — pure resolver tests,
  README.md / PROGRESS.md production wire, null-path + 404 paths.
- use-slice-timeline-markdown.test.tsx (existing 7/7) preserved
  via the backward-compat shim.

Targeted: 18/18 (5 missions-routes + 6 use-scope-markdown + 7
preserved slice-06). Full daemon suite: 3364 passed / 1 skipped /
0 failed. UI tests passing for hook tests; the rest of the UI suite
hits a pre-existing worktree-PostCSS infra issue (tailwindcss
module resolution from worktree path) unrelated to this slice.

Deferred (flagged for orch decision):
- Workspace Topology swap to HostMultiRigGraph snapshot
- Mission Progress vs Artifacts heat-map differentiation
Both are visual refinements that need dev-server walk to evaluate;
core walk-items 1, 2, 4 are covered by this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 06:32:46 -07:00
mvschwarz ca6c7f13b0 merge(bug-fix): deprecation-check-keys-widening — regression coverage for 91d81d2 hotfix class
Closes the regression-coverage gap that produced the 91d81d2 founder-walk VM unblocker. Slice 05 widened spec-library-starters.test.ts deprecation-check for deprecated SKILL refs (mental-model-ha) but kernel agent.yaml files still carried profiles.default.uses.hooks: [] placeholders rejected by plugin-primitive strict validator — caught only when founder-walk VM kernel auto-boot failed.

This slice extends the check to enforce against deprecated KEY paths + empty-array placeholders the validator rejects at CI time, so the same bug class can't repeat.

Test-only scope. No runtime source diff.

Implementation:
- packages/daemon/test/spec-library-starters.test.ts: DEPRECATED_KEY_PATHS allowlist seeded with 'resources.hooks' + 'profiles.*.uses.hooks' (wildcard on profile name); wildcard-aware path matcher; inline validateAgentSpec strict-validator invocation on every walked spec. KERNEL_AGENT_SPECS hoisted to top-level so existing deprecated-HA-skill test + new gates walk single source.
- packages/daemon/test/fixtures/deprecation-check/agent-clean.yaml + agent-with-empty-hooks.yaml + agent-with-removed-key.yaml (3 discriminator fixtures).

Discrimination proof (driver-verified): temporarily added hooks: [] to advisor.lead kernel agent.yaml → both new gates FAIL (key-path walker + strict validator); restored → all 24 PASS.

Test coverage: spec-library-starters 24/24 (18 prior + 6 new HG checks). Full daemon 3359 passed / 1 skipped / 0 failed. CLI/daemon tsc clean. git diff --check clean.

Hard gates: HG-1 (allowlist non-empty + doc cites IMPL-PRD), HG-2 (deprecated-key fixture flagged), HG-3 (empty-array placeholder rejected by validator), HG-4 (current AGENT_SPECS + kernel pass both gates), HG-5 (validator imported + invoked inline), HG-6 (no false positives on main) — all PASS.

Triple-guard: redo3-driver-3 driver; redo3-guard-3 guard CLEAR (verified discriminator coverage class — would catch future contributors adding new deprecated-key violations to kernel/starter specs). Test-only — no VM dogfood needed per banked substrate-only-slice discipline.

Sibling slice: plugin-discovery-respects-openrig-home (merged at eb5d9b3; same 91d81d2 follow-up triage).
2026-05-11 05:12:42 -07:00
mvschwarzandClaude Opus 4.7 c7d356db03 feat(test): widen deprecation-check to deprecated KEY paths + strict-validator gate
Bug-fix slice deprecation-check-keys-widening. Regression-coverage
follow-up to 91d81d2 hotfix. Slice 05 widened the deprecation-check
test to walk kernel agent.yaml against deprecated SKILL refs, but it
did not enforce against deprecated KEY paths nor invoke the strict
validator (validateAgentSpec) that rejected the `hooks: []`
placeholders. 91d81d2 fixed the immediate failure by adjusting the
placeholders; this slice closes the regression class so a future
contributor cannot reintroduce the pattern silently.

Test extensions in `spec-library-starters.test.ts` describe block
`deprecated KEY path + strict-validator gate`:

- `DEPRECATED_KEY_PATHS: string[]` allowlist seeded with the two
  paths the strict validator already rejects with explicit
  plugin-primitive Phase 3a migration errors (`resources.hooks` +
  `profiles.*.uses.hooks`; `*` is a profile-name wildcard). Future
  deprecations append; the comment cites the IMPL-PRD as the
  authoritative taxonomy.
- T1: documentation gate — allowlist is non-empty + the test file
  references this slice.
- T2/T3: AGENT_SPECS + kernel agent.yaml carry no deprecated key
  paths (walker honors wildcard segments).
- T4: AGENT_SPECS + kernel agent.yaml pass `validateAgentSpec`
  inline at test time — empty-array placeholders the strict
  validator rejects fail the test immediately, no need to wait for
  kernel auto-boot to surface them.
- T5: regression fixture `agent-with-empty-hooks.yaml` has
  `profiles.default.uses.hooks: []` → strict validator returns
  invalid with a `uses.hooks` migration error (matches the
  agent-manifest.ts:240 code path).
- T6: regression fixture `agent-with-removed-key.yaml` has
  `resources.hooks: []` → allowlist walker reports it.
- T7: baseline `agent-clean.yaml` passes both gates (no false
  positives).

KERNEL_AGENT_SPECS lifted from inline declaration in the
`public builtin agent profiles` test to a top-level constant so
the new gates walk from a single source.

Discrimination: temporarily adding `hooks: []` to advisor.lead
agent.yaml causes BOTH T2/T3 (key-path) AND T4 (strict-validator)
to FAIL. Restored state, all 24 pass.

Full daemon suite: 3359 passed / 1 skipped / 0 failed (6 new tests
from this slice). git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 05:08:34 -07:00
mvschwarz eb5d9b3b4a merge(bug-fix): plugin-discovery-respects-openrig-home — discovery uses OPENRIG_HOME helper
Resolves asymmetry between PluginVendorService (honors $OPENRIG_HOME via getDefaultOpenRigPath) and PluginDiscoveryService (scanned literal homedir()/.openrig/plugins). Under custom OPENRIG_HOME, vendor placed at $OPENRIG_HOME/plugins but discovery looked at the default location → mirror workaround required.

Fix (1 line at packages/daemon/src/startup.ts:777): route openrigPluginsDir through getDefaultOpenRigPath('plugins'); same helper already used by PluginVendorService. Both services now share single source of truth.

Test coverage (3 scenarios; packages/daemon/test/plugin-discovery-openrig-home-wiring.test.ts):
- Wiring: synthetic plugin at $OPENRIG_HOME=tmp/plugins/example/ → discovery surfaces 'example' via listPlugins
- Same-root: createDaemon under custom OPENRIG_HOME → openrig-core auto-vendored AND discovered with PluginEntry.path startsWith(<tmpHome>/plugins). Unique synthetic id (synthetic-cross-leak-<timestamp>) ensures pre-fix code on host with default openrig-core CANNOT satisfy via wrong root.
- Audit-grep regression: static walk over daemon/src/**/*.ts (line + block comments stripped) FAILS if any homedir()-based plugins path literal remains. Pre-fix FAILS at startup.ts:777; post-fix PASSES.

Discrimination verified: temp checkout of pre-fix tip showed 3/3 wiring tests FAIL; post-fix 3/3 PASS.

Audit evidence: rg shows only 6 doc/comment hits across daemon/src for legacy default-plugins-path references; zero runtime literals.

Branch commits:
- eeb583e fix(daemon): plugin discovery resolves openrigPluginsDir via OPENRIG_HOME helper
- d5cc5ed fix(test): harden plugin-discovery same-root proof + neutralize comment phrasing

Tests: wiring 3/3 PASS; full daemon 3353 passed / 1 skipped / 0 failed. CLI/daemon tsc clean. git diff --check clean.

Triple-guard: redo3-driver-3 driver (forward-fix-2 commit-body amend via reset+cherry-pick); redo3-guard-3 guard (3 reverify passes: CONCERNING on same-root test discriminator → driver hardened with synthetic id + path-startsWith assertion; CONCERNING on commit-body attribution → driver rewrote bodies; CLEAR on cumulative tree byte-equivalent + scan clean).

No SC-29 exceptions; no migrations, package metadata, routes, or event types touched. Single startup.ts callsite + one new test file.

Unblocks slice 22 (founder-walk-vm-populated-env): once both merge, populated-env setup is much cleaner (vendor + discovery agree under custom OPENRIG_HOME; no mirror workaround needed).
2026-05-11 05:02:21 -07:00
mvschwarzandClaude Opus 4.7 89ee6b059b test(slice-22): module-load regression for OPENRIG_HOME → constants (FF2)
Guard re-review on acc6afc caught a real test-coverage gap: the 3
env-contract tests added in FF1 verify the openrig-compat helper
functions respect OPENRIG_HOME, but they would not fail if
daemon-lifecycle.ts:66-69 stopped using those helpers for its
module-level OPENRIG_DIR / STATE_FILE / LOG_FILE constants. The
original bug class was the module-load SNAPSHOT — a future
contributor hardcoding "~/.openrig" in those lines would silently
break the slice's isolation guarantee with no failing test.

FIX: add one targeted vi.resetModules regression test that imports
daemon-lifecycle FRESH twice — once with
OPENRIG_HOME=/Users/example/.openrig-vm-blank-fresh, once with
.../.openrig-vm-populated-fresh — and asserts OPENRIG_DIR /
STATE_FILE / LOG_FILE all reflect the per-import env.

Discriminator verified locally: temporarily hardcoding
`export const OPENRIG_DIR = "/tmp/hardcoded-discriminator-probe"`
caused the test to fail with `expected
'/tmp/hardcoded-discriminator-probe' to be
'/Users/example/.openrig-vm-blank-fresh'`. Reverted; passes again.
This proves the test will catch any future regression where the
module-level constants stop honoring openrig-compat helpers.

Tests:
- packages/cli/test/daemon-lifecycle.test.ts: 56/56 (was 55; +1 new)
- CLI tsc clean (no daemon-lifecycle.ts changes; this commit is
  test-only)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 05:00:44 -07:00
mvschwarzandClaude Opus 4.7 d5cc5ed390 fix(test): harden plugin-discovery same-root proof + neutralize comment phrasing
Forward-fix on the initial wiring test, addressing two guard
findings:

(1) Same-root test hardening:
- Replaces 'openrig-core' (which exists on operators' default-rooted
  hosts) with a unique synthetic id `synthetic-cross-leak-${Date.now()}`
  so pre-fix code cannot satisfy the assertion from a host
  ~/.openrig/plugins/openrig-core directory.
- Adds a PluginEntry.path startsWith assertion proving discovery
  resolved against <tmpHome>/plugins, not the default homedir root.
- The previous id-only check would not have discriminated pre-fix
  from post-fix on hosts where openrig-core was already vendored at
  the default location.

(2) Comment phrasing adjusted to drop walk-context attribution:
- "caught it on $WALK refresh" → "caught the bug originally"
- "$WALK refresh exposed" → "velocity-qa VM dogfood exposed"
- Slice id token plugin-discovery-respects-openrig-home retained
  (stable identifier; same rule applied on slice 17 forward-fix #1)
- Substrate ACK packet at the substrate path is NOT retroactively
  modified (substrate context vs product source)

Test discrimination: 3 of 3 tests FAIL on pre-fix tip 91d81d2
(static-grep audit + scans-and-finds + same-root verification); 3 of 3
PASS on post-fix. Confirmed via temporary
`git checkout <pre-fix-sha> -- packages/daemon/src/startup.ts` + re-run.

Full daemon suite: 3353 passed / 1 skipped / 0 failed.
git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:59:33 -07:00
mvschwarzandClaude Opus 4.7 eeb583e654 fix(daemon): plugin discovery resolves openrigPluginsDir via OPENRIG_HOME helper
Bug-fix slice plugin-discovery-respects-openrig-home. Pre-fix the
PluginDiscoveryService was constructed in startup.ts with a hardcoded
`path.join(os.homedir(), '.openrig', 'plugins')` while the sibling
PluginVendorService used `getDefaultOpenRigPath('plugins')` (the
OPENRIG_HOME-aware helper). Operators setting a custom OPENRIG_HOME
found vendor placed assets under the override path but discovery
scanned the default — the asymmetry surfaced on velocity-qa VM
dogfood and required a manual mirror workaround.

Fix is a one-line call-site change: route openrigPluginsDir through
the same `getDefaultOpenRigPath` helper that vendor already uses.
claudeCacheDir / codexCacheDir stay homedir-anchored because those
locations belong to the runtime tools, not to the OpenRig state root.

Audit per IMPL-PRD §3.4 + banked feedback_lifecycle_exporter_audit_all_callers:

  git grep "\\.openrig['\"]/plugins" packages/daemon/src
  git grep -E 'homedir\\(\\).*\\.openrig.*plugins' packages/daemon/src

Every other hit is a comment / docstring (documentation mentioning
the legacy default path), not a runtime literal. The only runtime
construction was startup.ts:777.

New tests at packages/daemon/test/plugin-discovery-openrig-home-wiring.test.ts:

- HG-1: with OPENRIG_HOME=<tmp>, vendor a synthetic plugin at
  <tmp>/plugins/example/; discovery surfaces `example` via
  listPlugins. (Pre-fix: FAILS; vendored entries don't appear.)
- HG-3 + HG-4: vendor + discovery share the resolver. After
  createDaemon under custom OPENRIG_HOME, openrig-core is vendored
  into <tmp>/plugins/openrig-core/ AND surfaced via discovery — same
  root, no cross-leak between services.
- HG-2 + HG-5: static-grep audit walks daemon/src/**/*.ts (line +
  block comments stripped) and FAILS if any runtime homedir() literal
  for the plugins path remains. Pre-fix: FAILS on startup.ts.

Targeted 3/3 PASS. Discriminates: 2 of 3 tests fail on pre-fix tip
91d81d2 (sanity check). Full daemon suite: 3353 passed / 1 skipped /
0 failed. git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:59:14 -07:00
mvschwarzandClaude Opus 4.7 acc6afc358 fix(slice-22): drop --openrig-home flag; use process-env pattern (FF1)
Guard re-review on 5a14029 caught two real architectural findings:

FINDING #1: --openrig-home did NOT deliver the claimed fully-isolated
two-daemon lifecycle. Module-level constants OPENRIG_DIR / STATE_FILE /
LOG_FILE in packages/cli/src/daemon-lifecycle.ts:70-73 resolve from the
parent CLI's OPENRIG_HOME at IMPORT time. startDaemon reads state,
creates the directory, opens logs, and writes daemon.json through
those module-level constants — but the flag was only passed into
buildDaemonEnv for the SPAWNED child. Parent CLI process always
read/wrote default ~/.openrig state regardless of the flag. Two
sequential invocations would collide on ~/.openrig/daemon.json. Core
slice 22 "fully-isolated state" claim was broken.

FINDING #2: Bootstrap script + convention doc referenced
`rig daemon stop --openrig-home` for reset, but the `stop` subcommand
never had that flag. Doc path was non-executable.

FIX: per guard's v0-simplest recommendation (option (b) in dispatch):
drop the --openrig-home flag entirely and use the process-env pattern.
Each `rig` invocation has its own OPENRIG_HOME at process start →
module-level constants resolve to per-invocation paths "for free."
No flag-threading through start/status/stop/logs/preflight needed
(which would multiply the surface to 5+ files; deferred until a real
use case can't be served by env).

CHANGES:
- packages/cli/src/commands/daemon.ts: remove --openrig-home flag +
  opts.openrigHome wiring
- packages/cli/src/commands/up.ts: remove --openrig-home flag +
  opts.openrigHome wiring
- packages/cli/src/daemon-lifecycle.ts: remove openrigHome from
  StartOptions interface and buildDaemonEnv opt + the conditional
  OPENRIG_HOME export
- packages/cli/test/daemon-lifecycle.test.ts: replace the 4 Phase-B
  flag tests with 3 NEW env-contract regression tests
  proving:
    1. getOpenRigHome respects process.env.OPENRIG_HOME (distinct
       values → distinct paths)
    2. getDefaultOpenRigPath threads env-resolved home into derived
       paths (daemon.json + log)
    3. empty OPENRIG_HOME falls back to homedir default (not the
       literal empty string)
  These are the actual function contract the slice's isolation claim
  rests on. Distinct-value discriminators per banked
  feedback_poc_regression_must_discriminate.
- scripts/vm-bootstrap/two-daemon-start.sh: rewrite to use
  `OPENRIG_HOME=$X rig daemon start ...` pattern for both daemons +
  matching `OPENRIG_HOME=$X rig daemon stop` for reset. Adds an
  architecture-note section in the script header explaining the
  module-level-constants gotcha that ruled out the flag approach.
- packages/daemon/assets/vm-preview-fixtures/README.md: update "v0
  scope" section to describe the process-env pattern.

Substrate convention doc updated separately
(<work-tree>/conventions/vm-preview/README.md):
new "How isolation works (process-env pattern)" section + "Architecture
note: module-level constants vs function-level usage" section explaining
the lesson. IMPL-PRD §14 captures the forward-fix narrative.

Tests green:
- daemon-lifecycle.test.ts: 55/55 (was 56; +3 new env-contract /
  -4 flag tests = net 55)
- Full CLI+daemon sweep (env-vars cleaned): 4269/4271 (one
  environmental pre-existing fail unchanged)
- CLI tsc clean
- daemon untouched in this commit

No SC-29 EXCEPTIONS: pure additive/subtractive on existing
OPENRIG_HOME env contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:54:53 -07:00
mvschwarzandClaude Opus 4.7 131bf22626 fix(slices): matchQitems returns DESC by ts_created + walk-item phrasing
Forward-fix #1 on slice 17 walk-item 10. Guard re-review on 1341572
caught that the Phase A DESC sort was applied to ScopeQueueRollup
(workspace/mission rollup) only — the slice-detail Queue tab uses
SliceQueueTab which consumes `detail.qitemIds` unchanged. Backend
fix is the consistency point.

Resolution:

- slice-indexer.ts matchQitems returns idsArr sorted DESC by
  ts_created (queried via a single IN-list SELECT over the matched
  ids). Fallback to qitem-id lex DESC when ts_created is unavailable
  or the sort query fails (id format `qitem-YYYYMMDDHHMMSS-suffix`
  encodes the timestamp prefix so this still yields newest-first).
  Single-element / empty arrays short-circuit.
- ScopePages frontend ScopeQueueRollup sort kept as belt-and-
  suspenders for callers that don't route through the indexer.
- New regression test asserts the slice-detail path: 3 qitems with
  distinct ts_created values, all tagged `slice:fwx-slice` →
  `slice.qitemIds` equals `["q-newest", "q-middle", "q-oldest"]`
  (strict order, not alphabetic by id). Verifies the path the slice
  Queue tab consumes, not the rollup view.

Style adjustment per banked feedback_no_founder_attribution_in_commits +
feedback_no_verbatim_founder_quotes_in_commits — rule extends to
code/test comments:

- "founder item N" → "walk item N"
- "(founder\n  item N)" / "— founder\n  item N" multi-line
  continuations collapsed to "(walk item N)" / "— walk item N"

Slice name token `founder-walk-workspace-state-correctness` retained
because it's the stable slice id, not chain-of-authorization phrasing.

Tests: slice-indexer 28/28 (1 new) + slices-routes 21/21 + full
daemon suite 3352 passed / 1 skipped / 0 failed. git diff --check
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:45:40 -07:00
mvschwarzandClaude Opus 4.7 5a14029739 feat(slice-22): two-daemon VM-preview bootstrap + sample fixture
release-0.3.1 Slice 22 founder-walk-vm-populated-env Phase D + E
(merged) — ships the operational artifacts for running two daemons
side-by-side in a Tart preview VM with fully isolated state.

OQ resolutions (per qitem-20260511113113-9fea3ab4):
  OQ-1 (UI switcher):   (a) multi-port + operator-picks-URL — zero UI change
  OQ-2 (fixture source): (b) static YAML + idempotent loader (scope-cut to
                            bootstrap script as the loader for v0)
  OQ-3 (bootstrap loc):  (c) both — scripts in repo + conventions in substrate
  OQ-4 (HOME isolation): (a) fully isolated per daemon

Adds:

- scripts/vm-bootstrap/two-daemon-start.sh — idempotent two-daemon
  spawn (refuses to clobber an existing daemon.json; operator must
  cleanly reset state dirs to retry). Each daemon gets its own
  --openrig-home / --port / --db so they share only the host's
  network namespace, not state. Defaults: blank → 7433 at
  $HOME/.openrig-blank; populated → 7434 at $HOME/.openrig-populated.
  Env-overridable: BLANK_HOME, POPULATED_HOME, BLANK_PORT,
  POPULATED_PORT, FIXTURE_DIR.

- packages/daemon/assets/vm-preview-fixtures/ — fixture-source
  directory with README documenting the canonical layout, scope, and
  placeholder conventions (/Users/example/, <substrate-<internal-docs>>/
  per banked feedback_test_fixture_example_path_placeholder). Ships
  one sample workflow fixture (renamed from the basic-loop starter to
  vm-preview-basic-loop so it does not collide with the bundled
  built-in when dropped into a populated daemon's workspace/
  specs_root). Slice 11 folder-discovery auto-discovers it.

Scope cuts (explicit, called out in IMPL-PRD §13):
- Phase C structured fixture-loader module: not needed for v0; the
  bootstrap script is the loader. Operator runs `rig up product-team`
  for sample rigs, drops workflow YAMLs into the populated daemon's
  specs_root for workflow specs. No new daemon module.
- Rich populated content (~13-node openrig-velocity rig, ~20 qitems,
  getting-started narrative): deferred to follow-up content slices.

Convention doc added in substrate at
<work-tree>/conventions/vm-preview/README.md
with operator workflow, OQ resolutions, future-evolution ideas, and
cross-links to slice 11 (folder-discovery) and auth-bearer-tailscale
-trust (bind model).

No SC-29 EXCEPTIONS: --openrig-home is pure ergonomics on the
pre-existing OPENRIG_HOME env contract; no allowlist additions.

Tests unchanged from Phase B (56/56 daemon-lifecycle; full sweep
4270/4272; one pre-existing env-only fail). tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:42:04 -07:00
mvschwarzandClaude Opus 4.7 3d9a6f6190 feat(slice-22): --openrig-home flag for multi-instance daemon state
release-0.3.1 Slice 22 founder-walk-vm-populated-env Phase B — adds
`--openrig-home <path>` to the CLI lifecycle so the operator can run
two daemons in one Tart preview VM with fully isolated state per OQ-4
(a) (separate OPENRIG_HOME dirs per daemon).

Existing daemon multi-instance support was env-driven (OPENRIG_PORT,
OPENRIG_HOST, OPENRIG_DB, OPENRIG_HOME) and the CLI already had
--port/--host/--db flags. This commit adds the matching
--openrig-home flag end-to-end + tests, mirroring the
user-explicit-vs-default pattern from the auth-bearer-tailscale-trust
slice (host flag): omit OPENRIG_HOME from the spawn env when the opt
is undefined (so the shell-set value passes through baseEnv); set it
explicitly when the operator opts in.

Audits BOTH startDaemon callers per banked
feedback_lifecycle_exporter_audit_all_callers (the lesson from the
auth-bearer slice's two-round forward-fix dance):
- packages/cli/src/commands/daemon.ts — `rig daemon start
  --openrig-home <path>`
- packages/cli/src/commands/up.ts — `rig up <source>
  --openrig-home <path>`
Both threaded through StartOptions → buildDaemonEnv → spawn env.

Tests (TDD red→green with distinct-value discriminators per banked
feedback_poc_regression_must_discriminate):
- openrigHome undefined → OPENRIG_HOME omitted from spawn env
- openrigHome defined → OPENRIG_HOME set to value
- baseEnv has OPENRIG_HOME, opt undefined → shell value preserved
- baseEnv has OPENRIG_HOME=/shell/blank, opt=/opt/populated → opt wins
- daemon-lifecycle.test.ts: 56/56 green (was 52; +4 new)
- Full CLI+daemon sweep (env-vars cleaned): 4270/4272 (one
  environmental pre-existing fail unchanged from prior slices)
- CLI tsc clean

No new SC-29 EXCEPTIONS: OPENRIG_HOME pre-existed; this commit is
purely additive CLI ergonomics on an existing env contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:37:32 -07:00
mvschwarzandClaude Opus 4.7 1341572c89 feat(slices,ui): typed slice-tag preference + Explorer refresh + queue DESC sort
V0.3.1 slice 17 founder-walk-workspace-state-correctness. Three
correctness fixes from the 2026-05-11 founder walk (items 3, 8, 10).

(1) Item 3 — qitems-per-slice filter (slice-indexer matchQitems):
    pre-fix unioned `body LIKE` / `tags LIKE` on
    [sliceName, railItem, missionId]. The missionId substring term
    over-matched: a qitem tagged only `mission:<id>` appeared under
    EVERY slice in that mission. Fix: when at least one qitem has the
    typed `slice:<sliceName>` tag, the missionId substring term is
    dropped — the typed tag is the authoritative slice membership
    signal. Substring fallback (including missionId) preserved when
    no typed-tag rows exist so legacy corpora don't regress.

(2) Item 8 — Explorer auto-show:
    - new POST /api/slices/refresh route invokes indexer.invalidate()
      to drop both listing + detail caches
    - new useRefreshSlices mutation hook posts to /refresh then
      invalidates the slices + files react-query caches
    - ProjectTreeView gains a refresh button (RefreshCw lucide icon
      with spin-while-pending) on its header
    - useSlices + useFilesList opt into refetchOnWindowFocus so the
      common "switched away to mkdir + came back" path picks up new
      folders without manual click

(3) Item 10 — slice queue descending order:
    ScopeQueueRollup sorts qitemIds DESC by tsCreated (with qitem-id
    fallback before queueItemsById is loaded, since the id encodes the
    timestamp prefix). Latest qitem at top.

Tests:
- New `when typed slice:<name> tag matches exist, mission-tagged-only
  qitems are NOT included` (red→green proof of the over-match fix)
- New `preserves legacy substring fallback (including mission body)
  when NO typed slice: tag exists` (HG-2 — no regression for legacy
  corpora)
- New POST /api/slices/refresh tests (cache reset + 503 envelope)

slice-indexer 27/27 + slices-routes 21/21 PASS. Full daemon suite
3351 passed / 1 skipped / 0 failed on clean re-run (initial 6
failures were flaky 5s createDaemon timeouts that resolved on
re-run — same pattern banked during slice 05). UI typecheck on
touched files clean; pre-existing tanstack-react-table errors in
SpecsTable + TopologyTableView unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:36:17 -07:00
mvschwarz 91d81d2272 fix(daemon/specs): remove legacy hooks from kernel agents 2026-05-11 04:29:45 -07:00
mvschwarz 3c75cb2282 merge(0.3.1): slice 05 kernel-rig-as-default — kernel rig auto-boots on default rig daemon start; daemon health decoupled from kernel-agent readiness
Closes 0.3.1 release-gate item "Kernel rig auto-bootstraps on fresh rig setup".

What ships:

(1) Packaged kernel-referenced skills — openrig-architect, rig-lifecycle, local-sysadmin, intake-routing, control-plane-queue (5 skills, dropped deprecated mental-model-ha per 2026-05-10 2f56163 curation). Discoverable via daemon spec resolution; advisor.lead role.md includes inline compaction-survival discipline (rig whoami / rig transcript / rig queue) replacing the bundled-skill reference.

(2) Default rig daemon start auto-boots the kernel rig — advisor-lead + operator-agent + queue.worker spawned automatically; current --no-kernel flag remains the opt-out path. Operator lifecycle skill (openrig-operator) covers operator-driven diagnosis via rig ps + rig capture + the new /api/kernel/status endpoint.

(3) Daemon health/listen DECOUPLED from kernel-agent readiness (architectural fix per advisor-lead2-pending design call). New KernelBootTracker observable state machine (skipped/auth_blocked/spec_missing/booting/ready/partial_ready/bootstrap_failed/degraded). bootKernelIfNeeded refactored: returns tracker immediately; bootstrap runs fire-and-forget; createDaemon resolves; server binds healthz independent. Healthz no longer waits on operator-agent readiness — operator-agent startup_status=pending no longer blocks daemon binding.

(4) NEW endpoint GET /api/kernel/status — read-only envelope { kernel_state, agents[], first_unready_since, variant, detail }. 200 when tracker wired; 503 with kernel_boot_tracker_unavailable for test fixtures.

(5) NEW CLI flag --wait-for-kernel + --wait-for-kernel-ms — current rig daemon start healthz-only behavior preserved (no flag); when flag set, additionally polls /api/kernel/status until ready / partial_ready or timeout (default 60s); terminal non-ready states short-circuit with 3-part Error/Reason/Fix output.

(6) NEW event kernel.agent.degraded — emitted exactly once via tracker's degraded-timer (default 90s; OPENRIG_KERNEL_DEGRADED_MS env override; deps.degradedTimeoutMs test injection); cancelled when kernel reaches ready / partial_ready before deadline.

(7) Deprecation enforcement — spec-library-starters.test.ts widened to walk both AGENT_SPECS AND the 3 kernel agent specs; catches any future regression that reintroduces deprecated skills via kernel profile.uses.skills.

Why option (b) decouple vs (a) operator-readiness-fix:
- §16 founder framing established agent-driven lifecycle; coupling daemon to operator-agent readiness creates circular dependency (operator can't fix anything if daemon won't bind) and violates that doctrine
- Existing --no-kernel flag already proves daemon-without-kernel is an authorized mode; the default-with-kernel path should follow the same lifecycle decoupling
- Honest-errors doctrine: previous "Daemon failed to start: healthz not responding" is unactionable; with (b), daemon is up + can probe /api/kernel/status for actionable diagnosis
- Kernel may grow to 6/10/20 agents; coupling daemon-up to ALL being ready is brittle
- Codex auth / startup-delivery quality is the option-(a) class of work; tracked as sibling triage, NOT slice 05 scope (out-of-scope per design call)

Implementation:
- packages/daemon/src/kernel/kernel-boot-tracker.ts: state machine + degraded timer + agents-aggregation-on-read from sessions table
- packages/daemon/src/bootstrap/bootstrap-orchestrator.ts: emits ready/partial-ready/bootstrap_failed signals
- packages/daemon/src/kernel/kernel-boot.ts: bootKernelIfNeeded returns tracker; fire-and-forget bootstrap
- packages/daemon/src/server.ts: createDaemon wires tracker into deps; healthz/server bind no longer awaits bootstrap
- packages/daemon/src/routes/kernel-status.ts: GET /api/kernel/status route with 200 + 503 envelopes
- packages/cli/src/wait-for-kernel.ts + packages/cli/src/commands/daemon.ts: --wait-for-kernel flag + poll function + 3-part terminal-state error

Test coverage:
- kernel-boot-tracker: 13 tests (state transitions, agent aggregation, degraded telemetry, stop() idempotence, sessionRegistry-error fallthrough)
- kernel-status-route: 4 tests (503 + 200 envelopes, snake_case agents[] projection)
- kernel-boot: 16 tests refactored for tracker-returning API; short-circuit branches + fire-and-forget happy paths
- spec-library-starters: deprecation-check widened to kernel agents
- Total: 33 new tests; 59/59 kernel test files PASS; full daemon suite 3306/3306 (pre-merge) → 3347/3347 (post-merge, includes auth-bearer 41 added tests). Lint PASS. tsc clean.

VM dogfood (Mode 3 isolated Tart, source-backed build/link, two disposable clones):
- velocity-qa @ 828cc99 BLOCKING: daemon never bound healthz; operator-agent stuck startup_status=pending (root cause for option-b decision)
- velocity-qa @ 270642d CLEAR: default rig daemon start bound 127.0.0.1:7433 healthz IMMEDIATELY while /api/kernel/status was booting with pending agent; kernel then reached ready with 4 ready sessions. --wait-for-kernel --wait-for-kernel-ms 1 exited 1 with 3-part error while daemon health stayed up. Default 90s degraded timer emitted exactly one kernel.agent.degraded for pending codex agent. Tracker/status 33/33 + daemon-lifecycle 49/49 in VM.

Forward-fix iteration on this slice:
- 0f92966 packaged kernel-referenced skills + dropped deprecated mental-model-ha
- 828cc99 stripped trailing whitespace + reconciled advisor.lead role.md (replaced mental-model-ha reference with inline compaction-survival discipline; deprecation-check test widened)
- 270642d decoupled daemon health/listen from kernel-agent readiness (KernelBootTracker + /api/kernel/status + --wait-for-kernel + kernel.agent.degraded)

Merge conflict resolution: packages/daemon/test/user-settings-routes.test.ts had one trivial conflict (count annotation + comment block); resolved by combining HEAD's plugin-primitive Phase 3a comment with incoming slice 05 operator_seat_name comment, bumping union count from 28 to 29. velocity-guard CLEAR on resolution.

Triple-guard: redo3-driver-3 driver; redo3-guard-3 guard (3 narrow re-verifies across packaging / role reconciliation / architectural decoupling); velocity-qa VM dogfood (2 isolated Tart runs); advisor-lead2-pending design call for the architectural decoupling decision; velocity-guard merge-resolution review.

Operator note: --wait-for-kernel preserves the prior "daemon ready means kernel ready" semantics for CI/tests that depend on full-readiness; document in 0.3.1 release notes.
2026-05-11 04:22:07 -07:00
mvschwarz 0269359003 merge(bug-fix): auth-bearer-tailscale-trust — loopback always + tailscale CGNAT auto-detect + explicit daemon.host opt-in
Resolves operator-mobile-via-tailscale dogfood path. Supersedes the prior OPENRIG_AUTH_BEARER_TOKEN=tailnet-trust-sentinel + bind 0.0.0.0 path-3 workaround.

Refined model (founder design 2026-05-11):
- Daemon always binds loopback for local clients
- Tailscale CGNAT (100.64.0.0/10 IPv4 + fd7a:115c:a1e0::/48 IPv6 + magicDNS) auto-detected and added to multi-bind when present
- Explicit `daemon.host` (via OPENRIG_HOST, config file, or --host flag) opts into single-bind that interface; bearer token required for non-loopback explicit binds
- Default product launch path (no env, no config, no flag) gets multi-bind with tailscale auto-add if present, loopback-only if absent

Implementation:
- `packages/daemon/src/middleware/auth-bearer-token.ts`: async `assertBindAuthInvariant` with 3-path acceptance (loopback / tailscale-CGNAT / bearer); `isTailscaleBind` + `resolveToIpOrNull` with 5s DNS race timeout (Risk 9.2 mitigation)
- `packages/daemon/src/index.ts`: `detectTailscaleInterface` + multi-bind via N `serve()` instances + parallel close on shutdown
- `packages/cli/src/commands/daemon.ts` + `packages/cli/src/commands/up.ts`: use `ConfigStore.resolveWithSource('daemon.host')` to preserve user-explicit vs default-propagated signal; conditional `OPENRIG_HOST` export (omit when source=default so daemon takes multi-bind path)
- `packages/cli/src/daemon-lifecycle.ts`: `buildDaemonEnv` host now optional + conditional export; `startDaemon` split into `explicitHost` (passed to daemon) + `probeHost` (used for healthz + state.host persistence)

Test coverage:
- daemon middleware + bind: 39 tests including 7 IMPL-PRD §4 scenarios + boundary discriminators (100.63 false / 100.64 true / 100.127 true / 100.128 false; fd7a:115c:a1e0:: true; other IPv6 false) + real-DNS test (`this-host-does-not-exist.invalid`) + 5 synthetic-interface fixtures for `findTailscaleIpInInterfaces`
- CLI lifecycle: 32 + 3 new TDD scenarios for daemon.ts (env-default undefined; env-explicit preserved; baseEnv passthrough); 32 + 2 new for up.ts mirroring same patterns with distinct discriminator values
- All test files: 84/84 PASS (up.test + daemon-lifecycle.test); daemon 3306/3306; CLI tsc clean
- Full sweep 4223/4225 (one pre-existing env fail: `cli/dist/index.js` missing in worktree; unrelated)

VM dogfood (Mode 3 isolated Tart, source-backed build/link, disposable clone `auth-bearer-tailnet-0fb4e4e-20260511T105919Z`):
- HG-7 default tailnet-absent (both `rig daemon start` and `rig up`): PASS (loopback-only bind verified via lsof)
- HG-8 explicit 0.0.0.0 + bearer: PASS (TCP *:7433 LISTEN)
- HG-9 explicit 0.0.0.0 without bearer (both commands): PASS (refused with 3-part error citing loopback/tailscale/bearer paths)
- HG-6 / HG-6-NEW / HG-13 (real-tailnet behavior): DEFERRED to operator host dogfood; VM baseline lacks authenticated guest Tailscale (`BackendState=NeedsLogin`; no `TailscaleIPs`). HG-5 synthetic-interface unit test proves multi-bind logic at code level; operator host has authenticated Tailscale + real mobile reachability surface for HG-6/13 verification

Config: `daemon.host` already allowlisted in both `packages/cli/src/config-store.ts` + `packages/daemon/src/domain/user-settings/settings-store.ts` (pre-rename key; OPENRIG_HOST primary + legitimate RIGGED_HOST legacy). No SC-29 EXCEPTION needed; no new env keys.

Branch commits:
- cafcfa5 fix(daemon): tailscale-aware bind auth invariant + auto multi-bind
- f841612 fix(cli): preserve user-explicit-vs-default host signal through lifecycle
- 0fb4e4e fix(cli): rig up respects daemon.host source (forward-fix #2)

Triple-guard: redo-driver-2 driver; redo-guard-2 guard (caught two forensic-trace gaps across forward-fix iterations); velocity-qa VM (partial-proof PASS for explicit paths; tailnet-present deferred to operator host dogfood).
2026-05-11 04:10:48 -07:00
mvschwarzandClaude Opus 4.7 270642d8bc feat(daemon,cli): decouple daemon health/listen from kernel-agent readiness
Forward-fix #3 (architectural) on Phase 05d. Daemon previously awaited
`bootKernelIfNeeded` inside `createDaemon`, which awaited the full
`BootstrapOrchestrator.bootstrap(...)` of the kernel rig. A broken
kernel agent (e.g., Codex unauthenticated mid-boot) stalled the entire
daemon HTTP surface; healthz never bound; CLI reported "daemon failed
to start" even though the daemon itself was fine — only the kernel
was sad.

Architectural decoupling:

- New KernelBootTracker class. Owns kernel-boot state (skipped /
  auth_blocked / spec_missing / booting / ready / partial_ready /
  bootstrap_failed / degraded) and aggregates agents[] live from the
  sessions table on read. Bootstrap promise is fire-and-forget.
- `bootKernelIfNeeded` now returns a tracker immediately. createDaemon
  finishes; server.ts binds healthz; the bootstrap runs in the
  background. Probe / variant / spec-missing / auth-block short-
  circuits are still synchronous-ish (no expensive work).
- New event type `kernel.agent.degraded` emitted exactly once via the
  tracker's degraded-timer (default 90s; override via
  OPENRIG_KERNEL_DEGRADED_MS env or the deps.degradedTimeoutMs
  injection point used by tests).

New observability surface:

- GET /api/kernel/status. Returns
  { kernel_state, agents[], first_unready_since, variant, detail }
  when the tracker is wired; 503 with `kernel_boot_tracker_unavailable`
  otherwise. Read-only, no auth needed (same posture as healthz).

New CLI affordance (current `rig daemon start` behavior preserved):

- `--wait-for-kernel` flag: after healthz binds, additionally polls
  /api/kernel/status until kernel_state is `ready` or `partial_ready`
  (the two "operator can use the kernel" states), with a default 60s
  timeout overridable via `--wait-for-kernel-ms`. Terminal non-ready
  states (auth_blocked / spec_missing / bootstrap_failed / degraded /
  skipped) short-circuit the poll so the CLI fails fast with the
  3-part error (Error / Reason / Fix lines) per banked discipline.

Tests:

- kernel-boot-tracker.test.ts (13): state transitions, agent
  aggregation (ready / partial_ready / booting), degraded-timer
  emission (exactly once + cancel on ready before deadline),
  sessionRegistry error-handling fallthrough.
- kernel-boot.test.ts (16): refactored for the tracker-returning API;
  short-circuit branches (skipped / auth_blocked / spec_missing) +
  fire-and-forget bootstrap (booting on launch + bootstrap_failed
  after flush).
- kernel-status-route.test.ts (4): 503 envelope on missing tracker;
  200 envelope on each state; snake_case agents[] projection.

Targeted: 59/59 across the 5 kernel-related test files.
Full daemon suite: 3177 passed / 1 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 04:05:16 -07:00
mvschwarzandClaude Opus 4.7 0fb4e4e832 fix(cli): rig up respects daemon.host source (forward-fix #2)
Guard catch on f841612: the previous fix only covered `rig daemon
start`. `rig up` (auto-start path) also materialized
`resolvedConfig.daemon.host` into `startDaemon` regardless of whether
the value came from `env`/`file` (explicit) or `default` — so the
daemon's multi-bind path never ran on a default `rig up` launch
either. Same class of bug, second product launch path.

Applies the same `resolveWithSource("daemon.host")` discriminator to
`packages/cli/src/commands/up.ts`: when `source === "default"`, omit
`host` from the startDaemon call so `buildDaemonEnv` does not export
`OPENRIG_HOST` and the daemon falls through to loopback + tailscale
auto-detect. When `source === "env"` or `"file"`, pass the resolved
host through (operator opt-in path preserved).

Expanded forensic trace per banked feedback_forensic_trace and the
guard's expanded-audit directive: grep'd every `startDaemon` caller
in `packages/cli/src` — `daemon.ts` (fixed in f841612) and `up.ts`
(this commit). No other product launch paths call `startDaemon`.

Tests (TDD red→green with distinct-value discriminator per banked
feedback_poc_regression_must_discriminate):
- env-default scenario → `spawnedEnv["OPENRIG_HOST"]` undefined
- env-explicit scenario (`OPENRIG_HOST=100.64.55.66`) → preserved
  through spawn with the distinct tailscale-shape value, proving the
  preservation branch fired vs vacuous loopback-default match
- up.test.ts: 32/32 green (+2 new)
- daemon-lifecycle.test.ts: 52/52 green (unchanged)
- Full CLI+daemon sweep with env-var pollution cleaned: 4223/4225
  (same one environmental pre-existing fail: cli/dist/index.js
  missing in worktree)
- CLI tsc clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 03:54:44 -07:00
mvschwarzandClaude Opus 4.7 f84161289c fix(cli): preserve user-explicit-vs-default host signal through lifecycle
Guard review on cafcfa5 caught a real bug: the CLI daemon-start
lifecycle always exported `OPENRIG_HOST=127.0.0.1` to the daemon
process even when the operator never set it (config default
propagated through `startDaemon` → `buildDaemonEnv`). The daemon's
`index.ts` saw `OPENRIG_HOST` set and took the explicit-host branch,
single-binding loopback only — the multi-bind tailnet auto-detect
path never ran on a default product launch.

Fix: thread the user-explicit signal through the lifecycle.

- `packages/cli/src/commands/daemon.ts`: probe ConfigStore for the
  host setting's source (`resolveWithSource("daemon.host")`). When the
  source is `default` AND no `--host` flag, pass `host: undefined` to
  `startDaemon`. When source is `env`, `file`, or a CLI flag is
  present, pass the resolved host (operator opt-in path).
- `packages/cli/src/daemon-lifecycle.ts`:
  * `StartOptions.host` was already optional; `startDaemon` now
    threads `explicitHost = opts.host` (may be undefined) into
    `buildDaemonEnv` and uses `probeHost = explicitHost ?? DEFAULT_HOST`
    for the healthz probe + persisted state.host.
  * `buildDaemonEnv` makes `opts.host` optional in its signature and
    only sets `env["OPENRIG_HOST"]` when defined. If the operator's
    shell already has `OPENRIG_HOST` in baseEnv, the env-passthrough
    loop preserves that value (shell-level opt-in is honored).

After this fix, the three input paths each correctly signal the
daemon:
  - `OPENRIG_HOST` set in shell → preserved through baseEnv →
    daemon explicit path.
  - `rig config set daemon.host X` → resolveWithSource source=`file`
    → CLI exports `OPENRIG_HOST=X` → daemon explicit path.
  - No env, no config, no `--host` → source=`default` → CLI does NOT
    export `OPENRIG_HOST` → daemon falls through to
    multi-bind (loopback + tailscale auto-detect).

Tests:
- 3 new cases in daemon-lifecycle.test.ts asserting
  `buildDaemonEnv` omits/sets/respects-shell-OPENRIG_HOST correctly.
- daemon-lifecycle.test.ts: 52/52 green (was 49).
- Full sweep (env vars cleaned): 4222/4223 — the one failure is
  environmental (`packages/cli/dist/index.js` missing because the
  worktree is unbuilt; unrelated to this change).
- daemon tsc + cli tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 03:49:12 -07:00
mvschwarzandClaude Opus 4.7 cafcfa5e41 fix(daemon): tailscale-aware bind auth invariant + auto multi-bind
Bug-fix slice auth-bearer-tailscale-trust. PL-005 Phase B's
`assertBindAuthInvariant` was a generic public-internet auth pattern
applied to single-developer MVP context: any non-loopback bind plus
empty bearer caused the daemon to refuse to start. For tailscale binds
this was theater — the tailnet's WireGuard mesh + ACLs are the auth
boundary. The workaround in the field was to set
`OPENRIG_AUTH_BEARER_TOKEN=tailnet-trust-sentinel` and bind 0.0.0.0,
which exposed the LAN unnecessarily.

This change:

- Makes `assertBindAuthInvariant` async and extends it to accept three
  paths: (a) loopback bind, (b) tailscale-IP bind (CGNAT IPv4
  100.64.0.0/10 or ULA IPv6 fd7a:115c:a1e0::/48), (c) explicit
  non-empty bearer token. Hostnames are resolved via
  `node:dns/promises` first; if resolution yields a loopback or
  tailnet IP, the same short-circuit applies. The 3-part error
  message now cites all three accepted paths.
- Adds `isTailscaleBind` with strict CGNAT IPv4 + ULA IPv6 matching
  (boundary tests cover 100.63.x.x false / 100.64.x.x true /
  100.127.x.x true / 100.128.x.x false; fd7a:115c:a1e0:: true; other
  IPv6 false).
- Adds `resolveToIpOrNull` with a 5s race-timeout so an unresponsive
  resolver does not stall daemon startup (Risk 9.2 in slice IMPL-PRD).
- Adds `detectTailscaleInterface` + pure `findTailscaleIpInInterfaces`
  helper. Detection is IP-based via `os.networkInterfaces()`, not
  interface-name-based, so it survives platform differences in
  tailscale tun naming.
- Updates the daemon startup bind algorithm in
  `packages/daemon/src/index.ts`:
    * When `OPENRIG_HOST` is set: single bind to that host; the
      invariant enforces bearer requirement for genuinely public/LAN
      binds while loopback/tailscale binds short-circuit.
    * When `OPENRIG_HOST` is unset (default): bind 127.0.0.1 always;
      ALSO bind the active tailscale IP when present. Multi-bind via
      N `serve()` instances sharing the same Hono app; shutdown closes
      all in parallel.

The operator can now safely `unset OPENRIG_AUTH_BEARER_TOKEN && rig
daemon restart` after upgrading: a tailnet-present host auto-binds
both 127.0.0.1 and the tailscale interface, no LAN exposure.

No new SC-29 EXCEPTION:
- `daemon.host` is already allowlisted in
  packages/cli/src/config-store.ts and
  packages/daemon/src/domain/user-settings/settings-store.ts with
  env primary `OPENRIG_HOST` (legacy `RIGGED_HOST` alias is legitimate
  per the pre-rename window).
- No new event types, no DB migrations, no new routes.

Tests:
- 39 cases in `auth-bearer-token.test.ts` (was 16; +23 new) covering
  IMPL-PRD's 7 scenarios + boundary discriminators + helper
  unit tests + the multi-bind interface detector. DNS resolution is
  mocked for predictable test runs; an intentionally-unresolvable
  hostname verifies the "DNS fails → throws" path.
- Daemon full sweep: 3306/3306 green.
- Daemon tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 03:39:55 -07:00
mvschwarzandClaude Opus 4.7 828cc99ad9 fix(daemon/specs): strip trailing-ws + reconcile advisor.lead role.md with deprecation curation
Forward-fix #2 on Phase 05d guard-3 narrow re-verify at 0f92966.

(1) Whitespace strip: openrig-architect SKILL.md line 48 had 3 trailing
    spaces. git diff --check now clean across the worktree.

(2) Role/profile reconciliation: advisor.lead/guidance/role.md still
    referenced `mental-model-ha` as the compaction-survival skill even
    though the prior forward-fix dropped it from the profile (per the
    2026-05-10 2f56163 curation that deprecated the bundled copy).
    Resolution: replace the bundled-skill reference with an inline
    compaction-survival discipline note that names the durable
    substrate primitives (`rig whoami` / `rig transcript` / `rig
    queue`) and leaves a hook for operator-installed richer skills.

    Chose this over packaging mental-model-ha as the 6th skill because
    spec-library-starters.test.ts asserts BOTH (a) shared pool MUST
    NOT contain mental-model-ha AND (b) public builtin agent profiles
    MUST NOT reference it — the curation is binding, not opportunistic.

(3) Test gate widened: the "public builtin agent profiles do not
    reference deprecated HA skill" check previously walked only
    AGENT_SPECS (which excluded kernel agents). Kernel agents are
    now built-in surface and must respect the same curation, so the
    test now also walks the 3 kernel agent specs. Caught at Phase 05d
    forward-fix #2 when advisor.lead reintroduced the reference.

Tests: kernel-boot 16/16 + kernel-rig-spec-validate 8/8 + spec-
library-starters 18/18 all green. git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 03:12:51 -07:00
mvschwarz facb105e6b merge(0.3.1): slice 11 workflow-spec-folder-discovery — installable workflow YAML discovery + diagnostic rendering
Slice 11 makes workflow specs installable user-primitives: drop YAML in
workspace.specs_root/workflows/ → Library auto-discovers + validates +
shows valid row OR diagnostic row.

- Migration 040 (idempotent ALTER TABLE: status + error_message)
- WorkflowSpecCache.writeDiagnostic + removeBySourcePath
- scanWorkflowSpecFolder (mtime-driven cache skip; scope-bounded
  deletion; opportunistic on Library list request)
- New RigEvent variant workflow_spec.removed (sourcePath + spec id +
  reason=file_disappeared); emitted one per cache row removed
- UI Library renders diagnostic rows non-navigable with error styling
  + errorMessage inline

OQ-1..4 per IMPL-PRD; SC-29 #10 for migration 040; HG-13 OPENRIG_DB
isolation verified in Tart VM dogfood.

Verify gates: redo-guard-2 OK; velocity-qa Tart VM dogfood CLEAR at
8aa1f4d (HG-3 audit-log entry verified end-to-end via events table).
2026-05-11 03:11:54 -07:00
mvschwarzandClaude Opus 4.7 2c2e6ba5b3 Move DESIGN.md to docs/
DESIGN.md is documentation, not repo-root metadata like CHANGELOG or
LICENSE. It belongs alongside the as-built architecture/ui/codemap docs.

Updates the two doc references in docs/as-built/{architecture,ui}.md
that pointed at the old root location. Other DESIGN.md mentions in
packages/* refer to plugin-primitive's DESIGN.md (substrate workspace)
and are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 02:48:14 -07:00
mvschwarzandClaude Opus 4.7 0f929662b2 fix(daemon/specs): package kernel-referenced skills + drop deprecated mental-model-ha
Forward-fix on Phase 05d velocity-qa VM verdict: kernel agents declared
profile.uses.skills against 6 skills not registered in the packaged
shared resource pool, so `rig daemon start` reached kernel-boot but
bootstrap failed with "skills not found in resource pool".

Resolution:

- Package 5 skills into shared/agent.yaml + on-disk SKILL.md:
  - openrig-architect → skills/core/openrig-architect
  - rig-lifecycle → skills/core/rig-lifecycle
  - local-sysadmin → skills/process/local-sysadmin (with references/)
  - intake-routing → skills/process/intake-routing
  - control-plane-queue → skills/process/control-plane-queue
- Drop mental-model-ha from advisor.lead profile.uses.skills.
  Curation pass on 2026-05-10 (2f56163) removed mental-model-ha from
  the bundled starter pool as a deprecated 956-line skill;
  advisor.lead keeps openrig-user, openrig-architect, brainstorming,
  requirements-writer — the role-coverage stays intact.

Extended kernel-rig-spec-validate.test.ts with the resource-pool
containment gate that should have caught the regression earlier:

- on-disk SKILL.md existence for every shared/agent.yaml skills entry
- profile.uses.skills resolution against shared pool for all 3 kernel
  agents (advisor.lead, operator.agent, queue.worker)

Tests: 42/42 in kernel-rig-spec-validate + kernel-boot +
spec-library-starters. Full daemon suite 3160 passed / 1 skipped /
0 failed on clean re-run (two earlier timeouts were known flakes
documented in kernel-boot.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-11 01:11:42 -07:00
mvschwarz 6b76cdd1f1 merge(0.3.1): plugin-primitive batch 1+2 — Claude .claude-plugin + Codex .codex-plugin first-class content layer
# Conflicts:
#	packages/cli/src/config-store.ts
#	packages/daemon/src/domain/user-settings/settings-store.ts
2026-05-11 00:01:52 -07:00
mvschwarzandClaude Opus 4.7 8aa1f4d976 fix(slice-11): emit workflow_spec.removed audit event on deletion
QA VM-dogfood verdict at f68f453 (qitem-20260511065129-a4e881a1):
single BLOCKING miss — deletion path removed the workflow_specs cache
row but did not produce an audit-log entry. OQ-4 contract requires
BOTH: "Remove cache row when file disappears + audit-log entry. Clean
Library + traceable." HG-3 verification: deletion produces both cache
row removal AND audit-log entry visible in event log / audit surface.

Adds RigEvent variant `workflow_spec.removed` carrying sourcePath +
spec_id/name/version (when known) + reason="file_disappeared". The
folder scanner emits one event per row removed; emission is opt-in
via the new optional `eventBus` field on ScanWorkflowSpecFolderOpts,
so unit-test callers that don't care about audit can omit it (the
back-compat test asserts this). Route layer plumbs the eventBus
context var into every scanWorkflowSpecFolder invocation.

Tests (TDD red→green):
- workflow_spec.removed emitted once per deleted file with sourcePath
  matching the row that disappeared
- multiple deletions produce N distinct events with distinct paths
  (drift-discriminator per banked feedback_poc_regression_must_discriminate)
- omitting eventBus → no emission (back-compat)
- daemon full sweep: 3161/3161 green (+3 new tests)
- daemon tsc clean

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 23:55:12 -07:00
mvschwarz 0d424c6b8e merge(0.3.1): slice 06 storytelling-primitives
Slice 06 — UI kind primitives (KindFrame + DotTimeline + StatCardBand +
ProgressCard + FlowCard) + fenced-block grammars + TimelineTab refactor +
For You card primitives. Production-wire fix-cycle: useSliceTimelineMarkdown
resolves absolute slicePath via /api/files/roots; ProgressCard mounted in
Feed.tsx via useMissionDiscovery + buildStorytellingFeedItems pure-fn
adapter. Path-resolution + adapter routing covered by integration tests
(URL search params asserted). Test fixtures use /Users/example/...
placeholder.

Verify gates: design-reviewer PROFESSIONAL GRADE MET; redo3-guard-3 CLEAR;
velocity-qa VM CLEAR.

5 commits: e8e3f5b initial bundle; c6b699d yaml lockfile sync; 70001d1
production wire (forward-fix #1); 9a0ff00 allowlist resolve + ProgressCard
wire (forward-fix #2); 3f2f175 fixture path generalization.

Tests: 117/117 in storytelling lane + full UI suite 857 PASS / 1 skipped /
0 failures. SC-29 unchanged.
2026-05-10 23:44:11 -07:00
mvschwarzandClaude Opus 4.7 326c1f50f9 fix(daemon): kernel agent import paths + cwdOverride + openrig-installer registration
Forward-fix on the kernel-boot integration surfaced by exercising the
real BootstrapOrchestrator pipeline against the shipped variants
(integration path that the prior unit tests didn't run). Three real
issues caught + fixed before velocity-qa hits them in the VM dogfood:

1. agent_ref shared-spec path was off by one level. The 3 kernel
   agent.yamls imported `local:../../../../../agents/shared` (5
   levels up from
   packages/daemon/specs/rigs/launch/kernel/agents/<pod>/<member>/
   resolves to packages/daemon/specs/rigs/agents/shared, which
   does not exist). Corrected to
   `local:../../../../../../agents/shared` (6 levels up resolves
   to packages/daemon/specs/agents/shared).

2. operator.human cwd resolved to the kernel spec dir under the
   daemon installation tree. Bootstrap rejected with
   "cwd is inside the OpenRig installation". kernel-boot.ts now
   requires cwdOverride and threads it into the
   BootstrapOrchestrator.bootstrap call; startup.ts passes
   runtimeSettings.workspaceRoot (the resolved workspace.root
   setting) so kernel members run against the operator's workspace.

3. openrig-installer skill (NEW in Phase 05c at
   packages/daemon/specs/agents/shared/skills/core/openrig-installer/)
   wasn't registered in the shared agent's resources.skills list.
   The operator.agent profile.uses.skills referenced it but the
   resource pool didn't surface it. Added the entry so the
   filesystem-discovery (slice daemon-skill-discovery in 0.3.0)
   and the explicit shared-spec import both resolve cleanly.

Plus a test-cadence fix:

- bootKernelIfNeeded now short-circuits with outcome=skipped_no_kernel_flag
  when VITEST=true (vitest's standard env var) UNLESS deps.probeRuntimes
  is injected. Production callsites don't inject probeRuntimes →
  no test-env auto-skip in production. Test callsites that inject a
  fast deterministic probe (kernel-boot.test.ts) opt back in to the
  full logic. Side effect: daemon-composition tests that exercise
  createDaemon() across 10+ test files no longer pay the ~5s
  runtime-auth-probe cost.
- packages/daemon/test/startup.test.ts also sets
  OPENRIG_NO_KERNEL=1 via beforeAll/afterAll for explicit
  belt-and-suspenders; harmless redundancy with the VITEST env
  check.
- packages/daemon/test/kernel-boot.test.ts: cwdOverride is now
  required on KernelBootDeps; all 9 fixture configurations updated;
  one new assertion verifies cwdOverride propagates to
  BootstrapOrchestrator.bootstrap opts.

Verification:
- npx tsc --noEmit on daemon: CLEAN
- npx vitest run full daemon suite: 3156 PASS / 1 skipped / 0
  failures. No regressions in startup composition, mission-control,
  bundle/discovery/events/ps/rigspec/lifecycle test surfaces (all
  10 files that previously failed due to runtime-auth-probe timeout
  now green).
- Spec-validate gate test (kernel-rig-spec-validate.test.ts) still
  4/4 PASS — the shared-import-path correction is internal to the
  agent.yaml; the rig variants themselves stayed structurally
  identical.

SC-29 scope unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 23:23:14 -07:00
mvschwarzandClaude Opus 4.7 18a0196d3a docs(daemon/specs): align kernel auto-resume phrasing with already-managed-short-circuit semantics
Forward-fix on slice 05 Phase 05d narrow-advisory Finding 2 —
documentation drift between commit-body claim and shipped doc text.

The prior phrasing in rig.yaml summary, culture/CULTURE.md, and the
operator.agent startup context conflated two distinct semantics:

  1. The kernel rig RECORD persists in SQLite across daemon-restart
     (true; rigs table row survives the daemon process restart).
  2. Member agents are AUTO-RELAUNCHED on daemon-restart (false; the
     daemon does not re-launch any agent member of any rig on
     restart — the reconciler probes tmux survival and marks
     sessions detached when tmux is gone).

The phrase "auto-resume kernel via daemon-restart" read like (2) but
the code shape is only (1). Forensic-confirmed by re-reading
startup.ts (no restoreOrchestrator.restore call in the boot path) +
reconciler.ts (marks-stale-as-detached only; never re-launches).

Changes:

- packages/daemon/specs/rigs/launch/kernel/rig.yaml: summary rewritten
  to make the two lifecycle phases explicit (first-boot vs.
  subsequent-daemon-restart short-circuit) + the HOST-reboot vs.
  daemon-restart-only distinction for tmux survival.
- packages/daemon/specs/rigs/launch/kernel/culture/CULTURE.md:
  "Kernel auto-boot has two distinct lifecycle phases" replaces the
  prior "kernel is the only auto-start rig" bullet with the precise
  two-phase model + HOST-reboot detach + operator agent ownership of
  the post-host-reboot workflow.
- packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/startup/context.md:
  "On daemon-restart (precise semantics)" replaces the prior section
  with step-by-step lifecycle: daemon kernel-boot short-circuit →
  reconciler probes sessions → operator owns the agent-restart
  workflow when tmux is gone. The "what's already running" bullet
  now describes the rig-record-persists / sessions-may-be-detached
  / pending-restart state precisely.

Operator pod ownership of the post-host-reboot agent-restart
workflow remains the canonical path (the openrig-operator skill
carries the script; the operator agent's role.md already primes
this in 575f2b8).

Substrate companion doc (separate filesystem):
substrate/.../release-0.3.1/slices/05-kernel-rig-as-default/COMMIT-BODY-STYLE-CORRECTIONS.md
ledgers the prior commits' attribution-style phrasing per Finding 3b
(banked feedback_no_founder_attribution_in_commits +
feedback_no_verbatim_founder_quotes_in_commits). The ledger is
append-only; git history is not rewritten; future commits on this
branch comply with the adjusted style going forward.

Verification:
- npx tsc --noEmit on daemon: CLEAN (no source touched)
- packages/daemon/test/kernel-rig-spec-validate.test.ts still 4/4
  PASS (rig.yaml summary change is comment text; validator unaffected)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 23:11:58 -07:00
mvschwarzandClaude Opus 4.7 f68f453aec feat(slice-11): workflow-spec folder discovery + diagnostic rows
release-0.3.1 slice 11 (workflow-spec-folder-discovery) — workflow
specs as an installable user primitive. Operators drop YAML files in
`<workspace.specs_root>/workflows/`; the daemon's spec-library route
opportunistically scans the folder on each list request, parses +
validates each file, populates the workflow_specs cache, and surfaces
both valid + diagnostic rows so the Library UI can render in-place
errors for malformed YAML.

SC-29 #10 (verbatim declaration):
"Slice 11 (workflow-spec-folder-discovery) requires schema migration
040_workflow_specs_diagnostic.ts adding status TEXT DEFAULT 'valid' +
error_message TEXT columns to the workflow_specs cache. No new table,
no constraint changes beyond default; ALTER TABLE ADD COLUMN preserves
existing rows (default 'valid' fills retroactively for already-cached
rows). Read-only diagnostic surface — the cache stores parser/
validator errors so the Library UI can render them; daemon does not
act on the diagnostic state. Per IMPL-PRD §HG-8 'unless provenance /
status columns require migration — declare upfront if so': declared
upfront in this slice's ACK + commit body."

Behavior contract (4 OQs):
- OQ-1: canonical roots — folder = `<workspace.specs_root>/workflows`.
- OQ-2: diagnostic surface — workflow_specs cache rows carry status +
  error_message; scanner surfaces error rows with synthetic id
  `workflow:error:<source_path>` and zero counts.
- OQ-3: scanner trigger — opportunistic on each GET /api/specs/library;
  mtime check at second-resolution skips unchanged files.
- OQ-4: deletion representation — folder-scope-bounded removal; rows
  whose source_path falls under the scanned folder but no longer on
  disk are deleted from the cache.

Implementation:
- packages/daemon/src/db/migrations/040_workflow_specs_diagnostic.ts
  — idempotent ALTER TABLE ADD COLUMN (status, error_message).
- packages/daemon/src/domain/workflow-spec-cache.ts — writeDiagnostic +
  removeBySourcePath; diagnostic rows store basename as name when YAML
  unparseable.
- packages/daemon/src/domain/spec-library-workflow-scanner.ts —
  scanWorkflowSpecFolder filesystem walk; mtime skip; folder-scope
  deletion; extended scanWorkflowSpecs to emit diagnostic entries with
  status + errorMessage on SpecLibraryWorkflowEntry.
- packages/daemon/src/routes/spec-library.ts — refreshWorkflowEntries
  now triggers scanWorkflowSpecFolder before scanWorkflowSpecs when
  the workflowSpecCache + workflowsFolderDir context vars are wired.
- packages/daemon/src/startup.ts + server.ts — wire the new context
  vars (workflowSpecCache = workflowRuntime.specCache; workflowsFolderDir
  = `<workspace.specs_root>/workflows`) from SettingsStore.
- packages/ui/src/components/specs/SpecsLibraryPage.tsx +
  hooks/useSpecLibrary.ts — diagnostic rows render non-navigable with
  error styling + errorMessage inline; valid rows still link to
  /specs/library/$entryId.

Tests (all green):
- packages/daemon/test/workflow-specs-diagnostic-migration.test.ts (9)
- packages/daemon/test/workflow-spec-folder-scanner.test.ts (8)
- packages/daemon/test/spec-library-workflow-folder-route.test.ts (5)
- packages/ui/test/library-page-workflow-diagnostic.test.tsx (2)
- daemon full sweep: 3158/3158 green.

HG-13 (dogfood-time, NOT this commit): per banked
feedback_daemon_migration_db_isolation, the Tart VM dogfood pass MUST
set OPENRIG_DB explicitly before any daemon start that runs migration
040 — OPENRIG_HOME alone is not sufficient for migration-touching
slices.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 23:09:21 -07:00
mvschwarzandClaude Opus 4.7 82982a820b fix(daemon/specs): kernel variants pass rig spec validate + add validation gate test
Forward-fix on slice 05 Phase 05d (commit 4bf1fce) addressing the
critical Finding 1 from the narrow advisory: all 3 kernel rig
variants failed `rig spec validate` with
`pods[1].members[0]: terminal runtime requires profile 'none' (got 'default')`.
The kernel-boot unit tests at 4bf1fce passed because they mocked
BootstrapOrchestrator and never invoked the real validator pipeline
against the shipped specs.

Changes:

- packages/daemon/specs/rigs/launch/kernel/{rig.yaml, rig-claude-only.yaml, rig-codex-only.yaml}:
  operator.human builtin:terminal member now declares `profile: none`
  (was `profile: default`). RigSpec validation requires terminal
  runtime members to use profile=none.
- packages/daemon/test/kernel-rig-spec-validate.test.ts (NEW): runs
  the real validateRigSpecFromYaml validator (the same primitive
  BootstrapOrchestrator's resolve_spec stage uses) against each
  shipped variant. Closes the regression gap the prior unit tests
  left: a future variant edit that breaks the validator contract
  fails this test before the variant can ship.
- packages/cli/src/config-store.ts:32 comment adjustment: removed a
  developer-local placeholder string from the new operatorSeatName
  JSDoc per release-prep public-source hygiene.

Verification:

- Spec-validate gate test: 4/4 PASS (one per variant + topology-shape
  parity).
- npx tsc --noEmit on daemon + cli: CLEAN.
- The kernel-boot integration via startup.ts no longer surfaces the
  "terminal runtime requires profile 'none'" error in stderr during
  startup.test.ts; bootstrap proceeds cleanly against the corrected
  variants.

Scope: spec content fix + new test file. SC-29 unchanged. No source
changes to kernel-boot.ts, startup.ts, or any other production
TypeScript surface — the regression was in the YAML shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 23:08:40 -07:00
mvschwarzandClaude Opus 4.7 e477bd5544 fix(cli): yaml-parser-based skill frontmatter validation + null-guard manifests (slice 3.4.C repair)
Per velocity-guard 3.4.C BLOCKING-CONCERN at 489577b: regex-based
frontmatter parsing in validateSkillFrontmatter could pass quoted-empty
`name: ""` and `description: ""`, accept malformed YAML, and risk
crashes on null/non-object manifest JSON.

Repair:
- import { parse as parseYaml } from "yaml" (CLI dep already at ^2.8.3
  via adopt.ts + context-pack.ts pattern)
- validateSkillFrontmatter: parse fmBody with parseYaml; reject non-
  object/null/array results; require name + description as strings with
  trim().length > 0; keep ≤1024 char limit on description
- validateManifest: guard JSON.parse result with isPlainObject helper
  before field access (rejects null, arrays, primitives); same trimmed
  string check on name + version + (Codex) description
- Add isPlainObject helper for shared object-shape guard

Regressions added (all would fail against prior regex code):
- skill quoted-empty name FAILS
- skill quoted-empty description FAILS
- skill malformed YAML FAILS (unclosed bracket)
- skill non-object frontmatter (string scalar) FAILS
- skill name as number FAILS (type check, not just presence)
- manifest plugin.json containing null FAILS
- manifest plugin.json containing array FAILS

Tests: 29/29 green (was 22 + 7 new = 29).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 23:00:49 -07:00
mvschwarzandClaude Opus 4.7 4bf1fce9d0 feat(daemon,cli): kernel auto-boot path + --no-kernel flag + listDestinations operator-seat fallback
0.3.1 slice 05 kernel-rig-as-default — Phase 05d. Adds the daemon
startup hook that auto-boots the kernel rig on `rig daemon start`,
per IMPL-PRD §6.1 + amended §16.2. Forensic finding from the 05d
opener confirmed current daemon-restart already does NOT auto-restore
any rig (reconciler marks-stale-as-detached only; restoreOrchestrator
.restore() invoked only from explicit operator paths). HG-20
kernel-only-auto-restart is satisfied by-construction once the
kernel-boot path lands — no existing auto-restore-all to remove.

Founder OQ decisions captured per HG-18/HG-19:
- OQ-A = Option B (migration ceremony at 0.3.1 upgrade)
- OQ-B = L2 (rig daemon start owns kernel boot; rig setup invokes it)

Behavior:

- New module packages/daemon/src/domain/kernel-boot.ts exports
  bootKernelIfNeeded(deps): KernelBootResult with 6 outcomes
  (booted / skipped_no_kernel_flag / skipped_already_managed /
  auth_blocked / spec_missing / bootstrap_failed). Composed in
  startup.ts after BootstrapOrchestrator construction.
- Short-circuits:
  - OPENRIG_NO_KERNEL=1 → skipped_no_kernel_flag (CLI --no-kernel
    flag projected via daemon-lifecycle.ts buildDaemonEnv).
  - Existing rig named `kernel` in rigs table →
    skipped_already_managed (covers the OQ-A=B migration path where
    operator copied the substrate kernel into ~/.openrig/specs/rigs/
    before the V0.3.1 upgrade).
- Variant selection: probeRuntimes() (default shells out to
  `claude auth status` + `codex login status` with 5s timeout) →
  selectVariant() returns rig.yaml / rig-claude-only.yaml /
  rig-codex-only.yaml. Both-unavailable → auth_blocked with the
  3-part-error message (Error: / Reason: / Fix:) per the
  building-agent-software skill discipline (HG-3).
- Cold boot: BootstrapOrchestrator.bootstrap({mode:"apply",
  sourceRef: <variant-path>, sourceKind:"rig_spec",
  autoApprove:true}). Errors during bootstrap are logged but do NOT
  crash the daemon — the operator can retry manually.
- 3 rig variants now declare `runtime: terminal` on the
  operator.human builtin:terminal member (validation requires it).

CLI surface:

- `rig daemon start --no-kernel` skips kernel auto-boot. Commander's
  --no-kernel inverts to opts.kernel === false; thread through to
  StartOptions.skipKernelBoot → buildDaemonEnv emits
  OPENRIG_NO_KERNEL=1 in the daemon process env.

Mission-control fallback (HG-10):

- packages/daemon/src/routes/mission-control.ts listDestinations now
  accepts an optional operatorSeatFallback parameter. The GET
  /destinations route handler reads the configured operator seat
  via MissionControlReadLayer.getDefaultOperatorSession() (NEW
  public getter) and includes it in the destinations list even when
  the kernel hasn't booted yet — picker always offers the configured
  seat. Deduplicated: when the seat is already in topology or queue
  history, no duplicate emitted.

Tests (16 new):

- packages/daemon/test/kernel-boot.test.ts covers selectVariant
  (3 cases), kernelAlreadyManaged (3 cases), authBlockMessage
  3-part-error contract, bootKernelIfNeeded short-circuit branches
  (no-kernel-flag, already-managed, auth-blocked, spec-missing),
  bootKernelIfNeeded happy path with all 3 variants + bootstrap
  errors (orchestrator-reported + thrown).

Verification:

- npx tsc --noEmit on daemon + cli: CLEAN
- npx vitest run packages/daemon full suite: 3152 PASS / 1 skipped
  / 0 failures. No regressions in existing startup, mission-control,
  user-settings, or read-layer test surfaces.

SC-29 scope: no SQL migrations / new endpoints / new event types.
The new daemon-side code uses existing tables + existing route
handlers + existing event sinks. Workspace.operator_seat_name setting
extension lives in Phase 05b's existing allowlist plumbing. SC-29
EXCEPTION ledger unchanged.

Next phase per IMPL-PRD §11 + §16.5:
- 05e/05f — end-to-end disposable Tart VM lifecycle dogfood per
  HG-23 (consolidated v0 success criterion). Founder-flagged VM
  rehearsal of the OQ-A=B migration ceremony per the earlier
  reinforcement note. velocity-qa owns the sustained VM loop;
  driver hands off after this commit per established cadence.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 23:00:45 -07:00
mvschwarz 489577b503 feat(cli): rig plugin validate subcommand + --runtime/--source filter validation (slice 3.4 Checkpoint C)
Per IMPL-PRD §4.2 + HG-4.4 (validate) AND closes velocity-guard 3.4.A-repair
pre-close punch (--runtime/--source CLI-side validation).

============ rig plugin validate <path> (HG-4.4) ============

Local file inspection — does NOT require daemon. Validates:
- Manifest dir presence: at least one of .claude-plugin/plugin.json or
  .codex-plugin/plugin.json must exist
- Manifest required fields: name (string) + version (string); description
  (string) is REQUIRED on Codex (per Codex spec) and recommended-not-
  required on Claude
- Skill frontmatter for each skills/<id>/SKILL.md: name (non-empty) +
  description (non-empty, ≤1024 chars per agentskills.io spec)
- Path existence + isDirectory checks

Output:
- Default (pretty): 'Plugin at <path>: valid' OR error list with exit 1
- --json: { valid: boolean, errors: string[] }; exit 1 when invalid

8 tests:
- Valid dual-manifest + skill passes (exit 0)
- No manifest dirs at all FAILS
- Codex manifest missing description FAILS (Codex spec requires)
- Manifest missing name FAILS
- Skill missing frontmatter name FAILS
- Skill description >1024 chars FAILS (agentskills.io limit)
- --json output returns { valid, errors } shape
- Non-existent path FAILS with clear error

============ --runtime / --source filter validation (closes pre-close punch) ============

CLI-side validation per velocity-guard 3.4.A-repair carried concern: typos
like '--source rig-cwd' (a daemon-side parseSourceFilter rejection that
silently returns ALL plugins) now fail loud with clear error naming
allowed values.

VALID_RUNTIMES = ['claude', 'codex'] (mirror packages/daemon/src/routes/
plugins.ts L25-30 parseRuntimeFilter)
VALID_SOURCES = ['vendored', 'claude-cache', 'codex-cache'] (mirror L37-40
parseSourceFilter)

3 tests:
- rejects invalid --runtime value with clear error + non-zero exit
- rejects invalid --source value (rig-cwd typo case) with clear error
- accepts valid combos (regression for normal pass-through)

============ Verification ============

- plugin.test.ts: 22/22 pass (was 11; +8 validate + +3 filter validation)
- CLI typecheck: clean
- Full CLI suite: 897 pass + 4 fail (pre-existing DaemonClient/Preflight;
  NOT my regressions; same 4 fail since pre-batch-2-merge state)

============ Slice 3.4 status ============

All 4 subcommands shipped + tested + integrated:
- list (HG-4.1) ✓
- show (HG-4.2) ✓
- used-by (HG-4.3) ✓
- validate (HG-4.4) ✓
- All ship --json output (HG-4.5 building-agent-software) ✓

Ready for slice-close ACK + walk packet authoring + final hand-off DM
to orch-lead in next commit.

NO new SC-29 declaration this slice (read-only CLI consumer + local
file validation; no ConfigStore additions, no new HTTP routes, no DB
migrations). Cumulative ledger stays at #1-4 + project-observability
#5/#6 + #8 (3.3) + #9 (3.2).

Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + per banked
feedback_tdd_scope.
2026-05-10 22:50:20 -07:00
mvschwarzandClaude Opus 4.7 575f2b8ca0 feat(daemon/specs): kernel rig topology + agents + culture + openrig-installer skill
0.3.1 slice 05 kernel-rig-as-default — Phase 05c. Authors the
class-of-one always-on kernel rig that V0.3.1's daemon start logic
(Phase 05d) auto-boots. Three pods, three custom agents, one human
marker, three variants (dual / claude-only / codex-only) selected by
the daemon at boot per runtime authentication state.

Topology (Shape A** per IMPL-PRD §3):

  kernel/
  ├── advisor/
  │   └── lead       [Claude-preferred / Codex-only variant available]
  ├── operator/
  │   ├── human      [builtin:terminal — zero startup actions; topology marker]
  │   └── agent      [Codex-preferred / Claude-only variant available]
  └── queue/
      └── worker     [Codex-preferred / Claude-only variant available]

Edges per IMPL-PRD §3:
- collaborates_with: operator.agent ↔ operator.human
- escalates_to: operator.agent → operator.human
- delegates_to: advisor.lead → operator.agent
- delegates_to: advisor.lead → queue.worker
- escalates_to: queue.worker → advisor.lead

New files (13 total):
- packages/daemon/specs/rigs/launch/kernel/rig.yaml (dual-runtime; default variant)
- packages/daemon/specs/rigs/launch/kernel/rig-claude-only.yaml
- packages/daemon/specs/rigs/launch/kernel/rig-codex-only.yaml
- packages/daemon/specs/rigs/launch/kernel/culture/CULTURE.md
- packages/daemon/specs/rigs/launch/kernel/agents/advisor/lead/{agent.yaml, guidance/role.md, startup/context.md}
- packages/daemon/specs/rigs/launch/kernel/agents/operator/agent/{agent.yaml, guidance/role.md, startup/context.md}
- packages/daemon/specs/rigs/launch/kernel/agents/queue/worker/{agent.yaml, guidance/role.md, startup/context.md}
- packages/daemon/specs/agents/shared/skills/core/openrig-installer/SKILL.md (NEW shipped skill)

Skill loadouts per IMPL-PRD §4 (lean v0; agents load others on demand
via find-skills per HOST-TOPOLOGY §4.10):
- advisor.lead: openrig-user + openrig-architect + brainstorming +
  requirements-writer + mental-model-ha
- operator.agent: openrig-user + openrig-operator + local-sysadmin +
  rig-lifecycle + systematic-debugging + verification-before-completion
  + openrig-installer (NEW)
- queue.worker: openrig-user + intake-routing + control-plane-queue

The operator.agent's role.md primes the "bring my rigs back online"
post-reboot workflow (scope-expansion preview banked for 05d/05e:
kernel is the only rig the daemon auto-starts on restart; other rigs
require explicit operator-initiated restart per agent-driven
workflow). The openrig-operator skill carries the canonical script;
this slice's role.md hints at the pattern so guidance primes the agent
before the upstream skill updates land.

The openrig-installer SKILL.md documents the V0.3.1 substrate-kernel →
daemon-managed-kernel migration ceremony per OQ-A=B + "0.3.1 local
upgrade IS the migration moment" operational note: snapshot →
rig down → copy spec → upgrade CLI/daemon → verify → decommission.
Includes a rollback path via the pre-migration snapshot, plus a
3-part-error remediation table for the install paths.

Founder OQ decisions captured per HG-18/HG-19:
- OQ-A = Option B (migration ceremony at 0.3.1 upgrade)
- OQ-B = L2 (rig daemon start owns kernel boot; rig setup invokes it)

Verification (Phase 05c only):
- npx tsc --noEmit on daemon: CLEAN (no source changes; spec authoring)
- Spec YAML structurally valid (matches existing
  packages/daemon/specs/rigs/launch/* shape; rig spec validate
  runtime check moves to 05d when the daemon boot path loads these
  specs)

SC-29 scope: no source changes; pure spec / culture / skill content
authoring. SC-29 unchanged from prior phases.

Next phase per IMPL-PRD §11:
- 05d — daemon boot extension (kernel-boot logic in startup.ts + variant
  probe + auth-block 3-part error + --no-kernel flag + listDestinations
  fallback ~30L). Will re-read amended IMPL-PRD §16 + HG-20-23 at the
  05d boundary before code touches startup.ts / restore-orchestrator.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 22:47:58 -07:00
mvschwarz 120a56ec9a feat(cli): rig plugin used-by subcommand (slice 3.4 Checkpoint B)
Per IMPL-PRD §4.2 + HG-4.3. Reverse query: which agents reference a
given plugin in their profile.uses.plugins[].

Consumes slice 3.3 daemon route GET /api/plugins/:id/used-by which
returns AgentReference[] per PluginDiscoveryService L134-141:
  { agentName: string, sourcePath: string, profiles: string[] }

CLI surface:
  rig plugin used-by <id>      — pretty table: agentName [profiles] sourcePath
  rig plugin used-by <id> --json  — raw AgentReference[]

Empty result handling: explicit honest message ('No agents reference plugin
"<id>"') rather than silent zero-row exit; --json on empty returns []
(consistent JSON shape for agent consumers).

Tests added to plugin.test.ts (now 11 tests total; up from 7 after 3.4.A
repair):
- --json returns AgentReference[] from /api/plugins/:id/used-by with real
  agentName + sourcePath + profiles fields
- default (pretty) output prints agentName + sourcePath + profiles for
  each reference; real fields verbatim
- empty result is honest — does NOT exit non-zero; some honest 'no agents'
  message
- --json on empty result returns []

Fixture mock server added /api/plugins/unreferenced/used-by → [] for
empty-case coverage.

Verification:
- plugin.test.ts: 11/11 pass
- packages/cli typecheck: clean

Pre-close punch unchanged from 3.4.A-repair: --runtime/--source value
validation (currently pass-through; daemon silently ignores bad values).

NEXT (3.4.C): rig plugin validate <path> — local file inspection;
reuses validatePluginResources logic from agent-manifest.

Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + per banked
feedback_tdd_scope.
2026-05-10 22:43:42 -07:00
mvschwarz 4a8d92431c fix(cli): slice 3.4.A repair — align wire types/tests/output to real PluginDiscoveryService contract; drop dead flags
Per velocity-guard BLOCKING-CONCERN on ab56cbe (2026-05-11 via daemon
qitem-20260511053524-55bcb7c2): two real catches — contract drift +
exposed-but-unsupported flags. Truth-in-labeling repair both.

BLOCKER #1 RESOLVED — wire type contract drift:
plugin.ts had INVENTED PluginEntry/PluginDetail fields (rootPath,
skillCount, hookEventCount, mcpServerCount, skills[].id/path/description,
hooks[].eventCount, manifest skillsRef/hooksRef). Real daemon shapes
per packages/daemon/src/domain/plugin-discovery-service.ts L41-132:
- PluginEntry: id, name, version, description, source, sourceLabel,
  runtimes, path, lastSeenAt
- PluginManifestSummary: raw (Record), name, version, description,
  homepage, repository, license (all string|null)
- PluginSkillSummary: name, relativePath
- PluginHookSummary: runtime, relativePath, events[]
- PluginMcpServerSummary: runtime, name, command, transport
- PluginDetail: entry, claudeManifest, codexManifest, skills, hooks,
  mcpServers (NO entry-level count fields)

Test fixture used the same invented shape, so tests were falsely green
against the false fixture but human pretty-print would crash on real
daemon data (s.id.padEnd against undefined). NOW: fixtures + types +
pretty-print all use the verbatim PluginDiscoveryService wire contract.
Comment on the wire-types block flags lockstep requirement.

BLOCKER #2 RESOLVED — exposed-but-unsupported flags:
Removed --used and --tree CLI flag declarations (declared in PRD §4.2
but neither was actually wired). Restricted --source enum to
vendored|claude-cache|codex-cache (dropped rig-cwd which the daemon
parseSourceFilter doesn't accept). Restricted --runtime to claude|codex
(no 'both' which the daemon doesn't accept; the omit-flag-for-all
semantic IS the 'both' default). Header comment block now explicitly
documents WHY each of these is deferred to a later slice with the
implementation gap named.

NEW REGRESSION ASSERTIONS (per velocity-guard 'add regressions for real
pretty list/show'):
- list pretty output asserts entry.path is printed AND that the invented
  count fields are NOT present (regex check)
- show pretty output asserts entry.path + entry.lastSeenAt + manifest
  license + manifest repository + skill.name + skill.relativePath +
  hook.relativePath + hook events array all appear (real fields only)
- show --json asserts entry.path is the actual real PluginEntry.path
  field

Verification:
- plugin.test.ts: 7/7 pass (locking in real wire shape, not the false one)
- packages/cli typecheck: clean

Per velocity-guard BLOCKING-CONCERN on ab56cbe + IMPL-PRD §4.
2026-05-10 22:39:25 -07:00
mvschwarz ab56cbe416 feat(cli): rig plugin list + show subcommands (slice 3.4 Checkpoint A)
Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + DESIGN.md §6
(CLI minimalism: 4 read-only inspection commands). Makes the 7 TDD-red
tests in commit 7eb4bd2 pass.

New packages/cli/src/commands/plugin.ts (240L):
- pluginCommand factory mirrors contextPackCommand pattern (StatusDeps
  injection; getClient helper)
- Consumes slice 3.3 plugin-discovery-service via daemon HTTP routes
  (GET /api/plugins, GET /api/plugins/:id)

Subcommands implemented:
- list: --runtime claude|codex filter; --source vendored|claude-cache|
  codex-cache|rig-cwd filter; --used flag (placeholder; future
  refinement); --json output
- show <id>: --tree placeholder + --json output; pretty-prints manifests
  (claude + codex), skills with descriptions, hooks per runtime, MCP
  servers if present; 404 → non-zero exit + clear stderr error

Wire-up (packages/cli/src/index.ts):
- Import pluginCommand
- Add pluginDeps?: StatusDeps to deps override interface
- program.addCommand(pluginCommand(...))

Verification:
- plugin.test.ts: 7/7 pass
- packages/cli typecheck: clean
- 4 pre-existing CLI test failures (DaemonClient --version + DaemonClient
  config integration + Preflight CLI --json + Preflight CLI prints-check-
  marks) confirmed via git-stash test as NOT regressions; failed on
  pre-batch-2-merge state too

NOT YET in this commit (slice 3.4 remaining checkpoints):
- 3.4.B: rig plugin used-by <id> (reverse query via /api/plugins/:id/used-by)
- 3.4.C: rig plugin validate <path> (local file inspection; reuses
  validatePluginResources from agent-manifest)

Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + per banked
feedback_tdd_scope (TDD-first; restored discipline).
2026-05-10 22:31:25 -07:00
mvschwarzandClaude Opus 4.7 3b686135b9 feat(daemon,cli,ui): workspace.operator_seat_name setting replaces hardcoded operator seat
0.3.1 slice 05 kernel-rig-as-default — Phase 05b. Replaces the
mission-control read layer's hardcoded "human-operator@kernel"
constant with a cascade-resolved typed setting, sourced from the
existing user-settings-v0 allowlist infrastructure. Closes HG-7
(setting works end-to-end) + HG-8 (default derivation works) +
HG-9 (OPENRIG_* env override works; no RIGGED_* legacy) + HG-12
(daemon DEFAULT_OPERATOR_SESSION removed).

Founder OQ decisions captured per dispatch:
- OQ-A = Option B (migration ceremony; founder migrates substrate
  kernel ~/code/substrate/<internal-docs>/rigs/kernel/ to
  ~/.openrig/specs/rigs/kernel/ as one-time op; 0.3.1 local upgrade
  IS the migration moment)
- OQ-B = L2 (rig daemon start owns kernel boot; rig setup invokes it)

Both decisions apply forward through the remaining phases; 05b is the
foundational setting + read-layer cascade preceding 05c spec authoring
+ 05d daemon boot extension + 05e VM dogfood.

Behavior:
- New typed key `workspace.operator_seat_name` added to user-settings-v0
  allowlist (SETTINGS_VALID_KEYS + ENV_MAP + KEY_TO_PATH +
  getDefaultValue) on the daemon side. Lockstep on the CLI side:
  RiggedConfig.workspace.operatorSeatName + DEFAULTS + VALID_KEYS +
  ENV_MAP + KEY_TO_PATH + deriveWorkspaceDefault +
  WORKSPACE_DERIVED_KEYS + resolve() return shape.
- ResolvedConfig.workspaceOperatorSeatName (daemon) + RiggedConfig.workspace.operatorSeatName (CLI)
  both resolve to `operator-${os.userInfo().username}@kernel` by
  default; env override via OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME (no
  RIGGED_* legacy alias per banked feedback_no_rigged_legacy_for_new_keys);
  file override via `rig config set workspace.operator_seat_name <session>`.
- MissionControlReadLayer.DEFAULT_OPERATOR_SESSION constant removed.
  deps.defaultOperatorSession is now injected by startup.ts from the
  resolved setting; the read layer's ultimate fallback derives from
  os.userInfo() to keep the daemon's my-queue routing working even
  when the setting is unset.
- 2 UI cosmetic sites updated to reference the new shape
  (HumanSeatCard JSDoc + AuditHistoryView placeholder text). The
  IMPL-PRD §5 cited MyQueueView + GenericListView paths no longer
  exist in current main a2d4568 (citation drift verified pre-impl
  per feedback_verify_synthesis_brief_citations); HG-11 reads as 2
  cosmetic sites + the daemon production read-path replacement.

Tests:
- packages/daemon/test/user-settings-store.test.ts SETTINGS_VALID_KEYS
  list assertion updated to include workspace.operator_seat_name.
- packages/daemon/test/user-settings-routes.test.ts key-count
  assertion 27 → 28.
- packages/daemon/test/mission-control-read-layer.test.ts ReadLayer
  fixture now passes defaultOperatorSession: "human-operator@kernel"
  explicitly so the existing fixture data + my-queue routing align
  (production startup.ts injects the SettingsStore-resolved value).
- packages/cli/test/config-store-extended.test.ts VALID_KEYS list +
  2 new roundtrip tests covering:
  - default `operator-${USER}@kernel` derivation
  - set → resolve override + reset returns to default
  - OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME env override beats file value

Verification (Phase 05b only):
- npx tsc --noEmit on daemon / cli / ui tsconfigs: CLEAN
- npx vitest run packages/daemon suite: 3136 PASS / 1 skipped / 0
  failures
- npx vitest run packages/cli test/config-store-extended.test.ts:
  41 PASS

SC-29 scope: no new SQL migrations / endpoints / event types. The
new key uses the EXISTING user-settings-v0 typed-keys allowlist
infrastructure (same shape as Phase 4 agents.* / Phase 5
feed.subscriptions.* / pre-release transcripts.* additions). No new
SC-29 EXCEPTION declaration required per IMPL-PRD §5.

Next phases per IMPL-PRD §11:
- 05c — spec authoring (3 rig variants + agents + culture + openrig-installer skill)
- 05d — daemon boot extension (kernel-boot logic + variant probe + --no-kernel + listDestinations fallback)
- 05e — Tart VM dogfood

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 21:35:58 -07:00
mvschwarz 7eb4bd2b67 test(cli): failing tests for rig plugin list + show (slice 3.4 Checkpoint A)
Per IMPL-PRD §4 + DESIGN.md §6 (CLI minimalism: read-only inspection only).
8 tests across list + show subcommands, all currently FAIL (TDD red —
pluginCommand source file doesn't exist yet at packages/cli/src/commands/
plugin.ts).

Tests cover:
rig plugin list (HG-4.1):
- --json returns plugin entries from /api/plugins
- default (table) output prints id + version + runtimes + source label
- --runtime claude passes runtime filter to daemon route
- --source vendored passes source filter to daemon route

rig plugin show <id> (HG-4.2):
- --json returns plugin detail from /api/plugins/:id
- default (pretty) output prints manifest summary + skills + hooks counts
- missing plugin id returns non-zero exit + error to stderr

Test infrastructure mirrors slice 3.4 sister test/context-pack.test.ts —
in-memory http.createServer daemon mock + Commander parseAsync via
captureLogs helper.

Fixtures use slice 3.3 PluginEntry + PluginDetail + AgentReference
shapes from packages/daemon/src/domain/plugin-discovery-service.ts.

Per IMPL-PRD §4.4 HG-4.5 — all CLI commands ship --json output for
agent consumption per banked building-agent-software discipline.

Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + per banked
feedback_tdd_scope (TDD-first; restored from batch-1 Phase A self-audit
gap).
2026-05-10 18:10:50 -07:00
mvschwarz 3479dbc146 merge(plugin-primitive-v0): integrate batch 2 slice 3.3 (UI plugin surface) by redo-driver-2
Two-parent --no-ff merge of plugin-primitive-3-3-ui (tip 995d28f) into
plugin-primitive-v0 (was at tip c6e0670 after slice 3.2 close).

Batch 2 convergence: this merge brings the plugin-primitive-v0 branch
to the post-3.3-close state and unblocks slice 3.4 (rig plugin CLI)
which has a soft dependency on slice 3.3's plugin-discovery-service +
GET routes.

Slice 3.3 scope (per redo-driver-2; SC-29 #8 declared in their commit body):
- packages/daemon/src/domain/plugin-discovery-service.ts (607L) — service
  the slice 3.4 CLI commands will consume
- packages/daemon/src/routes/plugins.ts (84L) — GET /api/plugins,
  GET /api/plugins/:id, GET /api/plugins/:id/used-by
- packages/daemon/src/server.ts wiring + startup.ts wiring
- packages/ui/src/components/specs/{PluginDetailPage,AgentPluginsList,
  SpecsLibraryPage} + LiveNodeDetails Plugins section + usePlugins hook
  + routes registration
- 3.3 fix-iterations: 965398a (MCP section + NodeDetails Plugins +
  rig-cwd discovery) + 995d28f (rig-cwd id self-resolve)
- Tests: plugin-discovery-service.test.ts (602L) + plugins-routes.test.ts
  (342L) + 5 UI test files

Merge mechanics:
- Auto-merge clean — no conflicts. The startup.ts overlap (3.3's
  PluginDiscoveryService import + wire + my batch-1 D.2 ensureCodexFeatureFlag
  + slice 3.2 PluginVendorService wire) auto-resolved cleanly because the
  edits landed in non-overlapping line ranges.
- Per banked feedback_verify_merge_convention_before_dispatch.md: --no-ff
  two-parent local merge; agent-owned (founder gates push to origin only,
  not local merges).
- Single fixup landed alongside merge: packages/ui/test/spec-display.test.tsx
  L61 had stale 'hooks: [pre-commit]' from pre-rip 3.3 fixture; my batch-1
  AgentSpecReview interface swap (resources.hooks → resources.plugins)
  surfaced as 6 UI test fails post-merge. Replaced with
  'plugins: [openrig-core]' to match the post-rip interface. Pure fixture
  alignment; 3.3's intended behavior unchanged.

Verification post-merge:
- daemon typecheck: clean
- ui typecheck: clean
- daemon full suite: 3239 pass; 1 fail (pre-existing rsi-v2 only).
  Pre-merge daemon suite was 3208 pass — net +31 tests from 3.3
  (plugin-discovery-service.test.ts + plugins-routes.test.ts).
- ui full suite: 808 pass + 1 skipped (was 803-ish pre-merge; net +5
  from 3.3's UI test files; 1 skipped is pre-existing).

Cumulative SC-29 ledger now:
- #1-4 (kernel + shell-redesign-v1 phases)
- project-observability #5/#6
- (#7 NOT declared — demo-video-rig-v0 ripped per scope correction)
- #8 (slice 3.3 plugin-discovery-service + GET routes by redo-driver-2)
- #9 (slice 3.2 vendored plugin assets + PluginVendorService)
- (#10 may be declared by slice 3.4 if CLI adds new ConfigStore keys)

NEXT: drive slice 3.4 (rig plugin CLI list/show/used-by/validate) per
batch-2 dispatch. Discovery service the CLI consumes is now available
on this branch.

Per IMPL-PRD §8.3 dispatch shape + plugin-primitive Phase 3a batch 2.
2026-05-10 17:50:58 -07:00
mvschwarzandClaude Opus 4.7 995d28f120 plugin-primitive Phase 3a slice 3.3 fix-iteration: rig-cwd id self-resolve
redo-guard-2 BLOCK on 965398a raised 2 items. Forward-fix on both per
banked discipline (TDD red→green; loop redo-guard-2 for re-verify).

═══ Item 1 (functional): rig-cwd: id list→detail 404 ═══

Symptom: `GET /api/plugins?cwd=<path>` returns rig-cwd plugin ids;
`GET /api/plugins/<encoded-id>` 404s because
`PluginDiscoveryService.getPlugin(id)` calls `listPlugins()` with no
opts — cwdScanRoots defaults to empty, so the rig-cwd plugin isn't in
the list and `find((p) => p.id === id)` returns undefined → 404.

Fix: `getPlugin(id)` self-resolves rig-cwd: ids by parsing the cwd
out of the id prefix (constructed in `scanCwdBundledPlugins` as
`rig-cwd:<cwd>/.claude/plugins/<plugin>` or
`rig-cwd:<cwd>/.codex/plugins/<plugin>`). New helper
`extractCwdFromRigCwdId(id)` returns `[cwd]` for valid rig-cwd: ids
and `null` otherwise. Same shape as the other source kinds where
the id already encodes everything needed to re-resolve — keeps the
detail-call contract self-contained without adding `?cwd=` to the
detail endpoint.

Coverage added:
- 3 daemon-service tests: claude roundtrip, codex roundtrip, malformed
  id negative.
- 1 daemon-routes test: list-with-?cwd= → detail roundtrip through the
  HTTP layer (would have caught the original 404).

═══ Item 2 (process): ACK provenance stale ═══

The 965398a fix-ack had `(TBD at commit)` and `7 files / +~200` for
its net-diff line. Actual `git diff --stat 695ca74..965398a` is
**9 files / +448 / -9**. ACK §2 corrected; fix-iteration section
added at §9 covering both items.

═══ Verification ═══

- 62 tests across 7 files all green (was 58 at 965398a; +4 fix-iter
  coverage: 3 daemon-service + 1 daemon-routes).
- Daemon tsc clean.
- UI tsc clean for touched files (pre-existing @tanstack/react-table
  baseline on main c0fcccd unchanged).
- SC-29 #8 boundary unchanged (service-internal id-parse; no new
  state, no new endpoint, no mutation).

Substrate fix-ack updated at <work-tree>/missions/plugin-primitive/
attempt-1-redo-driver-2-phase-3a-batch-2-slice-3-3-fix-ack.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 17:42:21 -07:00
mvschwarzandClaude Opus 4.7 3f2f175c23 test(ui): timeline-markdown fixture path generalization
Generalizes fixture paths in packages/ui/test/use-slice-timeline-markdown.test.tsx to follow the standard placeholder convention.

7 occurrences updated; behavior unchanged. The resolveSlicePathToAllowlist pure-function fixtures already used a placeholder shape and stay as-is.

Verification:
- npx vitest run packages/ui test/use-slice-timeline-markdown.test.tsx: 7/7 PASS
- grep -n wrandom on the file: 0 matches

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 17:34:16 -07:00
mvschwarzandClaude Opus 4.7 9a0ff004b8 fix(ui): resolve absolute slicePath to allowlist root + wire ProgressCard from missions
Forward-fix #2 on slice 06 storytelling-primitives (after 70001d1)
addressing redo3-guard-3 narrow advisory's second-round findings.

Finding 1 (BLOCKING — path-construction bug):
The prior forward-fix at 70001d1 wired useSliceTimelineMarkdown to
"workspace" root with detail.slicePath as the relative path. But
SliceDetail.slicePath is an ABSOLUTE filesystem path from the
daemon, and /api/files/read rejects absolute paths and requires
a path RELATIVE TO ONE OF THE REGISTERED ALLOWLIST ROOTS. Real
slice timelines degraded to unavailable=true; only the prop-only
TimelineTab test fixture exercised the rendering path. The
component+hook unit tests crossed the same boundary in test that
the production wire crossed in app, but neither proved the call
shape sent to /api/files/read was the relative form the daemon
expects.

Fix:
- useSliceTimelineMarkdown(absoluteSlicePath) now resolves the
  absolute path against useFilesRoots() using the same
  deepest-prefix-wins pattern as useMissionDiscovery
  (isPathPrefix + relativeUnder, exact match preferred). Exports
  resolveSlicePathToAllowlist for the pure-function test surface.
- Returns a `resolved: { rootName, relPath } | null` diagnostic
  field so integration tests can assert the production call
  shape.
- ScopePages call site simplifies to
  useSliceTimelineMarkdown(detail.slicePath ?? null) — the hook
  owns root resolution + relPath construction internally.
- When no allowlist root contains the absolute slice path, the
  hook short-circuits before issuing the read (no spurious
  /api/files/read call with the absolute path).

Integration tests:
- packages/ui/test/use-slice-timeline-markdown.test.tsx — 7 tests
  covering: deepest-prefix root resolution (4 pure-function
  cases); absolute → relative conversion + /api/files/read call
  shape assertion (path query param is NOT absolute); no-root
  short-circuit; /api/files/read 404 graceful unavailable. The
  shape-assertion test (URL.searchParams("path") !== absolute)
  is the proof guard-3 asked for — it would have failed on the
  prior 70001d1 state and passes after the fix.

Finding 2 — ProgressCard production wire:
The prior wire routed missions through IncidentCard with
status=info; ProgressCard was test-only. Per IMPL-PRD §6,
ProgressCard's data source is the existing PROGRESS.md
frontmatter + checkbox count under workspace.root/missions/.

Fix:
- Feed.tsx now calls useMissionDiscovery() alongside the existing
  useSlices("all") query. The first 2 missions route to
  ProgressCard with mission.name as missionId, the mission
  filesystem path in the oneLiner, an "Open mission for live
  status" nextStep, and percent=0. The legacy slice routing
  continues to dispatch to ShippedCard / IncidentCard for the
  next 3 slice rows.
- Percent computation (PROGRESS.md checkbox count → percent)
  defers to a follow-up slice. ProgressCard mounts in production
  and surfaces the bar with percent=0; once the indexer ships
  per-mission progress percent, the wire updates without any
  call-site change.
- Adapter logic extracted to a pure function
  buildStorytellingFeedItems(missions, slices) → FeedCardItem[]
  exported from storytelling-cards.tsx. Feed.tsx calls it inside
  useMemo. The adapter is the testable surface for routing rules
  without mounting the full Feed.tsx component.

Adapter tests:
- packages/ui/test/storytelling-feed-adapter.test.ts — 9 tests
  covering: mission → ProgressCard routing (Finding 2 proof);
  cap-at-2-missions; shipped/complete/done → ShippedCard;
  blocked → IncidentCard warning; failed/danger → IncidentCard
  danger; everything else → IncidentCard info; composition
  ordering (missions first); empty-list; null/undefined defense.

Verification:
- npx tsc --noEmit on packages/ui/tsconfig.json: CLEAN
- npx vitest run packages/ui suite: 857 PASS / 1 skipped / 0
  failures. Storytelling-focused suite: 66/66.
- SC-29 scope unchanged — UI-only forward-fix; no daemon edits,
  no migrations, no new endpoints, no new event types.

Pattern lesson recorded for the slice walk packet: TDD'ing the
hook + component in isolation is necessary but not sufficient
when the integration crosses a runtime boundary (here client →
daemon API allowlist). The 7 path-conversion tests now exercise
the request URL shape through to /api/files/read, closing the
integration-test gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 17:00:30 -07:00
mvschwarzandClaude Opus 4.7 70001d11b5 fix(ui): wire storytelling primitives into production surfaces
Forward-fix on slice 06 storytelling-primitives (c6b699d) addressing
the two redo3-guard-3 advisory findings — production integration
incomplete relative to the IMPL-PRD promises.

Finding 1 (BLOCKING) — TimelineTab timeline.md discovery:
- New hook useSliceTimelineMarkdown(root, slicePath) fetches
  <slicePath>/timeline.md via the existing /api/files/read daemon
  route. Mirrors useMissionProgressStatus shape so cache + mtime
  invalidation stays consistent across markdown-as-typed-surface
  consumers. Read failure surfaces unavailable=true; content=null;
  no error toast.
- ScopePages.tsx slice-scope render calls the hook and threads the
  fetched content through to the TimelineTab via the existing
  timelineMarkdown prop (added in c6b699d). Default root="workspace"
  since slices live under workspace.slices_root.
- Now any slice that ships a timeline.md alongside its IMPLEMENTATION-PRD
  renders the curated narrative above the auto-captured event feed
  in the live Project UI — not just under prop injection in tests.

Finding 2 — For You card primitives production integration:
- Feed.tsx mounts a StorytellingFeed section labeled "Storytelling
  preview" above the existing FeedCard list. Sources the first 3
  rows from the daemon-driven useSlices("all") query and routes
  each by status to ShippedCard (status=shipped/complete/done) or
  IncidentCard (everything else with status badge derived from the
  slice status). The legacy FeedCard list remains the primary
  surface; this is the storytelling-primitives preview band.
- ProgressCard collapsed view now renders the IMPL-PRD §6 progress
  bar (leadingAccessory on CardShell; bg-sky-500 fill clamped to
  0..100%) and accepts a new nextStep field that replaces the
  oneLiner when supplied. Closes the §6 gap that surfaced as
  guard-3 sub-finding ("ProgressCard computes percent but never
  renders the progress bar/next-step shape").

Tests (6 new + 1 array guard):
- packages/ui/test/storytelling-wires.test.tsx covers:
  - TimelineTab renders story-timeline-markdown when prop supplied
  - TimelineTab falls back to empty-state without events + markdown
  - TimelineTab keeps the empty-state suppressed when markdown alone
    is present (events absent)
  - ProgressCard renders the progress bar with data-percent + fill
    width set from percent
  - ProgressCard nextStep replaces oneLiner on the collapsed view
  - ProgressCard clamps out-of-range percent values to 0..100
- Array.isArray(sliceRows) guard in Feed.tsx storytellingItems
  useMemo handles test fixtures where slicesQuery.data is mocked
  without a populated `slices` array.

Verification:
- npx tsc --noEmit on packages/ui/tsconfig.json: CLEAN
- npx vitest run packages/ui suite: 841 PASS / 1 skipped / 0
  failures. No regressions; app-shell For You route + rail-state
  tests fixed via the Array.isArray guard.

SC-29 scope unchanged from the parent commit c6b699d — UI-only;
git diff packages/daemon/ = empty; no migrations / endpoints /
event types. Substrate slice ack + walk updated separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 16:52:26 -07:00
mvschwarzandClaude Opus 4.7 c6b699de41 chore(deps): sync package-lock.json for yaml dependency added to packages/ui
Lockfile sync after declaring "yaml": "^2.8.3" in packages/ui/package.json
(slice 06 storytelling-primitives commit). The yaml package is already
hoisted at workspace root via daemon + cli; this lockfile diff records
the explicit ui declaration so npm ci stays reproducible.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 16:41:06 -07:00
mvschwarzandClaude Opus 4.7 e8e3f5b4f3 feat(ui): storytelling primitives — kind dispatcher, fenced-block grammars, TimelineTab refactor, For You card primitives
Slice 06 of release-0.3.1. Three bundled phases:

06a — MarkdownViewer extension:
- New module storytelling-primitives.ts exports extractKind +
  isFencedBlockLanguage + parsers for 5 fenced-block grammars
  (timeline / stats / risk-table / compare / slate). Every parser
  returns a discriminated union so callers can fall back to a
  labeled error box on unparseable YAML.
- New module blocks.tsx exports FencedBlockRenderer that dispatches
  each language to its specialized spatial primitive.
- New module primitives.tsx exports 7 spatial primitive components
  (TLDRSlate, DotTimeline, StatCardBand, RiskTableGrid, CompareTable,
  ActionChecklist, SummaryStrip). Honors OpenRig vellum tokens;
  square corners except the 12px status dots.
- New module kind-frame.tsx exports KindFrame — shared chrome
  (kind badge + title + meta row + optional TL;DR slate + optional
  summary strip for feature-shipped).
- MarkdownViewer.tsx wraps the body in KindFrame when frontmatter
  declares a known kind; intercepts the 5 fenced-block languages
  inside BlockRenderer. Fence-language regex extended to accept
  hyphens so risk-table parses cleanly. Unknown kinds + plain
  markdown continue to render exactly as before.
- yaml ^2.8.3 added to packages/ui/package.json (already hoisted at
  workspace root via daemon + cli).

06b — TimelineTab refactor:
- StoryTab.tsx renamed to TimelineTab.tsx via git mv. Exported
  symbol renamed; ScopePages.tsx + 3 test files updated. Internal
  data-testid prefixes (story-row-*, story-step-*, story-tab)
  preserved to keep the existing test surface stable.
- New timelineMarkdown?: string prop renders above the event feed
  via MarkdownViewer when present; the curated narrative augments,
  never replaces, the auto-captured event trail.
- Each step dot now carries data-dot-status + a color class
  derived from statusFromEventKind(event.kind) — success green,
  warning amber, danger red, info blue, muted stone.
- Empty state now points operators at <slice-dir>/timeline.md +
  the incident-timeline kind + the timeline fenced block.

06c — For You card primitives:
- New module feed/cards/storytelling-cards.tsx exports CardShell +
  5 card types (Shipped, Incident, Progress, Approval-needed,
  Concept) + StorytellingFeed composition.
- Mobile-first interaction levels: collapsed (min-h-[80px]) → tap
  to inline-expand → tap drill-in CTA (min-h-[44px]) to navigate.
- IncidentCard surfaces a status-color dot on the collapsed view
  via the shared CardShell.leadingAccessory slot. ApprovalCard
  surfaces approve/deny inline actions on the collapsed view via
  CardShell.inlineActions.
- Drill-in routes: shipped/incident/concept → /project/slice/<id>;
  progress → /project/mission/<id>; approval → source-provided
  href.

Tests (44 new):
- packages/ui/test/storytelling-primitives.test.ts — 19 pure-logic
  tests covering kind extraction + all 5 fenced-block parsers +
  graceful failure modes.
- packages/ui/test/markdown-viewer-storytelling.test.tsx — 12
  integration tests covering HG-1 (kind dispatch), HG-2 (fenced
  blocks parse + render), HG-3 (plain markdown unchanged).
- packages/ui/test/storytelling-cards.test.tsx — 13 tests covering
  HG-6 (all 5 cards), HG-7 (drill-in routing), HG-8 (≥44px touch
  targets + 80px collapsed height).

Verification (HG-1 through HG-13):
- npx tsc --noEmit on packages/ui/tsconfig.json: CLEAN
- npx vitest run packages/ui suite: 835 PASS / 1 skipped / 0
  failures. No regressions in existing markdown-viewer, slice-tabs,
  p5-slice-scope, polish-8-demo-readiness tests.
- SC-29 daemon byte-equivalent boundary: untouched. UI-only slice;
  no daemon source changes, no SQL migrations, no new endpoints,
  no new event types. git diff packages/daemon/ = empty.

Substrate slice artifacts at <work-tree>/missions/release-0.3.1/slices/06-storytelling-primitives/:
- ack.md (structured ACK with permission preflight + 4 DRIFT
  entries + HG-1 through HG-13 verification matrix)
- walk.md (founder-walk + design-reviewer packet with 9 live
  verification hooks)
- MARKDOWN-CONVENTIONS.md (operator-facing reference for the kind
  dispatcher + 5 fenced-block grammars)

Refs:
- IMPLEMENTATION-PRD.md (advisor-authored; 2026-05-10)
- research/thariq-ux-vocabulary-2026-05-10.md (UX vocabulary
  research; 4707 words)
- Demo timeline.md for plugin-primitive mission authored separately
  by advisor-lead per IMPL-PRD §9; renderer ready to consume it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 16:40:46 -07:00
mvschwarzandClaude Opus 4.7 a2d45687fa Release 0.3.0
See docs/releases/v0.3.0.md and CHANGELOG.md for the full release narrative.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 13:59:51 -07:00
mvschwarz 3949b958a4 docs(release): add 0.3.0 public notes 2026-05-10 13:39:05 -07:00
mvschwarz 97d64220a1 docs(config): clarify legacy env boundary 2026-05-10 13:23:50 -07:00
mvschwarz 188eea319d chore(release): align public release terminology 2026-05-10 13:14:53 -07:00
mvschwarzandClaude Opus 4.7 965398afa1 plugin-primitive Phase 3a slice 3.3 fix: MCP section + NodeDetails Plugins + rig-cwd discovery
Fix-cycle after velocity-qa Mode 3 VM verify REPORT.md against tip 695ca74
raised 3 specific failures vs dispatch §3.2 + DESIGN §5.4. All 3 closed
TDD red→green per banked discipline.

═══ Fixes ═══

1. PluginViewer MCP section (dispatch §3.2 "manifest preview + skills/
   hooks/MCP sections + Used-by section"):
   - PluginDiscoveryService.getPlugin returns PluginMcpServerSummary[]
     parsed from claude/codex manifest mcpServers field (best-effort:
     name + command + transport).
   - PluginDetailPage renders McpServersSection alongside Skills/Hooks/
     Used-by; EmptyState fallback when no MCP servers declared.
   - 2 new daemon tests (positive + empty) + UI assertion in existing
     plugin-detail-page test.

2. NodeDetails Plugins section wire-up (dispatch §3.2 "between Skills
   and Startup Files"):
   - LiveNodeDetails AgentSpecSection now renders AgentPluginsList in a
     dedicated `live-agent-plugins-section` after AgentSpecDisplay.
   - extractAgentPluginIds reads plugin IDs defensively from the agent
     review (tolerates resources.plugins[].id-or-string-shorthand; works
     on main without plugin-primitive-v0 merged — renders empty state).
   - 1 new live-node-details test asserts section presence + empty state
     behavior.
   - Original slice ship deferred this to merge-time-only; velocity-qa
     correctly read dispatch §3.2 as requiring visible-in-current-build.
     Wire-up inverts the deferral on main; AgentSpecDisplay opt-in still
     available at convergence into plugin-primitive-v0.

3. plugin-discovery-service rig-cwd source (DESIGN §5.4 union 4th
   category):
   - PluginSourceKind extended with "rig-cwd".
   - PluginDiscoveryService accepts cwdScanRoots option (constructor +
     per-call listPlugins opts; per-call wins by replacement).
   - scanCwdBundledPlugins scans `<cwd>/.claude/plugins/*` +
     `<cwd>/.codex/plugins/*` (the IMPL-PRD §1.2 projection target);
     emits with `rig-cwd:<cwd-tail>/<plugin>` source label.
   - GET /api/plugins accepts ?cwd=<path> query param for per-call
     rig-cwd discovery (Library omits; rig-context UI passes through).
   - 2 new daemon-service tests + 1 new daemon-routes test.

═══ SC-29 boundary unchanged ═══

Same SC-29 #8 (declared verbatim at slice ship). Fix-cycle additions
are within the existing read-only filesystem-scan boundary:
- MCP discovery reads existing manifest fields already on disk
- Rig-cwd scan is an additional read-only filesystem walk against an
  operator-provided cwd; same authority model as the 3 existing roots
- API gains ?cwd query param but no new endpoint, no new state, no
  mutation verb

═══ Verification ═══

- 58 tests across 7 files all green (was 42 at slice ship; +16 fix-cycle:
  4 daemon-service + 1 daemon-routes + 1 live-node-details + 10 covered
  via expanded fixtures in existing tests).
- Daemon tsc clean
- UI tsc clean for touched files (pre-existing @tanstack/react-table
  baseline on main c0fcccd unchanged)
- Pre-existing daemon test baseline (136 failed | 92 passed worktree
  fixture-load issue) unchanged at fix tip; not introduced by this slice
  or fix-cycle.
- Drift-discriminator regression extended from 3-source to 4-source
  coverage (vendored / claude-cache / codex-cache / rig-cwd) with
  distinct sourceLabel + source enum per layer.

Substrate ACK update at <work-tree>/missions/plugin-primitive/attempt-1-redo-driver-2-phase-3a-batch-2-slice-3-3-fix-ack.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 13:07:21 -07:00
mvschwarz 9d27d43294 fix(daemon): use portable agent starter fallback 2026-05-10 12:47:36 -07:00
mvschwarz 21eccf51b8 chore(release): generalize public-facing breadcrumbs 2026-05-10 12:28:25 -07:00
mvschwarz aa56b44e8c chore(release): normalize portable path handling 2026-05-10 12:01:41 -07:00
mvschwarz 7a5942bfb1 test: neutralize internal mission fixture names 2026-05-10 11:40:25 -07:00
mvschwarz cb82e5f759 merge(release): integrate daemon skill discovery 2026-05-10 11:39:51 -07:00
mvschwarz 10f6490b60 merge(release): integrate 0.3.0 starter refresh 2026-05-10 11:39:38 -07:00
mvschwarz 1166ff8a49 docs: refresh 0.3.0 design and as-built references 2026-05-10 11:39:04 -07:00
mvschwarzandClaude Opus 4.7 695ca74b05 plugin-primitive Phase 3a slice 3.3: UI plugin surface
Implements the UI side of the plugin-primitive mission:
- Library Explorer Plugins category (rendered alongside Skills)
- Plugin Detail page at /plugins/:pluginId (manifest + skills + hooks + used-by)
- Agent Plugins enrichment helper (AgentPluginsList) for merge-time
  wiring into plugin-primitive-v0's AgentSpecDisplay Plugins block

Backed by a new daemon-side plugin-discovery-service + 3 read-only
HTTP routes that aggregate plugin discoveries across vendored
(~/.openrig/plugins), Claude Code cache (~/.claude/plugins/cache),
and Codex cache (~/.codex/plugins/cache) per DESIGN.md §5.4. The
discovery service is structurally branch-merge-friendly: it operates
on parsed agent.yaml + filesystem reads, doesn't depend on batch 1's
PluginResource type from plugin-primitive-v0, and the used-by reverse
query walks resources.plugins[].id structurally rather than
string-grepping (regression test asserts comment + skill-description
mentions don't produce false positives).

═══ SC-29 EXCEPTION #8 (verbatim) ═══

Slice 3.3 (UI plugin surface) requires daemon-side plugin-discovery-service
+ 3 HTTP routes (GET /api/plugins, GET /api/plugins/:id,
GET /api/plugins/:id/used-by) as backing API. No additional state, no
SQL migration, no mutation routes. Read-only discovery surface
aggregating filesystem-scan unions per DESIGN.md §5.4. Per IMPL-PRD §3.3
'Code touches' this allocation is explicit; documenting in compliance
with banked SC-29 verbatim-declaration rule.

Cumulative ledger: #1-4 historical + project-observability #5+#6
(ab5644c) + daemon-skill-discovery #7 (in flight) + plugin-primitive 3.3
daemon discovery service #8 (this commit).

═══ Files ═══

Daemon (4 source + 2 tests):
- domain/plugin-discovery-service.ts (NEW; 332 lines) — listPlugins +
  getPlugin + findUsedBy. Filesystem-scan over 3 roots; structural
  agent.yaml parse for used-by.
- routes/plugins.ts (NEW; 66 lines) — 3 read-only routes; route-order
  /:id/used-by mounted before /:id per banked spec-library Phase A R1
  SSE lesson.
- server.ts (modified) — pluginsRoutes import + ServerDeps field +
  c.set("pluginDiscoveryService") + app.route mount.
- startup.ts (modified) — PluginDiscoveryService construction with
  stable filesystem roots from os.homedir() + getDefaultOpenRigPath.
- test/plugin-discovery-service.test.ts (NEW; 12 tests).
- test/plugins-routes.test.ts (NEW; 10 tests).

UI (5 source + 4 tests):
- hooks/usePlugins.ts (NEW; 117 lines) — usePlugins / usePlugin /
  usePluginUsedBy TanStack Query wrappers + type definitions mirroring
  daemon shapes.
- components/specs/SpecsLibraryPage.tsx (modified) — PluginsSection
  added alongside SkillsSection.
- components/specs/PluginDetailPage.tsx (NEW; 254 lines) — page with
  ManifestSection / SkillsSection / HooksSection / UsedBySection.
- components/specs/AgentPluginsList.tsx (NEW; 116 lines) — merge-time
  helper for plugin-primitive-v0's AgentSpecDisplay Plugins block.
  Standalone tested; 1-line wire-up at merge convergence.
- routes.tsx (modified) — pluginDetailRoute at /plugins/$pluginId.
- test/usePlugins.test.tsx (NEW; 7 tests).
- test/library-page.test.tsx (modified) — Plugins section assertion
  + new focused test.
- test/plugin-detail-page.test.tsx (NEW; 4 tests).
- test/agent-plugins-list.test.tsx (NEW; 4 tests).

═══ Verification ═══

- 42 new TDD tests, all green: 22 daemon + 20 UI
- TDD red→green discipline applied per phase (test-first; implementation
  follows red confirmation)
- Daemon tsc clean
- UI tsc clean for touched files (pre-existing @tanstack/react-table
  baseline on main c0fcccd unrelated to slice; SpecsTable.tsx not
  touched)
- Drift-discriminator regression coverage: plugin-discovery-service
  test 'aggregates discoveries across all 3 source roots' fixtures
  distinct vendored / claude-cache / codex-cache plugins emitting
  observably distinct sourceLabel + source enum per layer
- No SQL migration; no mutation routes; no auth-bypass

═══ Branch + merge plan ═══

Branched off main c0fcccd per dispatch (NOT off plugin-primitive-v0).
Converges into plugin-primitive-v0 at slice-close so all batch 2 work
merges before 0.3.1 ship + post-0.3.0 rebase. velocity-driver's slice
3.4 (rig plugin CLI) has soft dependency on this slice's
plugin-discovery-service; they paused before 3.4 starts and will
consume this service post-merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 05:07:56 -07:00
mvschwarz c6e06705f2 fix(daemon): slice 3.2 truth-in-labeling repair — README + test pins for v0 probe-only fetch path
Per velocity-guard BLOCKING-CONCERN on 67cafec (2026-05-10): vendored
README claimed GitHub auto-fetch + version-compare + hot-update behavior
that the implementation does not actually deliver at v0 (success path
only logs; no tarball extraction; no version-compare; no .version file
write). README + slice-close ACK language must match reality OR
extract/version-compare/update must be implemented.

Choosing truth-in-labeling path per IMPL-PRD §2.5 explicitly scoping
extract+install to slice 3.6 (marketplace-consumption phase) and per
orch direction 2026-05-10 (vendored is source of truth at v0; repo
currently empty per founder authorization; publish authorization
deferred).

REPAIR:

1. README rewrite:
   - 'At v0 (today)' explicitly framed: vendored copy is source of truth;
     copied to ~/.openrig/plugins/openrig-core/ on first run; offline-safe
     baseline; always available
   - 'Auto-fetch from GitHub at v0' explicitly framed as PROBE-ONLY:
     daemon hits releases tarball URL on launch with 5s timeout;
     **success path only logs the response and does NOT extract a tarball,
     compare versions, or update the local copy** — extract/version-
     compare/update scoped to slice 3.6
   - 404/network/timeout all tolerated silently with vendored remaining
     authoritative
   - 'Future (slice 3.6)' framed as the eventual hot-update path

2. New tests pin v0 contract (so any accidental extract-on-200 lands as
   TDD-red signal in slice 3.6, not silently in 3.2):
   - timeoutMs=5000 explicit assertion (per IMPL-PRD §2.5)
   - v0 success-path probe-only contract: even on 200, user plugin
     content unchanged + no .version file written

3. (Below — separate slice-close ACK packet authoring task) HG-2.3 status
   downgraded from 'fully green' to 'service-unit + startup-wire proof,
   not fresh daemon-start proof — fresh-daemon-start coverage requires
   QA/dogfood window'. HG-2.6/HG-2.7/HG-2.8 status flagged as 'live
   hook/UI behavior remains QA/dogfood proof'.

Verification:
- plugin-vendor-service.test.ts: 12/12 pass (10 existing + 2 new)
- All other 3.2 tests still green (openrig-core-plugin.test.ts: 25/25)
- Full daemon suite: 3208 pass; 1 fail (pre-existing rsi-v2)
- daemon tsc clean

Per velocity-guard BLOCKING-CONCERN on 67cafec + IMPL-PRD §2.5
(extract/install scoped to slice 3.6) + plugin-primitive Phase 3a
slice 3.2.
2026-05-10 04:56:13 -07:00
mvschwarz 67cafeca97 feat(daemon): PluginVendorService + daemon startup wire (slice 3.2 Phase 2)
Per IMPL-PRD §2.5 + DESIGN.md §5.5 + orch direction 2026-05-10. Makes
the 10 TDD-red tests in commit 63f7268 pass.

PluginVendorService (new packages/daemon/src/domain/plugin-vendor-service.ts):
- ensureVendored(name): copy vendored asset tree from
  packages/daemon/assets/plugins/<name>/ to ~/.openrig/plugins/<name>/.
  Idempotent — hash-skip when file content already matches per file.
  No-op when vendored asset doesn't exist.
- attemptAutoFetch(name): try to fetch latest from
  github.com/mvschwarz/openrig-plugins/releases/latest/download/<name>.tar.gz.
  Tolerates 404 + network errors + 5s timeout silently per orch direction
  (vendored is ALWAYS the fallback). Logs outcome for operator
  observability.
- ensureLatest(name): orchestrates vendored-first then fetch-attempt.
- Fully unit-testable — all fs ops + httpClient injectable.

Tarball extraction NOT implemented at v0 (success-path returns log;
when slice 3.6 lands marketplace-consumption, that's where extract logic
lives). Acceptable per IMPL-PRD §2.5 because repo is currently empty
+ founder authorization deferred publish; 404 is the expected normal-
state response.

Daemon startup wire (packages/daemon/src/startup.ts):
- After Codex feature flag setup, instantiate PluginVendorService with
  real fs + AbortController-bounded fetch (5s timeout) + console logger
- Call vendorService.ensureLatest('openrig-core') best-effort wrapped
  in try/catch (best-effort like reference docs vendoring; daemon does
  not fail to start on plugin vendoring problems)

Verification:
- plugin-vendor-service.test.ts: 10/10 pass
- Full daemon suite: 3206 pass; 1 fail (pre-existing rsi-v2 only)
- daemon tsc clean

This commit closes slice 3.2. ACK + walk packets in next commit; then
slice 3.4 BLOCKED on redo-driver-2's slice 3.3 (plugin-discovery-service
+ GET routes) per orch correction 2026-05-10.

Per IMPL-PRD §2 plugin-primitive Phase 3a slice 3.2 + per banked
feedback_tdd_scope (TDD red→green discipline).
2026-05-10 04:52:35 -07:00
mvschwarz 63f7268702 test(daemon): failing tests for PluginVendorService (slice 3.2 Phase 2)
Per IMPL-PRD §2.5 + DESIGN.md §5.5 + orch-lead 2026-05-10 (vendored
copy is source of truth at v0; auto-fetch tolerates 404 + vendored
fallback; repo currently empty per founder authorization).

10 tests, all currently FAIL (TDD red — PluginVendorService class
doesn't exist yet at packages/daemon/src/domain/plugin-vendor-service.ts).

Tests cover (HG-2.3, HG-2.4, HG-2.5):
- ensureVendored copies asset tree to user plugin dir on first launch
- ensureVendored idempotent (hash-skip when content matches)
- ensureVendored skips silently when asset doesn't exist
- attemptAutoFetch tolerates 404 silently (HG-2.5 explicit)
- attemptAutoFetch tolerates network errors silently (DNS/refused/etc)
- attemptAutoFetch tolerates 5s timeout silently
- attemptAutoFetch logs outcome (operator observability)
- attemptAutoFetch hits github.com/mvschwarz/openrig-plugins URL
- ensureLatest orchestrates vendored-first then fetch-attempt
- ensureLatest returns successfully even when fetch 404s + vendored exists

All test fixtures use injectable fs + httpClient mocks; service is
fully unit-testable without real network or filesystem.

Per IMPL-PRD §2 plugin-primitive Phase 3a slice 3.2 + per banked
feedback_tdd_scope (failing-tests-first; restored discipline).
2026-05-10 04:50:28 -07:00
mvschwarz 7e773347d6 fix(daemon): trim trailing whitespace in openrig-architect SKILL.md per velocity-guard 3.2.A concern #1
Per velocity-guard ADVISORY-OK-WITH-CONCERNS verdict on b98294b: 'git
diff --check 5e5644d..b98294b fails on trailing whitespace at packages/
daemon/assets/plugins/openrig-core/skills/openrig-architect/SKILL.md:48.
Clean it before close or the next repair pass.'

Single-line whitespace fixup. Source-of-truth advisor draft at
substrate/.../missions/plugin-primitive/openrig-core/skills/openrig-
architect/SKILL.md has the same issue and would re-introduce on next
asset re-copy; will surface to advisor for substrate-side cleanup
opportunistically (non-blocking; the asset is now the canonical source
post-3.2.A).

Verification:
- git diff --check 5e5644d..HEAD: clean
- openrig-core-plugin.test.ts still 25/25 pass (no shape change)
2026-05-10 04:49:02 -07:00
mvschwarz b98294b75e feat(daemon): copy openrig-core plugin tree to vendored assets location (slice 3.2 Phase 1)
SC-29 EXCEPTION #9 declared.

Per IMPL-PRD §2 + DESIGN.md §5.5 + plugin-primitive Phase 3a slice 3.2.
Makes the 25 TDD-red tests in commit 059825d pass.

Asset copy: substrate/.../missions/plugin-primitive/openrig-core/ →
packages/daemon/assets/plugins/openrig-core/

Vendored tree shape (per HG-2.1 + HG-2.2):
- LICENSE (Apache 2.0; public marketplace target)
- README.md
- .claude-plugin/plugin.json (Claude manifest; name + version + description
  + skills='./skills' + hooks='./hooks/claude.json' + repository
  github:mvschwarz/openrig-plugins)
- .codex-plugin/plugin.json (Codex manifest; required name+version+description;
  skills='./skills' + hooks='./hooks/codex.json')
- skills/ (11 canonical OpenRig skills, each with SKILL.md frontmatter:
  agent-startup-and-context-ingestion, claude-compact-in-place,
  claude-compaction-restore [+ scripts/], forming-an-openrig-mental-model,
  openrig-architect, openrig-operator, openrig-user, permission-posture,
  queue-handoff, seat-continuity-and-handover, session-compaction-and-restore)
- hooks/claude.json (4 events: SessionStart, UserPromptSubmit, Stop,
  Notification — Claude Code's full event surface)
- hooks/codex.json (3 events: SessionStart, UserPromptSubmit, Stop —
  Codex doesn't expose Notification)
- hooks/scripts/activity-relay.cjs (canonical relay script that POSTs
  to /api/activity/hooks endpoint; consumes the endpoint preserved in
  batch-1 D.2 rip per IMPL-PRD §1.2 endpoint discipline)

Cross-runtime path-substitution conventions used per runtime:
- Claude hooks reference ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs
- Codex hooks reference ${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs

Repository / publishing: vendored copy is the source of truth at v0.
Auto-fetch from github.com/mvschwarz/openrig-plugins (slice 3.2 Phase 2)
will tolerate 404 + fall back to vendored. Repo currently empty (LICENSE
only) per founder authorization 2026-05-10; separate publish authorization
deferred until post-batch-2 + post-0.3.0.

SC-29 EXCEPTION #9: this commit ships ~20 new vendored asset files at
packages/daemon/assets/plugins/openrig-core/ as the source-of-truth
plugin distribution surface. The plugin tree is daemon source-tree
inclusion (npm package ships these); not a ConfigStore key addition or
new HTTP endpoint or new SQL migration. Boundary: no daemon endpoint /
no event type / no DB migration; just packaged assets the daemon copies
to ~/.openrig/plugins/openrig-core/ on first launch (PluginVendorService
in next commit). Cumulative SC-29 ledger: #1-4 + project-observability
#5/#6 + redo-driver-2 declares #8 (slice 3.3 plugin-discovery-service +
GET routes) + this commit declares #9 (slice 3.2 vendored plugin assets +
PluginVendorService). #10 may be declared by slice 3.4 if CLI adds new
ConfigStore keys.

Verification:
- openrig-core-plugin.test.ts: 25/25 pass (TDD green)
- Tree contains 21 files (LICENSE + README + 2 manifests + 13 skill SKILL.md
  + 2 skill scripts + 2 hook configs + 1 relay script)

Per IMPL-PRD §2 plugin-primitive Phase 3a slice 3.2 + per banked
feedback_tdd_scope + feedback_sc29_declarations_in_commit_body.
2026-05-10 04:44:35 -07:00
mvschwarz 059825d704 test(daemon): failing tests for openrig-core plugin tree shape (slice 3.2 Phase 1)
Per IMPL-PRD §2 + DESIGN.md §5.5 + plugin-primitive Phase 3a slice 3.2.
25 tests, all currently FAIL (TDD red — vendored plugin tree at
packages/daemon/assets/plugins/openrig-core/ doesn't exist yet; substrate
draft at substrate/.../missions/plugin-primitive/openrig-core/ will be
copied + finalized in next commit).

Tests cover (HG-2.1, HG-2.2, partial HG-2.6/HG-2.7):
- Tree shape: plugin root exists; LICENSE (Apache 2.0); README.md; both
  .claude-plugin/ and .codex-plugin/ manifest dirs (Obra Superpowers shape)
- Manifest validation: name/version/description per Claude + Codex specs;
  skills + hooks pointing at correct paths; both manifests reference same
  skills/ subdir (cross-runtime portability)
- Skills: 11 expected skills present; each has SKILL.md with required
  frontmatter (name + description ≤1024 chars per agentskills.io spec);
  no drift from expected list
- Hooks: claude.json declares 4 events (incl Notification);
  codex.json declares 3 events (no Notification — Codex doesn't expose it);
  activity-relay.cjs script present + references /api/activity/hooks
  (the endpoint preserved in batch-1 D.2 rip per IMPL-PRD §1.2 endpoint
  discipline);  CLAUDE_PLUGIN_ROOT + CODEX_PLUGIN_ROOT path substitution
  conventions used per runtime
- Cross-batch integration: dual-manifest detection by batch-1's
  pluginAppliesToClaude/pluginAppliesToCodex filters under auto-detection

Next commit: copy substrate advisor draft to packages/daemon/assets/plugins/
openrig-core/ — should turn all 25 tests green.

Per IMPL-PRD §2 plugin-primitive Phase 3a slice 3.2 + per banked
feedback_tdd_scope (TDD-first; restored from batch-1 Phase A self-audit
gap).
2026-05-10 04:43:48 -07:00
mvschwarzandClaude Opus 4.7 aa6f7fc4c7 fix(daemon): surface skill-discovery rejection reasons through profile resolution
Forward-fix on the daemon-skill-discovery slice (609c6b3): when a
profile's resources.skills entry references a name whose dirname
matches a structurally-rejected SKILL.md, the resolver now surfaces
the precise rejection reason ("rejected — <reason> at <path>")
instead of the bare "not found in resource pool" error. Operators
see exactly which file and which check failed when a drop-in skill
isn't valid.

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

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

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

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

Refs: substrate slice ack + walk at <work-tree>/missions/bug-fix/slices/daemon-profile-resolver-skill-discovery/.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 04:38:12 -07:00
mvschwarz 5e5644ddc3 feat(daemon): HG-1.3 plugin manifest detection + plugin_type runtime applicability filtering (pre-close punch #2)
Per velocity-guard Checkpoint C carried concern + IMPL-PRD §1.3 HG-1.3 +
DESIGN.md §5.1 PluginResource.pluginType. Makes the 5 TDD-red tests in
commit 05c15c4 pass.

Three forms of plugin source dir + per-runtime filter:
  - Claude-only:  source has .claude-plugin/plugin.json only
  - Codex-only:   source has .codex-plugin/plugin.json only
  - Dual-manifest: source has BOTH (Obra Superpowers shape)

Per-runtime applicability rules (PluginResource.pluginType):
  - 'claude' → Claude adapter projects regardless of manifest presence;
    Codex adapter SKIPS
  - 'codex'  → Codex adapter projects regardless of manifest presence;
    Claude adapter SKIPS
  - 'auto' (or undefined) → adapter projects only if its runtime-specific
    manifest dir is present in the source tree

Implementation:
- ProjectionEntry interface gains pluginType?: 'claude' | 'codex' | 'auto'
  (only meaningful for category=plugin)
- projection-planner: extracts qr.resource.pluginType into entry.pluginType
  when building plugin entries (defaults to 'auto')
- ClaudeCodeAdapter.projectEntry: new pluginAppliesToClaude(entry) helper
  decides skip-vs-project before reaching resolveTargetDir; returns false
  for skip → project() puts entry in result.skipped[]
- CodexRuntimeAdapter.projectEntry: mirror — pluginAppliesToCodex helper
  with same logic against .codex-plugin/plugin.json

Drift discriminator (per banked feedback_poc_regression_must_discriminate):
3 plugins (claude-only, codex-only, dual) with same plan project distinctly
per adapter — Claude projects [claude-only, dual] + skips [codex-only];
Codex projects [codex-only, dual] + skips [claude-only]; cross-check no
plugin lands at the wrong runtime target.

Verification:
- plugin-manifest-detection.test.ts: 10/10 pass
- adapter-plugin-projection.test.ts: 7/7 pass (Checkpoint C unchanged —
  those fixtures use dual-manifest trees so detection still passes them
  through)
- Full daemon suite: 3171 pass; 1 fail (pre-existing rsi-v2)
- daemon tsc clean; ui tsc clean

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1 + DESIGN.md §5.1.
2026-05-10 03:58:51 -07:00
mvschwarz 05c15c4cad test(daemon): failing tests for HG-1.3 plugin manifest detection + plugin_type runtime applicability filtering (pre-close punch #2)
Per velocity-guard Checkpoint C carried concern + IMPL-PRD §1.3 HG-1.3 +
DESIGN.md §5.1 PluginResource.pluginType.

11 tests covering:
- Claude adapter: projects Claude-only + dual; SKIPS Codex-only (auto-detect)
- Claude adapter: explicit plugin_type=claude forces project even without
  matching manifest; explicit plugin_type=codex skips Claude even with
  matching manifest
- Codex adapter: same shape (projects Codex-only + dual; skips Claude-only)
- Drift discriminator: 3 plugins (claude-only, codex-only, dual) project
  distinctly per adapter; cross-check no plugin lands at wrong runtime target

5 currently FAIL (TDD red — need new filter logic):
- Claude SKIPS Codex-only auto-detect (currently projects)
- Claude SKIPS plugin_type=codex even with matching manifest (currently projects)
- Codex SKIPS Claude-only auto-detect
- Codex SKIPS plugin_type=claude
- Drift discriminator (currently both adapters project all 3 → wrong)

5 currently PASS (existing copy-regardless-of-manifest behavior happens to
match the projects-when-applicable cases for these fixtures).

Required impl in next commit:
- ProjectionEntry interface: add pluginType?: 'claude' | 'codex' | 'auto'
- projection-planner: read qr.resource.pluginType into entry.pluginType for
  category=plugin entries
- ClaudeCodeAdapter.projectEntry: skip plugin entries that don't apply to
  Claude (skip if pluginType=codex; skip if auto+no .claude-plugin/ dir)
- CodexRuntimeAdapter.projectEntry: skip plugins that don't apply to Codex
  (skip if pluginType=claude; skip if auto+no .codex-plugin/ dir)

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1 + DESIGN.md §5.1 +
banked feedback_poc_regression_must_discriminate (3-way drift discriminator).
2026-05-10 03:55:39 -07:00
mvschwarz 16e96747c3 fix(daemon+ui): replace stale hooks resource surface with plugins in spec-review (pre-close punch #1)
Per velocity-guard Checkpoint A finding (carried as pre-close punch #1
through Checkpoints B/C/D, address before Phase 3.1 close per their
explicit direction).

Stale hooks surface in spec-review path:
- packages/daemon/src/domain/spec-review-service.ts L78-83 +
  L333-338: AgentSpecReview.resources.hooks string[] field +
  extractPaths(rawResources['hooks']) extraction. Validated specs would
  400 before extraction (legacy hooks rejected in Checkpoint A) but the
  REVIEW SHAPE itself still advertised hooks instead of plugins, so any
  consumer of the review API saw the wrong field.
- packages/ui/src/hooks/useSpecReview.ts L66-71: AgentSpecReview type
  duplicate (UI side) had same hooks: string[] field.
- packages/ui/src/components/AgentSpecDisplay.tsx L57+L110-114:
  fallback default + Hooks rendering block; no Plugins row at all.
- packages/ui/src/components/LibraryReview.tsx L71: same fallback
  default with hooks: [] absent plugins: [].

Replacement: plugins replaces hooks in all four locations. Plugin entries
have a different shape (id + source: { kind, path }), so the daemon
extractor uses extractPluginIds() returning the operator-recognizable
plugin id (e.g. 'openrig-core') rather than a path. UI Plugin row
mirrors the Skills row visual treatment (chip-shaped, ToolMark icon).

Verification:
- daemon tsc --noEmit: clean
- ui tsc --noEmit: clean
- spec-review-service.test.ts: 8/8 pass
- Full daemon suite: 3161 pass; 1 fail (pre-existing rsi-v2)
- rg 'review.*resources.*hooks|hooks: \[\]' on packages/ui/src + packages/daemon/src/domain/spec-review-service.ts: zero matches (only the agent-manifest validator's rejection-error string mentions resources.hooks by name, which is intentional — operator must see the deprecated field name in the migration error)

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1 + velocity-guard
Checkpoint A finding.
2026-05-10 03:53:45 -07:00
mvschwarz 10b3b4f2c7 fix(cli+daemon): Checkpoint D repair — CLI test fixture + RiggedConfig structured-config + EOF blank line
Per velocity-guard BLOCKING-CONCERN on 61e3ca6 (2026-05-10): Checkpoint D
claimed daemon+cli lockstep but proof only included CLI typecheck, not CLI
tests. Two real CLI regressions caught + 1 non-blocker cleanup.

REPAIRS:

1. CLI ConfigStore VALID_KEYS test fixture (BLOCKER):
   packages/cli/test/config-store-extended.test.ts L72-94 expected list of
   27 keys; was missing runtime.codex.hooks_enabled added in 6cb24cc.
   Updated to include the new key. Test now passes.

2. CLI RiggedConfig structured-config + ConfigStore.resolve() (BLOCKER):
   The interface + resolve() builder was missing the runtime.codex.hooksEnabled
   field even though DEFAULTS/VALID_KEYS/ENV_MAP/KEY_TO_PATH had the entry.
   This meant rig config --json would not include the resolved boolean in
   its structured output, breaking the lockstep contract.

   Added per velocity-guard's recommended shape:
   - RiggedConfig interface: runtime: { codex: { hooksEnabled: boolean } }
   - ConfigStore.resolve() return: same structured shape

3. EOF blank line in claude-code-adapter.ts (non-blocker cleanup):
   git diff --check flagged 'blank line at end of file' on the post-rip
   tree (one trailing \n too many after removing trailing helpers).
   Trimmed to single terminal newline.

CORRECTION TO PRIOR COMMIT BODY 61e3ca6:
The body claimed 'packages/cli/daemon/assets/openrig-activity-hook-relay.cjs
DELETED' but that path did not exist on base c0fcccd (no prior mirror to
delete). Single asset deletion in 61e3ca6 is correct (packages/daemon/assets/
openrig-activity-hook-relay.cjs); the CLI mirror was a phantom claim from
the IMPL-PRD dispatch list.

PROOF COMMANDS + OUTPUT:

1. Focused CLI config test:
$ cd packages/cli && npx vitest run test/config-store-extended.test.ts
Test Files  1 passed (1) | Tests  40 passed (40)

2. Focused D daemon tests:
$ cd packages/daemon && npx vitest run test/codex-hooks-feature-flag.test.ts test/activity-hook-rip-proof.test.ts
Test Files  2 passed (2) | Tests  15 passed (15)

3. Full daemon suite:
Tests: 1 failed | 3161 passed | 1 skipped (pre-existing rsi-v2 only)

4. git diff --check (no whitespace issues):
(empty — clean)

Per velocity-guard verdict on 61e3ca6 + IMPL-PRD §5 plugin-primitive
Phase 3a slice 3.5.
2026-05-10 03:49:47 -07:00
mvschwarz 61e3ca63fb feat(daemon): rip activity-hook injection from both adapters; delete relay assets; wire ensureCodexFeatureFlag in startup (Checkpoint D.2)
Per IMPL-PRD §1.2 + DESIGN.md §3 (no-coexistence; rip-and-replace) +
velocity-guard cadence boundary (d). Makes the 2 TDD-red grep-source
absence tests in commit 879b405 pass.

Activity-hook injection RIPPED from both adapters:
- claude-code-adapter.ts: removed provisionActivityHooks() method,
  upsertCommandHook + hookEntryContainsCommand helpers, activityHookRelayAssetPath
  constructor option + private field + call site in deliverStartup,
  unused shellQuote import
- codex-runtime-adapter.ts: same — removed provisionActivityHooks() +
  upsertCommandHook + hookEntryContainsCommand + activityHookRelayAssetPath
  constructor option + field + call site. upsertCodexHooksFeature TOML
  helper KEPT (slice 3.5 ensureCodexFeatureFlag uses it; ensure no
  shipped behavior broken)

Asset deletion:
- packages/daemon/assets/openrig-activity-hook-relay.cjs DELETED
- packages/cli/daemon/assets/openrig-activity-hook-relay.cjs DELETED
- packages/daemon/test/activity-hook-relay.test.ts DELETED entirely

Test fixture cleanup:
- claude-runtime-adapter.test.ts: removed 'provisions project-local Claude
  hooks without clobbering existing local settings or persisting the hook
  token' test. Replacement coverage in activity-hook-rip-proof.test.ts.
- codex-runtime-adapter.test.ts: removed 'provisions project-local Codex
  hooks and feature flag without persisting the hook token' test. Same.

Daemon startup wire-up:
- Removed activityHookRelayAssetPath construction line + adapter constructor
  args
- Added codex feature flag setup: read runtime.codex.hooks_enabled setting,
  call codexAdapter.ensureCodexFeatureFlag(enabled) (slice 3.5; replaces
  the activity-hook-coupled feature-flag set)
- Added homedir to Codex fsOps so ensureCodexFeatureFlag can write to
  ~/.codex/config.toml

Endpoint discipline maintained per IMPL-PRD §1.2 + DESIGN.md §3:
- /api/activity/hooks endpoint STAYS (POST /hooks in src/routes/activity.ts)
- agent-activity-store ingestion path STAYS (plugin-shipped hooks in
  slice 3.2 will POST to it)

Verification:
- activity-hook-rip-proof.test.ts: 8/8 pass (negative-assertion regression
  locks + grep-source absence proof + endpoint-stays proof)
- codex-hooks-feature-flag.test.ts: 7/7 pass (slice 3.5 HG-5.1-5.4 unchanged)
- adapter-plugin-projection.test.ts: 7/7 pass (Checkpoint C unchanged)
- projection-planner-plugins.test.ts: 10/10 pass (Checkpoint B unchanged)
- agent-manifest-plugins.test.ts: 16/16 pass (Checkpoint A unchanged)
- Full daemon suite: 3161 pass; 1 fail (pre-existing rsi-v2)
- rg 'provisionActivityHooks|upsertCommandHook|hookEntryContainsCommand|
   activityHookRelayAssetPath|openrig-activity-hook-relay' packages/ src/:
   ZERO production-source matches; only test/ references in rip-proof +
   removed-test comments

Per IMPL-PRD §1 + §5 plugin-primitive Phase 3a slices 3.1 + 3.5.
2026-05-10 03:43:55 -07:00
mvschwarz 879b405c30 test(daemon): failing rip-proof tests for activity-hook injection (Checkpoint D.2)
Per velocity-guard cadence boundary (d) Checkpoint D.2. 8 tests, 2 currently
FAIL (TDD red — grep-source absence assertions on adapter symbols that
will be removed in next commit).

Tests:
1. HG-1.5 Claude — deliverStartup does NOT create .openrig/activity-hook-
   relay.cjs (currently passes vacuously because mock fs lacks
   activityHookRelayAssetPath; test serves as regression-lock for after rip)
2. HG-1.5 Claude — deliverStartup does NOT add OpenRig hook entries to
   settings.local.json (regression-lock)
3. HG-1.5 Claude — pre-existing user-authored hooks PRESERVED untouched
4. HG-1.6 Claude (FAILING) — adapter source no longer references
   provisionActivityHooks / upsertCommandHook / hookEntryContainsCommand /
   activityHookRelayAssetPath / openrig-activity-hook-relay
5-7. Same for Codex
8. HG-1.6 Codex (FAILING) — same source-absence assertions; PLUS
   upsertCodexHooksFeature STAYS (slice 3.5 ensureCodexFeatureFlag uses it)
9. /api/activity/hooks endpoint stays (POST /hooks in routes/activity.ts —
   regression-lock that endpoint discipline holds)

Honest about test discrimination: tests 1+2+3+5+6 currently pass on
unripped code BECAUSE the test mocks don't pass activityHookRelayAssetPath
(which is the trigger for activity-hook injection). They serve as
post-rip regression locks rather than red→green TDD on this commit. The
load-bearing red→green is the grep-source absence (tests 4 + 8) which
turn green after the rip removes the symbols from source.

Daemon tsconfig excludes test/ so @ts-expect-error directives wouldn't
get type-checked; switched to source-grep assertions for verifiable
absence.

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1 + DESIGN.md §3
(no-coexistence; rip-and-replace).
2026-05-10 03:39:31 -07:00
mvschwarz 6cb24cce19 feat(daemon+cli): slice 3.5 — runtime.codex.hooks_enabled setting + ensureCodexFeatureFlag adapter method
Per IMPL-PRD §5 + velocity-guard cadence boundary (d) Checkpoint D.1.
Makes the 7 TDD-red tests in commit 6f68042 pass.

Settings (CLI ConfigStore + daemon settings-store, lockstep):
- New SETTINGS_VALID_KEYS entry: runtime.codex.hooks_enabled (default true)
- ENV_MAP: OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED primary; legacy '' empty
  per banked feedback_no_rigged_legacy_for_new_keys.md (net-new key
  post-rename; no RIGGED_X cargo-cult)
- KEY_TO_PATH: ['runtime', 'codex', 'hooksEnabled']
- DEFAULTS (CLI side) + getDefaultValue switch (daemon side): both return
  true

CodexRuntimeAdapter:
- New ensureCodexFeatureFlag(enabled: boolean) method
- enabled=true → idempotent write of codex_hooks = true under [features]
  in ~/.codex/config.toml; preserves any existing config content;
  reuses existing upsertCodexHooksFeature TOML helper
- enabled=false → ZERO mutation (operator owns Codex config)
- Skips silently if no homedir available in fsOps

Existing settings-test fixtures updated:
- user-settings-store.test.ts: SETTINGS_VALID_KEYS expected list +1
- user-settings-routes.test.ts: settings count 27 → 28

Verification:
- codex-hooks-feature-flag.test.ts: 7/7 pass
- Full daemon suite: 3158 pass; 1 fail (pre-existing rsi-v2)

NOT YET in this commit (Checkpoint D.2):
- Activity-hook injection rip from both adapters (provisionActivityHooks,
  upsertCommandHook, hookEntryContainsCommand) — old upsertCodexHooksFeature
  TOML helper stays for now since ensureCodexFeatureFlag reuses it; rip
  removes the activity-hook coupling site
- Asset deletion (openrig-activity-hook-relay.cjs)
- Daemon startup wire-up: replace activityHookRelayAssetPath wiring with
  ensureCodexFeatureFlag(setting) call

Per IMPL-PRD §5 plugin-primitive Phase 3a slice 3.5.
2026-05-10 03:36:16 -07:00
mvschwarzandClaude Opus 4.7 609c6b39c9 fix(daemon): discover skills at runtime-truth filesystem paths during profile resolution
Closes the gap where the profile resolver only saw skills declared in
agent.yaml's resources.skills + imports — not skills present at the
runtime-truth filesystem locations the harness already loads from.
Operators can now drop any structurally-valid skill at the
user-library or rig-bundled paths and have it resolve through profile
validation, not just at runtime.

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

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

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

Refs: substrate slice ack + walk at <work-tree>/missions/bug-fix/slices/daemon-profile-resolver-skill-discovery/.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-10 03:35:33 -07:00
mvschwarz 6f6804267e test(daemon): failing tests for slice 3.5 Codex feature flag (HG-5.1-5.4)
Per IMPL-PRD §5 + velocity-guard cadence boundary (d). 7 tests, all
currently FAIL (TDD red). Replaces activity-hook-injection-coupled
upsertCodexHooksFeature() being removed in next commit.

7 tests cover:
- HG-5.1: runtime.codex.hooks_enabled is in SETTINGS_VALID_KEYS
- HG-5.2: ensureCodexFeatureFlag(true) writes codex_hooks = true to
  ~/.codex/config.toml when file missing AND when file has prior content
  (preserves prior content)
- HG-5.3: idempotent (running twice doesn't duplicate); pre-existing
  [features] block with other flags is preserved when adding codex_hooks
- HG-5.4: ensureCodexFeatureFlag(false) does NOT touch ~/.codex/config.toml
  (file missing OR existing — user owns it)

Next commit:
- Add 'runtime.codex.hooks_enabled' to SETTINGS_VALID_KEYS + ENV_MAP +
  KEY_TO_PATH (and lockstep CLI ConfigStore)
- Add ensureCodexFeatureFlag(enabled: boolean) method on
  CodexRuntimeAdapter (reuses existing upsertCodexHooksFeature TOML
  helper that's about to move from coupled location)
- Wire into daemon startup

Per IMPL-PRD §5.x + plugin-primitive Phase 3a slice 3.5.
2026-05-10 03:32:00 -07:00
mvschwarz fc73fd345f test(daemon): adapter plugin directory projection — regression codification (all 7 pass on first run)
Per velocity-guard cadence boundary (c) — Checkpoint C test pass: prove
the runtime adapters copy a real plugin tree (nested .claude-plugin/ +
skills/ + hooks/ subdirs) to <cwd>/.claude/plugins/<id>/ on Claude and
<cwd>/.codex/plugins/<id>/ on Codex.

CHECKPOINT C TDD HONESTY:
All 7 tests PASS on first run with no impl change. This is regression-
codification, NOT classic red→green TDD. The existing projectEntry
recursive-copy logic in commit 88ee79c was already general-purpose
enough to handle nested plugin trees, because:
1. Production fs.listFiles() walks subdirs recursively, returning
   relative paths with their nested prefix (e.g. 'skills/foo/SKILL.md').
2. projectEntry's loop uses nodePath.join(targetDir, file) which
   preserves the nested structure.
3. mkdirp(nodePath.dirname(dest)) ensures parent dirs exist before
   the per-file write.

The new test file LOCKS IN this contract so future refactoring of
the copy logic catches plugin-shape regressions.

7 tests cover (per velocity-guard category 5):
- Claude: copies entire plugin tree to .claude/plugins/<id>/ preserving
  nested .claude-plugin/, .codex-plugin/, skills/<id>/SKILL.md (×2),
  hooks/{claude,codex}.json, hooks/scripts/relay.cjs, README.md
- Claude: drift discriminator — plugin lands at .claude/plugins/, NOT
  .claude/skills/; nested skill stays inside plugin tree
- Claude: multi-plugin isolation — each plugin in its own <id> subdir
- Claude: idempotent re-projection (hash match → 0 writes)
- Codex: same nested copy to .codex/plugins/<id>/
- Codex: drift discriminator — lands at .codex/plugins/, NOT .agents/skills/
- Cross-runtime: same plugin source projects to distinct .claude/plugins
  vs .codex/plugins targets per adapter (per banked
  feedback_poc_regression_must_discriminate)

Verification:
- adapter-plugin-projection.test.ts: 7/7 pass
- Full daemon suite: 3151 pass; 1 fail (rsi-v2 pre-existing, not my regression)

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1.
2026-05-10 03:27:40 -07:00
mvschwarz b74b1c5d48 fix conveyor release cli paths 2026-05-10 03:25:33 -07:00
mvschwarz 19ca85d7e9 feat(daemon): tilde expansion for plugin source.path in projection planner
Per velocity-guard cadence boundary (b) — Checkpoint B impl: makes the
3 TDD-red tests in commit 6e6450a pass.

projection-planner.ts:
- New helper resolvePluginPath(rawPath, specSourcePath) handles 3 forms:
  - bare '~' → os.homedir() exactly
  - '~/X' (with slash) → nodePath.join(os.homedir(), 'X')
  - absolute (/abs/...) → preserved exactly via nodePath.isAbsolute
  - relative (plugins/X) → nodePath.resolve(specSourcePath, 'plugins/X')
- '~user/X' (explicit username) NOT expanded — treated as literal
  relative segment per Node convention. Test asserts this behavior.
- Plugin entry-build loop now calls resolvePluginPath() instead of
  inline isAbsolute/resolve dance.

Verification:
- test/projection-planner-plugins.test.ts: 10/10 pass
- Full daemon suite: 3144 pass; 1 fail (rsi-v2 pre-existing, not my regression)

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1.
2026-05-10 03:23:15 -07:00
mvschwarz 6e6450af06 test(daemon): failing tests for projection-planner plugin path semantics
Per velocity-guard cadence boundary (b) — Checkpoint B test pass: tilde,
absolute, relative path resolution for plugin source.path.

10 tests total, 3 currently FAIL (TDD red), 7 currently PASS (existing
absolute + relative behavior already works via nodePath.isAbsolute() +
nodePath.resolve() in projection-planner.ts plugin entry-build loop).

FAILING (need new impl — tilde expansion):
- tilde-prefixed plugin source.path expands to $HOME absolute path
- bare tilde plugin source.path expands to $HOME exactly
- three plugins with three different path shapes produce distinct
  absolutePaths (drift discriminator per banked
  feedback_poc_regression_must_discriminate)

PASSING (existing behavior verified):
- absolute plugin source.path yields entry.absolutePath = same absolute
- absolute path is NOT re-resolved against spec sourcePath
- tilde-with-username (~user/...) preserved as-is per Node convention
- relative plugin source.path resolves against qr.sourcePath
- ./-prefixed relative plugin source.path resolves correctly
- plugin entry preserves category 'plugin' + effectiveId
- entries deterministically sorted (plugin sorts before skill)

Next commit makes the 3 red tests green via tilde expansion in the
plugin entry-build loop.

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1.
2026-05-10 03:22:14 -07:00
mvschwarz 2f561633ec curate bundled starter skills 2026-05-10 03:19:51 -07:00
mvschwarz aab5794831 feat(daemon): strict reject legacy resources.hooks + profile.uses.hooks; clean all fixtures + shipped specs
Per velocity-guard ADVISORY-OK-WITH-CONCERNS verdict 2026-05-10 +
redo-guard-2 prior BLOCKING-CONCERN: silent-drop is not adequate
backward-compat. Strict reject including empty arrays. Clean cleanup
over deprecated-warn coexistence path.

Validator changes (agent-manifest.ts):
- resources.hooks present (any value, including empty []) → explicit
  rejection with migration error pointing operator at resources.plugins[]
- profile.${name}.uses.hooks present → same shape rejection pointing at
  profile.${name}.uses.plugins[]
- Profile uses validation de-nested out of the resources gate so it runs
  on profile-only specs (caught by failing test 'unqualified
  profile.uses.plugins reference to undeclared plugin fails')

Test fixtures cleaned (no legacy hooks: [] or hooks: <items>):
- agent-manifest.test.ts VALID_SPEC: replaced resources.hooks block
  with resources.plugins (openrig-core local source) + profile.uses
  hooks→plugins
- agent-manifest.test.ts L381 imported-skill profile uses: hooks→plugins
- agent-resolver.test.ts L9 inline YAML helper: hooks→plugins
- agentspec-startup.integration.test.ts L217 hooks: [] removed
- pod-rigspec-instantiator.test.ts L200 hooks: [] removed

Shipped agent specs cleaned (8 files, all had hooks: [] empty
placeholders per blast-radius research):
- specs/agents/research/{analyst,synthesizer}/agent.yaml
- specs/agents/design/product-designer/agent.yaml
- specs/agents/development/{qa,implementer}/agent.yaml
- specs/agents/review/independent-reviewer/agent.yaml
- specs/agents/orchestration/orchestrator/agent.yaml
- specs/agents/apps/vault-specialist/agent.yaml

Out-of-scope (NOT touched in this commit; different namespace):
- packages/daemon/test/install-planner.test.ts hooks: in PACKAGE
  manifest exports.hooks (not AgentSpec resources.hooks)
- packages/daemon/test/conflict-detector.test.ts same
- packages/daemon/test/role-resolver.test.ts hooks: in role-resolver
  spec format (rolepack, not AgentSpec)
- claude-runtime-adapter.test.ts + codex-runtime-adapter.test.ts hooks:
  blocks (testing native runtime hook event JSON shape, not OpenRig
  hook resources)
- restore-check-{service,routes}.test.ts (testing Claude PreCompact
  hook integration which is its own system)
Per velocity-guard cadence boundary (a) — these are scoped to later
work or out-of-scope for plugin-primitive entirely.

Doc tension flag for orch-lead/founder: IMPL-PRD HG-1.2 wording is
'AgentSpec WITHOUT hooks[] field still validates (hooks field is gone
OR ignored gracefully)'. velocity-guard interpretation per checkpoint
verdict overrides 'ignored gracefully' with strict-reject because:
(a) DESIGN.md §3.2 no-coexistence rule; (b) deprecated-warn would
hide the exact drift this slice removes; (c) prior redo-guard-2
verdict explicitly endorsed strict.

Verification:
- All 16 new agent-manifest-plugins.test.ts tests pass (4 cover legacy
  hooks rejection)
- Full daemon suite: 3134 pass; 1 fail (rsi-v2-starter-spec-content,
  pre-existing on base c0fcccd — confirmed via stash test in earlier
  commit history; not my regression)
- bootstrap-orchestrator test passes in isolation; appears flaky/
  state-polluted in full-suite run (also not my regression — confirmed
  by isolated run after this commit)

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1; per banked
feedback_tdd_scope (TDD-restored after Phase A self-audit gap).
2026-05-10 03:18:02 -07:00
mvschwarz 7adc2f5676 refresh product-team starter 2026-05-10 03:16:41 -07:00
mvschwarz 12018477d2 seed fresh install conveyor workspace 2026-05-10 03:11:18 -07:00
mvschwarz 7f06bf9036 add conveyor starter workflows 2026-05-10 03:04:34 -07:00
mvschwarz a2765a4919 test(daemon): failing tests for plugin support — explicit rejection of legacy hooks fields
Per redo-guard-2 BLOCKING-CONCERN verdict 2026-05-10: silent-drop of legacy
hooks fields by normalizer is not adequate backward-compat — operator must
get clear error pointing at the migration target (plugins).

16 tests across 3 categories (per redo-guard-2's 5 specified categories;
adapter projection and projection-planner-plugin-path covered by separate
test commits to follow):

Category 1 — REJECT LEGACY HOOKS (4 tests, all currently FAIL — TDD red):
- rejects legacy resources.hooks with clear migration error
- rejects legacy profile.uses.hooks with clear migration error
- legacy hooks rejection error names plugins as the migration target
- (4th is the empty-array case which still rejects)

Category 2 — ACCEPT/NORMALIZE PLUGIN RESOURCES (8 tests, currently
PASS — already implemented in commit 88ee79c):
- accepts resources.plugins with id + source.kind=local + source.path
- accepts plugin_type field (claude/codex/auto)
- rejects plugin entry missing id / missing source / unsupported source.kind
  (only 'local' at v0) / empty source.path
- rejects duplicate plugin ids
- rejects invalid plugin_type value
- plugin source.path may be absolute (operator-managed plugin location)

Category 3 — PROFILE USES.PLUGINS RESOLUTION (4 tests, 3 pass + 1
FAIL — TDD red):
- profile.uses.plugins references resolve against declared plugin pool
- unqualified profile.uses.plugins reference to undeclared plugin fails
  (FAILING — validator currently doesn't flag missing plugin refs)
- normalize produces empty plugins[] when not declared
- normalize produces empty profile.uses.plugins[] when not declared in profile

Run: 4 fail, 12 pass. Failing tests are exactly the gap redo-guard-2
identified. Next commit makes them green via validator changes.

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1; per banked
feedback_tdd_scope (TDD discipline restoration after Phase A self-audit
gap).
2026-05-10 02:59:55 -07:00
mvschwarz 1e749164e2 stage private RSI workflow recipe
Remove the bundled rsi-v2-hot-potato workflow spec and its recipe-pinning tests while keeping the workflow runtime, hot-potato primitives, and user-file workflow paths intact.

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

Dogfood evidence: <work-tree>/missions/recursive-self-improvement-v2/dogfood-evidence/release-0.3.0-slice-01-rsi-recipe-staging-20260510T024342/REPORT.md
2026-05-10 02:57:02 -07:00
mvschwarz a4b8c298e6 test(daemon): swap empty hooks: [] for plugins: [] in resolver+planner fixtures
Fixture-only updates after the resources.plugins[] / profile.uses.plugins[]
schema swap landed in 88ee79c:

profile-resolver.test.ts: 19 hooks: [] → plugins: [] occurrences across
inline AgentSpec + ProfileSpec.uses fixtures.

projection-planner.test.ts: emptyResources() helper updated; deterministic-
output T10 fixture replaced hook-z with plugin-z (using new makePluginQR
helper that builds the QualifiedResource shape with source: { kind, path }
not just path). Sort-order assertion adjusted: plugin (p) sorts before
skill (s) alphabetically.

Net: 26 test files affected; 26/26 prior failures clear; full daemon
suite drops from 26 failures to 1 (rsi-v2-starter-spec-content — pre-
existing on base c0fcccd; not in scope for this slice).

Per IMPL-PRD §1.2.
2026-05-10 02:54:33 -07:00
mvschwarz 88ee79cac1 feat(daemon): add resources.plugins[] + profile.uses.plugins[]; remove resources.hooks[]
types.ts:
- Add PluginResource interface + PluginSource union (kind: local at v0)
- Add AgentResources.plugins[]; remove AgentResources.hooks[]
- Add ProfileSpec.uses.plugins[]; remove ProfileSpec.uses.hooks[]
- Remove HookResource interface

agent-manifest.ts:
- Validator iterates plugins category in place of hooks
- normalizeResources returns plugins[]; drops hooks[]
- New validatePluginResources + normalizePluginResource (different shape:
  id + source object, not id + path)
- Plugin paths NOT subject to in-spec safe-path check (operator-managed
  absolute paths permitted: ~/.openrig/plugins/<id>/, etc.)

profile-resolver.ts:
- RESOURCE_CATEGORIES + YAML_CATEGORY_MAP swap hooks → plugins
- ResolvedResources type swap; resolveProfileUses + buildResourcePool
  carry plugin entries through pool

projection-planner.ts:
- ProjectionEntry.category swap hook → plugin
- CATEGORY_MAP swap
- Plugin source.path extraction in entry-build loop (different shape
  from skill/guidance entries)
- checkAmbiguity covers plugins category

agent-resolver.ts:
- RESOURCE_CATEGORIES swap hooks → plugins

claude-code-adapter.ts + codex-runtime-adapter.ts:
- resolveTargetDir swap case 'hook' → case 'plugin'
- Claude target: <cwd>/.claude/plugins/<id>/
- Codex target: <cwd>/.codex/plugins/<id>/

Adapter projectEntry hook-injection methods (provisionActivityHooks,
upsertCommandHook, hookEntryContainsCommand) NOT YET removed in this
commit — that's the next pass after tests confirm baseline rip is sound.

Tests still need updating in next commit; daemon tsc clean at this checkpoint.

Per IMPL-PRD §1 plugin-primitive Phase 3a slice 3.1.
2026-05-10 02:52:05 -07:00
mvschwarz c0fcccd102 fix(ui): show context metrics in topology views
Adds context percentage and token totals to topology table and terminal views using the existing node inventory context payload. Keeps pod table scoping aligned with pod pages and refreshes inventory polling with graph cadence.

Verification: focused UI tests, UI build, lint, and test:repo pass.
2026-05-09 04:36:20 -07:00
mvschwarz 279ced303e fix(daemon): backfill codex context for detached sessions 2026-05-09 04:06:07 -07:00
mvschwarz 4753db5834 feat(daemon): surface codex context usage 2026-05-09 03:58:42 -07:00
mvschwarz 01d10bdbaa feat(ui): reduce for-you badge overload 2026-05-09 03:40:32 -07:00
mvschwarz 1896a0e885 feat(ui): clarify human action workflow graphics 2026-05-09 03:24:22 -07:00
mvschwarz be959d9199 feat(ui): add artifact graphics vocabulary 2026-05-09 03:10:27 -07:00
mvschwarz 97780fae92 feat(ui): add project event and queue state badges 2026-05-09 01:47:49 -07:00
mvschwarz 0c7d106efb feat(ui): broaden graphics marks in library specs 2026-05-09 01:13:44 -07:00
mvschwarz db7fb17a5a test(ui): stabilize jsdom storage setup 2026-05-09 01:03:29 -07:00
mvschwarz 5ce3da3815 feat(ui): extend graphics marks to library flows 2026-05-09 01:01:12 -07:00
mvschwarz 3bc3620d78 feat(ui): extend graphics marks to spec surfaces 2026-05-09 00:59:43 -07:00
mvschwarz 6c4a283a27 fix(ui): polish library skill explorer 2026-05-09 00:58:03 -07:00
mvschwarz 3db619b7ea Route library skills to focused detail pages 2026-05-09 00:48:20 -07:00
mvschwarz 24e12c887e Hide compact terminal preview scrollbars 2026-05-09 00:48:14 -07:00
mvschwarz f84cb69b6c merge(post-V1): graphics-package-broaden
Integrates the shared graphics broaden pass after guard and VM verification.

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

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

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

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

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

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

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

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

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

Mechanism comparison and rationale at
<work-tree>/lab/skill-authoring-techniques/
findings/skills-hub-mirror-mechanism-comparison.md.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Envelope shape:

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

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

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

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

SC-29 EXCEPTION #4 continues for this slice.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

═══ COMMIT ═══
- a1b7fd7 vellum + black-glass aesthetic refinement

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

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

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

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

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

═══ COMMIT ═══
- 0a452aa portal terminal preview popover

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

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

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

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

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

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

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

═══ COMMIT ═══
- 4c52eb5 compact terminal popover

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Scope = 8 dispatch items bundled into one canvas component:

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

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

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

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

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

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

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

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

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

═══ COMMIT CHAIN (5 SHAs; post-style-rule rebase) ═══
- cea7e4a P5.1-1+1a+2 agent detail dedup + layout + graph nav
- 3b2499e P5.1-3 pod name truncation fix
- c320e2b P5.1-7 table CMUX column + row click navigation
- c835c1b P5.1-6 rig overview tab → RigSpecDisplay
- 057bf91 P5.1-5 pod-scope graph view

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

═══ COMMIT CHAIN (9 SHAs) ═══
- 079d1e2 P5-4 NodeDetailPanel drawer migration
- 86208da P5-1 trigger site-of-use wiring (closes Phase 4 P4-2 deferral)
- 71f1ed4 P5-2 slice tab content piping (per velocity-qa routing; fold mapping documented)
- de495ed P5-5 + P5-6 filesystem mission discovery + live PROGRESS.md status
- 8541921 P5-3 subscription toggle wiring (SC-29 allowlist exception)
- 49a9a30 P5-7 topology terminal pagination + pulsing-ring
- 7b2aba2 P5-8 HumanSeatCard VellumCard refactor
- f56d947 P5-9 mobile responsive polish
- 789abc5 V1 ship-gate bounce fix (P0-1 mobile rules-of-hooks; P0-2 NodeDetailPanel orphan whitespace)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Tests + visual verified at /topology 1440x900.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

CLASS A — 5 bug fixes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Founder walk packet authored at:
<work-tree>/missions/recursive-self-improvement-v2/
shell-redesign-v1/founder-walks/phase-2-walk.md

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Founder walk packet authored at:
<work-tree>/missions/recursive-self-improvement-v2/
shell-redesign-v1/founder-walks/phase-1-walk.md

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

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

Changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

TSC clean across daemon/cli/ui.

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

Permission posture: fleet-ok throughout.

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

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

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

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

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

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

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

TSC clean across daemon/cli/ui.

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

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

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

Items shipped:

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

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

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

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

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

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

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

Schema:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NO new SQLite schema, migrations, or event types.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NO new SQLite schema, migrations, or event types.

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

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

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

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

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

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

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

NO new SQLite schema, migrations, or event types.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  export OPENRIG_FILES_ALLOWLIST="workspace:<internal-docs>/<work-tree>,hub:<user-home>/code/projects/openrig-hub"
  export OPENRIG_PROGRESS_SCAN_ROOTS="workspace:<internal-docs>/<work-tree>"
  export OPENRIG_STEERING_WORKSPACE="<internal-docs>/<work-tree>"
  rig daemon restart

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  export OPENRIG_FILES_ALLOWLIST="workspace:<internal-docs>/<work-tree>,hub:<user-home>/code/projects/openrig-hub"
  export OPENRIG_PROGRESS_SCAN_ROOTS="workspace:<internal-docs>/<work-tree>"
  rig daemon restart

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

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

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

Daemon (4 new files + 3 edits):

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

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

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

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

UI (1 new file + 4 edits):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

R1 fix:

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

R1 regression tests (4 new total):

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

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

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

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

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

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

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

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

Daemon domain (4 new files, 1 new subdirectory):
  - mission-control-action-log: append-only writer; verb-specific
    required-field validation (annotate→annotation; hold/drop→reason).
    API surface lacks update/delete/remove (contract enforcement).
  - mission-control-write-contract: LOAD-BEARING. Atomic 7-verb
    coordinator. Each verb is one db.transaction: queue mutation via
    Phase D's QueueRepository.updateWithinTransaction (preserves Phase A
    hot-potato closure validation; emits queue.updated) + audit row +
    persisted mission_control.action_executed event. The 4-step handoff
    shape (source-update + destination-create + opt-in best-effort
    notify + audit append) is verified atomic; notify failure does NOT
    roll back durable mutations (PRD invariant). Failure-injection
    rollback test included.
  - mission-control-read-layer: maps 7 views to source-of-truth paths.
    PL-004 daemon-backed services as primary; filesystem only for
    graceful degradation. recently-active delegates to PL-004 Phase B
    ViewProjector.show("recently-active"). recent-observations reads
    PL-004 Phase A stream_items via StreamStore.
  - mission-control-fleet-cli-capability: per-rig CLI capability cache
    + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source
    Drift Acceptance (per-field availability, per-rig honesty,
    once-per-session-per-rig logging, "rigs running stale CLI"
    indicator).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-03 18:35:08 -07:00
mvschwarz a5bc64094a docs: update post-release references 2026-05-03 16:39:10 -07:00
mvschwarzandClaude Opus 4.7 401f260587 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 0eed69605f fix: include workflow keepalive in watchdog help 2026-05-03 13:11:02 -07:00
mvschwarz df20855445 fix: infer queue actor from managed session 2026-05-03 13:04:26 -07:00
mvschwarz 801a6a65bf fix: preserve cli bin executable bit on build 2026-05-03 12:52:42 -07:00
mvschwarz 833e23c049 test: make chatroom wait fixture time-stable 2026-05-03 12:43:07 -07:00
mvschwarz 6d1f26e680 fix: propagate daemon env to launched seats 2026-05-03 12:41:44 -07:00
mvschwarz 6d7dc38847 fix: launch codex seats with full access posture 2026-05-03 12:17:50 -07:00
mvschwarz 71f2ec6725 chore: mark PL-004 Phase D checkout recovery 2026-05-03 10:38:48 -07:00
mvschwarzandClaude Opus 4.7 1dcef41c98 fix(daemon): PL-004 Phase D R4 — workflow_step_trails listForInstance ordered by rowid (deterministic insertion order)
Repairs QA-found nondeterminism on R3 commit b3373ba flagged in:
<work-tree>/missions/recursive-self-improvement-v2/dogfood-evidence/
pl004-major-release-battle-test-b3373ba-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 b3373ba1a2 fix(daemon): PL-004 Phase D R3 — map exit_not_allowed to HTTP 400 + route regression
Repairs guard BLOCK on R2 commit b699f39:

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 b699f394dc fix(daemon): PL-004 Phase D R2 — durable current-step binding + allowed_exits enforcement at projection
Repairs guard BLOCK on R1 commit 4a66e3c:

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 1ab394e; 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 4a66e3c8a5 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 7182a96:

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 7182a966b8 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 1ab394e (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 1ab394e117 fix(daemon): PL-004 Phase C R2 — yaml-package frontmatter parser + discriminating scan_interval scheduler test
Repairs guard BLOCK on R1 commit 937b1e5:

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 937b1e5118 fix(daemon): PL-004 Phase C R1 — POC contract parity for watchdog policies + active-wake throttle
Repairs guard BLOCK on commit 25be687:

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
25be687 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 25be687a4d 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 f22f8e00b3 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 2e9dbf2 per qitem-20260503045232-1b836fbc + guard artifact pl004-phase-b-r1-2e9dbf2-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 42a930af 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 2e9dbf256f 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 068c06f per qitem-20260503043604-21709333 + guard artifact pl004-phase-b-068c06f-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 42a930af 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 068c06f3d0 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 42a930af (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 42a930af (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 42a930af 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 42a930afdf 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 744ef1e (PL-004 Phase A R1). Branch: phase-a-coordination-primitive-system (worktree-scoped; main NOT touched).

ROOT CAUSE:
The R1 commit 744ef1e 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 `<user-home>/code/projects/openrig` HEAD unchanged at f954a61d 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 744ef1e8d4 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 c4ca2a5 (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-c4ca2a5-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 c4ca2a5 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 `<user-home>/code/projects/openrig` HEAD unchanged at f954a61 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 c4ca2a5cdb 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: <work-tree>/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 f954a61d78 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 bcb6681 (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 bcb6681 (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 bcb6681339 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 3df89b0 (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 3df89b0 (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 3df89b0d8c 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 be7e264 (runtime parsers + redaction + omitted-records).
- M2b R2 ACCEPT: openrig 553f9bc (Claude parser nested tool_use/tool_result).
- M2c-CLI ACCEPT: openrig 8e599d3 (packet-writer atomic emission + write action).
- M2c-CLI R2 ACCEPT: openrig 6430b3c (--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 6430b3ccb3 fix(cli): Restore-Packet M2c-CLI R2 — --source-jsonl provenance from parsed session_meta
Closes guard BLOCK on M2c-CLI at 8e599d3. 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 / 8e599d3):
- `--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 8e599d38db 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 553f9bccf3 fix(cli): Restore-Packet M2b R2 — Claude parser nested tool_use/tool_result handling
Guard BLOCKED M2b at openrig be7e264: 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 "<user-home>/code/projects/openrig-hub".
- counts each tool_use part in a multi-tool assistant turn (tool_use*2 +
  text → function_call_output=2; path "<user-home>/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 <user-home>/ 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 be7e2646ba 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 bd3e01d):
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 bd3e01d (qitem-20260502005113-24bf9de8 closure).
2026-05-01 18:13:38 -07:00
mvschwarz bd3e01da42 fix(cli): Restore-Packet M2a R2 — schema packaging fix; validator self-contained after raw tsc emit
Guard BLOCKED M2a at 23f2921 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 23f2921eb9 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 <work-tree>/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 07a8078e67 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 33f8668) 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 33f8668f33 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 51d4765463 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
d791932. 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:
<work-tree>/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 d79193290f 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 / `8fc0c25`; 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:
<work-tree>/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 8fc0c258fe 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 b822653: 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 b822653;
+4 net new tests; 0 regressions).

Slice rails: <work-tree>/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 b822653ddd 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 38bcf27;
net +9 from this slice; deferral closed; zero regressions).

Slice rails: <work-tree>/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 38bcf27c5f 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 e7887ae.

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: <work-tree>/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 e7887aef32 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 40dac01)

Slice rails: <work-tree>/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 e5f692d92c 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
0848ca8) 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: <work-tree>/missions/recursive-self-improvement-v2/primitive-hardening/slices/host-ps-whoami-promotion/
Driver checkpoint: <internal-docs> rigs/openrig-velocity-claude/state/orch/velocity-host-ps-whoami-promotion-driver-checkpoint-20260430.md
Guard accept:      <internal-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 b3b40a3e9f 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 be91483): `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 be91483 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: <work-tree>/missions/recursive-self-improvement-v2/primitive-hardening/slices/fields-ux-rejection/
Driver checkpoint: <internal-docs> rigs/openrig-velocity-claude/state/orch/velocity-fields-ux-rejection-driver-checkpoint-20260430.md
Guard accept:      <internal-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 40dac01dbd 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: <work-tree>/missions/recursive-self-improvement-v2/primitive-hardening/slices/seat-continuity-and-handover/
Driver checkpoint: <internal-docs> rigs/openrig-velocity-claude/state/orch/velocity-seat-continuity-handover-driver-checkpoint-20260430.md
Guard accept:      <internal-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 0848ca8d12 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: <work-tree>/missions/recursive-self-improvement-v2/primitive-hardening/slices/cross-host-rig-commands/
Driver checkpoint: <internal-docs> rigs/openrig-velocity-claude/state/orch/velocity-cross-host-rig-commands-driver-checkpoint-20260430.md
Guard accept:      <internal-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 82e5a6d09a 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: <work-tree>/missions/recursive-self-improvement-v2/primitive-hardening/slices/session-source-fork/
Driver checkpoint: <internal-docs> rigs/openrig-velocity-claude/state/orch/velocity-session-source-fork-driver-checkpoint-20260430.md
Guard accept:      <internal-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 2272496c54 docs(cli-reference): bring rig ps section in line with shipped be91483
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:

- <internal-docs> rigs/openrig-velocity-claude/state/orch/velocity-runtime-truth-inventory-driver-checkpoint-20260430.md
- <internal-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 be9148328f 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 `d07e8df` 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: f7b0274 fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists
- L3 predecessor: be4cdbb feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation
- L2 predecessor: d07e8df feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection
- L1 predecessor: 9dfd816 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 f7b0274e8c fix(daemon,cli): rig up <rigName> falls back to manual snapshot when no auto-pre-down exists
Lifecycle v2 L3b builds on L3 (be4cdbb) by closing the rig-up-by-name +
manual-snapshot gap reproduced in the L4 VM proof at d07e8df 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 d07e8df 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-d07e8df-20260428.md (cycle 1 reproduced the gap)
- L3 predecessor: be4cdbb feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation
- L2 predecessor: d07e8df feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection
- L1 predecessor: 9dfd816 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 be4cdbb72f feat(daemon,cli): restore attempt id, attention_required classification, runtime-truth reconciliation
Lifecycle v2 L3 builds on L1 cold-start truth repair (9dfd816) and L2 lifecycle
projection (d07e8df) 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: 9dfd816 fix(daemon): classify tmux socket-absence as missing session and log startup reconcile
- L2 predecessor: d07e8df 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 d07e8dfd7b feat(daemon,cli): add recoverable/degraded/attention_required lifecycle projection
Lifecycle v2 L2 builds on the L1 cold-start truth repair (9dfd816) 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: 9dfd816 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 9dfd816c47 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:
- <work-tree>/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 d5f2156e6f feat(agents): make runtime config spec-driven 2026-04-26 18:30:08 -07:00
mvschwarz 9f70b571f4 fix: relax managed claude permission provisioning 2026-04-26 17:02:06 -07:00
mvschwarz ec5ab4440f fix: allow release docs in docs guard 2026-04-26 16:56:13 -07:00
mvschwarz 82a00f93af feat: support portable rig specs and codex profiles 2026-04-26 16:48:53 -07:00
mvschwarz 3bb967aa2f feat(daemon): provision managed runtime activity hooks 2026-04-24 14:41:13 -07:00
mvschwarz 10638ece42 fix(daemon): gate codex model selection readiness 2026-04-24 14:10:35 -07:00
mvschwarz d1de5154e2 fix(cli): honor wait-for-idle request timeout 2026-04-24 13:44:54 -07:00
mvschwarz 4b8127f915 fix(daemon): avoid flavor text in claude activity fallback 2026-04-24 13:20:39 -07:00
mvschwarz 57f501d460 fix(daemon): classify current claude activity for wait-for-idle 2026-04-24 13:11:23 -07:00
mvschwarz bc4504edac feat(cli): add wait-for-idle send mode 2026-04-24 10:52:48 -07:00
mvschwarz d425a900f8 fix(daemon): add codex queue state writable root 2026-04-24 08:52:58 -07:00
mvschwarz 047b14701a feat(workflow): add hook-backed agent activity truth 2026-04-24 00:26:19 -07:00
mvschwarz 5641ee0208 feat(workflow): surface agent activity truth 2026-04-23 23:57:34 -07:00
mvschwarz 478c23e32e feat(cli): add compact plan thresholds 2026-04-23 23:09:23 -07:00
mvschwarz d9ef02418b fix(daemon): keep polling for codex update gate 2026-04-23 22:35:39 -07:00
mvschwarz 0fbd03087e fix(restore): handle codex update-gated restore 2026-04-23 22:23:37 -07:00
mvschwarz d13c0730b9 feat(restore): prevalidate restore attempts 2026-04-23 21:01:44 -07:00
mvschwarz f130119952 fix(restore): correct restore truth semantics 2026-04-23 20:44:28 -07:00
mvschwarzandClaude Opus 4.6 137fbf3dd1 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 5ee7c8fcc0 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 4f74cc2065 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 d246b7b765 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 69dcec310c 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 f1840dad0e Validate startup actions in restore-check 2026-04-23 09:18:42 -07:00
mvschwarz e634e01048 Add restore-check startup-context integrity checks 2026-04-23 09:12:18 -07:00
mvschwarz cfe5b4b128 Block restore-check recovery on identity gaps 2026-04-23 08:31:56 -07:00
mvschwarz 2f41eb3efa Add restore-check recovery planning 2026-04-23 08:18:40 -07:00
mvschwarz 621e1b5083 Merge Claude hook checks across settings levels 2026-04-23 07:51:13 -07:00
mvschwarz 4dbba51519 Add restore-check Claude hook inspection 2026-04-23 07:44:44 -07:00
mvschwarz 3c013b2e2d Add host infra evidence path checks 2026-04-23 07:19:30 -07:00
mvschwarz e04b95fadf Preserve host infra state for missing rig restore check 2026-04-23 06:57:00 -07:00
mvschwarz cb795cb0d2 Add restore-check host infra declaration 2026-04-23 06:51:38 -07:00
mvschwarz 52fc458e5e Add restore-check route fallback repair packet 2026-04-23 06:34:51 -07:00
mvschwarz d8538e890d Add restore-check fully-back assertions 2026-04-23 06:19:46 -07:00
mvschwarz 2486c0a7d0 Add read-only compact plan command 2026-04-23 06:04:43 -07:00
mvschwarz 8659a2dbcd Add Claude compact-in-place skill 2026-04-23 05:45:41 -07:00
mvschwarzandClaude Opus 4.6 0facbacecf 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 2c63b910e4 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 bbacb48758 test(restore-check): temp OPENRIG_HOME mtime regression for read-only invariant
Cycle-5 follow-up to b83b53b (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 b83b53b 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 b83b53bdc3 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 e3dd68ce53 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 63c4c81e0b fix(restore-check): add spec/root checks + probe-error→unknown + honest hooks
Cycle-2 of 277e279 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 277e279c99 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 bd0539f070 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 5547d7c57e test: daemon refresh route + UI context prominence regressions
Cycle-3 of H44 Slice 2 per QA REJECT-AND-REVISE on b73f2a2.

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 b73f2a27b9 fix(context): strict threshold validation + refresh hard-fail + CLI tests
Cycle-2 of 5a2a7c7 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 5a2a7c7ac7 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 71bd9bde1f docs(skills): add OpenRig operator triage guidance 2026-04-23 00:49:40 -07:00
mvschwarzandClaude Opus 4.6 0794f644e8 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 95c6fe4437 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 87ed293837 fix(restore+tmux): classify sessions without mutation + reliable has-session
Cycle-2 revision of 01664b3 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 01664b35a7 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 33f32081e2 fix(transcript-store): filter full Codex startup box including model/directory lines
Cycle-2 revision of 84162bd 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 84162bd078 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 d02ee30f06 fix(claude-adapter): remove echo from baseline + file-idempotent provisioning
Cycle-2 revision of e119bd1 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 e119bd13c5 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 a7859d9722 fix(cli): include postinstall script in tarball + classify native errors honestly
Cycle-2 revision of 23e77a3 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 23e77a3acf 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 587206d:
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 587206db6d 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 cb4eb96952 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 7681ea3ec3 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 6d4bd6d'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 6d4bd6d318 fix(session-transport): detect padded trust prompts 2026-04-21 18:50:20 -07:00
mvschwarzandClaude Opus 4.6 bffb53b3e6 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 c18dcfc: 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 c18dcfc44c fix(session-transport): tighten idle-prompt discriminator per r2 adversarial review
Cycle-2 revision of f1de237. 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 f1de237452 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 e2dbe7c565 fix: remove ellipsis-only mid-task heuristic 2026-04-20 13:28:11 -07:00
Mike Schwarz 4e0708db71 Merge pull request #11 from mvschwarz/docs/release-notes-archive
docs: add release notes archive
2026-04-19 13:12:08 -07:00
mvschwarz cba88a0604 docs: add release notes archive 2026-04-19 13:09:36 -07:00
mvschwarz fc07e3cc68 Release 0.1.12 2026-04-19 10:50:39 -07:00
mvschwarz ddbb544f47 test: allow canonical reference docs in guard 2026-04-19 10:50:36 -07:00
Mike Schwarz ef6e095da9 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 1e6f2cc222 daemon: normalize user-clearable startup blockers 2026-04-19 10:39:23 -07:00
mvschwarz fe761b67d2 daemon: normalize stale Claude startup state 2026-04-19 01:31:16 -07:00
mvschwarz 11321b8f36 demo: switch proof scripts to rig commands 2026-04-19 01:25:56 -07:00
mvschwarz 3f6cbb5241 daemon: reject pod-aware node relaunch via legacy route 2026-04-19 01:22:50 -07:00
mvschwarz 2d76aa17e1 cli: extend import instantiate timeout budget 2026-04-19 01:18:54 -07:00
mvschwarz 0dc9b8c4b4 cleanup stray daemon test database artifact 2026-04-19 01:10:32 -07:00
mvschwarz 2c6f6dc401 fix Claude MCP startup blockers for managed sessions 2026-04-19 01:10:20 -07:00
mvschwarz dfa54b03b7 harden restore continuity and recovery guidance 2026-04-19 00:23:45 -07:00
mvschwarz 85323305a0 daemon: accept codex footer with trailing blank lines 2026-04-18 14:42:44 -07:00
mvschwarz a90db0a0d1 daemon: recognize resumed codex tui after header scrolls 2026-04-18 14:42:44 -07:00
mvschwarz ba132e73b9 daemon: capture codex resume ids from wrapped processes 2026-04-18 14:42:44 -07:00
mvschwarz 6e5d1e8897 daemon: ignore stale codex update banner once tui is live 2026-04-18 14:42:44 -07:00
mvschwarz 11a89230e6 cli: detect unhealthy tmux control sockets 2026-04-18 13:57:38 -07:00
mvschwarz 5462e6da25 Release 0.1.11 2026-04-14 12:40:58 -07:00
mvschwarz 8444a1c777 fix(cli): use real cmux settings for setup and doctor 2026-04-14 12:40:13 -07:00
mvschwarz eb81937e0f Fix cmux onboarding detection and setup 2026-04-14 11:21:45 -07:00
mvschwarz 36b4c42be1 Release 0.1.10 2026-04-13 22:53:26 -07:00
mvschwarz 95696ef544 Document launch walkthrough with annotated screenshots 2026-04-13 22:06:40 -07:00
mvschwarz 25b28ad26c Harden launch onboarding and first-run setup 2026-04-13 16:44:29 -07:00
mvschwarz 9e2866347f fix(cli): recover daemon state when state file is missing 2026-04-13 14:26:25 -07:00
mvschwarzandClaude Opus 4.6 ca34092fdf fix(runtime-adapters): propagate rig-role skip into result contracts
Cycle-1 (2aaf696) 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 2aaf696
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 2aaf6969ad 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 cac87fb7d2 fix(cmux-transport): reject error text and parse panel rows safely 2026-04-13 11:06:03 -07:00
mvschwarzandClaude Opus 4.6 a75e8eb97a 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 baf48a579a 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 cb5d1c0d6f 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
f3f940f4b1 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 3ce16cf123 docs: fix rig bundle reference launch semantics 2026-04-11 21:52:34 -07:00
Mike SchwarzandClaude Opus 4.6 6ca053192b 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 18d66e3a70 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 910dae3cd5 Release 0.1.9 2026-04-11 15:16:34 -07:00
Mike Schwarz d21f0d8dda Preserve cmux context and harden cmux health 2026-04-11 15:10:00 -07:00
Mike Schwarz 6ad7aced83 Sanitize daemon launcher env for cmux 2026-04-11 14:35:58 -07:00
Mike SchwarzandClaude Opus 4.6 6ea377e4e5 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 a5c2ee860b Prepare demo setup runtimes and Claude onboarding 2026-04-11 14:06:24 -07:00
Mike SchwarzandClaude Opus 4.6 cfcd86ee1c 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 1322be093e 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 8fba46844d 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 a5b644d03a 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 75b8cdad1b 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 43be616ebe 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 2bd3366b25 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 4797874652 Fix packaged CLI runtime and demo launch 2026-04-10 08:33:31 -07:00
Mike SchwarzandClaude Opus 4.6 2277b1f912 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 aede1de94b 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 f344627188 Guard against tracked docs outside as-built (#3) 2026-04-10 02:13:52 -07:00
Mike Schwarz df382a611c 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 50e5f36294 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 c0245fcafe Add cmux recovery and doctor guidance 2026-04-09 23:37:59 -07:00
Mike SchwarzandClaude Opus 4.6 d611bd297e 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 a63d7da1f8 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 4ec7eb9a10 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 f001d1fc11 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 8daa08bf32 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 6284ee608c 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 4bbe5e7004 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 56e6318972 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 58356f6fa4 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 cad345be2c 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 2c7b29864c docs: refresh as-built architecture and cli reference 2026-04-09 18:54:03 -07:00
Mike Schwarz 0a0e9035fc Teach CLI and openrig-user about managed apps 2026-04-09 18:18:13 -07:00
Mike SchwarzandClaude Opus 4.6 1ad418c232 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 50f2846232 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 48e046f342 update gitignore 2026-04-09 17:38:25 -07:00
Mike Schwarz 69669d49f4 Fix vault specialist dogfood regressions 2026-04-09 17:18:53 -07:00
Mike SchwarzandClaude Opus 4.6 066b541f56 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 051474c0f4 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 f03241e8c7 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 c2be888b99 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 690ffa6a72 Fix managed app env tab dogfood regressions 2026-04-09 15:11:33 -07:00
Mike SchwarzandClaude Opus 4.6 5d9b86e8bd 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 870f82935e 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 870c8ae6aa 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 21bf01325e 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 f688178755 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 0c3eb75e06 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 4290012932 Fix rig env T07 dogfood regressions 2026-04-09 06:24:33 -07:00
Mike SchwarzandClaude Opus 4.6 1aa84b05d8 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 149fae1b74 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 612a9d9d43 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 6ec7c52b09 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 d0abcdcaa7 Add compose services orchestrator 2026-04-09 04:43:11 -07:00
Mike Schwarz f07e9bbc55 Add rig services schema and persistence 2026-04-09 04:30:08 -07:00
Mike SchwarzandClaude Opus 4.6 f577370740 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 1850b84063 Bump version to 0.1.5 2026-04-09 04:09:42 -07:00
Mike Schwarz 637e6deb69 Fix refactor launch timeout and spec scanning 2026-04-09 04:04:05 -07:00
Mike Schwarz 5fef0df404 Revert "Standardize canonical session names"
This reverts commit 79c5a9ba4a.
2026-04-09 03:54:48 -07:00
Mike Schwarz 79c5a9ba4a Standardize canonical session names 2026-04-09 03:31:58 -07:00
Mike Schwarz b6465e8dd9 Reorganize bundled spec library 2026-04-09 03:31:49 -07:00
Mike Schwarz 9aa8ad6cb2 Bump version to 0.1.4 2026-04-09 03:06:27 -07:00
Mike Schwarz 7bc0c3c90e Expand review team operating manual 2026-04-09 03:05:37 -07:00
Mike SchwarzandClaude Opus 4.6 ac91e573d2 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 598537b47f 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 1020b8eace 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 105757 additions and 8602 deletions

No files matched your search

+669
View File
@@ -0,0 +1,669 @@
# 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.*
+29 -30
View File
@@ -15,32 +15,28 @@ npm install -g @openrig/cli
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
rig setup
# Boot the demo rig (3 pods, 8 nodes, mixed runtimes)
rig up demo
# Boot the conveyor starter rig (4 seats, mixed runtimes)
rig up conveyor
# Open the UI
rig ui open
```
After the demo boots, open the UI and click **orch1.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
After the conveyor rig boots, open the UI and click **intake.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
## Launch Walkthrough
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
### 1. Open the demo rig
### 1. Open the conveyor rig
When the UI opens, the explorer is already visible on the left. Click the `demo` rig to load its live topology.
When the UI opens, the explorer is already visible on the left. Click the `conveyor` rig to load its live topology.
![Step 1: Click the demo rig in the explorer.](assets/ui/screenshots/launch-happy-path/guide-step-01-open-demo.png)
### 2. Click `CMUX` on `intake.lead`
### 2. Click `CMUX` on `orch1.lead`
Once the topology loads, go to the `intake.lead` node and click its `CMUX` button. That opens the intake terminal directly.
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...
> From here, give the intake agent a packet of work and watch it move through planning, build, review, and closeout.
## What It Does
@@ -56,33 +52,36 @@ 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.
## Demo Rig
## Starter Rigs
OpenRig ships with a demo rig you can boot in seconds:
OpenRig ships with a conveyor starter rig for learning workflow handoff:
```bash
rig specs preview demo
rig specs preview conveyor --kind rig
```
```
demo (rig, pod_aware)
Launch-grade starter: a stable full product squad with two
orchestrators, implementation, QA, design, and two independent
reviewers.
conveyor (rig, pod_aware)
Starter workflow rig: a station pipeline that can move multiple
work packets at once, with queue depth acting as natural backpressure.
Pod: orch1 (2 members)
Pod: intake (1 member)
lead — claude-code
peer — codex
Pod: dev1 (3 members)
impl — claude-code
qa — codex
design — claude-code
Pod: rev1 (2 members)
r1 — claude-code
r2 — codex
Pod: plan (1 member)
planner — codex
Pod: build (1 member)
builder — claude-code
Pod: review (1 member)
reviewer — codex
```
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, `product-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
For a larger human-operated product-development topology, inspect `product-team`:
```bash
rig specs preview product-team
```
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
Browse the library: `rig specs ls`
@@ -95,7 +94,7 @@ CLI / UI / MCP
|
Hono HTTP daemon
|
Domain services (52)
Domain services
|
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:17439`
- guest daemon: `192.168.64.9:7433`
- host-forwarded UI: `http://127.0.0.1:<host-port>`
- guest daemon: `<guest-ip>:<guest-port>`
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:
- `/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/`
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
Notes:
- The README should use the annotated walkthrough files.
+346
View File
@@ -0,0 +1,346 @@
# OpenRig Design
This document captures the current OpenRig visual system so the product can be
recreated, extended, or translated into other media without reverse-engineering
the UI from code.
## Essence
OpenRig is a local control plane for multi-agent work. The interface should feel
like an operator's tactical dossier: precise, technical, calm, and legible under
pressure. It is not a consumer SaaS dashboard and not a decorative terminal skin.
It is a paper-and-glass workspace for watching work move through rigs, agents,
queues, projects, files, and proof packets.
The brand should communicate:
- Operator confidence: the human can see what is happening and what needs action.
- Composable systems: workspace, mission, slice, rig, pod, seat, queue, file, and
proof surfaces share primitives instead of bespoke one-off cards.
- Live work: activity, flow, context usage, proofs, and terminal state are visible
without turning the UI into a log dump.
- Local-first control: the app looks like a serious local cockpit, not a cloud
marketing product.
## Design Language
OpenRig currently uses two deliberate surface languages.
### Vellum Paper
Vellum is the default light surface language. It appears on navigation chrome,
dashboard cards, topology cards, project cards, drawers, file viewers, and
metadata containers.
Use vellum for persistent product surfaces:
- Explorer sidebars
- Dashboard cards
- Topology rig, pod, and agent cards
- Project and For You cards
- Drawer sheets and document viewers
- File and markdown surfaces
Core vellum traits:
- Paper-grid background visible behind and sometimes through surfaces
- White translucent fills, usually `bg-white/25` to `bg-white/40`
- `backdrop-blur-[8px]` for layered paper
- 1px outline or ghost border
- Hard shadow only on paper cards, not on floating glass
- Registration marks for framed tactical sheets
- Square corners everywhere except tiny status dots
Vellum should never become fully transparent. If the paper-grid makes text hard
to read, increase opacity before adding heavy borders.
### Black Glass
Black glass is the floating dark surface language. It is currently used for the
topology terminal preview and screenshot/proof overlays.
Use black glass for temporary preview surfaces:
- Terminal popovers
- Image proof viewers
- Other quick-look overlays where the content should float above the canvas
Core black-glass traits:
- Transparent dark fill, currently around `bg-stone-950/65`
- Light text, usually `text-stone-50`
- Optional subtle backdrop blur
- No hard shadow
- No 1px vellum border
- Chrome stripped to the minimum needed for comprehension
Black glass should feel like a smoked sheet above the canvas, not a framed
terminal emulator.
## Color Tokens
The canonical tokens live in `packages/ui/src/globals.css` and are surfaced to
Tailwind in `packages/ui/tailwind.config.ts`.
Paper:
- `--background: 47 20% 97%` - base paper, `#faf9f5`
- `--surface-container-lowest: 0 0% 100%` - card body white
- `--surface-container-low: 80 10% 95%`
- `--surface-container: 80 10% 92%`
- `--surface-container-high: 100 10% 89%`
- `--surface-container-highest: 100 10% 87%`
Ink:
- `--on-surface: 120 8% 19%` - primary text, `#2e342e`
- `--on-surface-variant: 108 5% 36%` - secondary text, `#5b615a`
- `--inverse-surface: 120 10% 4%` - dark strip fills, near black
Technical linework:
- `--secondary: 213 15% 39%` - connectors, handles, registration marks
- `--secondary-container: 220 39% 91%`
Status:
- `--success: 145 63% 35%`
- `--warning: 40 90% 50%`
- `--tertiary: 1 63% 42%` - stamped red / critical
- `--error: 0 43% 44%`
Borders:
- `--outline: 108 4% 47%`
- `--outline-variant: 108 4% 68%`
Usage rules:
- Use `outline-variant` for most structural borders.
- Reserve stone-900 borders for important card edges, active tabs, and hard
tactical emphasis.
- Do not create one-off color systems for each feature. Classify the state first,
then map to the shared neutral, info, success, warning, or danger tones.
## Typography
Tailwind font families:
- `font-body`: Inter for readable body text
- `font-headline`: Space Grotesk for display and stamped headings
- `font-mono`: JetBrains Mono for labels, IDs, queue state, technical metadata,
terminal previews, tabs, chips, and counters
General typography rules:
- Product labels use uppercase mono with letter spacing around `tracking-[0.10em]`.
- Body copy should be sentence case when it is meant for humans to read.
- Code identifiers should be transformed into human-readable labels where
possible, not displayed raw.
- Terminal and transcript content stays mono but should not force the rest of the
product to read like a log.
## Layout
The shell is route-first and explorer-first:
- 48px icon rail on desktop
- Explorer sidebar for route-specific tree context
- Center workspace for destination content
- Right drawer for transient detail and file views
- Preview stack for pinned quick-look panes
Primary destinations:
- `/` Dashboard
- `/topology` host topology
- `/for-you` For You feed
- `/project` workspace/project observability
- `/specs` Library
- `/settings` Settings
Topology and project both use hierarchical scopes:
- Topology: host -> rig -> pod -> seat
- Project: workspace -> mission -> slice
Each lower scope narrows the filter. Each higher scope widens the context.
## Core Primitives
Use these primitives before adding new component styles.
Vellum:
- `VellumCard`
- `VellumSheet`
- `RegistrationMarks`
- `StatusPip`
- `SectionHeader`
- `EmptyState`
Graphics:
- `RuntimeMark`
- `RuntimeBadge`
- `ToolMark`
- `ToolBadge`
- `ActorMark`
- `OperatorMoodMark`
- `runtime-brand.ts`
- `tool-brand.ts`
Project metadata:
- `ProjectPill`
- `EventBadge`
- `QueueStateBadge`
- `TagPill`
- `ActorChip`
- `DateChip`
- `FlowChips`
- `ProofThumbnailGrid`
- `ProofPacketHeader`
Topology:
- `HostMultiRigGraph`
- `HybridAgentNode`
- `HybridPodGroupNode`
- `RigGroupNode`
- `ActivityRing`
- `HotPotatoEdge`
- `TerminalPreviewPopover`
- `TopologyTableView`
- `TopologyTerminalView`
Preview and documents:
- `SessionPreviewPane`
- `ProofImageViewer`
- `FileViewer`
- `MarkdownViewer`
## Graphics System
OpenRig uses a shared graphics package rather than scattering literal icon logic
through product surfaces.
Runtime marks:
- Claude: pixel-style Claude mark, label `Claude`
- Codex: Codex CLI terminal-circle mark, label `Codex`
- Terminal: compact terminal glyph, label `TTY` or `Terminal`
- Unknown: neutral fallback
Tool marks:
- CMUX: blue chevron mark
- tmux: pane-grid mark with green base
- VS Code: blue code mark
- Terminal, file, markdown, config, code, screenshot, proof, transcript, commit,
folder, skill, video, and trace each have a central brand entry
Actor marks:
- Human/operator uses the monochrome climber mask
- Agent/runtime actors reuse runtime marks
Graphics placement rules:
- Use runtime marks where the runtime is the important scan dimension.
- Do not duplicate the same runtime mark in adjacent columns on the same row.
- Use tool marks for actions, not runtime marks.
- Prefer inline marks and open metadata rows over stacking more rectangular pill
badges when a card is already visually dense.
- Keep marks small enough to support scanning. They are landmarks, not mascots.
## Interaction Patterns
Hover-only actions:
- Dense topology cards show CMUX and terminal actions on hover/focus.
- Keyboard focus must reveal the same actions as mouse hover.
- Action clicks must stop propagation so row or node navigation does not also
fire.
Drawers and overlays:
- Drawers close with their close button and by clicking outside the sheet.
- Image viewers and terminal popovers are quick-look surfaces.
- Topology terminal popovers are portaled above React Flow node stacking contexts.
Queue and project:
- Queue is operational: what is pending, actionable, routed, or closed.
- Story is narrative: what happened over time, with body text primary and
metadata secondary.
- For You is attention routing: your turn, approvals, shipped proof, progress,
and observations.
## Motion
Motion should explain state, not decorate.
Current motion vocabulary:
- Activity card pulse for active, needs-input, and blocked states
- Source and target flash for handoff events
- Hot-potato packet animation on topology graph edges
- Rig frame pulse for aggregate activity
- Subtle route and node enter fades
Rules:
- Prefer slow pulses to blinking.
- Make directional work movement obvious enough to see at graph zoom levels.
- Honor `prefers-reduced-motion`: remove pulse/travel animation and keep static
state signals.
## Accessibility
- Icon-only actions need labels or tooltips.
- Decorative marks should use `aria-hidden`.
- Semantic status should use `StatusPip`, not activity animation classes.
- Keyboard focus must expose hover-only controls.
- Do not rely on color alone for action outcomes.
- Keep dense cards readable at the expected topology zoom range.
## Do And Do Not
Do:
- Use vellum and black-glass as distinct surface paradigms.
- Keep square geometry, 1px lines, and hard paper shadows.
- Centralize graphics through brand helpers.
- Translate raw event/state codes into human-readable labels.
- Make proof and terminal previews available where thumbnails or terminal marks
appear.
- Keep hierarchy visible: workspace -> mission -> slice, host -> rig -> pod ->
seat.
Do not:
- Add new one-off card styles when a primitive can do the job.
- Use emojis as product graphics.
- Turn metadata into a wall of rectangular pills.
- Make terminal/log aesthetics dominate human decision surfaces.
- Add decorative gradient blobs or generic SaaS hero styling.
- Add black borders or hard shadows to black-glass popovers.
- Duplicate runtime/tool brand logic outside `runtime-brand.ts`,
`tool-brand.ts`, and `RuntimeMark.tsx`.
## Implementation References
- Tokens: `packages/ui/src/globals.css`
- Tailwind theme: `packages/ui/tailwind.config.ts`
- Shell: `packages/ui/src/components/AppShell.tsx`
- Routes: `packages/ui/src/routes.tsx`
- Vellum primitives: `packages/ui/src/components/ui/`
- Graphics primitives: `packages/ui/src/components/graphics/RuntimeMark.tsx`
- Runtime brands: `packages/ui/src/lib/runtime-brand.ts`
- Tool brands: `packages/ui/src/lib/tool-brand.ts`
- Project metadata primitives: `packages/ui/src/components/project/ProjectMetaPrimitives.tsx`
- Topology graph nodes: `packages/ui/src/components/topology/HybridTopologyNodes.tsx`
- Terminal popover: `packages/ui/src/components/topology/TerminalPreviewPopover.tsx`
- Session preview: `packages/ui/src/components/preview/SessionPreviewPane.tsx`
+107 -43
View File
@@ -3,14 +3,16 @@
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.
- 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.
- OpenRig v0.2.0 also ships daemon-backed coordination primitives: stream, queue, project, view, watchdog, and workflow runtime.
- Shipped source footprint: `376` source files across the three packages.
- Daemon footprint: `211` source files total, including `126` domain files, `31` route files, `11` adapters, and `36` migrations.
- CLI footprint: `78` source files.
- UI footprint: `87` source files.
- Current 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
- Current v0.2.0 release verification:
- root build and package build passing
- daemon focused suite: `171/171` files, `2561/2561` passing
- CLI sanitized suite: `56/56` files, `794/794` passing
- Mode 3 real-agent dual-auth dogfood covered Claude-only, Codex-only, and mixed Claude-to-Codex paths
Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
@@ -32,10 +34,10 @@ Legacy flat-node/package flows remain for backward compatibility.
The stack is:
```text
CLI (40 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
CLI (53 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
v
Hono daemon routes (22 route groups + dedicated health/export/static handlers)
Hono daemon routes (31 route groups + dedicated health/export/static handlers)
|
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
+-- env routes (status / logs / down)
@@ -49,7 +51,7 @@ Hono daemon routes (22 route groups + dedicated health/export/static handlers)
v
Framework-free domain services
|
+-- SQLite state (20 migrations)
+-- SQLite state (36 migrations)
+-- tmux / cmux / resume adapters
+-- runtime adapters (Claude Code / Codex / Terminal)
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
@@ -120,7 +122,7 @@ Important route behaviors:
### CLI commands
`index.ts` currently mounts 40 command groups. Reboot-era commands plus:
`index.ts` currently mounts 53 command groups. Reboot-era commands plus:
**Operator and topology commands:**
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
@@ -172,36 +174,67 @@ The CLI-hosted MCP server exposes 17 tools:
### UI architecture
The UI is now explorer-first, with a shared drawer plus center-workspace route model:
The current UI is shell-first, route-first, and primitive-driven. See
`docs/DESIGN.md` for the brand/design spec and `docs/as-built/ui.md` for the detailed
as-built UI map.
- `/` renders `WorkspaceHome` (landing page when no rig selected)
- `AppShell` composes: `Explorer` sidebar + shared drawer selection context + `SharedDetailDrawer` + specs workspace context + activity/system surfaces
- Selecting a rig in the explorer loads its topology graph in the main area
- Clicking a node (in explorer or graph) opens the shared detail drawer
- The shared drawer has five primary surfaces:
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
- **Specs drawer:** 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`
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.
---
@@ -328,10 +361,41 @@ 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. Per PRD § L4 and founder direction (2026-04-22): owner-as-author semantically + 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. The PRD § L4 operating model is owner-as-author semantically plus workflow-as-transactional-scribe mechanically.
`workflow_specs` is a read-through cache of human-authored markdown/YAML spec files. Sources are workspace-surface; the daemon caches by `(name, version)` with a content `source_hash` so that valid operator edits to the spec file win at next read (workspace-surface reconciliation). Spec authoring stays markdown-authoritative; `workflow_specs` exists for fast lookup and runtime resolution.
@@ -732,7 +796,7 @@ Restore now:
### Config and preflight flow
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. The original runtime env keys retain deprecated `RIGGED_*` aliases for upgrade compatibility: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`. New typed config keys use `OPENRIG_*` only.
- Precedence: CLI flag > env var > config file > default
- `rig preflight` checks: Node.js ≥ 20, tmux available, writable home/db/transcript dirs, port available
- Auto-preflight runs on `rig up` and daemon start
@@ -1077,4 +1141,4 @@ These are the main intentional limits that still describe the shipped system:
This document is the architecture-level source of truth.
For file-by-file structure across daemon, CLI, and UI, use:
- [codemap.md](/Users/mschwarz/code/rigged/docs/as-built/codemap.md)
- [codemap.md](./codemap.md)
+109 -11
View File
@@ -1,6 +1,6 @@
# OpenRig CLI Reference
Verified against the shipped CLI on 2026-05-03 (v0.2.0) using:
Verified against the shipped CLI on 2026-05-15 (v0.3.1) 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: `53`
- Top-level command groups: `57`
- 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,6 +74,10 @@ 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
@@ -110,10 +114,11 @@ Subcommands:
### `rig config`
Usage:
- `rig config [--json]`
- `rig config get <key>`
- `rig config [--json] [--with-source]`
- `rig config get <key> [--show-source]`
- `rig config set <key> <value>`
- `rig config reset`
- `rig config reset [<key>]`
- `rig config init-workspace [--root <path>] [--force] [--dry-run] [--json]`
Supported keys:
- `daemon.port`
@@ -121,6 +126,7 @@ Supported keys:
- `db.path`
- `transcripts.enabled`
- `transcripts.path`
- `workspace.root` (and other workspace-rooted paths used by `init-workspace`)
Precedence:
- CLI flag
@@ -128,6 +134,13 @@ Precedence:
- config file
- default
Notes:
- `--with-source` (top-level) and `--show-source` (`get`) report per-key source/default for honest provenance.
- `init-workspace` scaffolds the default workspace at `~/.openrig/workspace/` (or the `--root` override) with mission/slice folders. `--force` overwrites scaffolded files (does NOT remove directories); `--dry-run` previews without writing. New in v0.3.0.
Legacy env compatibility: the original runtime keys still accept deprecated
`RIGGED_*` aliases. New typed config keys use `OPENRIG_*` only.
### `rig preflight`
Usage: `rig preflight [--json]`
@@ -248,6 +261,8 @@ 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`
@@ -267,7 +282,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>] [--host <id>]`
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--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`.
@@ -276,7 +291,8 @@ 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 only `status`, `lifecycleState`, `name-prefix`, `name`. Unknown keys are rejected before any HTTP call with a clear error naming the supported list.
- `--filter <key=value>` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do).
- `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
- Exit codes:
- `0` success
@@ -489,8 +505,8 @@ members:
ref:
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
value: # ordered list, highest-trust first
- /Users/wrandom/code/substrate/shared-docs/rigs/<rig>/CULTURE.md
- /Users/wrandom/code/substrate/shared-docs/specs/agents/<role>.md
- <substrate-shared-docs>/rigs/<rig>/CULTURE.md
- <substrate-shared-docs>/specs/agents/<role>.md
- /path/to/handover-packet.md
- /path/to/state/<pod>/<member>.queue.md
- /path/to/state/<pod>/shared.session.log
@@ -612,12 +628,12 @@ hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: wrandom # optional
user: your-username # optional
notes: "Tart VM" # optional
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: wrandom
user: your-username
```
Validation rules:
@@ -848,6 +864,88 @@ 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:
+22 -5
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: 150 source files (90 domain, 22 routes, 11 adapters, 20 migrations, 7 other)
- CLI: 49 source files
- daemon: 211 source files (126 domain, 31 routes, 11 adapters, 36 migrations, 7 other)
- CLI: 78 source files
- UI: 87 source files
- total: 286 source files
- total: 376 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/*` (40 groups)
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (53 groups)
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
@@ -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 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.
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 36 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, Final Stretch 3 managed-app services, and PL-004 coordination services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
### Adapters
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
@@ -98,6 +98,23 @@ 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
@@ -0,0 +1,238 @@
# 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, mental-model-ha]
skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging]
guidance: []
subagents: []
hooks: []
@@ -91,7 +91,7 @@ profiles:
preferences:
runtime: claude-code
uses:
skills: [openrig-user, mental-model-ha, vault-user]
skills: [openrig-user, systematic-debugging, 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, mental-model-ha, 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, systematic-debugging, 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, mental-model-ha, vault-user]
skills: [openrig-user, systematic-debugging, 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, mental-model-ha, vault-user]
skills: [openrig-user, systematic-debugging, 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, mental-model-ha, etc. | TDD implementation agent |
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, systematic-debugging, etc. | TDD implementation agent |
| `qa` | `specs/agents/development/qa/` | `shared` | openrig-user, development-team, etc. | Quality assurance agent |
| `orchestrator` | `specs/agents/orchestration/orchestrator/` | `shared` | openrig-user, orchestration-team, etc. | Rig orchestration lead |
| `reviewer` | `specs/agents/review/reviewer/` | `shared` | openrig-user, review-team, etc. | Independent code reviewer |
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, mental-model-ha, vault-user | Vault domain specialist |
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, systematic-debugging, 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, 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.
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, systematic-debugging" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
### The File Types
@@ -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, mental-model-ha. Use them.
You have the following skills loaded: openrig-user, test-driven-development, systematic-debugging. Use them.
```
This prompts the agent to actually invoke the skills, not just have them as passive context.
+102
View File
@@ -0,0 +1,102 @@
# Project Workspace Contract
OpenRig's Project UI is a file-backed view over a workspace root. The folder
shape is intentionally simple so humans and agents can create or repair it
without daemon-internal knowledge.
## Default Shape
`rig config init-workspace` creates the default workspace at
`~/.openrig/workspace` unless `--root` or `workspace.root` points elsewhere.
```text
workspace/
README.md
STEERING.md
missions/
README.md
idea-ledger/
README.md
PROGRESS.md
slices/
capture-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
triage-product-ideas/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
handoff-loop/
README.md
PROGRESS.md
slices/
route-work-packets/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
verify-loop-evidence/
README.md
PROGRESS.md
IMPLEMENTATION-PRD.md
progress/
field-notes/
specs/
```
## UI Mapping
- `workspace.root` maps to the Project workspace.
- `workspace.root/missions/<mission-id>` maps to a Project mission.
- `workspace.root/missions/<mission-id>/slices/<slice-id>` maps to a Project slice.
- Mission `PROGRESS.md` frontmatter supplies the mission status badge when the
file root is allowlisted.
- Slice `README.md`, `PROGRESS.md`, and `IMPLEMENTATION-PRD.md` frontmatter
supply display name, lifecycle status, mission id, and queue linkage hints.
Mission and slice ids should be stable kebab-case strings. Keep slice ids
unique inside the workspace so `/project/slice/<slice-id>` resolves without
ambiguity.
## Queue Mapping
Queue items attach to a slice when their body or tags mention one of:
- the slice id;
- the mission id;
- the legacy `rail-item` value in slice frontmatter.
For new work, include both mission and slice ids in the queue item body or
tags. Example:
```text
Mission: idea-ledger
Slice: capture-product-ideas
```
This makes Story, Queue, Tests, and Topology tabs line up with the filesystem
slice without adding a separate project database schema.
## Compatibility
The default discovery root is `workspace.slices_root=<workspace.root>/missions`.
The slice indexer also supports legacy flat roots such as
`workspace.slices_root=<workspace.root>/slices`, where each direct child folder
is a slice. Flat roots remain readable, but the mission-aware shape is the
default setup contract.
## Repair Checklist
If Project shows a mission discovery warning:
1. Run `rig config get workspace.root --show-source`.
2. Run `rig config get workspace.slices_root --show-source`.
3. Confirm `workspace.slices_root` points at a folder containing mission
directories with `slices/` children.
4. Confirm `files.allowlist` includes `workspace:<workspace.root>` so the UI
can read mission `PROGRESS.md`.
5. If the workspace is missing, run `rig config init-workspace` after operator
approval.
No daemon restart is required for most config reads. Restart when changing
startup-time roots such as `files.allowlist` or progress scan roots.
+2 -1
View File
@@ -7,7 +7,8 @@ It is intentionally simpler than a monolithic `CHANGELOG.md`.
Each shipped release gets its own note:
- `v0.1.12.md`
- `v0.1.13.md`
- `v0.2.0.md`
- `v0.3.0.md`
- and so on
## Why This Exists
+119
View File
@@ -0,0 +1,119 @@
# OpenRig v0.3.0
## Summary
OpenRig v0.3.0 turns the 0.2 coordination substrate into a more polished
operator product. It ships the redesigned shell, generic workflow starters,
mission-shaped workspace support, project observability, curated bundled skills,
runtime graphics, context telemetry, and release-hardening work for a cleaner
fresh install.
This release keeps the reusable primitives for queue handoff, workflow runtime,
watchdog supervision, artifact/proof surfaces, and agent coordination, while
the public starter content is intentionally generic.
## Included In This Release
- V1 shell redesign with the vellum visual system, two desktop chrome regions,
topology graph/table/terminal modes, drawer viewers, Project, For You, Queue,
Library, Settings, and proof-oriented detail surfaces.
- Generic starter workflow specs:
- `conveyor` for station-style work where multiple packets can be active
across the topology at once.
- `basic-loop` for a one-packet-at-a-time walkthrough flow.
- `conveyor` starter rig and matching agent specs for intake/planning/building
and review-oriented work.
- Mission-shaped workspace defaults and read-only nested mission/slice indexing.
- Project observability upgrades across For You, Story, Queue, Tests, Project
rollups, proof screenshots, and qitem drawers.
- Runtime/tool graphics package applied across topology, queue, project metadata,
proof rows, terminal previews, and agent identity marks.
- Terminal preview improvements, activity indicators, handoff edge animation,
and context usage telemetry in topology table/terminal views.
- Runtime skill discovery from filesystem skill roots during profile resolution.
- Pre-release CLI/daemon cleanup: bounded transcript rotation, `rig send`
message envelopes, OpenRig install/init marker naming, and public-release
terminology cleanup.
## User-Facing Changes
### Redesigned Shell
The UI now uses the V1 shell model with a narrow rail, destination-specific
Explorer region, center workspace, and named-trigger drawer. Topology pages have
graph, table, and terminal modes at one stable URL. Drawer viewers handle queue
items, files, sub-spec previews, terminal previews, and proof assets without
turning every reference click into a full-page navigation.
### Starter Workflows And Rigs
The release includes generic workflow examples rather than project-specific
automation recipes. `conveyor` demonstrates station-based flow with natural
queue backpressure. `basic-loop` demonstrates a simple one-token-at-a-time loop.
The `conveyor` rig is the public workflow starter. `product-team` remains the
more advanced human-operator-driven starter topology.
### Project And Proof Visibility
Project surfaces now distinguish current and archived work, show mission/slice
hierarchy, route mission labels to mission pages, and expose story/tests/proof
rollups. For You and Queue cards hydrate qitem bodies, proof images, audit
outcomes, and next-action state. Tests proof screenshots open in an in-page
viewer.
### Topology And Runtime Visibility
Topology now has a hybrid multi-rig graph, expand/collapse controls, activity
rings, animated handoff edges, runtime/tool marks, context percentage/token
displays, and a terminal fallback view. Agent detail surfaces now use the
canonical live node details view.
### Skills And Starter Guidance
Bundled skills were curated for starter coherence. Runtime skill discovery now
finds valid `SKILL.md` files in runtime and workspace skill roots, with
rig-local resources taking precedence over broader user libraries.
## Operator / Setup Notes
- Fresh workspace defaults are mission-shaped and derive from
`workspace.root`. Use `rig config init-workspace` to create the default
workspace structure.
- The repo uses npm workspaces and the tracked root `package-lock.json`.
`pnpm-lock.yaml` files are not release artifacts.
- Root-level runtime projection files such as `CLAUDE.md` are intentionally not
tracked in the product repo.
- Deprecated `RIGGED_*` aliases remain only for the original runtime env keys:
`daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, and
`transcripts.path`. New typed config keys use `OPENRIG_*` only.
- Existing local workflow specs can still be installed from workspace/spec
locations; product source only bundles the generic starter workflows.
## Known Limitations
- Plugin support is not part of 0.3.0. Plugin-packaged skills and external
plugin distribution are planned for a later release.
- The public starter workflows are examples, not a complete packaged automation
methodology.
- Some broad CLI tests are sensitive to local daemon port/config state. Focused
release-path tests and build/lint gates were used for the final push gate.
## Verification Performed
- `npm run lint`
- `npm run build`
- `npm run test:repo`
- Focused CLI, daemon, and UI tests for release hygiene, config boundaries,
starter skill discovery, shell/topology behavior, and project observability.
- Source scans for personal paths, internal attribution phrasing, default
fallback assumptions, and unwanted legacy env aliases on new typed keys.
## Commits Included
This release contains the accumulated product work since v0.2.0, including:
- Shell redesign V1 phases and polish slices.
- Project observability phases A-G plus follow-up fixes.
- Graphics package and broadened runtime/tool marks.
- Pre-release UI and CLI/daemon hardening.
- 0.3.0 starter refresh and release hygiene commits.
+336
View File
@@ -0,0 +1,336 @@
# 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.
+42 -7
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.2.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.2.0",
"version": "0.3.1",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -2296,6 +2296,26 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.168.3",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.3.tgz",
@@ -2328,6 +2348,19 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -7386,7 +7419,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.2.0",
"version": "0.3.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7412,12 +7445,12 @@
"vitest": "^3.0.0"
},
"engines": {
"node": "^20 || ^22 || ^24"
"node": ">=20"
}
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.2.0",
"version": "0.3.1",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7441,7 +7474,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.2.0",
"version": "0.3.1",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
@@ -7454,6 +7487,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.3",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.10.1",
"autoprefixer": "^10.4.27",
"class-variance-authority": "^0.7.1",
@@ -7464,7 +7498,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^3.4.19"
"tailwindcss": "^3.4.19",
"yaml": "^2.8.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.2.0",
"version": "0.3.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -12,10 +12,12 @@
"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",
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check",
"test:workspaces": "npm run test --workspaces",
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
"build:package": "bash scripts/build-package.sh"
"build:package": "bash scripts/build-package.sh",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check"
},
"engines": {
"node": "^20 || ^22 || ^24"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.2.0",
"version": "0.3.1",
"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/",
"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/",
"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 || ^22 || ^24"
"node": ">=20"
}
}
+401
View File
@@ -0,0 +1,401 @@
// Fork Primitive + Starter Agent Images v0 (PL-016) — `rig agent-image`
// CLI verb family. Parallel to `rig context-pack` shipped in PL-014.
//
// Subcommands:
// create <source-session> --name <name> — capture image from a live seat
// list — list all images
// show <name-or-id> — manifest + stats
// preview <name-or-id> — assembled preview + starter snippet
// delete <name-or-id> [--force] — delete (subject to evidence guard)
// pin <name-or-id> — pin from prune
// unpin <name-or-id> — unpin
// prune [--force] [--dry-run] [--json] — bulk delete evictable images
// sync — re-walk discovery roots
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface AgentImageEntryWire {
id: string;
kind: "agent-image";
name: string;
version: string;
runtime: "claude-code" | "codex";
sourceSeat: string;
sourceSessionId: string;
notes: string | null;
createdAt: string;
sourceType: "user_file" | "workspace" | "builtin";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: Array<{
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}>;
sourceResumeToken: string;
stats: {
forkCount: number;
lastUsedAt: string | null;
estimatedSizeBytes: number;
lineage: string[];
};
lineage: string[];
pinned: boolean;
}
interface PreviewWire extends Omit<AgentImageEntryWire, "sourcePath" | "relativePath" | "updatedAt" | "kind" | "sourceResumeToken" | "sourceType"> {
starterSnippet: string;
}
interface PruneWire {
dryRun: boolean;
forced?: boolean;
protected?: Array<{
imageId: string;
imageName: string;
imageVersion: string;
reasons: string[];
references: string[];
}>;
evictable?: Array<{ imageId: string; imageName: string; imageVersion: string }>;
deleted?: string[];
errors?: Array<{ imageId: string; error: string }>;
}
async function resolveImage(client: DaemonClient, nameOrId: string): Promise<AgentImageEntryWire> {
if (nameOrId.startsWith("agent-image:")) {
const res = await client.get<AgentImageEntryWire>(`/api/agent-images/library/${encodeURIComponent(nameOrId)}`);
if (res.status === 200) return res.data;
if (res.status === 404) throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library/${nameOrId}`);
}
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library`);
const matches = (res.data ?? []).filter((e) => e.name === nameOrId);
if (matches.length === 0) {
throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
}
if (matches.length > 1) {
const versions = matches.map((m) => m.version).join(", ");
throw new Error(`Agent image '${nameOrId}' is ambiguous (versions: ${versions}). Use 'agent-image:${nameOrId}:<version>'.`);
}
return matches[0]!;
}
export function agentImageCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("agent-image")
.description("Browse, snapshot, and manage agent images (PL-016)")
.addHelpText("after", `
Examples:
rig agent-image list
rig agent-image show driver-release-primed
rig agent-image create velocity-driver@openrig-velocity --name driver-release-primed --notes "after review"
rig agent-image preview driver-release-primed
rig agent-image pin driver-release-primed
rig agent-image prune --dry-run
rig agent-image delete driver-release-primed --force
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
cmd.command("list")
.description("List all agent images in the library")
.option("--runtime <runtime>", "Filter by runtime (claude-code | codex)")
.option("--json", "JSON output")
.action(async (opts: { runtime?: string; json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
let entries = res.data ?? [];
if (opts.runtime) entries = entries.filter((e) => e.runtime === opts.runtime);
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No agent images. Capture one with: rig agent-image create <source-session> --name <name>");
return;
}
for (const e of entries) {
const pinned = e.pinned ? " 📌" : "";
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${e.runtime.padEnd(12)} forks: ${String(e.stats.forkCount).padStart(3)} ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType}${pinned}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("show")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Show image manifest + statistics")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Runtime: ${entry.runtime}`);
console.log(`Source: ${entry.sourceSeat}`);
console.log(`Created: ${entry.createdAt}`);
console.log(`Path: ${entry.sourcePath}`);
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
console.log(`Pinned: ${entry.pinned}`);
console.log("");
console.log(`Stats:`);
console.log(` fork count: ${entry.stats.forkCount}`);
console.log(` last used: ${entry.stats.lastUsedAt ?? "never"}`);
console.log(` estimated size: ${entry.stats.estimatedSizeBytes} bytes`);
console.log(` lineage: ${entry.lineage.length === 0 ? "(none)" : entry.lineage.join(" → ")}`);
if (entry.notes) {
console.log("");
console.log("Notes:");
console.log(` ${entry.notes.replaceAll("\n", "\n ")}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("preview")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Show manifest + sized supplementary file metadata + starter snippet")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.get<PreviewWire>(`/api/agent-images/library/${encodeURIComponent(entry.id)}/preview`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const preview = res.data;
if (opts.json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
console.log(`# Preview: ${preview.name} v${preview.version} (${preview.runtime})`);
console.log(`# Source seat: ${preview.sourceSeat}`);
console.log(`# Stats: fork=${preview.stats.forkCount}, last-used=${preview.stats.lastUsedAt ?? "never"}, size=${preview.stats.estimatedSizeBytes}B`);
if (preview.lineage.length > 0) console.log(`# Lineage: ${preview.lineage.join(" → ")}`);
console.log("");
console.log("# Starter snippet (paste into agent.yaml):");
console.log(preview.starterSnippet);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("create")
.argument("<source-session>", "Source session canonical name (e.g., velocity-driver@openrig-velocity)")
.description("Capture a productive seat's resumable state into a new agent image")
.requiredOption("--name <name>", "Image name (used as the directory name and library id)")
// Use --image-version instead of --version because Commander.js
// intercepts the global --version flag (prints CLI version + exits
// 0 silently). Per-command name resolves the collision without
// losing the global --version surface.
.option("--image-version <version>", "Image version (default: 1)")
.option("--notes <text>", "Operator-supplied notes preserved in manifest")
.option("--estimated-tokens <n>", "Operator-supplied token estimate")
.option("--lineage <names...>", "Comma-separated parent image names if forking from another image")
.option("--json", "JSON output")
.action(async (sourceSession: string, opts: {
name: string;
imageVersion?: string;
notes?: string;
estimatedTokens?: string;
lineage?: string[];
json?: boolean;
}) => {
try {
const client = await getClient();
const body: Record<string, unknown> = {
sourceSession,
name: opts.name,
};
if (opts.imageVersion) body["version"] = opts.imageVersion;
if (opts.notes) body["notes"] = opts.notes;
if (opts.estimatedTokens) {
const n = Number(opts.estimatedTokens);
if (Number.isFinite(n)) body["estimatedTokens"] = n;
}
if (opts.lineage && opts.lineage.length > 0) body["lineage"] = opts.lineage;
const res = await client.post<{ imageId: string; imagePath: string; manifest: { name: string; version: string; runtime: string } }>(
"/api/agent-images/snapshot",
body,
);
if (res.status !== 200) {
const data = res.data as Partial<{ error: string; message: string; details: unknown }>;
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
}
const r = res.data;
if (opts.json) {
console.log(JSON.stringify(r, null, 2));
return;
}
console.log(`Captured ${r.manifest.name} v${r.manifest.version} (${r.manifest.runtime}) at ${r.imagePath}`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("delete")
.argument("<name-or-id>", "Image name or library id")
.option("--force", "Override evidence-preservation guard (CATASTROPHIC if active references exist)")
.option("--json", "JSON output")
.description("Delete an agent image (subject to evidence-preservation guard)")
.action(async (nameOrId: string, opts: { force?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const url = `/api/agent-images/library/${encodeURIComponent(entry.id)}${opts.force ? "?force=true" : ""}`;
const res = await client.delete<{ ok: boolean; forced: boolean; error?: string; message?: string; reasons?: string[] }>(url);
if (res.status !== 200) {
const data = res.data as Partial<{ error: string; message: string; reasons: string[] }>;
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
}
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
console.log(`Deleted ${entry.id}${opts.force ? " (forced)" : ""}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("pin")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Pin an image so prune cannot delete it")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/pin`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Pinned ${entry.id}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("unpin")
.argument("<name-or-id>", "Image name or library id")
.option("--json", "JSON output")
.description("Unpin an image")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveImage(client, nameOrId);
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/unpin`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
else console.log(`Unpinned ${entry.id}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("prune")
.option("--dry-run", "Preview without deleting (default if not --force)")
.option("--force", "Override evidence-preservation guard (CATASTROPHIC: deletes referenced images)")
.option("--json", "JSON output")
.description("Delete evictable images (protected by evidence-preservation guard)")
.action(async (opts: { dryRun?: boolean; force?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const dryRun = opts.dryRun !== false && !opts.force;
const res = await client.post<PruneWire>("/api/agent-images/prune", {
dryRun,
force: !!opts.force,
});
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
const r = res.data;
if (r.dryRun) {
const protectedList = r.protected ?? [];
const evictable = r.evictable ?? [];
console.log(`(dry-run) ${protectedList.length} protected, ${evictable.length} evictable`);
if (protectedList.length > 0) {
console.log("");
console.log("Protected:");
for (const p of protectedList) {
console.log(` ${p.imageName} v${p.imageVersion}: ${p.reasons.join(", ")}`);
for (const ref of p.references) console.log(` ↪ ${ref}`);
}
}
if (evictable.length > 0) {
console.log("");
console.log("Evictable:");
for (const e of evictable) console.log(` ${e.imageName} v${e.imageVersion}`);
}
} else {
console.log(`Deleted ${(r.deleted ?? []).length} image(s).${r.forced ? " (forced)" : ""}`);
for (const err of r.errors ?? []) console.log(` error: ${err.imageId}: ${err.error}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("sync")
.option("--json", "JSON output")
.description("Re-walk discovery roots and refresh the library index")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: AgentImageEntryWire[] }>(
"/api/agent-images/library/sync",
);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
return;
}
console.log(`Indexed ${res.data.count} agent image(s).`);
for (const e of res.data.errors) console.log(` error: ${e.source}: ${e.error}`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
@@ -0,0 +1,613 @@
// 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;
}
+86 -19
View File
@@ -1,33 +1,78 @@
import { Command } from "commander";
import { ConfigStore } from "../config-store.js";
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
import { initWorkspaceCommand } from "./config-init-workspace.js";
function formatRow(key: string, value: unknown): string {
return `${key.padEnd(28)} ${value}`;
}
function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting> {
return store.resolveAllWithSource();
}
export function configCommand(configPath?: string): Command {
const cmd = new Command("config").description("Inspect and change OpenRig configuration");
const store = new ConfigStore(configPath);
cmd
.option("--json", "JSON output for agents")
.option("--json", "JSON output for agents (resolved RiggedConfig)")
.option("--with-source", "Include source/default per key (honest provenance)")
.addHelpText("after", `
Examples:
rig config # show all resolved config
rig config --json # JSON output
rig config get daemon.port # read a single key
rig config set daemon.port 7434 # change a value
rig config reset # delete config file, revert to defaults
rig config # show all resolved config
rig config --json # JSON RiggedConfig (structured)
rig config --json --with-source # JSON per-key with source + default
rig config get daemon.port # read a single key
rig config get workspace.slices_root --show-source
rig config set daemon.port 7434 # change a value
rig config set workspace.slices_root /path # configure a workspace path
rig config reset # delete config file, revert all to defaults
rig config reset workspace.slices_root # clear one key, revert to default
rig config init-workspace # scaffold ~/.openrig/workspace/ with missions + slices
Keys:
daemon.* port, host
db.path
transcripts.* enabled, path, 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
Keys: daemon.port, daemon.host, db.path, transcripts.enabled, transcripts.path
Precedence: CLI flag > environment variable > config file > default`)
.action((opts: { json?: boolean }) => {
.action((opts: { json?: boolean; withSource?: boolean }) => {
try {
if (opts.withSource) {
const all = summarizeSettings(store);
if (opts.json) {
console.log(JSON.stringify(all, null, 2));
} else {
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
}
return;
}
// Default: structured RiggedConfig output (preserves pre-v0
// bare-action shape so existing scripts / tests keep working).
const config = store.resolve();
if (opts.json) {
console.log(JSON.stringify(config, null, 2));
} else {
console.log(`daemon.port ${config.daemon.port}`);
console.log(`daemon.host ${config.daemon.host}`);
console.log(`db.path ${config.db.path}`);
console.log(`transcripts.enabled ${config.transcripts.enabled}`);
console.log(`transcripts.path ${config.transcripts.path}`);
const all = summarizeSettings(store);
for (const key of VALID_KEYS) {
const r = all[key];
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
}
}
} catch (err) {
console.error((err as Error).message);
@@ -37,9 +82,20 @@ Precedence: CLI flag > environment variable > config file > default`)
const getCmd = new Command("get")
.argument("<key>", "Config key (e.g. daemon.port)")
.option("--json", "JSON output with value + source + default")
.option("--show-source", "Print value + source on a single line")
.description("Read a single config value")
.action((key: string) => {
.action((key: string, opts: { json?: boolean; showSource?: boolean }) => {
try {
if (opts.json || 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);
@@ -62,15 +118,26 @@ Precedence: CLI flag > environment variable > config file > default`)
});
const resetCmd = new Command("reset")
.description("Delete config file and revert to defaults")
.action(() => {
store.reset();
console.log("Config reset to defaults.");
.argument("[key]", "Optional config key to reset (omit to reset entire file)")
.description("Clear a config override (or delete the entire file when no key given)")
.action((key: string | undefined) => {
try {
store.reset(key);
if (key) {
console.log(`${key} reset to default (${store.get(key)}).`);
} else {
console.log("Config reset to defaults.");
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.addCommand(getCmd);
cmd.addCommand(setCmd);
cmd.addCommand(resetCmd);
cmd.addCommand(initWorkspaceCommand(configPath));
return cmd;
}
+407
View File
@@ -0,0 +1,407 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family.
//
// Six subcommands parallel to `rig specs`:
// list / show / preview / add / sync / send
//
// Each delegates to /api/context-packs/library/* against the daemon.
// The `add` verb installs a pack from a directory at
// ~/.openrig/context-packs/<name>/ — host-symlink-free contract,
// matches `rig specs add` shape (regular files only; no symlinks).
import { Command } from "commander";
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
} from "node:fs";
import { basename, extname, isAbsolute, join } from "node:path";
import { parse as parseYaml } from "yaml";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface ContextPackEntryWire {
id: string;
kind: "context-pack";
name: string;
version: string;
purpose: string | null;
sourceType: "builtin" | "user_file" | "workspace";
sourcePath: string;
relativePath: string;
updatedAt: string;
manifestEstimatedTokens: number | null;
derivedEstimatedTokens: number;
files: Array<{
path: string;
role: string;
summary: string | null;
absolutePath: string | null;
bytes: number | null;
estimatedTokens: number | null;
}>;
}
interface PreviewWire {
id: string;
name: string;
version: string;
bundleText: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
}
interface SendWire {
id: string;
name: string;
version: string;
destinationSession: string;
bundleBytes: number;
estimatedTokens: number;
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
missingFiles: Array<{ path: string; role: string }>;
dryRun: boolean;
bundleText?: string;
sent?: boolean;
reason?: string;
error?: string;
}
function assertTreeHasNoSymlinks(root: string): void {
const stack = [root];
while (stack.length > 0) {
const current = stack.pop()!;
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absPath = join(current, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(`Context pack directories must not contain symlinks: ${absPath}`);
}
if (entry.isDirectory()) stack.push(absPath);
}
}
}
const ALLOWED_CONTEXT_PACK_SUFFIXES = new Set([".md", ".markdown", ".yaml", ".yml", ".txt"]);
function validateContextPackManifestForInstall(manifestPath: string): void {
let parsed: unknown;
try {
parsed = parseYaml(readFileSync(manifestPath, "utf-8"));
} catch (err) {
throw new Error(`manifest at ${manifestPath} is not valid YAML: ${(err as Error).message}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`manifest at ${manifestPath} must be a YAML object at the root`);
}
const obj = parsed as Record<string, unknown>;
if (typeof obj["name"] !== "string" || obj["name"].length === 0) {
throw new Error(`manifest at ${manifestPath} is missing required field 'name' (string)`);
}
if (obj["version"] === undefined || obj["version"] === null) {
throw new Error(`manifest at ${manifestPath} is missing required field 'version'`);
}
const files = obj["files"];
if (!Array.isArray(files)) {
throw new Error(`manifest at ${manifestPath} must declare 'files: [...]'`);
}
for (let i = 0; i < files.length; i++) {
const entry = files[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`manifest at ${manifestPath} has malformed entry at files[${i}]`);
}
const file = entry as Record<string, unknown>;
const relPath = file["path"];
if (typeof relPath !== "string" || relPath.length === 0) {
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'path' (string)`);
}
if (relPath.includes("..") || isAbsolute(relPath) || relPath.startsWith("\\")) {
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' must be a relative path inside the pack (no '..' segments, no leading '/')`);
}
if (!ALLOWED_CONTEXT_PACK_SUFFIXES.has(extname(relPath))) {
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' has an unsupported suffix; allowed: ${Array.from(ALLOWED_CONTEXT_PACK_SUFFIXES).join(", ")}`);
}
if (typeof file["role"] !== "string" || file["role"].length === 0) {
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'role' (string)`);
}
}
}
async function resolvePack(client: DaemonClient, nameOrId: string): Promise<ContextPackEntryWire> {
// First try as id (context-pack:<name>:<version>); else search by name.
if (nameOrId.startsWith("context-pack:")) {
const res = await client.get<ContextPackEntryWire>(`/api/context-packs/library/${encodeURIComponent(nameOrId)}`);
if (res.status === 200) return res.data;
if (res.status === 404) throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library/${nameOrId}`);
}
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library`);
const entries = res.data ?? [];
const matches = entries.filter((e) => e.name === nameOrId);
if (matches.length === 0) {
throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
}
if (matches.length > 1) {
const versions = matches.map((m) => m.version).join(", ");
throw new Error(`Context pack '${nameOrId}' is ambiguous (versions: ${versions}). Use the full id 'context-pack:${nameOrId}:<version>'.`);
}
return matches[0]!;
}
export function contextPackCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("context-pack")
.description("Browse, preview, send, and install operator-authored context packs")
.addHelpText("after", `
Examples:
rig context-pack list
rig context-pack show pl-005-phase-a-priming
rig context-pack preview pl-005-phase-a-priming
rig context-pack add ./my-pack
rig context-pack sync
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity --dry-run
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity
`);
const getDeps = (): StatusDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
async function getClient(): Promise<DaemonClient> {
const deps = getDeps();
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
throw new Error("Daemon not running. Start it with: rig daemon start");
}
return deps.clientFactory(getDaemonUrl(status));
}
cmd.command("list")
.description("List all context packs in the library")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
const entries = res.data ?? [];
if (opts.json) {
console.log(JSON.stringify(entries, null, 2));
return;
}
if (entries.length === 0) {
console.log("No context packs in library. Author one at ~/.openrig/context-packs/<name>/ then run: rig context-pack sync");
return;
}
for (const e of entries) {
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${String(e.files.length).padStart(2)} files ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType} ${e.sourcePath}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("show")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show pack manifest + per-file metadata")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
return;
}
console.log(`Name: ${entry.name}`);
console.log(`Version: ${entry.version}`);
console.log(`Source: ${entry.sourceType} (${entry.sourcePath})`);
console.log(`Files: ${entry.files.length}`);
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
if (entry.purpose) {
console.log("");
console.log("Purpose:");
console.log(` ${entry.purpose.replaceAll("\n", "\n ")}`);
}
console.log("");
for (const f of entry.files) {
const sizeStr = f.bytes === null ? "(missing)" : `${f.bytes}B`;
const tokenStr = f.estimatedTokens === null ? "—" : `~${f.estimatedTokens} tokens`;
console.log(` ${f.path.padEnd(40)} role=${f.role.padEnd(20)} ${sizeStr.padEnd(12)} ${tokenStr}`);
if (f.summary) console.log(` ${f.summary}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("preview")
.argument("<name-or-id>", "Context pack name or library ID")
.description("Show the assembled bundle (the exact text that would be sent)")
.option("--json", "JSON output")
.action(async (nameOrId: string, opts: { json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.get<PreviewWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/preview`);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const preview = res.data;
if (opts.json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
if (preview.missingFiles.length > 0) {
console.error(`Warning: ${preview.missingFiles.length} file(s) referenced by manifest are missing on disk:`);
for (const m of preview.missingFiles) console.error(` - ${m.path} (role: ${m.role})`);
console.error("");
}
console.log(`# Preview: ${preview.name} v${preview.version}`);
console.log(`# Bundle: ${preview.bundleBytes} bytes (~${preview.estimatedTokens} tokens), ${preview.files.length} files`);
console.log("# ---");
console.log(preview.bundleText);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("sync")
.description("Re-walk discovery roots and refresh the library index")
.option("--json", "JSON output")
.action(async (opts: { json?: boolean }) => {
try {
const client = await getClient();
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>(
"/api/context-packs/library/sync",
);
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
console.log(`Indexed ${data.count} context pack(s).`);
if (data.errors.length > 0) {
console.log(`Encountered ${data.errors.length} parse error(s):`);
for (const e of data.errors) console.log(` - ${e.source}: ${e.error}`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("add")
.argument("<source-dir>", "Directory containing manifest.yaml + included files")
.description("Install a context pack from a local directory into ~/.openrig/context-packs/")
.option("--name <name>", "Override the install name (defaults to source directory basename)")
.option("--json", "JSON output")
.action(async (sourceDir: string, opts: { name?: string; json?: boolean }) => {
try {
if (!existsSync(sourceDir)) throw new Error(`Source directory not found: ${sourceDir}`);
const stat = lstatSync(sourceDir);
if (stat.isSymbolicLink()) throw new Error(`Source must not be a symlink: ${sourceDir}`);
if (!stat.isDirectory()) throw new Error(`Source must be a directory containing manifest.yaml: ${sourceDir}`);
const manifestPath = join(sourceDir, "manifest.yaml");
if (!existsSync(manifestPath)) {
throw new Error(`Source directory must contain manifest.yaml: ${sourceDir}`);
}
validateContextPackManifestForInstall(manifestPath);
// Read the manifest's name as the canonical install name when
// --name not given. Cheap parse: trust the daemon to validate
// on next sync; here we just need the name.
const installName = opts.name ?? (() => {
try {
const raw = readFileSync(manifestPath, "utf-8");
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
return m?.[1]?.trim() || basename(sourceDir);
} catch {
return basename(sourceDir);
}
})();
assertTreeHasNoSymlinks(sourceDir);
const targetRoot = getDefaultOpenRigPath("context-packs");
mkdirSync(targetRoot, { recursive: true });
const targetDir = join(targetRoot, installName);
if (existsSync(targetDir)) {
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
}
cpSync(sourceDir, targetDir, { recursive: true });
// Sync the daemon library so the new pack appears immediately.
const client = await getClient();
const syncRes = await client.post<{ count: number; errors?: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>("/api/context-packs/library/sync");
if (syncRes.status !== 200) {
// Install succeeded; sync failed → still surface install path.
if (opts.json) console.log(JSON.stringify({ installedAt: targetDir, syncError: `HTTP ${syncRes.status}` }, null, 2));
else console.log(`Installed at ${targetDir}; daemon sync failed (HTTP ${syncRes.status}). Run 'rig context-pack sync' manually.`);
return;
}
const syncError = syncRes.data.errors?.find((e) => e.source === targetDir);
if (syncError) {
throw new Error(`Installed at ${targetDir}, but daemon rejected the pack during sync: ${syncError.error}`);
}
if (opts.json) {
console.log(JSON.stringify({ installedAt: targetDir, count: syncRes.data.count }, null, 2));
} else {
console.log(`Installed at ${targetDir}. Library now has ${syncRes.data.count} context pack(s).`);
}
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
cmd.command("send")
.argument("<name-or-id>", "Context pack name or library ID")
.argument("<destination-session>", "Destination session name (e.g., velocity-driver@openrig-velocity)")
.description("Assemble the pack into one paste-ready bundle and send to a seat")
.option("--dry-run", "Show what would be sent without invoking SessionTransport")
.option("--json", "JSON output")
.action(async (nameOrId: string, destinationSession: string, opts: { dryRun?: boolean; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolvePack(client, nameOrId);
const res = await client.post<SendWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/send`, {
destinationSession,
dryRun: opts.dryRun ?? false,
});
if (res.status !== 200) {
// 502/503/etc. — surface daemon error verbatim.
const data = res.data as Partial<SendWire> & { error?: string; reason?: string };
throw new Error(data.error ?? data.reason ?? `Daemon returned HTTP ${res.status}`);
}
const data = res.data;
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (data.dryRun) {
if (data.missingFiles.length > 0) {
console.error(`Warning: ${data.missingFiles.length} manifest file(s) missing on disk.`);
}
console.log(`(dry-run) ${data.name} v${data.version} → ${data.destinationSession}`);
console.log(`Bundle: ${data.bundleBytes} bytes (~${data.estimatedTokens} tokens), ${data.files.length} files`);
if (data.bundleText) {
console.log("# ---");
console.log(data.bundleText);
}
return;
}
console.log(`Sent ${data.name} v${data.version} (${data.bundleBytes} bytes) to ${data.destinationSession}.`);
} catch (err) {
console.error((err as Error).message);
process.exitCode = 1;
}
});
return cmd;
}
+64 -2
View File
@@ -77,7 +77,21 @@ 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")
.action(async (opts: { port?: string; host?: string; db?: string }) => {
// 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 }) => {
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight } = await import("../system-preflight.js");
@@ -85,7 +99,14 @@ 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({
@@ -105,17 +126,58 @@ 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: effectiveHost,
host: hostForDaemon,
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,6 +1,5 @@
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";
@@ -293,10 +292,6 @@ 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;
}
+8 -3
View File
@@ -27,7 +27,8 @@ export function importCommand(depsOverride?: ImportDeps): Command {
.option("--preflight", "Run preflight checks")
.option("--target-rig <rigId>", "Target existing rig for additive materialization")
.option("--rig-root <root>", "Root directory for pod-aware resolution")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string }) => {
.option("--cwd <path>", "Override launch/materialization working directory for all members")
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string; cwd?: string }) => {
const deps = getDeps();
// Read local file first (before daemon check — fail fast on missing file)
@@ -59,7 +60,11 @@ export function importCommand(depsOverride?: ImportDeps): Command {
const rigRoot = podAware
? (opts.rigRoot ? nodePath.resolve(opts.rigRoot) : nodePath.dirname(nodePath.resolve(filePath)))
: undefined;
const extraHeaders = rigRoot ? { "X-Rig-Root": rigRoot } : undefined;
const cwdOverride = opts.cwd ? nodePath.resolve(opts.cwd) : undefined;
const extraHeaders = {
...(rigRoot ? { "X-Rig-Root": rigRoot } : {}),
...(cwdOverride ? { "X-Cwd-Override": cwdOverride } : {}),
};
if (opts.instantiate && opts.materializeOnly) {
console.error("Choose either --instantiate or --materialize-only, not both.");
@@ -97,7 +102,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
return;
}
const headers = {
...(extraHeaders ?? {}),
...extraHeaders,
...(opts.targetRig ? { "X-Target-Rig-Id": opts.targetRig } : {}),
};
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import/materialize", yaml, "text/yaml", headers);
+483
View File
@@ -0,0 +1,483 @@
// 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;
}
+194 -12
View File
@@ -43,6 +43,16 @@ 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;
}
@@ -55,7 +65,36 @@ const HUMAN_NODE_BUDGET = 100;
// L3-followup: --filter accepts only this allow-list. Unknown keys produce a
// clear error with the supported list (Guard Review Checklist: filter-parser
// security).
const ALLOWED_FILTER_KEYS = new Set(["status", "lifecycleState", "name-prefix", "name"]);
//
// PL-019 item 1: extends the allow-list with the nested key `agentActivity.state`.
// This is the only nested filter key v0 supports; the parser checks for the
// dotted form explicitly. Bare `agentActivity` is intentionally NOT a filter
// key — it's an object, not a scalar; project it via --fields if you need it.
const ALLOWED_FILTER_KEYS = new Set([
"status",
"lifecycleState",
"name-prefix",
"name",
"agentActivity.state",
// PL-012: context-usage filters. percent is numeric (>=, >, <=, <, =);
// state is enum (critical / warning / low / unknown — lockstep with
// computeContextHealthSummary tier vocabulary).
"contextUsage.percent",
"contextUsage.state",
]);
// PL-019 item 1: when filter key is `agentActivity.state`, the value is
// validated against the AgentActivityState enum. Invalid values produce a
// three-part error (what failed / what's allowed / what to do).
const ALLOWED_AGENT_ACTIVITY_STATES = new Set(["running", "needs_input", "idle", "unknown"]);
// PL-012: ALLOWED_CONTEXT_USAGE_STATES — must stay lockstep with the
// daemon's computeContextHealthSummary urgency vocab.
const ALLOWED_CONTEXT_USAGE_STATES = new Set(["critical", "warning", "low", "unknown"]);
const NUMERIC_FILTER_KEYS = new Set(["contextUsage.percent"]);
type NumericComparator = ">=" | ">" | "<=" | "<" | "=";
const NUMERIC_OPERATORS: NumericComparator[] = [">=", "<=", ">", "<", "="];
// C9a: --fields accepts a per-level allow-list. Unknown keys produce a clear
// error with the supported list, mirroring the --filter rejection pattern.
@@ -93,6 +132,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"resumeCommand",
"latestError",
"agentActivity",
"contextUsage",
]);
interface PsCliOptions {
@@ -104,6 +144,7 @@ interface PsCliOptions {
summary?: boolean;
filter?: string;
host?: string;
active?: boolean;
}
export interface PsDeps extends StatusDeps {
@@ -124,13 +165,34 @@ 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 } {
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);
// PL-012: pre-detect numeric comparators (>=, <=, >, <, =) so callers
// can write `contextUsage.percent>=80`. Order matters: check >= and
// <= before > and < so the longer prefix wins.
let op: NumericComparator = "=";
let opIdx = -1;
for (const candidate of NUMERIC_OPERATORS) {
const i = filter.indexOf(candidate);
if (i !== -1) {
// Prefer the leftmost match; on tie, prefer the longest comparator.
if (opIdx === -1 || i < opIdx || (i === opIdx && candidate.length > op.length)) {
opIdx = i;
op = candidate;
}
}
}
if (opIdx === -1) {
return { error: `--filter must be key<op>value (op = ${NUMERIC_OPERATORS.join(", ")}); got: '${filter}'` };
}
const key = filter.slice(0, opIdx);
const value = filter.slice(opIdx + op.length);
if (!ALLOWED_FILTER_KEYS.has(key)) {
return {
error: `Unknown --filter key '${key}'. Supported: ${[...ALLOWED_FILTER_KEYS].sort().join(", ")}`,
@@ -139,7 +201,60 @@ function parseFilter(filter: string): ParsedFilter | { error: string } {
if (!value) {
return { error: `--filter value is empty for key '${key}'` };
}
return { key, value };
// PL-012: numeric-keyed filters validate the value parses as a finite
// number. Three-part error per the existing convention.
if (NUMERIC_FILTER_KEYS.has(key)) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return {
error: `--filter ${key}${op}'${value}' is not a numeric value. ` +
`Allowed: a finite number (e.g., ${key}>=80). ` +
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
};
}
return { key, value, op, numericValue };
}
// PL-012: contextUsage.state enum guard — same shape as agentActivity.state.
if (key === "contextUsage.state" && !ALLOWED_CONTEXT_USAGE_STATES.has(value)) {
return {
error: `--filter contextUsage.state='${value}' is not a valid context state. ` +
`Allowed: ${[...ALLOWED_CONTEXT_USAGE_STATES].sort().join(", ")}. ` +
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
};
}
// PL-019 item 1: agentActivity.state is enum-valued; reject invalid values
// up-front so operators don't get a silent empty result on a typo. Three-part
// shape per `feedback_smart_agents_no_bureaucracy.md`: what failed / what's
// allowed / what to do.
if (key === "agentActivity.state" && !ALLOWED_AGENT_ACTIVITY_STATES.has(value)) {
return {
error: `--filter agentActivity.state='${value}' is not a valid activity state. ` +
`Allowed: ${[...ALLOWED_AGENT_ACTIVITY_STATES].sort().join(", ")}. ` +
`Use 'rig ps --nodes --fields agentActivity --json' to see what daemon is reporting.`,
};
}
// Non-numeric keys must use op = "=".
if (op !== "=") {
return {
error: `--filter ${key}${op}... uses a numeric comparator on a non-numeric key. ` +
`Allowed numeric keys: ${[...NUMERIC_FILTER_KEYS].sort().join(", ")}. ` +
`Use ${key}=<value> for equality.`,
};
}
return { key, value, op };
}
// PL-012: derive a context-usage tier from a numeric percent. Lockstep
// with daemon-side computeContextHealthSummary thresholds.
function deriveContextUsageState(percent: number | null | undefined): "critical" | "warning" | "low" | "unknown" {
if (typeof percent !== "number") return "unknown";
if (percent >= 80) return "critical";
if (percent >= 60) return "warning";
return "low";
}
// C9a: validate --fields against a per-level allow-list. Mirrors parseFilter's
@@ -172,6 +287,12 @@ 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;
});
}
@@ -183,6 +304,28 @@ 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;
});
}
@@ -278,12 +421,17 @@ 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. Other keys are rejected.
--filter accepts: status, lifecycleState, name-prefix, name, agentActivity.state.
Other keys are rejected. agentActivity.state is node-level (use with --nodes);
allowed values: running, needs_input, idle, unknown.
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
status, lifecycleState, uptime, latestSnapshot.
@@ -315,7 +463,8 @@ 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")
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name, agentActivity.state")
.option("--active", "Shortcut for --filter agentActivity.state=running (PL-019)")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.action(async (opts: PsCliOptions) => {
const deps = getDepsF();
@@ -335,9 +484,28 @@ 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 (opts.filter) {
const result = parseFilter(opts.filter);
if (effectiveFilter) {
const result = parseFilter(effectiveFilter);
if ("error" in result) {
console.error(result.error);
process.exitCode = 1;
@@ -536,7 +704,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", "RESTORE", "ERROR");
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "CTX", "RESTORE", "ERROR");
console.log(header);
for (const n of humanList as NodeEntry[]) {
const parts = n.logicalId.split(".");
@@ -553,6 +721,7 @@ async function handleNodes(
n.startupStatus ?? "—",
abbrevNodeLifecycle(n.lifecycleState),
formatActivity(n.agentActivity),
formatContextUsage(n.contextUsage),
n.restoreOutcome,
n.latestError ? truncate(n.latestError, 30) : "—",
));
@@ -595,7 +764,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, restore: string, error: string): string {
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, ctx: string, restore: string, error: string): string {
return [
fitCell(rig, 30),
fitCell(pod, 10),
@@ -606,11 +775,24 @@ 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,6 +68,7 @@ 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: {
@@ -79,6 +80,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
tags?: string;
expiresAt?: string;
id?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -94,6 +96,7 @@ 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);
@@ -178,6 +181,7 @@ 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: {
@@ -188,6 +192,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
priority?: string;
tier?: string;
tags?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -204,6 +209,7 @@ 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);
@@ -222,6 +228,7 @@ 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: {
@@ -232,6 +239,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
priority?: string;
tier?: string;
tags?: string;
targetRepo?: string;
nudge?: boolean;
json?: boolean;
}) => {
@@ -248,6 +256,7 @@ 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);
@@ -321,12 +330,14 @@ 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;
}) => {
@@ -335,6 +346,7 @@ 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
@@ -0,0 +1,681 @@
// 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 };
+35 -1
View File
@@ -6,9 +6,42 @@ 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
@@ -86,8 +119,9 @@ 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, verify: opts.verify, force: opts.force, waitForIdleMs,
session, text: wrappedText, verify: opts.verify, force: opts.force, waitForIdleMs,
}, waitForIdleRequestOptions(waitForIdleMs));
if (opts.json) {
+15 -10
View File
@@ -20,7 +20,7 @@ import type { StatusDeps } from "./status.js";
interface LibraryEntry {
id: string;
kind: string;
kind: "rig" | "agent" | "workflow";
name: string;
version: string;
sourceType: string;
@@ -145,8 +145,9 @@ 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[]>("/api/specs/library");
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
const entries = res.data ?? [];
// Try exact ID match first
@@ -158,14 +159,16 @@ export async function resolveLibrarySpec(
if (byName.length === 1) return byName[0]!;
if (byName.length > 1) {
const candidates = byName.map((e) => ` ${e.id} — ${e.sourcePath}`).join("\n");
const candidates = byName.map((e) => ` ${e.id} (${e.kind}) — ${e.sourcePath}`).join("\n");
const scope = opts?.kind ? ` ${opts.kind}` : "";
throw new Error(
`Spec name '${nameOrId}' is ambiguous — ${byName.length} entries match.\nUse the ID instead:\n${candidates}`
`Spec name '${nameOrId}' is ambiguous — ${byName.length}${scope} entries match.\nUse the ID instead:\n${candidates}`
);
}
const scope = opts?.kind ? ` ${opts.kind}` : "";
throw new Error(
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
`Spec '${nameOrId}' not found in${scope} library. Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.`
);
}
@@ -195,7 +198,7 @@ Examples:
// specs ls
cmd.command("ls")
.description("List library rigs, agents, and managed apps")
.option("--kind <kind>", "Filter by kind (rig or agent)")
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
.option("--json", "JSON output")
.action(async (opts: { kind?: string; json?: boolean }) => {
try {
@@ -227,11 +230,12 @@ 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: { json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
if (opts.json) {
console.log(JSON.stringify(entry, null, 2));
@@ -254,11 +258,12 @@ 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: { json?: boolean }) => {
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
try {
const client = await getClient();
const entry = await resolveLibrarySpec(client, nameOrId);
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
if (opts.json) {
+31 -6
View File
@@ -29,8 +29,9 @@ 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; json?: boolean }) => {
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; json?: boolean }) => {
const deps = getDepsF();
// Run preflight before auto-start
@@ -39,8 +40,17 @@ Examples:
let resolvedConfig: {
daemon: { port: number; host: string };
db: { path: string };
transcripts: { enabled: boolean; path: string };
transcripts: { enabled: boolean; path: string; lines: number; pollIntervalSeconds: number };
workspace: { root: string };
} | null = null;
// 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");
@@ -48,6 +58,10 @@ 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({
@@ -75,10 +89,13 @@ Examples:
try {
await startDaemon({
port: resolvedConfig?.daemon.port,
host: resolvedConfig?.daemon.host,
host: hostForDaemon,
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) {
@@ -115,10 +132,10 @@ Examples:
}
return rigSummariesCache;
};
if (isRigName) {
if (isRigName && !opts.existing) {
try {
const { resolveLibrarySpec } = await import("./specs.js");
const entry = await resolveLibrarySpec(client, source);
const entry = await resolveLibrarySpec(client, source, { kind: "rig" });
// Library match found — check for existing-rig collision
// Use /api/rigs/summary which mirrors findRigsByName (includes stopped rigs)
const rigSummaries = await fetchRigSummaries();
@@ -154,11 +171,19 @@ 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...`);
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
} else if (match?.lifecycleState === "stopped") {
console.log(`Turning on rig "${source}"...`);
}
}
} else if (isRigName && opts.existing && !opts.json) {
const summaries = await fetchRigSummaries();
const match = summaries.find((r) => r.name === source);
if (match?.lifecycleState === "recoverable") {
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
} else {
console.log(`Turning on existing rig "${source}"...`);
}
}
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
+6 -4
View File
@@ -198,11 +198,13 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
}
const client = deps.clientFactory(getDaemonUrl(status));
const query = source.nodeId
? `nodeId=${encodeURIComponent(source.nodeId)}`
: `sessionName=${encodeURIComponent(source.sessionName!)}`;
const params = new URLSearchParams();
if (source.nodeId) params.set("nodeId", source.nodeId);
else params.set("sessionName", source.sessionName!);
const targetRepo = readOpenRigEnv("OPENRIG_TARGET_REPO", "RIGGED_TARGET_REPO");
if (targetRepo) params.set("targetRepo", targetRepo);
const res = await client.get<Record<string, unknown>>(`/api/whoami?${query}`);
const res = await client.get<Record<string, unknown>>(`/api/whoami?${params.toString()}`);
if (opts.json) {
console.log(JSON.stringify(res.data, null, 2));
+41
View File
@@ -140,6 +140,47 @@ 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
@@ -0,0 +1,127 @@
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
//
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
// parses each .md file's YAML frontmatter, and emits a structured gap
// report. Advisory only — never modifies. curate-steward consumes the
// report.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import * as path from "node:path";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
export interface WorkspaceDeps extends StatusDeps {}
interface ValidationGap {
filePath: string;
relativePath: string;
kind: string;
field: string | null;
message: string;
workspaceKind: string | null;
}
interface ValidationReport {
root: string;
workspaceKind: string | null;
totalFiles: number;
filesWithFrontmatter: number;
gapCount: number;
gaps: ValidationGap[];
}
async function withClient<T>(
deps: WorkspaceDeps,
fn: (client: DaemonClient) => Promise<T>,
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
return fn(client);
}
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
const cmd = new Command("workspace").description(
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
);
const getDeps = (): WorkspaceDeps =>
depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
cmd
.command("validate [root]")
.description(
"Walk a workspace root, parse each .md file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: cwd.",
)
.option("--kind <kind>", "Workspace kind to validate against: user | project | knowledge | lab | delivery")
.option("--no-recursive", "Do not descend into subdirectories")
.option("--require-frontmatter", "Report a gap for every .md file without a frontmatter delimiter")
.option("--max-files <n>", "Hard cap on .md files walked", "10000")
.option("--json", "JSON output for agents")
.action(
async (
rootArg: string | undefined,
opts: {
kind?: string;
recursive?: boolean;
requireFrontmatter?: boolean;
maxFiles: string;
json?: boolean;
},
) => {
const root = path.resolve(rootArg ?? process.cwd());
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<ValidationReport>("/api/workspace/validate", {
root,
workspaceKind: opts.kind,
recursive: opts.recursive !== false,
requireFrontmatter: opts.requireFrontmatter ?? false,
maxFiles: Number.parseInt(opts.maxFiles, 10),
});
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
process.exitCode = 1;
return;
}
const report = res.data;
if (opts.json) {
console.log(JSON.stringify(report));
} else {
renderHumanReport(report);
}
// Exit non-zero when gaps found — operators chain into hygiene fix loops.
if (report.gapCount > 0) process.exitCode = 1;
});
},
);
return cmd;
}
function renderHumanReport(r: ValidationReport): void {
console.log(`workspace root: ${r.root}`);
console.log(`workspace kind: ${r.workspaceKind ?? "(none — kind-agnostic structural check)"}`);
console.log(`files walked: ${r.totalFiles}`);
console.log(`with frontmatter: ${r.filesWithFrontmatter}`);
console.log(`gaps: ${r.gapCount}`);
if (r.gapCount === 0) {
console.log("\n no gaps — canon is clean against v0 contract.");
return;
}
console.log("\n Gaps:");
for (const g of r.gaps) {
const fieldStr = g.field ? ` [${g.field}]` : "";
console.log(` [${g.kind}] ${g.relativePath}${fieldStr}`);
console.log(` ${g.message}`);
}
}
+629 -45
View File
@@ -1,47 +1,358 @@
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 };
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;
};
};
}
const DEFAULTS: RiggedConfig = {
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
const DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"If You Are About To Compact\" protocol. Create or update the mental-model restore map before compaction. If you are in the middle of a tiny atomic step, finish that step first; otherwise this preparation is the next priority.";
const DEFAULT_CLAUDE_COMPACTION_COMPACT_INSTRUCTION = "";
const DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"If You Just Compacted\" protocol.";
const DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION =
"Read the claude-compaction-restore skill and follow its \"Required Read-Depth Audit\" protocol.";
const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigPath(
"compaction/post-compact-extra.md",
);
const DEFAULTS = {
daemon: { port: 7433, host: "127.0.0.1" },
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts") },
};
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts"), lines: 1000, pollIntervalSeconds: 2 },
workspace: {
root: DEFAULT_WORKSPACE_ROOT,
slicesRoot: "",
steeringPath: "",
fieldNotesRoot: "",
specsRoot: "",
dogfoodEvidenceRoot: "",
// 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;
const VALID_KEYS = [
export const VALID_KEYS = [
"daemon.port",
"daemon.host",
"db.path",
"transcripts.enabled",
"transcripts.path",
// V1 pre-release CLI/daemon Item 1 — SC-29 EXCEPTION #4 allowlist
// sub-piece: transcript rotation tunables (line count + poll interval).
"transcripts.lines",
"transcripts.poll_interval_seconds",
"workspace.root",
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"ui.preview.refresh_interval_seconds",
"ui.preview.max_pins",
"ui.preview.default_lines",
"recovery.auto_drive_provider_prompts",
"recovery.provider_auth_env_allowlist",
// V1 Phase 4 SC-29 exception — allowlist-only additions.
"agents.advisor_session",
"agents.operator_session",
// 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;
type ValidKey = typeof VALID_KEYS[number];
export type ValidKey = typeof VALID_KEYS[number];
const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
// Only the original runtime keys keep RIGGED_* aliases for upgrade
// compatibility. New typed keys use OPENRIG_* only.
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
"transcripts.lines": { primary: "OPENRIG_TRANSCRIPTS_LINES" },
"transcripts.poll_interval_seconds": { primary: "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS" },
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT" },
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT" },
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH" },
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT" },
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT" },
"workspace.dogfood_evidence_root": { primary: "OPENRIG_DOGFOOD_EVIDENCE_ROOT" },
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
// OPENRIG_PROGRESS_SCAN_ROOTS become the env override for the new
// typed keys (no breaking change).
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST" },
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS" },
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION" },
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION" },
"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>, key: string): unknown {
const parts = key.split(".");
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
let current: unknown = obj;
for (const part of parts) {
if (current == null || typeof current !== "object") return undefined;
@@ -50,8 +361,7 @@ function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
return current;
}
function setNestedValue(obj: Record<string, unknown>, key: string, value: unknown): void {
const parts = key.split(".");
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
@@ -63,12 +373,58 @@ function setNestedValue(obj: Record<string, unknown>, key: string, value: unknow
current[parts[parts.length - 1]!] = value;
}
function getDefaultValue(key: ValidKey): string | number | boolean {
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, key) as string | number | boolean;
// Per-subdir defaults derived from workspace.root. Steering is a FILE path.
// Slice discovery defaults to the mission-aware workspace/missions contract;
// the indexer remains backward-compatible with flat slice roots when an
// operator sets workspace.slices_root explicitly.
// Files and Progress default to the whole workspace so a fresh
// `rig config init-workspace` install is browsable without extra env wiring.
export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): string {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "missions");
case "workspace.steering_path": return join(workspaceRoot, "STEERING.md");
case "workspace.field_notes_root": return join(workspaceRoot, "field-notes");
case "workspace.specs_root": return join(workspaceRoot, "specs");
case "workspace.dogfood_evidence_root": return join(workspaceRoot, "dogfood-evidence");
case "files.allowlist": return `workspace:${workspaceRoot}`;
case "progress.scan_roots": return `workspace:${workspaceRoot}`;
// 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 coerceValue(key: ValidKey, raw: string): string | number | boolean {
const defaultVal = getDefaultValue(key);
function deriveLegacyWorkspaceDefault(key: ValidKey, workspaceRoot: string): string | null {
switch (key) {
case "workspace.slices_root": return join(workspaceRoot, "slices");
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
default: return null;
}
}
const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
"workspace.slices_root",
"workspace.steering_path",
"workspace.field_notes_root",
"workspace.specs_root",
"workspace.dogfood_evidence_root",
"files.allowlist",
"progress.scan_roots",
"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);
if (typeof defaultVal === "number") {
const n = parseInt(raw, 10);
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
@@ -82,6 +438,76 @@ function coerceValue(key: ValidKey, raw: string): string | number | boolean {
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;
@@ -92,62 +518,192 @@ export class ConfigStore {
resolve(): RiggedConfig {
const fileConfig = this.readConfigFile();
const resolveKey = (key: ValidKey): string | number | boolean => {
// 1. Environment variable
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
if (envVal !== undefined && envVal !== "") {
return coerceValue(key, envVal);
}
// 2. Config file
const fileVal = getNestedValue(fileConfig, key);
if (fileVal !== undefined && fileVal !== null) {
return fileVal as string | number | boolean;
}
// 3. Default
return getDefaultValue(key);
};
// Workspace root must be resolved first so derived per-subdir
// defaults can use it.
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const v = (key: ValidKey) =>
this.resolveOne(key, fileConfig, workspaceRoot).value;
return {
daemon: {
port: resolveKey("daemon.port") as number,
host: resolveKey("daemon.host") as string,
port: v("daemon.port") as number,
host: v("daemon.host") as string,
},
db: {
path: resolveKey("db.path") as string,
path: v("db.path") as string,
},
transcripts: {
enabled: resolveKey("transcripts.enabled") as boolean,
path: resolveKey("transcripts.path") as string,
enabled: v("transcripts.enabled") as boolean,
path: v("transcripts.path") as string,
lines: v("transcripts.lines") as number,
pollIntervalSeconds: v("transcripts.poll_interval_seconds") as number,
},
workspace: {
root: workspaceRoot,
slicesRoot: v("workspace.slices_root") as string,
steeringPath: v("workspace.steering_path") as string,
fieldNotesRoot: v("workspace.field_notes_root") as string,
specsRoot: v("workspace.specs_root") as string,
dogfoodEvidenceRoot: v("workspace.dogfood_evidence_root") as string,
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,
},
},
};
}
get(key: string): string | number | boolean {
/**
* Resolve a single key with its source. Used by the daemon HTTP route
* + UI Settings panel for honest provenance display (env / file /
* default).
*/
resolveWithSource(key: string): ResolvedSetting {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const config = this.resolve();
return getNestedValue(config as unknown as Record<string, unknown>, key) as string | number | boolean;
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
return this.resolveOne(key, fileConfig, workspaceRoot);
}
/** Resolve all valid keys with sources. Convenience for the UI. */
resolveAllWithSource(): Record<ValidKey, ResolvedSetting> {
const fileConfig = this.readConfigFile();
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
const out = {} as Record<ValidKey, ResolvedSetting>;
for (const key of VALID_KEYS) {
out[key] = this.resolveOne(key, fileConfig, workspaceRoot);
}
return out;
}
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
const defaultValue = getDefaultValue(key, workspaceRoot);
// 1. Environment variable — 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;
}
set(key: string, value: string): void {
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
const coerced = coerceValue(key, value);
// readConfigFile (not Raw) — throws on malformed config so set doesn't silently overwrite
const fileConfig = this.readConfigFile();
setNestedValue(fileConfig, key, coerced);
const workspaceRoot = (getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined)
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|| DEFAULT_WORKSPACE_ROOT;
const coerced = coerceAndValidate(key, value, workspaceRoot);
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
mkdirSync(dirname(this.configPath), { recursive: true });
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
reset(): void {
try {
unlinkSync(this.configPath);
} catch {
// File doesn't exist — that's fine
/**
* Clear an override. Without a key argument, deletes the whole config
* file (revert all to defaults). With a key, removes just that key
* from the config file.
*/
reset(key?: string): void {
if (key === undefined) {
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
return;
}
if (!isValidKey(key)) {
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
}
if (!existsSync(this.configPath)) return;
const fileConfig = this.readConfigFile();
const parts = KEY_TO_PATH[key];
const parentParts = parts.slice(0, -1);
const leaf = parts[parts.length - 1]!;
const parent = getNestedValue(fileConfig, parentParts) as Record<string, unknown> | undefined;
if (parent && leaf in parent) {
delete parent[leaf];
}
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
}
private readConfigFile(): Record<string, unknown> {
@@ -161,5 +717,33 @@ export class ConfigStore {
);
}
}
}
// --- User Settings v0: named-pair decoder ---
//
// `files.allowlist` and `progress.scan_roots` are stored as the same
// comma-separated `name:/abs/path` strings UEP introduced via env var.
// This helper decodes the raw string into structured pairs. Invalid
// entries (no colon, empty name/path) are silently skipped per UEP
// convention; duplicate names: last wins.
export interface NamedPair {
name: string;
path: string;
}
export function parseNamedPairs(raw: string): NamedPair[] {
if (!raw || !raw.trim()) return [];
const out = new Map<string, string>();
for (const pair of raw.split(",")) {
const trimmed = pair.trim();
if (!trimmed) continue;
const colon = trimmed.indexOf(":");
if (colon === -1) continue;
const name = trimmed.slice(0, colon).trim();
const rawPath = trimmed.slice(colon + 1).trim();
if (!name || !rawPath) continue;
out.set(name, rawPath);
}
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
}
+168 -8
View File
@@ -3,6 +3,7 @@ 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;
@@ -31,6 +32,18 @@ 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 {
@@ -64,6 +77,45 @@ 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}`);
@@ -193,7 +245,31 @@ const ENV_SCRUB_EXACT = new Set([
export function buildDaemonEnv(
baseEnv: Record<string, string>,
opts: { port: number; host: string; db: string; transcriptsEnabled?: boolean; transcriptsPath?: 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;
},
): Record<string, string> {
const env: Record<string, string> = {};
@@ -205,7 +281,9 @@ export function buildDaemonEnv(
// Explicit OPENRIG_* overrides — always win over inherited values
env["OPENRIG_PORT"] = String(opts.port);
env["OPENRIG_HOST"] = opts.host;
if (opts.host !== undefined) {
env["OPENRIG_HOST"] = opts.host;
}
env["OPENRIG_DB"] = opts.db;
if (opts.transcriptsEnabled !== undefined) {
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
@@ -213,14 +291,30 @@ 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);
@@ -237,7 +331,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
await fetchDaemonProbe(deps, `http://${probeHost}:${port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
@@ -251,16 +345,22 @@ 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,
host: explicitHost,
db,
transcriptsEnabled: opts.transcriptsEnabled,
transcriptsPath: opts.transcriptsPath,
transcriptsLines: opts.transcriptsLines,
transcriptsPollIntervalSeconds: opts.transcriptsPollIntervalSeconds,
skipKernelBoot: opts.skipKernelBoot,
}),
stdio: ["ignore", logFd, logFd],
detached: true,
@@ -270,8 +370,9 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const pid = child.pid!;
// Poll healthz
const healthzUrl = `http://${host}:${port}/healthz`;
// 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`;
let healthy = false;
for (let i = 0; i < HEALTHZ_RETRIES; i++) {
try {
@@ -295,7 +396,10 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const state: DaemonState = {
pid,
port,
host,
// 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,
db,
startedAt: new Date().toISOString(),
};
@@ -432,3 +536,59 @@ 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: wrandom # optional
* user: your-username # optional
* notes: "Tart VM" # optional
*
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
+15
View File
@@ -17,6 +17,7 @@ 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";
@@ -41,6 +42,10 @@ 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";
@@ -93,6 +98,10 @@ export interface ProgramDeps {
askDeps?: StatusDeps;
chatroomDeps?: StatusDeps;
specsDeps?: StatusDeps;
contextPackDeps?: StatusDeps;
pluginDeps?: StatusDeps;
agentImageDeps?: StatusDeps;
workspaceDeps?: WorkspaceDeps;
whoamiDeps?: StatusDeps;
expandDeps?: StatusDeps;
envDeps?: StatusDeps;
@@ -151,6 +160,10 @@ 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());
@@ -170,6 +183,8 @@ 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;
}
@@ -0,0 +1,26 @@
---
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]
@@ -0,0 +1,22 @@
---
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]
@@ -0,0 +1,30 @@
---
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]
@@ -0,0 +1,20 @@
---
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]
@@ -0,0 +1,25 @@
---
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]
@@ -0,0 +1,30 @@
---
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]
@@ -0,0 +1,26 @@
---
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]
@@ -0,0 +1,22 @@
---
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
@@ -0,0 +1,173 @@
// 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
@@ -0,0 +1,517 @@
// 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
@@ -0,0 +1,82 @@
// 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
@@ -0,0 +1,97 @@
// 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",
];
+7 -5
View File
@@ -23,14 +23,16 @@ 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;
const legacyValue = process.env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
if (legacy) {
const legacyValue = process.env[legacy];
if (legacyValue !== undefined && legacyValue !== "") {
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
return legacyValue;
}
}
return undefined;
@@ -29,7 +29,7 @@ import type {
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
@@ -29,7 +29,7 @@ import type {
} from "./types.js";
const PATH_PATTERNS: readonly RegExp[] = [
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
];
+142
View File
@@ -0,0 +1,142 @@
// PL-016 hardening v0+1 — `rig agent-image create` --image-version flag
// regression test.
//
// Pins:
// - --image-version <v> propagates as body.version on the snapshot POST
// - --version on the per-command surface no longer shadows Commander's
// global --version (creating a separate per-command name resolves
// the collision; absence of `--version` as a per-command option is
// the contract this test enforces)
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { Command } from "commander";
import { agentImageCommand } from "../src/commands/agent-image.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function runningState(port: number): DaemonState {
return { pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" };
}
function runningLifecycleDeps(port: number): LifecycleDeps {
return mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify(runningState(port));
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
});
}
interface CapturedCall {
method: string;
url: string;
body: string;
}
function createMockDaemon(captured: CapturedCall[]) {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
if (url.pathname === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
if (req.method === "POST" && url.pathname === "/api/agent-images/snapshot") {
let body = "";
req.on("data", (c: Buffer) => { body += c.toString(); });
req.on("end", () => {
captured.push({ method: req.method!, url: url.pathname, body });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
imageId: "agent-image:test:2",
imagePath: "/tmp/agent-images/test",
manifest: { name: "test", version: "2", runtime: "claude-code" },
}));
});
return;
}
res.writeHead(404);
res.end();
});
return {
server,
close: () => new Promise<void>((r) => server.close(() => r())),
listen: () => new Promise<number>((r) => {
server.listen(0, () => {
const addr = server.address();
r(typeof addr === "object" && addr ? addr.port : 0);
});
}),
};
}
describe("rig agent-image create — --image-version flag", () => {
const captured: CapturedCall[] = [];
let srv: ReturnType<typeof createMockDaemon>;
let port: number;
beforeAll(async () => {
srv = createMockDaemon(captured);
port = await srv.listen();
});
afterAll(async () => { await srv.close(); });
it("--image-version <v> propagates as body.version on the snapshot POST", async () => {
captured.length = 0;
const program = new Command();
program.exitOverride();
program.addCommand(agentImageCommand({
lifecycleDeps: runningLifecycleDeps(port),
clientFactory: (url) => new DaemonClient(url),
}));
// Suppress console output for the test.
const origLog = console.log;
console.log = () => {};
try {
await program.parseAsync([
"node", "rig", "agent-image", "create", "alice@rigA",
"--name", "test",
"--image-version", "2",
]);
} finally {
console.log = origLog;
}
expect(captured).toHaveLength(1);
const body = JSON.parse(captured[0]!.body);
expect(body.version).toBe("2");
expect(body.name).toBe("test");
expect(body.sourceSession).toBe("alice@rigA");
});
it("the per-command create command does NOT register --version (avoids Commander.js global collision)", () => {
const cmd = agentImageCommand();
const createCmd = cmd.commands.find((c) => c.name() === "create");
expect(createCmd).toBeDefined();
const optionNames = createCmd!.options.map((o) => o.long);
// Must include --image-version (the rename target); must NOT include
// --version (which Commander.js intercepts as the global version flag).
expect(optionNames).toContain("--image-version");
expect(optionNames).not.toContain("--version");
});
});
@@ -0,0 +1,699 @@
// 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);
});
});
+112 -1
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 } from "../src/config-store.js";
import { ConfigStore, VALID_KEYS } 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,13 +110,26 @@ 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 () => {
@@ -266,6 +279,50 @@ 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"));
@@ -281,4 +338,58 @@ 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
@@ -0,0 +1,357 @@
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
// context-pack` CLI verb family tests.
//
// Stands up a small in-memory daemon mock for the /api/context-packs/*
// surface and exercises each subcommand against it.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { contextPackCommand } from "../src/commands/context-pack.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const errLogs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
process.exitCode = undefined;
try { await fn(); } catch { /* commander.exitOverride */ }
const exitCode = process.exitCode;
console.log = origLog;
console.error = origErr;
process.exitCode = origExitCode;
resolve({ logs, errLogs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
const FIXTURE_PACK = {
id: "context-pack:smoke:1",
kind: "context-pack" as const,
name: "smoke",
version: "1",
purpose: "Smoke test pack",
sourceType: "user_file" as const,
sourcePath: "/home/op/.openrig/context-packs/smoke",
relativePath: "smoke",
updatedAt: "2026-05-04T00:00:00Z",
manifestEstimatedTokens: null,
derivedEstimatedTokens: 100,
files: [
{ path: "notes.md", role: "notes", summary: "Smoke notes", absolutePath: "/abs/notes.md", bytes: 50, estimatedTokens: 13 },
],
};
const FIXTURE_PREVIEW = {
id: FIXTURE_PACK.id,
name: FIXTURE_PACK.name,
version: FIXTURE_PACK.version,
bundleText: "# OpenRig Context Pack: smoke v1\n\nSmoke test pack\n\n## File: notes.md (role: notes) — Smoke notes\n\nSmoke body\n",
bundleBytes: 110,
estimatedTokens: 28,
files: [{ path: "notes.md", role: "notes", bytes: 50, estimatedTokens: 13 }],
missingFiles: [] as Array<{ path: string; role: string }>,
};
describe("rig context-pack CLI (PL-014)", () => {
let server: http.Server;
let port: number;
let sendLog: Array<{ id: string; body: unknown }>;
let sendBehavior: "ok" | "fail" = "ok";
beforeAll(async () => {
sendLog = [];
server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
const url = req.url ?? "";
if (url === "/api/context-packs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([FIXTURE_PACK]));
return;
}
if (url === "/api/context-packs/library/sync" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: 1, errors: [], entries: [FIXTURE_PACK] }));
return;
}
const idMatch = url.match(/^\/api\/context-packs\/library\/([^/]+)(\/(preview|send))?$/);
if (idMatch) {
const id = decodeURIComponent(idMatch[1]!);
const sub = idMatch[3];
if (id !== FIXTURE_PACK.id) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `Context pack '${id}' not found in library` }));
return;
}
if (sub === "preview" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PREVIEW));
return;
}
if (sub === "send" && req.method === "POST") {
const parsed = JSON.parse(body || "{}") as { destinationSession?: string; dryRun?: boolean };
sendLog.push({ id, body: parsed });
if (sendBehavior === "fail") {
res.writeHead(502, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found", reason: "session_missing" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
...FIXTURE_PREVIEW,
destinationSession: parsed.destinationSession,
dryRun: !!parsed.dryRun,
...(parsed.dryRun ? {} : { sent: true }),
}));
return;
}
if (!sub && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(FIXTURE_PACK));
return;
}
}
res.writeHead(404);
res.end();
});
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as { port: number }).port;
});
afterAll(() => server.close());
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(contextPackCommand(runningDeps(port)));
return prog;
}
function writePack(manifest: string): string {
const dir = mkdtempSync(join(tmpdir(), "openrig-context-pack-test-"));
writeFileSync(join(dir, "manifest.yaml"), manifest);
return dir;
}
it.each([
[
"path traversal",
`name: invalid-pack
version: 1.0.0
files:
- path: ../secret.md
role: notes
`,
"must be a relative path inside the pack",
],
[
"absolute path",
`name: invalid-pack
version: 1.0.0
files:
- path: /etc/passwd
role: notes
`,
"must be a relative path inside the pack",
],
[
"leading backslash",
`name: invalid-pack
version: 1.0.0
files:
- path: '\\evil.md'
role: notes
`,
"must be a relative path inside the pack",
],
[
"unknown suffix",
`name: invalid-pack
version: 1.0.0
files:
- path: secret.bin
role: notes
`,
"has an unsupported suffix",
],
[
"missing name",
`version: 1.0.0
files:
- path: notes.md
role: notes
`,
"missing required field 'name'",
],
[
"missing version",
`name: invalid-pack
files:
- path: notes.md
role: notes
`,
"missing required field 'version'",
],
[
"missing files",
`name: invalid-pack
version: 1.0.0
`,
"must declare 'files: [...]'",
],
[
"missing path",
`name: invalid-pack
version: 1.0.0
files:
- role: notes
`,
"missing 'path'",
],
[
"missing role",
`name: invalid-pack
version: 1.0.0
files:
- path: notes.md
`,
"missing 'role'",
],
])("rejects invalid context-pack add manifest: %s", async (_name, manifest, expectedError) => {
const dir = writePack(manifest);
try {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "add", dir]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain(expectedError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("list shows discovered packs", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("smoke");
expect(logs.join("\n")).toContain("1 files");
});
it("list --json emits JSON", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "list", "--json"]);
});
const parsed = JSON.parse(logs.join("")) as Array<{ name: string }>;
expect(parsed[0]!.name).toBe("smoke");
});
it("show resolves by name", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "smoke"]);
});
expect(logs.join("\n")).toContain("Name: smoke");
expect(logs.join("\n")).toContain("Smoke test pack");
});
it("show fails with helpful error on unknown name", async () => {
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "missing-pack"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("not found in library");
});
it("preview prints the assembled bundle text", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "preview", "smoke"]);
});
expect(logs.join("\n")).toContain("# OpenRig Context Pack: smoke v1");
expect(logs.join("\n")).toContain("Smoke body");
});
it("send --dry-run does not invoke the daemon send path twice", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig", "--dry-run"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog).toHaveLength(1);
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: true });
expect(logs.join("\n")).toContain("(dry-run)");
});
it("send (real) reports successful delivery", async () => {
sendLog.length = 0;
sendBehavior = "ok";
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig"]);
});
expect(exitCode).toBeUndefined();
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: false });
expect(logs.join("\n")).toContain("Sent smoke v1");
});
it("send fails fast when daemon returns 502", async () => {
sendLog.length = 0;
sendBehavior = "fail";
const { errLogs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "missing@rig"]);
});
expect(exitCode).toBe(1);
expect(errLogs.join("\n")).toContain("Session not found");
sendBehavior = "ok";
});
it("sync reports indexed count", async () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "context-pack", "sync"]);
});
expect(exitCode).toBeUndefined();
expect(logs.join("\n")).toContain("Indexed 1 context pack");
});
});
+254
View File
@@ -10,6 +10,7 @@ import {
getDaemonPath,
resolveDaemonPath,
buildDaemonEnv,
ensureWorkspaceScaffold,
STATE_FILE,
LOG_FILE,
OPENRIG_DIR,
@@ -88,6 +89,25 @@ 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();
@@ -917,4 +937,238 @@ 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/mschwarz/code/rigged/packages/cli/src/commands",
baseDir: "/Users/example/code/openrig/packages/cli/src/commands",
exists: (p) =>
p === "/Users/mschwarz/code/rigged/packages/daemon/dist/index.js"
|| p === "/Users/mschwarz/code/rigged/packages/ui/dist/index.html",
p === "/Users/example/code/openrig/packages/daemon/dist/index.js"
|| p === "/Users/example/code/openrig/packages/ui/dist/index.html",
});
const { checks } = runDoctorChecks(deps);
+22
View File
@@ -124,6 +124,7 @@ 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(); });
@@ -144,6 +145,7 @@ 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({
@@ -298,6 +300,16 @@ 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");
@@ -423,6 +435,16 @@ 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: wrandom
user: tester
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: "wrandom",
user: "tester",
notes: "Tart VM",
});
}
@@ -62,14 +62,14 @@ hosts:
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: wrandom
user: tester
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.registry.hosts.map((h) => h.id)).toEqual(["vm-a", "laptop-b"]);
expect(r.registry.hosts[0]?.user).toBeUndefined();
expect(r.registry.hosts[1]?.user).toBe("wrandom");
expect(r.registry.hosts[1]?.user).toBe("tester");
}
});
});
+781
View File
@@ -0,0 +1,781 @@
// 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/);
});
});
});
@@ -0,0 +1,236 @@
// 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
@@ -0,0 +1,267 @@
// 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");
});
});
+3 -1
View File
@@ -616,7 +616,9 @@ describe("Ps CLI", () => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--filter", "noequals"]);
});
expect(logs.some((l) => l.includes("--filter must be key=value"))).toBe(true);
// 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(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 restore snap-123 --rig rig-1",
command: "rig up --existing test-rig",
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 restore snap-123 --rig rig-1");
expect(output).toContain("rig up --existing test-rig");
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/wrandom/code/projects/openrig-hub",
source_cwd: "/Users/example/code/projects/openrig-hub",
target_rig: "openrig-velocity",
target_runtime: "claude-code",
target_workspace_root: "/Users/wrandom/code/projects/openrig-hub",
target_workspace_root: "/Users/example/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/wrandom/code/projects/openrig-hub","id":"abc-123"}}
const content = `{"type":"session_meta","payload":{"cwd":"/Users/example/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/wrandom/code/projects/openrig-hub");
expect(result.sessionMeta?.cwd).toBe("/Users/example/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/wrandom/x.txt\\"}"}}
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/example/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/wrandom/code/projects/openrig-hub/README.md\\"}"}}`;
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/example/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/wrandom/code","sessionId":"sess-2"}`;
{"type":"user","message":{"role":"user","content":"first"},"cwd":"/Users/example/code","sessionId":"sess-2"}`;
const result = parseClaudeTranscript(content);
expect(result.sessionMeta?.cwd).toBe("/Users/wrandom/code");
expect(result.sessionMeta?.cwd).toBe("/Users/example/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/wrandom/x.txt","content":"file body"},"cwd":"/x","sessionId":"s"}`;
{"type":"attachment","attachment":{"path":"/Users/example/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/wrandom/code/projects/openrig-hub",
content: "/Users/example/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/wrandom/code/projects/openrig-hub")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/example/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/wrandom/code/x.md" } },
{ type: "tool_use", id: "t2", name: "Read", input: { path: "/Users/example/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/wrandom/code/x.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/example/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/wrandom prefix and includes trailing whitespace and word
// /Users/example 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/wrandom/code/a.md\nrunning" },
{ type: "tool_result", tool_use_id: "t2", content: "/Users/wrandom/code/b.md\nrunning" },
{ 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" },
],
},
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/wrandom/code/a.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/b.md")).toBe(true);
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);
});
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/wrandom or recognized prefix path in the input.
// No /Users/example 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/wrandom/code/projects/openrig-hub/README.md" },
{ type: "text", text: "found at /Users/example/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/wrandom/code/projects/openrig-hub/README.md")).toBe(true);
expect(r.paths.some((p) => p.path === "/Users/example/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/wrandom/code/projects/openrig-hub","id":"src-session-id"}}
structured: parseCodexJsonl(`{"type":"session_meta","payload":{"cwd":"/Users/example/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/wrandom/code/projects/openrig-hub",
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
defaultTargetRepo: "/Users/example/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/wrandom/code/projects/openrig-hub",
sourceCwd: "/Users/example/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/wrandom/code/projects/openrig-hub","id":"velocity-${role}@openrig-velocity-code"}}`,
`{"type":"session_meta","payload":{"cwd":"/Users/example/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/wrandom/code/projects/openrig-hub",
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
defaultTargetRepo: "/Users/example/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/wrandom/code/projects/openrig-hub",
sourceCwd: "/Users/example/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/wrandom/code/projects/openrig-hub");
expect(summary.source_cwd).toBe("/Users/example/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/wrandom/code/x","id":"src-session"}}
const fixtureContent = `{"type":"session_meta","payload":{"cwd":"/Users/example/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/wrandom/code/x" } };
return { status: 200, data: { content: fixtureContent, cwd: "/Users/example/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/wrandom/code/projects/openrig-hub",
cwd: "/Users/example/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/wrandom/code/projects/openrig-hub" },
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/example/code/projects/openrig-hub" },
],
},
cwd: "/Users/wrandom/code/projects/openrig-hub",
cwd: "/Users/example/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/wrandom/code/projects/openrig-hub",
targetWorkspaceRoot: "/Users/example/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/wrandom/code/projects/openrig-hub",
sourceCwd: "/Users/example/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/wrandom/code/projects/openrig-hub")).toBe(true);
p.path === "/Users/example/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/wrandom/code/projects/openrig-hub",
cwd: "/Users/example/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/wrandom/code/projects/openrig-hub");
expect(summary.source_cwd).toBe("/Users/example/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/wrandom/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
const VELOCITY_ROOT = "/Users/example/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/wrandom/code/projects/openrig-hub",
cwd: "/Users/example/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/wrandom/code/projects/openrig-hub" },
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/example/code/projects/openrig-hub" },
],
},
cwd: "/Users/wrandom/code/projects/openrig-hub",
cwd: "/Users/example/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/wrandom/code/projects/openrig-hub");
expect(summary.source_cwd).toBe("/Users/example/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/wrandom/code/projects/openrig-hub")).toBe(true);
p.path === "/Users/example/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/wrandom/code/projects/openrig-hub", id: "m3-driver@m3-rig" },
payload: { cwd: "/Users/example/code/projects/openrig-hub", id: "m3-driver@m3-rig" },
}),
JSON.stringify({
type: "response_item",
+565
View File
@@ -0,0 +1,565 @@
// 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
@@ -0,0 +1,177 @@
// 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
@@ -0,0 +1,339 @@
// 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
@@ -0,0 +1,41 @@
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/);
});
});
+5 -1
View File
@@ -137,11 +137,15 @@ describe("Send CLI", () => {
"/api/transport/send",
expect.objectContaining({
session: "dev-impl@my-rig",
text: "hello",
text: expect.stringContaining("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 () => {
+39 -3
View File
@@ -146,18 +146,46 @@ 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, and managed apps.");
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, workflows, 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", "--json"]);
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--kind", "rig", "--json"]);
});
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.kind).toBe("rig");
@@ -177,12 +205,14 @@ describe("Specs CLI", () => {
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
// Use temp dir as HOME to avoid polluting real ~/.openrig/specs/
// Use temp OpenRig 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 ?? "");
@@ -220,6 +250,8 @@ 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");
@@ -275,7 +307,9 @@ 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 ?? "");
@@ -320,6 +354,8 @@ 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 });
}
});
+165 -3
View File
@@ -618,6 +618,100 @@ 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";
@@ -690,6 +784,74 @@ 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");
@@ -721,7 +883,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');
expect(output).toContain('Recovering rig "stale-velocity" from latest snapshot or current DB state');
expect(output).not.toContain('Turning on rig "stale-velocity"');
expect(exitCode).toBeUndefined(); // 0
});
@@ -901,8 +1063,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 — Guard Finding 3 — which proved nothing about plan
// visibility of starter resolution.
// forwarding, 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");
@@ -0,0 +1,15 @@
{
"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"
}
@@ -0,0 +1,24 @@
{
"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"
}
}
@@ -0,0 +1,200 @@
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.
@@ -0,0 +1,42 @@
# 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
@@ -0,0 +1,78 @@
{
"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
}
]
}
]
}
}
@@ -0,0 +1,37 @@
{
"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,6 +1,18 @@
#!/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 = "";
@@ -0,0 +1,172 @@
#!/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,
};
@@ -0,0 +1,104 @@
---
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)
@@ -0,0 +1,131 @@
---
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.
@@ -0,0 +1,174 @@
#!/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.`,
});
}
@@ -0,0 +1,433 @@
#!/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);
}
@@ -0,0 +1 @@
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.
@@ -0,0 +1 @@
Read the claude-compaction-restore skill and follow its "If You Just Compacted" protocol.
@@ -0,0 +1,305 @@
---
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,11 +1,23 @@
---
name: rig-architect
description: Use when designing multi-agent topologies, authoring rig specs and agent specs, creating agent startup content (guidance, skills, culture), or diagnosing why a launched rig's agents aren't behaving as intended. Covers the full authoring lifecycle from user intent to validated, launchable rig.
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
---
# Rig Architect
# OpenRig Architect
You are now a rig architect. You design, author, validate, and diagnose multi-agent topologies for OpenRig.
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.
@@ -17,14 +29,14 @@ Load these before starting any design work. The quality of your output depends o
**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/reference/` or inside the OpenRig installation under `specs/agents/shared/skills/core/openrig-user/SKILL.md`.
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:**
@@ -35,13 +47,8 @@ Load these before starting any design work. The quality of your output depends o
**Read if present on this host:**
4. **Host-level doctrine** at `~/code/substrate/shared-docs/HOST-TOPOLOGY.md` (or wherever substrate lives on this host). If this file exists, it supersedes the baseline process below for complex or high-stakes rigs. Specifically:
- §3 defines the canonical rig classes (kernel, project, ephemeral, infra-build, managed-app). Classify before designing.
- §4 defines context-sharing patterns (pods as context domains, pair pattern, HA via mental-model-ha skill + substrate session logs, terminal nodes as common-room, chatroom/substrate/transcripts/Corpus).
- **§7 is the canonical 12-step rig-spec authoring SOP for high-stakes rigs — use it instead of the baseline "Design Process" below when the rig is ≥4 members, uses HA, is a managed-app, or will be shared/copied.** The baseline below still applies to small/focused rigs.
- §10 contains the host's bootstrap sequence so you know where your new rig fits.
- §12 has the naming canon and vocabulary mapping to shipped terms.
5. **Building-agent-software skill** at `~/code/substrate/shared-docs/skills/building-agent-software/` if present. Design principles for agent-facing tools and surfaces. Relevant when your rig ships a new CLI, service, or managed app.
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
@@ -104,10 +111,15 @@ Each pod member needs a clear role. The role determines:
| orchestrator | `local:agents/orchestration/orchestrator` | Rig orchestration lead |
| implementer | `local:agents/development/implementer` | TDD implementation agent |
| qa | `local:agents/development/qa` | Quality assurance agent |
| reviewer | `local:agents/review/reviewer` | Independent code reviewer |
| design | `local:agents/design` | Product designer |
| 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)
@@ -206,8 +218,9 @@ For rigs that reuse builtin agents, the agents directory is often unnecessary
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. **Launch:** `rig up rig.yaml --cwd /path/to/project`
8. **Verify:** `rig ps --nodes` — all agents ready? Check `rig capture` on each agent.
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
@@ -232,7 +245,7 @@ If validation fails, fix the errors. Do not try to launch an invalid spec — it
**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 the correct skills count.
**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
@@ -252,11 +265,17 @@ If validation fails, fix the errors. Do not try to launch an invalid spec — it
**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 are relative to the agent spec directory. Check `delivery_hint` — use `guidance_merge` for pre-boot content, `send_text` for post-boot instructions.
**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
@@ -0,0 +1,222 @@
---
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.
@@ -0,0 +1,597 @@
---
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`.
@@ -0,0 +1,122 @@
---
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
@@ -0,0 +1,137 @@
---
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
@@ -0,0 +1,48 @@
# 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).
@@ -0,0 +1,79 @@
# 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.2.0",
"version": "0.3.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"build": "tsc",
"build": "tsc && node scripts/copy-workflow-specs.mjs",
"test": "vitest run",
"test:watch": "vitest",
"dev": "tsx watch src/index.ts"
@@ -0,0 +1,19 @@
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,10 +11,9 @@ imports:
profiles:
default:
uses:
skills: [using-superpowers, openrig-user, mental-model-ha, systematic-debugging, verification-before-completion, vault-user]
skills: [using-superpowers, openrig-user, systematic-debugging, verification-before-completion, vault-user]
guidance: []
subagents: []
hooks: []
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
resources:
@@ -28,7 +28,6 @@ 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