mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 11:41:37 -10:00
Compare commits
215
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1205f3be | ||
|
|
e40214f0f8 | ||
|
|
90b177242f | ||
|
|
a536df3130 | ||
|
|
cff08c7e93 | ||
|
|
fe5b23e088 | ||
|
|
934f3c9d63 | ||
|
|
7a0ae0fa81 | ||
|
|
05ca19c11f | ||
|
|
3e32b0a80c | ||
|
|
1c94719b11 | ||
|
|
bf28cc2f7e | ||
|
|
55bdcbb73c | ||
|
|
1a3b313f0d | ||
|
|
dd9820eb2d | ||
|
|
48c0267615 | ||
|
|
629d061325 | ||
|
|
9f86df49e2 | ||
|
|
4ac0d20e35 | ||
|
|
194a1ebb4c | ||
|
|
fd4fb309b5 | ||
|
|
d8d8ee91c8 | ||
|
|
ab822eef53 | ||
|
|
a5132dad87 | ||
|
|
c7b74fa08f | ||
|
|
a8401f6181 | ||
|
|
e5de3abd12 | ||
|
|
5c87145e54 | ||
|
|
bdf4cf6844 | ||
|
|
40d07241ab | ||
|
|
de2dbd4f3a | ||
|
|
6af2754824 | ||
|
|
2f8f26952b | ||
|
|
2673e30ec6 | ||
|
|
eb4acf2c1d | ||
|
|
53e9409306 | ||
|
|
63ee206e93 | ||
|
|
fcfc7dc93c | ||
|
|
ab01331f50 | ||
|
|
6b7043a04d | ||
|
|
315745a167 | ||
|
|
578bd5c57d | ||
|
|
cdce3a6864 | ||
|
|
c7b6df192f | ||
|
|
4e277d7744 | ||
|
|
0a9fb436cd | ||
|
|
e70694f660 | ||
|
|
66785c8ee7 | ||
|
|
fad10a9311 | ||
|
|
3ad1df6339 | ||
|
|
17812d5d3b | ||
|
|
3071234eca | ||
|
|
de62cc2593 | ||
|
|
d7af73706a | ||
|
|
5a78aedc02 | ||
|
|
56b90d48c3 | ||
|
|
2e4067f41d | ||
|
|
e9b1251591 | ||
|
|
878a18296d | ||
|
|
437eaafbb5 | ||
|
|
d50f7b9aa5 | ||
|
|
388ea09e5b | ||
|
|
2def07e878 | ||
|
|
462550807a | ||
|
|
6a7e38fcf3 | ||
|
|
f1e4192975 | ||
|
|
aefe4b4a5d | ||
|
|
501f77766b | ||
|
|
e49bae30eb | ||
|
|
910d9786b5 | ||
|
|
5f3844fa39 | ||
|
|
9e6faddc32 | ||
|
|
6f90d644b4 | ||
|
|
6c7cc0d3f6 | ||
|
|
f657e53c3a | ||
|
|
f09dc5f6c5 | ||
|
|
b2d121c4d4 | ||
|
|
6696649095 | ||
|
|
331e9321e9 | ||
|
|
0843d4bc30 | ||
|
|
186fc4b221 | ||
|
|
c56934f427 | ||
|
|
69df0f1c99 | ||
|
|
2b6224c2be | ||
|
|
cee9a8481a | ||
|
|
69c1bffe7b | ||
|
|
5109053de5 | ||
|
|
6c52cbfef4 | ||
|
|
6aa68d89b2 | ||
|
|
004e36f2fa | ||
|
|
d8ef8dd18a | ||
|
|
89e25027f8 | ||
|
|
4a333f294b | ||
|
|
9efaa249f1 | ||
|
|
fbddd1155e | ||
|
|
a5d5fc3eae | ||
|
|
bad82b3a7b | ||
|
|
efa6f41575 | ||
|
|
bc9c9025b8 | ||
|
|
daccae566f | ||
|
|
9010f4563a | ||
|
|
44b7a3d872 | ||
|
|
8411d0bb0d | ||
|
|
3cafd3927d | ||
|
|
3b7ffa7d3b | ||
|
|
3d8d7e83c8 | ||
|
|
09f8cc8682 | ||
|
|
638c541151 | ||
|
|
c377fa935c | ||
|
|
0cbeca0c6d | ||
|
|
18bbd7f08f | ||
|
|
14a91700ca | ||
|
|
55385068f1 | ||
|
|
69479d9a39 | ||
|
|
85d0844303 | ||
|
|
a4071441c3 | ||
|
|
31b9d63307 | ||
|
|
beb3cc453e | ||
|
|
a41d1091be | ||
|
|
ca5f36d5b7 | ||
|
|
74cf893c00 | ||
|
|
37e2d5fda7 | ||
|
|
0d7e6eca65 | ||
|
|
233ec8128c | ||
|
|
43a0ace5d0 | ||
|
|
848b0327be | ||
|
|
2500ee028e | ||
|
|
105ed7e34d | ||
|
|
c44507fd11 | ||
|
|
30cf604d4c | ||
|
|
e4619cf13a | ||
|
|
a8adfe257a | ||
|
|
8ca8e43753 | ||
|
|
8b074614dc | ||
|
|
d10d277ebf | ||
|
|
9632c2b9bf | ||
|
|
b8758ed0ee | ||
|
|
fd22057318 | ||
|
|
138618637c | ||
|
|
4eda5c5ef4 | ||
|
|
d8881d867e | ||
|
|
697f8e60f8 | ||
|
|
3023de8112 | ||
|
|
bc2c899b45 | ||
|
|
e74ebbc91b | ||
|
|
e4a03ae48b | ||
|
|
b9e64c004a | ||
|
|
da581a9471 | ||
|
|
c10b8f8f81 | ||
|
|
81262deb52 | ||
|
|
fccb65a211 | ||
|
|
ae4ebb53a7 | ||
|
|
93a36c5f57 | ||
|
|
0aefd99a0e | ||
|
|
db8c54208a | ||
|
|
d4088d042e | ||
|
|
48f3c9dd25 | ||
|
|
b60725bccd | ||
|
|
36f6cc199b | ||
|
|
357d490ef3 | ||
|
|
e71ac524dc | ||
|
|
0dc01f188e | ||
|
|
9e9b3e020b | ||
|
|
68eb886041 | ||
|
|
6e0d9f8943 | ||
|
|
099edb33e1 | ||
|
|
39ab0f5508 | ||
|
|
9199ed7954 | ||
|
|
b9330f635b | ||
|
|
4fcd9764eb | ||
|
|
9abb516e6a | ||
|
|
428529d883 | ||
|
|
3f7e7e1ce5 | ||
|
|
1663286d33 | ||
|
|
496aef5a28 | ||
|
|
7a29f192d0 | ||
|
|
94ed046c72 | ||
|
|
d6521cb89d | ||
|
|
8c1479faf7 | ||
|
|
ef2b03272a | ||
|
|
2f4722cd03 | ||
|
|
8c10e9632e | ||
|
|
971da63cc9 | ||
|
|
778aa94ae9 | ||
|
|
fa4cd5034c | ||
|
|
1cefe8e07b | ||
|
|
e653db411e | ||
|
|
d6981151ef | ||
|
|
672d724dff | ||
|
|
1ba4540451 | ||
|
|
72b1dc7069 | ||
|
|
a6c1487ffa | ||
|
|
d16fadcbb0 | ||
|
|
4ab7ee68a3 | ||
|
|
464b810fec | ||
|
|
fa867b5ae6 | ||
|
|
9c16026744 | ||
|
|
8eef407062 | ||
|
|
70998e8ea8 | ||
|
|
aa4f1659ee | ||
|
|
b17c8857c4 | ||
|
|
0f564a2905 | ||
|
|
ec47323f5e | ||
|
|
959394a4ca | ||
|
|
76006c2ece | ||
|
|
fe0a000d51 | ||
|
|
b56ce63b13 | ||
|
|
0e4bfaa418 | ||
|
|
4b0544a127 | ||
|
|
7ac72c37d0 | ||
|
|
e6f854a3e6 | ||
|
|
dd0385e310 | ||
|
|
4771f9adfa | ||
|
|
83df1da3d4 | ||
|
|
ebf106100b |
No files matched your search
-669
@@ -1,669 +0,0 @@
|
||||
# OpenRig Changelog
|
||||
|
||||
Agent-readable release notes for coding agents and human operators installing,
|
||||
upgrading, or operating OpenRig.
|
||||
|
||||
Versioning: pre-1.0 minor releases may include contract additions,
|
||||
deprecations, and behavioral changes. Breaking changes are called out explicitly.
|
||||
|
||||
---
|
||||
|
||||
## [0.3.1] - 2026-05-15
|
||||
|
||||
**Status**: released. npm `@openrig/[email protected]` (latest); GitHub Release
|
||||
`v0.3.1`; git tag `v0.3.1`.
|
||||
|
||||
### Summary For Installing Agents
|
||||
|
||||
- **Package version**: package metadata bumped at release-manager step; CLI
|
||||
reports the new version after `npm publish`.
|
||||
- **Migrations**: no schema-breaking migrations in 0.3.1. Existing databases
|
||||
upgrade by running `rig daemon start`.
|
||||
- **Node engines**: unchanged from 0.3.0 (CLI accepts Node `>=20`).
|
||||
- **Backward compatibility**: existing CLI argument shapes, daemon route
|
||||
paths, RigSpec/AgentSpec schemas, and persisted settings remain backward
|
||||
compatible. New routes are additive. New ConfigStore keys are opt-in
|
||||
default-off.
|
||||
|
||||
### Claude Auto-Compaction Policy
|
||||
|
||||
The headline 0.3.1 feature: operator-configurable Claude session
|
||||
auto-compaction with safe defaults.
|
||||
|
||||
- **Opt-in default-off**: with no policy configured, no behavior change. The
|
||||
daemon never sends an auto-`/compact` until the operator explicitly enables
|
||||
the policy.
|
||||
- **7 new ConfigStore keys** in the `policies.claude_compaction.*` namespace
|
||||
(lockstep across CLI VALID_KEYS + daemon SETTINGS_VALID_KEYS): `enabled`,
|
||||
`threshold_percent` (strict integer 1-100), `compact_instruction` (default
|
||||
empty; appended to `/compact` slash-command args when set),
|
||||
`message_inline`, `message_file_path`, `pre_compact_instruction`,
|
||||
`post_restore_audit_instruction`. Strict validation across all source
|
||||
layers (`set/POST/env/file`) — invalid env values fall back to defaults
|
||||
with a warning to stderr rather than silently coercing.
|
||||
- **5 operator-editable prompt surfaces** rendered in the
|
||||
Settings → Policies UI form: pre-compaction prep, compact instruction,
|
||||
post-compaction restore (inline), restore file path, post-restore audit.
|
||||
Daemon-owned wrappers (usage/threshold framing, trust-channel preservation,
|
||||
marker paths, read-depth enforcement, turn-boundary handshake, dedup +
|
||||
cooldown) are non-editable.
|
||||
- **6-stage daemon-to-LLM lifecycle**:
|
||||
1. Pre-compact prep prompt — full-context Claude writes a mental-model
|
||||
restore map (annotated ASCII file/folder tree) before compaction
|
||||
("save game before quit").
|
||||
2. `/compact` with operator instructions + trust-channel preservation
|
||||
embedded in args.
|
||||
3. Post-compact turn-boundary handshake — non-restorative acknowledgment
|
||||
creates an assistant-turn boundary so the subsequent restore prompt
|
||||
lands in the correct trust context.
|
||||
4. Restore prompt — explicit user-request shape; defense-in-depth
|
||||
fallback chain (marker → JSONL transcript path → session-id → generic).
|
||||
5. Compliance prompt — forces FULL/PARTIAL/NOT_READ read-depth audit
|
||||
table; counters Claude's deferred-execution + token-conservation
|
||||
instincts.
|
||||
6. Cooldown (10-minute default) — prevents re-fire while restore work
|
||||
is still consuming context.
|
||||
- **PreCompact hook + SessionStart/UserPromptSubmit bridge**: the openrig-core
|
||||
plugin ships a marker-bridge that picks up pending-restore markers
|
||||
post-compaction and injects restore directives once. Templates live in
|
||||
the `claude-compaction-restore` skill and ship with the plugin.
|
||||
- **Safety hardening**: SessionTransport classifies typed prompt drafts as
|
||||
attention state — auto-`/compact` retries later instead of overwriting
|
||||
human input. Send-failure does not advance dedup state (transient retry).
|
||||
Re-arm requires threshold-crossing (session must drop below threshold
|
||||
before next auto-compact).
|
||||
|
||||
### Library Explorer Finishing
|
||||
|
||||
The Library destination (skills + plugins + specs) became a fully
|
||||
operator-facing surface:
|
||||
|
||||
- **No duplicate top-level entries**: `> SKILLS` and `> PLUGINS` rows
|
||||
removed from the top of `SpecsTreeView`. Bottom-row clicks now do
|
||||
dual-action (navigate to the matching index page + expand the tree).
|
||||
- **Reorder**: Plugins above Skills.
|
||||
- **OpenRig-managed skills discovery**: 32 shared skills now visible in
|
||||
the tree + on `/specs/skills` index. Previously the workspace-relative
|
||||
path lookup returned empty in production VM environments.
|
||||
- **Daemon-owned library discovery API** (new):
|
||||
- `GET /api/skills/library` → consolidated `LibrarySkillPublic[]`
|
||||
(workspace + openrig-managed sources; absolute paths not leaked).
|
||||
- `GET /api/skills/:id/files/list?path=<rel>` + `/api/skills/:id/files/read?path=<rel>` —
|
||||
skill folder browse + content read.
|
||||
- `GET /api/plugins/:id/files/list?path=<rel>` + `/api/plugins/:id/files/read?path=<rel>` —
|
||||
plugin folder browse + content read.
|
||||
- `PluginEntry.skillCount` field added to plugin discovery
|
||||
serialization.
|
||||
- **Real file-browser docs-browser on plugin and skill detail pages**:
|
||||
detail pages mount a `DirectoryTree` + `FileContentPanel` against the
|
||||
real plugin/skill folder. Markdown auto-renders; non-markdown files
|
||||
(e.g. `.ts`, `.json`) render as text. Folder navigation works (entering
|
||||
subfolders + listing files).
|
||||
- **Rolled-up index pages**: `/specs/skills` lists all skills as flat rows
|
||||
with source label + file count + entry link. `/specs/plugins` lists all
|
||||
plugins as rows with version + runtimes + skill-count + entry link.
|
||||
|
||||
### Plugin Primitive v0
|
||||
|
||||
- **Plugin discovery**: vendored plugins under `$OPENRIG_HOME/plugins/`
|
||||
(default `~/.openrig/plugins/`) are discovered, validated, and
|
||||
surfaced through `rig plugin list` + `/api/plugins`.
|
||||
- **Plugin install (v0)**: explicit operator copy or symlink to
|
||||
`$OPENRIG_HOME/plugins/<plugin-id>/`. A `rig plugin install
|
||||
<substrate-path>` verb is deferred to 0.3.2; see `OPENRIG-INSTALL.md`
|
||||
inside each plugin's source for the documented copy/symlink workflow.
|
||||
- **CLI**: `rig plugin list` / `show` / `used-by` / `validate` subcommands
|
||||
available. No `install` subcommand at v0.
|
||||
- **Plugins shipped as substrate references** (for plugin authors to
|
||||
copy-install): `gstack` (45 skills), `obra-superpowers` (14 skills).
|
||||
`openrig-core` ships bundled with the daemon (11 skills).
|
||||
|
||||
### Settings Destination Explorer
|
||||
|
||||
Settings became a 4-item Explorer destination matching Topology /
|
||||
Project / Library / For-You pattern:
|
||||
|
||||
- `/settings` (general config keys form), `/settings/policies`,
|
||||
`/settings/log`, `/settings/status`.
|
||||
- Old top-row tab nav removed.
|
||||
- Shared `SettingsPageShell` chrome across all 4 sub-routes.
|
||||
- Policies page is the home for the Claude auto-compaction policy form
|
||||
(see above).
|
||||
|
||||
### CMUX Launcher
|
||||
|
||||
- **Launch in CMUX button** on the rig-scope topology tab-bar trailing
|
||||
slot. Opens a cmux workspace for the rig with appropriate title +
|
||||
cwd parameters. Powered by new daemon route + cmux adapter
|
||||
extensions.
|
||||
|
||||
### Node-Page Overview + Details
|
||||
|
||||
- **Seat overview table** consolidated as 7-column horizontal layout with
|
||||
vertical grid lines (Claude/Codex agent + status + context + tokens +
|
||||
uptime + cwd + current-work).
|
||||
- **Tab consolidation** + activity alignment on the node-detail surface.
|
||||
- **Alert-only notification banner** (renders only on real-alert states:
|
||||
`failed`, `attention_required`, or `latestError !== null`); generic
|
||||
`recoveryGuidance` no longer triggers a banner on every seat.
|
||||
- **cwd / current-work separation**: factored into a `SeatOverviewSecondary`
|
||||
primitive below the column table.
|
||||
|
||||
### Mobile Drawer Behavior
|
||||
|
||||
The Explorer drawer at 375px viewports now layers above the mobile rail
|
||||
tray for Settings / Project / Library / For-You destinations
|
||||
(previously hidden behind rail-tray; visible click path didn't register
|
||||
on Explorer items). Topology mobile drawer is intentionally hidden in
|
||||
0.3.1 (clicking the hamburger triggered a pre-existing
|
||||
TopologyTableView renderer cascade); the topology mobile drawer is
|
||||
scheduled for full restoration in 0.3.2 via a dedicated
|
||||
TopologyTableView render-path slice.
|
||||
|
||||
### Dashboard And For You Visual Refresh
|
||||
|
||||
The Dashboard (`/`) and For You (`/for-you`) destinations got a coordinated
|
||||
visual refresh to a "vellum" surface language — translucent stone-tinted
|
||||
cards over a paper-grid background, ambient multi-stop shadow, mono+dot
|
||||
kind indicators, and L-shaped corner-bracket registration marks. The
|
||||
chrome vocabulary is consistent across destination cards (Dashboard) and
|
||||
both feed-card systems (For You).
|
||||
|
||||
- **Dashboard** — full rewrite of `/` into a thin composition over new
|
||||
`dashboard/vellum/` primitives (`BackVellumSheet`, `MidLayerContent`,
|
||||
`TopLayerContent`, `DestinationsLayer`, `VellumDestinationCard`,
|
||||
`CornerBracket`, `graphics.tsx`, `marks.tsx`). Hero typography ("WELCOME
|
||||
BACK") at display-lg + headline-bold; tactical instrument-panel stats
|
||||
line with tabular numerals and a success-token active count. Six
|
||||
destination cards (Topology / Project / For You / Library / Search /
|
||||
Settings) share the same numeral-layout treatment. Real-data hooks
|
||||
thread through (`useRigSummary`, `usePsEntries`, `useSpecLibrary`,
|
||||
`window.location.hostname`).
|
||||
- **For You** — both card systems unified to the same vellum recipe:
|
||||
- Storytelling band: `CardShell` rewritten to bg-stone-100/45 +
|
||||
backdrop-blur-[10px] + ambient shadow + corner brackets; design-token
|
||||
leading dots replace prior bg-emerald-50 / bg-amber-50 / etc.
|
||||
off-brand utilities. Title at 16px headline-bold; body at 12px.
|
||||
- Queue-item `FeedCard.tsx`: same outer chrome; `KIND_DOT` + `TONE_DOT`
|
||||
design-token maps; vellum bordered-no-fill action buttons
|
||||
(Approve/Deny/Route/Hold/Drop/Annotate/Handoff) with hover-invert and
|
||||
44px touch targets; `TONE_RECEIPT` strip is a subtle bg-stone-50/40
|
||||
with a leading colored dot.
|
||||
- **Single source of truth** — `/dashboard` and `/lab/vellum-lab` both
|
||||
import from `packages/ui/src/components/dashboard/vellum/index.js`.
|
||||
Future visual changes hit one location.
|
||||
- **Lab routes** — `/lab/card-previews`, `/lab/vellum-lab`,
|
||||
`/lab/vellum-bg/{a-large,b-small,c-allover}` are checked-in experiment
|
||||
surfaces for designer iteration. Reachable in production by direct URL;
|
||||
not linked from main nav. Useful when iterating on the visual system.
|
||||
|
||||
### For You Storytelling Adapter
|
||||
|
||||
The storytelling band (top of `/for-you`) now wires four card kinds to
|
||||
real data:
|
||||
|
||||
- **Progress** — from `useMissionDiscovery` (first 2 active missions)
|
||||
- **Shipped** — from `useSlices` (status = shipped/complete/done; capped at 3)
|
||||
- **Incident** — from `useSlices` (status = blocked/failed/danger or fallback "info"; capped at 3)
|
||||
- **Approval** — from `useActivityFeed` + `classifyFeed` (kind === "approval";
|
||||
capped at 2; qitemId extracted from event payload with snake_case alt
|
||||
and `FeedCard.id` fallback). Surfaces real queue items waiting on
|
||||
approval in a high-visibility band.
|
||||
|
||||
`ConceptCard` component is preserved in source but not emitted by the
|
||||
production adapter — a deliberate data-source decision is scheduled for
|
||||
0.3.2.
|
||||
|
||||
### Action Outcome And Inline Error Surface
|
||||
|
||||
Queue-item action buttons (`VerbActions` — Approve/Deny/Route/Hold/Drop/
|
||||
Annotate/Handoff) now render outcomes immediately and surface failures
|
||||
without silently reverting:
|
||||
|
||||
- **Optimistic outcome** — on mutation success, the
|
||||
`ActionOutcomePanel` ("Approved by X" / "Routed by X to Y") renders
|
||||
immediately. Audit-log roundtrip reconciles in background. Operator
|
||||
no longer waits for a query refetch to see what happened.
|
||||
- **Inline error surface** — on mutation error, a tertiary-bordered
|
||||
error block renders below the verb buttons with the daemon's error
|
||||
message; verb-selection state is preserved so the operator can
|
||||
correct and retry. Replaces the prior silent-revert UX where errors
|
||||
were never displayed.
|
||||
- **React-query callback discipline** — `submit()` split into separate
|
||||
`onSuccess` (optimistic outcome + reset selection) and `onError`
|
||||
(set error message; do NOT reset). Regression test guards the
|
||||
silent-revert class explicitly.
|
||||
|
||||
### Vendored Skill Provenance
|
||||
|
||||
Vendored skills shipped in `packages/daemon/specs/agents/shared/skills/`
|
||||
now declare their upstream lineage via `metadata.openrig` frontmatter
|
||||
and (when modifications exist) a companion `OPENRIG.md` sidecar:
|
||||
|
||||
- **vendoring_pattern**: `vendored-as-is` | `modify-the-file` |
|
||||
`add-supplementary-files`
|
||||
- **vendored_from**: upstream source identifier
|
||||
- **last_upstream_check**: most recent diff date
|
||||
- **divergence_notes**: human-readable summary of OpenRig-specific changes
|
||||
|
||||
Applied to all 10 process-skill surfaces (agent-browser, executing-plans,
|
||||
brainstorming, systematic-debugging, test-driven-development,
|
||||
using-superpowers, verification-before-completion, writing-plans,
|
||||
frontend-design, dogfood). `OPENRIG.md` sidecars added where the file
|
||||
has been modified or supplemented (agent-browser + executing-plans +
|
||||
brainstorming + using-superpowers + writing-plans). Convention is
|
||||
documented in the `writing-skills-for-openrig` skill.
|
||||
|
||||
### Plugins And Skills On The VM (Operator Note)
|
||||
|
||||
Operators dogfood-testing 0.3.1 should expect:
|
||||
|
||||
- **Stock VM install**: only `openrig-core` plugin is bundled. The
|
||||
`/specs/plugins` UI list will show one plugin until the operator
|
||||
installs additional plugins per the v0 copy workflow.
|
||||
- **Skills**: 32 OpenRig-managed shared skills ship under
|
||||
`packages/daemon/specs/agents/shared/skills/` (discovered via the
|
||||
daemon skill-library API; no operator action required).
|
||||
- **User-installed skills**: skills the operator installs under
|
||||
`~/.openrig/skills/` or the workspace `.openrig/skills/` directory
|
||||
surface in the same list.
|
||||
|
||||
### Config + Settings
|
||||
|
||||
Continues from 0.3.0 with the slice 08 validation pass:
|
||||
|
||||
- `rig config get/set/reset/list` remains the canonical CLI surface.
|
||||
- Lockstep CLI `VALID_KEYS` + daemon `SETTINGS_VALID_KEYS` byte-identical
|
||||
sets (verified in slice 08).
|
||||
- Help-drift CI gate from slice 08 honored across all new keys added in
|
||||
0.3.1.
|
||||
|
||||
### SC-29 Exceptions
|
||||
|
||||
The SC-29 exception process tracks explicit scope expansions to release
|
||||
contracts. Exceptions declared in 0.3.1:
|
||||
|
||||
- **#10 (slice 24)**: cmux launcher — `POST /api/rigs/:rigId/cmux/launch`
|
||||
+ CmuxLayoutService + 4 CmuxAdapter RPC methods.
|
||||
- **#10 (slice 27, numbering collision)**: Claude auto-compaction policy
|
||||
— 7 `policies.claude_compaction.*` ConfigStore keys. (Numbering
|
||||
collision with #10 above is a process-only inconsistency; both code
|
||||
scopes are correctly merged. Canonical `SC29-LEDGER.md` and ledger
|
||||
hygiene scheduled for 0.3.2.)
|
||||
- **#11 (slice 28)**: Library Explorer daemon API — 5 new daemon GET
|
||||
endpoints (`/api/skills/library`, `/api/skills/:id/files/list`,
|
||||
`/api/skills/:id/files/read`, `/api/plugins/:id/files/list`,
|
||||
`/api/plugins/:id/files/read`) + 2 response shape additions
|
||||
(`PluginEntry.skillCount`, `LibrarySkillPublic`).
|
||||
|
||||
### Known Carry-Forwards (0.3.2 Candidates)
|
||||
|
||||
- **`rig plugin install <substrate-path>` verb**: explicitly deferred.
|
||||
Documented copy/symlink workflow is the v0 install path.
|
||||
- **Topology mobile drawer**: hidden in 0.3.1 to avoid a pre-existing
|
||||
TopologyTableView renderer cascade at 375px viewports. Full
|
||||
restoration scheduled for 0.3.2 via dedicated render-path slice.
|
||||
- **Plugin source-label taxonomy**: copy-installed plugins currently
|
||||
land in the `vendored` source kind; UI label says "No user-installed
|
||||
plugins" while listing them. Taxonomy refinement scheduled for 0.3.2.
|
||||
- **`SC29-LEDGER.md`**: canonical SC-29 numbering ledger document.
|
||||
Authors currently self-assign exception numbers; documented ledger
|
||||
prevents collisions like the slice 24/27 #10.
|
||||
- **VM PreCompact hook installer**: the documented install path for the
|
||||
`claude-compaction-restore` skill is operator-manual at v0; an
|
||||
automated installer is a 0.3.2 candidate.
|
||||
- **`docs/DESIGN.md` docs-guard violation**: pre-existing
|
||||
`npm run test:repo` failure; tracked doc outside allowed paths;
|
||||
cleanup scheduled for 0.3.2 documentation hygiene pass.
|
||||
- **Environment-dependent tests**: a small number of vitest suites fail
|
||||
on developer hosts due to port-conflicts (preflight) or live host
|
||||
Claude hook state (restore-check); focused-test gates pass these
|
||||
suites; cumulative-workspace runs surface the gaps. Isolation cleanup
|
||||
scheduled for 0.3.2.
|
||||
- **Cross-daemon route awareness**: the VerbActions destination dropdown
|
||||
on `/for-you` currently lists session names without checking whether
|
||||
the local daemon can reach them. Routing to a non-local destination
|
||||
surfaces as an inline error (per the new error surface above) but the
|
||||
dropdown should ideally filter to local-daemon seats. 0.3.2 candidate.
|
||||
- **ConceptCard data source**: `ConceptCard` component is preserved in
|
||||
source but not wired in the production adapter. A 0.3.2 slice will
|
||||
pick a deliberate data source (likely shaped backlog candidates or
|
||||
early-stage discovery items).
|
||||
- **Warm error messages on demo surfaces**: daemon error strings (e.g.,
|
||||
"queue item not found") surface verbatim through the inline error
|
||||
surface. Demo-grade polish to humanize these on user-facing surfaces
|
||||
is scheduled for 0.3.2.
|
||||
|
||||
### Banked Discipline Patterns
|
||||
|
||||
0.3.1 surfaced a number of canonical agent-software-design patterns
|
||||
during the Claude compaction iteration cycle and Library Explorer
|
||||
finishing work. These are banked in operator-skill documentation for
|
||||
agent-prompt design + daemon-to-LLM trust establishment:
|
||||
|
||||
- **Channel model**: normal user message is the only authorized action
|
||||
surface; hook stdout is informational-only; `/compact` args carry
|
||||
trust contracts that the post-compact prompt invokes.
|
||||
- **Turn-boundary handshake**: when a daemon-driven action request
|
||||
would land too adjacent to local-command output, insert a
|
||||
non-committing acknowledgment message first to create an
|
||||
assistant-turn boundary.
|
||||
- **Save-game-before-quit pattern**: full-context agent writes
|
||||
restoration breadcrumb before forced context loss; context-loss
|
||||
agent reads it on restore.
|
||||
- **Structured-output forces completeness**: ask LLMs for explicit
|
||||
FULL/PARTIAL/NOT_READ accounting when thoroughness matters; counters
|
||||
token-conservation instincts.
|
||||
- **Daemon-owned shared-resource discovery**: skill/plugin discovery
|
||||
belongs at the daemon layer with HTTP endpoint surfaces; UI consumes
|
||||
via typed API. Avoids workspace-cwd-relative path-resolution
|
||||
brittleness.
|
||||
|
||||
### Quick Verification Commands
|
||||
|
||||
```bash
|
||||
# Confirm CLI version after the release-manager version bump
|
||||
rig --version
|
||||
|
||||
# Confirm daemon starts cleanly
|
||||
rig daemon start
|
||||
|
||||
# Confirm new Claude compaction policy keys are visible
|
||||
rig config list | grep policies.claude_compaction
|
||||
|
||||
# Confirm plugins discoverable
|
||||
rig plugin list
|
||||
|
||||
# Confirm skills discoverable
|
||||
curl -s http://localhost:7433/api/skills/library | jq 'length'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] - 2026-05-10
|
||||
|
||||
**Status**: release candidate for public publish. This entry documents the
|
||||
changes since `0.2.0`.
|
||||
|
||||
### Summary For Installing Agents
|
||||
|
||||
- **Package version**: package metadata is still `0.2.0` until the release
|
||||
manager performs the final version bump. The release contents documented here
|
||||
are the intended `0.3.0` payload.
|
||||
- **Migrations**: fresh databases apply migrations through
|
||||
`039_queue_target_repo`. Existing databases migrate by running
|
||||
`rig daemon start`.
|
||||
- **Node engines**: the published CLI accepts Node `>=20`. The root package and
|
||||
private daemon package remain constrained to active even-numbered Node lines.
|
||||
- **Specs and primitives**: workflow specs, context packs, agent images,
|
||||
workspace scaffolds, file browsing, queue observability, context usage, and
|
||||
runtime skill discovery are all first-class product surfaces.
|
||||
- **UI shell**: the operator UI has been rebuilt around the V1 shell:
|
||||
destination rail, explorer, center workspace, detail drawer, vellum surfaces,
|
||||
topology graph/table/terminal modes, and focused project observability.
|
||||
- **Starter content**: `0.3.0` ships generic starter workflows and starter rigs.
|
||||
Project-specific automation recipes are intentionally not shipped in product
|
||||
source.
|
||||
- **No schema-breaking release change**: existing CLI argument shapes, daemon
|
||||
route paths, RigSpec/AgentSpec schemas, and persisted settings remain
|
||||
backward compatible unless noted below.
|
||||
|
||||
### Quick Verification Commands
|
||||
|
||||
```bash
|
||||
# Confirm CLI version after the release-manager version bump
|
||||
rig --version
|
||||
|
||||
# Confirm daemon starts and migrations apply
|
||||
OPENRIG_DB=/tmp/openrig-030-verify.sqlite rig daemon start
|
||||
|
||||
# Confirm settings are readable
|
||||
rig config list --with-source
|
||||
|
||||
# Confirm starter specs are visible
|
||||
rig specs ls --json
|
||||
|
||||
# Confirm runtime identity and loaded context
|
||||
rig whoami --json
|
||||
```
|
||||
|
||||
If any verification fails, see "Failure Modes And Remediation" below.
|
||||
|
||||
---
|
||||
|
||||
### V1 Shell And Operator UI
|
||||
|
||||
The `0.3.0` UI moves from prototype surfaces to a coherent operator shell:
|
||||
|
||||
- Two desktop chrome regions: destination rail and explorer.
|
||||
- Center workspace for full pages.
|
||||
- Default-closed detail drawer for previews and referenced content.
|
||||
- Topology graph/table/terminal modes at a single topology URL.
|
||||
- Settings rendered as a center workspace page, not as a sidebar panel.
|
||||
- Vellum surface primitives, 1px region borders, and black-glass terminal
|
||||
preview styling.
|
||||
- Shared runtime graphics marks for agent/runtime/tool identity.
|
||||
- Retired legacy surfaces: old sidebar shell, legacy dashboard page, and
|
||||
rig-detail drawer patterns.
|
||||
|
||||
### Starter Rigs And Workflows
|
||||
|
||||
OpenRig now ships starter content designed to be useful on a fresh install
|
||||
without exposing project-specific automation recipes.
|
||||
|
||||
- `product-team` is the primary human-directed starter rig.
|
||||
- `conveyor` is the primary workflow-oriented starter rig.
|
||||
- `conveyor` includes two generic workflow specs:
|
||||
- **Conveyor**: each stage can process queued work independently, so multiple
|
||||
packets can be in flight at once and natural queue backpressure handles slow
|
||||
stages.
|
||||
- **Basic loop**: one packet advances hop-by-hop around a small loop, useful
|
||||
when the operator wants a slower, easier-to-watch workflow.
|
||||
- Generic starter workflows use the workflow runtime and queue primitives. They
|
||||
are examples and building blocks, not a hidden project workflow.
|
||||
- Project-specific workflow specs can still be installed from a user workspace
|
||||
or private spec directory; they do not need to be committed to product source.
|
||||
|
||||
### Mission-Shaped Workspace Defaults
|
||||
|
||||
Fresh installs now have a coherent default workspace path and scaffold:
|
||||
|
||||
- `rig config init-workspace` creates the default workspace structure.
|
||||
- Workspace defaults include missions, slices, specs, proof/evidence locations,
|
||||
steering files, and user-editable docs.
|
||||
- Existing installs are rebased at read time so newer defaults become available
|
||||
without destructive rewrites.
|
||||
- Read-only mission/slice indexing supports nested mission-shaped workspaces
|
||||
while preserving compatibility with earlier flat-root layouts.
|
||||
|
||||
### Project Observability
|
||||
|
||||
Project and queue work is now easier to inspect from the UI:
|
||||
|
||||
- For You cards classify queue lifecycle events, shipped work, progress,
|
||||
observations, and approvals.
|
||||
- Queue cards hydrate qitem bodies and proof previews.
|
||||
- Story tabs emphasize qitem body content and paginate long activity streams.
|
||||
- Queue rows preview bodies and open the detail drawer with full source,
|
||||
destination, state, tags, and created-time metadata.
|
||||
- Tests tabs show diagnostics and proof assets when no proof is available.
|
||||
- Workspace and mission rollup pages include scoped Progress, Artifacts, Queue,
|
||||
and Topology tabs.
|
||||
- Current/archive grouping separates active project work from old seed or
|
||||
completed work.
|
||||
|
||||
### Mission Control And Queue Actions
|
||||
|
||||
Mission Control remains the operator surface for queue observability and
|
||||
qitem actions:
|
||||
|
||||
- Views include personal queue, human gate, fleet, active work, recent ships,
|
||||
recent activity, and recent observations.
|
||||
- Actions include approve, deny, route, annotate, hold, drop, and handoff.
|
||||
- Mission Control audit outcomes are reflected back into For You cards so
|
||||
terminal or already-actioned items show evidence instead of stale controls.
|
||||
- Audit browsing and action history are read-only inspection surfaces.
|
||||
- Existing daemon endpoints are reused; no extra workflow-specific endpoint is
|
||||
required for the public starter content.
|
||||
|
||||
### Files, Markdown, Progress, And Proofs
|
||||
|
||||
The file and proof surfaces were expanded:
|
||||
|
||||
- File browser supports allowlisted roots, safe reads, asset reads, and
|
||||
conflict-checked writes.
|
||||
- Markdown rendering supports frontmatter, code blocks, tables, images, and
|
||||
raw/rendered toggles.
|
||||
- Progress views render status pills, hierarchy, and next-work markers.
|
||||
- Proof screenshots open in an in-page viewer.
|
||||
- File drawer headers, proof rows, queue related refs, and story rows use
|
||||
shared graphics marks for faster scanning.
|
||||
|
||||
### Workflow Runtime And Spec Library
|
||||
|
||||
Workflow primitives are available as general infrastructure:
|
||||
|
||||
- Workflow specs are cached from markdown/YAML sources.
|
||||
- Workflow instances and step trails are persisted.
|
||||
- Specs library includes workflow entries and graph preview.
|
||||
- Slice and project story surfaces can render spec-aware topology when a slice
|
||||
is bound to a workflow instance.
|
||||
- Cycle-aware traversal prevents graph rendering from hanging on looping specs.
|
||||
|
||||
### Context Packs And Agent Images
|
||||
|
||||
OpenRig now has additional reusable primitives for context and session state:
|
||||
|
||||
- `context_packs` package related context files into a coherent sendable bundle.
|
||||
- `agent_images` capture reusable starter state from productive sessions.
|
||||
- Library review surfaces can inspect these primitives and show safe summaries.
|
||||
- AgentSpec startup can reference context packs and agent images.
|
||||
- Resume-token data is redacted at route boundaries.
|
||||
|
||||
### Topology, Activity, And Context Usage
|
||||
|
||||
Topology is now a working operational surface rather than a static diagram:
|
||||
|
||||
- Graph/table/terminal views are available at the topology route.
|
||||
- Host graphs can show multiple rigs on one canvas.
|
||||
- Rig groups can expand/collapse, persist that state, and auto-expand for
|
||||
current rig/pod/seat URLs.
|
||||
- Agent activity, queue handoffs, token telemetry, and context usage are visible
|
||||
in topology views.
|
||||
- Codex context telemetry is read from local session state and reflected in
|
||||
topology table and terminal views when available.
|
||||
- Detached Codex sessions can still expose the last readable context sample;
|
||||
Claude context remains running-session based.
|
||||
|
||||
### Terminal Preview
|
||||
|
||||
Terminal preview matured across several passes:
|
||||
|
||||
- Preview panes use existing session capture primitives.
|
||||
- Compact terminal popovers are portal-mounted, clamp to viewport edges, and
|
||||
resize/reposition on scroll or viewport changes.
|
||||
- The compact view strips unnecessary chrome and uses a black-glass visual
|
||||
language.
|
||||
- The proof viewer and terminal preview share consistent drawer/popover
|
||||
behavior.
|
||||
|
||||
### Runtime Skill Discovery
|
||||
|
||||
Runtime skill discovery is now part of profile resolution:
|
||||
|
||||
- Rig-local skill references still win first.
|
||||
- Skills can be discovered from runtime-specific and shared user skill roots.
|
||||
- Structurally invalid `SKILL.md` files are rejected with precise reasons.
|
||||
- Profile resolution surfaces rejected-skill reasons instead of treating every
|
||||
broken skill as merely missing.
|
||||
- The behavior is intentionally strict: a skill must have frontmatter, non-empty
|
||||
`name`, non-empty `description`, and non-empty body content.
|
||||
|
||||
### CLI And Daemon Release Hardening
|
||||
|
||||
Several release-blocking polish items landed in the CLI/daemon:
|
||||
|
||||
- Transcript capture now uses bounded `tmux capture-pane` polling instead of an
|
||||
unbounded pipe file.
|
||||
- `rig send` wraps delivered messages with sender/recipient context and a reply
|
||||
hint.
|
||||
- Generated setup markers use OpenRig naming.
|
||||
- Original runtime environment aliases keep deprecated `RIGGED_*` fallbacks for
|
||||
compatibility; newer typed settings use `OPENRIG_*` only.
|
||||
- ConfigStore and SettingsStore remain lockstep for typed settings.
|
||||
- No new plugin loader ships in `0.3.0`; plugin support is planned for a later
|
||||
release.
|
||||
|
||||
### Removed Or Not Included
|
||||
|
||||
- Project-specific workflow recipes are not included in product source.
|
||||
- The old `demo` rig is no longer the recommended starter; public docs point to
|
||||
`product-team` and `conveyor`.
|
||||
- The legacy `mental-model-ha` skill is removed from starter guidance.
|
||||
- Root runtime projections such as `CLAUDE.md` are intentionally not tracked.
|
||||
- Package-local `pnpm-lock.yaml` files are intentionally not restored; this repo
|
||||
uses npm workspaces and the root `package-lock.json` for release installs.
|
||||
- Internal release artifacts and local dogfood packets are not part of the
|
||||
public release notes.
|
||||
|
||||
---
|
||||
|
||||
### Failure Modes And Remediation
|
||||
|
||||
**`rig daemon start` fails with an ABI mismatch**
|
||||
|
||||
- Cause: native dependencies were compiled against a different Node version.
|
||||
- Remediation: use an active even-numbered Node release, or rebuild native
|
||||
dependencies from the installed package directory.
|
||||
|
||||
**Files browser or Progress view appears empty on a fresh install**
|
||||
|
||||
- Cause: workspace scaffold has not been initialized or the configured root is
|
||||
not where the operator expects.
|
||||
- Remediation: run `rig config init-workspace`, then inspect
|
||||
`rig config list --with-source`.
|
||||
|
||||
**Mission Control views are empty**
|
||||
|
||||
- Expected on a rig with no queue items.
|
||||
- Remediation: create or hand off a queue item, then refresh. If still empty,
|
||||
confirm daemon status and DB path with `rig daemon status` and
|
||||
`rig config get db.path`.
|
||||
|
||||
**A skill reference resolves as rejected**
|
||||
|
||||
- Cause: the target `SKILL.md` exists but failed structural validation.
|
||||
- Remediation: fix the skill frontmatter and body. It must include delimited
|
||||
YAML frontmatter with non-empty `name` and `description`, followed by non-empty
|
||||
markdown body content.
|
||||
|
||||
**A public starter workflow is too simple for a specialized loop**
|
||||
|
||||
- Expected. `0.3.0` ships reusable primitives and generic starter workflows.
|
||||
Specialized workflow specs should live in user workspace or private rig
|
||||
packages.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] - 2026-04-22
|
||||
|
||||
Baseline for this changelog. Key shipped capabilities at `0.2.0`:
|
||||
|
||||
- Pod-aware multi-agent runtime with RigSpec, AgentSpec, pods, and seats.
|
||||
- Rig-scoped environment management.
|
||||
- Filesystem-backed spec library for built-in and user roots.
|
||||
- Daemon-backed stream, queue, project, view, watchdog, and workflow primitives.
|
||||
- Cross-runtime restore packets for Claude Code and Codex sessions.
|
||||
- Communication primitives: `rig send`, `rig capture`, `rig broadcast`, durable
|
||||
rig chat, and transcript inspection.
|
||||
- Identity and lifecycle commands: `rig whoami`, adoption/bind/materialize
|
||||
flows, snapshot/restore, and post-command handoff.
|
||||
- Operator surfaces: `rig ps`, `rig ps --nodes`, queue inspection, and daemon
|
||||
status.
|
||||
- 32 SQLite migrations.
|
||||
|
||||
---
|
||||
|
||||
*This changelog is written for agents and humans. It should describe public
|
||||
release behavior without depending on local workspace paths or private project
|
||||
packets.*
|
||||
@@ -15,28 +15,32 @@ npm install -g @openrig/cli
|
||||
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
|
||||
rig setup
|
||||
|
||||
# Boot the conveyor starter rig (4 seats, mixed runtimes)
|
||||
rig up conveyor
|
||||
# Boot the demo rig (3 pods, 8 nodes, mixed runtimes)
|
||||
rig up demo
|
||||
|
||||
# Open the UI
|
||||
rig ui open
|
||||
```
|
||||
|
||||
After the conveyor rig boots, open the UI and click **intake.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
|
||||
After the demo boots, open the UI and click **orch1.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
|
||||
|
||||
## Launch Walkthrough
|
||||
|
||||
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
|
||||
|
||||
### 1. Open the conveyor rig
|
||||
### 1. Open the demo rig
|
||||
|
||||
When the UI opens, the explorer is already visible on the left. Click the `conveyor` rig to load its live topology.
|
||||
When the UI opens, the explorer is already visible on the left. Click the `demo` rig to load its live topology.
|
||||
|
||||
### 2. Click `CMUX` on `intake.lead`
|
||||

|
||||
|
||||
Once the topology loads, go to the `intake.lead` node and click its `CMUX` button. That opens the intake terminal directly.
|
||||
### 2. Click `CMUX` on `orch1.lead`
|
||||
|
||||
> From here, give the intake agent a packet of work and watch it move through planning, build, review, and closeout.
|
||||
Once the topology loads, go to the `orch1.lead` node and click its `CMUX` button. That opens the orchestrator terminal directly.
|
||||
|
||||

|
||||
|
||||
> From here just talk to the orchestrator and tell it what you want it to build...
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -52,36 +56,33 @@ OpenRig is a multi-agent harness — it manages the system that coding agents fo
|
||||
|
||||
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
|
||||
|
||||
## Starter Rigs
|
||||
## Demo Rig
|
||||
|
||||
OpenRig ships with a conveyor starter rig for learning workflow handoff:
|
||||
OpenRig ships with a demo rig you can boot in seconds:
|
||||
|
||||
```bash
|
||||
rig specs preview conveyor --kind rig
|
||||
rig specs preview demo
|
||||
```
|
||||
|
||||
```
|
||||
conveyor (rig, pod_aware)
|
||||
Starter workflow rig: a station pipeline that can move multiple
|
||||
work packets at once, with queue depth acting as natural backpressure.
|
||||
demo (rig, pod_aware)
|
||||
Launch-grade starter: a stable full product squad with two
|
||||
orchestrators, implementation, QA, design, and two independent
|
||||
reviewers.
|
||||
|
||||
Pod: intake (1 member)
|
||||
Pod: orch1 (2 members)
|
||||
lead — claude-code
|
||||
Pod: plan (1 member)
|
||||
planner — codex
|
||||
Pod: build (1 member)
|
||||
builder — claude-code
|
||||
Pod: review (1 member)
|
||||
reviewer — codex
|
||||
peer — codex
|
||||
Pod: dev1 (3 members)
|
||||
impl — claude-code
|
||||
qa — codex
|
||||
design — claude-code
|
||||
Pod: rev1 (2 members)
|
||||
r1 — claude-code
|
||||
r2 — codex
|
||||
```
|
||||
|
||||
For a larger human-operated product-development topology, inspect `product-team`:
|
||||
|
||||
```bash
|
||||
rig specs preview product-team
|
||||
```
|
||||
|
||||
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
|
||||
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, `product-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
|
||||
|
||||
Browse the library: `rig specs ls`
|
||||
|
||||
@@ -94,7 +95,7 @@ CLI / UI / MCP
|
||||
|
|
||||
Hono HTTP daemon
|
||||
|
|
||||
Domain services
|
||||
Domain services (52)
|
||||
|
|
||||
SQLite + tmux + runtime adapters
|
||||
```
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Source capture:
|
||||
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
|
||||
- host-forwarded UI: `http://127.0.0.1:<host-port>`
|
||||
- guest daemon: `<guest-ip>:<guest-port>`
|
||||
- host-forwarded UI: `http://127.0.0.1:17439`
|
||||
- guest daemon: `192.168.64.9:7433`
|
||||
|
||||
Purpose:
|
||||
- README walkthrough
|
||||
@@ -16,8 +16,8 @@ Annotated README walkthrough:
|
||||
|
||||
Archived proof/source bundle:
|
||||
- raw proof captures and guide source were moved out of the repo to:
|
||||
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
|
||||
- `<substrate-shared-docs>/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
|
||||
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
|
||||
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
|
||||
|
||||
Notes:
|
||||
- The README should use the annotated walkthrough files.
|
||||
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
# OpenRig Design
|
||||
|
||||
This document captures the current OpenRig visual system so the product can be
|
||||
recreated, extended, or translated into other media without reverse-engineering
|
||||
the UI from code.
|
||||
|
||||
## Essence
|
||||
|
||||
OpenRig is a local control plane for multi-agent work. The interface should feel
|
||||
like an operator's tactical dossier: precise, technical, calm, and legible under
|
||||
pressure. It is not a consumer SaaS dashboard and not a decorative terminal skin.
|
||||
It is a paper-and-glass workspace for watching work move through rigs, agents,
|
||||
queues, projects, files, and proof packets.
|
||||
|
||||
The brand should communicate:
|
||||
|
||||
- Operator confidence: the human can see what is happening and what needs action.
|
||||
- Composable systems: workspace, mission, slice, rig, pod, seat, queue, file, and
|
||||
proof surfaces share primitives instead of bespoke one-off cards.
|
||||
- Live work: activity, flow, context usage, proofs, and terminal state are visible
|
||||
without turning the UI into a log dump.
|
||||
- Local-first control: the app looks like a serious local cockpit, not a cloud
|
||||
marketing product.
|
||||
|
||||
## Design Language
|
||||
|
||||
OpenRig currently uses two deliberate surface languages.
|
||||
|
||||
### Vellum Paper
|
||||
|
||||
Vellum is the default light surface language. It appears on navigation chrome,
|
||||
dashboard cards, topology cards, project cards, drawers, file viewers, and
|
||||
metadata containers.
|
||||
|
||||
Use vellum for persistent product surfaces:
|
||||
|
||||
- Explorer sidebars
|
||||
- Dashboard cards
|
||||
- Topology rig, pod, and agent cards
|
||||
- Project and For You cards
|
||||
- Drawer sheets and document viewers
|
||||
- File and markdown surfaces
|
||||
|
||||
Core vellum traits:
|
||||
|
||||
- Paper-grid background visible behind and sometimes through surfaces
|
||||
- White translucent fills, usually `bg-white/25` to `bg-white/40`
|
||||
- `backdrop-blur-[8px]` for layered paper
|
||||
- 1px outline or ghost border
|
||||
- Hard shadow only on paper cards, not on floating glass
|
||||
- Registration marks for framed tactical sheets
|
||||
- Square corners everywhere except tiny status dots
|
||||
|
||||
Vellum should never become fully transparent. If the paper-grid makes text hard
|
||||
to read, increase opacity before adding heavy borders.
|
||||
|
||||
### Black Glass
|
||||
|
||||
Black glass is the floating dark surface language. It is currently used for the
|
||||
topology terminal preview and screenshot/proof overlays.
|
||||
|
||||
Use black glass for temporary preview surfaces:
|
||||
|
||||
- Terminal popovers
|
||||
- Image proof viewers
|
||||
- Other quick-look overlays where the content should float above the canvas
|
||||
|
||||
Core black-glass traits:
|
||||
|
||||
- Transparent dark fill, currently around `bg-stone-950/65`
|
||||
- Light text, usually `text-stone-50`
|
||||
- Optional subtle backdrop blur
|
||||
- No hard shadow
|
||||
- No 1px vellum border
|
||||
- Chrome stripped to the minimum needed for comprehension
|
||||
|
||||
Black glass should feel like a smoked sheet above the canvas, not a framed
|
||||
terminal emulator.
|
||||
|
||||
## Color Tokens
|
||||
|
||||
The canonical tokens live in `packages/ui/src/globals.css` and are surfaced to
|
||||
Tailwind in `packages/ui/tailwind.config.ts`.
|
||||
|
||||
Paper:
|
||||
|
||||
- `--background: 47 20% 97%` - base paper, `#faf9f5`
|
||||
- `--surface-container-lowest: 0 0% 100%` - card body white
|
||||
- `--surface-container-low: 80 10% 95%`
|
||||
- `--surface-container: 80 10% 92%`
|
||||
- `--surface-container-high: 100 10% 89%`
|
||||
- `--surface-container-highest: 100 10% 87%`
|
||||
|
||||
Ink:
|
||||
|
||||
- `--on-surface: 120 8% 19%` - primary text, `#2e342e`
|
||||
- `--on-surface-variant: 108 5% 36%` - secondary text, `#5b615a`
|
||||
- `--inverse-surface: 120 10% 4%` - dark strip fills, near black
|
||||
|
||||
Technical linework:
|
||||
|
||||
- `--secondary: 213 15% 39%` - connectors, handles, registration marks
|
||||
- `--secondary-container: 220 39% 91%`
|
||||
|
||||
Status:
|
||||
|
||||
- `--success: 145 63% 35%`
|
||||
- `--warning: 40 90% 50%`
|
||||
- `--tertiary: 1 63% 42%` - stamped red / critical
|
||||
- `--error: 0 43% 44%`
|
||||
|
||||
Borders:
|
||||
|
||||
- `--outline: 108 4% 47%`
|
||||
- `--outline-variant: 108 4% 68%`
|
||||
|
||||
Usage rules:
|
||||
|
||||
- Use `outline-variant` for most structural borders.
|
||||
- Reserve stone-900 borders for important card edges, active tabs, and hard
|
||||
tactical emphasis.
|
||||
- Do not create one-off color systems for each feature. Classify the state first,
|
||||
then map to the shared neutral, info, success, warning, or danger tones.
|
||||
|
||||
## Typography
|
||||
|
||||
Tailwind font families:
|
||||
|
||||
- `font-body`: Inter for readable body text
|
||||
- `font-headline`: Space Grotesk for display and stamped headings
|
||||
- `font-mono`: JetBrains Mono for labels, IDs, queue state, technical metadata,
|
||||
terminal previews, tabs, chips, and counters
|
||||
|
||||
General typography rules:
|
||||
|
||||
- Product labels use uppercase mono with letter spacing around `tracking-[0.10em]`.
|
||||
- Body copy should be sentence case when it is meant for humans to read.
|
||||
- Code identifiers should be transformed into human-readable labels where
|
||||
possible, not displayed raw.
|
||||
- Terminal and transcript content stays mono but should not force the rest of the
|
||||
product to read like a log.
|
||||
|
||||
## Layout
|
||||
|
||||
The shell is route-first and explorer-first:
|
||||
|
||||
- 48px icon rail on desktop
|
||||
- Explorer sidebar for route-specific tree context
|
||||
- Center workspace for destination content
|
||||
- Right drawer for transient detail and file views
|
||||
- Preview stack for pinned quick-look panes
|
||||
|
||||
Primary destinations:
|
||||
|
||||
- `/` Dashboard
|
||||
- `/topology` host topology
|
||||
- `/for-you` For You feed
|
||||
- `/project` workspace/project observability
|
||||
- `/specs` Library
|
||||
- `/settings` Settings
|
||||
|
||||
Topology and project both use hierarchical scopes:
|
||||
|
||||
- Topology: host -> rig -> pod -> seat
|
||||
- Project: workspace -> mission -> slice
|
||||
|
||||
Each lower scope narrows the filter. Each higher scope widens the context.
|
||||
|
||||
## Core Primitives
|
||||
|
||||
Use these primitives before adding new component styles.
|
||||
|
||||
Vellum:
|
||||
|
||||
- `VellumCard`
|
||||
- `VellumSheet`
|
||||
- `RegistrationMarks`
|
||||
- `StatusPip`
|
||||
- `SectionHeader`
|
||||
- `EmptyState`
|
||||
|
||||
Graphics:
|
||||
|
||||
- `RuntimeMark`
|
||||
- `RuntimeBadge`
|
||||
- `ToolMark`
|
||||
- `ToolBadge`
|
||||
- `ActorMark`
|
||||
- `OperatorMoodMark`
|
||||
- `runtime-brand.ts`
|
||||
- `tool-brand.ts`
|
||||
|
||||
Project metadata:
|
||||
|
||||
- `ProjectPill`
|
||||
- `EventBadge`
|
||||
- `QueueStateBadge`
|
||||
- `TagPill`
|
||||
- `ActorChip`
|
||||
- `DateChip`
|
||||
- `FlowChips`
|
||||
- `ProofThumbnailGrid`
|
||||
- `ProofPacketHeader`
|
||||
|
||||
Topology:
|
||||
|
||||
- `HostMultiRigGraph`
|
||||
- `HybridAgentNode`
|
||||
- `HybridPodGroupNode`
|
||||
- `RigGroupNode`
|
||||
- `ActivityRing`
|
||||
- `HotPotatoEdge`
|
||||
- `TerminalPreviewPopover`
|
||||
- `TopologyTableView`
|
||||
- `TopologyTerminalView`
|
||||
|
||||
Preview and documents:
|
||||
|
||||
- `SessionPreviewPane`
|
||||
- `ProofImageViewer`
|
||||
- `FileViewer`
|
||||
- `MarkdownViewer`
|
||||
|
||||
## Graphics System
|
||||
|
||||
OpenRig uses a shared graphics package rather than scattering literal icon logic
|
||||
through product surfaces.
|
||||
|
||||
Runtime marks:
|
||||
|
||||
- Claude: pixel-style Claude mark, label `Claude`
|
||||
- Codex: Codex CLI terminal-circle mark, label `Codex`
|
||||
- Terminal: compact terminal glyph, label `TTY` or `Terminal`
|
||||
- Unknown: neutral fallback
|
||||
|
||||
Tool marks:
|
||||
|
||||
- CMUX: blue chevron mark
|
||||
- tmux: pane-grid mark with green base
|
||||
- VS Code: blue code mark
|
||||
- Terminal, file, markdown, config, code, screenshot, proof, transcript, commit,
|
||||
folder, skill, video, and trace each have a central brand entry
|
||||
|
||||
Actor marks:
|
||||
|
||||
- Human/operator uses the monochrome climber mask
|
||||
- Agent/runtime actors reuse runtime marks
|
||||
|
||||
Graphics placement rules:
|
||||
|
||||
- Use runtime marks where the runtime is the important scan dimension.
|
||||
- Do not duplicate the same runtime mark in adjacent columns on the same row.
|
||||
- Use tool marks for actions, not runtime marks.
|
||||
- Prefer inline marks and open metadata rows over stacking more rectangular pill
|
||||
badges when a card is already visually dense.
|
||||
- Keep marks small enough to support scanning. They are landmarks, not mascots.
|
||||
|
||||
## Interaction Patterns
|
||||
|
||||
Hover-only actions:
|
||||
|
||||
- Dense topology cards show CMUX and terminal actions on hover/focus.
|
||||
- Keyboard focus must reveal the same actions as mouse hover.
|
||||
- Action clicks must stop propagation so row or node navigation does not also
|
||||
fire.
|
||||
|
||||
Drawers and overlays:
|
||||
|
||||
- Drawers close with their close button and by clicking outside the sheet.
|
||||
- Image viewers and terminal popovers are quick-look surfaces.
|
||||
- Topology terminal popovers are portaled above React Flow node stacking contexts.
|
||||
|
||||
Queue and project:
|
||||
|
||||
- Queue is operational: what is pending, actionable, routed, or closed.
|
||||
- Story is narrative: what happened over time, with body text primary and
|
||||
metadata secondary.
|
||||
- For You is attention routing: your turn, approvals, shipped proof, progress,
|
||||
and observations.
|
||||
|
||||
## Motion
|
||||
|
||||
Motion should explain state, not decorate.
|
||||
|
||||
Current motion vocabulary:
|
||||
|
||||
- Activity card pulse for active, needs-input, and blocked states
|
||||
- Source and target flash for handoff events
|
||||
- Hot-potato packet animation on topology graph edges
|
||||
- Rig frame pulse for aggregate activity
|
||||
- Subtle route and node enter fades
|
||||
|
||||
Rules:
|
||||
|
||||
- Prefer slow pulses to blinking.
|
||||
- Make directional work movement obvious enough to see at graph zoom levels.
|
||||
- Honor `prefers-reduced-motion`: remove pulse/travel animation and keep static
|
||||
state signals.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Icon-only actions need labels or tooltips.
|
||||
- Decorative marks should use `aria-hidden`.
|
||||
- Semantic status should use `StatusPip`, not activity animation classes.
|
||||
- Keyboard focus must expose hover-only controls.
|
||||
- Do not rely on color alone for action outcomes.
|
||||
- Keep dense cards readable at the expected topology zoom range.
|
||||
|
||||
## Do And Do Not
|
||||
|
||||
Do:
|
||||
|
||||
- Use vellum and black-glass as distinct surface paradigms.
|
||||
- Keep square geometry, 1px lines, and hard paper shadows.
|
||||
- Centralize graphics through brand helpers.
|
||||
- Translate raw event/state codes into human-readable labels.
|
||||
- Make proof and terminal previews available where thumbnails or terminal marks
|
||||
appear.
|
||||
- Keep hierarchy visible: workspace -> mission -> slice, host -> rig -> pod ->
|
||||
seat.
|
||||
|
||||
Do not:
|
||||
|
||||
- Add new one-off card styles when a primitive can do the job.
|
||||
- Use emojis as product graphics.
|
||||
- Turn metadata into a wall of rectangular pills.
|
||||
- Make terminal/log aesthetics dominate human decision surfaces.
|
||||
- Add decorative gradient blobs or generic SaaS hero styling.
|
||||
- Add black borders or hard shadows to black-glass popovers.
|
||||
- Duplicate runtime/tool brand logic outside `runtime-brand.ts`,
|
||||
`tool-brand.ts`, and `RuntimeMark.tsx`.
|
||||
|
||||
## Implementation References
|
||||
|
||||
- Tokens: `packages/ui/src/globals.css`
|
||||
- Tailwind theme: `packages/ui/tailwind.config.ts`
|
||||
- Shell: `packages/ui/src/components/AppShell.tsx`
|
||||
- Routes: `packages/ui/src/routes.tsx`
|
||||
- Vellum primitives: `packages/ui/src/components/ui/`
|
||||
- Graphics primitives: `packages/ui/src/components/graphics/RuntimeMark.tsx`
|
||||
- Runtime brands: `packages/ui/src/lib/runtime-brand.ts`
|
||||
- Tool brands: `packages/ui/src/lib/tool-brand.ts`
|
||||
- Project metadata primitives: `packages/ui/src/components/project/ProjectMetaPrimitives.tsx`
|
||||
- Topology graph nodes: `packages/ui/src/components/topology/HybridTopologyNodes.tsx`
|
||||
- Terminal popover: `packages/ui/src/components/topology/TerminalPreviewPopover.tsx`
|
||||
- Session preview: `packages/ui/src/components/preview/SessionPreviewPane.tsx`
|
||||
+43
-107
@@ -3,16 +3,14 @@
|
||||
|
||||
Status:
|
||||
- OpenRig currently ships a pod-aware multi-agent runtime, rig-scoped environment management, a filesystem-backed spec library, managed-app browsing/review surfaces, and the canonical `secrets-manager` + `vault.specialist` agent-managed app example.
|
||||
- OpenRig v0.2.0 also ships daemon-backed coordination primitives: stream, queue, project, view, watchdog, and workflow runtime.
|
||||
- Shipped source footprint: `376` source files across the three packages.
|
||||
- Daemon footprint: `211` source files total, including `126` domain files, `31` route files, `11` adapters, and `36` migrations.
|
||||
- CLI footprint: `78` source files.
|
||||
- Shipped source footprint: `286` source files across the three packages.
|
||||
- Daemon footprint: `150` source files total, including `90` domain files, `22` route files, `11` adapters, and `20` migrations.
|
||||
- CLI footprint: `49` source files.
|
||||
- UI footprint: `87` source files.
|
||||
- Current v0.2.0 release verification:
|
||||
- root build and package build passing
|
||||
- daemon focused suite: `171/171` files, `2561/2561` passing
|
||||
- CLI sanitized suite: `56/56` files, `794/794` passing
|
||||
- Mode 3 real-agent dual-auth dogfood covered Claude-only, Codex-only, and mixed Claude-to-Codex paths
|
||||
- Current full-suite verification during this refresh:
|
||||
- daemon: `127/127` files, `1668/1668` passing
|
||||
- CLI: `37/37` files, `366/366` passing
|
||||
- UI: `37/37` files, `388/388` passing
|
||||
|
||||
Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
|
||||
|
||||
@@ -34,10 +32,10 @@ Legacy flat-node/package flows remain for backward compatibility.
|
||||
The stack is:
|
||||
|
||||
```text
|
||||
CLI (53 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
CLI (40 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (31 route groups + dedicated health/export/static handlers)
|
||||
Hono daemon routes (22 route groups + dedicated health/export/static handlers)
|
||||
|
|
||||
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
|
||||
+-- env routes (status / logs / down)
|
||||
@@ -51,7 +49,7 @@ Hono daemon routes (31 route groups + dedicated health/export/static handlers)
|
||||
v
|
||||
Framework-free domain services
|
||||
|
|
||||
+-- SQLite state (36 migrations)
|
||||
+-- SQLite state (20 migrations)
|
||||
+-- tmux / cmux / resume adapters
|
||||
+-- runtime adapters (Claude Code / Codex / Terminal)
|
||||
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
|
||||
@@ -122,7 +120,7 @@ Important route behaviors:
|
||||
|
||||
### CLI commands
|
||||
|
||||
`index.ts` currently mounts 53 command groups. Reboot-era commands plus:
|
||||
`index.ts` currently mounts 40 command groups. Reboot-era commands plus:
|
||||
|
||||
**Operator and topology commands:**
|
||||
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
|
||||
@@ -174,67 +172,36 @@ The CLI-hosted MCP server exposes 17 tools:
|
||||
|
||||
### UI architecture
|
||||
|
||||
The current UI is shell-first, route-first, and primitive-driven. See
|
||||
`docs/DESIGN.md` for the brand/design spec and `docs/as-built/ui.md` for the detailed
|
||||
as-built UI map.
|
||||
The UI is now explorer-first, with a shared drawer plus center-workspace route model:
|
||||
|
||||
High-level shape:
|
||||
|
||||
- `AppShell` wraps every route with a desktop rail, route-aware `Explorer`,
|
||||
center workspace, shared right drawer, preview stack, and topology overlay
|
||||
provider.
|
||||
- Primary destinations are Dashboard (`/`), Topology (`/topology`), For You
|
||||
(`/for-you`), Project (`/project`), Library (`/specs`), and Settings
|
||||
(`/settings`).
|
||||
- Topology uses scoped routes for host, rig, pod, and seat views:
|
||||
`/topology`, `/topology/rig/$rigId`, `/topology/pod/$rigId/$podName`, and
|
||||
`/topology/seat/$rigId/$logicalId`.
|
||||
- Project uses scoped routes for workspace, mission, and slice:
|
||||
`/project`, `/project/mission/$missionId`, and `/project/slice/$sliceId`.
|
||||
- Library keeps `/specs` as the route but presents the product label
|
||||
`Library`, including skills at `/specs/skills/$skillToken` and
|
||||
`/specs/skills/$skillToken/file/$fileToken`.
|
||||
|
||||
Current UI primitives:
|
||||
|
||||
- Vellum primitives live in `components/ui`: `VellumCard`, `VellumSheet`,
|
||||
`RegistrationMarks`, `StatusPip`, empty states, tabs, tables, buttons, and
|
||||
form controls.
|
||||
- Graphics primitives live in `components/graphics` and `lib`: `RuntimeMark`,
|
||||
`RuntimeBadge`, `ToolMark`, `ToolBadge`, `ActorMark`, `runtime-brand.ts`, and
|
||||
`tool-brand.ts`.
|
||||
- Project metadata primitives live in `components/project/ProjectMetaPrimitives`
|
||||
and render event badges, queue state badges, tag pills, actor chips, date
|
||||
chips, flow chips, proof thumbnails, and proof packet headers.
|
||||
|
||||
Current topology UI:
|
||||
|
||||
- Host topology is a hybrid React Flow graph with soft rig frames, pod frames,
|
||||
compact agent cards, context percentage, token totals, activity card tint,
|
||||
activity rings, hot-potato edge animation, hover/focus CMUX actions, and
|
||||
terminal preview popovers.
|
||||
- Topology table and terminal views share the same route-scope model and preserve
|
||||
graph/table/tree navigation parity to seat detail URLs.
|
||||
- Terminal preview popovers use black-glass styling and are portaled above the
|
||||
React Flow canvas to avoid node stacking conflicts.
|
||||
|
||||
Current project and feed UI:
|
||||
|
||||
- Project scopes render Story, Progress, Artifacts, Tests, Queue, and Topology
|
||||
tabs filtered by workspace, mission, or slice.
|
||||
- Story is narrative and body-primary; Queue is operational and actionable.
|
||||
- For You classifies feed items into action-required, approval, shipped,
|
||||
progress, and observation cards, with body previews, proof thumbnails, action
|
||||
outcomes, actor flow, and queue actions.
|
||||
- Proof images use a reusable black-glass viewer.
|
||||
|
||||
Current drawer/viewer model:
|
||||
|
||||
- `SharedDetailDrawer` remains the shared transient detail surface.
|
||||
- Drawer triggers carry root/path provenance for file and queue item viewers.
|
||||
- The drawer supports outside-click dismissal.
|
||||
- Skill details use Library Explorer navigation plus `FileViewer` in the center
|
||||
workspace instead of a nested three-column browser.
|
||||
- `/` renders `WorkspaceHome` (landing page when no rig selected)
|
||||
- `AppShell` composes: `Explorer` sidebar + shared drawer selection context + `SharedDetailDrawer` + specs workspace context + activity/system surfaces
|
||||
- Selecting a rig in the explorer loads its topology graph in the main area
|
||||
- Clicking a node (in explorer or graph) opens the shared detail drawer
|
||||
- The shared drawer has five primary surfaces:
|
||||
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
|
||||
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
|
||||
- **Specs drawer:** unified library list with `All / Apps / Rigs / Agents` filters, type/stability badges, draft/spec review routing, and spec authoring entry points
|
||||
- **Discovery drawer:** placement-first adopt/bind/materialize flow
|
||||
- **System drawer:** global event/activity surface replacing the old status-bar role
|
||||
- Pod selection opens the rig drawer with pod section expanded (no separate pod drawer)
|
||||
- Human-readable IDs are UI-only: pod labels instead of ULIDs in explorer, short ULID tails for glanceability, full IDs in detail views
|
||||
- Infrastructure/terminal nodes have distinct visual treatment
|
||||
- Graph shows pod grouping via React Flow group nodes, status colors (green=ready, amber=launching, red=failed, gray=stopped), and lightweight hover/runtime hints
|
||||
- Service-backed library entries render as `APP` rows instead of generic rigs
|
||||
- `LibraryReview` for a managed app shows:
|
||||
- `Library — Managed App`
|
||||
- `Copy Setup Prompt`
|
||||
- a `Specialist Agent` card (canonical example: `vault.specialist`)
|
||||
- a library-only `Environment` tab with compose preview, health gates, and surfaces
|
||||
- The rig drawer now has `Info`, `Env`, and `Chat Room` tabs for service-backed rigs
|
||||
- `RigEnvPanel` exposes overall env state, per-service health, health gates, surfaces, logs, and explicit env teardown
|
||||
- Explicit center-workspace routes now exist for:
|
||||
- `/specs`
|
||||
- `/specs/rig`
|
||||
- `/specs/agent`
|
||||
- `/specs/library/$entryId`
|
||||
- `/rigs/$rigId/nodes/$logicalId`
|
||||
|
||||
---
|
||||
|
||||
@@ -361,41 +328,10 @@ Migration boundary:
|
||||
- `028_project_classifications.ts` through `030_views_custom.ts` add the PL-004 Phase B classifier + view tables.
|
||||
- `031_watchdog_jobs.ts` and `032_watchdog_history.ts` add the PL-004 Phase C watchdog supervision-tree tables. `watchdog_jobs` is the canonical scheduler state (policy, target_session, scan + active-wake intervals, actionable + last_actionable_at active-wake state, lifecycle); `watchdog_history` is the append-only audit of meaningful evaluations only (loud `sent`/`skipped`/`terminal`). Quiet skip reasons (`not_due`, `no_actionable_artifacts`, `no_missing_edge_artifacts`, `active_wake_not_due`) are NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are not woken about scheduler polls. Scan cadence (`scan_interval_seconds`) is enforced by the scheduler; re-delivery cadence (`active_wake_interval_seconds`) is enforced by the policy engine and only suppresses repeated wakes during a continuously-actionable window. Phase D extends the policy enum to four values (Phase C's three plus `workflow-keepalive`); see § Workflow Runtime below.
|
||||
- `033_workflow_specs.ts`, `034_workflow_instances.ts`, `035_workflow_step_trails.ts` add the PL-004 Phase D Workflow Runtime tables. Migration `036_watchdog_policy_enum_extension.ts` is a documenting no-op that records the Phase D enum extension (Phase C uses application-layer enforcement via `PHASE_D_POLICIES` array, so no DDL is needed).
|
||||
- `037_mission_control_actions.ts` adds the PL-005 Phase A Mission Control action audit table. Append-only at the API surface; records every operator action through Mission Control with before/after qitem snapshots for forensic reconstruction (see § Mission Control / Queue Observability below).
|
||||
|
||||
### Mission Control / Queue Observability (PL-005 Phase A)
|
||||
|
||||
Daemon-backed Mission Control surface. Per PRD acceptance criteria: seven views, seven verbs, first-class human seats, recent-ships=10, and a daemon-backed action audit table; no old-dashboard migration/cutover.
|
||||
|
||||
**Seven views** (`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`, `recently-active`, `recent-observations`) all return rows in the load-bearing 9-field phone-friendly content model (rig/mission name, current phase, active|idle|attention|blocked|degraded, next-action, pending-human-decision, read-cost, last-update timestamp, confidence/freshness, evidence link). The model is non-negotiable across all 7 views; UI may render compact, JSON preserves all 9.
|
||||
|
||||
`MissionControlReadLayer` maps each view to its source-of-truth path:
|
||||
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004 Phase A `queue_items` via `QueueRepository`.
|
||||
- `fleet` consumes the per-rig CLI capability cache + queue summary.
|
||||
- `recently-active` delegates to PL-004 Phase B `ViewProjector.show("recently-active")`.
|
||||
- `recent-observations` reads PL-004 Phase A `stream_items` via `StreamStore`.
|
||||
|
||||
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep) are graceful-degradation aids, NOT the primary path.
|
||||
|
||||
**Seven verbs** (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute through the load-bearing `MissionControlWriteContract`. Each verb is one atomic daemon transaction: queue mutation via `QueueRepository.updateWithinTransaction()` (which preserves Phase A hot-potato closure validation) + audit row in `mission_control_actions` + persisted `mission_control.action_executed` event, all in one `db.transaction`. The 4-step `handoff` shape (source-update + destination-create + opt-in best-effort notify + audit-record append) is verified atomic; notify failure does NOT roll back durable mutations (PRD invariant). Failure-injection rolls back source closure + audit row + new qitem together.
|
||||
|
||||
**Cross-CLI-version drift** is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance:
|
||||
1. Per-field availability check (`MissionControlFleetCliCapability.probeRig`).
|
||||
2. Per-rig capability honesty (each rig surfaces its own capabilities; mixed fleet rendered honestly).
|
||||
3. Once-per-session-per-rig logging (`MissionControlFleetCliCapability` deduplicates drift events by `(rig, field)` until daemon restart).
|
||||
4. Fleet-level "rigs running stale CLI" indicator (`FleetView` surfaces `staleCliCount` from `/api/mission-control/cli-capabilities`).
|
||||
|
||||
**Human seats are first-class product concepts**: the operator's default `human-operator@kernel` seat is rendered via `HumanSeatCard` showing identity, pending load, blocked count, and verb capabilities. NOT invisible config-layer convention.
|
||||
|
||||
**Mission Control is integrated product UI inside the existing shell** — a new top-level route `/mission-control` registered in `packages/ui/src/routes.tsx` mounting `MissionControlSurface`, NOT a new managed app. An earlier prototype that informed Mission Control's view/verb vocabulary was retired in PL-005 Phase B; git history preserves it as evidence.
|
||||
|
||||
PL-005 Phase B extends the daemon Mission Control surface with: a tailnet-bind bearer-token middleware (`packages/daemon/src/middleware/auth-bearer-token.ts` — constant-time comparison via `crypto.timingSafeEqual`; daemon refuses non-loopback bind when the bearer is empty; mounted on `POST /api/mission-control/action` and `POST /api/mission-control/notifications/test`); a notification dispatcher with two adapters (`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts` alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env; ntfy.sh recommended for the operator's phone via tailnet); a read-only audit-history browse layer (`audit-browse.ts`) over `mission_control_actions` exposed at `GET /api/mission-control/audit` with filters (`qitem_id`, `action_verb`, `actor_session`, `since`, `until`) and `(limit, before_id)` pagination cursored on SQLite `rowid` (deterministic insertion order; ULID action_id same-ms tail is non-deterministic). Two new RigEvent types (`mission_control.notification_sent`, `mission_control.notification_failed`) cover the notification audit trail. Phase B adds NO migrations — the audit table from Phase A migration `037_mission_control_actions` is the only data source. UI adds an `audit-history` tab to `MissionControlSurface` with filter UI + paginated result table; the existing 7 Phase A views and 7 verbs are unmodified.
|
||||
|
||||
PL-005 Phase A extends `RigEvent` with 3 new types: `mission_control.action_executed`, `mission_control.cli_drift_detected`, `mission_control.view_refreshed`. Existing 32 PL-004 events untouched. Localhost-only auth at v0 (matches existing dashboard posture; remote-bind is a separate slice).
|
||||
|
||||
### Workflow Runtime (PL-004 Phase D)
|
||||
|
||||
Daemon-native Workflow Runtime — declarative workflow specs, live instance state, append-only step trails, and the load-bearing transactional-scribe contract. The PRD § L4 operating model is owner-as-author semantically plus workflow-as-transactional-scribe mechanically.
|
||||
Daemon-native Workflow Runtime — declarative workflow specs, live instance state, append-only step trails, and the load-bearing transactional-scribe contract. Per PRD § L4 and founder direction (2026-04-22): owner-as-author semantically + workflow-as-transactional-scribe mechanically.
|
||||
|
||||
`workflow_specs` is a read-through cache of human-authored markdown/YAML spec files. Sources are workspace-surface; the daemon caches by `(name, version)` with a content `source_hash` so that valid operator edits to the spec file win at next read (workspace-surface reconciliation). Spec authoring stays markdown-authoritative; `workflow_specs` exists for fast lookup and runtime resolution.
|
||||
|
||||
@@ -796,7 +732,7 @@ Restore now:
|
||||
|
||||
### Config and preflight flow
|
||||
|
||||
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. The original runtime env keys retain deprecated `RIGGED_*` aliases for upgrade compatibility: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`. New typed config keys use `OPENRIG_*` only.
|
||||
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`
|
||||
- Precedence: CLI flag > env var > config file > default
|
||||
- `rig preflight` checks: Node.js ≥ 20, tmux available, writable home/db/transcript dirs, port available
|
||||
- Auto-preflight runs on `rig up` and daemon start
|
||||
@@ -1141,4 +1077,4 @@ These are the main intentional limits that still describe the shipped system:
|
||||
This document is the architecture-level source of truth.
|
||||
|
||||
For file-by-file structure across daemon, CLI, and UI, use:
|
||||
- [codemap.md](./codemap.md)
|
||||
- [codemap.md](/Users/mschwarz/code/rigged/docs/as-built/codemap.md)
|
||||
+11
-109
@@ -1,6 +1,6 @@
|
||||
# OpenRig CLI Reference
|
||||
|
||||
Verified against the shipped CLI on 2026-05-15 (v0.3.1) using:
|
||||
Verified against the shipped CLI on 2026-05-03 (v0.2.0) using:
|
||||
- `packages/cli/src/index.ts`
|
||||
- `packages/cli/src/commands/*.ts`
|
||||
- `packages/cli/src/mcp-server.ts`
|
||||
@@ -11,7 +11,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
## Overview
|
||||
|
||||
- Binary: `rig`
|
||||
- Top-level command groups: `57`
|
||||
- Top-level command groups: `53`
|
||||
- Output mode: human-readable by default; many commands also support `--json`
|
||||
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
|
||||
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
|
||||
@@ -74,10 +74,6 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `compact-plan` | Plan Claude compact-in-place candidates without compacting anything |
|
||||
| `heartbeat` | Show workflow execution proof state from queue files |
|
||||
| `seat` | Inspect OpenRig seat observability state |
|
||||
| `agent-image` | Browse, snapshot, and manage agent images |
|
||||
| `context-pack` | Browse, preview, send, and install operator-authored context packs |
|
||||
| `workspace` | Workspace primitive — typed-kind tooling (frontmatter validation) |
|
||||
| `plugin` | Inspect plugins (read-only) — list, show, used-by, validate |
|
||||
|
||||
## Core Daemon and System Commands
|
||||
|
||||
@@ -114,11 +110,10 @@ Subcommands:
|
||||
### `rig config`
|
||||
|
||||
Usage:
|
||||
- `rig config [--json] [--with-source]`
|
||||
- `rig config get <key> [--show-source]`
|
||||
- `rig config [--json]`
|
||||
- `rig config get <key>`
|
||||
- `rig config set <key> <value>`
|
||||
- `rig config reset [<key>]`
|
||||
- `rig config init-workspace [--root <path>] [--force] [--dry-run] [--json]`
|
||||
- `rig config reset`
|
||||
|
||||
Supported keys:
|
||||
- `daemon.port`
|
||||
@@ -126,7 +121,6 @@ Supported keys:
|
||||
- `db.path`
|
||||
- `transcripts.enabled`
|
||||
- `transcripts.path`
|
||||
- `workspace.root` (and other workspace-rooted paths used by `init-workspace`)
|
||||
|
||||
Precedence:
|
||||
- CLI flag
|
||||
@@ -134,13 +128,6 @@ Precedence:
|
||||
- config file
|
||||
- default
|
||||
|
||||
Notes:
|
||||
- `--with-source` (top-level) and `--show-source` (`get`) report per-key source/default for honest provenance.
|
||||
- `init-workspace` scaffolds the default workspace at `~/.openrig/workspace/` (or the `--root` override) with mission/slice folders. `--force` overwrites scaffolded files (does NOT remove directories); `--dry-run` previews without writing. New in v0.3.0.
|
||||
|
||||
Legacy env compatibility: the original runtime keys still accept deprecated
|
||||
`RIGGED_*` aliases. New typed config keys use `OPENRIG_*` only.
|
||||
|
||||
### `rig preflight`
|
||||
|
||||
Usage: `rig preflight [--json]`
|
||||
@@ -261,8 +248,6 @@ Flags:
|
||||
Notes:
|
||||
- When `--snapshot` succeeds, human output includes the restore command.
|
||||
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
|
||||
- v0.2.0 known issue: `rig down <name> --delete` can return HTTP 404; use
|
||||
`rig ps` to find the rig ID and run `rig down <rigId> --delete`.
|
||||
|
||||
### `rig env`
|
||||
|
||||
@@ -282,7 +267,7 @@ Notes:
|
||||
|
||||
Usage:
|
||||
- `rig ps [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
|
||||
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--host <id>]`
|
||||
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
|
||||
|
||||
Notes:
|
||||
- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`.
|
||||
@@ -291,8 +276,7 @@ Notes:
|
||||
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
|
||||
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
|
||||
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
|
||||
- `--filter <key=value>` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do).
|
||||
- `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture.
|
||||
- `--filter <key=value>` accepts only `status`, `lifecycleState`, `name-prefix`, `name`. Unknown keys are rejected before any HTTP call with a clear error naming the supported list.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
|
||||
- Exit codes:
|
||||
- `0` success
|
||||
@@ -505,8 +489,8 @@ members:
|
||||
ref:
|
||||
kind: artifact_set # v1 rebuild mode supports "artifact_set" only
|
||||
value: # ordered list, highest-trust first
|
||||
- <substrate-shared-docs>/rigs/<rig>/CULTURE.md
|
||||
- <substrate-shared-docs>/specs/agents/<role>.md
|
||||
- /Users/wrandom/code/substrate/shared-docs/rigs/<rig>/CULTURE.md
|
||||
- /Users/wrandom/code/substrate/shared-docs/specs/agents/<role>.md
|
||||
- /path/to/handover-packet.md
|
||||
- /path/to/state/<pod>/<member>.queue.md
|
||||
- /path/to/state/<pod>/shared.session.log
|
||||
@@ -628,12 +612,12 @@ hosts:
|
||||
- id: vm-claude-test
|
||||
transport: ssh
|
||||
target: vm-claude-test.local
|
||||
user: your-username # optional
|
||||
user: wrandom # optional
|
||||
notes: "Tart VM" # optional
|
||||
- id: laptop-b
|
||||
transport: ssh
|
||||
target: laptop-b.tail-scale-net
|
||||
user: your-username
|
||||
user: wrandom
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
@@ -864,88 +848,6 @@ Notes:
|
||||
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
|
||||
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
|
||||
|
||||
## Mission Control / Queue Observability (PL-005 Phase A)
|
||||
|
||||
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only."
|
||||
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture.md` § Mission Control / Queue Observability. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
|
||||
Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
|
||||
|
||||
## Agent Images, Context Packs, and Workspace (v0.3.0)
|
||||
|
||||
Three top-level commands shipped in v0.3.0 for operator-authored library content (agent images and context packs) and the workspace primitive.
|
||||
|
||||
### `rig agent-image`
|
||||
|
||||
Usage: `rig agent-image <subcommand>` — browse, snapshot, and manage agent images (PL-016).
|
||||
|
||||
Subcommands:
|
||||
- `list [options]` — list all agent images in the library.
|
||||
- `show <name-or-id> [options]` — show image manifest + statistics.
|
||||
- `preview <name-or-id> [options]` — show manifest + sized supplementary file metadata + starter snippet.
|
||||
- `create <source-session> [options]` — capture a productive seat's resumable state into a new agent image.
|
||||
- `delete <name-or-id> [options]` — delete an agent image (subject to evidence-preservation guard).
|
||||
- `pin <name-or-id> [options]` — pin an image so prune cannot delete it.
|
||||
- `unpin <name-or-id> [options]` — unpin an image.
|
||||
- `prune [options]` — delete evictable images (protected by evidence-preservation guard).
|
||||
- `sync [options]` — re-walk discovery roots and refresh the library index.
|
||||
|
||||
Notes:
|
||||
- Images are an operator-authored library form; deletion is gated by an evidence-preservation guard so productive seat snapshots are not lost accidentally.
|
||||
- `pin` / `unpin` are the operator levers for explicit retention; `prune` honours them.
|
||||
|
||||
### `rig context-pack`
|
||||
|
||||
Usage: `rig context-pack <subcommand>` — browse, preview, send, and install operator-authored context packs.
|
||||
|
||||
Subcommands:
|
||||
- `list [options]` — list all context packs in the library.
|
||||
- `show <name-or-id> [options]` — show pack manifest + per-file metadata.
|
||||
- `preview <name-or-id> [options]` — show the assembled bundle (the exact text that would be sent).
|
||||
- `sync [options]` — re-walk discovery roots and refresh the library index.
|
||||
- `add <source-dir> [options]` — install a context pack from a local directory into `~/.openrig/context-packs/`.
|
||||
- `send <name-or-id> <destination-session> [options]` — assemble the pack into one paste-ready bundle and send to a seat.
|
||||
|
||||
Notes:
|
||||
- Context packs are operator-authored bundles of context (manifest + files) intended to prime a managed seat with a coherent starting context.
|
||||
- `preview` is the canonical way to see exactly what `send` will deliver; useful before priming a live seat.
|
||||
- `send --dry-run` is supported for preview-then-deliver flows.
|
||||
|
||||
### `rig workspace`
|
||||
|
||||
Usage: `rig workspace <subcommand>` — Workspace Primitive (PL-007), v0 typed-kind tooling.
|
||||
|
||||
Subcommands:
|
||||
- `validate [root]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`.
|
||||
|
||||
Notes:
|
||||
- v0 surface is intentionally narrow (`validate` only) — read-only frontmatter audit.
|
||||
- Future versions will add typed-kind authoring/refactor tooling on the same root walker.
|
||||
- See `rig config init-workspace` to scaffold a fresh default workspace.
|
||||
|
||||
## Plugin Inspection (v0.3.1)
|
||||
|
||||
One read-only top-level command added in v0.3.1 to inspect plugins
|
||||
discovered from `$OPENRIG_HOME/plugins/` (default `~/.openrig/plugins/`).
|
||||
No `install` verb at v0 — installation is explicit operator copy or
|
||||
symlink per each plugin's `OPENRIG-INSTALL.md`.
|
||||
|
||||
### `rig plugin`
|
||||
|
||||
Usage: `rig plugin <subcommand>` — read-only plugin inspection.
|
||||
|
||||
Subcommands:
|
||||
- `list [options]` — list discoverable plugins (aggregated across vendored + runtime caches).
|
||||
- `show <id> [options]` — show plugin manifest + skills + hooks + mcp servers.
|
||||
- `used-by <id> [options]` — list agents referencing this plugin in their `profile.uses.plugins[]`.
|
||||
- `validate <path> [options]` — validate plugin manifest + skill frontmatter against the agentskills.io spec.
|
||||
|
||||
Notes:
|
||||
- Plugin discovery aggregates `$OPENRIG_HOME/plugins/` (vendored at runtime by the operator) with the daemon's bundled plugin cache.
|
||||
- `openrig-core` ships bundled with the daemon (11 skills). Additional plugins (`gstack` — 45 skills; `obra-superpowers` — 14 skills) ship as substrate references for plugin authors to copy-install per the `OPENRIG-INSTALL.md` workflow inside each plugin's source tree.
|
||||
- A first-class `rig plugin install <substrate-path>` verb is deferred to 0.3.2.
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
|
||||
|
||||
Current footprint:
|
||||
- daemon: 211 source files (126 domain, 31 routes, 11 adapters, 36 migrations, 7 other)
|
||||
- CLI: 78 source files
|
||||
- daemon: 150 source files (90 domain, 22 routes, 11 adapters, 20 migrations, 7 other)
|
||||
- CLI: 49 source files
|
||||
- UI: 87 source files
|
||||
- total: 376 source files
|
||||
- total: 286 source files
|
||||
|
||||
How to use this doc:
|
||||
- `Exports` lists the public symbols defined by the file.
|
||||
@@ -27,7 +27,7 @@ Fast lookup:
|
||||
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
|
||||
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
|
||||
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (53 groups)
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (40 groups)
|
||||
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
|
||||
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
|
||||
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
|
||||
@@ -42,7 +42,7 @@ Fast lookup:
|
||||
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
|
||||
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
|
||||
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 36 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, Final Stretch 3 managed-app services, and PL-004 coordination services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 20 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, and Final Stretch 3 managed-app services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
|
||||
### Adapters
|
||||
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
@@ -98,23 +98,6 @@ Fast lookup:
|
||||
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/037_mission_control_actions.ts`: PL-005 Phase A — Mission Control action audit table (append-only; 7-verb enum at app layer; before/after qitem snapshots; FK to queue_items). Exports: missionControlActionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
|
||||
### Domain: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-action-log.ts`: Append-only writer for mission_control_actions. API surface lacks update/delete/remove (contract enforcement). Validates verb-specific required fields (annotate→annotation, hold/drop→reason). Exports: MissionControlActionLog, MissionControlActionLogError, MissionControlVerb, MISSION_CONTROL_VERBS. Related: `packages/daemon/src/db/migrations/037_mission_control_actions.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-write-contract.ts`: LOAD-BEARING. Atomic 7-verb action coordinator. Each verb is one db.transaction: queue mutation via Phase D's QueueRepository.updateWithinTransaction (preserves Phase A hot-potato closure validation; emits queue.updated) + audit row + persisted mission_control.action_executed event. The 4-step handoff shape (source-update + destination-create + opt-in best-effort notify + audit append) is verified atomic; notify failure does NOT roll back durable state. Exports: MissionControlWriteContract, MissionControlWriteContractError, MissionControlActionInput, MissionControlActionResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/mission-control/mission-control-action-log.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-read-layer.ts`: Maps 7 Mission Control views to source-of-truth paths. my-queue/human-gate/active-work/recent-ships query queue_items via QueueRepository; fleet uses fleetCliCapability roll-up; recently-active delegates to PL-004 Phase B ViewProjector.show("recently-active"); recent-observations reads stream_items via StreamStore. All rows return the 9-field phone-friendly content model (PRD non-negotiable). Exports: MissionControlReadLayer, MissionControlViewName, CompactStatusRow, MISSION_CONTROL_VIEWS. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/view-projector.js`, `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts`: Per-rig CLI capability cache + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: per-field availability check, per-rig honesty, once-per-session-per-rig logging, "rigs running stale CLI" indicator. Synthesizes per-rig activity state (active|idle|attention|blocked|degraded) from queue_items aggregation. Exports: MissionControlFleetCliCapability, FleetRollupRow, FleetRollup, MISSION_CONTROL_DESIRED_FIELDS. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/db/migrations/024_queue_items.js`.
|
||||
- `packages/daemon/src/routes/mission-control.ts`: Hono route module for `/api/mission-control` (`GET /views` literal list; `GET /views/:view-name` view fetch; `POST /action` 7-verb atomic execute; `GET /sse` + `GET /watch` SSE; `GET /cli-capabilities` fleet drift cache). SSE/literal paths mounted before /views/:view-name catchall per PL-004 Phase A R1 route-order discipline. Exports: missionControlRoutes. Related: `packages/daemon/src/domain/mission-control/*`, `packages/daemon/src/domain/event-bus.js`.
|
||||
|
||||
### Product UI: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/ui/src/components/mission-control/MissionControlSurface.tsx`: Top-level Mission Control surface inside the existing product UI shell. Tab navigation across the 7 view names. Mounted at the `/mission-control` route. Exports: MissionControlSurface.
|
||||
- `packages/ui/src/components/mission-control/views/{MyQueueView,HumanGateView,FleetView,ActiveWorkView,RecentShipsView,RecentlyActiveView,RecentObservationsView}.tsx`: 7 view components. Most wrap a shared GenericListView; FleetView combines CliDriftIndicator + per-row MissingFieldPlaceholder; MyQueueView combines HumanSeatCard + GenericListView with verb actions enabled.
|
||||
- `packages/ui/src/components/mission-control/components/CompactStatusRow.tsx`: 9-field phone-friendly content-model atom (PRD non-negotiable). Renders compact / expanded density modes; UI may collapse but JSON preserves all 9 fields.
|
||||
- `packages/ui/src/components/mission-control/components/VerbActions.tsx`: Shared 7-verb action sub-component. Renders verb buttons + per-verb required-field input (destination for route/handoff, annotation for annotate, reason for hold/drop). Submits to `POST /api/mission-control/action`. Invalidates Mission Control view queries on success.
|
||||
- `packages/ui/src/components/mission-control/components/HumanSeatCard.tsx`: First-class human-seat rendering. Shows session identity, pending load, blocked count, verb capabilities. NOT invisible config-layer convention.
|
||||
- `packages/ui/src/components/mission-control/components/CliDriftIndicator.tsx`: Cross-CLI-version drift indicator (PRD sub-clause 4). Shows fleet-level "rigs running stale CLI" badge + per-row "field unavailable on this rig's daemon version" placeholders via the MissingFieldPlaceholder export.
|
||||
- `packages/ui/src/components/mission-control/hooks/{useMissionControlView,useMissionControlAction,useMissionControlCliCapabilities}.ts`: TanStack Query hooks fetching from `/api/mission-control/{views/:name,action,cli-capabilities}`.
|
||||
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# OpenRig UI As-Built
|
||||
|
||||
This document describes the current UI architecture and component system. The
|
||||
brand/design rules live in `docs/DESIGN.md`; this file focuses on how the shipped UI
|
||||
is assembled.
|
||||
|
||||
## Package Shape
|
||||
|
||||
UI package: `packages/ui`
|
||||
|
||||
Primary implementation areas:
|
||||
|
||||
- `src/routes.tsx` - TanStack Router route tree
|
||||
- `src/components/AppShell.tsx` - global shell, rail, Explorer, drawer, preview stack
|
||||
- `src/components/ui/` - reusable vellum and base UI primitives
|
||||
- `src/components/graphics/` - runtime, tool, and actor marks
|
||||
- `src/components/topology/` - host/rig/pod/seat graph, table, terminal, activity
|
||||
- `src/components/project/` - workspace/mission/slice observability and project metadata primitives
|
||||
- `src/components/for-you/` - attention feed and actionable queue cards
|
||||
- `src/components/specs/` - Library and skill file viewing
|
||||
- `src/components/preview/` - terminal/session preview panes
|
||||
- `src/hooks/` - route data hooks and event consumption
|
||||
- `src/lib/` - classifiers, formatters, layout helpers, brand helpers, activity logic
|
||||
|
||||
## Shell Model
|
||||
|
||||
`AppShell` wraps every route with:
|
||||
|
||||
- Desktop rail
|
||||
- Route-aware Explorer sidebar
|
||||
- Center workspace
|
||||
- Shared detail drawer
|
||||
- Preview stack
|
||||
- Topology overlay state provider
|
||||
- Shared drawer selection context
|
||||
- Shared discovery placement context
|
||||
|
||||
The rail destinations are:
|
||||
|
||||
- Dashboard: `/`
|
||||
- Topology: `/topology`
|
||||
- For You: `/for-you`
|
||||
- Project: `/project`
|
||||
- Library: `/specs`
|
||||
- Settings: `/settings`
|
||||
- Advisor and Operator placeholders/settings links
|
||||
|
||||
The Explorer is contextual. It changes its tree based on the current destination
|
||||
rather than behaving as a generic file browser.
|
||||
|
||||
## Route Model
|
||||
|
||||
Main routes:
|
||||
|
||||
- `/` - Dashboard
|
||||
- `/topology` - host topology scope
|
||||
- `/topology/rig/$rigId` - rig scope
|
||||
- `/topology/pod/$rigId/$podName` - pod scope
|
||||
- `/topology/seat/$rigId/$logicalId` - seat scope with live node details
|
||||
- `/for-you` - attention feed
|
||||
- `/project` - workspace project scope
|
||||
- `/project/mission/$missionId` - mission scope
|
||||
- `/project/slice/$sliceId` - slice scope
|
||||
- `/specs` - Library
|
||||
- `/specs/applications` - application section
|
||||
- `/specs/skills/$skillToken` - skill viewer defaulting to `skill.md`
|
||||
- `/specs/skills/$skillToken/file/$fileToken` - specific skill file viewer
|
||||
- `/settings` - settings center
|
||||
- `/search` - audit/history view
|
||||
- `/lab/project-graphics-preview` - graphics preview lab route
|
||||
|
||||
Legacy routes remain for compatibility where needed, including package flows,
|
||||
bootstrap flows, validation flows, and `/rigs/$rigId`.
|
||||
|
||||
## Design Primitives
|
||||
|
||||
The UI moved from route-specific one-off panels toward reusable primitives.
|
||||
|
||||
Base primitives:
|
||||
|
||||
- `VellumCard` - paper card with optional dark header, hard shadow, registration marks
|
||||
- `VellumSheet` - side sheet/drawer with vellum-heavy surface and registration marks
|
||||
- `RegistrationMarks` - corner registration marks
|
||||
- `StatusPip` - semantic status only
|
||||
- `SectionHeader`, `EmptyState`, `Button`, `Tabs`, `Table`, and form controls
|
||||
|
||||
Graphics primitives:
|
||||
|
||||
- `RuntimeMark` and `RuntimeBadge`
|
||||
- `ToolMark` and `ToolBadge`
|
||||
- `ActorMark` and `OperatorMoodMark`
|
||||
- Runtime and tool normalization in `runtime-brand.ts` and `tool-brand.ts`
|
||||
|
||||
Project metadata primitives:
|
||||
|
||||
- `EventBadge`
|
||||
- `QueueStateBadge`
|
||||
- `TagPill`
|
||||
- `ActorChip`
|
||||
- `DateChip`
|
||||
- `FlowChips`
|
||||
- `ProofThumbnailGrid`
|
||||
- `ProofPacketHeader`
|
||||
|
||||
These primitives centralize visual language for runtime identity, queue state,
|
||||
actor flow, event labels, dates, tags, and proof images.
|
||||
|
||||
## Topology
|
||||
|
||||
Topology is a scoped workspace with graph, table, and terminal views.
|
||||
|
||||
Current topology pieces:
|
||||
|
||||
- `HostMultiRigGraph` renders the host-level hybrid React Flow graph.
|
||||
- `HybridAgentNode` renders compact agent cards with runtime badges, context
|
||||
percentage, token totals, activity state, terminal preview, and CMUX actions.
|
||||
- `HybridPodGroupNode` renders soft dashed pod frames.
|
||||
- `RigGroupNode` renders soft rig frames with registration marks and aggregate
|
||||
activity.
|
||||
- `ActivityRing` and card activity classes show active, needs-input, and blocked
|
||||
states.
|
||||
- `HotPotatoEdge` visualizes directional queue movement through the graph.
|
||||
- `TopologyTableView` mirrors topology data in a dense table.
|
||||
- `TopologyTerminalView` shows terminal-oriented topology state.
|
||||
- `TerminalPreviewPopover` provides black-glass quick terminal preview above the
|
||||
graph via a portal.
|
||||
|
||||
Important topology contracts:
|
||||
|
||||
- Graph/table/tree navigation resolves to seat URLs.
|
||||
- Host-level graph uses cross-rig node ID prefixing.
|
||||
- Expanded rig state is managed by `TopologyOverlayProvider`.
|
||||
- Graph data is lazy-fetched for expanded rigs.
|
||||
- Terminal preview actions are hover/focus visible.
|
||||
- The terminal popover must escape React Flow stacking contexts and stay within
|
||||
the viewport.
|
||||
|
||||
## Project Observability
|
||||
|
||||
Project is organized as a hierarchy:
|
||||
|
||||
- Workspace
|
||||
- Mission
|
||||
- Slice
|
||||
|
||||
The current route scope acts as a filter. Higher scopes show broader rollups;
|
||||
lower scopes show more specific story, queue, proof, artifact, test, and topology
|
||||
data.
|
||||
|
||||
Current project tabs:
|
||||
|
||||
- Story - narrative progress and queue body content
|
||||
- Progress - status and activity rollup
|
||||
- Artifacts - files, commits, proof, and outputs
|
||||
- Tests - proof material, screenshots, and verification detail
|
||||
- Queue - operational qitems with actions and drawer detail
|
||||
- Topology - workflow/agent diagram where available
|
||||
|
||||
Project primitives translate raw queue and event data into readable badges,
|
||||
chips, dates, flows, proof thumbnails, and action outcome panels.
|
||||
|
||||
## For You
|
||||
|
||||
For You is the attention-routing surface. It uses live event feed data plus queue
|
||||
details to classify cards into:
|
||||
|
||||
- Action Required
|
||||
- Approval
|
||||
- Shipped
|
||||
- Progress
|
||||
- Observation
|
||||
|
||||
The card renderer prioritizes readable body content, proof thumbnails, actor
|
||||
flow, friendly dates, and action affordances. Queue actions reuse existing
|
||||
mission-control action endpoints.
|
||||
|
||||
## Library And Skills
|
||||
|
||||
`/specs` is the Library route. It contains specs, applications, context packs,
|
||||
agent specs, agent images, and skills.
|
||||
|
||||
Skills are folder-based. The skill tree in Explorer treats each skill as a
|
||||
folder. Clicking a skill opens a skill detail route that defaults to `skill.md`
|
||||
case-insensitively. The skill detail page uses the existing `FileViewer` and
|
||||
does not add a second in-page file browser; file navigation belongs in the
|
||||
Explorer tree.
|
||||
|
||||
## Drawer And Viewer System
|
||||
|
||||
The shared detail drawer is used for transient focused detail:
|
||||
|
||||
- Queue item viewer
|
||||
- File viewer
|
||||
- Subspec preview
|
||||
- Other detail selections
|
||||
|
||||
Drawer triggers carry root/path provenance so the viewer can fetch and render the
|
||||
correct content. The drawer supports outside-click dismissal.
|
||||
|
||||
Image proof viewing is handled by `ProofImageViewer`, which respects the shell
|
||||
layout and uses black-glass styling.
|
||||
|
||||
## Event And Activity Consumption
|
||||
|
||||
UI event consumption is centralized through shared event hooks so the app avoids
|
||||
spawning duplicate SSE connections for related surfaces.
|
||||
|
||||
Current activity consumers include:
|
||||
|
||||
- For You feed hydration
|
||||
- Topology activity rings and hot-potato movement
|
||||
- Activity feed/system surfaces
|
||||
- Rig event surfaces where appropriate
|
||||
|
||||
Reduced-motion preference is honored for pulse and packet animations.
|
||||
|
||||
## Graphics Layer
|
||||
|
||||
The graphics package is intentionally centralized:
|
||||
|
||||
- Runtime identity: Claude, Codex, Terminal, Unknown
|
||||
- Tool identity: CMUX, tmux, VS Code, Terminal, file, markdown, config, code,
|
||||
screenshot, proof, transcript, commit, folder, skill, video, trace
|
||||
- Actor identity: human/operator, runtime agents, terminal fallback
|
||||
|
||||
The goal is to make dense operator surfaces scannable without adding more raw
|
||||
metadata text.
|
||||
|
||||
## Current Design Constraints
|
||||
|
||||
- Use the shared primitives before creating new card styles.
|
||||
- Keep vellum and black-glass separate.
|
||||
- Keep status semantics separate from activity animation.
|
||||
- Do not duplicate runtime/tool brand detection outside the central helpers.
|
||||
- Do not add new daemon shape for UI polish unless the slice explicitly declares
|
||||
and verifies that boundary.
|
||||
- Do not make page-level content look like a raw terminal log unless the content
|
||||
is actually terminal output.
|
||||
@@ -46,7 +46,7 @@ imports:
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [using-superpowers, openrig-user, development-team, test-driven-development, systematic-debugging]
|
||||
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
@@ -91,7 +91,7 @@ profiles:
|
||||
preferences:
|
||||
runtime: claude-code
|
||||
uses:
|
||||
skills: [openrig-user, systematic-debugging, vault-user]
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
@@ -216,7 +216,7 @@ imports:
|
||||
- ref: local:../../shared
|
||||
```
|
||||
|
||||
This gives access to the full pool of shared skills (openrig-user, systematic-debugging, development-team, etc.) and agents select the ones they need via profile `uses`.
|
||||
This gives access to the full pool of shared skills (openrig-user, mental-model-ha, development-team, etc.) and agents select the ones they need via profile `uses`.
|
||||
|
||||
---
|
||||
|
||||
@@ -230,7 +230,7 @@ profiles:
|
||||
runtime: claude-code
|
||||
model: claude-opus-4-6
|
||||
uses:
|
||||
skills: [openrig-user, systematic-debugging, vault-user]
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
hooks: []
|
||||
@@ -258,7 +258,7 @@ The `uses` block selects which resources from the `resources` pool (including im
|
||||
|
||||
```yaml
|
||||
uses:
|
||||
skills: [openrig-user, systematic-debugging, vault-user]
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
hooks: [pre-commit]
|
||||
@@ -410,9 +410,9 @@ The only required file is `agent.yaml`. Everything else is referenced by paths i
|
||||
| Agent | Location | Imports | Profile Skills | Purpose |
|
||||
|-------|----------|---------|---------------|---------|
|
||||
| `shared` | `specs/agents/shared/` | none | — (resource pool only) | Shared skill pool for all built-in agents |
|
||||
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, systematic-debugging, etc. | TDD implementation agent |
|
||||
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, mental-model-ha, etc. | TDD implementation agent |
|
||||
| `qa` | `specs/agents/development/qa/` | `shared` | openrig-user, development-team, etc. | Quality assurance agent |
|
||||
| `orchestrator` | `specs/agents/orchestration/orchestrator/` | `shared` | openrig-user, orchestration-team, etc. | Rig orchestration lead |
|
||||
| `reviewer` | `specs/agents/review/reviewer/` | `shared` | openrig-user, review-team, etc. | Independent code reviewer |
|
||||
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, systematic-debugging, vault-user | Vault domain specialist |
|
||||
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, mental-model-ha, vault-user | Vault domain specialist |
|
||||
| `design` | `specs/agents/design/` | none | — | Product designer |
|
||||
@@ -61,7 +61,7 @@ Skills are delivered to agents through two parallel paths:
|
||||
|
||||
Both paths are important. The spec projection ensures the files are physically present. The startup instruction ensures the agent knows to read them. This redundancy is intentional — it handles cases where one path fails.
|
||||
|
||||
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, systematic-debugging" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
|
||||
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, mental-model-ha" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
|
||||
|
||||
### The File Types
|
||||
|
||||
@@ -301,7 +301,7 @@ Don't put project documentation inside the role guidance. The role is about the
|
||||
|
||||
In a startup file or guidance, include a line like:
|
||||
```
|
||||
You have the following skills loaded: openrig-user, test-driven-development, systematic-debugging. Use them.
|
||||
You have the following skills loaded: openrig-user, test-driven-development, mental-model-ha. Use them.
|
||||
```
|
||||
This prompts the agent to actually invoke the skills, not just have them as passive context.
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# Project Workspace Contract
|
||||
|
||||
OpenRig's Project UI is a file-backed view over a workspace root. The folder
|
||||
shape is intentionally simple so humans and agents can create or repair it
|
||||
without daemon-internal knowledge.
|
||||
|
||||
## Default Shape
|
||||
|
||||
`rig config init-workspace` creates the default workspace at
|
||||
`~/.openrig/workspace` unless `--root` or `workspace.root` points elsewhere.
|
||||
|
||||
```text
|
||||
workspace/
|
||||
README.md
|
||||
STEERING.md
|
||||
missions/
|
||||
README.md
|
||||
idea-ledger/
|
||||
README.md
|
||||
PROGRESS.md
|
||||
slices/
|
||||
capture-product-ideas/
|
||||
README.md
|
||||
PROGRESS.md
|
||||
IMPLEMENTATION-PRD.md
|
||||
triage-product-ideas/
|
||||
README.md
|
||||
PROGRESS.md
|
||||
IMPLEMENTATION-PRD.md
|
||||
handoff-loop/
|
||||
README.md
|
||||
PROGRESS.md
|
||||
slices/
|
||||
route-work-packets/
|
||||
README.md
|
||||
PROGRESS.md
|
||||
IMPLEMENTATION-PRD.md
|
||||
verify-loop-evidence/
|
||||
README.md
|
||||
PROGRESS.md
|
||||
IMPLEMENTATION-PRD.md
|
||||
progress/
|
||||
field-notes/
|
||||
specs/
|
||||
```
|
||||
|
||||
## UI Mapping
|
||||
|
||||
- `workspace.root` maps to the Project workspace.
|
||||
- `workspace.root/missions/<mission-id>` maps to a Project mission.
|
||||
- `workspace.root/missions/<mission-id>/slices/<slice-id>` maps to a Project slice.
|
||||
- Mission `PROGRESS.md` frontmatter supplies the mission status badge when the
|
||||
file root is allowlisted.
|
||||
- Slice `README.md`, `PROGRESS.md`, and `IMPLEMENTATION-PRD.md` frontmatter
|
||||
supply display name, lifecycle status, mission id, and queue linkage hints.
|
||||
|
||||
Mission and slice ids should be stable kebab-case strings. Keep slice ids
|
||||
unique inside the workspace so `/project/slice/<slice-id>` resolves without
|
||||
ambiguity.
|
||||
|
||||
## Queue Mapping
|
||||
|
||||
Queue items attach to a slice when their body or tags mention one of:
|
||||
|
||||
- the slice id;
|
||||
- the mission id;
|
||||
- the legacy `rail-item` value in slice frontmatter.
|
||||
|
||||
For new work, include both mission and slice ids in the queue item body or
|
||||
tags. Example:
|
||||
|
||||
```text
|
||||
Mission: idea-ledger
|
||||
Slice: capture-product-ideas
|
||||
```
|
||||
|
||||
This makes Story, Queue, Tests, and Topology tabs line up with the filesystem
|
||||
slice without adding a separate project database schema.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The default discovery root is `workspace.slices_root=<workspace.root>/missions`.
|
||||
The slice indexer also supports legacy flat roots such as
|
||||
`workspace.slices_root=<workspace.root>/slices`, where each direct child folder
|
||||
is a slice. Flat roots remain readable, but the mission-aware shape is the
|
||||
default setup contract.
|
||||
|
||||
## Repair Checklist
|
||||
|
||||
If Project shows a mission discovery warning:
|
||||
|
||||
1. Run `rig config get workspace.root --show-source`.
|
||||
2. Run `rig config get workspace.slices_root --show-source`.
|
||||
3. Confirm `workspace.slices_root` points at a folder containing mission
|
||||
directories with `slices/` children.
|
||||
4. Confirm `files.allowlist` includes `workspace:<workspace.root>` so the UI
|
||||
can read mission `PROGRESS.md`.
|
||||
5. If the workspace is missing, run `rig config init-workspace` after operator
|
||||
approval.
|
||||
|
||||
No daemon restart is required for most config reads. Restart when changing
|
||||
startup-time roots such as `files.allowlist` or progress scan roots.
|
||||
@@ -7,8 +7,7 @@ It is intentionally simpler than a monolithic `CHANGELOG.md`.
|
||||
Each shipped release gets its own note:
|
||||
|
||||
- `v0.1.12.md`
|
||||
- `v0.2.0.md`
|
||||
- `v0.3.0.md`
|
||||
- `v0.1.13.md`
|
||||
- and so on
|
||||
|
||||
## Why This Exists
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
# OpenRig v0.3.0
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.0 turns the 0.2 coordination substrate into a more polished
|
||||
operator product. It ships the redesigned shell, generic workflow starters,
|
||||
mission-shaped workspace support, project observability, curated bundled skills,
|
||||
runtime graphics, context telemetry, and release-hardening work for a cleaner
|
||||
fresh install.
|
||||
|
||||
This release keeps the reusable primitives for queue handoff, workflow runtime,
|
||||
watchdog supervision, artifact/proof surfaces, and agent coordination, while
|
||||
the public starter content is intentionally generic.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- V1 shell redesign with the vellum visual system, two desktop chrome regions,
|
||||
topology graph/table/terminal modes, drawer viewers, Project, For You, Queue,
|
||||
Library, Settings, and proof-oriented detail surfaces.
|
||||
- Generic starter workflow specs:
|
||||
- `conveyor` for station-style work where multiple packets can be active
|
||||
across the topology at once.
|
||||
- `basic-loop` for a one-packet-at-a-time walkthrough flow.
|
||||
- `conveyor` starter rig and matching agent specs for intake/planning/building
|
||||
and review-oriented work.
|
||||
- Mission-shaped workspace defaults and read-only nested mission/slice indexing.
|
||||
- Project observability upgrades across For You, Story, Queue, Tests, Project
|
||||
rollups, proof screenshots, and qitem drawers.
|
||||
- Runtime/tool graphics package applied across topology, queue, project metadata,
|
||||
proof rows, terminal previews, and agent identity marks.
|
||||
- Terminal preview improvements, activity indicators, handoff edge animation,
|
||||
and context usage telemetry in topology table/terminal views.
|
||||
- Runtime skill discovery from filesystem skill roots during profile resolution.
|
||||
- Pre-release CLI/daemon cleanup: bounded transcript rotation, `rig send`
|
||||
message envelopes, OpenRig install/init marker naming, and public-release
|
||||
terminology cleanup.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### Redesigned Shell
|
||||
|
||||
The UI now uses the V1 shell model with a narrow rail, destination-specific
|
||||
Explorer region, center workspace, and named-trigger drawer. Topology pages have
|
||||
graph, table, and terminal modes at one stable URL. Drawer viewers handle queue
|
||||
items, files, sub-spec previews, terminal previews, and proof assets without
|
||||
turning every reference click into a full-page navigation.
|
||||
|
||||
### Starter Workflows And Rigs
|
||||
|
||||
The release includes generic workflow examples rather than project-specific
|
||||
automation recipes. `conveyor` demonstrates station-based flow with natural
|
||||
queue backpressure. `basic-loop` demonstrates a simple one-token-at-a-time loop.
|
||||
The `conveyor` rig is the public workflow starter. `product-team` remains the
|
||||
more advanced human-operator-driven starter topology.
|
||||
|
||||
### Project And Proof Visibility
|
||||
|
||||
Project surfaces now distinguish current and archived work, show mission/slice
|
||||
hierarchy, route mission labels to mission pages, and expose story/tests/proof
|
||||
rollups. For You and Queue cards hydrate qitem bodies, proof images, audit
|
||||
outcomes, and next-action state. Tests proof screenshots open in an in-page
|
||||
viewer.
|
||||
|
||||
### Topology And Runtime Visibility
|
||||
|
||||
Topology now has a hybrid multi-rig graph, expand/collapse controls, activity
|
||||
rings, animated handoff edges, runtime/tool marks, context percentage/token
|
||||
displays, and a terminal fallback view. Agent detail surfaces now use the
|
||||
canonical live node details view.
|
||||
|
||||
### Skills And Starter Guidance
|
||||
|
||||
Bundled skills were curated for starter coherence. Runtime skill discovery now
|
||||
finds valid `SKILL.md` files in runtime and workspace skill roots, with
|
||||
rig-local resources taking precedence over broader user libraries.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
- Fresh workspace defaults are mission-shaped and derive from
|
||||
`workspace.root`. Use `rig config init-workspace` to create the default
|
||||
workspace structure.
|
||||
- The repo uses npm workspaces and the tracked root `package-lock.json`.
|
||||
`pnpm-lock.yaml` files are not release artifacts.
|
||||
- Root-level runtime projection files such as `CLAUDE.md` are intentionally not
|
||||
tracked in the product repo.
|
||||
- Deprecated `RIGGED_*` aliases remain only for the original runtime env keys:
|
||||
`daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, and
|
||||
`transcripts.path`. New typed config keys use `OPENRIG_*` only.
|
||||
- Existing local workflow specs can still be installed from workspace/spec
|
||||
locations; product source only bundles the generic starter workflows.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Plugin support is not part of 0.3.0. Plugin-packaged skills and external
|
||||
plugin distribution are planned for a later release.
|
||||
- The public starter workflows are examples, not a complete packaged automation
|
||||
methodology.
|
||||
- Some broad CLI tests are sensitive to local daemon port/config state. Focused
|
||||
release-path tests and build/lint gates were used for the final push gate.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint`
|
||||
- `npm run build`
|
||||
- `npm run test:repo`
|
||||
- Focused CLI, daemon, and UI tests for release hygiene, config boundaries,
|
||||
starter skill discovery, shell/topology behavior, and project observability.
|
||||
- Source scans for personal paths, internal attribution phrasing, default
|
||||
fallback assumptions, and unwanted legacy env aliases on new typed keys.
|
||||
|
||||
## Commits Included
|
||||
|
||||
This release contains the accumulated product work since v0.2.0, including:
|
||||
|
||||
- Shell redesign V1 phases and polish slices.
|
||||
- Project observability phases A-G plus follow-up fixes.
|
||||
- Graphics package and broadened runtime/tool marks.
|
||||
- Pre-release UI and CLI/daemon hardening.
|
||||
- 0.3.0 starter refresh and release hygiene commits.
|
||||
@@ -1,336 +0,0 @@
|
||||
# OpenRig v0.3.1
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.1 builds on v0.3.0 with operator-facing UX work, the headline
|
||||
Claude auto-compaction policy, a dashboard + For You visual refresh, the
|
||||
Plugin Primitive v0 surface (`rig plugin`), Library Explorer finishing
|
||||
(skills + plugins + specs with real folder browsing), Settings Destination
|
||||
Explorer, action outcome + inline error UX, and a comprehensive privacy
|
||||
hygiene pass.
|
||||
|
||||
No schema-breaking migrations. Existing v0.3.0 databases upgrade on first
|
||||
`rig daemon start` (adds migration `040_workflow_specs_diagnostic` — two
|
||||
new diagnostic columns on `workflow_specs` cache, `ALTER TABLE ADD COLUMN`
|
||||
with safe defaults).
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **Claude Auto-Compaction Policy** — operator-configurable session
|
||||
auto-`/compact` with safe defaults (opt-in default-off; no behavior
|
||||
change unless explicitly enabled). 7 new ConfigStore keys; 6-stage
|
||||
daemon-to-LLM lifecycle (pre-prep prompt → compact → handshake →
|
||||
restore → compliance audit → cooldown); PreCompact hook +
|
||||
SessionStart/UserPromptSubmit bridge.
|
||||
- **Plugin Primitive v0** — `rig plugin` command surface (read-only at
|
||||
v0: `list`, `show`, `used-by`, `validate`). Plugin discovery from
|
||||
`$OPENRIG_HOME/plugins/`, vendored-and-provenance pattern. No
|
||||
`install` verb at v0; deferred to 0.3.2.
|
||||
- **Library Explorer Finishing** — skills + plugins + specs as a
|
||||
fully operator-facing surface with real folder browsing. New
|
||||
daemon-owned library discovery API (`/api/skills/library`,
|
||||
`/api/skills/:id/files/...`, `/api/plugins/:id/files/...`).
|
||||
- **Settings Destination Explorer** — settings became a 4-item
|
||||
Explorer destination matching Topology / Project / Library / For-You
|
||||
pattern, with `/settings`, `/settings/policies`, `/settings/log`,
|
||||
`/settings/status` sub-routes.
|
||||
- **Dashboard And For You Visual Refresh** — both routes adopt a
|
||||
shared "vellum" surface language (translucent stone-tinted cards,
|
||||
paper-grid background, ambient shadow, mono+dot kind indicators,
|
||||
L-shaped corner-bracket registration marks). Single source of truth
|
||||
between production and `/lab/vellum-lab`.
|
||||
- **For You Storytelling Adapter** — Progress / Shipped / Incident /
|
||||
Approval card kinds wired to real data (missions, slices, activity
|
||||
feed, queue items). ApprovalCard surfaces real queue items waiting
|
||||
on approval.
|
||||
- **Action Outcome + Inline Error Surface** — queue-item verb actions
|
||||
(Approve/Deny/Route/Hold/Drop/Annotate/Handoff) render outcomes
|
||||
immediately on success and surface failures inline without
|
||||
silently reverting. Closes a demo-blocking silent-revert bug.
|
||||
- **Mobile Drawer Behavior** — 375px Explorer drawer now layers above
|
||||
the rail tray for Settings / Project / Library / For-You. Topology
|
||||
mobile drawer is intentionally hidden in 0.3.1; scheduled for full
|
||||
restoration in 0.3.2.
|
||||
- **Vendored Skill Provenance** — vendored skills declare upstream
|
||||
lineage via `metadata.openrig` frontmatter and (when modified) a
|
||||
companion `OPENRIG.md` sidecar. Applied to all 10 process-skill
|
||||
surfaces.
|
||||
- **Workflow specs diagnostic surface** — migration `040` adds
|
||||
`status` + `error_message` columns to the `workflow_specs` cache so
|
||||
the Library UI can render parse/validation errors as diagnostic
|
||||
rows instead of hiding them.
|
||||
- **Privacy hygiene pass** — three filter-repo passes scrubbed
|
||||
personal data, internal references, and historic versions from
|
||||
history; commit subjects and bodies polished for public surface.
|
||||
Backup repos preserved for rollback.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### Claude Auto-Compaction Policy
|
||||
|
||||
Operator-configurable auto-`/compact` for Claude sessions, with safe
|
||||
defaults:
|
||||
|
||||
- **Opt-in default-off**: nothing changes unless you explicitly enable
|
||||
the policy. With no policy configured, the daemon never sends an
|
||||
auto-`/compact`.
|
||||
- **7 new ConfigStore keys** in the `policies.claude_compaction.*`
|
||||
namespace: `enabled`, `threshold_percent` (strict integer 1-100),
|
||||
`compact_instruction` (operator-supplied; appended to `/compact`
|
||||
args when set), `message_inline`, `message_file_path`,
|
||||
`pre_compact_instruction`, `post_restore_audit_instruction`. Strict
|
||||
validation across all source layers (`set`/`POST`/env/file). Invalid
|
||||
env values fall back to defaults with a stderr warning rather than
|
||||
silently coercing.
|
||||
- **5 operator-editable prompt surfaces** in Settings → Policies UI:
|
||||
pre-compaction prep, compact instruction, post-compaction restore
|
||||
(inline), restore file path, post-restore audit. Daemon-owned
|
||||
wrappers (usage/threshold framing, trust-channel preservation,
|
||||
marker paths, read-depth enforcement, turn-boundary handshake,
|
||||
dedup + cooldown) are non-editable.
|
||||
- **6-stage lifecycle**: pre-compact prep prompt (save-game-before-quit
|
||||
mental-model restore map) → `/compact` with operator instructions
|
||||
→ post-compact turn-boundary handshake → restore prompt with
|
||||
defense-in-depth fallback chain → compliance audit (forces
|
||||
FULL/PARTIAL/NOT_READ table) → 10-minute cooldown.
|
||||
- **Safety hardening**: typed prompt drafts classified as attention
|
||||
state so auto-`/compact` retries later instead of overwriting human
|
||||
input. Send-failure does not advance dedup state. Re-arm requires
|
||||
threshold-crossing.
|
||||
|
||||
### New `rig plugin` Command (read-only at v0)
|
||||
|
||||
```
|
||||
rig plugin list # Discoverable plugins across vendored + runtime caches
|
||||
rig plugin show <id> # Plugin manifest + skills + hooks + mcp servers
|
||||
rig plugin used-by <id> # Agents referencing this plugin
|
||||
rig plugin validate <path> # Validate plugin manifest + skill frontmatter
|
||||
```
|
||||
|
||||
No `install` verb at v0 — installation is explicit operator copy or
|
||||
symlink to `$OPENRIG_HOME/plugins/<plugin-id>/`. See each plugin's
|
||||
`OPENRIG-INSTALL.md` for the documented copy/symlink workflow. A
|
||||
`rig plugin install <substrate-path>` verb is deferred to 0.3.2.
|
||||
|
||||
### Library Explorer Finishing
|
||||
|
||||
The Library destination (skills + plugins + specs) is now fully
|
||||
operator-facing:
|
||||
|
||||
- No duplicate top-level entries (`> SKILLS` / `> PLUGINS` rows removed
|
||||
from `SpecsTreeView`; bottom-row clicks navigate + expand the tree).
|
||||
- Plugins above Skills in the tree.
|
||||
- 32 OpenRig-managed shared skills are now discoverable in production
|
||||
VM environments (previously empty due to workspace-relative path
|
||||
lookup).
|
||||
- Daemon-owned library discovery API: `GET /api/skills/library`,
|
||||
`GET /api/skills/:id/files/list?path=...` + `/files/read?path=...`,
|
||||
parallel routes under `/api/plugins/:id/`.
|
||||
- Real file-browser + docs-browser on plugin and skill detail pages.
|
||||
Markdown auto-renders; non-markdown files render as text. Folder
|
||||
navigation works.
|
||||
- Rolled-up index pages at `/specs/skills` and `/specs/plugins` with
|
||||
flat-row listings, source labels, file counts, and entry links.
|
||||
|
||||
### Settings Destination Explorer
|
||||
|
||||
Settings is now a top-level Explorer destination matching the Topology /
|
||||
Project / Library / For-You pattern:
|
||||
|
||||
- `/settings` (general config keys form)
|
||||
- `/settings/policies` (Claude auto-compaction policy form lives here)
|
||||
- `/settings/log`
|
||||
- `/settings/status`
|
||||
|
||||
Old top-row tab navigation removed. Shared `SettingsPageShell` chrome
|
||||
across all 4 sub-routes.
|
||||
|
||||
### Dashboard And For You Visual Refresh
|
||||
|
||||
A coordinated visual refresh to a shared "vellum" surface language:
|
||||
|
||||
- **Dashboard `/`** — full rewrite over new `dashboard/vellum/`
|
||||
primitives. Hero typography ("WELCOME BACK") at display-lg + headline-
|
||||
bold. Tactical instrument-panel stats line with tabular numerals.
|
||||
Six destination cards (Topology / Project / For You / Library /
|
||||
Search / Settings) share the numeral-layout treatment. Real-data
|
||||
hooks: `useRigSummary`, `usePsEntries`, `useSpecLibrary`.
|
||||
- **For You `/for-you`** — both card systems unified to the vellum
|
||||
recipe: storytelling band (`CardShell` rewritten with bg-stone-100/45
|
||||
+ backdrop-blur + ambient shadow + corner brackets) and queue-item
|
||||
`FeedCard.tsx` (same outer chrome; vellum bordered-no-fill action
|
||||
buttons with hover-invert and 44px touch targets).
|
||||
- **Single source of truth** between `/dashboard` and
|
||||
`/lab/vellum-lab` via `packages/ui/src/components/dashboard/vellum/`.
|
||||
- **Lab routes** — `/lab/card-previews`, `/lab/vellum-lab`,
|
||||
`/lab/vellum-bg/{a-large,b-small,c-allover}` are checked-in
|
||||
experiment surfaces for designer iteration. Reachable in production
|
||||
by direct URL; not linked from main navigation.
|
||||
|
||||
### For You Storytelling Adapter
|
||||
|
||||
The storytelling band at top of `/for-you` now wires four card kinds to
|
||||
real data:
|
||||
|
||||
- **Progress** — first 2 active missions (`useMissionDiscovery`)
|
||||
- **Shipped** — slices with status = shipped/complete/done (capped at 3;
|
||||
from `useSlices`)
|
||||
- **Incident** — slices with status = blocked/failed/danger (capped at 3)
|
||||
- **Approval** — queue items waiting on approval, from
|
||||
`useActivityFeed` + `classifyFeed` (capped at 2)
|
||||
|
||||
`ConceptCard` component is preserved in source but not emitted by the
|
||||
production adapter; a deliberate data-source decision is scheduled for
|
||||
0.3.2.
|
||||
|
||||
### Action Outcome And Inline Error Surface
|
||||
|
||||
Queue-item verb actions (Approve/Deny/Route/Hold/Drop/Annotate/Handoff):
|
||||
|
||||
- On success, `ActionOutcomePanel` renders immediately ("Approved by X",
|
||||
"Routed by X to Y") — no waiting for audit-log roundtrip.
|
||||
- On error, a tertiary-bordered error block renders below the verb
|
||||
buttons with the daemon's error message; verb-selection state is
|
||||
preserved so the operator can correct and retry.
|
||||
- Replaces the prior silent-revert UX. Regression test guards the class
|
||||
explicitly.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/[email protected]
|
||||
rig --version # expect 0.3.1
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
Migration `040_workflow_specs_diagnostic` applies automatically on first
|
||||
post-upgrade daemon start. It is a non-destructive `ALTER TABLE ADD
|
||||
COLUMN` with safe defaults (existing rows retroactively get
|
||||
`status: 'valid'`). No manual action required.
|
||||
|
||||
For Claude auto-compaction policy: opt-in default-off. Visit
|
||||
`/settings/policies` to configure. Recommended for sessions where
|
||||
context exhaustion is a recurring operational concern. Read the docs
|
||||
section in the Settings UI before enabling.
|
||||
|
||||
For the new `rig plugin` surface: no behavior change unless you install
|
||||
plugins. Bundled `openrig-core` plugin ships with the daemon (11 skills).
|
||||
Operators can install `gstack` (45 skills) or `obra-superpowers`
|
||||
(14 skills) by following each plugin's `OPENRIG-INSTALL.md` workflow.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **`rig down <name>` still returns HTTP 404** — name-based teardown
|
||||
still doesn't resolve through the daemon down route at v0.3.1. Use
|
||||
`rig down <rigId> --delete` instead. Carries forward as known issue
|
||||
to v0.3.2.
|
||||
- **`rig queue list --json --limit` and `rig view list/show --json`
|
||||
flag inconsistency** — flags scaffolded but not fully wired. Use
|
||||
human-readable output for now. Carries forward to v0.3.2.
|
||||
- **Topology mobile drawer hidden** at 375px viewports in 0.3.1
|
||||
(intentional; clicking the hamburger triggered a pre-existing
|
||||
`TopologyTableView` renderer cascade). Full restoration scheduled
|
||||
for 0.3.2 via a dedicated render-path slice.
|
||||
- **`ConceptCard` component preserved in source** but not emitted by
|
||||
the production For You adapter. A deliberate data-source decision is
|
||||
scheduled for 0.3.2.
|
||||
- **`/lab/*` routes ship to production builds** intentionally per
|
||||
founder direction; 0.3.2 adds an index page + per-lab banner +
|
||||
manifest pattern.
|
||||
- **Plugin install ergonomics** — v0 plugin install is operator-driven
|
||||
copy or symlink; first-class `rig plugin install <substrate-path>`
|
||||
is deferred to 0.3.2.
|
||||
- **Lifecycle classifications** remain truthful but still rely on
|
||||
heuristics rather than perfect provider-native primitives in some
|
||||
recovery paths.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- `npm run test:repo` green.
|
||||
- Daemon test suite: 3600 pass, 1 pre-existing host-env-sensitive
|
||||
failure on `restore-check-routes` reading malformed local Claude
|
||||
settings (banked as environment-dependent class).
|
||||
- UI test suite: 1106 pass / 0 fail (post-test-fixes merge; was 1093
|
||||
pass + 3 failing on stale-test pre-fix).
|
||||
- TypeScript checks clean on all touched files.
|
||||
- Code review (review-lead, advisory-clean) on every merge in the
|
||||
release window.
|
||||
- Founder-walk VM at `http://100.95.124.51:17459/` walked end-to-end
|
||||
on host-main `4d88d98d` with realistic demo seed (8 seeded queue
|
||||
items, 8 feed events, 4 verified `/for-you` action scenarios
|
||||
including the inline error path).
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.0 → v0.3.1 delta is 224 commits / 376 files / +37,391
|
||||
insertions / −3,153 deletions. The list below groups load-bearing
|
||||
commits by theme:
|
||||
|
||||
### Claude Auto-Compaction Policy
|
||||
- `feat(daemon,cli)`: Claude auto-compaction policy scaffolding +
|
||||
ConfigStore keys
|
||||
- `feat(daemon)`: 6-stage compaction lifecycle (pre-prep → compact →
|
||||
handshake → restore → compliance → cooldown)
|
||||
- `feat(daemon)`: PreCompact hook + SessionStart/UserPromptSubmit
|
||||
bridge for marker restoration
|
||||
- `feat(daemon)`: SessionTransport draft-attention classification +
|
||||
send-failure dedup discipline
|
||||
|
||||
### Plugin Primitive v0
|
||||
- `feat(daemon,cli)`: `rig plugin` command + plugin discovery surface
|
||||
- `feat(daemon)`: plugin manifest + skill provenance metadata schema
|
||||
- `chore(skills)`: vendoring metadata + `OPENRIG.md` sidecars for 10
|
||||
process-skill surfaces
|
||||
|
||||
### Library Explorer
|
||||
- `feat(daemon)`: library discovery API
|
||||
(`/api/skills/library`, `/api/{skills,plugins}/:id/files/list+read`)
|
||||
- `feat(ui)`: SpecsTreeView dual-action + plugin/skill detail file
|
||||
browsers
|
||||
- `feat(ui)`: rolled-up `/specs/skills` and `/specs/plugins` index
|
||||
pages
|
||||
|
||||
### Settings Destination Explorer
|
||||
- `feat(ui)`: 4-route Settings Explorer destination
|
||||
(`/settings`/`policies`/`log`/`status`) with shared `SettingsPageShell`
|
||||
|
||||
### Dashboard + For You Visual Refresh
|
||||
- `redesign(dashboard)`: full rewrite over `dashboard/vellum/`
|
||||
primitives + 6 destination cards
|
||||
- `redesign(for-you)`: vellum-coherent storytelling cards + queue-item
|
||||
FeedCard chrome unification
|
||||
- `refactor(dashboard)`: extract vellum primitives — single source of
|
||||
truth between `/dashboard` and `/lab/vellum-lab`
|
||||
- `simplify(dashboard)`: drop back layer + back vellum sheet; add
|
||||
ambient shadow
|
||||
- `merge(release)`: storytelling adapter extension — approval cards
|
||||
wired to real data
|
||||
- `feat(for-you)`: wire ApprovalCard to real data; remove ConceptCard
|
||||
from production switch
|
||||
- `fix(for-you)`: optimistic action outcome + inline error surface in
|
||||
VerbActions
|
||||
|
||||
### Migration 040 (workflow_specs diagnostic)
|
||||
- `feat(daemon)`: migration 040 adds `status` + `error_message`
|
||||
diagnostic columns to `workflow_specs` cache
|
||||
|
||||
### Final test alignment
|
||||
- `test(ui)`: fix 3 pre-existing failures — match deliberate design
|
||||
changes
|
||||
- `docs(changelog)`: cover 0.3.1 dashboard + for-you visual refresh +
|
||||
action UX
|
||||
|
||||
### Privacy hygiene
|
||||
- Three filter-repo passes (LOCAL-INSIGHTS personal-data scrub +
|
||||
commit-body internal-doc reference scrub + file-content historic
|
||||
versions scrub)
|
||||
- Commit subject rewrites to remove privacy-scream patterns
|
||||
- Force-pushed at commit `7e55d9d3` as the privacy-cleaned baseline
|
||||
|
||||
See `CHANGELOG.md` for the full agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
Generated
+7
-42
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -2296,26 +2296,6 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/router-core": {
|
||||
"version": "1.168.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.3.tgz",
|
||||
@@ -2348,19 +2328,6 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -7419,7 +7386,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -7445,12 +7412,12 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7474,7 +7441,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
@@ -7487,7 +7454,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -7498,8 +7464,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"yaml": "^2.8.3"
|
||||
"tailwindcss": "^3.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
|
||||
+3
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -12,12 +12,10 @@
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"test": "npm run test:repo && npm run test:workspaces",
|
||||
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs && node scripts/mirror-skills.mjs --check",
|
||||
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs",
|
||||
"test:workspaces": "npm run test --workspaces",
|
||||
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
|
||||
"build:package": "bash scripts/build-package.sh",
|
||||
"mirror-skills": "node scripts/mirror-skills.mjs",
|
||||
"mirror-skills:check": "node scripts/mirror-skills.mjs --check"
|
||||
"build:package": "bash scripts/build-package.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20 || ^22 || ^24"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
@@ -35,7 +35,7 @@
|
||||
"scripts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/",
|
||||
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
|
||||
"postinstall": "node scripts/check-abi.mjs",
|
||||
"prepublishOnly": "bash ../../scripts/build-package.sh",
|
||||
"test": "vitest run",
|
||||
@@ -61,6 +61,6 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^20 || ^22 || ^24"
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
// Fork Primitive + Starter Agent Images v0 (PL-016) — `rig agent-image`
|
||||
// CLI verb family. Parallel to `rig context-pack` shipped in PL-014.
|
||||
//
|
||||
// Subcommands:
|
||||
// create <source-session> --name <name> — capture image from a live seat
|
||||
// list — list all images
|
||||
// show <name-or-id> — manifest + stats
|
||||
// preview <name-or-id> — assembled preview + starter snippet
|
||||
// delete <name-or-id> [--force] — delete (subject to evidence guard)
|
||||
// pin <name-or-id> — pin from prune
|
||||
// unpin <name-or-id> — unpin
|
||||
// prune [--force] [--dry-run] [--json] — bulk delete evictable images
|
||||
// sync — re-walk discovery roots
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface AgentImageEntryWire {
|
||||
id: string;
|
||||
kind: "agent-image";
|
||||
name: string;
|
||||
version: string;
|
||||
runtime: "claude-code" | "codex";
|
||||
sourceSeat: string;
|
||||
sourceSessionId: string;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
sourceType: "user_file" | "workspace" | "builtin";
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
updatedAt: string;
|
||||
manifestEstimatedTokens: number | null;
|
||||
derivedEstimatedTokens: number;
|
||||
files: Array<{
|
||||
path: string;
|
||||
role: string;
|
||||
summary: string | null;
|
||||
absolutePath: string | null;
|
||||
bytes: number | null;
|
||||
estimatedTokens: number | null;
|
||||
}>;
|
||||
sourceResumeToken: string;
|
||||
stats: {
|
||||
forkCount: number;
|
||||
lastUsedAt: string | null;
|
||||
estimatedSizeBytes: number;
|
||||
lineage: string[];
|
||||
};
|
||||
lineage: string[];
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
interface PreviewWire extends Omit<AgentImageEntryWire, "sourcePath" | "relativePath" | "updatedAt" | "kind" | "sourceResumeToken" | "sourceType"> {
|
||||
starterSnippet: string;
|
||||
}
|
||||
|
||||
interface PruneWire {
|
||||
dryRun: boolean;
|
||||
forced?: boolean;
|
||||
protected?: Array<{
|
||||
imageId: string;
|
||||
imageName: string;
|
||||
imageVersion: string;
|
||||
reasons: string[];
|
||||
references: string[];
|
||||
}>;
|
||||
evictable?: Array<{ imageId: string; imageName: string; imageVersion: string }>;
|
||||
deleted?: string[];
|
||||
errors?: Array<{ imageId: string; error: string }>;
|
||||
}
|
||||
|
||||
async function resolveImage(client: DaemonClient, nameOrId: string): Promise<AgentImageEntryWire> {
|
||||
if (nameOrId.startsWith("agent-image:")) {
|
||||
const res = await client.get<AgentImageEntryWire>(`/api/agent-images/library/${encodeURIComponent(nameOrId)}`);
|
||||
if (res.status === 200) return res.data;
|
||||
if (res.status === 404) throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
|
||||
throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library/${nameOrId}`);
|
||||
}
|
||||
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/agent-images/library`);
|
||||
const matches = (res.data ?? []).filter((e) => e.name === nameOrId);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`Agent image '${nameOrId}' not found in library. Run 'rig agent-image list' to see what's installed.`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const versions = matches.map((m) => m.version).join(", ");
|
||||
throw new Error(`Agent image '${nameOrId}' is ambiguous (versions: ${versions}). Use 'agent-image:${nameOrId}:<version>'.`);
|
||||
}
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
export function agentImageCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("agent-image")
|
||||
.description("Browse, snapshot, and manage agent images (PL-016)")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig agent-image list
|
||||
rig agent-image show driver-release-primed
|
||||
rig agent-image create velocity-driver@openrig-velocity --name driver-release-primed --notes "after review"
|
||||
rig agent-image preview driver-release-primed
|
||||
rig agent-image pin driver-release-primed
|
||||
rig agent-image prune --dry-run
|
||||
rig agent-image delete driver-release-primed --force
|
||||
`);
|
||||
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
async function getClient(): Promise<DaemonClient> {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
throw new Error("Daemon not running. Start it with: rig daemon start");
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
cmd.command("list")
|
||||
.description("List all agent images in the library")
|
||||
.option("--runtime <runtime>", "Filter by runtime (claude-code | codex)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { runtime?: string; json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<AgentImageEntryWire[]>("/api/agent-images/library");
|
||||
let entries = res.data ?? [];
|
||||
if (opts.runtime) entries = entries.filter((e) => e.runtime === opts.runtime);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
console.log("No agent images. Capture one with: rig agent-image create <source-session> --name <name>");
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const pinned = e.pinned ? " 📌" : "";
|
||||
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${e.runtime.padEnd(12)} forks: ${String(e.stats.forkCount).padStart(3)} ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType}${pinned}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("show")
|
||||
.argument("<name-or-id>", "Image name or library id")
|
||||
.option("--json", "JSON output")
|
||||
.description("Show image manifest + statistics")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolveImage(client, nameOrId);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Name: ${entry.name}`);
|
||||
console.log(`Version: ${entry.version}`);
|
||||
console.log(`Runtime: ${entry.runtime}`);
|
||||
console.log(`Source: ${entry.sourceSeat}`);
|
||||
console.log(`Created: ${entry.createdAt}`);
|
||||
console.log(`Path: ${entry.sourcePath}`);
|
||||
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
|
||||
console.log(`Pinned: ${entry.pinned}`);
|
||||
console.log("");
|
||||
console.log(`Stats:`);
|
||||
console.log(` fork count: ${entry.stats.forkCount}`);
|
||||
console.log(` last used: ${entry.stats.lastUsedAt ?? "never"}`);
|
||||
console.log(` estimated size: ${entry.stats.estimatedSizeBytes} bytes`);
|
||||
console.log(` lineage: ${entry.lineage.length === 0 ? "(none)" : entry.lineage.join(" → ")}`);
|
||||
if (entry.notes) {
|
||||
console.log("");
|
||||
console.log("Notes:");
|
||||
console.log(` ${entry.notes.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("preview")
|
||||
.argument("<name-or-id>", "Image name or library id")
|
||||
.option("--json", "JSON output")
|
||||
.description("Show manifest + sized supplementary file metadata + starter snippet")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolveImage(client, nameOrId);
|
||||
const res = await client.get<PreviewWire>(`/api/agent-images/library/${encodeURIComponent(entry.id)}/preview`);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const preview = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`# Preview: ${preview.name} v${preview.version} (${preview.runtime})`);
|
||||
console.log(`# Source seat: ${preview.sourceSeat}`);
|
||||
console.log(`# Stats: fork=${preview.stats.forkCount}, last-used=${preview.stats.lastUsedAt ?? "never"}, size=${preview.stats.estimatedSizeBytes}B`);
|
||||
if (preview.lineage.length > 0) console.log(`# Lineage: ${preview.lineage.join(" → ")}`);
|
||||
console.log("");
|
||||
console.log("# Starter snippet (paste into agent.yaml):");
|
||||
console.log(preview.starterSnippet);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("create")
|
||||
.argument("<source-session>", "Source session canonical name (e.g., velocity-driver@openrig-velocity)")
|
||||
.description("Capture a productive seat's resumable state into a new agent image")
|
||||
.requiredOption("--name <name>", "Image name (used as the directory name and library id)")
|
||||
// Use --image-version instead of --version because Commander.js
|
||||
// intercepts the global --version flag (prints CLI version + exits
|
||||
// 0 silently). Per-command name resolves the collision without
|
||||
// losing the global --version surface.
|
||||
.option("--image-version <version>", "Image version (default: 1)")
|
||||
.option("--notes <text>", "Operator-supplied notes preserved in manifest")
|
||||
.option("--estimated-tokens <n>", "Operator-supplied token estimate")
|
||||
.option("--lineage <names...>", "Comma-separated parent image names if forking from another image")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (sourceSession: string, opts: {
|
||||
name: string;
|
||||
imageVersion?: string;
|
||||
notes?: string;
|
||||
estimatedTokens?: string;
|
||||
lineage?: string[];
|
||||
json?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const body: Record<string, unknown> = {
|
||||
sourceSession,
|
||||
name: opts.name,
|
||||
};
|
||||
if (opts.imageVersion) body["version"] = opts.imageVersion;
|
||||
if (opts.notes) body["notes"] = opts.notes;
|
||||
if (opts.estimatedTokens) {
|
||||
const n = Number(opts.estimatedTokens);
|
||||
if (Number.isFinite(n)) body["estimatedTokens"] = n;
|
||||
}
|
||||
if (opts.lineage && opts.lineage.length > 0) body["lineage"] = opts.lineage;
|
||||
const res = await client.post<{ imageId: string; imagePath: string; manifest: { name: string; version: string; runtime: string } }>(
|
||||
"/api/agent-images/snapshot",
|
||||
body,
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
const data = res.data as Partial<{ error: string; message: string; details: unknown }>;
|
||||
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
|
||||
}
|
||||
const r = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Captured ${r.manifest.name} v${r.manifest.version} (${r.manifest.runtime}) at ${r.imagePath}`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("delete")
|
||||
.argument("<name-or-id>", "Image name or library id")
|
||||
.option("--force", "Override evidence-preservation guard (CATASTROPHIC if active references exist)")
|
||||
.option("--json", "JSON output")
|
||||
.description("Delete an agent image (subject to evidence-preservation guard)")
|
||||
.action(async (nameOrId: string, opts: { force?: boolean; json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolveImage(client, nameOrId);
|
||||
const url = `/api/agent-images/library/${encodeURIComponent(entry.id)}${opts.force ? "?force=true" : ""}`;
|
||||
const res = await client.delete<{ ok: boolean; forced: boolean; error?: string; message?: string; reasons?: string[] }>(url);
|
||||
if (res.status !== 200) {
|
||||
const data = res.data as Partial<{ error: string; message: string; reasons: string[] }>;
|
||||
throw new Error(data.message ?? data.error ?? `Daemon returned HTTP ${res.status}`);
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Deleted ${entry.id}${opts.force ? " (forced)" : ""}.`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("pin")
|
||||
.argument("<name-or-id>", "Image name or library id")
|
||||
.option("--json", "JSON output")
|
||||
.description("Pin an image so prune cannot delete it")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolveImage(client, nameOrId);
|
||||
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/pin`);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
|
||||
else console.log(`Pinned ${entry.id}.`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("unpin")
|
||||
.argument("<name-or-id>", "Image name or library id")
|
||||
.option("--json", "JSON output")
|
||||
.description("Unpin an image")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolveImage(client, nameOrId);
|
||||
const res = await client.post(`/api/agent-images/library/${encodeURIComponent(entry.id)}/unpin`);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
if (opts.json) console.log(JSON.stringify(res.data, null, 2));
|
||||
else console.log(`Unpinned ${entry.id}.`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("prune")
|
||||
.option("--dry-run", "Preview without deleting (default if not --force)")
|
||||
.option("--force", "Override evidence-preservation guard (CATASTROPHIC: deletes referenced images)")
|
||||
.option("--json", "JSON output")
|
||||
.description("Delete evictable images (protected by evidence-preservation guard)")
|
||||
.action(async (opts: { dryRun?: boolean; force?: boolean; json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const dryRun = opts.dryRun !== false && !opts.force;
|
||||
const res = await client.post<PruneWire>("/api/agent-images/prune", {
|
||||
dryRun,
|
||||
force: !!opts.force,
|
||||
});
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
const r = res.data;
|
||||
if (r.dryRun) {
|
||||
const protectedList = r.protected ?? [];
|
||||
const evictable = r.evictable ?? [];
|
||||
console.log(`(dry-run) ${protectedList.length} protected, ${evictable.length} evictable`);
|
||||
if (protectedList.length > 0) {
|
||||
console.log("");
|
||||
console.log("Protected:");
|
||||
for (const p of protectedList) {
|
||||
console.log(` ${p.imageName} v${p.imageVersion}: ${p.reasons.join(", ")}`);
|
||||
for (const ref of p.references) console.log(` ↪ ${ref}`);
|
||||
}
|
||||
}
|
||||
if (evictable.length > 0) {
|
||||
console.log("");
|
||||
console.log("Evictable:");
|
||||
for (const e of evictable) console.log(` ${e.imageName} v${e.imageVersion}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Deleted ${(r.deleted ?? []).length} image(s).${r.forced ? " (forced)" : ""}`);
|
||||
for (const err of r.errors ?? []) console.log(` error: ${err.imageId}: ${err.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("sync")
|
||||
.option("--json", "JSON output")
|
||||
.description("Re-walk discovery roots and refresh the library index")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: AgentImageEntryWire[] }>(
|
||||
"/api/agent-images/library/sync",
|
||||
);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Indexed ${res.data.count} agent image(s).`);
|
||||
for (const e of res.data.errors) console.log(` error: ${e.source}: ${e.error}`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
// User Settings v0 — `rig config init-workspace` workspace scaffolding.
|
||||
//
|
||||
// Creates the mission-aware default workspace at ~/.openrig/workspace/ (or a
|
||||
// caller-supplied --root). The same scaffold is also used idempotently by
|
||||
// daemon startup so a fresh install has a browsable Project workspace before
|
||||
// the user discovers the explicit command.
|
||||
//
|
||||
// Behavior:
|
||||
// - Reads workspace.root setting (default ~/.openrig/workspace/).
|
||||
// - Creates canonical subdirs: missions/ progress/ field-notes/ specs/ dogfood-evidence/.
|
||||
// - Seeds two example missions with multiple slices.
|
||||
// - Drops workspace README.md + STEERING.md.
|
||||
// - --dry-run: report what would be created without acting.
|
||||
// - --force: overwrite existing files (NOT directories — never deletes
|
||||
// operator content).
|
||||
// - Idempotent without --force: existing-dir + existing-readme is a no-op.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
|
||||
export interface InitWorkspaceOpts {
|
||||
root?: string;
|
||||
force?: boolean;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export interface InitWorkspaceResult {
|
||||
root: string;
|
||||
/** True when this run would (or did) create the root dir; false if root existed. */
|
||||
rootCreated: boolean;
|
||||
subdirs: Array<{ name: string; path: string; created: boolean }>;
|
||||
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
|
||||
/** True when --dry-run; nothing was actually written. */
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
type DefaultSlice = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
};
|
||||
|
||||
type DefaultMission = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
slices: DefaultSlice[];
|
||||
};
|
||||
|
||||
const DEFAULT_MISSIONS: DefaultMission[] = [
|
||||
{
|
||||
id: "getting-started",
|
||||
title: "Getting Started",
|
||||
status: "active",
|
||||
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
|
||||
slices: [
|
||||
{
|
||||
id: "first-conveyor-run",
|
||||
title: "First Conveyor Run",
|
||||
status: "active",
|
||||
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
|
||||
},
|
||||
{
|
||||
id: "inspect-project-evidence",
|
||||
title: "Inspect Project Evidence",
|
||||
status: "draft",
|
||||
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const WORKSPACE_DIRS = [
|
||||
"missions",
|
||||
"artifacts",
|
||||
"evidence",
|
||||
"progress",
|
||||
"field-notes",
|
||||
"specs",
|
||||
"dogfood-evidence",
|
||||
...DEFAULT_MISSIONS.flatMap((mission) => [
|
||||
`missions/${mission.id}`,
|
||||
`missions/${mission.id}/slices`,
|
||||
...mission.slices.map((slice) => `missions/${mission.id}/slices/${slice.id}`),
|
||||
]),
|
||||
] as const;
|
||||
|
||||
function subdirReadmeContent(subdir: string): string {
|
||||
switch (subdir) {
|
||||
case "missions":
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
|
||||
case "artifacts":
|
||||
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
|
||||
case "evidence":
|
||||
return "# evidence\n\nHuman-readable verification notes live here. Use this for compact proof summaries that are not tied to a screenshot or video packet.\n";
|
||||
case "progress":
|
||||
return "# progress\n\nPROGRESS.md tree. OpenRig's Progress browse view scans this directory recursively for PROGRESS.md files and renders them as a hierarchical tree.\n";
|
||||
case "field-notes":
|
||||
return "# field-notes\n\nOperator field notes. Free-form markdown notes from your daily work. OpenRig surfaces these alongside missions and slices for context.\n";
|
||||
case "specs":
|
||||
return "# specs\n\nWorkspace specs (rig specs / agent specs / workflow specs / context packs / skills). OpenRig's Library browses this directory alongside bundled specs.\n";
|
||||
case "dogfood-evidence":
|
||||
return "# dogfood-evidence\n\nProof packets live here. Each proof packet folder is matched to a Project slice by folder-name tokens and may contain markdown, screenshots, videos, traces, and other verification artifacts.\n";
|
||||
default:
|
||||
return `# ${subdir}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const WORKSPACE_README = `# OpenRig Workspace
|
||||
|
||||
This workspace is file-backed. The Project UI mirrors this structure:
|
||||
|
||||
- \`missions/<mission-id>\` becomes a Project mission.
|
||||
- \`missions/<mission-id>/slices/<slice-id>\` becomes a Project slice.
|
||||
- Queue items should mention or tag the mission id and slice id so Project can attach live work to the right slice.
|
||||
- \`artifacts/\` is for work products that a slice needs to keep.
|
||||
- \`evidence/\` is for proof notes and verification summaries.
|
||||
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
|
||||
|
||||
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
|
||||
`;
|
||||
|
||||
const STEERING_PLACEHOLDER = `---
|
||||
title: Priority Stack
|
||||
status: placeholder
|
||||
---
|
||||
|
||||
# OpenRig Priority Stack
|
||||
|
||||
This file is a placeholder created by \`rig config init-workspace\`. Edit it
|
||||
to record your top priorities. OpenRig's Steering surface reads this file
|
||||
alongside your Project workspace.
|
||||
|
||||
## Top 3
|
||||
|
||||
1. Run the \`conveyor\` starter rig.
|
||||
2. Move one packet through \`basic-loop\` or \`conveyor\`.
|
||||
3. Inspect the Project, Queue, Story, and Tests surfaces.
|
||||
|
||||
## In Motion
|
||||
|
||||
(Active slices land here as you push them through the priority rail.)
|
||||
|
||||
## Loop State
|
||||
|
||||
(Health gates + loop diagnostics land here.)
|
||||
`;
|
||||
|
||||
function missionReadme(mission: DefaultMission): string {
|
||||
return `---
|
||||
title: ${mission.title}
|
||||
status: ${mission.status}
|
||||
mission: ${mission.id}
|
||||
---
|
||||
|
||||
# ${mission.title}
|
||||
|
||||
${mission.objective}
|
||||
|
||||
## Slices
|
||||
|
||||
${mission.slices.map((slice) => `- [${slice.title}](slices/${slice.id}/README.md)`).join("\n")}
|
||||
`;
|
||||
}
|
||||
|
||||
function missionProgress(mission: DefaultMission): string {
|
||||
return `---
|
||||
title: ${mission.title} Progress
|
||||
status: ${mission.status}
|
||||
mission: ${mission.id}
|
||||
---
|
||||
|
||||
# ${mission.title} Progress
|
||||
|
||||
- [ ] Keep mission README current.
|
||||
- [ ] Keep active slices queue-backed with mission and slice ids.
|
||||
`;
|
||||
}
|
||||
|
||||
// V0.3.1 slice 21 onboarding-conveyor — narrative content for the
|
||||
// getting-started mission's two slices. PARITY CONTRACT with
|
||||
// `packages/daemon/src/domain/workspace/getting-started-narrative.ts`:
|
||||
// the daemon side is the canonical source; this CLI-side mirror MUST
|
||||
// stay byte-identical. The narrative-parity test in
|
||||
// `packages/daemon/test/getting-started-narrative-parity.test.ts`
|
||||
// (added in this slice) asserts byte-identity by reading both this
|
||||
// file and the daemon module — if either drifts, the test fails.
|
||||
// If you update content here, update the daemon mirror in lockstep.
|
||||
|
||||
const FIRST_CONVEYOR_RUN_README = `# First Conveyor Run
|
||||
|
||||
Welcome to OpenRig. This is the first slice in your getting-started mission. It exists to teach you what a **conveyor run** is + how OpenRig moves work through a multi-agent topology.
|
||||
|
||||
## What a conveyor run is
|
||||
|
||||
A **conveyor** is OpenRig's central work-movement primitive. One conveyor run = one complete flow of work through your topology:
|
||||
|
||||
1. **Slice declared** — operator (you) or an agent declares a slice with intent + acceptance criteria
|
||||
2. **Work routed** — orchestrator picks the slice up + routes it to the right agent
|
||||
3. **Agents collaborate** — work moves agent-to-agent via durable handoffs (queue items)
|
||||
4. **Evidence accumulates** — each step leaves a trail: commits, files, proof packets, screenshots
|
||||
5. **Slice closes** — acceptance criteria met; orchestrator routes the result back
|
||||
|
||||
You're looking at a mocked conveyor run RIGHT NOW. The other tabs (Story / Progress / Artifacts / Tests / Queue / Topology) each show a different slice of this same run.
|
||||
|
||||
## Walking the tabs
|
||||
|
||||
- **Overview** (you're here) — read what a slice intends + how to use the rest of the tabs
|
||||
- **Story** — narrative timeline of what happened, beat by beat
|
||||
- **Progress** — acceptance checklist; what's done, what's pending
|
||||
- **Artifacts** — files produced, commits, proof packets — the durable evidence trail
|
||||
- **Tests** — pass/fail summaries + screenshots + verification proof
|
||||
- **Queue** — operational qitems showing how work moved between agents
|
||||
- **Topology** — the rig graph; which agents touched this slice + which edges fired
|
||||
|
||||
## Click through
|
||||
|
||||
Try them in order. Each tab teaches itself.
|
||||
`;
|
||||
|
||||
const FIRST_CONVEYOR_RUN_TIMELINE = `# Story — First Conveyor Run
|
||||
|
||||
## 2026-04-15 09:00 — Slice declared
|
||||
|
||||
Operator request: "Build a CLI tool that lints todo lists." Slice declared in \`getting-started/slices/first-conveyor-run/\`. Acceptance: \`tdl lint <file>\` exits non-zero on malformed entries.
|
||||
|
||||
## 09:02 — Orchestrator routes
|
||||
|
||||
\`orch-lead@getting-started\` picks up the slice. Routes to \`driver@getting-started\` via \`rig queue handoff\`. Driver receives nudge; opens IMPL-PRD; reads acceptance.
|
||||
|
||||
## 09:05 — Driver picks up
|
||||
|
||||
Driver claims qitem. Reads slice scope. Surveys existing tdl repo (this is a worked example — imagine the repo is real). Plans: parse YAML; validate entries; exit code per finding.
|
||||
|
||||
## 09:30 — First commit
|
||||
|
||||
\`feat(lint): parse + validate todo entries\` — driver commits 4 files. Stream event emitted; visible in Artifacts tab.
|
||||
|
||||
## 10:15 — Driver hands to reviewer
|
||||
|
||||
Acceptance met locally. Driver hands off to \`reviewer@getting-started\`. Reviewer opens diff; reads tests.
|
||||
|
||||
## 10:25 — Reviewer flags concern
|
||||
|
||||
"Edge case: what about UTF-8 entries with combining characters?" Hands back to driver as \`concerning\`.
|
||||
|
||||
## 10:40 — Driver addresses
|
||||
|
||||
Driver fixes UTF-8 handling. Re-runs tests; all pass. Hands back.
|
||||
|
||||
## 11:00 — Reviewer accepts
|
||||
|
||||
\`accept\` decision; closes qitem with \`closure-reason: handed_off_to orch-lead\`. Conveyor proceeds.
|
||||
|
||||
## 11:05 — Slice ships
|
||||
|
||||
Orchestrator merges. Proof packet generated. Slice marked SHIPPED.
|
||||
|
||||
---
|
||||
|
||||
**What you just read**: one conveyor run from declaration to ship. ~2 hours wall-clock; 3 agents involved; 1 handoff with concern + remediation; 5 stream events; 1 proof packet.
|
||||
|
||||
The Story tab shows this kind of narrative for any real slice. Driver-authored \`timeline.md\` files live in each slice folder; this is yours to read + your operator's to update as work moves.
|
||||
`;
|
||||
|
||||
const FIRST_CONVEYOR_RUN_PROGRESS = `---
|
||||
title: First Conveyor Run Progress
|
||||
status: active
|
||||
mission: getting-started
|
||||
rail-item: getting-started
|
||||
slice: first-conveyor-run
|
||||
---
|
||||
|
||||
# Progress — First Conveyor Run
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Parse YAML todo entries
|
||||
- [x] Validate per-entry shape (id, title, status, due-date)
|
||||
- [x] Exit non-zero on malformed entries
|
||||
- [x] Handle UTF-8 entries with combining characters
|
||||
- [x] Tests pass (12/12)
|
||||
- [x] Reviewer accepts
|
||||
- [x] Slice merged
|
||||
|
||||
## Status: SHIPPED
|
||||
|
||||
This was a mocked conveyor run — no real code was produced. But the Progress tab works the same way for real work: as acceptance criteria are met, boxes get checked; the operator can scan at a glance whether the slice is on track.
|
||||
|
||||
## How the Progress tab works
|
||||
|
||||
The tab renders the slice's \`PROGRESS.md\` markdown file. Driver updates it as work progresses. Operator scans it during walks. Founder reviews at slice closure.
|
||||
|
||||
Try writing one for a real slice you start. It's just markdown; live updates appear here.
|
||||
`;
|
||||
|
||||
const INSPECT_PROJECT_EVIDENCE_README = `# Inspect Project Evidence
|
||||
|
||||
The previous slice (First Conveyor Run) showed you what a conveyor run looks like as it happens. This slice teaches you how to inspect the evidence after a run completes.
|
||||
|
||||
## Why inspect evidence?
|
||||
|
||||
In OpenRig, agents work autonomously. The operator (you) doesn't watch every keystroke. Instead, the operator:
|
||||
|
||||
1. Declares slices with clear acceptance
|
||||
2. Routes them to agents
|
||||
3. Inspects evidence when slices close
|
||||
|
||||
The Artifacts + Tests + Queue + Topology tabs are your inspection surface. They show you what happened, with enough detail to:
|
||||
|
||||
- Verify the acceptance was actually met
|
||||
- Spot subtle issues a passing test might miss
|
||||
- Build trust in the agents over time
|
||||
|
||||
## Walking the tabs
|
||||
|
||||
- **Overview** — you're here; learn what to look for
|
||||
- **Story** — read the narrative of what happened (high-level)
|
||||
- **Progress** — acceptance status at a glance
|
||||
- **Artifacts** — the durable evidence: files, commits, proof packets. **THIS IS THE TAB YOU LEARN MOST FROM.**
|
||||
- **Tests** — pass/fail + screenshots + verification proof
|
||||
- **Queue** — who handed what to whom (audit trail of decisions)
|
||||
- **Topology** — which agents touched this; which edges fired
|
||||
|
||||
## Key inspection skills
|
||||
|
||||
When walking a finished slice:
|
||||
|
||||
1. **Read the Story** for context — what was the operator trying to do?
|
||||
2. **Check Progress** — did acceptance actually pass? Any TODO boxes?
|
||||
3. **Open Artifacts → Files** — sample-read 2-3 files; does the code match what the Story claimed?
|
||||
4. **Open Tests → Proof packets** — do screenshots actually show what was claimed?
|
||||
5. **Read Queue** — were there concerning decisions? Hand-offs to escalation?
|
||||
6. **Skim Topology** — were unexpected agents involved?
|
||||
|
||||
Trust comes from repeated successful inspections, not from a single proof packet.
|
||||
`;
|
||||
|
||||
const INSPECT_PROJECT_EVIDENCE_TIMELINE = `# Story — Inspect Project Evidence
|
||||
|
||||
This slice mirrors the previous one (First Conveyor Run). The same agents shipped the same work. But this view is from the inspector's angle, not the runner's.
|
||||
|
||||
## 11:10 — Operator opens project
|
||||
|
||||
Operator (you, in the real world) opens \`/project/slice/first-conveyor-run\` after the slice ships. First read: the Progress tab. All boxes checked.
|
||||
|
||||
## 11:12 — Read Story
|
||||
|
||||
Operator reads the Story narrative. Notices the reviewer's UTF-8 concern + driver's remediation. Confidence-building: there was friction, friction was caught, friction was addressed.
|
||||
|
||||
## 11:15 — Open Artifacts
|
||||
|
||||
Operator clicks Artifacts. Sees 4 commits, 5 files, 1 proof packet, 2 screenshots. Opens commit \`feat(lint): parse + validate todo entries\`. Reads the diff.
|
||||
|
||||
## 11:18 — Read tests
|
||||
|
||||
Operator opens Tests tab. 12/12 passing. Opens the proof packet — screenshot shows CLI output \`Error: malformed entry at line 3\`. Visually verifies the acceptance was met.
|
||||
|
||||
## 11:22 — Skim Queue
|
||||
|
||||
Operator scrolls Queue. 4 qitems in chronological order. Sees the \`concerning\` decision + subsequent \`accept\`. Confidence reinforced.
|
||||
|
||||
## 11:25 — Mark complete
|
||||
|
||||
Operator marks slice acceptance complete. Closes the inspection.
|
||||
|
||||
---
|
||||
|
||||
**What you just read**: a clean inspection in ~15 minutes. Operator builds trust through the evidence trail. This is the OpenRig usage pattern at scale: declare → route → inspect.
|
||||
`;
|
||||
|
||||
const INSPECT_PROJECT_EVIDENCE_PROGRESS = `---
|
||||
title: Inspect Project Evidence Progress
|
||||
status: active
|
||||
mission: getting-started
|
||||
rail-item: getting-started
|
||||
slice: inspect-project-evidence
|
||||
---
|
||||
|
||||
# Progress — Inspect Project Evidence
|
||||
|
||||
## Inspection checklist
|
||||
|
||||
- [x] Read Story (context: what was being done)
|
||||
- [x] Check Progress (acceptance status)
|
||||
- [x] Sample-read Artifacts files
|
||||
- [x] Verify Tests proof packets visually
|
||||
- [x] Skim Queue for concerning decisions
|
||||
- [x] Skim Topology for unexpected agents
|
||||
|
||||
## Status: COMPLETE
|
||||
|
||||
This was a mocked inspection — a worked example of the inspection workflow.
|
||||
|
||||
## How to apply this
|
||||
|
||||
For any real slice that ships in YOUR project: open the slice page, walk the tabs in this order, check each box mentally. Over time you'll develop instincts for what passes the smell test vs what needs deeper investigation.
|
||||
`;
|
||||
|
||||
const GETTING_STARTED_NARRATIVE: Record<
|
||||
string,
|
||||
{ readme: string; timeline: string; progress: string }
|
||||
> = {
|
||||
"first-conveyor-run": {
|
||||
readme: FIRST_CONVEYOR_RUN_README,
|
||||
timeline: FIRST_CONVEYOR_RUN_TIMELINE,
|
||||
progress: FIRST_CONVEYOR_RUN_PROGRESS,
|
||||
},
|
||||
"inspect-project-evidence": {
|
||||
readme: INSPECT_PROJECT_EVIDENCE_README,
|
||||
timeline: INSPECT_PROJECT_EVIDENCE_TIMELINE,
|
||||
progress: INSPECT_PROJECT_EVIDENCE_PROGRESS,
|
||||
},
|
||||
};
|
||||
|
||||
function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
|
||||
// V0.3.1 slice 21: getting-started slices ship rich narrative.
|
||||
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
|
||||
if (narrative) {
|
||||
return `---
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
rail-item: ${mission.id}
|
||||
slice: ${slice.id}
|
||||
---
|
||||
|
||||
${narrative.readme}`;
|
||||
}
|
||||
return `---
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
rail-item: ${mission.id}
|
||||
slice: ${slice.id}
|
||||
---
|
||||
|
||||
# ${slice.title}
|
||||
|
||||
${slice.objective}
|
||||
|
||||
## Queue Mapping
|
||||
|
||||
Queue items for this slice should mention or tag:
|
||||
|
||||
- mission: \`${mission.id}\`
|
||||
- slice: \`${slice.id}\`
|
||||
|
||||
This lets Project attach queue activity to the slice story, queue, tests, and topology tabs.
|
||||
`;
|
||||
}
|
||||
|
||||
function sliceTimeline(mission: DefaultMission, slice: DefaultSlice): string | null {
|
||||
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
|
||||
return narrative ? narrative.timeline : null;
|
||||
}
|
||||
|
||||
function sliceProgress(mission: DefaultMission, slice: DefaultSlice): string {
|
||||
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
|
||||
if (narrative) return narrative.progress;
|
||||
return `---
|
||||
title: ${slice.title} Progress
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
rail-item: ${mission.id}
|
||||
slice: ${slice.id}
|
||||
---
|
||||
|
||||
# ${slice.title} Progress
|
||||
|
||||
- [ ] Define the next concrete packet.
|
||||
- [ ] Attach queue work to this slice id.
|
||||
- [ ] Capture proof or notes before closing.
|
||||
`;
|
||||
}
|
||||
|
||||
function slicePrd(mission: DefaultMission, slice: DefaultSlice): string {
|
||||
return `---
|
||||
title: ${slice.title} Implementation Notes
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
rail-item: ${mission.id}
|
||||
slice: ${slice.id}
|
||||
---
|
||||
|
||||
# ${slice.title} Implementation Notes
|
||||
|
||||
## Goal
|
||||
|
||||
${slice.objective}
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] The work is visible in the Project slice.
|
||||
- [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`.
|
||||
- [ ] Any proof artifacts are referenced from the slice before closure.
|
||||
`;
|
||||
}
|
||||
|
||||
export function workspaceScaffoldDirs(): string[] {
|
||||
return [...WORKSPACE_DIRS];
|
||||
}
|
||||
|
||||
export function workspaceScaffoldFiles(): Array<{ relPath: string; content: string }> {
|
||||
const files: Array<{ relPath: string; content: string }> = [
|
||||
{ relPath: "README.md", content: WORKSPACE_README },
|
||||
{ relPath: "STEERING.md", content: STEERING_PLACEHOLDER },
|
||||
{ relPath: "missions/README.md", content: subdirReadmeContent("missions") },
|
||||
{ relPath: "artifacts/README.md", content: subdirReadmeContent("artifacts") },
|
||||
{ relPath: "evidence/README.md", content: subdirReadmeContent("evidence") },
|
||||
{ relPath: "progress/README.md", content: subdirReadmeContent("progress") },
|
||||
{ relPath: "field-notes/README.md", content: subdirReadmeContent("field-notes") },
|
||||
{ relPath: "specs/README.md", content: subdirReadmeContent("specs") },
|
||||
{ relPath: "dogfood-evidence/README.md", content: subdirReadmeContent("dogfood-evidence") },
|
||||
];
|
||||
for (const mission of DEFAULT_MISSIONS) {
|
||||
files.push(
|
||||
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
|
||||
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
|
||||
);
|
||||
for (const slice of mission.slices) {
|
||||
files.push(
|
||||
{ relPath: `missions/${mission.id}/slices/${slice.id}/README.md`, content: sliceReadme(mission, slice) },
|
||||
{ relPath: `missions/${mission.id}/slices/${slice.id}/PROGRESS.md`, content: sliceProgress(mission, slice) },
|
||||
{ relPath: `missions/${mission.id}/slices/${slice.id}/IMPLEMENTATION-PRD.md`, content: slicePrd(mission, slice) },
|
||||
);
|
||||
// V0.3.1 slice 21: getting-started slices ship timeline.md.
|
||||
const timeline = sliceTimeline(mission, slice);
|
||||
if (timeline) {
|
||||
files.push({
|
||||
relPath: `missions/${mission.id}/slices/${slice.id}/timeline.md`,
|
||||
content: timeline,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function initWorkspaceCommand(configPath?: string): Command {
|
||||
const cmd = new Command("init-workspace")
|
||||
.description("Scaffold the default workspace at ~/.openrig/workspace/ with mission/slice folders")
|
||||
.option("--root <path>", "Override workspace root (default: workspace.root setting)")
|
||||
.option("--force", "Overwrite existing scaffolded files (does NOT remove directories)")
|
||||
.option("--dry-run", "Show what would be created without writing")
|
||||
.option("--json", "JSON output")
|
||||
.action((opts: InitWorkspaceOpts) => {
|
||||
try {
|
||||
const effectiveJson = opts.json ?? Boolean(cmd.optsWithGlobals().json);
|
||||
const result = runInitWorkspace({ ...opts, json: effectiveJson, configPath });
|
||||
if (effectiveJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.dryRun) console.log(`(dry-run) workspace root: ${result.root}`);
|
||||
else console.log(`workspace root: ${result.root}`);
|
||||
for (const sub of result.subdirs) {
|
||||
console.log(` ${sub.created ? "+" : " "} ${sub.name}/`);
|
||||
}
|
||||
for (const f of result.files) {
|
||||
console.log(` ${f.created ? "+" : " "} ${f.relPath}${f.skipped ? ` (skipped: ${f.skipped})` : ""}`);
|
||||
}
|
||||
if (result.dryRun) console.log("(dry-run; no files written.)");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string }): InitWorkspaceResult {
|
||||
const store = new ConfigStore(opts.configPath);
|
||||
const root = opts.root ?? (store.get("workspace.root") as string);
|
||||
const dryRun = !!opts.dryRun;
|
||||
const force = !!opts.force;
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
const result: InitWorkspaceResult = {
|
||||
root,
|
||||
rootCreated: !rootExists,
|
||||
subdirs: [],
|
||||
files: [],
|
||||
dryRun,
|
||||
};
|
||||
|
||||
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
|
||||
|
||||
for (const sub of workspaceScaffoldDirs()) {
|
||||
const subPath = join(root, sub);
|
||||
const subExists = existsSync(subPath);
|
||||
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
|
||||
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
|
||||
}
|
||||
|
||||
for (const file of workspaceScaffoldFiles()) {
|
||||
const absPath = join(root, file.relPath);
|
||||
const exists = existsSync(absPath);
|
||||
if (exists && !force) {
|
||||
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
|
||||
} else {
|
||||
if (!dryRun) writeFileSync(absPath, file.content, "utf-8");
|
||||
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,78 +1,33 @@
|
||||
import { Command } from "commander";
|
||||
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
|
||||
import { initWorkspaceCommand } from "./config-init-workspace.js";
|
||||
|
||||
function formatRow(key: string, value: unknown): string {
|
||||
return `${key.padEnd(28)} ${value}`;
|
||||
}
|
||||
|
||||
function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting> {
|
||||
return store.resolveAllWithSource();
|
||||
}
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
|
||||
export function configCommand(configPath?: string): Command {
|
||||
const cmd = new Command("config").description("Inspect and change OpenRig configuration");
|
||||
const store = new ConfigStore(configPath);
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents (resolved RiggedConfig)")
|
||||
.option("--with-source", "Include source/default per key (honest provenance)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig config # show all resolved config
|
||||
rig config --json # JSON RiggedConfig (structured)
|
||||
rig config --json --with-source # JSON per-key with source + default
|
||||
rig config get daemon.port # read a single key
|
||||
rig config get workspace.slices_root --show-source
|
||||
rig config set daemon.port 7434 # change a value
|
||||
rig config set workspace.slices_root /path # configure a workspace path
|
||||
rig config reset # delete config file, revert all to defaults
|
||||
rig config reset workspace.slices_root # clear one key, revert to default
|
||||
rig config init-workspace # scaffold ~/.openrig/workspace/ with missions + slices
|
||||
|
||||
Keys:
|
||||
daemon.* port, host
|
||||
db.path
|
||||
transcripts.* enabled, path, lines, poll_interval_seconds
|
||||
workspace.* root, slices_root, steering_path, field_notes_root,
|
||||
specs_root, dogfood_evidence_root, operator_seat_name
|
||||
files.allowlist name:/abs/path,name:/abs/path
|
||||
progress.scan_roots name:/abs/path,name:/abs/path
|
||||
ui.preview.* refresh_interval_seconds, max_pins, default_lines
|
||||
recovery.* auto_drive_provider_prompts, provider_auth_env_allowlist
|
||||
agents.* advisor_session, operator_session
|
||||
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
|
||||
runtime.codex.* hooks_enabled
|
||||
policies.claude_compaction.*
|
||||
enabled, threshold_percent, compact_instruction,
|
||||
message_inline, message_file_path
|
||||
rig config # show all resolved config
|
||||
rig config --json # JSON output
|
||||
rig config get daemon.port # read a single key
|
||||
rig config set daemon.port 7434 # change a value
|
||||
rig config reset # delete config file, revert to defaults
|
||||
|
||||
Keys: daemon.port, daemon.host, db.path, transcripts.enabled, transcripts.path
|
||||
Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action((opts: { json?: boolean; withSource?: boolean }) => {
|
||||
.action((opts: { json?: boolean }) => {
|
||||
try {
|
||||
if (opts.withSource) {
|
||||
const all = summarizeSettings(store);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(all, null, 2));
|
||||
} else {
|
||||
for (const key of VALID_KEYS) {
|
||||
const r = all[key];
|
||||
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Default: structured RiggedConfig output (preserves pre-v0
|
||||
// bare-action shape so existing scripts / tests keep working).
|
||||
const config = store.resolve();
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
} else {
|
||||
const all = summarizeSettings(store);
|
||||
for (const key of VALID_KEYS) {
|
||||
const r = all[key];
|
||||
console.log(formatRow(key, `${r.value} (source: ${r.source})`));
|
||||
}
|
||||
console.log(`daemon.port ${config.daemon.port}`);
|
||||
console.log(`daemon.host ${config.daemon.host}`);
|
||||
console.log(`db.path ${config.db.path}`);
|
||||
console.log(`transcripts.enabled ${config.transcripts.enabled}`);
|
||||
console.log(`transcripts.path ${config.transcripts.path}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
@@ -82,20 +37,9 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
|
||||
const getCmd = new Command("get")
|
||||
.argument("<key>", "Config key (e.g. daemon.port)")
|
||||
.option("--json", "JSON output with value + source + default")
|
||||
.option("--show-source", "Print value + source on a single line")
|
||||
.description("Read a single config value")
|
||||
.action((key: string, opts: { json?: boolean; showSource?: boolean }) => {
|
||||
.action((key: string) => {
|
||||
try {
|
||||
if (opts.json || cmd.opts<{ json?: boolean }>().json) {
|
||||
console.log(JSON.stringify(store.resolveWithSource(key), null, 2));
|
||||
return;
|
||||
}
|
||||
if (opts.showSource) {
|
||||
const r = store.resolveWithSource(key);
|
||||
console.log(`${r.value}\t(source: ${r.source})`);
|
||||
return;
|
||||
}
|
||||
console.log(String(store.get(key)));
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
@@ -118,26 +62,15 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
});
|
||||
|
||||
const resetCmd = new Command("reset")
|
||||
.argument("[key]", "Optional config key to reset (omit to reset entire file)")
|
||||
.description("Clear a config override (or delete the entire file when no key given)")
|
||||
.action((key: string | undefined) => {
|
||||
try {
|
||||
store.reset(key);
|
||||
if (key) {
|
||||
console.log(`${key} reset to default (${store.get(key)}).`);
|
||||
} else {
|
||||
console.log("Config reset to defaults.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
.description("Delete config file and revert to defaults")
|
||||
.action(() => {
|
||||
store.reset();
|
||||
console.log("Config reset to defaults.");
|
||||
});
|
||||
|
||||
cmd.addCommand(getCmd);
|
||||
cmd.addCommand(setCmd);
|
||||
cmd.addCommand(resetCmd);
|
||||
cmd.addCommand(initWorkspaceCommand(configPath));
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
|
||||
// context-pack` CLI verb family.
|
||||
//
|
||||
// Six subcommands parallel to `rig specs`:
|
||||
// list / show / preview / add / sync / send
|
||||
//
|
||||
// Each delegates to /api/context-packs/library/* against the daemon.
|
||||
// The `add` verb installs a pack from a directory at
|
||||
// ~/.openrig/context-packs/<name>/ — host-symlink-free contract,
|
||||
// matches `rig specs add` shape (regular files only; no symlinks).
|
||||
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, extname, isAbsolute, join } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { getDefaultOpenRigPath } from "../openrig-compat.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ContextPackEntryWire {
|
||||
id: string;
|
||||
kind: "context-pack";
|
||||
name: string;
|
||||
version: string;
|
||||
purpose: string | null;
|
||||
sourceType: "builtin" | "user_file" | "workspace";
|
||||
sourcePath: string;
|
||||
relativePath: string;
|
||||
updatedAt: string;
|
||||
manifestEstimatedTokens: number | null;
|
||||
derivedEstimatedTokens: number;
|
||||
files: Array<{
|
||||
path: string;
|
||||
role: string;
|
||||
summary: string | null;
|
||||
absolutePath: string | null;
|
||||
bytes: number | null;
|
||||
estimatedTokens: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PreviewWire {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
bundleText: string;
|
||||
bundleBytes: number;
|
||||
estimatedTokens: number;
|
||||
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
}
|
||||
|
||||
interface SendWire {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
destinationSession: string;
|
||||
bundleBytes: number;
|
||||
estimatedTokens: number;
|
||||
files: Array<{ path: string; role: string; bytes: number; estimatedTokens: number }>;
|
||||
missingFiles: Array<{ path: string; role: string }>;
|
||||
dryRun: boolean;
|
||||
bundleText?: string;
|
||||
sent?: boolean;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function assertTreeHasNoSymlinks(root: string): void {
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
const absPath = join(current, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Context pack directories must not contain symlinks: ${absPath}`);
|
||||
}
|
||||
if (entry.isDirectory()) stack.push(absPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_CONTEXT_PACK_SUFFIXES = new Set([".md", ".markdown", ".yaml", ".yml", ".txt"]);
|
||||
|
||||
function validateContextPackManifestForInstall(manifestPath: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(readFileSync(manifestPath, "utf-8"));
|
||||
} catch (err) {
|
||||
throw new Error(`manifest at ${manifestPath} is not valid YAML: ${(err as Error).message}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`manifest at ${manifestPath} must be a YAML object at the root`);
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj["name"] !== "string" || obj["name"].length === 0) {
|
||||
throw new Error(`manifest at ${manifestPath} is missing required field 'name' (string)`);
|
||||
}
|
||||
if (obj["version"] === undefined || obj["version"] === null) {
|
||||
throw new Error(`manifest at ${manifestPath} is missing required field 'version'`);
|
||||
}
|
||||
const files = obj["files"];
|
||||
if (!Array.isArray(files)) {
|
||||
throw new Error(`manifest at ${manifestPath} must declare 'files: [...]'`);
|
||||
}
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const entry = files[i];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`manifest at ${manifestPath} has malformed entry at files[${i}]`);
|
||||
}
|
||||
const file = entry as Record<string, unknown>;
|
||||
const relPath = file["path"];
|
||||
if (typeof relPath !== "string" || relPath.length === 0) {
|
||||
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'path' (string)`);
|
||||
}
|
||||
if (relPath.includes("..") || isAbsolute(relPath) || relPath.startsWith("\\")) {
|
||||
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' must be a relative path inside the pack (no '..' segments, no leading '/')`);
|
||||
}
|
||||
if (!ALLOWED_CONTEXT_PACK_SUFFIXES.has(extname(relPath))) {
|
||||
throw new Error(`manifest at ${manifestPath} files[${i}].path '${relPath}' has an unsupported suffix; allowed: ${Array.from(ALLOWED_CONTEXT_PACK_SUFFIXES).join(", ")}`);
|
||||
}
|
||||
if (typeof file["role"] !== "string" || file["role"].length === 0) {
|
||||
throw new Error(`manifest at ${manifestPath} files[${i}] missing 'role' (string)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePack(client: DaemonClient, nameOrId: string): Promise<ContextPackEntryWire> {
|
||||
// First try as id (context-pack:<name>:<version>); else search by name.
|
||||
if (nameOrId.startsWith("context-pack:")) {
|
||||
const res = await client.get<ContextPackEntryWire>(`/api/context-packs/library/${encodeURIComponent(nameOrId)}`);
|
||||
if (res.status === 200) return res.data;
|
||||
if (res.status === 404) throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
|
||||
throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library/${nameOrId}`);
|
||||
}
|
||||
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status} for /api/context-packs/library`);
|
||||
const entries = res.data ?? [];
|
||||
const matches = entries.filter((e) => e.name === nameOrId);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`Context pack '${nameOrId}' not found in library. Run 'rig context-pack list' to see what's available.`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
const versions = matches.map((m) => m.version).join(", ");
|
||||
throw new Error(`Context pack '${nameOrId}' is ambiguous (versions: ${versions}). Use the full id 'context-pack:${nameOrId}:<version>'.`);
|
||||
}
|
||||
return matches[0]!;
|
||||
}
|
||||
|
||||
export function contextPackCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("context-pack")
|
||||
.description("Browse, preview, send, and install operator-authored context packs")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig context-pack list
|
||||
rig context-pack show pl-005-phase-a-priming
|
||||
rig context-pack preview pl-005-phase-a-priming
|
||||
rig context-pack add ./my-pack
|
||||
rig context-pack sync
|
||||
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity --dry-run
|
||||
rig context-pack send pl-005-phase-a-priming velocity-driver@openrig-velocity
|
||||
`);
|
||||
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
async function getClient(): Promise<DaemonClient> {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
throw new Error("Daemon not running. Start it with: rig daemon start");
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
cmd.command("list")
|
||||
.description("List all context packs in the library")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<ContextPackEntryWire[]>("/api/context-packs/library");
|
||||
const entries = res.data ?? [];
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
console.log("No context packs in library. Author one at ~/.openrig/context-packs/<name>/ then run: rig context-pack sync");
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
console.log(`${e.name.padEnd(28)} v${String(e.version).padEnd(6)} ${String(e.files.length).padStart(2)} files ~${String(e.derivedEstimatedTokens).padStart(6)} tokens ${e.sourceType} ${e.sourcePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("show")
|
||||
.argument("<name-or-id>", "Context pack name or library ID")
|
||||
.description("Show pack manifest + per-file metadata")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolvePack(client, nameOrId);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Name: ${entry.name}`);
|
||||
console.log(`Version: ${entry.version}`);
|
||||
console.log(`Source: ${entry.sourceType} (${entry.sourcePath})`);
|
||||
console.log(`Files: ${entry.files.length}`);
|
||||
console.log(`Tokens (~): ${entry.derivedEstimatedTokens}${entry.manifestEstimatedTokens !== null ? ` (manifest: ${entry.manifestEstimatedTokens})` : ""}`);
|
||||
if (entry.purpose) {
|
||||
console.log("");
|
||||
console.log("Purpose:");
|
||||
console.log(` ${entry.purpose.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
console.log("");
|
||||
for (const f of entry.files) {
|
||||
const sizeStr = f.bytes === null ? "(missing)" : `${f.bytes}B`;
|
||||
const tokenStr = f.estimatedTokens === null ? "—" : `~${f.estimatedTokens} tokens`;
|
||||
console.log(` ${f.path.padEnd(40)} role=${f.role.padEnd(20)} ${sizeStr.padEnd(12)} ${tokenStr}`);
|
||||
if (f.summary) console.log(` ${f.summary}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("preview")
|
||||
.argument("<name-or-id>", "Context pack name or library ID")
|
||||
.description("Show the assembled bundle (the exact text that would be sent)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolvePack(client, nameOrId);
|
||||
const res = await client.get<PreviewWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/preview`);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const preview = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
return;
|
||||
}
|
||||
if (preview.missingFiles.length > 0) {
|
||||
console.error(`Warning: ${preview.missingFiles.length} file(s) referenced by manifest are missing on disk:`);
|
||||
for (const m of preview.missingFiles) console.error(` - ${m.path} (role: ${m.role})`);
|
||||
console.error("");
|
||||
}
|
||||
console.log(`# Preview: ${preview.name} v${preview.version}`);
|
||||
console.log(`# Bundle: ${preview.bundleBytes} bytes (~${preview.estimatedTokens} tokens), ${preview.files.length} files`);
|
||||
console.log("# ---");
|
||||
console.log(preview.bundleText);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("sync")
|
||||
.description("Re-walk discovery roots and refresh the library index")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.post<{ count: number; errors: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>(
|
||||
"/api/context-packs/library/sync",
|
||||
);
|
||||
if (res.status !== 200) throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
const data = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`Indexed ${data.count} context pack(s).`);
|
||||
if (data.errors.length > 0) {
|
||||
console.log(`Encountered ${data.errors.length} parse error(s):`);
|
||||
for (const e of data.errors) console.log(` - ${e.source}: ${e.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("add")
|
||||
.argument("<source-dir>", "Directory containing manifest.yaml + included files")
|
||||
.description("Install a context pack from a local directory into ~/.openrig/context-packs/")
|
||||
.option("--name <name>", "Override the install name (defaults to source directory basename)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (sourceDir: string, opts: { name?: string; json?: boolean }) => {
|
||||
try {
|
||||
if (!existsSync(sourceDir)) throw new Error(`Source directory not found: ${sourceDir}`);
|
||||
const stat = lstatSync(sourceDir);
|
||||
if (stat.isSymbolicLink()) throw new Error(`Source must not be a symlink: ${sourceDir}`);
|
||||
if (!stat.isDirectory()) throw new Error(`Source must be a directory containing manifest.yaml: ${sourceDir}`);
|
||||
const manifestPath = join(sourceDir, "manifest.yaml");
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`Source directory must contain manifest.yaml: ${sourceDir}`);
|
||||
}
|
||||
validateContextPackManifestForInstall(manifestPath);
|
||||
// Read the manifest's name as the canonical install name when
|
||||
// --name not given. Cheap parse: trust the daemon to validate
|
||||
// on next sync; here we just need the name.
|
||||
const installName = opts.name ?? (() => {
|
||||
try {
|
||||
const raw = readFileSync(manifestPath, "utf-8");
|
||||
const m = raw.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m);
|
||||
return m?.[1]?.trim() || basename(sourceDir);
|
||||
} catch {
|
||||
return basename(sourceDir);
|
||||
}
|
||||
})();
|
||||
assertTreeHasNoSymlinks(sourceDir);
|
||||
const targetRoot = getDefaultOpenRigPath("context-packs");
|
||||
mkdirSync(targetRoot, { recursive: true });
|
||||
const targetDir = join(targetRoot, installName);
|
||||
if (existsSync(targetDir)) {
|
||||
throw new Error(`A context pack named '${installName}' already exists at ${targetDir}. Remove it first or use --name to install under a different name.`);
|
||||
}
|
||||
cpSync(sourceDir, targetDir, { recursive: true });
|
||||
// Sync the daemon library so the new pack appears immediately.
|
||||
const client = await getClient();
|
||||
const syncRes = await client.post<{ count: number; errors?: Array<{ source: string; error: string }>; entries: ContextPackEntryWire[] }>("/api/context-packs/library/sync");
|
||||
if (syncRes.status !== 200) {
|
||||
// Install succeeded; sync failed → still surface install path.
|
||||
if (opts.json) console.log(JSON.stringify({ installedAt: targetDir, syncError: `HTTP ${syncRes.status}` }, null, 2));
|
||||
else console.log(`Installed at ${targetDir}; daemon sync failed (HTTP ${syncRes.status}). Run 'rig context-pack sync' manually.`);
|
||||
return;
|
||||
}
|
||||
const syncError = syncRes.data.errors?.find((e) => e.source === targetDir);
|
||||
if (syncError) {
|
||||
throw new Error(`Installed at ${targetDir}, but daemon rejected the pack during sync: ${syncError.error}`);
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ installedAt: targetDir, count: syncRes.data.count }, null, 2));
|
||||
} else {
|
||||
console.log(`Installed at ${targetDir}. Library now has ${syncRes.data.count} context pack(s).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
cmd.command("send")
|
||||
.argument("<name-or-id>", "Context pack name or library ID")
|
||||
.argument("<destination-session>", "Destination session name (e.g., velocity-driver@openrig-velocity)")
|
||||
.description("Assemble the pack into one paste-ready bundle and send to a seat")
|
||||
.option("--dry-run", "Show what would be sent without invoking SessionTransport")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, destinationSession: string, opts: { dryRun?: boolean; json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolvePack(client, nameOrId);
|
||||
const res = await client.post<SendWire>(`/api/context-packs/library/${encodeURIComponent(entry.id)}/send`, {
|
||||
destinationSession,
|
||||
dryRun: opts.dryRun ?? false,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
// 502/503/etc. — surface daemon error verbatim.
|
||||
const data = res.data as Partial<SendWire> & { error?: string; reason?: string };
|
||||
throw new Error(data.error ?? data.reason ?? `Daemon returned HTTP ${res.status}`);
|
||||
}
|
||||
const data = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (data.dryRun) {
|
||||
if (data.missingFiles.length > 0) {
|
||||
console.error(`Warning: ${data.missingFiles.length} manifest file(s) missing on disk.`);
|
||||
}
|
||||
console.log(`(dry-run) ${data.name} v${data.version} → ${data.destinationSession}`);
|
||||
console.log(`Bundle: ${data.bundleBytes} bytes (~${data.estimatedTokens} tokens), ${data.files.length} files`);
|
||||
if (data.bundleText) {
|
||||
console.log("# ---");
|
||||
console.log(data.bundleText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log(`Sent ${data.name} v${data.version} (${data.bundleBytes} bytes) to ${data.destinationSession}.`);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -77,21 +77,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
.option("--port <port>", "Port to listen on")
|
||||
.option("--host <host>", "Host to bind on")
|
||||
.option("--db <path>", "Database path")
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — skip the kernel auto-boot
|
||||
// path. Used by test fixtures, headless CI, and operators who want
|
||||
// a no-kernel daemon for ad-hoc topology work. The daemon proceeds
|
||||
// and serves its HTTP API normally; just doesn't materialize the
|
||||
// kernel rig.
|
||||
.option("--no-kernel", "Skip kernel auto-boot (daemon serves without the kernel rig)")
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — forward-fix #3 architectural.
|
||||
// After the daemon's healthz binds (current behavior preserved),
|
||||
// additionally poll /api/kernel/status until kernel_state is
|
||||
// ready / partial_ready, or the timeout elapses. Used by operators
|
||||
// who want a "kernel-ready" signal at start-time rather than the
|
||||
// weaker "daemon-ready". Default 60s; override with --wait-for-kernel-ms.
|
||||
.option("--wait-for-kernel", "After daemon binds, also wait for kernel-agent readiness (default timeout 60s)")
|
||||
.option("--wait-for-kernel-ms <ms>", "Override --wait-for-kernel timeout in milliseconds")
|
||||
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean; waitForKernel?: boolean; waitForKernelMs?: string }) => {
|
||||
.action(async (opts: { port?: string; host?: string; db?: string }) => {
|
||||
try {
|
||||
const { ConfigStore } = await import("../config-store.js");
|
||||
const { SystemPreflight } = await import("../system-preflight.js");
|
||||
@@ -99,14 +85,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
const configStore = new ConfigStore();
|
||||
const config = configStore.resolve();
|
||||
const effectivePort = opts.port ? parseInt(opts.port, 10) : config.daemon.port;
|
||||
// bug-fix slice auth-bearer-tailscale-trust: distinguish
|
||||
// user-explicit from default-fallback so the daemon can
|
||||
// multi-bind (loopback + tailscale auto-detect) when the operator
|
||||
// never opted in to a specific host.
|
||||
const hostResolution = configStore.resolveWithSource("daemon.host");
|
||||
const hostUserExplicit = opts.host !== undefined || hostResolution.source !== "default";
|
||||
const effectiveHost = opts.host ?? config.daemon.host;
|
||||
const hostForDaemon = hostUserExplicit ? effectiveHost : undefined;
|
||||
|
||||
// Run preflight before starting
|
||||
const preflight = new SystemPreflight({
|
||||
@@ -126,58 +105,17 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
// V0.3.1 slice 05 — Commander's --no-kernel inverts to opts.kernel === false.
|
||||
const skipKernel = opts.kernel === false;
|
||||
const state = await startDaemon(
|
||||
{
|
||||
port: effectivePort,
|
||||
host: hostForDaemon,
|
||||
host: effectiveHost,
|
||||
db: opts.db ?? config.db.path,
|
||||
transcriptsEnabled: config.transcripts.enabled,
|
||||
transcriptsPath: config.transcripts.path,
|
||||
workspaceRoot: config.workspace.root,
|
||||
// V1 pre-release CLI/daemon Item 1 — project the
|
||||
// ConfigStore-resolved rotation tunables into the daemon
|
||||
// process env so file-stored values
|
||||
// (`rig config set transcripts.lines 500`) actually
|
||||
// reach the rotation hook.
|
||||
transcriptsLines: config.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — propagated via
|
||||
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
|
||||
// check in startup.ts honors the flag.
|
||||
skipKernelBoot: skipKernel,
|
||||
},
|
||||
getDeps(),
|
||||
);
|
||||
console.log(`Daemon started on port ${state.port} (pid ${state.pid})`);
|
||||
|
||||
// V0.3.1 slice 05 forward-fix #3 architectural — --wait-for-kernel
|
||||
// post-bind polling. Kernel boot is fire-and-forget after the
|
||||
// daemon binds healthz, so without this flag the CLI doesn't
|
||||
// know whether the kernel itself reached ready. Operators who
|
||||
// need a kernel-ready signal opt in here.
|
||||
if (opts.waitForKernel) {
|
||||
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
|
||||
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
|
||||
? parseInt(opts.waitForKernelMs, 10)
|
||||
: 60_000;
|
||||
const baseUrl = `http://${state.host}:${state.port}`;
|
||||
const result = await waitForKernelReady(baseUrl, timeoutMs);
|
||||
if (result.ok) {
|
||||
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
|
||||
} else {
|
||||
// Honest 3-part error per banked discipline.
|
||||
console.error(
|
||||
`Error: kernel did not reach ready / partial_ready within ${timeoutMs}ms.\n` +
|
||||
`Reason: kernel_state=${result.kernelState ?? "unknown"}` +
|
||||
(result.detail ? `; ${result.detail}` : "") +
|
||||
"\n" +
|
||||
"Fix: inspect `rig ps --rig kernel` for stalled agents, or run `claude auth status` / `codex login status` to confirm runtime auth.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { Command } from "commander";
|
||||
|
||||
@@ -292,6 +293,10 @@ function resolveSharedDocsRoot(env: NodeJS.ProcessEnv): string | null {
|
||||
const value = env[key];
|
||||
if (value && fs.existsSync(nodePath.join(value, "rigs"))) return value;
|
||||
}
|
||||
|
||||
const home = env["HOME"] ?? os.homedir();
|
||||
const wellKnown = nodePath.join(home, "code", "substrate", "shared-docs");
|
||||
if (fs.existsSync(nodePath.join(wellKnown, "rigs"))) return wellKnown;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
|
||||
.option("--preflight", "Run preflight checks")
|
||||
.option("--target-rig <rigId>", "Target existing rig for additive materialization")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--cwd <path>", "Override launch/materialization working directory for all members")
|
||||
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string; cwd?: string }) => {
|
||||
.action(async (filePath: string, opts: { instantiate?: boolean; materializeOnly?: boolean; preflight?: boolean; targetRig?: string; rigRoot?: string }) => {
|
||||
const deps = getDeps();
|
||||
|
||||
// Read local file first (before daemon check — fail fast on missing file)
|
||||
@@ -60,11 +59,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
|
||||
const rigRoot = podAware
|
||||
? (opts.rigRoot ? nodePath.resolve(opts.rigRoot) : nodePath.dirname(nodePath.resolve(filePath)))
|
||||
: undefined;
|
||||
const cwdOverride = opts.cwd ? nodePath.resolve(opts.cwd) : undefined;
|
||||
const extraHeaders = {
|
||||
...(rigRoot ? { "X-Rig-Root": rigRoot } : {}),
|
||||
...(cwdOverride ? { "X-Cwd-Override": cwdOverride } : {}),
|
||||
};
|
||||
const extraHeaders = rigRoot ? { "X-Rig-Root": rigRoot } : undefined;
|
||||
|
||||
if (opts.instantiate && opts.materializeOnly) {
|
||||
console.error("Choose either --instantiate or --materialize-only, not both.");
|
||||
@@ -102,7 +97,7 @@ export function importCommand(depsOverride?: ImportDeps): Command {
|
||||
return;
|
||||
}
|
||||
const headers = {
|
||||
...extraHeaders,
|
||||
...(extraHeaders ?? {}),
|
||||
...(opts.targetRig ? { "X-Target-Rig-Id": opts.targetRig } : {}),
|
||||
};
|
||||
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import/materialize", yaml, "text/yaml", headers);
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
// rig plugin CLI verb family — read-only plugin inspection.
|
||||
//
|
||||
// Per IMPL-PRD §4 plugin-primitive Phase 3a slice 3.4 + DESIGN.md §6
|
||||
// (CLI minimalism: 4 read-only inspection commands).
|
||||
//
|
||||
// Subcommands at slice 3.4 scope (this commit):
|
||||
// list — aggregated view of discoverable plugins
|
||||
// flags: --runtime claude|codex
|
||||
// --source vendored|claude-cache|codex-cache
|
||||
// --json
|
||||
// show <id> — inspect manifest + skills + hooks + mcp servers
|
||||
// flags: --json
|
||||
// used-by <id> — agents referencing this plugin
|
||||
// flags: --json
|
||||
// validate <path> — local file inspection of a plugin source tree
|
||||
// (manifest shape + skill frontmatter per agentskills.io)
|
||||
// flags: --json
|
||||
//
|
||||
// list/show consume slice 3.3 daemon HTTP routes (GET /api/plugins[/...]).
|
||||
//
|
||||
// All commands ship --json output for agent consumption per banked
|
||||
// building-agent-software + IMPL-PRD §4.4 HG-4.5.
|
||||
//
|
||||
// Wire shapes mirror slice 3.3 PluginDiscoveryService exports verbatim
|
||||
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132). Types
|
||||
// here are the wire contract; any drift from daemon source means the
|
||||
// daemon shape changed and this file must update in lockstep.
|
||||
//
|
||||
// Flags NOT supported at v0 (deferred per velocity-guard 3.4.A blocker):
|
||||
// --used — declared in PRD §4.2 but daemon route doesn't expose a
|
||||
// "filter to plugins referenced by an agent" filter; would
|
||||
// require client-side post-filter against /api/plugins +
|
||||
// /api/plugins/:id/used-by N+1 calls. Not in v0 scope.
|
||||
// --tree — declared in PRD §4.2 but show output already prints the
|
||||
// tree structure (manifests + skills + hooks + mcp).
|
||||
// Distinct tree-only mode is post-v0 polish.
|
||||
// --source rig-cwd — daemon route's parseSourceFilter doesn't accept
|
||||
// rig-cwd; rig-cwd scan is enabled via separate ?cwd=<path>
|
||||
// query (out of scope for v0 since CLI doesn't have a "scan
|
||||
// this rig's cwd" surface yet).
|
||||
// --runtime both — daemon parseRuntimeFilter accepts only claude|codex;
|
||||
// omitting --runtime returns all (the "both" semantic is
|
||||
// default no-filter).
|
||||
// Each of these surfaces in a future slice; CLI flag declarations are
|
||||
// tightly scoped to what the daemon actually supports.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
// Filter-value enums per slice-3.3 daemon route validation
|
||||
// (packages/daemon/src/routes/plugins.ts L37-58). CLI-side validation
|
||||
// gives operators clear errors instead of silent pass-through where the
|
||||
// daemon ignores unknown values.
|
||||
const VALID_RUNTIMES = ["claude", "codex"] as const;
|
||||
const VALID_SOURCES = ["vendored", "claude-cache", "codex-cache"] as const;
|
||||
|
||||
// ============================================================
|
||||
// Wire shapes — must mirror PluginDiscoveryService verbatim
|
||||
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132)
|
||||
// ============================================================
|
||||
|
||||
type PluginRuntime = "claude" | "codex";
|
||||
type PluginSourceKind = "vendored" | "claude-cache" | "codex-cache" | "rig-cwd";
|
||||
|
||||
interface PluginEntryWire {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
source: PluginSourceKind;
|
||||
sourceLabel: string;
|
||||
runtimes: PluginRuntime[];
|
||||
path: string;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
interface PluginManifestSummaryWire {
|
||||
raw: Record<string, unknown>;
|
||||
name: string | null;
|
||||
version: string | null;
|
||||
description: string | null;
|
||||
homepage: string | null;
|
||||
repository: string | null;
|
||||
license: string | null;
|
||||
}
|
||||
|
||||
interface PluginSkillSummaryWire {
|
||||
name: string;
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
interface PluginHookSummaryWire {
|
||||
runtime: PluginRuntime;
|
||||
relativePath: string;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
interface PluginMcpServerSummaryWire {
|
||||
runtime: PluginRuntime;
|
||||
name: string;
|
||||
command: string | null;
|
||||
transport: string | null;
|
||||
}
|
||||
|
||||
interface PluginDetailWire {
|
||||
entry: PluginEntryWire;
|
||||
claudeManifest: PluginManifestSummaryWire | null;
|
||||
codexManifest: PluginManifestSummaryWire | null;
|
||||
skills: PluginSkillSummaryWire[];
|
||||
hooks: PluginHookSummaryWire[];
|
||||
mcpServers: PluginMcpServerSummaryWire[];
|
||||
}
|
||||
|
||||
// AgentReference per PluginDiscoveryService L134-141 verbatim.
|
||||
interface AgentReferenceWire {
|
||||
agentName: string;
|
||||
sourcePath: string;
|
||||
profiles: string[];
|
||||
}
|
||||
|
||||
export function pluginCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("plugin");
|
||||
cmd.description("Inspect plugins (read-only)");
|
||||
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
async function getClient(): Promise<DaemonClient> {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
throw new Error("Daemon not running. Start it with: rig daemon start");
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
// -- rig plugin list --
|
||||
cmd.command("list")
|
||||
.description("List discoverable plugins (aggregated across vendored + runtime caches)")
|
||||
.option("--runtime <runtime>", "Filter by runtime support: claude | codex (omit for all)")
|
||||
.option("--source <source>", "Filter by source: vendored | claude-cache | codex-cache")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { runtime?: string; source?: string; json?: boolean }) => {
|
||||
try {
|
||||
// CLI-side filter-value validation per pre-close punch from velocity-guard
|
||||
// 3.4.A repair verdict: typos like --source rig-cwd should fail loud,
|
||||
// not silently be ignored by the daemon route.
|
||||
if (opts.runtime && !(VALID_RUNTIMES as readonly string[]).includes(opts.runtime)) {
|
||||
throw new Error(`Invalid --runtime value "${opts.runtime}". Valid: ${VALID_RUNTIMES.join(" | ")} (omit flag for all)`);
|
||||
}
|
||||
if (opts.source && !(VALID_SOURCES as readonly string[]).includes(opts.source)) {
|
||||
throw new Error(`Invalid --source value "${opts.source}". Valid: ${VALID_SOURCES.join(" | ")}`);
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const params: string[] = [];
|
||||
if (opts.runtime) params.push(`runtime=${encodeURIComponent(opts.runtime)}`);
|
||||
if (opts.source) params.push(`source=${encodeURIComponent(opts.source)}`);
|
||||
const path = `/api/plugins${params.length > 0 ? `?${params.join("&")}` : ""}`;
|
||||
const res = await client.get<PluginEntryWire[]>(path);
|
||||
const entries = res.data ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log("No plugins discovered.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const runtimesStr = e.runtimes.join(",");
|
||||
// Real fields only: id, version, runtimes, sourceLabel, path
|
||||
console.log(
|
||||
`${e.id.padEnd(28)} v${String(e.version).padEnd(8)} ` +
|
||||
`[${runtimesStr.padEnd(13)}] ${e.sourceLabel.padEnd(36)} ` +
|
||||
`${e.path}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// -- rig plugin show <id> --
|
||||
cmd.command("show")
|
||||
.argument("<id>", "Plugin id (e.g., openrig-core)")
|
||||
.description("Show plugin manifest + skills + hooks + mcp servers")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (id: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<PluginDetailWire>(`/api/plugins/${encodeURIComponent(id)}`);
|
||||
if (res.status === 404) {
|
||||
throw new Error(`Plugin not found: "${id}"`);
|
||||
}
|
||||
if (res.status !== 200 || !res.data) {
|
||||
throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
}
|
||||
const detail = res.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(detail, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = detail.entry;
|
||||
console.log(`Id: ${entry.id}`);
|
||||
console.log(`Name: ${entry.name}`);
|
||||
console.log(`Version: ${entry.version}`);
|
||||
console.log(`Description: ${entry.description ?? "(none)"}`);
|
||||
console.log(`Source: ${entry.sourceLabel}`);
|
||||
console.log(`Path: ${entry.path}`);
|
||||
console.log(`Runtimes: ${entry.runtimes.join(", ")}`);
|
||||
if (entry.lastSeenAt) {
|
||||
console.log(`Last seen: ${entry.lastSeenAt}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
// Manifests — real PluginManifestSummary fields
|
||||
console.log("Manifests:");
|
||||
if (detail.claudeManifest) {
|
||||
const m = detail.claudeManifest;
|
||||
const parts: string[] = [];
|
||||
if (m.name) parts.push(`name=${m.name}`);
|
||||
if (m.version) parts.push(`version=${m.version}`);
|
||||
if (m.license) parts.push(`license=${m.license}`);
|
||||
if (m.repository) parts.push(`repo=${m.repository}`);
|
||||
console.log(` claude: ${parts.join(" ") || "(present)"}`);
|
||||
}
|
||||
if (detail.codexManifest) {
|
||||
const m = detail.codexManifest;
|
||||
const parts: string[] = [];
|
||||
if (m.name) parts.push(`name=${m.name}`);
|
||||
if (m.version) parts.push(`version=${m.version}`);
|
||||
if (m.license) parts.push(`license=${m.license}`);
|
||||
if (m.repository) parts.push(`repo=${m.repository}`);
|
||||
console.log(` codex: ${parts.join(" ") || "(present)"}`);
|
||||
}
|
||||
if (!detail.claudeManifest && !detail.codexManifest) {
|
||||
console.log(" (none)");
|
||||
}
|
||||
|
||||
// Skills — PluginSkillSummary has name + relativePath only
|
||||
console.log("");
|
||||
console.log(`Skills (${detail.skills.length}):`);
|
||||
for (const s of detail.skills) {
|
||||
console.log(` ${s.name.padEnd(40)} ${s.relativePath}`);
|
||||
}
|
||||
|
||||
// Hooks — PluginHookSummary has runtime + relativePath + events[]
|
||||
console.log("");
|
||||
console.log(`Hooks (${detail.hooks.length}):`);
|
||||
for (const h of detail.hooks) {
|
||||
const eventList = h.events.length > 0 ? h.events.join(",") : "(none)";
|
||||
console.log(` ${h.runtime.padEnd(10)} ${String(h.events.length).padStart(2)} events [${eventList}] ${h.relativePath}`);
|
||||
}
|
||||
|
||||
// MCP servers
|
||||
if (detail.mcpServers.length > 0) {
|
||||
console.log("");
|
||||
console.log(`MCP servers (${detail.mcpServers.length}):`);
|
||||
for (const m of detail.mcpServers) {
|
||||
const detail2: string[] = [];
|
||||
if (m.transport) detail2.push(`transport=${m.transport}`);
|
||||
if (m.command) detail2.push(`command=${m.command}`);
|
||||
console.log(` ${m.runtime.padEnd(10)} ${m.name.padEnd(28)} ${detail2.join(" ")}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// -- rig plugin used-by <id> --
|
||||
cmd.command("used-by")
|
||||
.argument("<id>", "Plugin id (e.g., openrig-core)")
|
||||
.description("List agents referencing this plugin in their profile.uses.plugins[]")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (id: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const res = await client.get<AgentReferenceWire[]>(`/api/plugins/${encodeURIComponent(id)}/used-by`);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`Daemon returned HTTP ${res.status}`);
|
||||
}
|
||||
const refs = res.data ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(refs, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (refs.length === 0) {
|
||||
console.log(`No agents reference plugin "${id}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of refs) {
|
||||
const profilesStr = r.profiles.length > 0 ? r.profiles.join(",") : "(none)";
|
||||
console.log(`${r.agentName.padEnd(36)} [${profilesStr.padEnd(20)}] ${r.sourcePath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// -- rig plugin validate <path> --
|
||||
// Local file inspection — no daemon dependency; useful when authoring
|
||||
// a plugin in a feature branch where the daemon may not yet have
|
||||
// discovered it. Validates manifest shape (per Claude/Codex specs)
|
||||
// + skill frontmatter (per agentskills.io: name + description ≤1024 chars).
|
||||
cmd.command("validate")
|
||||
.argument("<path>", "Plugin source directory to validate")
|
||||
.description("Validate plugin manifest + skill frontmatter against agentskills.io spec")
|
||||
.option("--json", "JSON output ({ valid: boolean, errors: string[] })")
|
||||
.action((path: string, opts: { json?: boolean }) => {
|
||||
const errors = validatePluginTree(path);
|
||||
const valid = errors.length === 0;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ valid, errors }, null, 2));
|
||||
process.exitCode = valid ? undefined : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
console.log(`Plugin at ${path}: valid`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Plugin at ${path}: INVALID (${errors.length} error${errors.length === 1 ? "" : "s"})`);
|
||||
for (const err of errors) {
|
||||
console.error(` - ${err}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// validate plumbing — local file inspection
|
||||
// ============================================================
|
||||
|
||||
function validatePluginTree(pluginPath: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!existsSync(pluginPath)) {
|
||||
errors.push(`Plugin path does not exist: ${pluginPath}`);
|
||||
return errors;
|
||||
}
|
||||
if (!statSync(pluginPath).isDirectory()) {
|
||||
errors.push(`Plugin path is not a directory: ${pluginPath}`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
const claudeManifestPath = join(pluginPath, ".claude-plugin", "plugin.json");
|
||||
const codexManifestPath = join(pluginPath, ".codex-plugin", "plugin.json");
|
||||
const hasClaude = existsSync(claudeManifestPath);
|
||||
const hasCodex = existsSync(codexManifestPath);
|
||||
|
||||
if (!hasClaude && !hasCodex) {
|
||||
errors.push("No plugin manifest found: expected .claude-plugin/plugin.json and/or .codex-plugin/plugin.json");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (hasClaude) {
|
||||
errors.push(...validateManifest(claudeManifestPath, "claude"));
|
||||
}
|
||||
if (hasCodex) {
|
||||
errors.push(...validateManifest(codexManifestPath, "codex"));
|
||||
}
|
||||
|
||||
// Validate skill frontmatter
|
||||
const skillsDir = join(pluginPath, "skills");
|
||||
if (existsSync(skillsDir) && statSync(skillsDir).isDirectory()) {
|
||||
for (const skillId of readdirSync(skillsDir)) {
|
||||
const skillMdPath = join(skillsDir, skillId, "SKILL.md");
|
||||
if (existsSync(skillMdPath)) {
|
||||
errors.push(...validateSkillFrontmatter(skillMdPath, skillId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateManifest(manifestPath: string, runtime: "claude" | "codex"): string[] {
|
||||
const errors: string[] = [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
} catch (err) {
|
||||
errors.push(`${runtime} manifest parse error at ${manifestPath}: ${(err as Error).message}`);
|
||||
return errors;
|
||||
}
|
||||
// Guard non-object/null parse results — JSON.parse('null') returns null,
|
||||
// JSON.parse('[1,2]') returns an array — both must fail validation, not crash.
|
||||
if (!isPlainObject(parsed)) {
|
||||
errors.push(`${runtime} manifest at ${manifestPath} must be a JSON object (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
|
||||
return errors;
|
||||
}
|
||||
const raw = parsed;
|
||||
if (typeof raw["name"] !== "string" || raw["name"].trim().length === 0) {
|
||||
errors.push(`${runtime} manifest missing required field: name (must be non-empty string)`);
|
||||
}
|
||||
if (typeof raw["version"] !== "string" || raw["version"].trim().length === 0) {
|
||||
errors.push(`${runtime} manifest missing required field: version (must be non-empty string)`);
|
||||
}
|
||||
// Codex spec: description is REQUIRED. Claude spec: description is recommended but
|
||||
// not a hard requirement; we treat missing description as a warning by NOT erroring
|
||||
// on Claude-only-missing-description while erroring on Codex-missing-description.
|
||||
if (runtime === "codex" && (typeof raw["description"] !== "string" || raw["description"].trim().length === 0)) {
|
||||
errors.push(`codex manifest missing required field: description (Codex spec requires it)`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateSkillFrontmatter(skillPath: string, skillId: string): string[] {
|
||||
const errors: string[] = [];
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(skillPath, "utf-8");
|
||||
} catch (err) {
|
||||
errors.push(`skill "${skillId}": failed to read SKILL.md: ${(err as Error).message}`);
|
||||
return errors;
|
||||
}
|
||||
// Frontmatter: file MUST start with `---\n` and contain a closing `---` line
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) {
|
||||
errors.push(`skill "${skillId}": SKILL.md missing frontmatter (must open with --- ... ---)`);
|
||||
return errors;
|
||||
}
|
||||
const fmBody = fmMatch[1] ?? "";
|
||||
|
||||
// Parse frontmatter as YAML — regex-based validation can pass quoted-empty
|
||||
// values and miss type errors per velocity-guard 3.4.C BLOCKING-CONCERN.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(fmBody);
|
||||
} catch (err) {
|
||||
errors.push(`skill "${skillId}": invalid YAML frontmatter: ${(err as Error).message}`);
|
||||
return errors;
|
||||
}
|
||||
if (!isPlainObject(parsed)) {
|
||||
errors.push(`skill "${skillId}": frontmatter must be a YAML object/map (got ${parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed})`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Required: name (non-empty string after trim)
|
||||
const name = parsed["name"];
|
||||
if (typeof name !== "string" || name.trim().length === 0) {
|
||||
errors.push(`skill "${skillId}": frontmatter missing required field: name (must be non-empty string)`);
|
||||
}
|
||||
|
||||
// Required: description (non-empty string after trim; ≤1024 chars per agentskills.io)
|
||||
const description = parsed["description"];
|
||||
if (typeof description !== "string" || description.trim().length === 0) {
|
||||
errors.push(`skill "${skillId}": frontmatter missing required field: description (must be non-empty string)`);
|
||||
} else if (description.trim().length > 1024) {
|
||||
errors.push(`skill "${skillId}": description length ${description.trim().length} chars exceeds agentskills.io limit of 1024`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
+12
-194
@@ -43,16 +43,6 @@ interface NodeEntry {
|
||||
sampledAt: string;
|
||||
evidence: string | null;
|
||||
};
|
||||
// PL-012: context-usage block surfaced from the daemon's
|
||||
// /api/rigs/:id/nodes route (already populated). Optional because
|
||||
// older daemons may not emit it.
|
||||
contextUsage?: {
|
||||
availability: "known" | "unknown";
|
||||
usedPercentage: number | null;
|
||||
fresh: boolean;
|
||||
state?: "critical" | "warning" | "low" | "unknown";
|
||||
sampledAt: string | null;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -65,36 +55,7 @@ const HUMAN_NODE_BUDGET = 100;
|
||||
// L3-followup: --filter accepts only this allow-list. Unknown keys produce a
|
||||
// clear error with the supported list (Guard Review Checklist: filter-parser
|
||||
// security).
|
||||
//
|
||||
// PL-019 item 1: extends the allow-list with the nested key `agentActivity.state`.
|
||||
// This is the only nested filter key v0 supports; the parser checks for the
|
||||
// dotted form explicitly. Bare `agentActivity` is intentionally NOT a filter
|
||||
// key — it's an object, not a scalar; project it via --fields if you need it.
|
||||
const ALLOWED_FILTER_KEYS = new Set([
|
||||
"status",
|
||||
"lifecycleState",
|
||||
"name-prefix",
|
||||
"name",
|
||||
"agentActivity.state",
|
||||
// PL-012: context-usage filters. percent is numeric (>=, >, <=, <, =);
|
||||
// state is enum (critical / warning / low / unknown — lockstep with
|
||||
// computeContextHealthSummary tier vocabulary).
|
||||
"contextUsage.percent",
|
||||
"contextUsage.state",
|
||||
]);
|
||||
|
||||
// PL-019 item 1: when filter key is `agentActivity.state`, the value is
|
||||
// validated against the AgentActivityState enum. Invalid values produce a
|
||||
// three-part error (what failed / what's allowed / what to do).
|
||||
const ALLOWED_AGENT_ACTIVITY_STATES = new Set(["running", "needs_input", "idle", "unknown"]);
|
||||
|
||||
// PL-012: ALLOWED_CONTEXT_USAGE_STATES — must stay lockstep with the
|
||||
// daemon's computeContextHealthSummary urgency vocab.
|
||||
const ALLOWED_CONTEXT_USAGE_STATES = new Set(["critical", "warning", "low", "unknown"]);
|
||||
|
||||
const NUMERIC_FILTER_KEYS = new Set(["contextUsage.percent"]);
|
||||
type NumericComparator = ">=" | ">" | "<=" | "<" | "=";
|
||||
const NUMERIC_OPERATORS: NumericComparator[] = [">=", "<=", ">", "<", "="];
|
||||
const ALLOWED_FILTER_KEYS = new Set(["status", "lifecycleState", "name-prefix", "name"]);
|
||||
|
||||
// C9a: --fields accepts a per-level allow-list. Unknown keys produce a clear
|
||||
// error with the supported list, mirroring the --filter rejection pattern.
|
||||
@@ -132,7 +93,6 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"resumeCommand",
|
||||
"latestError",
|
||||
"agentActivity",
|
||||
"contextUsage",
|
||||
]);
|
||||
|
||||
interface PsCliOptions {
|
||||
@@ -144,7 +104,6 @@ interface PsCliOptions {
|
||||
summary?: boolean;
|
||||
filter?: string;
|
||||
host?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface PsDeps extends StatusDeps {
|
||||
@@ -165,34 +124,13 @@ export interface PsDeps extends StatusDeps {
|
||||
interface ParsedFilter {
|
||||
key: string;
|
||||
value: string;
|
||||
/** PL-012: numeric comparator for keys in NUMERIC_FILTER_KEYS;
|
||||
* defaults to "=" (equality) for all other keys. */
|
||||
op: NumericComparator;
|
||||
/** PL-012: parsed numeric value when op is a numeric comparator. */
|
||||
numericValue?: number;
|
||||
}
|
||||
|
||||
function parseFilter(filter: string): ParsedFilter | { error: string } {
|
||||
// PL-012: pre-detect numeric comparators (>=, <=, >, <, =) so callers
|
||||
// can write `contextUsage.percent>=80`. Order matters: check >= and
|
||||
// <= before > and < so the longer prefix wins.
|
||||
let op: NumericComparator = "=";
|
||||
let opIdx = -1;
|
||||
for (const candidate of NUMERIC_OPERATORS) {
|
||||
const i = filter.indexOf(candidate);
|
||||
if (i !== -1) {
|
||||
// Prefer the leftmost match; on tie, prefer the longest comparator.
|
||||
if (opIdx === -1 || i < opIdx || (i === opIdx && candidate.length > op.length)) {
|
||||
opIdx = i;
|
||||
op = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opIdx === -1) {
|
||||
return { error: `--filter must be key<op>value (op = ${NUMERIC_OPERATORS.join(", ")}); got: '${filter}'` };
|
||||
}
|
||||
const key = filter.slice(0, opIdx);
|
||||
const value = filter.slice(opIdx + op.length);
|
||||
const idx = filter.indexOf("=");
|
||||
if (idx === -1) return { error: `--filter must be key=value; got: '${filter}'` };
|
||||
const key = filter.slice(0, idx);
|
||||
const value = filter.slice(idx + 1);
|
||||
if (!ALLOWED_FILTER_KEYS.has(key)) {
|
||||
return {
|
||||
error: `Unknown --filter key '${key}'. Supported: ${[...ALLOWED_FILTER_KEYS].sort().join(", ")}`,
|
||||
@@ -201,60 +139,7 @@ function parseFilter(filter: string): ParsedFilter | { error: string } {
|
||||
if (!value) {
|
||||
return { error: `--filter value is empty for key '${key}'` };
|
||||
}
|
||||
|
||||
// PL-012: numeric-keyed filters validate the value parses as a finite
|
||||
// number. Three-part error per the existing convention.
|
||||
if (NUMERIC_FILTER_KEYS.has(key)) {
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return {
|
||||
error: `--filter ${key}${op}'${value}' is not a numeric value. ` +
|
||||
`Allowed: a finite number (e.g., ${key}>=80). ` +
|
||||
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
|
||||
};
|
||||
}
|
||||
return { key, value, op, numericValue };
|
||||
}
|
||||
|
||||
// PL-012: contextUsage.state enum guard — same shape as agentActivity.state.
|
||||
if (key === "contextUsage.state" && !ALLOWED_CONTEXT_USAGE_STATES.has(value)) {
|
||||
return {
|
||||
error: `--filter contextUsage.state='${value}' is not a valid context state. ` +
|
||||
`Allowed: ${[...ALLOWED_CONTEXT_USAGE_STATES].sort().join(", ")}. ` +
|
||||
`Use 'rig ps --nodes --fields contextUsage --json' to see what daemon is reporting.`,
|
||||
};
|
||||
}
|
||||
|
||||
// PL-019 item 1: agentActivity.state is enum-valued; reject invalid values
|
||||
// up-front so operators don't get a silent empty result on a typo. Three-part
|
||||
// shape per `feedback_smart_agents_no_bureaucracy.md`: what failed / what's
|
||||
// allowed / what to do.
|
||||
if (key === "agentActivity.state" && !ALLOWED_AGENT_ACTIVITY_STATES.has(value)) {
|
||||
return {
|
||||
error: `--filter agentActivity.state='${value}' is not a valid activity state. ` +
|
||||
`Allowed: ${[...ALLOWED_AGENT_ACTIVITY_STATES].sort().join(", ")}. ` +
|
||||
`Use 'rig ps --nodes --fields agentActivity --json' to see what daemon is reporting.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Non-numeric keys must use op = "=".
|
||||
if (op !== "=") {
|
||||
return {
|
||||
error: `--filter ${key}${op}... uses a numeric comparator on a non-numeric key. ` +
|
||||
`Allowed numeric keys: ${[...NUMERIC_FILTER_KEYS].sort().join(", ")}. ` +
|
||||
`Use ${key}=<value> for equality.`,
|
||||
};
|
||||
}
|
||||
return { key, value, op };
|
||||
}
|
||||
|
||||
// PL-012: derive a context-usage tier from a numeric percent. Lockstep
|
||||
// with daemon-side computeContextHealthSummary thresholds.
|
||||
function deriveContextUsageState(percent: number | null | undefined): "critical" | "warning" | "low" | "unknown" {
|
||||
if (typeof percent !== "number") return "unknown";
|
||||
if (percent >= 80) return "critical";
|
||||
if (percent >= 60) return "warning";
|
||||
return "low";
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
// C9a: validate --fields against a per-level allow-list. Mirrors parseFilter's
|
||||
@@ -287,12 +172,6 @@ function applyRigFilter(entries: PsEntry[], filter: ParsedFilter): PsEntry[] {
|
||||
if (filter.key === "lifecycleState") return e.lifecycleState === filter.value;
|
||||
if (filter.key === "name-prefix") return (e.rigName ?? e.name).startsWith(filter.value);
|
||||
if (filter.key === "name") return (e.rigName ?? e.name) === filter.value;
|
||||
// PL-019 item 1: agentActivity.state is node-level only; at the rig
|
||||
// level it has no meaning, so the filter passes everything through and
|
||||
// the user gets all rigs (the operator can `--nodes` to scope it).
|
||||
if (filter.key === "agentActivity.state") return true;
|
||||
// PL-012: contextUsage.* filters are node-level only.
|
||||
if (filter.key === "contextUsage.percent" || filter.key === "contextUsage.state") return true;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -304,28 +183,6 @@ function applyNodeFilter(entries: NodeEntry[], filter: ParsedFilter): NodeEntry[
|
||||
if (filter.key === "lifecycleState") return n.lifecycleState === filter.value;
|
||||
if (filter.key === "name-prefix") return n.rigName.startsWith(filter.value);
|
||||
if (filter.key === "name") return n.rigName === filter.value;
|
||||
// PL-019 item 1: nested-key traversal for agentActivity.state. Nodes
|
||||
// without an agentActivity attachment never match a non-unknown filter
|
||||
// value (the daemon reports `unknown` when it has no signal — explicit).
|
||||
if (filter.key === "agentActivity.state") return n.agentActivity?.state === filter.value;
|
||||
// PL-012: contextUsage.percent — numeric comparison. Nodes with no
|
||||
// sample fail every comparator (operator can filter on
|
||||
// contextUsage.state=unknown to find them).
|
||||
if (filter.key === "contextUsage.percent") {
|
||||
const pct = n.contextUsage?.usedPercentage;
|
||||
if (typeof pct !== "number" || filter.numericValue === undefined) return false;
|
||||
switch (filter.op) {
|
||||
case ">=": return pct >= filter.numericValue;
|
||||
case ">": return pct > filter.numericValue;
|
||||
case "<=": return pct <= filter.numericValue;
|
||||
case "<": return pct < filter.numericValue;
|
||||
case "=": return pct === filter.numericValue;
|
||||
}
|
||||
}
|
||||
if (filter.key === "contextUsage.state") {
|
||||
const derived = n.contextUsage?.state ?? deriveContextUsageState(n.contextUsage?.usedPercentage ?? null);
|
||||
return derived === filter.value;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -421,17 +278,12 @@ Examples:
|
||||
rig ps --filter name-prefix=demo Filter by rig-name prefix
|
||||
rig ps --nodes Per-node detail (human; truncated to ${HUMAN_NODE_BUDGET})
|
||||
rig ps --nodes --json --limit 50 Bounded per-node JSON envelope
|
||||
rig ps --nodes --active Show only nodes whose agentActivity.state == running (PL-019)
|
||||
rig ps --nodes --filter agentActivity.state=running
|
||||
Same as --active (the explicit form)
|
||||
rig ps --host vm-claude-test --nodes --json Run on a remote host via single-hop ssh
|
||||
|
||||
JSON output entries include both \`name\` and \`rigName\` (alias) for forward
|
||||
compatibility; agent code should prefer \`rigName\` (matches per-node JSON).
|
||||
|
||||
--filter accepts: status, lifecycleState, name-prefix, name, agentActivity.state.
|
||||
Other keys are rejected. agentActivity.state is node-level (use with --nodes);
|
||||
allowed values: running, needs_input, idle, unknown.
|
||||
--filter accepts: status, lifecycleState, name-prefix, name. Other keys are rejected.
|
||||
|
||||
--fields accepts (rig-level): rigId, name, rigName, nodeCount, runningCount,
|
||||
status, lifecycleState, uptime, latestSnapshot.
|
||||
@@ -463,8 +315,7 @@ Exit codes:
|
||||
.option("--limit <n>", "Limit number of entries (rigs or nodes)")
|
||||
.option("--fields <list>", "Comma-separated field list to project (JSON only)")
|
||||
.option("--summary", "Emit aggregate-only output (counts by status/lifecycle)")
|
||||
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name, agentActivity.state")
|
||||
.option("--active", "Shortcut for --filter agentActivity.state=running (PL-019)")
|
||||
.option("--filter <key=value>", "Filter entries; supported keys: status, lifecycleState, name-prefix, name")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.action(async (opts: PsCliOptions) => {
|
||||
const deps = getDepsF();
|
||||
@@ -484,28 +335,9 @@ Exit codes:
|
||||
|
||||
// Parse filter once up front so unknown keys/malformed filters surface
|
||||
// as exit-1 errors before any HTTP call.
|
||||
//
|
||||
// PL-019 item 1: --active is sugar for --filter agentActivity.state=running.
|
||||
// Combining --active with --filter is rejected (composition is
|
||||
// ambiguous — pick one explicit form). Parity is verified end-to-end
|
||||
// by the focused test: `--active` and the explicit filter must yield
|
||||
// identical output on the same fixture.
|
||||
let effectiveFilter = opts.filter;
|
||||
if (opts.active) {
|
||||
if (effectiveFilter) {
|
||||
console.error(
|
||||
`--active and --filter cannot be combined. ` +
|
||||
`--active is sugar for --filter agentActivity.state=running. ` +
|
||||
`Pick one form, or compose by upgrading to --filter directly.`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
effectiveFilter = "agentActivity.state=running";
|
||||
}
|
||||
let parsedFilter: ParsedFilter | null = null;
|
||||
if (effectiveFilter) {
|
||||
const result = parseFilter(effectiveFilter);
|
||||
if (opts.filter) {
|
||||
const result = parseFilter(opts.filter);
|
||||
if ("error" in result) {
|
||||
console.error(result.error);
|
||||
process.exitCode = 1;
|
||||
@@ -704,7 +536,7 @@ async function handleNodes(
|
||||
const humanList = (opts.full || limit !== null) ? limited : limited.slice(0, HUMAN_NODE_BUDGET);
|
||||
const humanTruncated = !opts.full && limit === null && filtered.length > HUMAN_NODE_BUDGET;
|
||||
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "CTX", "RESTORE", "ERROR");
|
||||
const header = padNodeRow("RIG", "POD", "MEMBER", "SESSION", "RUNTIME", "STATUS", "STARTUP", "LIFECYCLE", "ACTIVITY", "RESTORE", "ERROR");
|
||||
console.log(header);
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const parts = n.logicalId.split(".");
|
||||
@@ -721,7 +553,6 @@ async function handleNodes(
|
||||
n.startupStatus ?? "—",
|
||||
abbrevNodeLifecycle(n.lifecycleState),
|
||||
formatActivity(n.agentActivity),
|
||||
formatContextUsage(n.contextUsage),
|
||||
n.restoreOutcome,
|
||||
n.latestError ? truncate(n.latestError, 30) : "—",
|
||||
));
|
||||
@@ -764,7 +595,7 @@ function padRigRow(rig: string, nodes: string, running: string, status: string,
|
||||
].join("");
|
||||
}
|
||||
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, ctx: string, restore: string, error: string): string {
|
||||
function padNodeRow(rig: string, pod: string, member: string, session: string, runtime: string, status: string, startup: string, lifecycle: string, activity: string, restore: string, error: string): string {
|
||||
return [
|
||||
fitCell(rig, 30),
|
||||
fitCell(pod, 10),
|
||||
@@ -775,24 +606,11 @@ function padNodeRow(rig: string, pod: string, member: string, session: string, r
|
||||
fitCell(startup, 10),
|
||||
fitCell(lifecycle, 11),
|
||||
fitCell(activity, 12),
|
||||
fitCell(ctx, 6),
|
||||
fitCell(restore, 10),
|
||||
error,
|
||||
].join("");
|
||||
}
|
||||
|
||||
// PL-012: render context-usage as a 5-char cell — "<percent>%" when
|
||||
// known + fresh, "<percent>%*" when known but stale, "??" when unknown.
|
||||
// 4-char width keeps the table compact without truncating two-digit
|
||||
// percentages (e.g., "98%*" or "5%").
|
||||
function formatContextUsage(ctx: NodeEntry["contextUsage"]): string {
|
||||
if (!ctx || ctx.availability !== "known" || typeof ctx.usedPercentage !== "number") {
|
||||
return "??";
|
||||
}
|
||||
const stale = ctx.fresh === false ? "*" : "";
|
||||
return `${ctx.usedPercentage}%${stale}`;
|
||||
}
|
||||
|
||||
|
||||
async function runCrossHostPs(
|
||||
hostId: string,
|
||||
|
||||
@@ -68,7 +68,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
|
||||
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
@@ -80,7 +79,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
tags?: string;
|
||||
expiresAt?: string;
|
||||
id?: string;
|
||||
targetRepo?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -96,7 +94,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
expiresAt: opts.expiresAt,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
@@ -181,7 +178,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
@@ -192,7 +188,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
targetRepo?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -209,7 +204,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
@@ -228,7 +222,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
@@ -239,7 +232,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
targetRepo?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -256,7 +248,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
@@ -330,14 +321,12 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--destination <session>", "Filter by destination session")
|
||||
.option("--source <session>", "Filter by source session")
|
||||
.option("--state <state>", "Filter by state (comma-separated for multiple)")
|
||||
.option("--target-repo <name>", "PL-007: filter qitems by target_repo (exact match)")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
destination?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
targetRepo?: string;
|
||||
limit: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -346,7 +335,6 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.targetRepo) params.set("targetRepo", opts.targetRepo);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
|
||||
|
||||
@@ -1,681 +0,0 @@
|
||||
// release-0.3.2 slice 12 — `rig scope` CLI primitive.
|
||||
//
|
||||
// Command grammar: rig scope <tier> <verb>. v0 ships `mission` +
|
||||
// `slice` tiers; `project` and `sub-slice` are reserved per the
|
||||
// substrate convention `conventions/scope-and-versioning/README.md`
|
||||
// (stage: provisional). The CLI mints stable dot-IDs into created
|
||||
// mission/slice frontmatter per §1 of that convention.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
|
||||
import {
|
||||
CLOSE_REASONS,
|
||||
MISSION_TEMPLATE_KINDS,
|
||||
SLICE_TEMPLATE_KINDS,
|
||||
ScopeCliError,
|
||||
type CloseReason,
|
||||
type MissionTemplateKind,
|
||||
type SliceTemplateKind,
|
||||
type SliceState,
|
||||
} from "../lib/scope/types.js";
|
||||
import {
|
||||
DEFAULT_PROJECT_PREFIX,
|
||||
inferMissionDotId,
|
||||
isMissionDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
sliceIdFromMission,
|
||||
} from "../lib/scope/dot-id.js";
|
||||
import {
|
||||
ensureMissionIdPersisted,
|
||||
findMission,
|
||||
findSlice,
|
||||
listMissions,
|
||||
listSlices,
|
||||
moveSlice,
|
||||
nextSliceNN,
|
||||
pad2,
|
||||
resolveMissionsRoot,
|
||||
splitFrontmatter,
|
||||
todayDateISO,
|
||||
updateFrontmatter,
|
||||
} from "../lib/scope/scope-fs.js";
|
||||
import {
|
||||
renderMissionTemplate,
|
||||
renderSliceTemplate,
|
||||
titleFromSlug,
|
||||
} from "../lib/scope/templates.js";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
interface Stdout {
|
||||
write: (text: string) => void;
|
||||
}
|
||||
|
||||
function makeStdout(): Stdout {
|
||||
return { write: (text: string) => process.stdout.write(text) };
|
||||
}
|
||||
|
||||
function emit(out: Stdout, payload: unknown, json: boolean, lines?: string[]): void {
|
||||
if (json) {
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (lines) {
|
||||
for (const line of lines) out.write(line + "\n");
|
||||
return;
|
||||
}
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function fail(err: unknown, json: boolean, out: Stdout): never {
|
||||
if (err instanceof ScopeCliError) {
|
||||
if (json) {
|
||||
out.write(JSON.stringify({
|
||||
ok: false,
|
||||
error: { fact: err.fact, consequence: err.consequence, action: err.action },
|
||||
}, null, 2) + "\n");
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
|
||||
}
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function slugify(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
interface RootOpts {
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
function getOpts(cmd: Command): RootOpts {
|
||||
// commander v13 attaches opts on the parent.
|
||||
let walker: Command | null = cmd;
|
||||
while (walker) {
|
||||
const o = walker.opts() as RootOpts;
|
||||
if (o.workspace) return o;
|
||||
walker = walker.parent;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice ls
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceLsCommand(): Command {
|
||||
const cmd = new Command("ls")
|
||||
.description("List slices in a mission (or across all missions)")
|
||||
.option("--mission <name>", "Restrict to a single mission")
|
||||
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
const state = (opts.state as SliceState) ?? "active";
|
||||
if (!["active", "closed", "shipped", "all"].includes(state)) {
|
||||
fail(new ScopeCliError({
|
||||
fact: `Unknown --state value "${state}".`,
|
||||
consequence: "Command did not run.",
|
||||
action: "Pick one of: active, closed, shipped, all.",
|
||||
}), json, out);
|
||||
}
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const missions = opts.mission
|
||||
? [findMission(missionsRoot, opts.mission)]
|
||||
: listMissions(missionsRoot);
|
||||
const rows: unknown[] = [];
|
||||
const lines: string[] = [];
|
||||
for (const mission of missions) {
|
||||
const slices = listSlices(mission, state);
|
||||
for (const slice of slices) {
|
||||
rows.push({
|
||||
mission: mission.name,
|
||||
name: slice.name,
|
||||
nn: slice.nn,
|
||||
slug: slice.slug,
|
||||
id: slice.id,
|
||||
status: slice.status,
|
||||
path: slice.absPath,
|
||||
});
|
||||
lines.push(`${mission.name}/${slice.name} ${slice.id ?? "—"} ${slice.status ?? "—"}`);
|
||||
}
|
||||
}
|
||||
emit(out, { ok: true, count: rows.length, slices: rows }, json, lines.length === 0 ? ["(no slices)"] : lines);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice show
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceShowCommand(): Command {
|
||||
return new Command("show")
|
||||
.description("Inspect a single slice (frontmatter + README + children)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative-to-substrate, or NN-slug)")
|
||||
.option("--mission <name>", "Hint mission when path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const readme = slice.readmePath ? fs.readFileSync(slice.readmePath, "utf8") : null;
|
||||
const children = fs.readdirSync(slice.absPath, { withFileTypes: true })
|
||||
.map((e) => ({ name: e.name, kind: e.isDirectory() ? "dir" : "file" as const }));
|
||||
const payload = {
|
||||
ok: true,
|
||||
slice: {
|
||||
mission: slice.missionName,
|
||||
name: slice.name,
|
||||
id: slice.id,
|
||||
status: slice.status,
|
||||
path: slice.absPath,
|
||||
frontmatter: slice.frontmatter,
|
||||
readme,
|
||||
children,
|
||||
},
|
||||
};
|
||||
if (json) {
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
} else {
|
||||
out.write(`Slice: ${slice.missionName}/${slice.name}\n`);
|
||||
out.write(` id: ${slice.id ?? "—"}\n`);
|
||||
out.write(` status: ${slice.status ?? "—"}\n`);
|
||||
out.write(` path: ${slice.absPath}\n`);
|
||||
out.write(` children: ${children.length}\n`);
|
||||
if (readme) {
|
||||
out.write("\n--- README ---\n");
|
||||
out.write(readme);
|
||||
if (!readme.endsWith("\n")) out.write("\n");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice create
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceCreateCommand(): Command {
|
||||
return new Command("create")
|
||||
.description("Create a new slice in a mission")
|
||||
.argument("<mission>", "Mission name")
|
||||
.argument("<slug>", "Short slug (becomes the folder name's suffix)")
|
||||
.option("--template <kind>", `Template: ${SLICE_TEMPLATE_KINDS.join(" | ")}`, "placeholder")
|
||||
.option("--title <text>", "Display title (defaults to titlecased slug)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (missionName: string, rawSlug: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const kind = opts.template as SliceTemplateKind;
|
||||
if (!SLICE_TEMPLATE_KINDS.includes(kind)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Unknown --template kind "${kind}".`,
|
||||
consequence: "Slice not created.",
|
||||
action: `Pick one of: ${SLICE_TEMPLATE_KINDS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
const slug = slugify(rawSlug);
|
||||
if (!slug) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slug "${rawSlug}" reduces to empty after slugification.`,
|
||||
consequence: "Slice not created.",
|
||||
action: "Pick a slug containing letters or digits.",
|
||||
});
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
// Persist the parent mission's id back into its README at the
|
||||
// SAME moment we mint the child's id (per convention §1
|
||||
// lazy-adoption rule + guard BC BLOCK 3 — every child-mint site
|
||||
// must persist the parent's id, not just slice create).
|
||||
const missionId = ensureMissionIdPersisted(mission, missionsRoot);
|
||||
const nn = nextSliceNN(mission.absPath);
|
||||
const sliceFolder = `${pad2(nn)}-${slug}`;
|
||||
const sliceAbs = path.join(mission.absPath, "slices", sliceFolder);
|
||||
if (fs.existsSync(sliceAbs)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice folder ${sliceAbs} already exists.`,
|
||||
consequence: "Refusing to overwrite.",
|
||||
action: "Pick a different slug, or rm -rf the existing folder first.",
|
||||
});
|
||||
}
|
||||
const id = sliceIdFromMission(missionId, nn);
|
||||
const title = opts.title ?? titleFromSlug(slug);
|
||||
const body = renderSliceTemplate(kind, {
|
||||
id,
|
||||
slice_number: pad2(nn),
|
||||
slug,
|
||||
mission: mission.name,
|
||||
title,
|
||||
created_date: todayDateISO(),
|
||||
});
|
||||
fs.mkdirSync(sliceAbs, { recursive: true });
|
||||
const readmePath = path.join(sliceAbs, "README.md");
|
||||
fs.writeFileSync(readmePath, body, "utf8");
|
||||
const payload = {
|
||||
ok: true,
|
||||
slice: {
|
||||
mission: mission.name,
|
||||
name: sliceFolder,
|
||||
id,
|
||||
path: sliceAbs,
|
||||
readmePath,
|
||||
template: kind,
|
||||
},
|
||||
};
|
||||
emit(out, payload, json, [
|
||||
`Created ${mission.name}/slices/${sliceFolder}`,
|
||||
` id: ${id}`,
|
||||
` template: ${kind}`,
|
||||
` path: ${sliceAbs}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice ship
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceShipCommand(): Command {
|
||||
return new Command("ship")
|
||||
.description("Ship a slice to a release mission (preserves git history)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.argument("<release-mission>", "Target release mission name")
|
||||
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, releaseMission: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const target = findMission(missionsRoot, releaseMission);
|
||||
const targetSlicesDir = path.join(target.absPath, "slices");
|
||||
fs.mkdirSync(targetSlicesDir, { recursive: true });
|
||||
const newNN = nextSliceNN(target.absPath);
|
||||
const slug = slice.slug ?? slugify(slice.name);
|
||||
const newName = `${pad2(newNN)}-${slug}`;
|
||||
const destAbs = path.join(targetSlicesDir, newName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const targetId = ensureMissionIdPersisted(target, missionsRoot);
|
||||
const newSliceId = sliceIdFromMission(targetId, newNN);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
updateFrontmatter(newReadme, {
|
||||
id: newSliceId,
|
||||
mission: target.name,
|
||||
status: `shipped-to-${target.name}`,
|
||||
"shipped-on": todayDateISO(),
|
||||
"shipped-from": slice.missionName,
|
||||
});
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
shipped: {
|
||||
from: { mission: slice.missionName, name: slice.name, id: slice.id },
|
||||
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
|
||||
git: { usedGit, repoRoot },
|
||||
},
|
||||
}, json, [
|
||||
`Shipped ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
|
||||
` id: ${newSliceId}`,
|
||||
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice close
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceCloseCommand(): Command {
|
||||
return new Command("close")
|
||||
.description("Close a slice (move to <mission>/closed/, update status)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.requiredOption("--reason <reason>", `Closure reason: ${CLOSE_REASONS.join(" | ")}`)
|
||||
.option("--note <text>", "Optional closure note")
|
||||
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const reason = opts.reason as CloseReason;
|
||||
if (!CLOSE_REASONS.includes(reason)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Unknown --reason "${reason}".`,
|
||||
consequence: "Slice not closed.",
|
||||
action: `Pick one of: ${CLOSE_REASONS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const mission = findMission(missionsRoot, slice.missionName);
|
||||
const closedDir = path.join(mission.absPath, "closed");
|
||||
fs.mkdirSync(closedDir, { recursive: true });
|
||||
const destName = slice.name;
|
||||
const destAbs = path.join(closedDir, destName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
const updates: Record<string, unknown> = {
|
||||
status: `closed-${reason}`,
|
||||
"closed-on": todayDateISO(),
|
||||
};
|
||||
if (opts.note) updates["closure-note"] = opts.note;
|
||||
updateFrontmatter(newReadme, updates);
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
closed: {
|
||||
mission: slice.missionName,
|
||||
name: destName,
|
||||
id: slice.id,
|
||||
reason,
|
||||
note: opts.note ?? null,
|
||||
path: destAbs,
|
||||
git: { usedGit, repoRoot },
|
||||
},
|
||||
}, json, [
|
||||
`Closed ${slice.missionName}/${slice.name} → ${slice.missionName}/closed/${destName}`,
|
||||
` reason: ${reason}`,
|
||||
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice move
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceMoveCommand(): Command {
|
||||
return new Command("move")
|
||||
.description("Move a slice between missions (re-numbers in destination)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.argument("<dest-mission>", "Destination mission name")
|
||||
.option("--mission <name>", "Hint source mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, destMission: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const target = findMission(missionsRoot, destMission);
|
||||
const targetSlicesDir = path.join(target.absPath, "slices");
|
||||
fs.mkdirSync(targetSlicesDir, { recursive: true });
|
||||
const newNN = nextSliceNN(target.absPath);
|
||||
const slug = slice.slug ?? slugify(slice.name);
|
||||
const newName = `${pad2(newNN)}-${slug}`;
|
||||
const destAbs = path.join(targetSlicesDir, newName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const targetId = ensureMissionIdPersisted(target, missionsRoot);
|
||||
const newSliceId = sliceIdFromMission(targetId, newNN);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
updateFrontmatter(newReadme, {
|
||||
id: newSliceId,
|
||||
mission: target.name,
|
||||
"moved-on": todayDateISO(),
|
||||
"moved-from": slice.missionName,
|
||||
});
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
moved: {
|
||||
from: { mission: slice.missionName, name: slice.name, id: slice.id },
|
||||
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
|
||||
git: { usedGit, repoRoot },
|
||||
},
|
||||
}, json, [
|
||||
`Moved ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
|
||||
` id: ${newSliceId}`,
|
||||
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope mission ls / show / create
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildMissionLsCommand(): Command {
|
||||
return new Command("ls")
|
||||
.description("List missions (top-level folders with README.md)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const missions = listMissions(missionsRoot);
|
||||
const rows = missions.map((m) => ({
|
||||
name: m.name,
|
||||
id: m.id,
|
||||
path: m.absPath,
|
||||
activeSliceCount: m.activeSliceCount,
|
||||
closedSliceCount: m.closedSliceCount,
|
||||
}));
|
||||
emit(out, { ok: true, count: rows.length, missions: rows }, json,
|
||||
rows.length === 0
|
||||
? ["(no missions)"]
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
|
||||
);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionShowCommand(): Command {
|
||||
return new Command("show")
|
||||
.description("Inspect a single mission")
|
||||
.argument("<mission>", "Mission name")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (missionName: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
const readme = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null;
|
||||
const slices = listSlices(mission, "all").map((s) => ({
|
||||
name: s.name, id: s.id, status: s.status, nn: s.nn,
|
||||
}));
|
||||
const payload = {
|
||||
ok: true,
|
||||
mission: {
|
||||
name: mission.name,
|
||||
id: mission.id,
|
||||
path: mission.absPath,
|
||||
activeSliceCount: mission.activeSliceCount,
|
||||
closedSliceCount: mission.closedSliceCount,
|
||||
frontmatter: mission.frontmatter,
|
||||
readme,
|
||||
slices,
|
||||
},
|
||||
};
|
||||
if (json) {
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
} else {
|
||||
out.write(`Mission: ${mission.name}\n`);
|
||||
out.write(` id: ${mission.id ?? "—"}\n`);
|
||||
out.write(` active slices: ${mission.activeSliceCount}\n`);
|
||||
out.write(` closed slices: ${mission.closedSliceCount}\n`);
|
||||
out.write(` path: ${mission.absPath}\n`);
|
||||
if (readme) {
|
||||
out.write("\n--- README ---\n");
|
||||
out.write(readme);
|
||||
if (!readme.endsWith("\n")) out.write("\n");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionCreateCommand(): Command {
|
||||
return new Command("create")
|
||||
.description("Create a new mission (mints a stable dot-ID into frontmatter)")
|
||||
.argument("<name>", "Mission folder name (e.g., release-0.4.0, backlog-foo)")
|
||||
.option("--template <kind>", `Template: ${MISSION_TEMPLATE_KINDS.join(" | ")} (auto when name matches release-X.Y.Z)`, "")
|
||||
.option("--id <dot-id>", "Explicit dot-ID. Overrides name-pattern inference.")
|
||||
.option("--title <text>", "Display title (defaults to titlecased name)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (rawName: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const name = rawName.trim();
|
||||
if (!name || /[\\/\s]/.test(name)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Invalid mission name "${rawName}".`,
|
||||
consequence: "Mission not created.",
|
||||
action: "Pick a name with no whitespace or path separators.",
|
||||
});
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const absPath = path.join(missionsRoot, name);
|
||||
if (fs.existsSync(absPath)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission folder ${absPath} already exists.`,
|
||||
consequence: "Refusing to overwrite.",
|
||||
action: "Pick a different name, or use `rig scope mission show <name>` to inspect the existing mission.",
|
||||
});
|
||||
}
|
||||
// Resolve template kind: explicit > release-pattern auto > placeholder.
|
||||
const isReleaseName = /^release-\d+\.\d+(?:\.\d+)?$/.test(name);
|
||||
let templateKind: MissionTemplateKind = opts.template as MissionTemplateKind;
|
||||
if (!templateKind) templateKind = isReleaseName ? "release" : "placeholder";
|
||||
if (!MISSION_TEMPLATE_KINDS.includes(templateKind)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Unknown --template kind "${templateKind}".`,
|
||||
consequence: "Mission not created.",
|
||||
action: `Pick one of: ${MISSION_TEMPLATE_KINDS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
// Mint the dot-ID.
|
||||
let id: string;
|
||||
if (opts.id) {
|
||||
// Tier-aware validation per guard BC verdict (BLOCK 1).
|
||||
// A mission ID has 2-3 numeric segments after the prefix
|
||||
// (release X.Y or X.Y.Z; escape-band 99.x.y). Reject
|
||||
// slice-shaped IDs (4 segments) so the parent identity stays
|
||||
// unambiguous.
|
||||
if (!isMissionDotId(opts.id)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Supplied --id "${opts.id}" is not a mission-tier dot-ID.`,
|
||||
consequence: "Mission not created. A mission ID has the shape <PFX>.<ver> (2-3 numeric segments), not a slice shape <PFX>.<ver>.<n>.",
|
||||
action: "Use a mission-shaped dot-ID like OPR.0.3.2 (release) or OPR.99.0.1 (escape band). For slice IDs, scope automatically mints them when you create a slice.",
|
||||
});
|
||||
}
|
||||
id = opts.id;
|
||||
} else if (isReleaseName) {
|
||||
id = inferMissionDotId(name, null);
|
||||
} else {
|
||||
const peers = listMissions(missionsRoot);
|
||||
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
|
||||
id = inferMissionDotId(name, ordinal);
|
||||
}
|
||||
// Scaffold the mission directory + README.
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
|
||||
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
|
||||
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
|
||||
const body = renderMissionTemplate(templateKind, {
|
||||
id,
|
||||
slug: name,
|
||||
mission: name,
|
||||
title,
|
||||
created_date: todayDateISO(),
|
||||
release_version: releaseVersion,
|
||||
});
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
fs.writeFileSync(readmePath, body, "utf8");
|
||||
emit(out, {
|
||||
ok: true,
|
||||
mission: {
|
||||
name,
|
||||
id,
|
||||
template: templateKind,
|
||||
path: absPath,
|
||||
readmePath,
|
||||
},
|
||||
}, json, [
|
||||
`Created mission ${name}`,
|
||||
` id: ${id}`,
|
||||
` template: ${templateKind}`,
|
||||
` path: ${absPath}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Aggregate
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function scopeCommand(): Command {
|
||||
const cmd = new Command("scope")
|
||||
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
|
||||
.option("--workspace <path>", "Override workspace root (otherwise inferred from cwd or $OPENRIG_WORK_ROOT)");
|
||||
|
||||
const slice = new Command("slice").description("Slice-tier commands");
|
||||
slice.addCommand(buildSliceLsCommand());
|
||||
slice.addCommand(buildSliceShowCommand());
|
||||
slice.addCommand(buildSliceCreateCommand());
|
||||
slice.addCommand(buildSliceShipCommand());
|
||||
slice.addCommand(buildSliceCloseCommand());
|
||||
slice.addCommand(buildSliceMoveCommand());
|
||||
cmd.addCommand(slice);
|
||||
|
||||
const mission = new Command("mission").description("Mission-tier commands");
|
||||
mission.addCommand(buildMissionLsCommand());
|
||||
mission.addCommand(buildMissionShowCommand());
|
||||
mission.addCommand(buildMissionCreateCommand());
|
||||
cmd.addCommand(mission);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Re-exports for tests.
|
||||
export { DEFAULT_PROJECT_PREFIX, splitFrontmatter };
|
||||
@@ -6,42 +6,9 @@ import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
|
||||
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
|
||||
|
||||
const SENDER_FALLBACK = "<unknown sender>";
|
||||
|
||||
/**
|
||||
* Wrap a `rig send` body with an email-style envelope so the recipient
|
||||
* pane has both the sender's identity and a copy-pasteable reply hint.
|
||||
* Cross-host sends do NOT wrap locally: the remote rig wraps when it
|
||||
* runs the same command, and double-wrapping would nest envelopes.
|
||||
*
|
||||
* V0.3.1 slice 23 parity contract: `packages/daemon/src/lib/pane-envelope.ts`
|
||||
* exports `wrapPaneEnvelope` with BYTE-IDENTICAL output for the same
|
||||
* inputs. The two implementations live in separate packages because
|
||||
* cli + daemon don't cross-import today. Daemon-side nudges from
|
||||
* `rig queue create|handoff` use the daemon helper so queue nudges
|
||||
* render the same envelope as peer-to-peer `rig send`. If you update
|
||||
* this function, update wrapPaneEnvelope in lockstep.
|
||||
*/
|
||||
export function wrapSendBody(sender: string | undefined, recipient: string, body: string): string {
|
||||
const senderLabel = sender && sender.trim().length > 0 ? sender : SENDER_FALLBACK;
|
||||
return [
|
||||
`From: ${senderLabel}`,
|
||||
`To: ${recipient}`,
|
||||
"---",
|
||||
body,
|
||||
"---",
|
||||
`↩ Reply: rig send ${senderLabel} "..."`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function resolveSenderSession(): string | undefined {
|
||||
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
}
|
||||
|
||||
export interface SendDeps extends StatusDeps {
|
||||
/**
|
||||
* Cross-host hooks. Both default to the production loaders/executors; tests
|
||||
@@ -119,9 +86,8 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const wrappedText = wrapSendBody(resolveSenderSession(), session, text);
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session, text: wrappedText, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
session, text, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
}, waitForIdleRequestOptions(waitForIdleMs));
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface LibraryEntry {
|
||||
id: string;
|
||||
kind: "rig" | "agent" | "workflow";
|
||||
kind: string;
|
||||
name: string;
|
||||
version: string;
|
||||
sourceType: string;
|
||||
@@ -145,9 +145,8 @@ function installSpecSource(source: AddSpecSource, userRoot: string): string {
|
||||
export async function resolveLibrarySpec(
|
||||
client: DaemonClient,
|
||||
nameOrId: string,
|
||||
opts?: { kind?: LibraryEntry["kind"] },
|
||||
): Promise<LibraryEntry> {
|
||||
const res = await client.get<LibraryEntry[]>(opts?.kind ? `/api/specs/library?kind=${opts.kind}` : "/api/specs/library");
|
||||
const res = await client.get<LibraryEntry[]>("/api/specs/library");
|
||||
const entries = res.data ?? [];
|
||||
|
||||
// Try exact ID match first
|
||||
@@ -159,16 +158,14 @@ export async function resolveLibrarySpec(
|
||||
if (byName.length === 1) return byName[0]!;
|
||||
|
||||
if (byName.length > 1) {
|
||||
const candidates = byName.map((e) => ` ${e.id} (${e.kind}) — ${e.sourcePath}`).join("\n");
|
||||
const scope = opts?.kind ? ` ${opts.kind}` : "";
|
||||
const candidates = byName.map((e) => ` ${e.id} — ${e.sourcePath}`).join("\n");
|
||||
throw new Error(
|
||||
`Spec name '${nameOrId}' is ambiguous — ${byName.length}${scope} entries match.\nUse the ID instead:\n${candidates}`
|
||||
`Spec name '${nameOrId}' is ambiguous — ${byName.length} entries match.\nUse the ID instead:\n${candidates}`
|
||||
);
|
||||
}
|
||||
|
||||
const scope = opts?.kind ? ` ${opts.kind}` : "";
|
||||
throw new Error(
|
||||
`Spec '${nameOrId}' not found in${scope} library. Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.`
|
||||
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,7 +195,7 @@ Examples:
|
||||
// specs ls
|
||||
cmd.command("ls")
|
||||
.description("List library rigs, agents, and managed apps")
|
||||
.option("--kind <kind>", "Filter by kind (rig, agent, or workflow)")
|
||||
.option("--kind <kind>", "Filter by kind (rig or agent)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { kind?: string; json?: boolean }) => {
|
||||
try {
|
||||
@@ -230,12 +227,11 @@ Examples:
|
||||
cmd.command("show")
|
||||
.argument("<name-or-id>", "Spec name or library ID")
|
||||
.description("Show spec metadata and path")
|
||||
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
|
||||
const entry = await resolveLibrarySpec(client, nameOrId);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
@@ -258,12 +254,11 @@ Examples:
|
||||
cmd.command("preview")
|
||||
.argument("<name-or-id>", "Spec name or library ID")
|
||||
.description("Show structured spec review, including managed app details")
|
||||
.option("--kind <kind>", "Disambiguate by kind (rig, agent, or workflow)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { kind?: LibraryEntry["kind"]; json?: boolean }) => {
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const entry = await resolveLibrarySpec(client, nameOrId, opts.kind ? { kind: opts.kind } : undefined);
|
||||
const entry = await resolveLibrarySpec(client, nameOrId);
|
||||
const res = await client.get<Record<string, unknown>>(`/api/specs/library/${encodeURIComponent(entry.id)}/review`);
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -29,9 +29,8 @@ Examples:
|
||||
.option("--yes", "Auto-approve trusted actions")
|
||||
.option("--cwd <path>", "Override launch working directory for all members for this run only")
|
||||
.option("--target <root>", "Target root directory for package installation (.rigbundle only; does not change agent cwd)")
|
||||
.option("--existing", "Treat <source> as an existing rig name; bypass library-spec name resolution")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; json?: boolean }) => {
|
||||
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// Run preflight before auto-start
|
||||
@@ -40,17 +39,8 @@ Examples:
|
||||
let resolvedConfig: {
|
||||
daemon: { port: number; host: string };
|
||||
db: { path: string };
|
||||
transcripts: { enabled: boolean; path: string; lines: number; pollIntervalSeconds: number };
|
||||
workspace: { root: string };
|
||||
transcripts: { enabled: boolean; path: string };
|
||||
} | null = null;
|
||||
// bug-fix slice auth-bearer-tailscale-trust: track whether
|
||||
// daemon.host was operator-explicit (env or config file) vs
|
||||
// default-fallback. The daemon's multi-bind path (loopback +
|
||||
// tailscale auto-detect) only runs when OPENRIG_HOST is NOT
|
||||
// exported to the child, so we omit it on the default path.
|
||||
// Hoisted to function scope so the startDaemon block below can
|
||||
// read it after the preflight try-catch.
|
||||
let hostForDaemon: string | undefined;
|
||||
try {
|
||||
const { ConfigStore } = await import("../config-store.js");
|
||||
const { SystemPreflight } = await import("../system-preflight.js");
|
||||
@@ -58,10 +48,6 @@ Examples:
|
||||
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
|
||||
const configStore = new ConfigStore();
|
||||
resolvedConfig = configStore.resolve();
|
||||
const hostResolution = configStore.resolveWithSource("daemon.host");
|
||||
hostForDaemon = hostResolution.source === "default"
|
||||
? undefined
|
||||
: resolvedConfig.daemon.host;
|
||||
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
|
||||
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
const preflight = new SystemPreflight({
|
||||
@@ -89,13 +75,10 @@ Examples:
|
||||
try {
|
||||
await startDaemon({
|
||||
port: resolvedConfig?.daemon.port,
|
||||
host: hostForDaemon,
|
||||
host: resolvedConfig?.daemon.host,
|
||||
db: resolvedConfig?.db.path,
|
||||
transcriptsEnabled: resolvedConfig?.transcripts.enabled,
|
||||
transcriptsPath: resolvedConfig?.transcripts.path,
|
||||
transcriptsLines: resolvedConfig?.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: resolvedConfig?.transcripts.pollIntervalSeconds,
|
||||
workspaceRoot: resolvedConfig?.workspace.root,
|
||||
}, deps.lifecycleDeps);
|
||||
status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
} catch (err) {
|
||||
@@ -132,10 +115,10 @@ Examples:
|
||||
}
|
||||
return rigSummariesCache;
|
||||
};
|
||||
if (isRigName && !opts.existing) {
|
||||
if (isRigName) {
|
||||
try {
|
||||
const { resolveLibrarySpec } = await import("./specs.js");
|
||||
const entry = await resolveLibrarySpec(client, source, { kind: "rig" });
|
||||
const entry = await resolveLibrarySpec(client, source);
|
||||
// Library match found — check for existing-rig collision
|
||||
// Use /api/rigs/summary which mirrors findRigsByName (includes stopped rigs)
|
||||
const rigSummaries = await fetchRigSummaries();
|
||||
@@ -171,19 +154,11 @@ Examples:
|
||||
const summaries = await fetchRigSummaries();
|
||||
const match = summaries.find((r) => r.name === source);
|
||||
if (match?.lifecycleState === "recoverable") {
|
||||
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
|
||||
console.log(`Recovering rig "${source}" from latest snapshot...`);
|
||||
} else if (match?.lifecycleState === "stopped") {
|
||||
console.log(`Turning on rig "${source}"...`);
|
||||
}
|
||||
}
|
||||
} else if (isRigName && opts.existing && !opts.json) {
|
||||
const summaries = await fetchRigSummaries();
|
||||
const match = summaries.find((r) => r.name === source);
|
||||
if (match?.lifecycleState === "recoverable") {
|
||||
console.log(`Recovering rig "${source}" from latest snapshot or current DB state...`);
|
||||
} else {
|
||||
console.log(`Turning on existing rig "${source}"...`);
|
||||
}
|
||||
}
|
||||
|
||||
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
|
||||
|
||||
@@ -198,13 +198,11 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const params = new URLSearchParams();
|
||||
if (source.nodeId) params.set("nodeId", source.nodeId);
|
||||
else params.set("sessionName", source.sessionName!);
|
||||
const targetRepo = readOpenRigEnv("OPENRIG_TARGET_REPO", "RIGGED_TARGET_REPO");
|
||||
if (targetRepo) params.set("targetRepo", targetRepo);
|
||||
const query = source.nodeId
|
||||
? `nodeId=${encodeURIComponent(source.nodeId)}`
|
||||
: `sessionName=${encodeURIComponent(source.sessionName!)}`;
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(`/api/whoami?${params.toString()}`);
|
||||
const res = await client.get<Record<string, unknown>>(`/api/whoami?${query}`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
|
||||
@@ -140,47 +140,6 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
});
|
||||
});
|
||||
|
||||
// List cached workflow_specs (NOT instances). The existing
|
||||
// `rig workflow list` lists instances; this is the complementary
|
||||
// surface for inspecting which specs are registered, including any
|
||||
// built-in starter(s) shipped at daemon startup.
|
||||
// Built-in rows display a `(built-in)` indicator in human output and
|
||||
// an `isBuiltIn: true` field in JSON output.
|
||||
cmd
|
||||
.command("specs")
|
||||
.description("List registered workflow specs; built-in starters tagged with (built-in)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<{ specs: Array<{
|
||||
name: string; version: string; purpose: string | null;
|
||||
targetRig: string | null; coordinationTerminalTurnRule: string;
|
||||
sourcePath: string; cachedAt: string; isBuiltIn: boolean;
|
||||
}> }>("/api/workflow/specs");
|
||||
if (opts.json) {
|
||||
printResult(true, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
printResult(false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
const rows = res.data.specs ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log("No workflow specs registered.");
|
||||
return;
|
||||
}
|
||||
// Compact human table — name, version, source, indicator.
|
||||
for (const row of rows) {
|
||||
const indicator = row.isBuiltIn ? " (built-in)" : "";
|
||||
console.log(`${row.name} v${row.version}${indicator}`);
|
||||
if (row.purpose) console.log(` purpose: ${row.purpose.replace(/\n/g, " ").slice(0, 120)}`);
|
||||
console.log(` source: ${row.sourcePath}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <instanceId>")
|
||||
.description("Show one workflow instance")
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
|
||||
//
|
||||
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, and emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes the
|
||||
// report.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import * as path from "node:path";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export interface WorkspaceDeps extends StatusDeps {}
|
||||
|
||||
interface ValidationGap {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
kind: string;
|
||||
field: string | null;
|
||||
message: string;
|
||||
workspaceKind: string | null;
|
||||
}
|
||||
|
||||
interface ValidationReport {
|
||||
root: string;
|
||||
workspaceKind: string | null;
|
||||
totalFiles: number;
|
||||
filesWithFrontmatter: number;
|
||||
gapCount: number;
|
||||
gaps: ValidationGap[];
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: WorkspaceDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
const cmd = new Command("workspace").description(
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
|
||||
);
|
||||
|
||||
const getDeps = (): WorkspaceDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("validate [root]")
|
||||
.description(
|
||||
"Walk a workspace root, parse each .md file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: cwd.",
|
||||
)
|
||||
.option("--kind <kind>", "Workspace kind to validate against: user | project | knowledge | lab | delivery")
|
||||
.option("--no-recursive", "Do not descend into subdirectories")
|
||||
.option("--require-frontmatter", "Report a gap for every .md file without a frontmatter delimiter")
|
||||
.option("--max-files <n>", "Hard cap on .md files walked", "10000")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(
|
||||
async (
|
||||
rootArg: string | undefined,
|
||||
opts: {
|
||||
kind?: string;
|
||||
recursive?: boolean;
|
||||
requireFrontmatter?: boolean;
|
||||
maxFiles: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
const root = path.resolve(rootArg ?? process.cwd());
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<ValidationReport>("/api/workspace/validate", {
|
||||
root,
|
||||
workspaceKind: opts.kind,
|
||||
recursive: opts.recursive !== false,
|
||||
requireFrontmatter: opts.requireFrontmatter ?? false,
|
||||
maxFiles: Number.parseInt(opts.maxFiles, 10),
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const report = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
renderHumanReport(report);
|
||||
}
|
||||
// Exit non-zero when gaps found — operators chain into hygiene fix loops.
|
||||
if (report.gapCount > 0) process.exitCode = 1;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function renderHumanReport(r: ValidationReport): void {
|
||||
console.log(`workspace root: ${r.root}`);
|
||||
console.log(`workspace kind: ${r.workspaceKind ?? "(none — kind-agnostic structural check)"}`);
|
||||
console.log(`files walked: ${r.totalFiles}`);
|
||||
console.log(`with frontmatter: ${r.filesWithFrontmatter}`);
|
||||
console.log(`gaps: ${r.gapCount}`);
|
||||
if (r.gapCount === 0) {
|
||||
console.log("\n no gaps — canon is clean against v0 contract.");
|
||||
return;
|
||||
}
|
||||
console.log("\n Gaps:");
|
||||
for (const g of r.gaps) {
|
||||
const fieldStr = g.field ? ` [${g.field}]` : "";
|
||||
console.log(` [${g.kind}] ${g.relativePath}${fieldStr}`);
|
||||
console.log(` ${g.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1,358 +1,47 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { userInfo } from "node:os";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
getDefaultOpenRigPath,
|
||||
readOpenRigEnv,
|
||||
} from "./openrig-compat.js";
|
||||
|
||||
// User Settings v0 — extends the existing ConfigStore with new namespaces
|
||||
// (workspace.*, files.*, progress.*) without changing the existing 5
|
||||
// daemon/db/transcripts keys' behavior. Storage stays single-source-of-
|
||||
// truth at ~/.openrig/config.json. Resolution stays env > file > default.
|
||||
|
||||
export interface RiggedConfig {
|
||||
daemon: { port: number; host: string };
|
||||
db: { path: string };
|
||||
transcripts: {
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
|
||||
// SC-29 EXCEPTION #4 declared in pre-release ACK §5: same allowlist
|
||||
// shape as the Phase 4 / Phase 5 prior exceptions.
|
||||
lines: number;
|
||||
pollIntervalSeconds: number;
|
||||
};
|
||||
// User Settings v0 — workspace paths.
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — operatorSeatName carries the
|
||||
// workspace's operator seat session name (default
|
||||
// `operator-${USER}@kernel`). Read by mission-control read layer and
|
||||
// 2 UI cosmetic sites to replace the legacy hardcoded
|
||||
// "human-operator@kernel" constant.
|
||||
workspace: {
|
||||
root: string;
|
||||
slicesRoot: string;
|
||||
steeringPath: string;
|
||||
fieldNotesRoot: string;
|
||||
specsRoot: string;
|
||||
dogfoodEvidenceRoot: string;
|
||||
operatorSeatName: string;
|
||||
};
|
||||
// User Settings v0 — UEP env-var graduation.
|
||||
// Values are stored as raw named-pair strings ("name:/abs/path,...")
|
||||
// matching the OPENRIG_FILES_ALLOWLIST / OPENRIG_PROGRESS_SCAN_ROOTS
|
||||
// formats; decoded helpers (parseNamedPairs) turn them into structured
|
||||
// arrays.
|
||||
files: {
|
||||
allowlist: string;
|
||||
};
|
||||
progress: {
|
||||
scanRoots: string;
|
||||
};
|
||||
// Preview Terminal v0 (PL-018) — UI-side preferences for the live
|
||||
// terminal preview pane.
|
||||
ui: {
|
||||
preview: {
|
||||
refreshIntervalSeconds: number;
|
||||
maxPins: number;
|
||||
defaultLines: number;
|
||||
};
|
||||
};
|
||||
recovery: {
|
||||
autoDriveProviderPrompts: boolean;
|
||||
providerAuthEnvAllowlist: string;
|
||||
};
|
||||
// V1 attempt-3 Phase 4 — Advisor / Operator rail icon V1 placeholders
|
||||
// per universal-shell.md L82–L84. SC-29 EXCEPTION: allowlist-only
|
||||
// additions (no schema migrations / new endpoints / event types).
|
||||
agents: {
|
||||
advisorSession: string;
|
||||
operatorSession: string;
|
||||
};
|
||||
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles per
|
||||
// for-you-feed.md L144–L151. SC-29 EXCEPTION declared in Phase 5
|
||||
// dispatch ACK §5 DRIFT P5-D2: same scope as Phase 4 (allowlist-only;
|
||||
// no migrations / new endpoints / event types).
|
||||
feed: {
|
||||
subscriptions: {
|
||||
actionRequired: boolean;
|
||||
approvals: boolean;
|
||||
shipped: boolean;
|
||||
progress: boolean;
|
||||
auditLog: boolean;
|
||||
};
|
||||
};
|
||||
// plugin-primitive Phase 3a slice 3.5 — runtime feature flags. Currently
|
||||
// single-flag for Codex; extracts to its own primitive workspace if/when
|
||||
// 3+ flags accumulate (per DESIGN.md §5.8).
|
||||
runtime: {
|
||||
codex: {
|
||||
hooksEnabled: boolean;
|
||||
};
|
||||
};
|
||||
// Slice 27 — Claude auto-compaction policy. Operator-configurable
|
||||
// pre-compaction trigger: when a Claude seat's context usage crosses
|
||||
// `thresholdPercent`, daemon sends a pre-compact prep prompt, then
|
||||
// sends /compact via SessionTransport and passes `compactInstruction`
|
||||
// as slash-command args for the actual compaction phase. The existing
|
||||
// PreCompact hook records `messageInline` plus contents of
|
||||
// `messageFilePath` alongside the standard restore-instructions in
|
||||
// the post-compact marker/context. The daemon then sends
|
||||
// `postRestoreAuditInstruction` as the editable read-depth nudge.
|
||||
//
|
||||
// Defaults: opt-in default-off (enabled=false). The compaction
|
||||
// instruction ships as inline text. The post-compaction restore prompt
|
||||
// defaults to loading the canonical restore skill plus a user-owned
|
||||
// extra instruction file path for mission-specific reading lists.
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
preCompactInstruction: string;
|
||||
compactInstruction: string;
|
||||
messageInline: string;
|
||||
messageFilePath: string;
|
||||
postRestoreAuditInstruction: string;
|
||||
};
|
||||
};
|
||||
transcripts: { enabled: boolean; path: string };
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
|
||||
const DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION =
|
||||
"Read the claude-compaction-restore skill and follow its \"If You Are About To Compact\" protocol. Create or update the mental-model restore map before compaction. If you are in the middle of a tiny atomic step, finish that step first; otherwise this preparation is the next priority.";
|
||||
|
||||
const DEFAULT_CLAUDE_COMPACTION_COMPACT_INSTRUCTION = "";
|
||||
|
||||
const DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION =
|
||||
"Read the claude-compaction-restore skill and follow its \"If You Just Compacted\" protocol.";
|
||||
|
||||
const DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION =
|
||||
"Read the claude-compaction-restore skill and follow its \"Required Read-Depth Audit\" protocol.";
|
||||
|
||||
const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigPath(
|
||||
"compaction/post-compact-extra.md",
|
||||
);
|
||||
|
||||
const DEFAULTS = {
|
||||
const DEFAULTS: RiggedConfig = {
|
||||
daemon: { port: 7433, host: "127.0.0.1" },
|
||||
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
|
||||
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts"), lines: 1000, pollIntervalSeconds: 2 },
|
||||
workspace: {
|
||||
root: DEFAULT_WORKSPACE_ROOT,
|
||||
slicesRoot: "",
|
||||
steeringPath: "",
|
||||
fieldNotesRoot: "",
|
||||
specsRoot: "",
|
||||
dogfoodEvidenceRoot: "",
|
||||
// V0.3.1 slice 05 — empty default; resolve() picks up the derived
|
||||
// `operator-${USER}@kernel` at read time via the deriveWorkspaceDefault
|
||||
// helper so the default tracks the OS username without caching at
|
||||
// module-load time.
|
||||
operatorSeatName: "",
|
||||
},
|
||||
files: { allowlist: "" },
|
||||
progress: { scanRoots: "" },
|
||||
ui: {
|
||||
preview: {
|
||||
refreshIntervalSeconds: 3,
|
||||
maxPins: 4,
|
||||
defaultLines: 50,
|
||||
},
|
||||
},
|
||||
recovery: {
|
||||
autoDriveProviderPrompts: false,
|
||||
providerAuthEnvAllowlist: "",
|
||||
},
|
||||
// V1 Phase 4 — Advisor default per universal-shell.md L83;
|
||||
// Operator default empty per L84 ("not configured").
|
||||
agents: {
|
||||
advisorSession: "advisor-lead@openrig-velocity",
|
||||
operatorSession: "",
|
||||
},
|
||||
// V1 Phase 5 P5-3 — feed subscription defaults per for-you-feed.md
|
||||
// L144–L151. action_required forced ON in UI (load-bearing
|
||||
// human-gate items per L145; cannot be disabled); approvals/
|
||||
// shipped/progress default ON; audit_log default OFF (verbose;
|
||||
// opt-in for triage runs).
|
||||
feed: {
|
||||
subscriptions: {
|
||||
actionRequired: true,
|
||||
approvals: true,
|
||||
shipped: true,
|
||||
progress: true,
|
||||
auditLog: false,
|
||||
},
|
||||
},
|
||||
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag default ON.
|
||||
runtime: {
|
||||
codex: {
|
||||
hooksEnabled: true,
|
||||
},
|
||||
},
|
||||
// Slice 27 — opt-in default-off. Threshold default 80% per spec.
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: false,
|
||||
thresholdPercent: 80,
|
||||
preCompactInstruction: DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION,
|
||||
compactInstruction: DEFAULT_CLAUDE_COMPACTION_COMPACT_INSTRUCTION,
|
||||
messageInline: DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION,
|
||||
messageFilePath: DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH,
|
||||
postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts") },
|
||||
};
|
||||
|
||||
export const VALID_KEYS = [
|
||||
const VALID_KEYS = [
|
||||
"daemon.port",
|
||||
"daemon.host",
|
||||
"db.path",
|
||||
"transcripts.enabled",
|
||||
"transcripts.path",
|
||||
// V1 pre-release CLI/daemon Item 1 — SC-29 EXCEPTION #4 allowlist
|
||||
// sub-piece: transcript rotation tunables (line count + poll interval).
|
||||
"transcripts.lines",
|
||||
"transcripts.poll_interval_seconds",
|
||||
"workspace.root",
|
||||
"workspace.slices_root",
|
||||
"workspace.steering_path",
|
||||
"workspace.field_notes_root",
|
||||
"workspace.specs_root",
|
||||
"workspace.dogfood_evidence_root",
|
||||
"files.allowlist",
|
||||
"progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds",
|
||||
"ui.preview.max_pins",
|
||||
"ui.preview.default_lines",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 Phase 4 SC-29 exception — allowlist-only additions.
|
||||
"agents.advisor_session",
|
||||
"agents.operator_session",
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — operator seat name used by
|
||||
// mission-control read layer + 2 UI sites; default
|
||||
// `operator-${USER}@kernel` derived in resolve(). OPENRIG_* only;
|
||||
// no RIGGED_* legacy alias.
|
||||
"workspace.operator_seat_name",
|
||||
// V1 Phase 5 P5-3 SC-29 exception — allowlist-only additions.
|
||||
"feed.subscriptions.action_required",
|
||||
"feed.subscriptions.approvals",
|
||||
"feed.subscriptions.shipped",
|
||||
"feed.subscriptions.progress",
|
||||
"feed.subscriptions.audit_log",
|
||||
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
|
||||
"runtime.codex.hooks_enabled",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
|
||||
// 7 ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.pre_compact_instruction",
|
||||
"policies.claude_compaction.compact_instruction",
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
] as const;
|
||||
|
||||
export type ValidKey = typeof VALID_KEYS[number];
|
||||
type ValidKey = typeof VALID_KEYS[number];
|
||||
|
||||
export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
// Only the original runtime keys keep RIGGED_* aliases for upgrade
|
||||
// compatibility. New typed keys use OPENRIG_* only.
|
||||
const ENV_MAP: Record<ValidKey, { primary: string; legacy: string }> = {
|
||||
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
|
||||
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
|
||||
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
|
||||
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
|
||||
"transcripts.path": { primary: "OPENRIG_TRANSCRIPTS_PATH", legacy: "RIGGED_TRANSCRIPTS_PATH" },
|
||||
"transcripts.lines": { primary: "OPENRIG_TRANSCRIPTS_LINES" },
|
||||
"transcripts.poll_interval_seconds": { primary: "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS" },
|
||||
"workspace.root": { primary: "OPENRIG_WORKSPACE_ROOT" },
|
||||
"workspace.slices_root": { primary: "OPENRIG_WORKSPACE_SLICES_ROOT" },
|
||||
"workspace.steering_path": { primary: "OPENRIG_WORKSPACE_STEERING_PATH" },
|
||||
"workspace.field_notes_root": { primary: "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT" },
|
||||
"workspace.specs_root": { primary: "OPENRIG_WORKSPACE_SPECS_ROOT" },
|
||||
"workspace.dogfood_evidence_root": { primary: "OPENRIG_DOGFOOD_EVIDENCE_ROOT" },
|
||||
// UEP env-var graduation: existing OPENRIG_FILES_ALLOWLIST /
|
||||
// OPENRIG_PROGRESS_SCAN_ROOTS become the env override for the new
|
||||
// typed keys (no breaking change).
|
||||
"files.allowlist": { primary: "OPENRIG_FILES_ALLOWLIST" },
|
||||
"progress.scan_roots": { primary: "OPENRIG_PROGRESS_SCAN_ROOTS" },
|
||||
"ui.preview.refresh_interval_seconds": { primary: "OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS" },
|
||||
"ui.preview.max_pins": { primary: "OPENRIG_UI_PREVIEW_MAX_PINS" },
|
||||
"ui.preview.default_lines": { primary: "OPENRIG_UI_PREVIEW_DEFAULT_LINES" },
|
||||
"recovery.auto_drive_provider_prompts": { primary: "OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS" },
|
||||
"recovery.provider_auth_env_allowlist": { primary: "OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST" },
|
||||
"agents.advisor_session": { primary: "OPENRIG_AGENTS_ADVISOR_SESSION" },
|
||||
"agents.operator_session": { primary: "OPENRIG_AGENTS_OPERATOR_SESSION" },
|
||||
"workspace.operator_seat_name": { primary: "OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME" },
|
||||
"feed.subscriptions.action_required": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED" },
|
||||
"feed.subscriptions.approvals": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS" },
|
||||
"feed.subscriptions.shipped": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED" },
|
||||
"feed.subscriptions.progress": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS" },
|
||||
"feed.subscriptions.audit_log": { primary: "OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG" },
|
||||
// Net-new key post-rename: OPENRIG_X primary only per the 5-key
|
||||
// boundary doctrine (no RIGGED_X legacy on net-new keys).
|
||||
"runtime.codex.hooks_enabled": { primary: "OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED" },
|
||||
// Slice 27 — Claude auto-compaction policy. OPENRIG_X primary only
|
||||
// (net-new keys, no legacy).
|
||||
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
|
||||
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
|
||||
"policies.claude_compaction.pre_compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION" },
|
||||
"policies.claude_compaction.compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_COMPACT_INSTRUCTION" },
|
||||
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
|
||||
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
|
||||
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
|
||||
};
|
||||
|
||||
// Maps dotted-string config keys to the camelCase RiggedConfig path.
|
||||
// Workspace per-subdir keys are stored on disk as `workspace.slices_root`
|
||||
// (snake) and exposed in RiggedConfig as `workspace.slicesRoot` (camel)
|
||||
// to match TS conventions.
|
||||
const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"daemon.port": ["daemon", "port"],
|
||||
"daemon.host": ["daemon", "host"],
|
||||
"db.path": ["db", "path"],
|
||||
"transcripts.enabled": ["transcripts", "enabled"],
|
||||
"transcripts.path": ["transcripts", "path"],
|
||||
"transcripts.lines": ["transcripts", "lines"],
|
||||
"transcripts.poll_interval_seconds": ["transcripts", "pollIntervalSeconds"],
|
||||
"workspace.root": ["workspace", "root"],
|
||||
"workspace.slices_root": ["workspace", "slicesRoot"],
|
||||
"workspace.steering_path": ["workspace", "steeringPath"],
|
||||
"workspace.field_notes_root": ["workspace", "fieldNotesRoot"],
|
||||
"workspace.specs_root": ["workspace", "specsRoot"],
|
||||
"workspace.dogfood_evidence_root": ["workspace", "dogfoodEvidenceRoot"],
|
||||
"files.allowlist": ["files", "allowlist"],
|
||||
"progress.scan_roots": ["progress", "scanRoots"],
|
||||
"ui.preview.refresh_interval_seconds": ["ui", "preview", "refreshIntervalSeconds"],
|
||||
"ui.preview.max_pins": ["ui", "preview", "maxPins"],
|
||||
"ui.preview.default_lines": ["ui", "preview", "defaultLines"],
|
||||
"recovery.auto_drive_provider_prompts": ["recovery", "autoDriveProviderPrompts"],
|
||||
"recovery.provider_auth_env_allowlist": ["recovery", "providerAuthEnvAllowlist"],
|
||||
"agents.advisor_session": ["agents", "advisorSession"],
|
||||
"agents.operator_session": ["agents", "operatorSession"],
|
||||
"workspace.operator_seat_name": ["workspace", "operatorSeatName"],
|
||||
"feed.subscriptions.action_required": ["feed", "subscriptions", "actionRequired"],
|
||||
"feed.subscriptions.approvals": ["feed", "subscriptions", "approvals"],
|
||||
"feed.subscriptions.shipped": ["feed", "subscriptions", "shipped"],
|
||||
"feed.subscriptions.progress": ["feed", "subscriptions", "progress"],
|
||||
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
|
||||
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
|
||||
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
|
||||
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
|
||||
"policies.claude_compaction.pre_compact_instruction": ["policies", "claudeCompaction", "preCompactInstruction"],
|
||||
"policies.claude_compaction.compact_instruction": ["policies", "claudeCompaction", "compactInstruction"],
|
||||
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
|
||||
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
|
||||
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
|
||||
};
|
||||
|
||||
function isValidKey(key: string): key is ValidKey {
|
||||
return (VALID_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
|
||||
function getNestedValue(obj: Record<string, unknown>, key: string): unknown {
|
||||
const parts = key.split(".");
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== "object") return undefined;
|
||||
@@ -361,7 +50,8 @@ function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown
|
||||
return current;
|
||||
}
|
||||
|
||||
function setNestedValue(obj: Record<string, unknown>, parts: string[], value: unknown): void {
|
||||
function setNestedValue(obj: Record<string, unknown>, key: string, value: unknown): void {
|
||||
const parts = key.split(".");
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i]!;
|
||||
@@ -373,58 +63,12 @@ function setNestedValue(obj: Record<string, unknown>, parts: string[], value: un
|
||||
current[parts[parts.length - 1]!] = value;
|
||||
}
|
||||
|
||||
// Per-subdir defaults derived from workspace.root. Steering is a FILE path.
|
||||
// Slice discovery defaults to the mission-aware workspace/missions contract;
|
||||
// the indexer remains backward-compatible with flat slice roots when an
|
||||
// operator sets workspace.slices_root explicitly.
|
||||
// Files and Progress default to the whole workspace so a fresh
|
||||
// `rig config init-workspace` install is browsable without extra env wiring.
|
||||
export function deriveWorkspaceDefault(key: ValidKey, workspaceRoot: string): string {
|
||||
switch (key) {
|
||||
case "workspace.slices_root": return join(workspaceRoot, "missions");
|
||||
case "workspace.steering_path": return join(workspaceRoot, "STEERING.md");
|
||||
case "workspace.field_notes_root": return join(workspaceRoot, "field-notes");
|
||||
case "workspace.specs_root": return join(workspaceRoot, "specs");
|
||||
case "workspace.dogfood_evidence_root": return join(workspaceRoot, "dogfood-evidence");
|
||||
case "files.allowlist": return `workspace:${workspaceRoot}`;
|
||||
case "progress.scan_roots": return `workspace:${workspaceRoot}`;
|
||||
// V0.3.1 slice 05 — `operator-${USER}@kernel` derived from the
|
||||
// OS username at resolve() time. Lockstep with the daemon's
|
||||
// settings-store getDefaultValue("workspace.operator_seat_name")
|
||||
// case so both sides resolve to the same default.
|
||||
case "workspace.operator_seat_name": return `operator-${userInfo().username}@kernel`;
|
||||
default: return "";
|
||||
}
|
||||
function getDefaultValue(key: ValidKey): string | number | boolean {
|
||||
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, key) as string | number | boolean;
|
||||
}
|
||||
|
||||
function deriveLegacyWorkspaceDefault(key: ValidKey, workspaceRoot: string): string | null {
|
||||
switch (key) {
|
||||
case "workspace.slices_root": return join(workspaceRoot, "slices");
|
||||
case "workspace.steering_path": return join(workspaceRoot, "steering", "STEERING.md");
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
const WORKSPACE_DERIVED_KEYS: ReadonlySet<ValidKey> = new Set([
|
||||
"workspace.slices_root",
|
||||
"workspace.steering_path",
|
||||
"workspace.field_notes_root",
|
||||
"workspace.specs_root",
|
||||
"workspace.dogfood_evidence_root",
|
||||
"files.allowlist",
|
||||
"progress.scan_roots",
|
||||
"workspace.operator_seat_name",
|
||||
]);
|
||||
|
||||
function getDefaultValue(key: ValidKey, workspaceRoot: string): string | number | boolean {
|
||||
if (WORKSPACE_DERIVED_KEYS.has(key)) {
|
||||
return deriveWorkspaceDefault(key, workspaceRoot);
|
||||
}
|
||||
return getNestedValue(DEFAULTS as unknown as Record<string, unknown>, KEY_TO_PATH[key]) as string | number | boolean;
|
||||
}
|
||||
|
||||
function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
|
||||
const defaultVal = getDefaultValue(key, workspaceRoot);
|
||||
function coerceValue(key: ValidKey, raw: string): string | number | boolean {
|
||||
const defaultVal = getDefaultValue(key);
|
||||
if (typeof defaultVal === "number") {
|
||||
const n = parseInt(raw, 10);
|
||||
if (isNaN(n)) throw new Error(`Invalid value for ${key}: expected a number, got "${raw}"`);
|
||||
@@ -438,76 +82,6 @@ function coerceValue(key: ValidKey, raw: string, workspaceRoot: string): string
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Slice 27 — strict per-key constraint validators applied AFTER coerceValue
|
||||
// in `set()`. The generic coerce uses parseInt which accepts partial
|
||||
// parses (e.g., "80abc" → 80) and truncates fractions ("80.5" → 80); for
|
||||
// keys with documented range/integer contracts this is unsafe. Per
|
||||
// banked feedback_static_gates_mirror_runtime_validators, the runtime
|
||||
// validator is the source of truth and must reject what the contract
|
||||
// forbids.
|
||||
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
|
||||
// Policy threshold: integer in [1, 100]. Documented contract from
|
||||
// slice 27 README §"What the operator gets" — operator can lower to
|
||||
// e.g. 50 = compact earlier; range is 1-100 inclusive. A value of 0
|
||||
// would fire /compact on every poll tick (catastrophic without
|
||||
// default-off as backstop).
|
||||
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (typeof coerced !== "number" || !Number.isInteger(coerced)) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: expected an integer in [1, 100], got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (coerced < 1 || coerced > 100) {
|
||||
throw new Error(
|
||||
`Invalid value for policies.claude_compaction.threshold_percent: must be in [1, 100], got ${coerced}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
|
||||
const check = KEY_CONSTRAINTS[key];
|
||||
if (check) check(raw, coerced);
|
||||
}
|
||||
|
||||
// Slice 27 BLOCKING-FIX-2 — shared coerce + validate, used by every
|
||||
// surface that turns operator-supplied input into a typed config value:
|
||||
// set() (CLI + daemon write paths), resolveOne env-source branch, and
|
||||
// (indirectly via validateTypedValue) the resolveOne file-source branch.
|
||||
// Per banked feedback_audit_every_layer_function_and_module_constants:
|
||||
// validation must run at every LAYER that ingests untrusted input, not
|
||||
// just at write-time.
|
||||
function coerceAndValidate(key: ValidKey, raw: string, workspaceRoot: string): string | number | boolean {
|
||||
const coerced = coerceValue(key, raw, workspaceRoot);
|
||||
validateKeyConstraints(key, raw, coerced);
|
||||
return coerced;
|
||||
}
|
||||
|
||||
// File-source values arrive already typed (JSON-parsed). The constraint
|
||||
// closure expects (raw, coerced) to drive its regex check too; pass the
|
||||
// stringified value so a string-typed file value (e.g., "80abc" written
|
||||
// directly into config.json) still trips the regex.
|
||||
function validateTypedFileValue(key: ValidKey, value: string | number | boolean): void {
|
||||
const check = KEY_CONSTRAINTS[key];
|
||||
if (!check) return;
|
||||
const raw = typeof value === "string" ? value : String(value);
|
||||
check(raw, value);
|
||||
}
|
||||
|
||||
export type SettingSource = "env" | "file" | "default";
|
||||
|
||||
export interface ResolvedSetting {
|
||||
value: string | number | boolean;
|
||||
source: SettingSource;
|
||||
defaultValue: string | number | boolean;
|
||||
}
|
||||
|
||||
export class ConfigStore {
|
||||
readonly configPath: string;
|
||||
|
||||
@@ -518,192 +92,62 @@ export class ConfigStore {
|
||||
resolve(): RiggedConfig {
|
||||
const fileConfig = this.readConfigFile();
|
||||
|
||||
// Workspace root must be resolved first so derived per-subdir
|
||||
// defaults can use it.
|
||||
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
|
||||
|
||||
const v = (key: ValidKey) =>
|
||||
this.resolveOne(key, fileConfig, workspaceRoot).value;
|
||||
const resolveKey = (key: ValidKey): string | number | boolean => {
|
||||
// 1. Environment variable
|
||||
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
|
||||
if (envVal !== undefined && envVal !== "") {
|
||||
return coerceValue(key, envVal);
|
||||
}
|
||||
// 2. Config file
|
||||
const fileVal = getNestedValue(fileConfig, key);
|
||||
if (fileVal !== undefined && fileVal !== null) {
|
||||
return fileVal as string | number | boolean;
|
||||
}
|
||||
// 3. Default
|
||||
return getDefaultValue(key);
|
||||
};
|
||||
|
||||
return {
|
||||
daemon: {
|
||||
port: v("daemon.port") as number,
|
||||
host: v("daemon.host") as string,
|
||||
port: resolveKey("daemon.port") as number,
|
||||
host: resolveKey("daemon.host") as string,
|
||||
},
|
||||
db: {
|
||||
path: v("db.path") as string,
|
||||
path: resolveKey("db.path") as string,
|
||||
},
|
||||
transcripts: {
|
||||
enabled: v("transcripts.enabled") as boolean,
|
||||
path: v("transcripts.path") as string,
|
||||
lines: v("transcripts.lines") as number,
|
||||
pollIntervalSeconds: v("transcripts.poll_interval_seconds") as number,
|
||||
},
|
||||
workspace: {
|
||||
root: workspaceRoot,
|
||||
slicesRoot: v("workspace.slices_root") as string,
|
||||
steeringPath: v("workspace.steering_path") as string,
|
||||
fieldNotesRoot: v("workspace.field_notes_root") as string,
|
||||
specsRoot: v("workspace.specs_root") as string,
|
||||
dogfoodEvidenceRoot: v("workspace.dogfood_evidence_root") as string,
|
||||
operatorSeatName: v("workspace.operator_seat_name") as string,
|
||||
},
|
||||
files: {
|
||||
allowlist: v("files.allowlist") as string,
|
||||
},
|
||||
progress: {
|
||||
scanRoots: v("progress.scan_roots") as string,
|
||||
},
|
||||
ui: {
|
||||
preview: {
|
||||
refreshIntervalSeconds: v("ui.preview.refresh_interval_seconds") as number,
|
||||
maxPins: v("ui.preview.max_pins") as number,
|
||||
defaultLines: v("ui.preview.default_lines") as number,
|
||||
},
|
||||
},
|
||||
recovery: {
|
||||
autoDriveProviderPrompts: v("recovery.auto_drive_provider_prompts") as boolean,
|
||||
providerAuthEnvAllowlist: v("recovery.provider_auth_env_allowlist") as string,
|
||||
},
|
||||
agents: {
|
||||
advisorSession: v("agents.advisor_session") as string,
|
||||
operatorSession: v("agents.operator_session") as string,
|
||||
},
|
||||
feed: {
|
||||
subscriptions: {
|
||||
actionRequired: v("feed.subscriptions.action_required") as boolean,
|
||||
approvals: v("feed.subscriptions.approvals") as boolean,
|
||||
shipped: v("feed.subscriptions.shipped") as boolean,
|
||||
progress: v("feed.subscriptions.progress") as boolean,
|
||||
auditLog: v("feed.subscriptions.audit_log") as boolean,
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
codex: {
|
||||
hooksEnabled: v("runtime.codex.hooks_enabled") as boolean,
|
||||
},
|
||||
},
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
enabled: v("policies.claude_compaction.enabled") as boolean,
|
||||
thresholdPercent: v("policies.claude_compaction.threshold_percent") as number,
|
||||
preCompactInstruction: v("policies.claude_compaction.pre_compact_instruction") as string,
|
||||
compactInstruction: v("policies.claude_compaction.compact_instruction") as string,
|
||||
messageInline: v("policies.claude_compaction.message_inline") as string,
|
||||
messageFilePath: v("policies.claude_compaction.message_file_path") as string,
|
||||
postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string,
|
||||
},
|
||||
enabled: resolveKey("transcripts.enabled") as boolean,
|
||||
path: resolveKey("transcripts.path") as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single key with its source. Used by the daemon HTTP route
|
||||
* + UI Settings panel for honest provenance display (env / file /
|
||||
* default).
|
||||
*/
|
||||
resolveWithSource(key: string): ResolvedSetting {
|
||||
get(key: string): string | number | boolean {
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
const fileConfig = this.readConfigFile();
|
||||
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
|
||||
return this.resolveOne(key, fileConfig, workspaceRoot);
|
||||
}
|
||||
|
||||
/** Resolve all valid keys with sources. Convenience for the UI. */
|
||||
resolveAllWithSource(): Record<ValidKey, ResolvedSetting> {
|
||||
const fileConfig = this.readConfigFile();
|
||||
const workspaceRoot = this.resolveOne("workspace.root", fileConfig, DEFAULT_WORKSPACE_ROOT).value as string;
|
||||
const out = {} as Record<ValidKey, ResolvedSetting>;
|
||||
for (const key of VALID_KEYS) {
|
||||
out[key] = this.resolveOne(key, fileConfig, workspaceRoot);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
|
||||
const defaultValue = getDefaultValue(key, workspaceRoot);
|
||||
// 1. Environment variable — validate. On invalid env, drop the
|
||||
// override and fall through to file/default (safer-failure than
|
||||
// crashing or accepting a bad value). Warn so the operator sees
|
||||
// the misconfigured env on stderr.
|
||||
const envVal = readOpenRigEnv(ENV_MAP[key].primary, ENV_MAP[key].legacy);
|
||||
if (envVal !== undefined && envVal !== "") {
|
||||
try {
|
||||
return { value: coerceAndValidate(key, envVal, workspaceRoot), source: "env", defaultValue };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`[openrig-config] env override for ${key} rejected: ${reason}; falling back to file/default\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2. Config file
|
||||
const fileVal = getNestedValue(fileConfig, KEY_TO_PATH[key]);
|
||||
if (fileVal !== undefined && fileVal !== null && fileVal !== "") {
|
||||
const legacyDefault = deriveLegacyWorkspaceDefault(key, workspaceRoot);
|
||||
if (legacyDefault !== null && fileVal === legacyDefault) {
|
||||
return { value: defaultValue, source: "default", defaultValue };
|
||||
}
|
||||
// Validate the file-source value too. Hand-edited config.json with
|
||||
// a bad threshold (e.g. thresholdPercent: 0 or "80abc") falls back
|
||||
// to default rather than poisoning the trigger contract.
|
||||
try {
|
||||
validateTypedFileValue(key, fileVal as string | number | boolean);
|
||||
return { value: fileVal as string | number | boolean, source: "file", defaultValue };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`[openrig-config] file value for ${key} rejected: ${reason}; falling back to default\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 3. Default
|
||||
return { value: defaultValue, source: "default", defaultValue };
|
||||
}
|
||||
|
||||
get(key: string): string | number | boolean {
|
||||
return this.resolveWithSource(key).value;
|
||||
const config = this.resolve();
|
||||
return getNestedValue(config as unknown as Record<string, unknown>, key) as string | number | boolean;
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
const coerced = coerceValue(key, value);
|
||||
// readConfigFile (not Raw) — throws on malformed config so set doesn't silently overwrite
|
||||
const fileConfig = this.readConfigFile();
|
||||
const workspaceRoot = (getNestedValue(fileConfig, KEY_TO_PATH["workspace.root"]) as string | undefined)
|
||||
|| readOpenRigEnv(ENV_MAP["workspace.root"].primary, ENV_MAP["workspace.root"].legacy)
|
||||
|| DEFAULT_WORKSPACE_ROOT;
|
||||
const coerced = coerceAndValidate(key, value, workspaceRoot);
|
||||
setNestedValue(fileConfig, KEY_TO_PATH[key], coerced);
|
||||
setNestedValue(fileConfig, key, coerced);
|
||||
mkdirSync(dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear an override. Without a key argument, deletes the whole config
|
||||
* file (revert all to defaults). With a key, removes just that key
|
||||
* from the config file.
|
||||
*/
|
||||
reset(key?: string): void {
|
||||
if (key === undefined) {
|
||||
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
|
||||
return;
|
||||
reset(): void {
|
||||
try {
|
||||
unlinkSync(this.configPath);
|
||||
} catch {
|
||||
// File doesn't exist — that's fine
|
||||
}
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
if (!existsSync(this.configPath)) return;
|
||||
const fileConfig = this.readConfigFile();
|
||||
const parts = KEY_TO_PATH[key];
|
||||
const parentParts = parts.slice(0, -1);
|
||||
const leaf = parts[parts.length - 1]!;
|
||||
const parent = getNestedValue(fileConfig, parentParts) as Record<string, unknown> | undefined;
|
||||
if (parent && leaf in parent) {
|
||||
delete parent[leaf];
|
||||
}
|
||||
writeFileSync(this.configPath, JSON.stringify(fileConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
private readConfigFile(): Record<string, unknown> {
|
||||
@@ -717,33 +161,5 @@ export class ConfigStore {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Settings v0: named-pair decoder ---
|
||||
//
|
||||
// `files.allowlist` and `progress.scan_roots` are stored as the same
|
||||
// comma-separated `name:/abs/path` strings UEP introduced via env var.
|
||||
// This helper decodes the raw string into structured pairs. Invalid
|
||||
// entries (no colon, empty name/path) are silently skipped per UEP
|
||||
// convention; duplicate names: last wins.
|
||||
|
||||
export interface NamedPair {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function parseNamedPairs(raw: string): NamedPair[] {
|
||||
if (!raw || !raw.trim()) return [];
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(",")) {
|
||||
const trimmed = pair.trim();
|
||||
if (!trimmed) continue;
|
||||
const colon = trimmed.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const name = trimmed.slice(0, colon).trim();
|
||||
const rawPath = trimmed.slice(colon + 1).trim();
|
||||
if (!name || !rawPath) continue;
|
||||
out.set(name, rawPath);
|
||||
}
|
||||
return Array.from(out.entries()).map(([name, path]) => ({ name, path }));
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { existsSync } from "node:fs";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { workspaceScaffoldDirs, workspaceScaffoldFiles } from "./commands/config-init-workspace.js";
|
||||
|
||||
export interface DaemonState {
|
||||
pid: number;
|
||||
@@ -32,18 +31,6 @@ export interface StartOptions {
|
||||
db?: string;
|
||||
transcriptsEnabled?: boolean;
|
||||
transcriptsPath?: string;
|
||||
/** When set, daemon startup idempotently ensures the default workspace exists. */
|
||||
workspaceRoot?: string;
|
||||
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
|
||||
// Threaded through to the daemon process env so the rotation hook
|
||||
// picks up file-stored ConfigStore values, not just shell env.
|
||||
transcriptsLines?: number;
|
||||
transcriptsPollIntervalSeconds?: number;
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — when true, daemon startup
|
||||
// skips the kernel auto-boot path (kernel rig is not materialized).
|
||||
// Exposed at the CLI as `rig daemon start --no-kernel`; projected
|
||||
// into the daemon process env as OPENRIG_NO_KERNEL.
|
||||
skipKernelBoot?: boolean;
|
||||
}
|
||||
|
||||
export interface LifecycleDeps {
|
||||
@@ -77,45 +64,6 @@ const HEALTHZ_RETRIES = 20;
|
||||
const HEALTHZ_DELAY_MS = 250;
|
||||
const HEALTHZ_PROBE_TIMEOUT_MS = 250;
|
||||
|
||||
export interface WorkspaceScaffoldResult {
|
||||
root: string;
|
||||
rootCreated: boolean;
|
||||
subdirs: Array<{ name: string; path: string; created: boolean }>;
|
||||
files: Array<{ relPath: string; absPath: string; created: boolean; skipped: "exists" | null }>;
|
||||
}
|
||||
|
||||
export function ensureWorkspaceScaffold(root: string, deps: LifecycleDeps): WorkspaceScaffoldResult {
|
||||
const rootExists = deps.exists(root);
|
||||
if (!rootExists) deps.mkdirp(root);
|
||||
|
||||
const result: WorkspaceScaffoldResult = {
|
||||
root,
|
||||
rootCreated: !rootExists,
|
||||
subdirs: [],
|
||||
files: [],
|
||||
};
|
||||
|
||||
for (const sub of workspaceScaffoldDirs()) {
|
||||
const subPath = path.join(root, sub);
|
||||
const existed = deps.exists(subPath);
|
||||
if (!existed) deps.mkdirp(subPath);
|
||||
result.subdirs.push({ name: sub, path: subPath, created: !existed });
|
||||
}
|
||||
|
||||
for (const file of workspaceScaffoldFiles()) {
|
||||
const absPath = path.join(root, file.relPath);
|
||||
const existed = deps.exists(absPath);
|
||||
if (existed) {
|
||||
result.files.push({ relPath: file.relPath, absPath, created: false, skipped: "exists" });
|
||||
continue;
|
||||
}
|
||||
deps.writeFile(absPath, file.content);
|
||||
result.files.push({ relPath: file.relPath, absPath, created: true, skipped: null });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class HealthProbeTimeoutError extends Error {
|
||||
constructor(url: string) {
|
||||
super(`healthz probe timed out for ${url}`);
|
||||
@@ -245,31 +193,7 @@ const ENV_SCRUB_EXACT = new Set([
|
||||
|
||||
export function buildDaemonEnv(
|
||||
baseEnv: Record<string, string>,
|
||||
opts: {
|
||||
port: number;
|
||||
/**
|
||||
* Operator-explicit bind host. When undefined the CLI is signaling
|
||||
* "no operator opt-in" so the daemon's index.ts can fall through to
|
||||
* the default loopback+tailscale-auto multi-bind path (bug-fix slice
|
||||
* auth-bearer-tailscale-trust). When defined, the daemon takes the
|
||||
* explicit-host branch and applies the bearer invariant to it.
|
||||
* If undefined here AND the operator shell already has OPENRIG_HOST
|
||||
* set in baseEnv, the env-passthrough loop preserves that value
|
||||
* (their shell-level opt-in still wins).
|
||||
*/
|
||||
host?: string;
|
||||
db: string;
|
||||
transcriptsEnabled?: boolean;
|
||||
transcriptsPath?: string;
|
||||
// V1 pre-release CLI/daemon Item 1 — projected from ConfigStore so
|
||||
// the rotation hook honors file-stored values, not just inherited
|
||||
// shell env.
|
||||
transcriptsLines?: number;
|
||||
transcriptsPollIntervalSeconds?: number;
|
||||
// V0.3.1 slice 05 — projected via OPENRIG_NO_KERNEL env var so
|
||||
// the daemon's startup.ts kernel-boot path honors the flag.
|
||||
skipKernelBoot?: boolean;
|
||||
},
|
||||
opts: { port: number; host: string; db: string; transcriptsEnabled?: boolean; transcriptsPath?: string },
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
@@ -281,9 +205,7 @@ export function buildDaemonEnv(
|
||||
|
||||
// Explicit OPENRIG_* overrides — always win over inherited values
|
||||
env["OPENRIG_PORT"] = String(opts.port);
|
||||
if (opts.host !== undefined) {
|
||||
env["OPENRIG_HOST"] = opts.host;
|
||||
}
|
||||
env["OPENRIG_HOST"] = opts.host;
|
||||
env["OPENRIG_DB"] = opts.db;
|
||||
if (opts.transcriptsEnabled !== undefined) {
|
||||
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
|
||||
@@ -291,30 +213,14 @@ export function buildDaemonEnv(
|
||||
if (opts.transcriptsPath) {
|
||||
env["OPENRIG_TRANSCRIPTS_PATH"] = opts.transcriptsPath;
|
||||
}
|
||||
if (opts.transcriptsLines !== undefined) {
|
||||
env["OPENRIG_TRANSCRIPTS_LINES"] = String(opts.transcriptsLines);
|
||||
}
|
||||
if (opts.transcriptsPollIntervalSeconds !== undefined) {
|
||||
env["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"] = String(opts.transcriptsPollIntervalSeconds);
|
||||
}
|
||||
if (opts.skipKernelBoot) {
|
||||
env["OPENRIG_NO_KERNEL"] = "1";
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const host = opts.host ?? DEFAULT_HOST;
|
||||
const db = opts.db ?? DEFAULT_DB;
|
||||
// bug-fix slice auth-bearer-tailscale-trust: preserve the
|
||||
// user-explicit-vs-default distinction. `opts.host` is undefined when
|
||||
// the operator never opted in; in that case healthz probes still use
|
||||
// the loopback default, but buildDaemonEnv must NOT export
|
||||
// OPENRIG_HOST so the daemon falls through to the multi-bind default
|
||||
// (loopback + tailscale auto-detect).
|
||||
const explicitHost = opts.host;
|
||||
const probeHost = explicitHost ?? DEFAULT_HOST;
|
||||
|
||||
// Check if already running
|
||||
const existing = readState(deps);
|
||||
@@ -331,7 +237,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
} else {
|
||||
let recoveredRunning = false;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${probeHost}:${port}/healthz`);
|
||||
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
|
||||
recoveredRunning = true;
|
||||
} catch (err) {
|
||||
if (err instanceof HealthProbeTimeoutError) {
|
||||
@@ -345,22 +251,16 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
|
||||
|
||||
deps.mkdirp(OPENRIG_DIR);
|
||||
if (opts.workspaceRoot) {
|
||||
ensureWorkspaceScaffold(opts.workspaceRoot, deps);
|
||||
}
|
||||
|
||||
const logFd = deps.openForAppend(LOG_FILE);
|
||||
|
||||
const child = deps.spawn(process.execPath, [daemonEntry], {
|
||||
env: buildDaemonEnv(process.env as Record<string, string>, {
|
||||
port,
|
||||
host: explicitHost,
|
||||
host,
|
||||
db,
|
||||
transcriptsEnabled: opts.transcriptsEnabled,
|
||||
transcriptsPath: opts.transcriptsPath,
|
||||
transcriptsLines: opts.transcriptsLines,
|
||||
transcriptsPollIntervalSeconds: opts.transcriptsPollIntervalSeconds,
|
||||
skipKernelBoot: opts.skipKernelBoot,
|
||||
}),
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
detached: true,
|
||||
@@ -370,9 +270,8 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
|
||||
const pid = child.pid!;
|
||||
|
||||
// Poll healthz against the configured probe host (loopback default
|
||||
// when operator didn't opt in to a specific bind).
|
||||
const healthzUrl = `http://${probeHost}:${port}/healthz`;
|
||||
// Poll healthz
|
||||
const healthzUrl = `http://${host}:${port}/healthz`;
|
||||
let healthy = false;
|
||||
for (let i = 0; i < HEALTHZ_RETRIES; i++) {
|
||||
try {
|
||||
@@ -396,10 +295,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
const state: DaemonState = {
|
||||
pid,
|
||||
port,
|
||||
// Persist the probe host (operator-explicit when set, loopback
|
||||
// default otherwise) so subsequent status reads can reach the
|
||||
// daemon regardless of whether tailscale was added at multi-bind.
|
||||
host: probeHost,
|
||||
host,
|
||||
db,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -536,59 +432,3 @@ async function fetchDaemonProbe(deps: LifecycleDeps, url: string): Promise<{ ok:
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — forward-fix #3 architectural.
|
||||
// Poll GET /api/kernel/status until kernel_state reaches ready or
|
||||
// partial_ready (the two "operator can use the kernel" states) or the
|
||||
// timeout elapses. Used by `rig daemon start --wait-for-kernel`.
|
||||
//
|
||||
// Returns { ok: true, ... } on success; { ok: false, ... } with the
|
||||
// last observed kernelState + detail for the CLI's 3-part error.
|
||||
export interface KernelReadyResult {
|
||||
ok: boolean;
|
||||
kernelState: string | null;
|
||||
variant: string | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export async function waitForKernelReady(
|
||||
baseUrl: string,
|
||||
timeoutMs: number,
|
||||
pollIntervalMs = 500,
|
||||
): Promise<KernelReadyResult> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: KernelReadyResult = { ok: false, kernelState: null, variant: null, detail: null };
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/kernel/status`);
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as {
|
||||
kernel_state?: string;
|
||||
variant?: string | null;
|
||||
detail?: string | null;
|
||||
};
|
||||
last = {
|
||||
ok: body.kernel_state === "ready" || body.kernel_state === "partial_ready",
|
||||
kernelState: body.kernel_state ?? null,
|
||||
variant: body.variant ?? null,
|
||||
detail: body.detail ?? null,
|
||||
};
|
||||
if (last.ok) return last;
|
||||
// Terminal non-ready states: don't keep polling.
|
||||
if (
|
||||
body.kernel_state === "auth_blocked" ||
|
||||
body.kernel_state === "spec_missing" ||
|
||||
body.kernel_state === "bootstrap_failed" ||
|
||||
body.kernel_state === "degraded" ||
|
||||
body.kernel_state === "skipped"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Transient fetch failure; keep polling until the deadline.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export function defaultHostRegistryPath(): string {
|
||||
* - id: vm-claude-test
|
||||
* transport: ssh
|
||||
* target: vm-claude-test.local
|
||||
* user: your-username # optional
|
||||
* user: wrandom # optional
|
||||
* notes: "Tart VM" # optional
|
||||
*
|
||||
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
|
||||
|
||||
@@ -17,7 +17,6 @@ import { attachCommand } from "./commands/attach.js";
|
||||
import { bindCommand } from "./commands/bind.js";
|
||||
import { adoptCommand, type AdoptDeps } from "./commands/adopt.js";
|
||||
import { bundleCommand } from "./commands/bundle.js";
|
||||
import { scopeCommand } from "./commands/scope.js";
|
||||
import { upCommand } from "./commands/up.js";
|
||||
import { downCommand } from "./commands/down.js";
|
||||
import { psCommand } from "./commands/ps.js";
|
||||
@@ -42,10 +41,6 @@ import { envCommand } from "./commands/env.js";
|
||||
import { askCommand } from "./commands/ask.js";
|
||||
import { chatroomCommand } from "./commands/chatroom.js";
|
||||
import { specsCommand } from "./commands/specs.js";
|
||||
import { contextPackCommand } from "./commands/context-pack.js";
|
||||
import { pluginCommand } from "./commands/plugin.js";
|
||||
import { agentImageCommand } from "./commands/agent-image.js";
|
||||
import { workspaceCommand, type WorkspaceDeps } from "./commands/workspace.js";
|
||||
import { whoamiCommand } from "./commands/whoami.js";
|
||||
import { unclaimCommand } from "./commands/unclaim.js";
|
||||
import { releaseCommand } from "./commands/release.js";
|
||||
@@ -98,10 +93,6 @@ export interface ProgramDeps {
|
||||
askDeps?: StatusDeps;
|
||||
chatroomDeps?: StatusDeps;
|
||||
specsDeps?: StatusDeps;
|
||||
contextPackDeps?: StatusDeps;
|
||||
pluginDeps?: StatusDeps;
|
||||
agentImageDeps?: StatusDeps;
|
||||
workspaceDeps?: WorkspaceDeps;
|
||||
whoamiDeps?: StatusDeps;
|
||||
expandDeps?: StatusDeps;
|
||||
envDeps?: StatusDeps;
|
||||
@@ -160,10 +151,6 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(askCommand(depsOverride?.askDeps));
|
||||
program.addCommand(chatroomCommand(depsOverride?.chatroomDeps));
|
||||
program.addCommand(specsCommand(depsOverride?.specsDeps));
|
||||
program.addCommand(contextPackCommand(depsOverride?.contextPackDeps));
|
||||
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
|
||||
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
|
||||
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
|
||||
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
|
||||
program.addCommand(configCommand(depsOverride?.configPath));
|
||||
program.addCommand(preflightCommand());
|
||||
@@ -183,8 +170,6 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
// release-0.3.2 slice 12 — rig scope CLI primitive.
|
||||
program.addCommand(scopeCommand());
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Target
|
||||
|
||||
[What is being deprecated]
|
||||
|
||||
## Current state
|
||||
|
||||
[How it works today; what depends on it]
|
||||
|
||||
## Migration
|
||||
|
||||
[Path off the deprecated thing]
|
||||
|
||||
## Removal
|
||||
|
||||
[When/how the deprecated thing gets deleted]
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Current state
|
||||
|
||||
[How the code/system looks today]
|
||||
|
||||
## Cost
|
||||
|
||||
[What this debt costs us — surface friction, bug class, perf, etc]
|
||||
|
||||
## Proposal
|
||||
|
||||
[How to pay it down]
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Repro
|
||||
|
||||
[Steps to reproduce]
|
||||
|
||||
## Expected
|
||||
|
||||
[Expected behavior]
|
||||
|
||||
## Actual
|
||||
|
||||
[Actual behavior]
|
||||
|
||||
## Impact
|
||||
|
||||
[Who is affected; severity]
|
||||
|
||||
## Fix proposal
|
||||
|
||||
[Optional: thinking on the fix]
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
mission: {{mission}}
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Mission — {{title}}
|
||||
|
||||
## Purpose
|
||||
|
||||
[Why this mission exists and what success looks like]
|
||||
|
||||
## Slices
|
||||
|
||||
[List notable slices and their state]
|
||||
|
||||
## Status
|
||||
|
||||
[Where the mission is right now]
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
mission: {{mission}}
|
||||
release: {{release_version}}
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Release {{release_version}} — {{title}}
|
||||
|
||||
## Theme
|
||||
|
||||
[What this release is about — the coherent bet]
|
||||
|
||||
## Scope
|
||||
|
||||
[What ships in v{{release_version}} vs what waits]
|
||||
|
||||
## Slices
|
||||
|
||||
[Ordering + dependencies; PROGRESS.md tracks state]
|
||||
|
||||
## Acceptance
|
||||
|
||||
[Release-level done definition]
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Goal
|
||||
|
||||
[1-2 sentence goal.]
|
||||
|
||||
## Source material
|
||||
|
||||
- [Paths or refs]
|
||||
|
||||
## Status
|
||||
|
||||
- TODO: [next steps]
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [Cross-slice / cross-release]
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [3-5 bullets]
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Goal
|
||||
|
||||
[What this feature ships and why now]
|
||||
|
||||
## Scope
|
||||
|
||||
[In / out for v0]
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [3-5 bullets]
|
||||
|
||||
## Risks
|
||||
|
||||
[Known unknowns]
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Question
|
||||
|
||||
[The thing we are trying to learn]
|
||||
|
||||
## Source
|
||||
|
||||
[Where the answer is most likely to come from]
|
||||
|
||||
## Findings
|
||||
|
||||
[Captured as work progresses]
|
||||
@@ -1,173 +0,0 @@
|
||||
// release-0.3.2 slice 12 — dot-ID grammar per
|
||||
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
|
||||
//
|
||||
// Grammar (v0 single-project):
|
||||
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
|
||||
// mission = <PFX>.<ver>
|
||||
// slice = <PFX>.<ver>.<n>
|
||||
// sub = <PFX>.<ver>.<n>.<m>
|
||||
//
|
||||
// Version inference from mission folder name:
|
||||
// release-X.Y[.Z] → "X.Y[.Z]" (release train)
|
||||
// anything else → "99.0.<m>" escape band (uniform-numeric;
|
||||
// NO alpha — convention §1 explicit)
|
||||
//
|
||||
// The escape-band <m> is assigned by scanning peer non-release missions
|
||||
// for an existing id and picking max+1. Releases keep their semver.
|
||||
|
||||
import type { DotId } from "./types.js";
|
||||
import { ScopeCliError } from "./types.js";
|
||||
|
||||
/** Default project prefix for the OpenRig single-project workspace.
|
||||
* Multi-project work waits for a real second project (convention §1). */
|
||||
export const DEFAULT_PROJECT_PREFIX = "OPR";
|
||||
|
||||
const RELEASE_NAME_RE = /^release-(\d+\.\d+(?:\.\d+)?)$/;
|
||||
// Strict positional grammar: every segment is a number except for the
|
||||
// 2-3 letter project prefix at position 0. Accepts 2-5 numeric
|
||||
// segments after the prefix (mission has 2-3, slice has 3-4, sub-slice
|
||||
// has 4-5). Tier-aware parse helpers below split version/n/m correctly.
|
||||
const DOT_ID_RE = /^([A-Z]{2,3})\.(\d+(?:\.\d+){1,4})$/;
|
||||
|
||||
/** Parse a dot-ID string into structured parts; returns null when the
|
||||
* string doesn't conform to the §1 positional grammar. When `tier`
|
||||
* is supplied, the trailing segments are peeled off into `n` (slice)
|
||||
* or `n` + `m` (sub-slice). With no tier, all numeric segments are
|
||||
* returned as `version` for backwards compatibility. */
|
||||
export function parseDotId(
|
||||
raw: string,
|
||||
tier?: "mission" | "slice" | "sub-slice",
|
||||
): DotId | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
const m = DOT_ID_RE.exec(trimmed);
|
||||
if (!m) return null;
|
||||
const project = m[1]!;
|
||||
const segments = m[2]!.split(".");
|
||||
if (!tier) {
|
||||
return { project, version: segments.join("."), n: undefined, m: undefined };
|
||||
}
|
||||
if (tier === "mission") {
|
||||
return { project, version: segments.join("."), n: undefined, m: undefined };
|
||||
}
|
||||
if (tier === "slice") {
|
||||
if (segments.length < 3) return null; // slice = ver(>=2) + n
|
||||
const verSegs = segments.slice(0, -1);
|
||||
const nSeg = segments[segments.length - 1]!;
|
||||
return { project, version: verSegs.join("."), n: Number(nSeg), m: undefined };
|
||||
}
|
||||
// sub-slice
|
||||
if (segments.length < 4) return null;
|
||||
const verSegs = segments.slice(0, -2);
|
||||
const nSeg = segments[segments.length - 2]!;
|
||||
const mSeg = segments[segments.length - 1]!;
|
||||
return { project, version: verSegs.join("."), n: Number(nSeg), m: Number(mSeg) };
|
||||
}
|
||||
|
||||
/** Validate a mission-ver segments array against the §1 escape-band
|
||||
* rule. The convention defines the non-release escape band as
|
||||
* `<PFX>.99.0.<n>` — the `0` after `99` is FIXED. A version like
|
||||
* `99.7.8` does NOT conform. Shared between isMissionDotId and the
|
||||
* parent-version check inside isSliceDotId so both surfaces stay in
|
||||
* lockstep. */
|
||||
function isValidMissionVerSegments(segs: string[]): boolean {
|
||||
if (segs.length < 2 || segs.length > 3) return false;
|
||||
if (segs[0] === "99") {
|
||||
// Escape band: exactly [99, "0", "<ordinal>"]. The `0` segment is
|
||||
// fixed; `<ordinal>` must be a non-empty numeric string.
|
||||
return segs.length === 3 && segs[1] === "0" && /^\d+$/.test(segs[2] ?? "");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Depth-based tier discriminator. Per §1 the positional grammar
|
||||
* is fixed:
|
||||
* mission = <PFX>.<ver> (ver = 2-3 numeric segments;
|
||||
* escape band is exactly 99.0.n)
|
||||
* slice = <PFX>.<ver>.<n> (3-4 numeric segments)
|
||||
* sub-slice = <PFX>.<ver>.<n>.<m> (4-5 numeric segments)
|
||||
* Where a mission shape OVERLAPS with a slice shape (e.g. release
|
||||
* X.Y mission has 2 segments; release X.Y.Z mission has 3 segments
|
||||
* which is also the slice shape for release X.Y missions) the tier
|
||||
* is resolved by depth: mission caps at 3 numeric segments; slice
|
||||
* always has 3-4; sub-slice has 4-5. Escape band is fully unambiguous
|
||||
* because §1 fixes both the 99 marker AND the 0 segment that follows. */
|
||||
export function isMissionDotId(raw: unknown): boolean {
|
||||
if (typeof raw !== "string") return false;
|
||||
const parsed = parseDotId(raw, "mission");
|
||||
if (!parsed) return false;
|
||||
return isValidMissionVerSegments(parsed.version.split("."));
|
||||
}
|
||||
|
||||
export function isSliceDotId(raw: unknown): boolean {
|
||||
if (typeof raw !== "string") return false;
|
||||
const parsed = parseDotId(raw, "slice");
|
||||
if (!parsed) return false;
|
||||
// Slice = mission-ver + 1 ordinal. Parent ver must itself be a
|
||||
// valid mission shape (including the escape-band exact-[99.0.n]
|
||||
// rule — a parent like 99.7.8 is not a real mission).
|
||||
return isValidMissionVerSegments(parsed.version.split("."));
|
||||
}
|
||||
|
||||
/** Render a DotId back to its canonical dot-string. */
|
||||
export function formatDotId(id: DotId): string {
|
||||
const parts = [id.project, id.version];
|
||||
if (id.n !== undefined) parts.push(String(id.n));
|
||||
if (id.m !== undefined) parts.push(String(id.m));
|
||||
return parts.join(".");
|
||||
}
|
||||
|
||||
/** Compose a slice ID by appending an ordinal to a parent mission ID. */
|
||||
export function sliceIdFromMission(missionId: string, n: number): string {
|
||||
return `${missionId}.${n}`;
|
||||
}
|
||||
|
||||
/** Infer the (project, version) pair from a mission folder name when
|
||||
* no explicit id is supplied. Release missions use their semver; any
|
||||
* other name falls into the escape band where the caller supplies the
|
||||
* next ordinal via the second arg. */
|
||||
export function inferMissionDotId(
|
||||
missionFolderName: string,
|
||||
escapeBandOrdinal: number | null,
|
||||
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
|
||||
): string {
|
||||
const releaseMatch = RELEASE_NAME_RE.exec(missionFolderName);
|
||||
if (releaseMatch) {
|
||||
return `${projectPrefix}.${releaseMatch[1]}`;
|
||||
}
|
||||
if (escapeBandOrdinal === null) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission "${missionFolderName}" doesn't match the release-X.Y[.Z] pattern and no escape-band ordinal was supplied.`,
|
||||
consequence: "No dot-ID could be inferred for this mission.",
|
||||
action: "Pass an explicit ordinal, or rename the mission to release-X.Y.Z, or supply --id on the CLI.",
|
||||
});
|
||||
}
|
||||
return `${projectPrefix}.99.0.${escapeBandOrdinal}`;
|
||||
}
|
||||
|
||||
/** Pick the next escape-band ordinal by scanning peer mission IDs.
|
||||
* Looks for `<PFX>.99.0.<m>` shapes and returns max(m)+1, starting
|
||||
* at 1 when no peer exists. */
|
||||
export function nextEscapeBandOrdinal(
|
||||
existingIds: ReadonlyArray<string | null>,
|
||||
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
|
||||
): number {
|
||||
let max = 0;
|
||||
const re = new RegExp(`^${projectPrefix}\\.99\\.0\\.(\\d+)$`);
|
||||
for (const id of existingIds) {
|
||||
if (!id) continue;
|
||||
const m = re.exec(id);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
/** Does this candidate string parse as a valid §1 dot-ID at ANY
|
||||
* tier? Prefer the tier-specific isMissionDotId/isSliceDotId at use
|
||||
* sites where the tier is known — depth alone doesn't disambiguate
|
||||
* mission vs slice for release IDs with overlapping shapes. */
|
||||
export function isConformantDotId(raw: unknown): boolean {
|
||||
return typeof raw === "string" && parseDotId(raw) !== null;
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
// release-0.3.2 slice 12 — filesystem + git helpers shared across
|
||||
// `rig scope` verbs. Mission/slice discovery, frontmatter parse/write,
|
||||
// auto-numbering, git mv wrapper. Keeps slice/mission command files
|
||||
// thin.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
|
||||
import type {
|
||||
MissionInfo,
|
||||
SliceInfo,
|
||||
SliceState,
|
||||
} from "./types.js";
|
||||
import { ScopeCliError } from "./types.js";
|
||||
import {
|
||||
DEFAULT_PROJECT_PREFIX,
|
||||
inferMissionDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
} from "./dot-id.js";
|
||||
|
||||
const FRONTMATTER_DELIM = "---\n";
|
||||
const SLICE_DIRNAME_RE = /^(\d+)-(.+)$/;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Frontmatter parse + write
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Split a markdown file into [frontmatter, body]. Returns `[{}, content]`
|
||||
* when no frontmatter delimiter is present. */
|
||||
export function splitFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
} {
|
||||
if (!content.startsWith(FRONTMATTER_DELIM)) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
const rest = content.slice(FRONTMATTER_DELIM.length);
|
||||
const endIdx = rest.indexOf(`\n${FRONTMATTER_DELIM.trim()}\n`);
|
||||
if (endIdx === -1) {
|
||||
// Allow trailing `---` without a final newline (last char of file).
|
||||
const endIdx2 = rest.indexOf(`\n---`);
|
||||
if (endIdx2 === -1) return { frontmatter: {}, body: content };
|
||||
const raw = rest.slice(0, endIdx2);
|
||||
return { frontmatter: parseYamlSafely(raw), body: rest.slice(endIdx2 + 4).replace(/^\n/, "") };
|
||||
}
|
||||
const raw = rest.slice(0, endIdx);
|
||||
const body = rest.slice(endIdx + `\n${FRONTMATTER_DELIM.trim()}\n`.length);
|
||||
return { frontmatter: parseYamlSafely(raw), body };
|
||||
}
|
||||
|
||||
function parseYamlSafely(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = YAML.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-stitch frontmatter + body into a single markdown string. */
|
||||
export function joinFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
body: string,
|
||||
): string {
|
||||
const yaml = YAML.stringify(frontmatter, { lineWidth: 0 }).trim();
|
||||
const trailing = body.startsWith("\n") ? "" : "\n";
|
||||
return `---\n${yaml}\n---\n${trailing}${body}`;
|
||||
}
|
||||
|
||||
/** Read + parse frontmatter from a markdown file. Returns `{}` if the
|
||||
* file is missing OR has no frontmatter (graceful for placeholder
|
||||
* missions that pre-date the convention). */
|
||||
export function readFrontmatter(absPath: string): Record<string, unknown> {
|
||||
try {
|
||||
return splitFrontmatter(fs.readFileSync(absPath, "utf8")).frontmatter;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Update specific keys in a markdown file's frontmatter. Existing
|
||||
* unknown keys are preserved. Generates minimal frontmatter when
|
||||
* absent. */
|
||||
export function updateFrontmatter(
|
||||
absPath: string,
|
||||
updates: Record<string, unknown>,
|
||||
): void {
|
||||
const original = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
|
||||
const { frontmatter, body } = splitFrontmatter(original);
|
||||
const merged = { ...frontmatter, ...updates };
|
||||
fs.writeFileSync(absPath, joinFrontmatter(merged, body), "utf8");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Mission discovery
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Locate the missions root for the working substrate. Resolution
|
||||
* order:
|
||||
* 1. Explicit override (env or arg)
|
||||
* 2. $OPENRIG_WORK_ROOT/missions
|
||||
* 3. Search up from cwd for a `missions/` folder
|
||||
*/
|
||||
export function resolveMissionsRoot(opts: {
|
||||
override?: string | null;
|
||||
cwd?: string;
|
||||
} = {}): string {
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
const fromOverride = opts.override ?? process.env.OPENRIG_WORK_ROOT;
|
||||
if (fromOverride) {
|
||||
const candidate = path.isAbsolute(fromOverride) ? fromOverride : path.resolve(cwd, fromOverride);
|
||||
const missions = path.join(candidate, "missions");
|
||||
if (fs.existsSync(missions) && fs.statSync(missions).isDirectory()) return missions;
|
||||
if (path.basename(candidate) === "missions" && fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
let dir = cwd;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const candidate = path.join(dir, "missions");
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Could not locate a missions/ root from cwd ${cwd}.`,
|
||||
consequence: "No mission tree to operate on.",
|
||||
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/substrate/shared-docs/openrig-work.",
|
||||
});
|
||||
}
|
||||
|
||||
/** List mission folders under the missions root. A mission is any
|
||||
* top-level folder containing a README.md (per IMPLEMENTATION-PRD
|
||||
* §2.1 / HG-8). Directories without a README.md are skipped — they
|
||||
* represent scratch/junk, not declared missions. */
|
||||
export function listMissions(missionsRoot: string): MissionInfo[] {
|
||||
if (!fs.existsSync(missionsRoot)) return [];
|
||||
const entries = fs.readdirSync(missionsRoot, { withFileTypes: true });
|
||||
const out: MissionInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const absPath = path.join(missionsRoot, entry.name);
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
const hasReadme = fs.existsSync(readmePath);
|
||||
if (!hasReadme) continue;
|
||||
const frontmatter = readFrontmatter(readmePath);
|
||||
const slicesDir = path.join(absPath, "slices");
|
||||
const closedDir = path.join(absPath, "closed");
|
||||
const activeSliceCount = countSliceDirs(slicesDir);
|
||||
const closedSliceCount = countSliceDirs(closedDir);
|
||||
const id = pickIdFromFrontmatter(frontmatter);
|
||||
out.push({
|
||||
name: entry.name,
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
id,
|
||||
activeSliceCount,
|
||||
closedSliceCount,
|
||||
});
|
||||
}
|
||||
// Stable order: by mission name.
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
function countSliceDirs(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
|
||||
.length;
|
||||
}
|
||||
|
||||
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
|
||||
const candidate = fm.id ?? fm.dotId;
|
||||
return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
|
||||
}
|
||||
|
||||
/** Resolve a mission by name OR path relative to the missions root.
|
||||
* Throws ScopeCliError on miss OR when the directory exists but
|
||||
* lacks a README.md (consistent with listMissions' README gate per
|
||||
* PRD §2.1 / HG-8 — README-less dirs are scratch/junk, not declared
|
||||
* missions, so mutation commands must not silently target them). */
|
||||
export function findMission(missionsRoot: string, identifier: string): MissionInfo {
|
||||
const candidates = [
|
||||
path.join(missionsRoot, identifier),
|
||||
path.isAbsolute(identifier) ? identifier : path.resolve(missionsRoot, identifier),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
||||
const readmePath = path.join(candidate, "README.md");
|
||||
if (!fs.existsSync(readmePath)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Directory "${identifier}" exists at ${candidate} but contains no README.md.`,
|
||||
consequence: "It is not a declared mission. Command did not run.",
|
||||
action: "Create it as a mission with: rig scope mission create " + identifier + ". Or add a README.md if the folder is intended to be a mission.",
|
||||
});
|
||||
}
|
||||
return buildMissionInfo(missionsRoot, candidate);
|
||||
}
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission "${identifier}" not found under ${missionsRoot}.`,
|
||||
consequence: "Command did not run.",
|
||||
action: "List available missions with: rig scope mission ls",
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
const hasReadme = fs.existsSync(readmePath);
|
||||
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
|
||||
return {
|
||||
name: path.basename(absPath),
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
id: pickIdFromFrontmatter(frontmatter),
|
||||
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
|
||||
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Slice discovery
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function listSlices(
|
||||
mission: MissionInfo,
|
||||
state: SliceState,
|
||||
): SliceInfo[] {
|
||||
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
|
||||
if (state === "active" || state === "shipped" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
|
||||
}
|
||||
if (state === "closed" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
|
||||
}
|
||||
const out: SliceInfo[] = [];
|
||||
for (const { root } of dirs) {
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const m = SLICE_DIRNAME_RE.exec(entry.name);
|
||||
if (!m) continue;
|
||||
const sliceInfo = buildSliceInfo(mission, root, entry.name);
|
||||
out.push(sliceInfo);
|
||||
}
|
||||
}
|
||||
if (state === "active") {
|
||||
// active = not in closed/, but also not shipped status.
|
||||
return out.filter((s) => {
|
||||
const st = (s.status ?? "").toLowerCase();
|
||||
return !st.startsWith("closed") && !st.startsWith("shipped");
|
||||
});
|
||||
}
|
||||
if (state === "shipped") {
|
||||
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildSliceInfo(mission: MissionInfo, sliceRoot: string, dirName: string): SliceInfo {
|
||||
const m = SLICE_DIRNAME_RE.exec(dirName);
|
||||
const nn = m ? Number(m[1]) : null;
|
||||
const slug = m ? m[2]! : null;
|
||||
const absPath = path.join(sliceRoot, dirName);
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
const hasReadme = fs.existsSync(readmePath);
|
||||
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
|
||||
const id = pickIdFromFrontmatter(frontmatter);
|
||||
const status = typeof frontmatter.status === "string" ? (frontmatter.status as string).toLowerCase() : null;
|
||||
return {
|
||||
name: dirName,
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
nn,
|
||||
slug,
|
||||
missionName: mission.name,
|
||||
id,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a slice path (absolute, relative-to-substrate, or
|
||||
* relative-to-mission) into a SliceInfo. */
|
||||
export function findSlice(
|
||||
missionsRoot: string,
|
||||
slicePath: string,
|
||||
hintMission?: string | null,
|
||||
): SliceInfo {
|
||||
const candidates: string[] = [];
|
||||
if (path.isAbsolute(slicePath)) {
|
||||
candidates.push(slicePath);
|
||||
} else {
|
||||
candidates.push(path.resolve(missionsRoot, "..", slicePath));
|
||||
candidates.push(path.resolve(missionsRoot, slicePath));
|
||||
if (hintMission) {
|
||||
candidates.push(path.join(missionsRoot, hintMission, "slices", slicePath));
|
||||
candidates.push(path.join(missionsRoot, hintMission, "closed", slicePath));
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
||||
// Walk up to find the owning mission.
|
||||
const owningMissionPath = findOwningMission(missionsRoot, candidate);
|
||||
if (!owningMissionPath) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice path "${slicePath}" resolved to ${candidate} but no parent mission was found.`,
|
||||
consequence: "Cannot determine mission context for this slice.",
|
||||
action: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/.",
|
||||
});
|
||||
}
|
||||
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
|
||||
const sliceRoot = path.dirname(candidate);
|
||||
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
|
||||
}
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice "${slicePath}" not found.`,
|
||||
consequence: "Command did not run.",
|
||||
action: "Check the path. List slices in a mission with: rig scope slice ls --mission <name>",
|
||||
});
|
||||
}
|
||||
|
||||
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
|
||||
let dir = path.dirname(slicePath);
|
||||
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
|
||||
if (path.dirname(dir) === missionsRoot) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Auto-numbering
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Find the next available NN for a mission's slices/ folder. Scans
|
||||
* BOTH slices/ AND closed/ so numbers are never reused (§3.2). */
|
||||
export function nextSliceNN(missionAbsPath: string): number {
|
||||
let max = 0;
|
||||
for (const subdir of ["slices", "closed"]) {
|
||||
const root = path.join(missionAbsPath, subdir);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const m = SLICE_DIRNAME_RE.exec(entry.name);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
/** Mint or read the dot-ID for a mission. If the mission's README has
|
||||
* an `id:` field, return it. Otherwise infer from the folder name
|
||||
* (release-X.Y.Z → OPR.X.Y.Z; otherwise escape band). */
|
||||
export function ensureMissionId(
|
||||
mission: MissionInfo,
|
||||
missionsRoot: string,
|
||||
): string {
|
||||
if (mission.id) return mission.id;
|
||||
// Try release-pattern first; if it matches, no peer scan needed.
|
||||
try {
|
||||
const id = inferMissionDotId(mission.name, null);
|
||||
return id;
|
||||
} catch {
|
||||
// Fall through to escape-band path.
|
||||
}
|
||||
const peers = listMissions(missionsRoot).filter((m) => m.name !== mission.name);
|
||||
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
|
||||
return inferMissionDotId(mission.name, ordinal);
|
||||
}
|
||||
|
||||
/** Same as ensureMissionId, but ALSO writes the inferred id back into
|
||||
* the mission's README frontmatter when it was absent. Use this at
|
||||
* every site that mints a child id (slice create / ship / move target)
|
||||
* — per the convention's lazy-adoption rule: when a child is created
|
||||
* under a pre-existing parent with no id, assign the parent's id
|
||||
* on-demand at the same time. Narrow, single-parent, create-triggered;
|
||||
* never mass-migration. */
|
||||
export function ensureMissionIdPersisted(
|
||||
mission: MissionInfo,
|
||||
missionsRoot: string,
|
||||
): string {
|
||||
const id = ensureMissionId(mission, missionsRoot);
|
||||
if (!mission.id && mission.readmePath) {
|
||||
updateFrontmatter(mission.readmePath, { id });
|
||||
// Reflect the write on the in-memory MissionInfo so subsequent
|
||||
// callers in the same command don't re-mint a different ordinal.
|
||||
mission.id = id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Git move
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Return the git toplevel for a path, or null if not inside a repo. */
|
||||
export function gitTopLevel(absPath: string): string | null {
|
||||
const dir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory() ? absPath : path.dirname(absPath);
|
||||
try {
|
||||
const out = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Refuse to move a path with uncommitted local changes. Catches the
|
||||
* "dirty working tree" risk in §11.1 / §3.4 — `git mv` would silently
|
||||
* carry along the user's in-progress edits. */
|
||||
export function assertCleanWorkingTree(repoRoot: string, relPath: string): void {
|
||||
let status: string;
|
||||
try {
|
||||
status = execFileSync(
|
||||
"git",
|
||||
["-C", repoRoot, "status", "--porcelain", "--", relPath],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
} catch (err) {
|
||||
throw new ScopeCliError({
|
||||
fact: `git status failed for ${relPath} in ${repoRoot}: ${(err as Error).message}`,
|
||||
consequence: "Could not verify the slice's working-tree state. Move aborted.",
|
||||
action: "Check the repository at " + repoRoot + " and retry.",
|
||||
});
|
||||
}
|
||||
if (status.trim().length > 0) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Working tree under ${relPath} has uncommitted changes:\n${status.trimEnd()}`,
|
||||
consequence: "Refusing to git mv — your local edits would silently move with the slice.",
|
||||
action: "Commit (git commit -m '...') or stash (git stash) and retry.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a directory using `git mv` when inside a repo; fall back to
|
||||
* `fs.renameSync` with a warning when not. Returns true if git was
|
||||
* used. */
|
||||
export function moveSlice(srcAbs: string, destAbs: string, opts: {
|
||||
/** Stage the move (default true). git mv stages by default; we
|
||||
* preserve that semantic. */
|
||||
commit?: boolean;
|
||||
} = {}): { usedGit: boolean; repoRoot: string | null } {
|
||||
if (!fs.existsSync(srcAbs)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Source path ${srcAbs} does not exist.`,
|
||||
consequence: "Move did not run.",
|
||||
action: "Verify the slice path and retry.",
|
||||
});
|
||||
}
|
||||
if (fs.existsSync(destAbs)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Destination path ${destAbs} already exists.`,
|
||||
consequence: "Refusing to overwrite an existing slice.",
|
||||
action: "Pick a different destination, or remove the existing folder first.",
|
||||
});
|
||||
}
|
||||
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
||||
const repoRoot = gitTopLevel(srcAbs);
|
||||
if (!repoRoot) {
|
||||
fs.renameSync(srcAbs, destAbs);
|
||||
return { usedGit: false, repoRoot: null };
|
||||
}
|
||||
// Normalize symlinks (macOS /var/folders → /private/var/folders) so
|
||||
// path.relative produces a path INSIDE the repo, not a ../../escape.
|
||||
const realSrcAbs = fs.realpathSync(srcAbs);
|
||||
const realDestParent = fs.realpathSync(path.dirname(destAbs));
|
||||
const realDestAbs = path.join(realDestParent, path.basename(destAbs));
|
||||
const srcRel = path.relative(repoRoot, realSrcAbs);
|
||||
assertCleanWorkingTree(repoRoot, srcRel);
|
||||
const destRel = path.relative(repoRoot, realDestAbs);
|
||||
try {
|
||||
execFileSync("git", ["-C", repoRoot, "mv", srcRel, destRel], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ScopeCliError({
|
||||
fact: `git mv ${srcRel} ${destRel} failed: ${(err as Error).message}`,
|
||||
consequence: "Move aborted; source unchanged.",
|
||||
action: "Inspect the repo state and retry.",
|
||||
});
|
||||
}
|
||||
if (opts.commit) {
|
||||
// Future hook; v0 doesn't auto-commit.
|
||||
}
|
||||
return { usedGit: true, repoRoot };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Misc
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function todayDateISO(): string {
|
||||
const d = new Date();
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
export { DEFAULT_PROJECT_PREFIX };
|
||||
@@ -1,82 +0,0 @@
|
||||
// release-0.3.2 slice 12 — template loader. Templates live as
|
||||
// markdown files alongside the source so they can be edited like any
|
||||
// other doc. The build copies them to dist/ via tsconfig
|
||||
// rootDir/files behavior — but since .md isn't a .ts file, we read
|
||||
// them directly via fileURLToPath to remain robust across local dev
|
||||
// and the published package layout.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { MissionTemplateKind, SliceTemplateKind } from "./types.js";
|
||||
import { ScopeCliError } from "./types.js";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Candidate template roots, in order: source tree (dev), dist (built).
|
||||
* We resolve at call time so the first existing directory wins. */
|
||||
function candidateRoots(): string[] {
|
||||
return [
|
||||
// Dev: packages/cli/src/lib/scope/ → ../scope-templates
|
||||
path.resolve(here, "..", "scope-templates"),
|
||||
// Built: dist/lib/scope/ → ../../src/lib/scope-templates (one level up further if
|
||||
// dist lands at packages/cli/dist/lib/scope).
|
||||
path.resolve(here, "..", "..", "lib", "scope-templates"),
|
||||
// Fallback: source tree relative to compiled dist when source is co-shipped.
|
||||
path.resolve(here, "..", "..", "..", "src", "lib", "scope-templates"),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveTemplate(filename: string): string {
|
||||
for (const root of candidateRoots()) {
|
||||
const candidate = path.join(root, filename);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Template ${filename} could not be located.`,
|
||||
consequence: "Cannot scaffold the new artifact.",
|
||||
action: "Reinstall @openrig/cli, or run from a checkout with packages/cli/src/lib/scope-templates/ present.",
|
||||
});
|
||||
}
|
||||
|
||||
export interface RenderOpts {
|
||||
id: string;
|
||||
slice_number?: string; // zero-padded; only for slice templates
|
||||
slug: string;
|
||||
mission: string;
|
||||
title: string;
|
||||
created_date: string;
|
||||
release_version?: string;
|
||||
}
|
||||
|
||||
function applyPlaceholders(content: string, opts: RenderOpts): string {
|
||||
return content
|
||||
.replace(/\{\{id\}\}/g, opts.id)
|
||||
.replace(/\{\{slice_number\}\}/g, opts.slice_number ?? "")
|
||||
.replace(/\{\{slug\}\}/g, opts.slug)
|
||||
.replace(/\{\{mission\}\}/g, opts.mission)
|
||||
.replace(/\{\{title\}\}/g, opts.title)
|
||||
.replace(/\{\{created_date\}\}/g, opts.created_date)
|
||||
.replace(/\{\{release_version\}\}/g, opts.release_version ?? "");
|
||||
}
|
||||
|
||||
export function renderSliceTemplate(kind: SliceTemplateKind, opts: RenderOpts): string {
|
||||
const filename = `${kind}.md`;
|
||||
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
|
||||
return applyPlaceholders(raw, opts);
|
||||
}
|
||||
|
||||
export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpts): string {
|
||||
const filename = kind === "release" ? "mission-release.md" : "mission-placeholder.md";
|
||||
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
|
||||
return applyPlaceholders(raw, opts);
|
||||
}
|
||||
|
||||
/** Convert a folder-slug to a title-cased display name. */
|
||||
export function titleFromSlug(slug: string): string {
|
||||
return slug
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map((word) => word[0]!.toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// release-0.3.2 slice 12 — scope CLI primitive types.
|
||||
//
|
||||
// Aligned with the substrate convention at
|
||||
// `openrig-work/conventions/scope-and-versioning/README.md` (stage:
|
||||
// provisional, authored 2026-05-15). The CLI mints stable dot-IDs
|
||||
// into created mission/slice frontmatter per §1 of that convention.
|
||||
|
||||
/** Scope tiers. v0 ships `mission` + `slice`; `project` and `sub-slice`
|
||||
* exist in the grammar but have no CLI verbs yet. */
|
||||
export type ScopeTier = "project" | "mission" | "slice" | "sub-slice";
|
||||
|
||||
/** Stable reference ID parts. `OPR.0.3.2.12` → project="OPR",
|
||||
* version="0.3.2", n=12. Escape-band non-release missions look like
|
||||
* `OPR.99.0.1` → version="99.0.1", n=undefined. */
|
||||
export interface DotId {
|
||||
project: string; // 2-3 letter project prefix
|
||||
version: string; // semver-shaped (release) or escape-band (99.x.y)
|
||||
n?: number; // slice ordinal
|
||||
m?: number; // sub-slice ordinal
|
||||
}
|
||||
|
||||
export interface MissionInfo {
|
||||
/** Mission folder name (e.g., "release-0.3.2", "backlog"). */
|
||||
name: string;
|
||||
/** Absolute path to the mission folder. */
|
||||
absPath: string;
|
||||
/** Path to the mission's README.md, if present. */
|
||||
readmePath: string | null;
|
||||
/** Parsed frontmatter from the README. */
|
||||
frontmatter: Record<string, unknown>;
|
||||
/** Minted dot-ID for this mission (read from frontmatter if present,
|
||||
* otherwise inferred from the folder name pattern). */
|
||||
id: string | null;
|
||||
activeSliceCount: number;
|
||||
closedSliceCount: number;
|
||||
}
|
||||
|
||||
export interface SliceInfo {
|
||||
name: string; // folder name like "12-rig-slice-cli-primitive"
|
||||
absPath: string;
|
||||
readmePath: string | null;
|
||||
frontmatter: Record<string, unknown>;
|
||||
/** "NN-slug" → NN. */
|
||||
nn: number | null;
|
||||
/** "NN-slug" → slug. */
|
||||
slug: string | null;
|
||||
missionName: string;
|
||||
/** Minted dot-ID, parent.NN form. */
|
||||
id: string | null;
|
||||
/** Frontmatter `status` field, lowercased. */
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
/** 3-part error shape per `building-agent-software` skill §3.6. */
|
||||
export class ScopeCliError extends Error {
|
||||
readonly fact: string;
|
||||
readonly consequence: string;
|
||||
readonly action: string;
|
||||
constructor(opts: { fact: string; consequence: string; action: string }) {
|
||||
super(`${opts.fact}\n${opts.consequence}\n${opts.action}`);
|
||||
this.name = "ScopeCliError";
|
||||
this.fact = opts.fact;
|
||||
this.consequence = opts.consequence;
|
||||
this.action = opts.action;
|
||||
}
|
||||
}
|
||||
|
||||
export type SliceState = "active" | "closed" | "shipped" | "all";
|
||||
export type SliceTemplateKind =
|
||||
| "placeholder"
|
||||
| "bug-fix"
|
||||
| "backlog-deprecation"
|
||||
| "backlog-tech-debt"
|
||||
| "release-feature"
|
||||
| "research";
|
||||
export const SLICE_TEMPLATE_KINDS: ReadonlyArray<SliceTemplateKind> = [
|
||||
"placeholder",
|
||||
"bug-fix",
|
||||
"backlog-deprecation",
|
||||
"backlog-tech-debt",
|
||||
"release-feature",
|
||||
"research",
|
||||
];
|
||||
|
||||
export type MissionTemplateKind = "placeholder" | "release";
|
||||
export const MISSION_TEMPLATE_KINDS: ReadonlyArray<MissionTemplateKind> = [
|
||||
"placeholder",
|
||||
"release",
|
||||
];
|
||||
|
||||
export type CloseReason = "wontfix" | "deferred" | "superseded" | "stale";
|
||||
export const CLOSE_REASONS: ReadonlyArray<CloseReason> = [
|
||||
"wontfix",
|
||||
"deferred",
|
||||
"superseded",
|
||||
"stale",
|
||||
];
|
||||
@@ -23,16 +23,14 @@ function warnOnce(key: string, message: string): void {
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export function readOpenRigEnv(primary: string, legacy?: string): string | undefined {
|
||||
export function readOpenRigEnv(primary: string, legacy: string): string | undefined {
|
||||
const primaryValue = process.env[primary];
|
||||
if (primaryValue !== undefined && primaryValue !== "") return primaryValue;
|
||||
|
||||
if (legacy) {
|
||||
const legacyValue = process.env[legacy];
|
||||
if (legacyValue !== undefined && legacyValue !== "") {
|
||||
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
|
||||
return legacyValue;
|
||||
}
|
||||
const legacyValue = process.env[legacy];
|
||||
if (legacyValue !== undefined && legacyValue !== "") {
|
||||
warnOnce(`env:${legacy}`, `Warning: ${legacy} is deprecated; use ${primary} instead.`);
|
||||
return legacyValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
} from "./types.js";
|
||||
|
||||
const PATH_PATTERNS: readonly RegExp[] = [
|
||||
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
|
||||
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
|
||||
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
|
||||
];
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
} from "./types.js";
|
||||
|
||||
const PATH_PATTERNS: readonly RegExp[] = [
|
||||
/\/(?:Users|home)\/[^/\s]+\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
|
||||
/\/Users\/wrandom\/[A-Za-z0-9._~:/@%+=,\- ]+/g,
|
||||
/\b(?:packages|docs|scripts|test|tests|src|openrig-work|rigs|control-plane)\/[A-Za-z0-9._~:/@%+=,\-]+/g,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// PL-016 hardening v0+1 — `rig agent-image create` --image-version flag
|
||||
// regression test.
|
||||
//
|
||||
// Pins:
|
||||
// - --image-version <v> propagates as body.version on the snapshot POST
|
||||
// - --version on the per-command surface no longer shadows Commander's
|
||||
// global --version (creating a separate per-command name resolves
|
||||
// the collision; absence of `--version` as a per-command option is
|
||||
// the contract this test enforces)
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { agentImageCommand } from "../src/commands/agent-image.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runningState(port: number): DaemonState {
|
||||
return { pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" };
|
||||
}
|
||||
|
||||
function runningLifecycleDeps(port: number): LifecycleDeps {
|
||||
return mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify(runningState(port));
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturedCall {
|
||||
method: string;
|
||||
url: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function createMockDaemon(captured: CapturedCall[]) {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
if (url.pathname === "/healthz") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/agent-images/snapshot") {
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => { body += c.toString(); });
|
||||
req.on("end", () => {
|
||||
captured.push({ method: req.method!, url: url.pathname, body });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
imageId: "agent-image:test:2",
|
||||
imagePath: "/tmp/agent-images/test",
|
||||
manifest: { name: "test", version: "2", runtime: "claude-code" },
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
return {
|
||||
server,
|
||||
close: () => new Promise<void>((r) => server.close(() => r())),
|
||||
listen: () => new Promise<number>((r) => {
|
||||
server.listen(0, () => {
|
||||
const addr = server.address();
|
||||
r(typeof addr === "object" && addr ? addr.port : 0);
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("rig agent-image create — --image-version flag", () => {
|
||||
const captured: CapturedCall[] = [];
|
||||
let srv: ReturnType<typeof createMockDaemon>;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
srv = createMockDaemon(captured);
|
||||
port = await srv.listen();
|
||||
});
|
||||
afterAll(async () => { await srv.close(); });
|
||||
|
||||
it("--image-version <v> propagates as body.version on the snapshot POST", async () => {
|
||||
captured.length = 0;
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(agentImageCommand({
|
||||
lifecycleDeps: runningLifecycleDeps(port),
|
||||
clientFactory: (url) => new DaemonClient(url),
|
||||
}));
|
||||
|
||||
// Suppress console output for the test.
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
await program.parseAsync([
|
||||
"node", "rig", "agent-image", "create", "alice@rigA",
|
||||
"--name", "test",
|
||||
"--image-version", "2",
|
||||
]);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
expect(captured).toHaveLength(1);
|
||||
const body = JSON.parse(captured[0]!.body);
|
||||
expect(body.version).toBe("2");
|
||||
expect(body.name).toBe("test");
|
||||
expect(body.sourceSession).toBe("alice@rigA");
|
||||
});
|
||||
|
||||
it("the per-command create command does NOT register --version (avoids Commander.js global collision)", () => {
|
||||
const cmd = agentImageCommand();
|
||||
const createCmd = cmd.commands.find((c) => c.name() === "create");
|
||||
expect(createCmd).toBeDefined();
|
||||
const optionNames = createCmd!.options.map((o) => o.long);
|
||||
// Must include --image-version (the rename target); must NOT include
|
||||
// --version (which Commander.js intercepts as the global version flag).
|
||||
expect(optionNames).toContain("--image-version");
|
||||
expect(optionNames).not.toContain("--version");
|
||||
});
|
||||
});
|
||||
@@ -1,699 +0,0 @@
|
||||
// User Settings v0 — extended ConfigStore + init-workspace tests.
|
||||
//
|
||||
// Pins the load-bearing behaviors of the v0 extension:
|
||||
// - new keys parse + validate; legacy 5 keys' behavior preserved
|
||||
// - per-subdir override resolution (workspace.root / per-subdir overrides / default)
|
||||
// - env > file > default precedence for new keys
|
||||
// - UEP env-var graduation: OPENRIG_FILES_ALLOWLIST + OPENRIG_PROGRESS_SCAN_ROOTS still work
|
||||
// - reset(key) clears one key; bare reset deletes the file
|
||||
// - parseNamedPairs decodes the named-pair format
|
||||
// - init-workspace creates mission-aware workspace files; idempotent
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
ConfigStore,
|
||||
VALID_KEYS,
|
||||
parseNamedPairs,
|
||||
deriveWorkspaceDefault,
|
||||
} from "../src/config-store.js";
|
||||
import { configCommand } from "../src/commands/config.js";
|
||||
import { initWorkspaceCommand, runInitWorkspace } from "../src/commands/config-init-workspace.js";
|
||||
|
||||
function clearEnv(): () => void {
|
||||
const keysToClear = [
|
||||
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB",
|
||||
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
|
||||
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
|
||||
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
|
||||
"OPENRIG_WORKSPACE_STEERING_PATH", "OPENRIG_WORKSPACE_FIELD_NOTES_ROOT",
|
||||
"OPENRIG_WORKSPACE_SPECS_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
|
||||
"OPENRIG_FILES_ALLOWLIST", "OPENRIG_PROGRESS_SCAN_ROOTS",
|
||||
"OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS",
|
||||
"OPENRIG_UI_PREVIEW_MAX_PINS", "OPENRIG_UI_PREVIEW_DEFAULT_LINES",
|
||||
"OPENRIG_RECOVERY_AUTO_DRIVE_PROVIDER_PROMPTS",
|
||||
"OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST",
|
||||
"OPENRIG_AGENTS_ADVISOR_SESSION", "OPENRIG_AGENTS_OPERATOR_SESSION",
|
||||
"OPENRIG_FEED_SUBSCRIPTIONS_ACTION_REQUIRED", "OPENRIG_FEED_SUBSCRIPTIONS_APPROVALS",
|
||||
"OPENRIG_FEED_SUBSCRIPTIONS_SHIPPED", "OPENRIG_FEED_SUBSCRIPTIONS_PROGRESS",
|
||||
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
|
||||
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_COMPACT_INSTRUCTION",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION",
|
||||
"RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB",
|
||||
"RIGGED_TRANSCRIPTS_ENABLED", "RIGGED_TRANSCRIPTS_PATH",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of keysToClear) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
return () => {
|
||||
for (const k of keysToClear) {
|
||||
if (saved[k] !== undefined) process.env[k] = saved[k]!;
|
||||
else delete process.env[k];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "config-store-ext-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
restoreEnv = clearEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it("VALID_KEYS includes legacy, user-settings, ui.preview, recovery, agents, feed, and transcript-rotation keys", () => {
|
||||
const expected = [
|
||||
"daemon.port", "daemon.host", "db.path",
|
||||
"transcripts.enabled", "transcripts.path",
|
||||
// V1 pre-release CLI/daemon Item 1 — capture-pane rotation tunables.
|
||||
"transcripts.lines", "transcripts.poll_interval_seconds",
|
||||
"workspace.root", "workspace.slices_root", "workspace.steering_path",
|
||||
"workspace.field_notes_root", "workspace.specs_root",
|
||||
"workspace.dogfood_evidence_root",
|
||||
"files.allowlist", "progress.scan_roots",
|
||||
"ui.preview.refresh_interval_seconds", "ui.preview.max_pins", "ui.preview.default_lines",
|
||||
"recovery.auto_drive_provider_prompts",
|
||||
"recovery.provider_auth_env_allowlist",
|
||||
// V1 attempt-3 Phase 4 — Advisor/Operator placeholders.
|
||||
"agents.advisor_session", "agents.operator_session",
|
||||
// V0.3.1 slice 05 kernel-rig-as-default — operator seat name
|
||||
// read by daemon mission-control + UI; default
|
||||
// `operator-${USER}@kernel` derived in resolve().
|
||||
"workspace.operator_seat_name",
|
||||
// V1 attempt-3 Phase 5 P5-3 — For You feed subscription toggles.
|
||||
"feed.subscriptions.action_required",
|
||||
"feed.subscriptions.approvals",
|
||||
"feed.subscriptions.shipped",
|
||||
"feed.subscriptions.progress",
|
||||
"feed.subscriptions.audit_log",
|
||||
// plugin-primitive Phase 3a slice 3.5 — Codex feature flag.
|
||||
"runtime.codex.hooks_enabled",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.pre_compact_instruction",
|
||||
"policies.claude_compaction.compact_instruction",
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
];
|
||||
expect([...VALID_KEYS]).toEqual(expected);
|
||||
});
|
||||
|
||||
it("workspace.operator_seat_name roundtrip — default derives operator-${USER}@kernel; set → resolve reflects override", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const before = store.resolve();
|
||||
// Default cascade derives from OS username at resolve() time.
|
||||
expect(before.workspace.operatorSeatName).toMatch(/^operator-.+@kernel$/);
|
||||
|
||||
store.set("workspace.operator_seat_name", "operator-test@kernel");
|
||||
const after = store.resolve();
|
||||
expect(after.workspace.operatorSeatName).toBe("operator-test@kernel");
|
||||
|
||||
store.reset("workspace.operator_seat_name");
|
||||
const reset = store.resolve();
|
||||
expect(reset.workspace.operatorSeatName).toMatch(/^operator-.+@kernel$/);
|
||||
});
|
||||
|
||||
it("workspace.operator_seat_name env override beats file-stored value", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.operator_seat_name", "operator-file@kernel");
|
||||
process.env.OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME = "operator-env@kernel";
|
||||
try {
|
||||
const resolved = store.resolve();
|
||||
expect(resolved.workspace.operatorSeatName).toBe("operator-env@kernel");
|
||||
} finally {
|
||||
delete process.env.OPENRIG_WORKSPACE_OPERATOR_SEAT_NAME;
|
||||
}
|
||||
});
|
||||
|
||||
it("transcripts.lines + transcripts.poll_interval_seconds roundtrip — set → resolve reflects file-stored values for daemon launch projection", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
// Defaults BEFORE set.
|
||||
const before = store.resolve();
|
||||
expect(before.transcripts.lines).toBe(1000);
|
||||
expect(before.transcripts.pollIntervalSeconds).toBe(2);
|
||||
|
||||
store.set("transcripts.lines", "500");
|
||||
store.set("transcripts.poll_interval_seconds", "5");
|
||||
|
||||
const after = store.resolve();
|
||||
expect(after.transcripts.lines).toBe(500);
|
||||
expect(after.transcripts.pollIntervalSeconds).toBe(5);
|
||||
});
|
||||
|
||||
it("legacy 5-key behavior preserved: get/set/reset still work", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("daemon.port", "9999");
|
||||
expect(store.get("daemon.port")).toBe(9999);
|
||||
store.set("transcripts.enabled", "false");
|
||||
expect(store.get("transcripts.enabled")).toBe(false);
|
||||
store.reset("daemon.port");
|
||||
expect(store.get("daemon.port")).toBe(7433);
|
||||
});
|
||||
|
||||
it("workspace.root default is ~/.openrig/workspace; per-subdir defaults derive from it", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const cfg = store.resolve();
|
||||
expect(cfg.workspace.root).toMatch(/\.openrig[\/\\]workspace$/);
|
||||
expect(cfg.workspace.slicesRoot).toBe(join(cfg.workspace.root, "missions"));
|
||||
expect(cfg.workspace.steeringPath).toBe(join(cfg.workspace.root, "STEERING.md"));
|
||||
expect(cfg.workspace.fieldNotesRoot).toBe(join(cfg.workspace.root, "field-notes"));
|
||||
expect(cfg.workspace.specsRoot).toBe(join(cfg.workspace.root, "specs"));
|
||||
expect(cfg.workspace.dogfoodEvidenceRoot).toBe(join(cfg.workspace.root, "dogfood-evidence"));
|
||||
expect(cfg.files.allowlist).toBe(`workspace:${cfg.workspace.root}`);
|
||||
expect(cfg.progress.scanRoots).toBe(`workspace:${cfg.workspace.root}`);
|
||||
});
|
||||
|
||||
it("setting workspace.root cascades into per-subdir defaults", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/custom/ws");
|
||||
const cfg = store.resolve();
|
||||
expect(cfg.workspace.root).toBe("/custom/ws");
|
||||
expect(cfg.workspace.slicesRoot).toBe("/custom/ws/missions");
|
||||
expect(cfg.workspace.steeringPath).toBe("/custom/ws/STEERING.md");
|
||||
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
|
||||
expect(cfg.files.allowlist).toBe("workspace:/custom/ws");
|
||||
expect(cfg.progress.scanRoots).toBe("workspace:/custom/ws");
|
||||
});
|
||||
|
||||
it("treats persisted legacy workspace defaults as default-derived values", () => {
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
workspace: {
|
||||
root: "/custom/ws",
|
||||
slicesRoot: "/custom/ws/slices",
|
||||
steeringPath: "/custom/ws/steering/STEERING.md",
|
||||
},
|
||||
}));
|
||||
const store = new ConfigStore(configPath);
|
||||
const slices = store.resolveWithSource("workspace.slices_root");
|
||||
const steering = store.resolveWithSource("workspace.steering_path");
|
||||
expect(slices).toMatchObject({ value: "/custom/ws/missions", source: "default" });
|
||||
expect(steering).toMatchObject({ value: "/custom/ws/STEERING.md", source: "default" });
|
||||
});
|
||||
|
||||
it("per-subdir override beats workspace.root cascade", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/ws");
|
||||
store.set("workspace.slices_root", "/custom/slices");
|
||||
const cfg = store.resolve();
|
||||
// workspace.root cascade applies to OTHER subdirs:
|
||||
expect(cfg.workspace.fieldNotesRoot).toBe("/ws/field-notes");
|
||||
expect(cfg.workspace.dogfoodEvidenceRoot).toBe("/ws/dogfood-evidence");
|
||||
// per-subdir override wins:
|
||||
expect(cfg.workspace.slicesRoot).toBe("/custom/slices");
|
||||
});
|
||||
|
||||
it("env > file > default for new keys", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
// No file → default
|
||||
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
|
||||
|
||||
// File set → file source
|
||||
store.set("workspace.slices_root", "/from/file");
|
||||
expect(store.resolveWithSource("workspace.slices_root").value).toBe("/from/file");
|
||||
expect(store.resolveWithSource("workspace.slices_root").source).toBe("file");
|
||||
|
||||
// Env set → env source
|
||||
process.env["OPENRIG_WORKSPACE_SLICES_ROOT"] = "/from/env";
|
||||
try {
|
||||
const r = store.resolveWithSource("workspace.slices_root");
|
||||
expect(r.value).toBe("/from/env");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_WORKSPACE_SLICES_ROOT"];
|
||||
}
|
||||
});
|
||||
|
||||
it("UEP env-var graduation: OPENRIG_FILES_ALLOWLIST is the env override for files.allowlist", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env["OPENRIG_FILES_ALLOWLIST"] = "ws:/Users/me,docs:/var/docs";
|
||||
try {
|
||||
const r = store.resolveWithSource("files.allowlist");
|
||||
expect(r.value).toBe("ws:/Users/me,docs:/var/docs");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_FILES_ALLOWLIST"];
|
||||
}
|
||||
});
|
||||
|
||||
it("UEP env-var graduation: OPENRIG_PROGRESS_SCAN_ROOTS is the env override for progress.scan_roots", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
process.env["OPENRIG_PROGRESS_SCAN_ROOTS"] = "main:/code/main";
|
||||
try {
|
||||
const r = store.resolveWithSource("progress.scan_roots");
|
||||
expect(r.value).toBe("main:/code/main");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_PROGRESS_SCAN_ROOTS"];
|
||||
}
|
||||
});
|
||||
|
||||
it("dogfood evidence root defaults under workspace.root and supports env override", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/custom/ws");
|
||||
expect(store.resolve().workspace.dogfoodEvidenceRoot).toBe("/custom/ws/dogfood-evidence");
|
||||
|
||||
process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"] = "/proof/root";
|
||||
try {
|
||||
const r = store.resolveWithSource("workspace.dogfood_evidence_root");
|
||||
expect(r.value).toBe("/proof/root");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_DOGFOOD_EVIDENCE_ROOT"];
|
||||
}
|
||||
});
|
||||
|
||||
it("set rejects unknown keys with hint listing valid keys", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("workspace.bogus", "x")).toThrow(/Unknown config key/);
|
||||
expect(() => store.set("workspace.bogus", "x")).toThrow(/workspace\.root/);
|
||||
});
|
||||
|
||||
it("get rejects unknown keys", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.get("nope.doesnt.exist")).toThrow(/Unknown config key/);
|
||||
});
|
||||
|
||||
it("reset(key) clears just one key; reset() deletes whole file", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/ws");
|
||||
store.set("workspace.slices_root", "/ws/slices-custom");
|
||||
store.reset("workspace.slices_root");
|
||||
expect(store.resolveWithSource("workspace.slices_root").source).toBe("default");
|
||||
expect(store.resolveWithSource("workspace.root").source).toBe("file");
|
||||
|
||||
store.reset();
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolveAllWithSource returns every valid key with source + default", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const all = store.resolveAllWithSource();
|
||||
for (const key of VALID_KEYS) {
|
||||
expect(all[key]).toBeDefined();
|
||||
expect(all[key].source).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed config.json throws with reset hint (preserves legacy behavior)", () => {
|
||||
writeFileSync(configPath, "{not-json");
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.resolve()).toThrow(/malformed/i);
|
||||
expect(() => store.resolve()).toThrow(/reset/i);
|
||||
});
|
||||
|
||||
// --- Preview Terminal v0 (PL-018) keys ---
|
||||
it("ui.preview.refresh_interval_seconds defaults to 3", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("ui.preview.refresh_interval_seconds")).toBe(3);
|
||||
});
|
||||
|
||||
it("ui.preview.max_pins defaults to 4", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("ui.preview.max_pins")).toBe(4);
|
||||
});
|
||||
|
||||
it("ui.preview.default_lines defaults to 50", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("ui.preview.default_lines")).toBe(50);
|
||||
});
|
||||
|
||||
it("ui.preview.* keys coerce numeric values from set", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("ui.preview.refresh_interval_seconds", "5");
|
||||
store.set("ui.preview.max_pins", "2");
|
||||
store.set("ui.preview.default_lines", "100");
|
||||
expect(store.get("ui.preview.refresh_interval_seconds")).toBe(5);
|
||||
expect(store.get("ui.preview.max_pins")).toBe(2);
|
||||
expect(store.get("ui.preview.default_lines")).toBe(100);
|
||||
});
|
||||
|
||||
it("ui.preview.* keys reject non-numeric values", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("ui.preview.refresh_interval_seconds", "soon")).toThrow(/expected a number/);
|
||||
});
|
||||
|
||||
it("OPENRIG_UI_PREVIEW_* env vars override file values", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("ui.preview.refresh_interval_seconds", "5");
|
||||
process.env["OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS"] = "10";
|
||||
try {
|
||||
const r = store.resolveWithSource("ui.preview.refresh_interval_seconds");
|
||||
expect(r.value).toBe(10);
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_UI_PREVIEW_REFRESH_INTERVAL_SECONDS"];
|
||||
}
|
||||
});
|
||||
|
||||
it("recovery provider auth env allowlist defaults empty and supports env override", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("recovery.provider_auth_env_allowlist")).toBe("");
|
||||
store.set("recovery.provider_auth_env_allowlist", "ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN");
|
||||
expect(store.resolve().recovery.providerAuthEnvAllowlist).toBe("ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN");
|
||||
|
||||
process.env["OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST"] = "OPENAI_API_KEY";
|
||||
try {
|
||||
const r = store.resolveWithSource("recovery.provider_auth_env_allowlist");
|
||||
expect(r.value).toBe("OPENAI_API_KEY");
|
||||
expect(r.source).toBe("env");
|
||||
} finally {
|
||||
delete process.env["OPENRIG_RECOVERY_PROVIDER_AUTH_ENV_ALLOWLIST"];
|
||||
}
|
||||
});
|
||||
|
||||
it("RiggedConfig.ui.preview shape exposed", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const cfg = store.resolve();
|
||||
expect(cfg.ui.preview.refreshIntervalSeconds).toBe(3);
|
||||
expect(cfg.ui.preview.maxPins).toBe(4);
|
||||
expect(cfg.ui.preview.defaultLines).toBe(50);
|
||||
});
|
||||
|
||||
it("recovery.auto_drive_provider_prompts defaults false and coerces booleans", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(false);
|
||||
store.set("recovery.auto_drive_provider_prompts", "true");
|
||||
expect(store.get("recovery.auto_drive_provider_prompts")).toBe(true);
|
||||
expect(store.resolve().recovery.autoDriveProviderPrompts).toBe(true);
|
||||
});
|
||||
|
||||
// Slice 27 BLOCKING-FIX-2 — env override + file value validation.
|
||||
// Bypass-prevention at the RESOLVE path: an env override like
|
||||
// OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc previously
|
||||
// parseInt-coerced to 80 and shipped through resolveOne as a "valid"
|
||||
// env-sourced value. With BLOCKING-FIX-2 the env resolution validates;
|
||||
// bad env drops the override (falls to file/default) and warns on stderr.
|
||||
describe("BLOCKING-FIX-2: env + file source validation", () => {
|
||||
const reject = ["0", "101", "-1", "80abc", "80.5", "NaN", "Infinity"];
|
||||
|
||||
for (const raw of reject) {
|
||||
it(`env=${JSON.stringify(raw)} → resolve returns default 80; warning emitted`, () => {
|
||||
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = raw;
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const store = new ConfigStore(configPath);
|
||||
const resolved = store.resolve();
|
||||
expect(resolved.policies.claudeCompaction.thresholdPercent).toBe(80);
|
||||
const calls = stderrSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((c) => c.includes("env override for policies.claude_compaction.threshold_percent rejected"))).toBe(true);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("discriminator: env=80abc (rejected, source falls to default, warning) vs env=80 (accepted, source=env, no warning)", () => {
|
||||
// Case A: rejected → source=default, warning emitted
|
||||
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "80abc";
|
||||
let stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const a = new ConfigStore(configPath).resolveWithSource("policies.claude_compaction.threshold_percent");
|
||||
expect(a.value).toBe(80);
|
||||
expect(a.source).toBe("default");
|
||||
const warns = stderrSpy.mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.filter((c) => c.includes("env override for policies.claude_compaction.threshold_percent rejected"));
|
||||
expect(warns.length).toBe(1);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
|
||||
}
|
||||
|
||||
// Case B: accepted → SAME numeric value (80) but source=env, NO warning
|
||||
process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"] = "80";
|
||||
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const b = new ConfigStore(configPath).resolveWithSource("policies.claude_compaction.threshold_percent");
|
||||
expect(b.value).toBe(80);
|
||||
expect(b.source).toBe("env");
|
||||
const warns = stderrSpy.mock.calls
|
||||
.map((c) => String(c[0]))
|
||||
.filter((c) => c.includes("rejected"));
|
||||
expect(warns.length).toBe(0);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
delete process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT"];
|
||||
}
|
||||
});
|
||||
|
||||
const fileReject: Array<{ name: string; written: unknown }> = [
|
||||
{ name: "0", written: 0 },
|
||||
{ name: "101", written: 101 },
|
||||
{ name: "-1", written: -1 },
|
||||
{ name: "80.5 (JSON non-integer)", written: 80.5 },
|
||||
{ name: '"80abc" (JSON string)', written: "80abc" },
|
||||
];
|
||||
|
||||
for (const { name, written } of fileReject) {
|
||||
it(`file thresholdPercent=${name} → resolve returns default 80; warning emitted`, () => {
|
||||
writeFileSync(configPath, JSON.stringify({
|
||||
policies: { claudeCompaction: { thresholdPercent: written } },
|
||||
}));
|
||||
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
const resolved = new ConfigStore(configPath).resolve();
|
||||
expect(resolved.policies.claudeCompaction.thresholdPercent).toBe(80);
|
||||
const calls = stderrSpy.mock.calls.map((c) => String(c[0]));
|
||||
expect(calls.some((c) => c.includes("file value for policies.claude_compaction.threshold_percent rejected"))).toBe(true);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Slice 27 BLOCKING-FIX — strict accept/reject matrix for
|
||||
// policies.claude_compaction.threshold_percent. The contract is
|
||||
// integer in [1, 100]. parseInt's permissive coercion would otherwise
|
||||
// accept "80abc" → 80, "80.5" → 80, and even 0 / 101 / -1 which would
|
||||
// break the trigger's safety contract (0 = compact every tick).
|
||||
describe("policies.claude_compaction.threshold_percent strict validation matrix", () => {
|
||||
const accept = ["1", "2", "50", "80", "99", "100"];
|
||||
const reject: Array<{ raw: string; reason: RegExp }> = [
|
||||
{ raw: "0", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "101", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "-1", reason: /must be in \[1, 100\]/ },
|
||||
{ raw: "80abc", reason: /expected an integer/ },
|
||||
{ raw: "abc80", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "80.5", reason: /expected an integer/ },
|
||||
{ raw: "", reason: /expected a number|expected an integer/ },
|
||||
{ raw: " ", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "NaN", reason: /expected a number|expected an integer/ },
|
||||
{ raw: "Infinity", reason: /expected a number|expected an integer/ },
|
||||
];
|
||||
|
||||
for (const value of accept) {
|
||||
it(`accepts ${JSON.stringify(value)}`, () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", value)).not.toThrow();
|
||||
expect(store.get("policies.claude_compaction.threshold_percent")).toBe(Number(value));
|
||||
});
|
||||
}
|
||||
|
||||
for (const { raw, reason } of reject) {
|
||||
it(`rejects ${JSON.stringify(raw)}`, () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(() => store.set("policies.claude_compaction.threshold_percent", raw)).toThrow(reason);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNamedPairs", () => {
|
||||
it("returns empty array for empty/whitespace input", () => {
|
||||
expect(parseNamedPairs("")).toEqual([]);
|
||||
expect(parseNamedPairs(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits comma-separated name:path pairs", () => {
|
||||
const out = parseNamedPairs("ws:/abs/path,docs:/var/docs");
|
||||
expect(out).toEqual([
|
||||
{ name: "ws", path: "/abs/path" },
|
||||
{ name: "docs", path: "/var/docs" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips entries without a colon", () => {
|
||||
expect(parseNamedPairs("just-name,ws:/path")).toEqual([{ name: "ws", path: "/path" }]);
|
||||
});
|
||||
|
||||
it("trims whitespace around each pair + name + path", () => {
|
||||
expect(parseNamedPairs(" ws : /abs/path , docs:/var/docs ")).toEqual([
|
||||
{ name: "ws", path: "/abs/path" },
|
||||
{ name: "docs", path: "/var/docs" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes by name (last wins)", () => {
|
||||
expect(parseNamedPairs("ws:/old,ws:/new")).toEqual([{ name: "ws", path: "/new" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveWorkspaceDefault", () => {
|
||||
it("returns canonical subpaths under workspace root", () => {
|
||||
expect(deriveWorkspaceDefault("workspace.slices_root", "/ws")).toBe("/ws/missions");
|
||||
expect(deriveWorkspaceDefault("workspace.steering_path", "/ws")).toBe("/ws/STEERING.md");
|
||||
expect(deriveWorkspaceDefault("workspace.field_notes_root", "/ws")).toBe("/ws/field-notes");
|
||||
expect(deriveWorkspaceDefault("workspace.specs_root", "/ws")).toBe("/ws/specs");
|
||||
expect(deriveWorkspaceDefault("workspace.dogfood_evidence_root", "/ws")).toBe("/ws/dogfood-evidence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("init-workspace runner", () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
let workspaceRoot: string;
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "init-workspace-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
workspaceRoot = join(tmpDir, "workspace");
|
||||
restoreEnv = clearEnv();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it("--dry-run reports what would be created without writing anything", () => {
|
||||
const result = runInitWorkspace({ dryRun: true, root: workspaceRoot, configPath });
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.subdirs.map((s) => s.name)).toEqual(expect.arrayContaining([
|
||||
"missions",
|
||||
"artifacts",
|
||||
"evidence",
|
||||
"progress",
|
||||
"field-notes",
|
||||
"specs",
|
||||
"dogfood-evidence",
|
||||
"missions/getting-started/slices/first-conveyor-run",
|
||||
"missions/getting-started/slices/inspect-project-evidence",
|
||||
]));
|
||||
expect(existsSync(workspaceRoot)).toBe(false);
|
||||
});
|
||||
|
||||
it("command --json emits parseable JSON for agent setup flows", async () => {
|
||||
const cmd = initWorkspaceCommand(configPath);
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (msg?: unknown) => { logs.push(String(msg)); };
|
||||
try {
|
||||
await cmd.parseAsync(["node", "init-workspace", "--root", workspaceRoot, "--dry-run", "--json"]);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
|
||||
expect(parsed.root).toBe(workspaceRoot);
|
||||
expect(parsed.dryRun).toBe(true);
|
||||
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/getting-started/slices/first-conveyor-run");
|
||||
});
|
||||
|
||||
it("rig-level --json emits parseable JSON for config init-workspace", async () => {
|
||||
const cmd = new Command("rig");
|
||||
cmd.exitOverride();
|
||||
cmd.option("--json", "emit machine-readable JSON");
|
||||
cmd.addCommand(configCommand(configPath));
|
||||
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (msg?: unknown) => { logs.push(String(msg)); };
|
||||
try {
|
||||
await cmd.parseAsync([
|
||||
"node",
|
||||
"rig",
|
||||
"--json",
|
||||
"config",
|
||||
"init-workspace",
|
||||
"--root",
|
||||
workspaceRoot,
|
||||
"--dry-run",
|
||||
]);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n")) as { root: string; dryRun: boolean; subdirs: Array<{ name: string }> };
|
||||
expect(parsed.root).toBe(workspaceRoot);
|
||||
expect(parsed.dryRun).toBe(true);
|
||||
expect(parsed.subdirs.map((s) => s.name)).toContain("missions/getting-started/slices/inspect-project-evidence");
|
||||
});
|
||||
|
||||
it("creates mission-aware workspace files + STEERING placeholder", () => {
|
||||
const result = runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
expect(result.dryRun).toBe(false);
|
||||
expect(existsSync(workspaceRoot)).toBe(true);
|
||||
for (const sub of ["missions", "artifacts", "evidence", "progress", "field-notes", "specs", "dogfood-evidence"]) {
|
||||
expect(existsSync(join(workspaceRoot, sub))).toBe(true);
|
||||
expect(existsSync(join(workspaceRoot, sub, "README.md"))).toBe(true);
|
||||
}
|
||||
expect(existsSync(join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md"))).toBe(true);
|
||||
expect(existsSync(join(workspaceRoot, "missions", "getting-started", "slices", "inspect-project-evidence", "IMPLEMENTATION-PRD.md"))).toBe(true);
|
||||
const steeringMd = readFileSync(join(workspaceRoot, "STEERING.md"), "utf-8");
|
||||
expect(steeringMd).toContain("OpenRig Priority Stack");
|
||||
});
|
||||
|
||||
it("is idempotent: running twice without --force is a no-op for existing files", () => {
|
||||
runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
const sliceReadme = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md");
|
||||
writeFileSync(sliceReadme, "operator-edited content", "utf-8");
|
||||
|
||||
const second = runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
const sliceFile = second.files.find((f) => f.relPath === "missions/getting-started/slices/first-conveyor-run/README.md");
|
||||
expect(sliceFile?.skipped).toBe("exists");
|
||||
expect(readFileSync(sliceReadme, "utf-8")).toBe("operator-edited content");
|
||||
});
|
||||
|
||||
it("--force overwrites existing files but never deletes operator content under directories", () => {
|
||||
runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
const operatorFile = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "operator-note.md");
|
||||
writeFileSync(operatorFile, "my work", "utf-8");
|
||||
const operatorReadme = join(workspaceRoot, "missions", "getting-started", "slices", "first-conveyor-run", "README.md");
|
||||
writeFileSync(operatorReadme, "edited", "utf-8");
|
||||
|
||||
runInitWorkspace({ root: workspaceRoot, force: true, configPath });
|
||||
// Operator file under the subdir survives
|
||||
expect(existsSync(operatorFile)).toBe(true);
|
||||
expect(readFileSync(operatorFile, "utf-8")).toBe("my work");
|
||||
// README is overwritten
|
||||
expect(readFileSync(operatorReadme, "utf-8")).toContain("# First Conveyor Run");
|
||||
});
|
||||
|
||||
it("--root override beats configured workspace.root", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", "/should/not/be/used");
|
||||
const result = runInitWorkspace({ root: workspaceRoot, configPath });
|
||||
expect(result.root).toBe(workspaceRoot);
|
||||
expect(existsSync(workspaceRoot)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads workspace.root from settings when --root is not given", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
store.set("workspace.root", workspaceRoot);
|
||||
const result = runInitWorkspace({ configPath });
|
||||
expect(result.root).toBe(workspaceRoot);
|
||||
expect(existsSync(workspaceRoot)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { ConfigStore, VALID_KEYS } from "../src/config-store.js";
|
||||
import { ConfigStore } from "../src/config-store.js";
|
||||
import { configCommand } from "../src/commands/config.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
@@ -110,26 +110,13 @@ describe("ConfigStore", () => {
|
||||
|
||||
describe("DaemonClient config integration", () => {
|
||||
let tmpDir: string;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "client-config-"));
|
||||
savedEnv = {
|
||||
OPENRIG_PORT: process.env["OPENRIG_PORT"],
|
||||
OPENRIG_HOST: process.env["OPENRIG_HOST"],
|
||||
OPENRIG_DB: process.env["OPENRIG_DB"],
|
||||
};
|
||||
delete process.env["OPENRIG_PORT"];
|
||||
delete process.env["OPENRIG_HOST"];
|
||||
delete process.env["OPENRIG_DB"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it("ConfigStore resolves non-default host/port from config file", async () => {
|
||||
@@ -279,50 +266,6 @@ describe("Config CLI", () => {
|
||||
expect(logs.join("\n")).toContain("transcripts");
|
||||
});
|
||||
|
||||
it("rig config get <key> --json prints value with source metadata", async () => {
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
delete process.env["OPENRIG_HOST"];
|
||||
const cmd = configCommand(join(tmpDir, "config.json"));
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(cmd);
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "config", "get", "daemon.host", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.value).toBe("127.0.0.1");
|
||||
expect(parsed.source).toBe("default");
|
||||
expect(parsed.defaultValue).toBe("127.0.0.1");
|
||||
} finally {
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
it("rig config --json get <key> prints value with source metadata", async () => {
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
delete process.env["OPENRIG_HOST"];
|
||||
const cmd = configCommand(join(tmpDir, "config.json"));
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(cmd);
|
||||
|
||||
try {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "config", "--json", "get", "daemon.host"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.value).toBe("127.0.0.1");
|
||||
expect(parsed.source).toBe("default");
|
||||
expect(parsed.defaultValue).toBe("127.0.0.1");
|
||||
} finally {
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
// Test 10
|
||||
it("rig config --help includes subcommands and examples", () => {
|
||||
const cmd = configCommand(join(tmpDir, "config.json"));
|
||||
@@ -338,58 +281,4 @@ describe("Config CLI", () => {
|
||||
// by checking description and options contain the essential info
|
||||
expect(coreHelp).toContain("config");
|
||||
});
|
||||
|
||||
// V0.3.1 slice 08 — HG-6: rig config --help text must enumerate every
|
||||
// top-level dotted-prefix used in VALID_KEYS. The slice 08 verification
|
||||
// pass caught a drift where ui.preview.* / agents.* / feed.subscriptions.* /
|
||||
// runtime.codex.* + workspace.dogfood_evidence_root +
|
||||
// workspace.operator_seat_name + transcripts.lines +
|
||||
// transcripts.poll_interval_seconds were missing from the help "Keys:"
|
||||
// section. This regression test extracts every top-level prefix from
|
||||
// VALID_KEYS and asserts each appears (or its parent group appears) in
|
||||
// the addHelpText after-text — guards against silent drift when new
|
||||
// keys are added without updating help text.
|
||||
it("HG-6: rig config --help enumerates every VALID_KEYS top-level prefix", () => {
|
||||
const cmd = configCommand(join(tmpDir, "config.json"));
|
||||
// Commander's `addHelpText("after", ...)` text is NOT included by
|
||||
// `helpInformation()` — it's emitted via lifecycle hooks at help
|
||||
// display time. Capture the full displayed help by routing
|
||||
// `outputHelp()` through a custom writer.
|
||||
let combined = "";
|
||||
cmd.configureOutput({
|
||||
writeOut: (text) => {
|
||||
combined += text;
|
||||
},
|
||||
writeErr: () => {},
|
||||
});
|
||||
cmd.outputHelp();
|
||||
|
||||
// Top-level prefixes are the first dotted segment OR first-two
|
||||
// segments for grouped namespaces like `ui.preview.*`,
|
||||
// `feed.subscriptions.*`, `runtime.codex.*`.
|
||||
const prefixes = new Set<string>();
|
||||
for (const key of VALID_KEYS) {
|
||||
const parts = key.split(".");
|
||||
// Group sub-namespaces under their second segment when there are
|
||||
// 3+ segments (e.g. ui.preview.refresh_interval_seconds → "ui.preview").
|
||||
if (parts.length >= 3) {
|
||||
prefixes.add(`${parts[0]}.${parts[1]}`);
|
||||
} else {
|
||||
prefixes.add(parts[0]!);
|
||||
}
|
||||
}
|
||||
// Single-segment leaves (db.path, files.allowlist, progress.scan_roots)
|
||||
// also appear in the help; the test allows either bare prefix OR
|
||||
// the full leaf to satisfy the assertion.
|
||||
const knownLeaves: Record<string, string[]> = {
|
||||
"db": ["db.path"],
|
||||
"files": ["files.allowlist"],
|
||||
"progress": ["progress.scan_roots"],
|
||||
};
|
||||
for (const prefix of prefixes) {
|
||||
const accepted = [prefix, ...(knownLeaves[prefix] ?? [])];
|
||||
const found = accepted.some((needle) => combined.includes(needle));
|
||||
expect(found, `rig config --help text must mention "${prefix}" (or one of ${accepted.join(", ")})`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,357 +0,0 @@
|
||||
// Rig Context / Composable Context Injection v0 (PL-014) — `rig
|
||||
// context-pack` CLI verb family tests.
|
||||
//
|
||||
// Stands up a small in-memory daemon mock for the /api/context-packs/*
|
||||
// surface and exercises each subcommand against it.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { contextPackCommand } from "../src/commands/context-pack.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const errLogs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
|
||||
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try { await fn(); } catch { /* commander.exitOverride */ }
|
||||
const exitCode = process.exitCode;
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, errLogs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function runningDeps(port: number): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
const FIXTURE_PACK = {
|
||||
id: "context-pack:smoke:1",
|
||||
kind: "context-pack" as const,
|
||||
name: "smoke",
|
||||
version: "1",
|
||||
purpose: "Smoke test pack",
|
||||
sourceType: "user_file" as const,
|
||||
sourcePath: "/home/op/.openrig/context-packs/smoke",
|
||||
relativePath: "smoke",
|
||||
updatedAt: "2026-05-04T00:00:00Z",
|
||||
manifestEstimatedTokens: null,
|
||||
derivedEstimatedTokens: 100,
|
||||
files: [
|
||||
{ path: "notes.md", role: "notes", summary: "Smoke notes", absolutePath: "/abs/notes.md", bytes: 50, estimatedTokens: 13 },
|
||||
],
|
||||
};
|
||||
|
||||
const FIXTURE_PREVIEW = {
|
||||
id: FIXTURE_PACK.id,
|
||||
name: FIXTURE_PACK.name,
|
||||
version: FIXTURE_PACK.version,
|
||||
bundleText: "# OpenRig Context Pack: smoke v1\n\nSmoke test pack\n\n## File: notes.md (role: notes) — Smoke notes\n\nSmoke body\n",
|
||||
bundleBytes: 110,
|
||||
estimatedTokens: 28,
|
||||
files: [{ path: "notes.md", role: "notes", bytes: 50, estimatedTokens: 13 }],
|
||||
missingFiles: [] as Array<{ path: string; role: string }>,
|
||||
};
|
||||
|
||||
describe("rig context-pack CLI (PL-014)", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let sendLog: Array<{ id: string; body: unknown }>;
|
||||
let sendBehavior: "ok" | "fail" = "ok";
|
||||
|
||||
beforeAll(async () => {
|
||||
sendLog = [];
|
||||
server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
||||
req.on("end", () => {
|
||||
const url = req.url ?? "";
|
||||
if (url === "/api/context-packs/library" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([FIXTURE_PACK]));
|
||||
return;
|
||||
}
|
||||
if (url === "/api/context-packs/library/sync" && req.method === "POST") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ count: 1, errors: [], entries: [FIXTURE_PACK] }));
|
||||
return;
|
||||
}
|
||||
const idMatch = url.match(/^\/api\/context-packs\/library\/([^/]+)(\/(preview|send))?$/);
|
||||
if (idMatch) {
|
||||
const id = decodeURIComponent(idMatch[1]!);
|
||||
const sub = idMatch[3];
|
||||
if (id !== FIXTURE_PACK.id) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: `Context pack '${id}' not found in library` }));
|
||||
return;
|
||||
}
|
||||
if (sub === "preview" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(FIXTURE_PREVIEW));
|
||||
return;
|
||||
}
|
||||
if (sub === "send" && req.method === "POST") {
|
||||
const parsed = JSON.parse(body || "{}") as { destinationSession?: string; dryRun?: boolean };
|
||||
sendLog.push({ id, body: parsed });
|
||||
if (sendBehavior === "fail") {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Session not found", reason: "session_missing" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
...FIXTURE_PREVIEW,
|
||||
destinationSession: parsed.destinationSession,
|
||||
dryRun: !!parsed.dryRun,
|
||||
...(parsed.dryRun ? {} : { sent: true }),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!sub && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(FIXTURE_PACK));
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => server.close());
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(contextPackCommand(runningDeps(port)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
function writePack(manifest: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "openrig-context-pack-test-"));
|
||||
writeFileSync(join(dir, "manifest.yaml"), manifest);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
"path traversal",
|
||||
`name: invalid-pack
|
||||
version: 1.0.0
|
||||
files:
|
||||
- path: ../secret.md
|
||||
role: notes
|
||||
`,
|
||||
"must be a relative path inside the pack",
|
||||
],
|
||||
[
|
||||
"absolute path",
|
||||
`name: invalid-pack
|
||||
version: 1.0.0
|
||||
files:
|
||||
- path: /etc/passwd
|
||||
role: notes
|
||||
`,
|
||||
"must be a relative path inside the pack",
|
||||
],
|
||||
[
|
||||
"leading backslash",
|
||||
`name: invalid-pack
|
||||
version: 1.0.0
|
||||
files:
|
||||
- path: '\\evil.md'
|
||||
role: notes
|
||||
`,
|
||||
"must be a relative path inside the pack",
|
||||
],
|
||||
[
|
||||
"unknown suffix",
|
||||
`name: invalid-pack
|
||||
version: 1.0.0
|
||||
files:
|
||||
- path: secret.bin
|
||||
role: notes
|
||||
`,
|
||||
"has an unsupported suffix",
|
||||
],
|
||||
[
|
||||
"missing name",
|
||||
`version: 1.0.0
|
||||
files:
|
||||
- path: notes.md
|
||||
role: notes
|
||||
`,
|
||||
"missing required field 'name'",
|
||||
],
|
||||
[
|
||||
"missing version",
|
||||
`name: invalid-pack
|
||||
files:
|
||||
- path: notes.md
|
||||
role: notes
|
||||
`,
|
||||
"missing required field 'version'",
|
||||
],
|
||||
[
|
||||
"missing files",
|
||||
`name: invalid-pack
|
||||
version: 1.0.0
|
||||
`,
|
||||
"must declare 'files: [...]'",
|
||||
],
|
||||
[
|
||||
"missing path",
|
||||
`name: invalid-pack
|
||||
version: 1.0.0
|
||||
files:
|
||||
- role: notes
|
||||
`,
|
||||
"missing 'path'",
|
||||
],
|
||||
[
|
||||
"missing role",
|
||||
`name: invalid-pack
|
||||
version: 1.0.0
|
||||
files:
|
||||
- path: notes.md
|
||||
`,
|
||||
"missing 'role'",
|
||||
],
|
||||
])("rejects invalid context-pack add manifest: %s", async (_name, manifest, expectedError) => {
|
||||
const dir = writePack(manifest);
|
||||
try {
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "add", dir]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toContain(expectedError);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("list shows discovered packs", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "list"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n")).toContain("smoke");
|
||||
expect(logs.join("\n")).toContain("1 files");
|
||||
});
|
||||
|
||||
it("list --json emits JSON", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "list", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("")) as Array<{ name: string }>;
|
||||
expect(parsed[0]!.name).toBe("smoke");
|
||||
});
|
||||
|
||||
it("show resolves by name", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "smoke"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("Name: smoke");
|
||||
expect(logs.join("\n")).toContain("Smoke test pack");
|
||||
});
|
||||
|
||||
it("show fails with helpful error on unknown name", async () => {
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "show", "missing-pack"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toContain("not found in library");
|
||||
});
|
||||
|
||||
it("preview prints the assembled bundle text", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "preview", "smoke"]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("# OpenRig Context Pack: smoke v1");
|
||||
expect(logs.join("\n")).toContain("Smoke body");
|
||||
});
|
||||
|
||||
it("send --dry-run does not invoke the daemon send path twice", async () => {
|
||||
sendLog.length = 0;
|
||||
sendBehavior = "ok";
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig", "--dry-run"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(sendLog).toHaveLength(1);
|
||||
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: true });
|
||||
expect(logs.join("\n")).toContain("(dry-run)");
|
||||
});
|
||||
|
||||
it("send (real) reports successful delivery", async () => {
|
||||
sendLog.length = 0;
|
||||
sendBehavior = "ok";
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "driver@rig"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(sendLog[0]!.body).toEqual({ destinationSession: "driver@rig", dryRun: false });
|
||||
expect(logs.join("\n")).toContain("Sent smoke v1");
|
||||
});
|
||||
|
||||
it("send fails fast when daemon returns 502", async () => {
|
||||
sendLog.length = 0;
|
||||
sendBehavior = "fail";
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "send", "smoke", "missing@rig"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toContain("Session not found");
|
||||
sendBehavior = "ok";
|
||||
});
|
||||
|
||||
it("sync reports indexed count", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "context-pack", "sync"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n")).toContain("Indexed 1 context pack");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getDaemonPath,
|
||||
resolveDaemonPath,
|
||||
buildDaemonEnv,
|
||||
ensureWorkspaceScaffold,
|
||||
STATE_FILE,
|
||||
LOG_FILE,
|
||||
OPENRIG_DIR,
|
||||
@@ -89,25 +88,6 @@ describe("Daemon Lifecycle", () => {
|
||||
expect(opts.detached).toBe(true);
|
||||
});
|
||||
|
||||
it("start: idempotently scaffolds configured workspace before spawning", async () => {
|
||||
const created = new Set<string>();
|
||||
const written = new Map<string, string>();
|
||||
const deps = startableDeps({
|
||||
exists: vi.fn((p: string) => created.has(p) || written.has(p)),
|
||||
mkdirp: vi.fn((p: string) => { created.add(p); }),
|
||||
writeFile: vi.fn((p: string, content: string) => { written.set(p, content); }),
|
||||
});
|
||||
await startDaemon({ port: 7433, db: "openrig.sqlite", workspaceRoot: "/tmp/openrig-workspace" }, deps);
|
||||
|
||||
expect(created.has("/tmp/openrig-workspace")).toBe(true);
|
||||
expect(created.has(path.join("/tmp/openrig-workspace", "missions", "getting-started", "slices", "first-conveyor-run"))).toBe(true);
|
||||
expect(created.has(path.join("/tmp/openrig-workspace", "artifacts"))).toBe(true);
|
||||
expect(created.has(path.join("/tmp/openrig-workspace", "evidence"))).toBe(true);
|
||||
expect(written.get(path.join("/tmp/openrig-workspace", "STEERING.md"))).toContain("Run the `conveyor` starter rig.");
|
||||
expect(written.get(path.join("/tmp/openrig-workspace", "missions", "getting-started", "slices", "first-conveyor-run", "README.md")))
|
||||
.toContain("First Conveyor Run");
|
||||
});
|
||||
|
||||
// Test 3: start waits for healthz, writes daemon.json with pid+port+db+startedAt
|
||||
it("start: writes daemon.json with pid, port, db, startedAt after healthz", async () => {
|
||||
const deps = startableDeps();
|
||||
@@ -937,238 +917,4 @@ describe("buildDaemonEnv", () => {
|
||||
expect(result["OPENRIG_PORT"]).toBe("7433");
|
||||
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
|
||||
});
|
||||
|
||||
// bug-fix slice auth-bearer-tailscale-trust forward-fix:
|
||||
// Default-product-launch must NOT export OPENRIG_HOST so daemon's
|
||||
// index.ts falls through to the loopback+tailscale multi-bind path.
|
||||
describe("auth-bearer-tailscale-trust: explicit-vs-default host signal", () => {
|
||||
it("omits OPENRIG_HOST when opts.host is undefined (default-product-launch)", () => {
|
||||
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
|
||||
const result = buildDaemonEnv(baseEnv, {
|
||||
port: 7433,
|
||||
db: "/tmp/test.db",
|
||||
// host intentionally omitted — operator never set it (default path)
|
||||
});
|
||||
expect(result["OPENRIG_HOST"]).toBeUndefined();
|
||||
// port + db still flow through
|
||||
expect(result["OPENRIG_PORT"]).toBe("7433");
|
||||
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
|
||||
});
|
||||
|
||||
it("sets OPENRIG_HOST when opts.host is explicit (user opt-in)", () => {
|
||||
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
|
||||
const result = buildDaemonEnv(baseEnv, {
|
||||
port: 7433,
|
||||
host: "0.0.0.0",
|
||||
db: "/tmp/test.db",
|
||||
});
|
||||
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
it("does NOT scrub OPENRIG_HOST from baseEnv when opts.host is undefined (operator shell env wins)", () => {
|
||||
// If the operator has OPENRIG_HOST set in their shell, that's an
|
||||
// explicit signal — preserve it through the env-passthrough loop.
|
||||
const baseEnv: Record<string, string> = {
|
||||
HOME: "/Users/tester",
|
||||
PATH: "/usr/bin",
|
||||
OPENRIG_HOST: "0.0.0.0",
|
||||
};
|
||||
const result = buildDaemonEnv(baseEnv, {
|
||||
port: 7433,
|
||||
db: "/tmp/test.db",
|
||||
});
|
||||
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
// Slice 22 founder-walk-vm-populated-env forward-fix #1: the slice
|
||||
// intentionally drops a `--openrig-home` CLI flag in favor of using
|
||||
// the process-env-pattern (each `rig` invocation gets its own
|
||||
// OPENRIG_HOME via the shell). The slice's "fully isolated state"
|
||||
// claim depends on the env contract being honored at module load
|
||||
// time — specifically that getOpenRigHome() returns the current
|
||||
// env value (not a hardcoded default) and that getDefaultOpenRigPath
|
||||
// threads it into derived paths. The module-level constants
|
||||
// (OPENRIG_DIR / STATE_FILE / LOG_FILE) are a single snapshot of
|
||||
// these functions at load.
|
||||
//
|
||||
// These regression tests prove the function contract with distinct-
|
||||
// value discriminators per banked feedback_poc_regression_must_discriminate.
|
||||
describe("slice 22: OPENRIG_HOME env contract for per-process state isolation", () => {
|
||||
it("getOpenRigHome respects process.env.OPENRIG_HOME (distinct values yield distinct paths)", async () => {
|
||||
const { getOpenRigHome } = await import("../src/openrig-compat.js");
|
||||
const saved = process.env["OPENRIG_HOME"];
|
||||
try {
|
||||
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-blank";
|
||||
const blank = getOpenRigHome();
|
||||
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-populated";
|
||||
const populated = getOpenRigHome();
|
||||
expect(blank).toBe("/Users/example/.openrig-blank");
|
||||
expect(populated).toBe("/Users/example/.openrig-populated");
|
||||
expect(blank).not.toBe(populated);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("getDefaultOpenRigPath threads env-resolved OPENRIG_HOME into derived paths (daemon.json + log)", async () => {
|
||||
const { getDefaultOpenRigPath } = await import("../src/openrig-compat.js");
|
||||
const saved = process.env["OPENRIG_HOME"];
|
||||
try {
|
||||
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-blank";
|
||||
const blankState = getDefaultOpenRigPath("daemon.json");
|
||||
const blankLog = getDefaultOpenRigPath("daemon.log");
|
||||
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-populated";
|
||||
const populatedState = getDefaultOpenRigPath("daemon.json");
|
||||
const populatedLog = getDefaultOpenRigPath("daemon.log");
|
||||
expect(blankState).toBe("/Users/example/.openrig-blank/daemon.json");
|
||||
expect(populatedState).toBe("/Users/example/.openrig-populated/daemon.json");
|
||||
expect(blankLog).toBe("/Users/example/.openrig-blank/daemon.log");
|
||||
expect(populatedLog).toBe("/Users/example/.openrig-populated/daemon.log");
|
||||
expect(blankState).not.toBe(populatedState);
|
||||
expect(blankLog).not.toBe(populatedLog);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = saved;
|
||||
}
|
||||
});
|
||||
|
||||
it("empty OPENRIG_HOME falls back to homedir default (not the literal empty string)", async () => {
|
||||
const { getOpenRigHome } = await import("../src/openrig-compat.js");
|
||||
const { homedir } = await import("node:os");
|
||||
const saved = process.env["OPENRIG_HOME"];
|
||||
try {
|
||||
process.env["OPENRIG_HOME"] = "";
|
||||
const result = getOpenRigHome();
|
||||
expect(result).toBe(`${homedir()}/.openrig`);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = saved;
|
||||
}
|
||||
});
|
||||
|
||||
// FF2 regression test (per qitem-20260511115845-ee9a4775): the
|
||||
// prior 3 tests verify the openrig-compat function contract but
|
||||
// do NOT prove daemon-lifecycle's module-level OPENRIG_DIR /
|
||||
// STATE_FILE / LOG_FILE constants pick up OPENRIG_HOME at import
|
||||
// time. Future code could hardcode "~/.openrig" in
|
||||
// daemon-lifecycle.ts:66-69 and the prior tests would still pass.
|
||||
//
|
||||
// This test imports daemon-lifecycle FRESH twice via
|
||||
// vi.resetModules + dynamic import, with distinct OPENRIG_HOME
|
||||
// values per import. Discriminator-distinct paths (per banked
|
||||
// feedback_poc_regression_must_discriminate) prove each module
|
||||
// load reflects the env that was set at that moment. Will fail
|
||||
// if a future contributor hardcodes a literal path or otherwise
|
||||
// bypasses openrig-compat helpers at module level.
|
||||
it("daemon-lifecycle module-level constants reflect OPENRIG_HOME at import time (slice 22 FF2)", async () => {
|
||||
const saved = process.env["OPENRIG_HOME"];
|
||||
try {
|
||||
// First import — OPENRIG_HOME = blank-slate value
|
||||
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-vm-blank-fresh";
|
||||
vi.resetModules();
|
||||
const blank = await import("../src/daemon-lifecycle.js");
|
||||
|
||||
// Second import — OPENRIG_HOME = populated value (distinct dir;
|
||||
// not equal, not a symlink prefix). vi.resetModules clears the
|
||||
// module cache so the second import re-evaluates module-level
|
||||
// constants under the new env.
|
||||
process.env["OPENRIG_HOME"] = "/Users/example/.openrig-vm-populated-fresh";
|
||||
vi.resetModules();
|
||||
const populated = await import("../src/daemon-lifecycle.js");
|
||||
|
||||
// OPENRIG_DIR — direct snapshot of getOpenRigHome() at module load
|
||||
expect(blank.OPENRIG_DIR).toBe("/Users/example/.openrig-vm-blank-fresh");
|
||||
expect(populated.OPENRIG_DIR).toBe("/Users/example/.openrig-vm-populated-fresh");
|
||||
expect(blank.OPENRIG_DIR).not.toBe(populated.OPENRIG_DIR);
|
||||
|
||||
// STATE_FILE / LOG_FILE — derived as path.join(OPENRIG_DIR, "daemon.{json,log}")
|
||||
expect(blank.STATE_FILE).toBe("/Users/example/.openrig-vm-blank-fresh/daemon.json");
|
||||
expect(populated.STATE_FILE).toBe("/Users/example/.openrig-vm-populated-fresh/daemon.json");
|
||||
expect(blank.STATE_FILE).not.toBe(populated.STATE_FILE);
|
||||
|
||||
expect(blank.LOG_FILE).toBe("/Users/example/.openrig-vm-blank-fresh/daemon.log");
|
||||
expect(populated.LOG_FILE).toBe("/Users/example/.openrig-vm-populated-fresh/daemon.log");
|
||||
expect(blank.LOG_FILE).not.toBe(populated.LOG_FILE);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env["OPENRIG_HOME"];
|
||||
else process.env["OPENRIG_HOME"] = saved;
|
||||
// Re-reset modules so subsequent tests (and the file-top
|
||||
// import of daemon-lifecycle at line 4-19) aren't holding
|
||||
// stale references to a module instance bound to a temp env.
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("V1 pre-release CLI/daemon Item 1 — transcript rotation tunables projection", () => {
|
||||
it("projects transcriptsLines + transcriptsPollIntervalSeconds into OPENRIG_* env vars when provided", () => {
|
||||
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
|
||||
const result = buildDaemonEnv(baseEnv, {
|
||||
port: 7433,
|
||||
host: "127.0.0.1",
|
||||
db: "/tmp/test.db",
|
||||
transcriptsLines: 500,
|
||||
transcriptsPollIntervalSeconds: 5,
|
||||
});
|
||||
expect(result["OPENRIG_TRANSCRIPTS_LINES"]).toBe("500");
|
||||
expect(result["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"]).toBe("5");
|
||||
});
|
||||
|
||||
it("omits the transcript rotation env vars when the opts are undefined so the rotation hook falls back to its own defaults", () => {
|
||||
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
|
||||
const result = buildDaemonEnv(baseEnv, {
|
||||
port: 7433,
|
||||
host: "127.0.0.1",
|
||||
db: "/tmp/test.db",
|
||||
});
|
||||
expect(result["OPENRIG_TRANSCRIPTS_LINES"]).toBeUndefined();
|
||||
expect(result["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("scrubs inherited OPENRIG_TRANSCRIPTS_* from the base env when opts override is set, so the daemon honors the file-stored ConfigStore value", () => {
|
||||
const baseEnv: Record<string, string> = {
|
||||
HOME: "/Users/tester",
|
||||
PATH: "/usr/bin",
|
||||
OPENRIG_TRANSCRIPTS_LINES: "9999",
|
||||
OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS: "60",
|
||||
};
|
||||
const result = buildDaemonEnv(baseEnv, {
|
||||
port: 7433,
|
||||
host: "127.0.0.1",
|
||||
db: "/tmp/test.db",
|
||||
transcriptsLines: 500,
|
||||
transcriptsPollIntervalSeconds: 5,
|
||||
});
|
||||
expect(result["OPENRIG_TRANSCRIPTS_LINES"]).toBe("500");
|
||||
expect(result["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"]).toBe("5");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureWorkspaceScaffold", () => {
|
||||
it("does not overwrite existing workspace files", () => {
|
||||
const existing = new Set<string>([
|
||||
"/tmp/ws",
|
||||
path.join("/tmp/ws", "STEERING.md"),
|
||||
path.join("/tmp/ws", "missions", "getting-started", "slices", "first-conveyor-run", "README.md"),
|
||||
]);
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn((p: string) => existing.has(p)),
|
||||
mkdirp: vi.fn((p: string) => { existing.add(p); }),
|
||||
writeFile: vi.fn(),
|
||||
});
|
||||
|
||||
const result = ensureWorkspaceScaffold("/tmp/ws", deps);
|
||||
|
||||
expect(result.rootCreated).toBe(false);
|
||||
expect(result.files.find((f) => f.relPath === "STEERING.md")?.skipped).toBe("exists");
|
||||
expect(result.files.find((f) => f.relPath === "missions/getting-started/slices/first-conveyor-run/README.md")?.skipped)
|
||||
.toBe("exists");
|
||||
expect(deps.writeFile).not.toHaveBeenCalledWith(
|
||||
path.join("/tmp/ws", "STEERING.md"),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,10 @@ function makeDeps(overrides?: Partial<DoctorDeps>): DoctorDeps {
|
||||
describe("runDoctorChecks", () => {
|
||||
it("monorepo command-dir base resolves via the same daemon root seam as daemon start", () => {
|
||||
const deps = makeDeps({
|
||||
baseDir: "/Users/example/code/openrig/packages/cli/src/commands",
|
||||
baseDir: "/Users/mschwarz/code/rigged/packages/cli/src/commands",
|
||||
exists: (p) =>
|
||||
p === "/Users/example/code/openrig/packages/daemon/dist/index.js"
|
||||
|| p === "/Users/example/code/openrig/packages/ui/dist/index.html",
|
||||
p === "/Users/mschwarz/code/rigged/packages/daemon/dist/index.js"
|
||||
|| p === "/Users/mschwarz/code/rigged/packages/ui/dist/index.html",
|
||||
});
|
||||
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
|
||||
@@ -124,7 +124,6 @@ function createMockDaemon() {
|
||||
if (req.method === "POST" && url.pathname === "/api/rigs/import") {
|
||||
capturedImportHeaders = {
|
||||
"x-rig-root": req.headers["x-rig-root"],
|
||||
"x-cwd-override": req.headers["x-cwd-override"],
|
||||
};
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => { body += c.toString(); });
|
||||
@@ -145,7 +144,6 @@ function createMockDaemon() {
|
||||
capturedImportHeaders = {
|
||||
"x-rig-root": req.headers["x-rig-root"],
|
||||
"x-target-rig-id": req.headers["x-target-rig-id"],
|
||||
"x-cwd-override": req.headers["x-cwd-override"],
|
||||
};
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
@@ -300,16 +298,6 @@ describe("rig export + import", () => {
|
||||
expect(capturedImportHeaders["x-target-rig-id"]).toBe("rig-123");
|
||||
});
|
||||
|
||||
it("import --materialize-only with --cwd forwards absolute cwd override", async () => {
|
||||
capturedImportHeaders = {};
|
||||
const deps = importDeps(`version: "0.2"\nname: test\npods: []\n`);
|
||||
const program = new Command();
|
||||
program.addCommand(importCommand(deps));
|
||||
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--materialize-only", "--cwd", "relative/project", "--rig-root", "/tmp"]));
|
||||
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
|
||||
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
|
||||
});
|
||||
|
||||
// Test 8: import --instantiate with preflight fail (409)
|
||||
it("import --instantiate: preflight conflict (409)", async () => {
|
||||
const deps = importDeps("CONFLICT");
|
||||
@@ -435,16 +423,6 @@ describe("rig export + import", () => {
|
||||
expect(capturedImportHeaders["x-rig-root"]).toMatch(/\/my\/project/);
|
||||
});
|
||||
|
||||
it("import --instantiate with --cwd forwards absolute cwd override", async () => {
|
||||
capturedImportHeaders = {};
|
||||
const deps = importDeps("schema_version: 1\nname: test\npods:\n - name: pod-a\n");
|
||||
const program = new Command();
|
||||
program.addCommand(importCommand(deps));
|
||||
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate", "--cwd", "relative/project", "--rig-root", "/my/project"]));
|
||||
expect(capturedImportHeaders["x-cwd-override"]).toMatch(/^\//);
|
||||
expect(String(capturedImportHeaders["x-cwd-override"])).toContain("relative/project");
|
||||
});
|
||||
|
||||
// NS-T14: import --instantiate handoff includes attach command
|
||||
it("import --instantiate success shows attach command", async () => {
|
||||
const deps: ImportDeps = {
|
||||
|
||||
@@ -35,7 +35,7 @@ hosts:
|
||||
- id: vm-claude-test
|
||||
transport: ssh
|
||||
target: vm-claude-test.local
|
||||
user: tester
|
||||
user: wrandom
|
||||
notes: "Tart VM"
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
@@ -46,7 +46,7 @@ hosts:
|
||||
id: "vm-claude-test",
|
||||
transport: "ssh",
|
||||
target: "vm-claude-test.local",
|
||||
user: "tester",
|
||||
user: "wrandom",
|
||||
notes: "Tart VM",
|
||||
});
|
||||
}
|
||||
@@ -62,14 +62,14 @@ hosts:
|
||||
- id: laptop-b
|
||||
transport: ssh
|
||||
target: laptop-b.tail-scale-net
|
||||
user: tester
|
||||
user: wrandom
|
||||
`, (path) => {
|
||||
const r = loadHostRegistry(path);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.registry.hosts.map((h) => h.id)).toEqual(["vm-a", "laptop-b"]);
|
||||
expect(r.registry.hosts[0]?.user).toBeUndefined();
|
||||
expect(r.registry.hosts[1]?.user).toBe("tester");
|
||||
expect(r.registry.hosts[1]?.user).toBe("wrandom");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,781 +0,0 @@
|
||||
// rig plugin CLI commands (slice 3.4) — list/show/used-by/validate.
|
||||
//
|
||||
// Per IMPL-PRD §4 + DESIGN.md §6 (CLI minimalism: read-only inspection only).
|
||||
// Consumes plugin-discovery-service from slice 3.3 via HTTP routes
|
||||
// (GET /api/plugins, GET /api/plugins/:id, GET /api/plugins/:id/used-by).
|
||||
// `validate` is local file inspection — reuses agent-manifest's
|
||||
// validatePluginResources logic + skill frontmatter checks.
|
||||
//
|
||||
// All commands ship --json output for agent consumption per
|
||||
// banked building-agent-software + IMPL-PRD §4.4 HG-4.5.
|
||||
//
|
||||
// Test pattern follows context-pack.test.ts (slice 3.4 mirror) — in-memory
|
||||
// http.createServer daemon mock + Commander.parseAsync via captureLogs.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import http from "node:http";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Command } from "commander";
|
||||
import { pluginCommand } from "../src/commands/plugin.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const errLogs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
|
||||
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try { await fn(); } catch { /* commander.exitOverride */ }
|
||||
const exitCode = process.exitCode;
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, errLogs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function runningDeps(port: number): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
// Fixtures mirror the slice 3.3 PluginDiscoveryService wire shape verbatim
|
||||
// (packages/daemon/src/domain/plugin-discovery-service.ts L41-132). Any drift
|
||||
// from that source means the daemon contract changed and these fixtures
|
||||
// must update in lockstep with the CLI wire types in plugin.ts.
|
||||
|
||||
const FIXTURE_PLUGIN_ENTRY = {
|
||||
id: "openrig-core",
|
||||
name: "openrig-core",
|
||||
version: "0.1.0",
|
||||
description: "OpenRig canonical skills and hooks",
|
||||
source: "vendored" as const,
|
||||
sourceLabel: "vendored:openrig-core",
|
||||
runtimes: ["claude", "codex"] as const,
|
||||
path: "/home/op/.openrig/plugins/openrig-core",
|
||||
lastSeenAt: "2026-05-11T05:00:00Z",
|
||||
};
|
||||
|
||||
const FIXTURE_PLUGIN_DETAIL = {
|
||||
entry: FIXTURE_PLUGIN_ENTRY,
|
||||
claudeManifest: {
|
||||
raw: { name: "openrig-core", version: "0.1.0", description: "OpenRig canonical skills and hooks", license: "Apache-2.0", repository: "github:mvschwarz/openrig-plugins" },
|
||||
name: "openrig-core",
|
||||
version: "0.1.0",
|
||||
description: "OpenRig canonical skills and hooks",
|
||||
homepage: null,
|
||||
repository: "github:mvschwarz/openrig-plugins",
|
||||
license: "Apache-2.0",
|
||||
},
|
||||
codexManifest: {
|
||||
raw: { name: "openrig-core", version: "0.1.0", description: "OpenRig canonical skills and hooks", license: "Apache-2.0" },
|
||||
name: "openrig-core",
|
||||
version: "0.1.0",
|
||||
description: "OpenRig canonical skills and hooks",
|
||||
homepage: null,
|
||||
repository: null,
|
||||
license: "Apache-2.0",
|
||||
},
|
||||
skills: [
|
||||
{ name: "openrig-user", relativePath: "skills/openrig-user" },
|
||||
{ name: "openrig-architect", relativePath: "skills/openrig-architect" },
|
||||
],
|
||||
hooks: [
|
||||
{ runtime: "claude" as const, relativePath: "hooks/claude.json", events: ["SessionStart", "UserPromptSubmit", "Stop", "Notification"] },
|
||||
{ runtime: "codex" as const, relativePath: "hooks/codex.json", events: ["SessionStart", "UserPromptSubmit", "Stop"] },
|
||||
],
|
||||
mcpServers: [],
|
||||
};
|
||||
|
||||
const FIXTURE_USED_BY = [
|
||||
{
|
||||
agentName: "advisor-lead",
|
||||
sourcePath: "/home/op/.openrig/specs/agents/advisor/lead/agent.yaml",
|
||||
profiles: ["default"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("rig plugin CLI (slice 3.4)", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let lastQuery: Record<string, string | undefined>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://localhost`);
|
||||
lastQuery = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
if (url.pathname === "/api/plugins" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([FIXTURE_PLUGIN_ENTRY]));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/plugins/openrig-core" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(FIXTURE_PLUGIN_DETAIL));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/plugins/openrig-core/used-by" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(FIXTURE_USED_BY));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/plugins/unreferenced/used-by" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([]));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/plugins/missing" && req.method === "GET") {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "plugin_not_found" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not_found" }));
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
port = (server.address() as { port: number }).port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
lastQuery = {};
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// rig plugin list (HG-4.1)
|
||||
// ============================================================
|
||||
|
||||
describe("rig plugin list", () => {
|
||||
it("--json returns the plugin entries from /api/plugins", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "list", "--json"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const parsed = JSON.parse(logs.join("\n")) as Array<{ id: string }>;
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.id).toBe("openrig-core");
|
||||
});
|
||||
|
||||
it("default (table) output prints id + version + runtimes + source label + path (real PluginEntry fields)", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "list"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("openrig-core");
|
||||
expect(out).toContain("0.1.0");
|
||||
expect(out).toContain("vendored:openrig-core");
|
||||
// Regression: real path field, not invented rootPath
|
||||
expect(out).toContain("/home/op/.openrig/plugins/openrig-core");
|
||||
// Regression: must NOT print invented count fields (they don't exist on PluginEntry)
|
||||
expect(out).not.toMatch(/\bskillCount\b/);
|
||||
expect(out).not.toMatch(/\bhookEventCount\b/);
|
||||
expect(out).not.toMatch(/\bmcpServerCount\b/);
|
||||
});
|
||||
|
||||
it("--runtime claude passes runtime filter to the daemon route", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "claude", "--json"]);
|
||||
});
|
||||
|
||||
expect(lastQuery.runtime).toBe("claude");
|
||||
});
|
||||
|
||||
it("--source vendored passes source filter to the daemon route", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "list", "--source", "vendored", "--json"]);
|
||||
});
|
||||
|
||||
expect(lastQuery.source).toBe("vendored");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// rig plugin show <id> (HG-4.2)
|
||||
// ============================================================
|
||||
|
||||
describe("rig plugin show <id>", () => {
|
||||
it("--json returns the plugin detail from /api/plugins/:id", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "show", "openrig-core", "--json"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const parsed = JSON.parse(logs.join("\n")) as { entry: { id: string; path: string; lastSeenAt: string | null }; skills: unknown[]; hooks: unknown[] };
|
||||
expect(parsed.entry.id).toBe("openrig-core");
|
||||
expect(parsed.entry.path).toBe("/home/op/.openrig/plugins/openrig-core"); // real PluginEntry.path
|
||||
expect(parsed.skills).toHaveLength(2);
|
||||
expect(parsed.hooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("default (pretty) output uses REAL PluginDetail fields (name + relativePath + events array)", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "show", "openrig-core"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const out = logs.join("\n");
|
||||
|
||||
// PluginEntry fields
|
||||
expect(out).toContain("openrig-core");
|
||||
expect(out).toContain("0.1.0");
|
||||
expect(out).toContain("/home/op/.openrig/plugins/openrig-core"); // entry.path
|
||||
expect(out).toContain("2026-05-11T05:00:00Z"); // entry.lastSeenAt
|
||||
|
||||
// Manifests — real PluginManifestSummary fields
|
||||
expect(out).toMatch(/claude:/);
|
||||
expect(out).toMatch(/codex:/);
|
||||
expect(out).toContain("Apache-2.0");
|
||||
expect(out).toContain("github:mvschwarz/openrig-plugins");
|
||||
|
||||
// Skills — uses skill.name + skill.relativePath (not invented id/path/description)
|
||||
expect(out).toContain("openrig-user");
|
||||
expect(out).toContain("openrig-architect");
|
||||
expect(out).toContain("skills/openrig-user");
|
||||
expect(out).toContain("skills/openrig-architect");
|
||||
|
||||
// Hooks — uses runtime + events array (not invented eventCount alone) +
|
||||
// relativePath
|
||||
expect(out).toContain("hooks/claude.json");
|
||||
expect(out).toContain("hooks/codex.json");
|
||||
expect(out).toContain("SessionStart");
|
||||
expect(out).toContain("UserPromptSubmit");
|
||||
expect(out).toContain("Notification"); // Claude event count = 4, includes Notification
|
||||
});
|
||||
|
||||
it("missing plugin id returns non-zero exit + error to stderr", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "show", "missing"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toMatch(/not.*found|404|missing/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// rig plugin used-by <id> (HG-4.3)
|
||||
//
|
||||
// Reverse query: AgentReference[] = { agentName, sourcePath, profiles[] }
|
||||
// (PluginDiscoveryService L134-141 verbatim shape).
|
||||
// Daemon route: GET /api/plugins/:id/used-by
|
||||
// ============================================================
|
||||
|
||||
describe("rig plugin used-by <id>", () => {
|
||||
it("--json returns the AgentReference[] from /api/plugins/:id/used-by", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "used-by", "openrig-core", "--json"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const parsed = JSON.parse(logs.join("\n")) as Array<{ agentName: string; sourcePath: string; profiles: string[] }>;
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0]?.agentName).toBe("advisor-lead");
|
||||
expect(parsed[0]?.sourcePath).toBe("/home/op/.openrig/specs/agents/advisor/lead/agent.yaml");
|
||||
expect(parsed[0]?.profiles).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("default (pretty) output prints agentName + sourcePath + profiles for each reference", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "used-by", "openrig-core"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const out = logs.join("\n");
|
||||
// Real AgentReference fields verbatim — no invented fields
|
||||
expect(out).toContain("advisor-lead");
|
||||
expect(out).toContain("/home/op/.openrig/specs/agents/advisor/lead/agent.yaml");
|
||||
expect(out).toContain("default");
|
||||
});
|
||||
|
||||
it("empty result (no agents reference the plugin) is honest — does NOT exit non-zero", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
// Use 'unreferenced' which the http server returns [] for
|
||||
await program.parseAsync(["node", "rig", "plugin", "used-by", "unreferenced"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const out = logs.join("\n");
|
||||
// Some honest message acknowledging zero refs (not crash, not silent empty)
|
||||
expect(out.toLowerCase()).toMatch(/no agent|0|none|not referenced/);
|
||||
});
|
||||
|
||||
it("--json on empty result returns []", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "used-by", "unreferenced", "--json"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const parsed = JSON.parse(logs.join("\n")) as unknown[];
|
||||
expect(parsed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// rig plugin list — filter-value validation (pre-close punch from
|
||||
// velocity-guard 3.4.A repair verdict)
|
||||
//
|
||||
// CLI-side validation so typos like --source rig-cwd don't look
|
||||
// filtered while the daemon silently returns all plugins.
|
||||
// ============================================================
|
||||
|
||||
describe("rig plugin list — filter validation", () => {
|
||||
it("rejects invalid --runtime value with clear error + non-zero exit", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "bogus"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n");
|
||||
expect(err).toMatch(/--runtime/);
|
||||
expect(err).toMatch(/bogus/);
|
||||
// Should name the allowed values so operator can correct
|
||||
expect(err.toLowerCase()).toMatch(/claude|codex/);
|
||||
});
|
||||
|
||||
it("rejects invalid --source value (rig-cwd typo case) with clear error + non-zero exit", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "list", "--source", "rig-cwd"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n");
|
||||
expect(err).toMatch(/--source/);
|
||||
expect(err).toMatch(/rig-cwd/);
|
||||
// Should name the allowed values
|
||||
expect(err).toMatch(/vendored|claude-cache|codex-cache/);
|
||||
});
|
||||
|
||||
it("accepts valid --runtime + --source values (regression: still passes through correctly)", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand(runningDeps(port)));
|
||||
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "list", "--runtime", "claude", "--source", "vendored", "--json"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(lastQuery.runtime).toBe("claude");
|
||||
expect(lastQuery.source).toBe("vendored");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// rig plugin validate <path> (HG-4.4)
|
||||
//
|
||||
// Local file inspection — does NOT require daemon. Validates:
|
||||
// - .claude-plugin/plugin.json + .codex-plugin/plugin.json shape
|
||||
// (name + version required; description required on Codex)
|
||||
// - At least one manifest dir is present
|
||||
// - Each skills/<id>/SKILL.md has frontmatter with name (non-empty)
|
||||
// + description (non-empty, ≤1024 chars per agentskills.io)
|
||||
//
|
||||
// Output: error list; exit code 0 when valid, 1 when any errors.
|
||||
// ============================================================
|
||||
|
||||
describe("rig plugin validate <path>", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "plugin-validate-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeManifest(relativePath: string, contents: Record<string, unknown>) {
|
||||
const fullPath = join(tmpRoot, relativePath);
|
||||
mkdirSync(dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, JSON.stringify(contents, null, 2));
|
||||
}
|
||||
|
||||
function writeSkill(skillId: string, frontmatter: string, body = "# body\n") {
|
||||
const skillPath = join(tmpRoot, "skills", skillId, "SKILL.md");
|
||||
mkdirSync(dirname(skillPath), { recursive: true });
|
||||
writeFileSync(skillPath, `---\n${frontmatter}\n---\n\n${body}`);
|
||||
}
|
||||
|
||||
it("valid dual-manifest plugin tree passes validation (exit 0)", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", {
|
||||
name: "test-plugin", version: "1.0.0", description: "Test",
|
||||
});
|
||||
writeManifest(".codex-plugin/plugin.json", {
|
||||
name: "test-plugin", version: "1.0.0", description: "Test",
|
||||
});
|
||||
writeSkill("foo", "name: foo\ndescription: A foo skill");
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, logs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n").toLowerCase()).toMatch(/valid|ok|pass/);
|
||||
});
|
||||
|
||||
it("plugin with no manifest dirs at all FAILS validation", async () => {
|
||||
writeSkill("foo", "name: foo\ndescription: A foo skill");
|
||||
// No .claude-plugin/ + no .codex-plugin/
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n").toLowerCase()).toMatch(/manifest|plugin\.json/);
|
||||
});
|
||||
|
||||
it("Codex manifest missing description FAILS (Codex requires description)", async () => {
|
||||
writeManifest(".codex-plugin/plugin.json", {
|
||||
name: "test-plugin", version: "1.0.0",
|
||||
// description MISSING
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n").toLowerCase()).toMatch(/codex.*description|description.*codex/);
|
||||
});
|
||||
|
||||
it("manifest missing required name FAILS", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", {
|
||||
version: "1.0.0", description: "Test",
|
||||
// name MISSING
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n").toLowerCase()).toMatch(/name/);
|
||||
});
|
||||
|
||||
it("skill missing frontmatter name FAILS", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", {
|
||||
name: "test-plugin", version: "1.0.0",
|
||||
});
|
||||
writeSkill("bad-skill", "description: a description but no name");
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/bad-skill/);
|
||||
expect(err).toMatch(/name/);
|
||||
});
|
||||
|
||||
it("skill frontmatter description >1024 chars FAILS (agentskills.io limit)", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", {
|
||||
name: "test-plugin", version: "1.0.0",
|
||||
});
|
||||
const longDescription = "x".repeat(1100);
|
||||
writeSkill("verbose", `name: verbose\ndescription: ${longDescription}`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/verbose/);
|
||||
expect(err).toMatch(/1024|description.*length|too long/);
|
||||
});
|
||||
|
||||
it("--json output returns structured { valid, errors } shape", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", {
|
||||
name: "test-plugin", version: "1.0.0",
|
||||
});
|
||||
writeSkill("foo", "name: foo\ndescription: A foo skill");
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, logs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot, "--json"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const parsed = JSON.parse(logs.join("\n")) as { valid: boolean; errors: string[] };
|
||||
expect(parsed.valid).toBe(true);
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("non-existent path FAILS with clear error", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", join(tmpRoot, "nonexistent")]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n").toLowerCase()).toMatch(/not found|does not exist|enoent/);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// velocity-guard 3.4.C BLOCKING-CONCERN regressions:
|
||||
// regex-based validation could stamp `name: ""` quoted-empty as
|
||||
// valid; YAML-parser-based validation catches it. Also null/non-
|
||||
// object manifest results.
|
||||
// ============================================================
|
||||
|
||||
it("BLOCKING-FIX: skill with quoted-empty name FAILS (regex would have passed)", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
|
||||
writeSkill("empty-name", `name: ""\ndescription: A real description`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/empty-name/);
|
||||
expect(err).toMatch(/name/);
|
||||
});
|
||||
|
||||
it("BLOCKING-FIX: skill with quoted-empty description FAILS (regex would have passed)", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
|
||||
writeSkill("empty-desc", `name: empty-desc\ndescription: ""`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/empty-desc/);
|
||||
expect(err).toMatch(/description/);
|
||||
});
|
||||
|
||||
it("BLOCKING-FIX: skill with malformed YAML frontmatter FAILS (not parseable as object)", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
|
||||
// Malformed: unclosed bracket
|
||||
writeSkill("bad-yaml", `name: foo\ndescription: [unclosed`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/bad-yaml/);
|
||||
expect(err).toMatch(/yaml|parse|invalid/);
|
||||
});
|
||||
|
||||
it("BLOCKING-FIX: skill with non-object frontmatter (e.g. just a string) FAILS", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
|
||||
// YAML scalar — parses as string, not object
|
||||
writeSkill("not-object", `just a string`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/not-object/);
|
||||
expect(err).toMatch(/object|map/);
|
||||
});
|
||||
|
||||
it("BLOCKING-FIX: skill name as non-string (e.g. number) FAILS", async () => {
|
||||
writeManifest(".claude-plugin/plugin.json", { name: "test-plugin", version: "1.0.0" });
|
||||
// YAML parses 42 as a number, not string
|
||||
writeSkill("number-name", `name: 42\ndescription: A skill`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/number-name/);
|
||||
expect(err).toMatch(/name/);
|
||||
});
|
||||
|
||||
it("BLOCKING-FIX: manifest plugin.json containing null FAILS (not crashes)", async () => {
|
||||
// Write literal "null" as the manifest body — JSON.parse returns null
|
||||
const claudeManifestPath = join(tmpRoot, ".claude-plugin", "plugin.json");
|
||||
mkdirSync(dirname(claudeManifestPath), { recursive: true });
|
||||
writeFileSync(claudeManifestPath, "null");
|
||||
writeSkill("foo", `name: foo\ndescription: A skill`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/manifest|object/);
|
||||
});
|
||||
|
||||
it("BLOCKING-FIX: manifest plugin.json containing array FAILS (must be object)", async () => {
|
||||
const claudeManifestPath = join(tmpRoot, ".claude-plugin", "plugin.json");
|
||||
mkdirSync(dirname(claudeManifestPath), { recursive: true });
|
||||
writeFileSync(claudeManifestPath, JSON.stringify(["not", "an", "object"]));
|
||||
writeSkill("foo", `name: foo\ndescription: A skill`);
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(pluginCommand());
|
||||
|
||||
const { exitCode, errLogs } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "plugin", "validate", tmpRoot]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
const err = errLogs.join("\n").toLowerCase();
|
||||
expect(err).toMatch(/manifest|object/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
// PL-012 Token / Context Usage Surface v0 — `rig ps --nodes --filter
|
||||
// contextUsage.percent>=N` + `contextUsage.state=…` + `--fields contextUsage`.
|
||||
// Mirrors the PL-019 mock-daemon harness so this stays independent of
|
||||
// any other slice in flight.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { psCommand } from "../src/commands/ps.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const errLogs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
|
||||
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try { await fn(); } catch { /* commander throws on exitOverride */ }
|
||||
const exitCode = process.exitCode;
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, errLogs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function runningDeps(port: number): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function nodeAt(logicalId: string, percent: number | null, fresh = true) {
|
||||
return {
|
||||
rigId: "rig-1",
|
||||
rigName: "demo",
|
||||
logicalId,
|
||||
podId: "p-1",
|
||||
canonicalSessionName: `demo-${logicalId}`,
|
||||
nodeKind: "agent" as const,
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
startupStatus: "ready" as const,
|
||||
restoreOutcome: "n-a",
|
||||
tmuxAttachCommand: null,
|
||||
resumeCommand: null,
|
||||
latestError: null,
|
||||
contextUsage: {
|
||||
availability: percent === null ? "unknown" : "known",
|
||||
usedPercentage: percent,
|
||||
fresh,
|
||||
sampledAt: percent === null ? null : "2026-05-04T12:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("PL-012 ps --filter contextUsage.* + CTX field", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let nodesByRig: Record<string, unknown[]>;
|
||||
|
||||
beforeAll(async () => {
|
||||
nodesByRig = {};
|
||||
server = http.createServer((req, res) => {
|
||||
const m = req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes(?:\?.*)?$/);
|
||||
if (m && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(nodesByRig[decodeURIComponent(m[1]!)] ?? []));
|
||||
} else if (req.url === "/api/ps" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ rigId: "rig-1", name: "demo", nodeCount: 4, runningCount: 4, status: "running", uptime: "1h", latestSnapshot: null }]));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(psCommand(runningDeps(port)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("--filter contextUsage.percent>=80 narrows to critical seats", async () => {
|
||||
nodesByRig["rig-1"] = [
|
||||
nodeAt("alpha", 90),
|
||||
nodeAt("beta", 65),
|
||||
nodeAt("gamma", 30),
|
||||
nodeAt("delta", null),
|
||||
];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent>=80"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("--filter contextUsage.percent<60 picks low/ok seats only", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 90), nodeAt("beta", 65), nodeAt("gamma", 30)];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent<60"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["gamma"]);
|
||||
});
|
||||
|
||||
it("--filter contextUsage.percent>=N excludes nodes with no sample (unknown availability)", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 90), nodeAt("delta", null)];
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent>=0"]);
|
||||
});
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("--filter contextUsage.state=critical maps tier semantics correctly", async () => {
|
||||
nodesByRig["rig-1"] = [
|
||||
nodeAt("alpha", 90), // critical
|
||||
nodeAt("beta", 65), // warning
|
||||
nodeAt("gamma", 30), // low
|
||||
nodeAt("delta", null),// unknown
|
||||
];
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.state=critical"]);
|
||||
});
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("--filter contextUsage.state=unknown picks no-sample seats", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 90), nodeAt("delta", null)];
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.state=unknown"]);
|
||||
});
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries.map((e: { logicalId: string }) => e.logicalId)).toEqual(["delta"]);
|
||||
});
|
||||
|
||||
it("--filter contextUsage.percent>=invalid fails fast with three-part error and exit 1", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 90)];
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.percent>=banana"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
const out = errLogs.join("\n");
|
||||
expect(out).toContain("contextUsage.percent>='banana'");
|
||||
expect(out).toContain("a finite number");
|
||||
expect(out).toContain("rig ps --nodes --fields contextUsage --json");
|
||||
});
|
||||
|
||||
it("--filter contextUsage.state=bogus fails fast with three-part error and exit 1", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 90)];
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "contextUsage.state=lol-no"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
const out = errLogs.join("\n");
|
||||
expect(out).toContain("contextUsage.state='lol-no'");
|
||||
expect(out).toContain("critical, low, unknown, warning");
|
||||
expect(out).toContain("rig ps --nodes --fields contextUsage --json");
|
||||
});
|
||||
|
||||
it("--filter contextUsage.percent>=N rejects equality-only on non-numeric keys cleanly", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 90)];
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "name>=demo"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toContain("numeric comparator on a non-numeric key");
|
||||
});
|
||||
|
||||
it("--fields contextUsage projects only the contextUsage column", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 73)];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--fields", "logicalId,contextUsage"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries[0]).toEqual({ logicalId: "alpha", contextUsage: expect.objectContaining({ usedPercentage: 73 }) });
|
||||
});
|
||||
|
||||
it("CTX column appears in human output", async () => {
|
||||
nodesByRig["rig-1"] = [nodeAt("alpha", 73), nodeAt("delta", null)];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("CTX");
|
||||
expect(out).toContain("73%");
|
||||
expect(out).toContain("??"); // unknown placeholder
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
// PL-019 item 1: --filter agentActivity.state=<state> + --active sugar.
|
||||
// Focused tests; the existing ps.test.ts covers the broader filter
|
||||
// machinery surface. We mirror its mock-daemon harness so this stays
|
||||
// independent of any other slice in flight.
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { psCommand } from "../src/commands/ps.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; errLogs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const errLogs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
|
||||
console.error = (...args: unknown[]) => { errLogs.push(args.map(String).join(" ")); };
|
||||
process.exitCode = undefined;
|
||||
try { await fn(); } catch { /* commander throws on exitOverride; logged separately */ }
|
||||
const exitCode = process.exitCode;
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, errLogs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function runningDeps(port: number): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-05-04T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
const NODE_RUNNING = {
|
||||
rigId: "rig-1",
|
||||
rigName: "demo",
|
||||
logicalId: "alpha",
|
||||
podId: "p-1",
|
||||
canonicalSessionName: "demo-alpha",
|
||||
nodeKind: "agent" as const,
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
startupStatus: "ready" as const,
|
||||
restoreOutcome: "n-a",
|
||||
tmuxAttachCommand: null,
|
||||
resumeCommand: null,
|
||||
latestError: null,
|
||||
agentActivity: {
|
||||
state: "running" as const,
|
||||
reason: "mid_work_pattern",
|
||||
evidenceSource: "pane_heuristic",
|
||||
sampledAt: "2026-05-04T12:00:00.000Z",
|
||||
evidence: "Working on x",
|
||||
},
|
||||
};
|
||||
|
||||
const NODE_IDLE = {
|
||||
rigId: "rig-1",
|
||||
rigName: "demo",
|
||||
logicalId: "beta",
|
||||
podId: "p-1",
|
||||
canonicalSessionName: "demo-beta",
|
||||
nodeKind: "agent" as const,
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
startupStatus: "ready" as const,
|
||||
restoreOutcome: "n-a",
|
||||
tmuxAttachCommand: null,
|
||||
resumeCommand: null,
|
||||
latestError: null,
|
||||
agentActivity: {
|
||||
state: "idle" as const,
|
||||
reason: "idle_prompt",
|
||||
evidenceSource: "pane_heuristic",
|
||||
sampledAt: "2026-05-04T12:00:00.000Z",
|
||||
evidence: "$ ",
|
||||
},
|
||||
};
|
||||
|
||||
const NODE_NEEDS_INPUT = {
|
||||
rigId: "rig-1",
|
||||
rigName: "demo",
|
||||
logicalId: "gamma",
|
||||
podId: "p-1",
|
||||
canonicalSessionName: "demo-gamma",
|
||||
nodeKind: "agent" as const,
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
startupStatus: "ready" as const,
|
||||
restoreOutcome: "n-a",
|
||||
tmuxAttachCommand: null,
|
||||
resumeCommand: null,
|
||||
latestError: null,
|
||||
agentActivity: {
|
||||
state: "needs_input" as const,
|
||||
reason: "approval_pending",
|
||||
evidenceSource: "pane_heuristic",
|
||||
sampledAt: "2026-05-04T12:00:00.000Z",
|
||||
evidence: "Approve?",
|
||||
},
|
||||
};
|
||||
|
||||
const NODE_UNKNOWN = {
|
||||
rigId: "rig-1",
|
||||
rigName: "demo",
|
||||
logicalId: "delta",
|
||||
podId: "p-1",
|
||||
canonicalSessionName: "demo-delta",
|
||||
nodeKind: "agent" as const,
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
startupStatus: "ready" as const,
|
||||
restoreOutcome: "n-a",
|
||||
tmuxAttachCommand: null,
|
||||
resumeCommand: null,
|
||||
latestError: null,
|
||||
agentActivity: {
|
||||
state: "unknown" as const,
|
||||
reason: "capture_failed",
|
||||
evidenceSource: "pane_heuristic",
|
||||
sampledAt: "2026-05-04T12:00:00.000Z",
|
||||
evidence: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("PL-019 ps --filter agentActivity.state + --active", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let nodesByRig: Record<string, unknown[]>;
|
||||
|
||||
beforeAll(async () => {
|
||||
nodesByRig = {};
|
||||
server = http.createServer((req, res) => {
|
||||
const m = req.url?.match(/^\/api\/rigs\/([^/]+)\/nodes(?:\?.*)?$/);
|
||||
if (m && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(nodesByRig[decodeURIComponent(m[1]!)] ?? []));
|
||||
} else if (req.url === "/api/ps" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ rigId: "rig-1", name: "demo", nodeCount: 4, runningCount: 4, status: "running", uptime: "1h", latestSnapshot: null }]));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => server.close());
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(psCommand(runningDeps(port)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("--filter agentActivity.state=running narrows to the running node", async () => {
|
||||
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=running"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].logicalId).toBe("alpha");
|
||||
expect(entries[0].agentActivity.state).toBe("running");
|
||||
});
|
||||
|
||||
it("--active is identical to --filter agentActivity.state=running on same fixture", async () => {
|
||||
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
|
||||
const a = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=running"]);
|
||||
});
|
||||
const b = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active"]);
|
||||
});
|
||||
expect(a.exitCode).toBeUndefined();
|
||||
expect(b.exitCode).toBeUndefined();
|
||||
expect(a.logs.join("")).toBe(b.logs.join(""));
|
||||
});
|
||||
|
||||
it("--filter agentActivity.state=needs_input picks the amber-state node", async () => {
|
||||
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_IDLE, NODE_NEEDS_INPUT, NODE_UNKNOWN];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=needs_input"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].logicalId).toBe("gamma");
|
||||
});
|
||||
|
||||
it("--filter agentActivity.state=invalid fails fast with three-part error and exit 1", async () => {
|
||||
nodesByRig["rig-1"] = [NODE_RUNNING];
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--filter", "agentActivity.state=lol-no"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
const errOutput = errLogs.join("\n");
|
||||
// What failed
|
||||
expect(errOutput).toContain("agentActivity.state='lol-no'");
|
||||
// What's allowed (sorted enum)
|
||||
expect(errOutput).toContain("idle, needs_input, running, unknown");
|
||||
// What to do (next-step pointer)
|
||||
expect(errOutput).toContain("rig ps --nodes --fields agentActivity --json");
|
||||
});
|
||||
|
||||
it("--active combined with --filter is rejected explicitly with exit 1", async () => {
|
||||
nodesByRig["rig-1"] = [NODE_RUNNING];
|
||||
const { errLogs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active", "--filter", "status=running"]);
|
||||
});
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errLogs.join("\n")).toContain("--active and --filter cannot be combined");
|
||||
});
|
||||
|
||||
it("--filter agentActivity.state=running excludes nodes whose agentActivity is missing", async () => {
|
||||
const NODE_NO_ACTIVITY = {
|
||||
...NODE_RUNNING,
|
||||
logicalId: "epsilon",
|
||||
canonicalSessionName: "demo-epsilon",
|
||||
agentActivity: undefined,
|
||||
};
|
||||
nodesByRig["rig-1"] = [NODE_RUNNING, NODE_NO_ACTIVITY];
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "--json", "--active"]);
|
||||
});
|
||||
expect(exitCode).toBeUndefined();
|
||||
const env = JSON.parse(logs.join(""));
|
||||
const entries = Array.isArray(env) ? env : env.entries;
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].logicalId).toBe("alpha");
|
||||
});
|
||||
});
|
||||
@@ -616,9 +616,7 @@ describe("Ps CLI", () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--json", "--filter", "noequals"]);
|
||||
});
|
||||
// PL-012: error message now names the comparator family since
|
||||
// numeric ops (>=, >, <=, <, =) are accepted.
|
||||
expect(logs.some((l) => l.includes("--filter must be key") && l.includes("op = "))).toBe(true);
|
||||
expect(logs.some((l) => l.includes("--filter must be key=value"))).toBe(true);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("rig restore-check", () => {
|
||||
rigId: "rig-1",
|
||||
rigName: "test-rig",
|
||||
action: "restore_from_latest_snapshot",
|
||||
command: "rig up --existing test-rig",
|
||||
command: "rig restore snap-123 --rig rig-1",
|
||||
reason: "Rig has a latest snapshot and one or more seats are not running/ready.",
|
||||
safe: false,
|
||||
blocking: true,
|
||||
@@ -189,7 +189,7 @@ describe("rig restore-check", () => {
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("RECOVERY: ACTIONABLE");
|
||||
expect(output).toContain("rig up --existing test-rig");
|
||||
expect(output).toContain("rig restore snap-123 --rig rig-1");
|
||||
expect(output).toContain("1 rig can be recovered");
|
||||
});
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ function validSummary(): Record<string, unknown> {
|
||||
source_session_id: "velocity-driver@openrig-velocity",
|
||||
source_rig: "openrig-velocity",
|
||||
source_runtime: "claude-code",
|
||||
source_cwd: "/Users/example/code/projects/openrig-hub",
|
||||
source_cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
target_rig: "openrig-velocity",
|
||||
target_runtime: "claude-code",
|
||||
target_workspace_root: "/Users/example/code/projects/openrig-hub",
|
||||
target_workspace_root: "/Users/wrandom/code/projects/openrig-hub",
|
||||
default_target_repo: null,
|
||||
role_pointer: "rigs/openrig-velocity/state/velocity/driver-role.md",
|
||||
bounded_latest_transcript: {
|
||||
@@ -472,11 +472,11 @@ describe("M2b runtime-detect", () => {
|
||||
|
||||
describe("M2b codex-jsonl-parser", () => {
|
||||
it("parses session_meta + response_item messages into StructuredTranscript", () => {
|
||||
const content = `{"type":"session_meta","payload":{"cwd":"/Users/example/code/projects/openrig-hub","id":"abc-123"}}
|
||||
const content = `{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"abc-123"}}
|
||||
{"type":"response_item","timestamp":"2026-05-02T01:00:00Z","payload":{"type":"message","role":"user","content":"Hello world"}}
|
||||
{"type":"response_item","timestamp":"2026-05-02T01:00:01Z","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"Hi back"}]}}`;
|
||||
const result = parseCodexJsonl(content);
|
||||
expect(result.sessionMeta?.cwd).toBe("/Users/example/code/projects/openrig-hub");
|
||||
expect(result.sessionMeta?.cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
|
||||
expect(result.sessionMeta?.sessionId).toBe("abc-123");
|
||||
expect(result.messageCount).toBe(2);
|
||||
expect(result.messages[0]!.role).toBe("user");
|
||||
@@ -488,7 +488,7 @@ describe("M2b codex-jsonl-parser", () => {
|
||||
|
||||
it("filters reasoning + function_call + custom_tool_call records and counts them", () => {
|
||||
const content = `{"type":"response_item","payload":{"type":"reasoning","content":"<thinking>"}}
|
||||
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/example/x.txt\\"}"}}
|
||||
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/wrandom/x.txt\\"}"}}
|
||||
{"type":"response_item","payload":{"type":"custom_tool_call","input":"some input"}}
|
||||
{"type":"response_item","payload":{"type":"message","role":"user","content":"kept"}}`;
|
||||
const result = parseCodexJsonl(content);
|
||||
@@ -526,7 +526,7 @@ not valid json garbage line
|
||||
|
||||
it("extracts paths from message content and tool args; sorts by frequency", () => {
|
||||
const content = `{"type":"response_item","payload":{"type":"message","role":"user","content":"see packages/cli/src/index.ts and packages/cli/src/index.ts again"}}
|
||||
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/example/code/projects/openrig-hub/README.md\\"}"}}`;
|
||||
{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"/Users/wrandom/code/projects/openrig-hub/README.md\\"}"}}`;
|
||||
const result = parseCodexJsonl(content);
|
||||
expect(result.paths.length).toBeGreaterThan(0);
|
||||
const indexEntry = result.paths.find((p) => p.path === "packages/cli/src/index.ts");
|
||||
@@ -549,15 +549,15 @@ describe("M2b claude-transcript-parser", () => {
|
||||
|
||||
it("captures sessionMeta from the first record carrying cwd + sessionId", () => {
|
||||
const content = `{"type":"customTitle","customTitle":"x","sessionId":"sess-2"}
|
||||
{"type":"user","message":{"role":"user","content":"first"},"cwd":"/Users/example/code","sessionId":"sess-2"}`;
|
||||
{"type":"user","message":{"role":"user","content":"first"},"cwd":"/Users/wrandom/code","sessionId":"sess-2"}`;
|
||||
const result = parseClaudeTranscript(content);
|
||||
expect(result.sessionMeta?.cwd).toBe("/Users/example/code");
|
||||
expect(result.sessionMeta?.cwd).toBe("/Users/wrandom/code");
|
||||
expect(result.sessionMeta?.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("filters attachments → raw_tool_outputs counter", () => {
|
||||
const content = `{"type":"user","message":{"role":"user","content":"see attached"},"cwd":"/x","sessionId":"s"}
|
||||
{"type":"attachment","attachment":{"path":"/Users/example/x.txt","content":"file body"},"cwd":"/x","sessionId":"s"}`;
|
||||
{"type":"attachment","attachment":{"path":"/Users/wrandom/x.txt","content":"file body"},"cwd":"/x","sessionId":"s"}`;
|
||||
const result = parseClaudeTranscript(content);
|
||||
expect(result.messageCount).toBe(1);
|
||||
expect(result.omittedCounts.raw_tool_outputs).toBe(1);
|
||||
@@ -658,7 +658,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: "/Users/example/code/projects/openrig-hub",
|
||||
content: "/Users/wrandom/code/projects/openrig-hub",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -673,7 +673,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
// messages but the nested tool_result must STILL be counted.)
|
||||
expect(r.omittedCounts.function_call_output).toBe(1);
|
||||
expect(r.omittedCounts.raw_tool_outputs).toBe(1);
|
||||
expect(r.paths.some((p) => p.path === "/Users/example/code/projects/openrig-hub")).toBe(true);
|
||||
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
|
||||
});
|
||||
|
||||
it("counts each tool_use part in a multi-tool assistant turn", () => {
|
||||
@@ -683,7 +683,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: { cmd: "ls" } },
|
||||
{ type: "tool_use", id: "t2", name: "Read", input: { path: "/Users/example/code/x.md" } },
|
||||
{ type: "tool_use", id: "t2", name: "Read", input: { path: "/Users/wrandom/code/x.md" } },
|
||||
{ type: "text", text: "running both" },
|
||||
],
|
||||
},
|
||||
@@ -692,12 +692,12 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
});
|
||||
const r = parseClaudeTranscript(content);
|
||||
expect(r.omittedCounts.function_call_output).toBe(2);
|
||||
expect(r.paths.some((p) => p.path === "/Users/example/code/x.md")).toBe(true);
|
||||
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/x.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("counts each tool_result part in a multi-result user turn", () => {
|
||||
// Note: the Velocity-prior-art path pattern is greedy on the
|
||||
// /Users/example prefix and includes trailing whitespace and word
|
||||
// /Users/wrandom prefix and includes trailing whitespace and word
|
||||
// chars until end-of-line; using \n separators here so each path
|
||||
// is matched cleanly.
|
||||
const content = JSON.stringify({
|
||||
@@ -705,8 +705,8 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "t1", content: "/Users/example/code/a.md\nrunning" },
|
||||
{ type: "tool_result", tool_use_id: "t2", content: "/Users/example/code/b.md\nrunning" },
|
||||
{ type: "tool_result", tool_use_id: "t1", content: "/Users/wrandom/code/a.md\nrunning" },
|
||||
{ type: "tool_result", tool_use_id: "t2", content: "/Users/wrandom/code/b.md\nrunning" },
|
||||
],
|
||||
},
|
||||
cwd: "/x",
|
||||
@@ -714,8 +714,8 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
});
|
||||
const r = parseClaudeTranscript(content);
|
||||
expect(r.omittedCounts.raw_tool_outputs).toBe(2);
|
||||
expect(r.paths.some((p) => p.path === "/Users/example/code/a.md")).toBe(true);
|
||||
expect(r.paths.some((p) => p.path === "/Users/example/code/b.md")).toBe(true);
|
||||
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/a.md")).toBe(true);
|
||||
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/b.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("mixed text + tool_use: visible text kept; tool_use counted", () => {
|
||||
@@ -754,7 +754,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
});
|
||||
const r = parseClaudeTranscript(content);
|
||||
expect(r.omittedCounts.function_call_output).toBe(1);
|
||||
// No /Users/example or recognized prefix path in the input.
|
||||
// No /Users/wrandom or recognized prefix path in the input.
|
||||
expect(r.paths.length).toBe(0);
|
||||
});
|
||||
|
||||
@@ -787,7 +787,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
type: "tool_result",
|
||||
tool_use_id: "t1",
|
||||
content: [
|
||||
{ type: "text", text: "found at /Users/example/code/projects/openrig-hub/README.md" },
|
||||
{ type: "text", text: "found at /Users/wrandom/code/projects/openrig-hub/README.md" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -797,7 +797,7 @@ describe("M2b R2 Claude parser nested-content-part handling", () => {
|
||||
});
|
||||
const r = parseClaudeTranscript(content);
|
||||
expect(r.omittedCounts.raw_tool_outputs).toBe(1);
|
||||
expect(r.paths.some((p) => p.path === "/Users/example/code/projects/openrig-hub/README.md")).toBe(true);
|
||||
expect(r.paths.some((p) => p.path === "/Users/wrandom/code/projects/openrig-hub/README.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("kept text-only assistant turn (no tools): counters stay at zero", () => {
|
||||
@@ -846,14 +846,14 @@ describe("M2c-CLI packet-writer atomic emission", () => {
|
||||
function buildBaselineOpts(targetDir: string): Record<string, unknown> {
|
||||
return {
|
||||
targetDir,
|
||||
structured: parseCodexJsonl(`{"type":"session_meta","payload":{"cwd":"/Users/example/code/projects/openrig-hub","id":"src-session-id"}}
|
||||
structured: parseCodexJsonl(`{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"src-session-id"}}
|
||||
{"type":"response_item","timestamp":"2026-05-02T01:00:00Z","payload":{"type":"message","role":"user","content":"Walk through the M1 contract"}}
|
||||
{"type":"response_item","timestamp":"2026-05-02T01:00:01Z","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"Per § 2.1 the schema requires 21 fields"}]}}`),
|
||||
sourceRuntime: "codex" as const,
|
||||
targetRig: "openrig-velocity-claude",
|
||||
targetRuntime: "claude-code" as const,
|
||||
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
|
||||
defaultTargetRepo: "/Users/example/code/projects/openrig-hub/openrig",
|
||||
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
|
||||
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
|
||||
rolePointer: "rigs/openrig-velocity-claude/state/velocity/driver-role.md",
|
||||
currentWorkSummary: "Working on Restore-Packet vertical M2c-CLI chunk.",
|
||||
nextOwner: "self",
|
||||
@@ -862,7 +862,7 @@ describe("M2c-CLI packet-writer atomic emission", () => {
|
||||
sourceTrustRanking: ["rig_whoami", "bounded_latest_transcript"],
|
||||
sourceSessionId: "velocity-driver@openrig-velocity",
|
||||
sourceRig: "openrig-velocity",
|
||||
sourceCwd: "/Users/example/code/projects/openrig-hub",
|
||||
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
generatorVersion: "[email protected]",
|
||||
includeFullTranscript: false,
|
||||
};
|
||||
@@ -1008,7 +1008,7 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
|
||||
// + reasoning records + a credential-pattern needle for redaction.
|
||||
function syntheticCodexJsonlFor(role: string): string {
|
||||
return [
|
||||
`{"type":"session_meta","payload":{"cwd":"/Users/example/code/projects/openrig-hub","id":"velocity-${role}@openrig-velocity-code"}}`,
|
||||
`{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/projects/openrig-hub","id":"velocity-${role}@openrig-velocity-code"}}`,
|
||||
`{"type":"response_item","payload":{"type":"reasoning","content":"<thinking>"}}`,
|
||||
`{"type":"response_item","payload":{"type":"function_call","arguments":"{\\"path\\":\\"packages/cli/src/index.ts\\"}"}}`,
|
||||
`{"type":"response_item","timestamp":"2026-04-23T18:08:00Z","payload":{"type":"message","role":"user","content":"${role}: please review the diff"}}`,
|
||||
@@ -1038,8 +1038,8 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
|
||||
sourceRuntime: "codex",
|
||||
targetRig: "openrig-velocity-claude",
|
||||
targetRuntime: "claude-code",
|
||||
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
|
||||
defaultTargetRepo: "/Users/example/code/projects/openrig-hub/openrig",
|
||||
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
|
||||
defaultTargetRepo: "/Users/wrandom/code/projects/openrig-hub/openrig",
|
||||
rolePointer: `rigs/openrig-velocity-claude/state/velocity/${role}-role.md`,
|
||||
currentWorkSummary: `Synthetic ${role} round-trip for Velocity-shape parity.`,
|
||||
nextOwner: "self",
|
||||
@@ -1048,7 +1048,7 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
|
||||
sourceTrustRanking: ["rig_whoami", "bounded_latest_transcript"],
|
||||
sourceSessionId: `velocity-${role}@openrig-velocity-code`,
|
||||
sourceRig: "openrig-velocity-code",
|
||||
sourceCwd: "/Users/example/code/projects/openrig-hub",
|
||||
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
generatorVersion: "[email protected]",
|
||||
includeFullTranscript: true,
|
||||
});
|
||||
@@ -1058,7 +1058,7 @@ describe("M2c-CLI Velocity-shape round-trip (synthetic 4-role fixtures)", () =>
|
||||
expect(validation.valid, JSON.stringify(validation.errors)).toBe(true);
|
||||
// Provenance preserved at relevant fields:
|
||||
expect(summary.source_session_id).toBe(`velocity-${role}@openrig-velocity-code`);
|
||||
expect(summary.source_cwd).toBe("/Users/example/code/projects/openrig-hub");
|
||||
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
|
||||
expect(summary.bounded_latest_transcript.message_count).toBe(2);
|
||||
// Omitted-class enumeration matches what the parser counted:
|
||||
expect(summary.omitted_classes).toContain("reasoning_records");
|
||||
@@ -1101,7 +1101,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
|
||||
postExpectText: vi.fn(async () => { throw new Error("mock-daemon: postExpectText should not be called"); }),
|
||||
};
|
||||
// Synthetic Codex JSONL the mock-daemon serves on the new full-read route.
|
||||
const fixtureContent = `{"type":"session_meta","payload":{"cwd":"/Users/example/code/x","id":"src-session"}}
|
||||
const fixtureContent = `{"type":"session_meta","payload":{"cwd":"/Users/wrandom/code/x","id":"src-session"}}
|
||||
{"type":"response_item","payload":{"type":"message","role":"user","content":"hello from session"}}
|
||||
{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"text","text":"reply"}]}}`;
|
||||
|
||||
@@ -1111,7 +1111,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
|
||||
get: vi.fn(async (p: string) => {
|
||||
requestedPaths.push(p);
|
||||
if (p === "/api/transcripts/src-session/full") {
|
||||
return { status: 200, data: { content: fixtureContent, cwd: "/Users/example/code/x" } };
|
||||
return { status: 200, data: { content: fixtureContent, cwd: "/Users/wrandom/code/x" } };
|
||||
}
|
||||
return { status: 404, data: { error: "not found" } };
|
||||
}),
|
||||
@@ -1163,17 +1163,17 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
|
||||
{ type: "text", text: "checking now" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/example/code/projects/openrig-hub",
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-session-id",
|
||||
}) + "\n" + JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/example/code/projects/openrig-hub" },
|
||||
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/wrandom/code/projects/openrig-hub" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/example/code/projects/openrig-hub",
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-session-id",
|
||||
});
|
||||
const structured = parseClaudeTranscript(content);
|
||||
@@ -1185,7 +1185,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
|
||||
sourceRuntime: "claude-code",
|
||||
targetRig: "openrig-velocity-claude",
|
||||
targetRuntime: "claude-code",
|
||||
targetWorkspaceRoot: "/Users/example/code/projects/openrig-hub",
|
||||
targetWorkspaceRoot: "/Users/wrandom/code/projects/openrig-hub",
|
||||
defaultTargetRepo: null,
|
||||
rolePointer: "rigs/openrig-velocity-claude/state/velocity/driver-role.md",
|
||||
currentWorkSummary: "Nested-content round-trip for M2c-CLI.",
|
||||
@@ -1195,7 +1195,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
|
||||
sourceTrustRanking: ["rig_whoami"],
|
||||
sourceSessionId: "claude-session-id",
|
||||
sourceRig: "openrig-velocity-claude",
|
||||
sourceCwd: "/Users/example/code/projects/openrig-hub",
|
||||
sourceCwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
generatorVersion: "[email protected]",
|
||||
includeFullTranscript: false,
|
||||
});
|
||||
@@ -1205,7 +1205,7 @@ describe("M2c-CLI redaction + omitted-record round-trip end-to-end", () => {
|
||||
expect(summary.omitted_classes).toContain("raw_tool_outputs");
|
||||
// Touched-files inventory captured the path from the omitted tool_result.
|
||||
expect(summary.touched_files.top_paths.some((p: { path: string }) =>
|
||||
p.path === "/Users/example/code/projects/openrig-hub")).toBe(true);
|
||||
p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1245,7 +1245,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
|
||||
JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
cwd: "/Users/example/code/projects/openrig-hub",
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
id: "velocity-driver@openrig-velocity",
|
||||
},
|
||||
}),
|
||||
@@ -1314,7 +1314,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
|
||||
// Bug reproducer's expected values per BLOCK artifact:
|
||||
expect(summary.source_session_id).toBe("velocity-driver@openrig-velocity");
|
||||
expect(summary.source_rig).toBe("openrig-velocity");
|
||||
expect(summary.source_cwd).toBe("/Users/example/code/projects/openrig-hub");
|
||||
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
|
||||
// session_session_id MUST NOT be the file path.
|
||||
expect(summary.source_session_id).not.toContain("/source.jsonl");
|
||||
expect(summary.source_session_id).not.toContain(tmpRoot);
|
||||
@@ -1468,7 +1468,7 @@ describe("M2c-CLI R2 --source-jsonl provenance from parsed session_meta", () =>
|
||||
|
||||
describe("M2c-Daemon final regression — Velocity 4-pack round-trip via CLI", () => {
|
||||
let tmpRoot: string;
|
||||
const VELOCITY_ROOT = "/Users/example/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
|
||||
const VELOCITY_ROOT = "/Users/wrandom/code/substrate/shared-docs/openrig-work/field-notes/2026-04-27-velocity-claude-from-codex-restore";
|
||||
|
||||
beforeEach(async () => {
|
||||
const fs = await import("node:fs");
|
||||
@@ -1641,7 +1641,7 @@ describe("M2c-Daemon final regression — CLI-surface redaction + omitted round-
|
||||
{ type: "text", text: "checking now" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/example/code/projects/openrig-hub",
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-final@openrig-velocity-claude",
|
||||
}),
|
||||
JSON.stringify({
|
||||
@@ -1649,10 +1649,10 @@ describe("M2c-Daemon final regression — CLI-surface redaction + omitted round-
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/example/code/projects/openrig-hub" },
|
||||
{ type: "tool_result", tool_use_id: "toolu_1", content: "/Users/wrandom/code/projects/openrig-hub" },
|
||||
],
|
||||
},
|
||||
cwd: "/Users/example/code/projects/openrig-hub",
|
||||
cwd: "/Users/wrandom/code/projects/openrig-hub",
|
||||
sessionId: "claude-final@openrig-velocity-claude",
|
||||
}),
|
||||
].join("\n"), "utf8");
|
||||
@@ -1679,10 +1679,10 @@ describe("M2c-Daemon final regression — CLI-surface redaction + omitted round-
|
||||
// Provenance from session_meta-equivalent (Claude per-record sessionId).
|
||||
expect(summary.source_session_id).toBe("claude-final@openrig-velocity-claude");
|
||||
expect(summary.source_rig).toBe("openrig-velocity-claude");
|
||||
expect(summary.source_cwd).toBe("/Users/example/code/projects/openrig-hub");
|
||||
expect(summary.source_cwd).toBe("/Users/wrandom/code/projects/openrig-hub");
|
||||
// Touched-files inventory captured the path from the omitted tool_result.
|
||||
expect(summary.touched_files.top_paths.some((p: { path: string }) =>
|
||||
p.path === "/Users/example/code/projects/openrig-hub")).toBe(true);
|
||||
p.path === "/Users/wrandom/code/projects/openrig-hub")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1718,7 +1718,7 @@ describe("M3 restore-packet read + validate", () => {
|
||||
fs.writeFileSync(src, [
|
||||
JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: { cwd: "/Users/example/code/projects/openrig-hub", id: "m3-driver@m3-rig" },
|
||||
payload: { cwd: "/Users/wrandom/code/projects/openrig-hub", id: "m3-driver@m3-rig" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "response_item",
|
||||
|
||||
@@ -1,565 +0,0 @@
|
||||
// release-0.3.2 slice 12 — `rig scope` command-surface integration
|
||||
// tests. Drives the commander tree end-to-end with a tmp substrate
|
||||
// fixture so every HG-N gate has direct coverage.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { Command } from "commander";
|
||||
|
||||
import { scopeCommand } from "../src/commands/scope.js";
|
||||
import { readFrontmatter } from "../src/lib/scope/scope-fs.js";
|
||||
|
||||
function mktemp(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-cmd-"));
|
||||
}
|
||||
|
||||
function writeFile(p: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content, "utf8");
|
||||
}
|
||||
|
||||
function seedSubstrate(): { root: string; missionsRoot: string } {
|
||||
const root = mktemp();
|
||||
const missionsRoot = path.join(root, "openrig-work", "missions");
|
||||
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.name", "T"], { stdio: "ignore" });
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "01-existing", "README.md"),
|
||||
"---\nid: OPR.0.3.2.1\nstatus: active\n---\n# existing\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "README.md"),
|
||||
"---\nid: OPR.99.0.1\n---\n# backlog\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "slices", "01-debt-foo", "README.md"),
|
||||
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# debt\n",
|
||||
);
|
||||
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
|
||||
return { root, missionsRoot };
|
||||
}
|
||||
|
||||
interface CaptureResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
async function run(args: string[], workspace: string): Promise<CaptureResult> {
|
||||
const stdoutBuf: string[] = [];
|
||||
const stderrBuf: string[] = [];
|
||||
const origWrite = process.stdout.write.bind(process.stdout);
|
||||
const origErrWrite = process.stderr.write.bind(process.stderr);
|
||||
const origExit = process.exit;
|
||||
let exitCode = 0;
|
||||
process.stdout.write = ((chunk: unknown) => { stdoutBuf.push(String(chunk)); return true; }) as typeof process.stdout.write;
|
||||
process.stderr.write = ((chunk: unknown) => { stderrBuf.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = code ?? 0;
|
||||
throw new Error(`__EXIT__${exitCode}`);
|
||||
}) as typeof process.exit;
|
||||
const program = new Command();
|
||||
program.addCommand(scopeCommand());
|
||||
program.exitOverride();
|
||||
try {
|
||||
await program.parseAsync(["node", "rig", "scope", ...args, "--workspace", path.dirname(workspace)]);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? "";
|
||||
if (!msg.startsWith("__EXIT__")) {
|
||||
stderrBuf.push(msg + "\n");
|
||||
}
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
process.stderr.write = origErrWrite;
|
||||
process.exit = origExit;
|
||||
}
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdoutBuf.join(""),
|
||||
stderr: stderrBuf.join(""),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-1 + HG-9: rig scope slice ls --json + state filter
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice ls", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("HG-1 + HG-9: --json returns active slices from a specific mission", async () => {
|
||||
const r = await run(["slice", "ls", "--mission", "release-0.3.2", "--state", "active", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.slices.map((s: { name: string }) => s.name)).toEqual(["01-existing"]);
|
||||
expect(parsed.slices[0].id).toBe("OPR.0.3.2.1");
|
||||
});
|
||||
|
||||
it("returns slices from all missions when --mission is omitted", async () => {
|
||||
const r = await run(["slice", "ls", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-2: rig scope slice show
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice show (HG-2)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("renders frontmatter + README + children", async () => {
|
||||
const r = await run([
|
||||
"slice", "show", "01-existing",
|
||||
"--mission", "release-0.3.2",
|
||||
"--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.slice.id).toBe("OPR.0.3.2.1");
|
||||
expect(parsed.slice.readme).toContain("# existing");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-3 + HG-4 + HG-15: rig scope slice create
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice create", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("HG-3: auto-finds next NN, never reuses numbers", async () => {
|
||||
// Add a closed slice 04 so existing NNs are 01 + 04. Next must be 05.
|
||||
writeFile(
|
||||
path.join(env.missionsRoot, "release-0.3.2", "closed", "04-old", "README.md"),
|
||||
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\n",
|
||||
);
|
||||
const r = await run([
|
||||
"slice", "create", "release-0.3.2", "new-thing", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.slice.name).toMatch(/^05-/);
|
||||
});
|
||||
|
||||
it("HG-4: --template populates the template", async () => {
|
||||
const r = await run([
|
||||
"slice", "create", "release-0.3.2", "perf-fix",
|
||||
"--template", "bug-fix", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
const readme = fs.readFileSync(path.join(parsed.slice.path, "README.md"), "utf8");
|
||||
expect(readme).toMatch(/## Repro/);
|
||||
expect(readme).toMatch(/## Expected/);
|
||||
});
|
||||
|
||||
it("HG-15: created slice frontmatter has a conformant dot-ID", async () => {
|
||||
const r = await run([
|
||||
"slice", "create", "release-0.3.2", "tdd-foo", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
const fm = readFrontmatter(path.join(parsed.slice.path, "README.md"));
|
||||
expect(fm.id).toBe("OPR.0.3.2.2");
|
||||
expect(typeof fm.id === "string" && /^OPR\.0\.3\.2\.\d+$/.test(fm.id as string)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-5 + HG-9: rig scope slice ship — git mv + frontmatter update
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice ship (HG-5)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("git mv + frontmatter status: shipped-to-release-X.Y", async () => {
|
||||
const r = await run([
|
||||
"slice", "ship", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.shipped.git.usedGit).toBe(true);
|
||||
// Source removed; target present.
|
||||
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
|
||||
expect(fs.existsSync(parsed.shipped.to.path)).toBe(true);
|
||||
const fm = readFrontmatter(path.join(parsed.shipped.to.path, "README.md"));
|
||||
expect(fm.status).toBe("shipped-to-release-0.3.2");
|
||||
expect(fm.mission).toBe("release-0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-6: rig scope slice close --reason
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice close (HG-6)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("moves to closed/ + sets status: closed-<reason>", async () => {
|
||||
const r = await run([
|
||||
"slice", "close", "01-debt-foo",
|
||||
"--mission", "backlog",
|
||||
"--reason", "wontfix",
|
||||
"--note", "obsolete by design",
|
||||
"--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo"))).toBe(true);
|
||||
const fm = readFrontmatter(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo", "README.md"));
|
||||
expect(fm.status).toBe("closed-wontfix");
|
||||
expect(fm["closure-note"]).toBe("obsolete by design");
|
||||
});
|
||||
|
||||
it("rejects unknown --reason values", async () => {
|
||||
const r = await run([
|
||||
"slice", "close", "01-debt-foo",
|
||||
"--mission", "backlog",
|
||||
"--reason", "fishy",
|
||||
"--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-7: rig scope slice move
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice move (HG-7)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("moves between missions; auto-renumber; frontmatter.mission updates", async () => {
|
||||
const r = await run([
|
||||
"slice", "move", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.moved.to.name).toMatch(/^02-/); // 01 was taken
|
||||
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
|
||||
const fm = readFrontmatter(path.join(parsed.moved.to.path, "README.md"));
|
||||
expect(fm.mission).toBe("release-0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-8: rig scope mission ls
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope mission ls (HG-8)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("--json lists missions with id + slice counts", async () => {
|
||||
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
|
||||
expect(names).toEqual(["backlog", "release-0.3.2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig scope mission show", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("renders frontmatter + README + slice inventory", async () => {
|
||||
const r = await run(["mission", "show", "release-0.3.2", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.id).toBe("OPR.0.3.2");
|
||||
expect(parsed.mission.slices.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-14 + HG-15: rig scope mission create (PROMOTED to v0 per Amendment §B)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope mission create (HG-14 + HG-15)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("HG-14: mission create is invocable with the SAME pattern as slice create", async () => {
|
||||
// Symmetry test: both verbs take (positional name) + (--template) +
|
||||
// (--json) and emit { ok, [tier]: { name, id, template, path } }.
|
||||
const r = await run([
|
||||
"mission", "create", "release-0.4.0", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.mission.name).toBe("release-0.4.0");
|
||||
expect(parsed.mission.path).toBeTruthy();
|
||||
expect(parsed.mission.template).toBe("release");
|
||||
});
|
||||
|
||||
it("HG-15: created mission frontmatter has a conformant dot-ID per §1", async () => {
|
||||
const r = await run(["mission", "create", "release-0.5.0", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.id).toBe("OPR.0.5.0");
|
||||
const fm = readFrontmatter(path.join(parsed.mission.path, "README.md"));
|
||||
expect(fm.id).toBe("OPR.0.5.0");
|
||||
});
|
||||
|
||||
it("HG-15: non-release names get an escape-band ID (uniform-numeric)", async () => {
|
||||
const r = await run([
|
||||
"mission", "create", "experiments-foo", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.id).toMatch(/^OPR\.99\.0\.\d+$/);
|
||||
});
|
||||
|
||||
it("HG-15: accepts an explicit --id override and rejects malformed ones", async () => {
|
||||
const ok = await run([
|
||||
"mission", "create", "weird-thing",
|
||||
"--id", "OPR.99.0.42", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(JSON.parse(ok.stdout).mission.id).toBe("OPR.99.0.42");
|
||||
|
||||
const bad = await run([
|
||||
"mission", "create", "another-weird-thing",
|
||||
"--id", "OPR.A.1", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(bad.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("BLOCK 1 discriminator: mission create rejects slice-depth --id (OPR.0.3.2.12)", async () => {
|
||||
// Per guard BC verdict: a 4-segment slice-shape must NOT pass
|
||||
// mission --id validation. Without tier-aware validation this
|
||||
// would succeed and create a mission with a slice-shaped id.
|
||||
const r = await run([
|
||||
"mission", "create", "wrong-tier",
|
||||
"--id", "OPR.0.3.2.12", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/not a mission-tier dot-ID/);
|
||||
});
|
||||
|
||||
it("BLOCK 1 follow-up: mission create still accepts valid mission shapes (escape band + release)", async () => {
|
||||
const eb = await run([
|
||||
"mission", "create", "exp-band",
|
||||
"--id", "OPR.99.0.42", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(eb.exitCode).toBe(0);
|
||||
expect(JSON.parse(eb.stdout).mission.id).toBe("OPR.99.0.42");
|
||||
|
||||
const rel = await run([
|
||||
"mission", "create", "release-0.6.0", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(rel.exitCode).toBe(0);
|
||||
expect(JSON.parse(rel.stdout).mission.id).toBe("OPR.0.6.0");
|
||||
});
|
||||
|
||||
it("BC-2 BLOCK 1 discriminator: rejects escape-band with non-zero middle segment (OPR.99.7.8)", async () => {
|
||||
const r = await run([
|
||||
"mission", "create", "bad-eb",
|
||||
"--id", "OPR.99.7.8", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(JSON.parse(r.stdout).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// BC-2 BLOCK 2: README-less dirs are rejected at every mutation surface
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("BC-2 BLOCK 2 — README-less dirs are not declared missions at any mutation surface", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
|
||||
beforeEach(() => {
|
||||
env = seedSubstrate();
|
||||
// Sneak a README-less directory into missions/ to verify every
|
||||
// surface rejects it consistently with listMissions.
|
||||
fs.mkdirSync(path.join(env.missionsRoot, "no-readme"), { recursive: true });
|
||||
});
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("mission ls omits no-readme (already in HG-8 above; sanity check here)", async () => {
|
||||
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.missions.map((m: { name: string }) => m.name)).not.toContain("no-readme");
|
||||
});
|
||||
|
||||
it("mission show no-readme exits 1 with the 3-part 'not a declared mission' error", async () => {
|
||||
const r = await run(["mission", "show", "no-readme", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/no README\.md|not a declared mission/);
|
||||
});
|
||||
|
||||
it("slice create no-readme exits 1 + does NOT create slices/foo/", async () => {
|
||||
const r = await run([
|
||||
"slice", "create", "no-readme", "foo", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(fs.existsSync(path.join(env.missionsRoot, "no-readme", "slices", "01-foo"))).toBe(false);
|
||||
});
|
||||
|
||||
it("slice ship to no-readme target exits 1; source slice unmoved", async () => {
|
||||
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
|
||||
expect(fs.existsSync(srcAbs)).toBe(true);
|
||||
const r = await run([
|
||||
"slice", "ship", "01-debt-foo", "no-readme",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
// Source unchanged.
|
||||
expect(fs.existsSync(srcAbs)).toBe(true);
|
||||
});
|
||||
|
||||
it("slice move to no-readme target exits 1; source slice unmoved", async () => {
|
||||
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
|
||||
const r = await run([
|
||||
"slice", "move", "01-debt-foo", "no-readme",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
expect(fs.existsSync(srcAbs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// BLOCK 3 discriminator: ship + move persist target.id back to target README
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("BLOCK 3 — ship/move persist target mission id into target README", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
|
||||
beforeEach(() => {
|
||||
env = seedSubstrate();
|
||||
// Replace release-0.3.2/README.md with an ID-LESS variant so we
|
||||
// can observe the lazy-adopt-and-persist behavior.
|
||||
fs.writeFileSync(
|
||||
path.join(env.missionsRoot, "release-0.3.2", "README.md"),
|
||||
"# release-0.3.2\n\n(no id frontmatter yet)\n",
|
||||
"utf8",
|
||||
);
|
||||
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", env.root, "commit", "-m", "strip-target-id", "-q"], { stdio: "ignore" });
|
||||
});
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("slice ship: target mission gains a persisted id; child id = targetId.NN", async () => {
|
||||
const r = await run([
|
||||
"slice", "ship", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
// Target README now has id: OPR.0.3.2 written back.
|
||||
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
|
||||
expect(targetFm.id).toBe("OPR.0.3.2");
|
||||
// Child id matches parent.NN.
|
||||
expect(parsed.shipped.to.id).toBe("OPR.0.3.2.2");
|
||||
});
|
||||
|
||||
it("slice move: target mission gains a persisted id; child id = targetId.NN", async () => {
|
||||
const r = await run([
|
||||
"slice", "move", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
|
||||
expect(targetFm.id).toBe("OPR.0.3.2");
|
||||
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
|
||||
});
|
||||
|
||||
it("sibling missions without id remain UNCHANGED after a ship/move (narrow lazy adoption, not mass-migrate)", async () => {
|
||||
// Spawn an unrelated id-less mission alongside; assert it survives
|
||||
// untouched through a ship to a different mission.
|
||||
fs.mkdirSync(path.join(env.missionsRoot, "untouched-mission"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(env.missionsRoot, "untouched-mission", "README.md"),
|
||||
"# untouched\n",
|
||||
"utf8",
|
||||
);
|
||||
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", env.root, "commit", "-m", "seed-sibling", "-q"], { stdio: "ignore" });
|
||||
const before = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
|
||||
await run([
|
||||
"slice", "ship", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const after = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
|
||||
expect(after).toBe(before);
|
||||
expect(readFrontmatter(path.join(env.missionsRoot, "untouched-mission", "README.md")).id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-10: 3-part error shape
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("3-part error shape (HG-10)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("ship to nonexistent target mission yields fact/consequence/action", async () => {
|
||||
const r = await run([
|
||||
"slice", "ship", "01-debt-foo", "release-9.9.9",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toBeTruthy();
|
||||
expect(parsed.error.consequence).toBeTruthy();
|
||||
expect(parsed.error.action).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-12: --help exists for every command
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("--help is present on every command (HG-12)", () => {
|
||||
it("every tier and verb has help text", () => {
|
||||
const cmd = scopeCommand();
|
||||
expect(cmd.description()).toBeTruthy();
|
||||
const tiers = cmd.commands;
|
||||
expect(tiers.map((c) => c.name()).sort()).toEqual(["mission", "slice"]);
|
||||
for (const tier of tiers) {
|
||||
expect(tier.description()).toBeTruthy();
|
||||
for (const verb of tier.commands) {
|
||||
expect(verb.description()).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
// release-0.3.2 slice 12 — dot-ID grammar unit tests.
|
||||
//
|
||||
// Aligned with `openrig-work/conventions/scope-and-versioning/README.md` §1.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatDotId,
|
||||
inferMissionDotId,
|
||||
isConformantDotId,
|
||||
isMissionDotId,
|
||||
isSliceDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
parseDotId,
|
||||
sliceIdFromMission,
|
||||
} from "../src/lib/scope/dot-id.js";
|
||||
|
||||
describe("dot-ID — parse + format", () => {
|
||||
it("parses a slice ID like OPR.0.3.2.12", () => {
|
||||
const id = parseDotId("OPR.0.3.2.12");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.project).toBe("OPR");
|
||||
expect(id!.version).toBe("0.3.2.12");
|
||||
});
|
||||
|
||||
it("parses an escape-band ID like OPR.99.0.1", () => {
|
||||
const id = parseDotId("OPR.99.0.1");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.project).toBe("OPR");
|
||||
expect(id!.version).toBe("99.0.1");
|
||||
});
|
||||
|
||||
it("rejects alpha segments (§1 explicit: uniform-numeric escape band)", () => {
|
||||
expect(parseDotId("OPR.A.1")).toBeNull();
|
||||
expect(parseDotId("OPR.0.3.A")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed prefixes", () => {
|
||||
expect(parseDotId("opr.0.3.2")).toBeNull();
|
||||
expect(parseDotId("O.0.3.2")).toBeNull();
|
||||
expect(parseDotId("OPRX.0.3.2")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a DotId back to canonical string", () => {
|
||||
expect(formatDotId({ project: "OPR", version: "0.3.2" })).toBe("OPR.0.3.2");
|
||||
expect(formatDotId({ project: "OPR", version: "0.3.2", n: 12 })).toBe("OPR.0.3.2.12");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferMissionDotId — release pattern + escape band", () => {
|
||||
it("infers OPR.X.Y.Z from release-X.Y.Z folder names", () => {
|
||||
expect(inferMissionDotId("release-0.3.2", null)).toBe("OPR.0.3.2");
|
||||
expect(inferMissionDotId("release-0.4.0", null)).toBe("OPR.0.4.0");
|
||||
expect(inferMissionDotId("release-1.0", null)).toBe("OPR.1.0");
|
||||
});
|
||||
|
||||
it("requires an escape-band ordinal for non-release names", () => {
|
||||
expect(() => inferMissionDotId("backlog", null)).toThrow();
|
||||
expect(inferMissionDotId("backlog", 5)).toBe("OPR.99.0.5");
|
||||
expect(inferMissionDotId("bug-fix", 3)).toBe("OPR.99.0.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextEscapeBandOrdinal", () => {
|
||||
it("returns 1 when no peer IDs exist", () => {
|
||||
expect(nextEscapeBandOrdinal([])).toBe(1);
|
||||
expect(nextEscapeBandOrdinal([null, null])).toBe(1);
|
||||
});
|
||||
|
||||
it("returns max+1 across existing escape-band peers", () => {
|
||||
expect(nextEscapeBandOrdinal(["OPR.99.0.1", "OPR.99.0.3", null])).toBe(4);
|
||||
});
|
||||
|
||||
it("ignores non-matching IDs (releases) when picking ordinal", () => {
|
||||
expect(nextEscapeBandOrdinal(["OPR.0.3.2", "OPR.99.0.2"])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sliceIdFromMission", () => {
|
||||
it("appends the slice ordinal to the mission ID", () => {
|
||||
expect(sliceIdFromMission("OPR.0.3.2", 12)).toBe("OPR.0.3.2.12");
|
||||
expect(sliceIdFromMission("OPR.99.0.1", 4)).toBe("OPR.99.0.1.4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConformantDotId", () => {
|
||||
it("accepts conformant IDs across all tiers", () => {
|
||||
expect(isConformantDotId("OPR.0.3.2")).toBe(true);
|
||||
expect(isConformantDotId("OPR.0.3.2.12")).toBe(true);
|
||||
expect(isConformantDotId("OPR.99.0.1")).toBe(true);
|
||||
});
|
||||
it("rejects non-conformant inputs", () => {
|
||||
expect(isConformantDotId("")).toBe(false);
|
||||
expect(isConformantDotId(42)).toBe(false);
|
||||
expect(isConformantDotId("opr.0.3.2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BLOCK 1 — tier-aware validation (isMissionDotId / isSliceDotId)", () => {
|
||||
it("isMissionDotId accepts release-shaped IDs (2-3 numeric segments)", () => {
|
||||
expect(isMissionDotId("OPR.0.3.2")).toBe(true);
|
||||
expect(isMissionDotId("OPR.1.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("isMissionDotId accepts the escape-band shape (99.x.y, exactly 3 segments)", () => {
|
||||
expect(isMissionDotId("OPR.99.0.1")).toBe(true);
|
||||
expect(isMissionDotId("OPR.99.0.42")).toBe(true);
|
||||
});
|
||||
|
||||
it("isMissionDotId REJECTS slice-shaped IDs (4+ numeric segments) — guard BC discriminator", () => {
|
||||
// The crux of BLOCK 1: a slice-depth ID like OPR.0.3.2.12 must
|
||||
// not pass mission validation, even though it parses as a valid
|
||||
// dot-ID at SOME tier.
|
||||
expect(isMissionDotId("OPR.0.3.2.12")).toBe(false);
|
||||
expect(isMissionDotId("OPR.99.0.1.5")).toBe(false);
|
||||
});
|
||||
|
||||
it("isMissionDotId rejects malformed inputs (alpha / wrong prefix shape)", () => {
|
||||
expect(isMissionDotId("OPR.A.1")).toBe(false);
|
||||
expect(isMissionDotId("opr.0.3.2")).toBe(false);
|
||||
expect(isMissionDotId(42 as unknown)).toBe(false);
|
||||
expect(isMissionDotId("OPR.99.0")).toBe(false); // escape band must be exactly 3
|
||||
});
|
||||
|
||||
it("isSliceDotId accepts slice-shaped IDs (3-4 numeric segments)", () => {
|
||||
expect(isSliceDotId("OPR.0.3.2.12")).toBe(true); // release-X.Y.Z slice
|
||||
expect(isSliceDotId("OPR.1.0.5")).toBe(true); // release-X.Y slice
|
||||
expect(isSliceDotId("OPR.99.0.1.7")).toBe(true); // escape-band slice
|
||||
});
|
||||
|
||||
it("isSliceDotId admits the inherent depth-3 overlap (release X.Y.Z mission shape = slice for an X.Y parent)", () => {
|
||||
// The convention's positional grammar can't distinguish a
|
||||
// release-X.Y.Z mission from a slice of a release-X.Y mission
|
||||
// by depth alone. Both shapes are accepted by isSliceDotId; the
|
||||
// CLI disambiguates by tier at use sites (mission create uses
|
||||
// isMissionDotId; slice IDs are minted from a parent + ordinal,
|
||||
// never user-typed).
|
||||
expect(isSliceDotId("OPR.0.3.2")).toBe(true); // release X.Y.Z OR slice of X.Y
|
||||
// Escape-band IDs are UNAMBIGUOUS by depth: an escape-band mission
|
||||
// is depth-3 (OPR.99.0.1); an escape-band slice is depth-4
|
||||
// (OPR.99.0.1.5). Depth-3 with leading 99 is mission-only.
|
||||
expect(isSliceDotId("OPR.99.0.1")).toBe(false);
|
||||
// The strictly-impossible-slice case is depth 2 (X.Y only) — no
|
||||
// parent can have a sub-NN-segment of "Y".
|
||||
expect(isSliceDotId("OPR.1.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("parseDotId(s, 'slice') peels the last segment off as `n`", () => {
|
||||
// Per guard discriminator: parsing OPR.0.3.2.12 as a slice must
|
||||
// yield version=0.3.2 and n=12, not version=0.3.2.12.
|
||||
const id = parseDotId("OPR.0.3.2.12", "slice");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.version).toBe("0.3.2");
|
||||
expect(id!.n).toBe(12);
|
||||
});
|
||||
|
||||
it("parseDotId(s, 'mission') keeps all numeric segments as version", () => {
|
||||
const id = parseDotId("OPR.0.3.2", "mission");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.version).toBe("0.3.2");
|
||||
expect(id!.n).toBeUndefined();
|
||||
});
|
||||
|
||||
it("BC-2 BLOCK 1: escape band rejects [99, !0, n] mission ver (only [99, 0, n] is valid)", () => {
|
||||
// §1 fixes the escape band as <PFX>.99.0.<n> — the `0` is FIXED.
|
||||
// Previously the depth-3 check accepted OPR.99.7.8 as a mission;
|
||||
// now require the middle 0.
|
||||
expect(isMissionDotId("OPR.99.7.8")).toBe(false);
|
||||
expect(isMissionDotId("OPR.99.1.0")).toBe(false);
|
||||
expect(isMissionDotId("OPR.99.0.42")).toBe(true); // still valid
|
||||
expect(isMissionDotId("OPR.99.0.1")).toBe(true); // still valid
|
||||
});
|
||||
|
||||
it("BC-2 BLOCK 1: escape-band slice parent must also satisfy the [99,0,n] rule", () => {
|
||||
expect(isSliceDotId("OPR.99.7.8.1")).toBe(false); // parent=99.7.8 invalid
|
||||
expect(isSliceDotId("OPR.99.0.42.7")).toBe(true); // parent=99.0.42 valid
|
||||
});
|
||||
});
|
||||
@@ -1,339 +0,0 @@
|
||||
// release-0.3.2 slice 12 — scope-fs helpers + frontmatter parser tests.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
ensureMissionId,
|
||||
findMission,
|
||||
findSlice,
|
||||
listMissions,
|
||||
listSlices,
|
||||
moveSlice,
|
||||
nextSliceNN,
|
||||
readFrontmatter,
|
||||
resolveMissionsRoot,
|
||||
splitFrontmatter,
|
||||
updateFrontmatter,
|
||||
} from "../src/lib/scope/scope-fs.js";
|
||||
|
||||
function mktemp(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-test-"));
|
||||
}
|
||||
|
||||
function writeFile(p: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content, "utf8");
|
||||
}
|
||||
|
||||
describe("frontmatter parser", () => {
|
||||
it("returns {} when there is no frontmatter delimiter", () => {
|
||||
const { frontmatter, body } = splitFrontmatter("# heading\n\nbody");
|
||||
expect(frontmatter).toEqual({});
|
||||
expect(body).toBe("# heading\n\nbody");
|
||||
});
|
||||
|
||||
it("parses simple key/value pairs", () => {
|
||||
const src = "---\nid: OPR.0.3.2.12\nstatus: active\n---\nbody";
|
||||
const { frontmatter, body } = splitFrontmatter(src);
|
||||
expect(frontmatter).toEqual({ id: "OPR.0.3.2.12", status: "active" });
|
||||
expect(body).toBe("body");
|
||||
});
|
||||
|
||||
it("preserves unknown keys on update", () => {
|
||||
const dir = mktemp();
|
||||
const p = path.join(dir, "README.md");
|
||||
writeFile(p, "---\nstatus: active\ncustom: keep-me\n---\nbody\n");
|
||||
updateFrontmatter(p, { status: "shipped" });
|
||||
const fm = readFrontmatter(p);
|
||||
expect(fm.status).toBe("shipped");
|
||||
expect(fm.custom).toBe("keep-me");
|
||||
});
|
||||
|
||||
it("generates minimal frontmatter when absent", () => {
|
||||
const dir = mktemp();
|
||||
const p = path.join(dir, "README.md");
|
||||
writeFile(p, "body only\n");
|
||||
updateFrontmatter(p, { id: "OPR.0.3.2" });
|
||||
const fm = readFrontmatter(p);
|
||||
expect(fm.id).toBe("OPR.0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMissionsRoot", () => {
|
||||
it("uses an explicit override path when present", () => {
|
||||
const root = mktemp();
|
||||
fs.mkdirSync(path.join(root, "missions"));
|
||||
expect(resolveMissionsRoot({ override: root })).toBe(path.join(root, "missions"));
|
||||
});
|
||||
|
||||
it("throws ScopeCliError when no missions/ root is found", () => {
|
||||
const empty = mktemp();
|
||||
expect(() => resolveMissionsRoot({ override: empty, cwd: empty })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMissions + listSlices + nextSliceNN", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
// mission with 2 active + 1 closed slice
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "01-foo", "README.md"),
|
||||
"---\nid: OPR.0.3.2.1\nstatus: active\n---\nbody\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "02-bar", "README.md"),
|
||||
"---\nid: OPR.0.3.2.2\nstatus: active\n---\nbody\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "closed", "04-baz", "README.md"),
|
||||
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\nbody\n",
|
||||
);
|
||||
// mission with no README — should be skipped as "no slice count basis"
|
||||
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lists missions sorted by name with slice counts; SKIPS dirs without README.md (HG-8)", () => {
|
||||
const missions = listMissions(missionsRoot);
|
||||
// no-readme/ has no README.md and must NOT appear (HG-8 / PRD §2.1).
|
||||
expect(missions.map((m) => m.name)).toEqual(["release-0.3.2"]);
|
||||
const r = missions.find((m) => m.name === "release-0.3.2")!;
|
||||
expect(r.activeSliceCount).toBe(2);
|
||||
expect(r.closedSliceCount).toBe(1);
|
||||
expect(r.id).toBe("OPR.0.3.2");
|
||||
});
|
||||
|
||||
it("HG-8 discriminator: 3 dirs / 1 without README → exactly 2 missions listed", () => {
|
||||
// Seed a third mission alongside the existing fixture for a clear
|
||||
// make-it-fail-first signal per guard BC verdict.
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "README.md"),
|
||||
"---\nid: OPR.99.0.1\n---\n# backlog\n",
|
||||
);
|
||||
const missions = listMissions(missionsRoot);
|
||||
expect(missions.map((m) => m.name).sort()).toEqual(["backlog", "release-0.3.2"]);
|
||||
// The README-less directory survives on disk but does not show up.
|
||||
expect(fs.existsSync(path.join(missionsRoot, "no-readme"))).toBe(true);
|
||||
});
|
||||
|
||||
it("listSlices active filter excludes closed/shipped (HG-1)", () => {
|
||||
const mission = findMission(missionsRoot, "release-0.3.2");
|
||||
const active = listSlices(mission, "active");
|
||||
expect(active.map((s) => s.name).sort()).toEqual(["01-foo", "02-bar"]);
|
||||
const closed = listSlices(mission, "closed");
|
||||
expect(closed.map((s) => s.name)).toEqual(["04-baz"]);
|
||||
});
|
||||
|
||||
it("nextSliceNN skips numbers already used in slices/ AND closed/ (HG-3)", () => {
|
||||
const missionAbs = path.join(missionsRoot, "release-0.3.2");
|
||||
// existing: 01, 02, 04 → next should be 5 (not 3 — numbers never reused).
|
||||
expect(nextSliceNN(missionAbs)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSlice + resolution variants", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\nbody",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "07-target", "README.md"),
|
||||
"---\nid: OPR.0.3.2.7\nstatus: active\n---\nbody",
|
||||
);
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it("resolves a mission-relative path with --mission hint", () => {
|
||||
const slice = findSlice(missionsRoot, "07-target", "release-0.3.2");
|
||||
expect(slice.name).toBe("07-target");
|
||||
expect(slice.id).toBe("OPR.0.3.2.7");
|
||||
});
|
||||
|
||||
it("resolves an absolute path", () => {
|
||||
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
|
||||
const slice = findSlice(missionsRoot, abs);
|
||||
expect(slice.missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("3-part error when slice not found (HG-10)", () => {
|
||||
expect(() => findSlice(missionsRoot, "99-missing", "release-0.3.2")).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BC-2 BLOCK 2 — findMission rejects README-less directories", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\n",
|
||||
);
|
||||
// README-less directory next to a real mission — scratch/junk.
|
||||
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it("findMission throws 3-part error when target directory has no README.md", () => {
|
||||
expect(() => findMission(missionsRoot, "no-readme")).toThrow(/not a declared mission|no README/);
|
||||
});
|
||||
|
||||
it("findMission still resolves declared missions normally", () => {
|
||||
expect(findMission(missionsRoot, "release-0.3.2").name).toBe("release-0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureMissionId", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it("returns existing frontmatter id when present", () => {
|
||||
writeFile(
|
||||
path.join(missionsRoot, "weird-name", "README.md"),
|
||||
"---\nid: OPR.99.0.7\n---\n",
|
||||
);
|
||||
const m = findMission(missionsRoot, "weird-name");
|
||||
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.99.0.7");
|
||||
});
|
||||
|
||||
it("derives from release-X.Y.Z pattern when no id present", () => {
|
||||
writeFile(path.join(missionsRoot, "release-0.4.0", "README.md"), "");
|
||||
const m = findMission(missionsRoot, "release-0.4.0");
|
||||
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.0.4.0");
|
||||
});
|
||||
|
||||
it("falls into escape band for non-release names", () => {
|
||||
writeFile(path.join(missionsRoot, "backlog", "README.md"), "");
|
||||
const m = findMission(missionsRoot, "backlog");
|
||||
const id = ensureMissionId(m, missionsRoot);
|
||||
expect(id).toMatch(/^OPR\.99\.0\.\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// git mv path — uses real git in a tmp repo (HG-5, HG-11)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function initRepo(root: string): void {
|
||||
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.email", "[email protected]"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.name", "Tester"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "commit", "--allow-empty", "-m", "init", "-q"], { stdio: "ignore" });
|
||||
}
|
||||
|
||||
describe("moveSlice — git mv preserves history (HG-5) + refuses dirty tree (HG-11)", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
initRepo(root);
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "README.md"),
|
||||
"---\nid: OPR.99.0.1\n---\n# backlog\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "slices", "01-foo", "README.md"),
|
||||
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# foo\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\n# release\n",
|
||||
);
|
||||
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it("uses git mv inside a repo and preserves the file via git log --follow", () => {
|
||||
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
|
||||
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "01-foo");
|
||||
const result = moveSlice(src, dest);
|
||||
expect(result.usedGit).toBe(true);
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "ship", "-q"], { stdio: "ignore" });
|
||||
const log = execFileSync("git", [
|
||||
"-C", root, "log", "--follow", "--pretty=format:%s", "--", path.relative(root, path.join(dest, "README.md")),
|
||||
], { encoding: "utf8" });
|
||||
expect(log).toMatch(/seed/);
|
||||
expect(log).toMatch(/ship/);
|
||||
});
|
||||
|
||||
it("refuses to move when slice has uncommitted local edits (HG-11)", () => {
|
||||
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
|
||||
fs.writeFileSync(path.join(src, "README.md"), "dirty\n", "utf8");
|
||||
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "02-foo");
|
||||
expect(() => moveSlice(src, dest)).toThrow(/uncommitted/);
|
||||
});
|
||||
|
||||
it("falls back to fs.rename outside a git repo", () => {
|
||||
const dir = mktemp();
|
||||
const src = path.join(dir, "src");
|
||||
const dest = path.join(dir, "dest");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "README.md"), "body");
|
||||
const result = moveSlice(src, dest);
|
||||
expect(result.usedGit).toBe(false);
|
||||
expect(fs.existsSync(dest)).toBe(true);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("BLOCK 4 discriminator: handles symlinked src/dest paths without escaping the repo (macOS /var↔/private/var class)", () => {
|
||||
// Build a parallel tree of symlinks pointing at the real repo
|
||||
// tree. Without realpath normalization inside moveSlice, path.relative
|
||||
// would resolve to ../.. and git mv would reject with "outside
|
||||
// repository". With normalization, git mv succeeds and history
|
||||
// follows the move via --follow.
|
||||
const linkParent = mktemp();
|
||||
const linkRoot = path.join(linkParent, "linked-substrate");
|
||||
fs.symlinkSync(root, linkRoot);
|
||||
// Sanity: the symlinked tree resolves to a different absolute path
|
||||
// than the real tree (covers the /var↔/private/var class on macOS
|
||||
// and any other path-resolving symlink class on other OSes).
|
||||
expect(fs.realpathSync(linkRoot)).not.toBe(linkRoot);
|
||||
const src = path.join(linkRoot, "missions", "backlog", "slices", "01-foo");
|
||||
const dest = path.join(linkRoot, "missions", "release-0.3.2", "slices", "01-foo");
|
||||
const result = moveSlice(src, dest);
|
||||
expect(result.usedGit).toBe(true);
|
||||
expect(result.repoRoot).toBe(fs.realpathSync(root)); // git returned the realpath
|
||||
// git mv staged a rename; history follows.
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "ship-via-symlink", "-q"], { stdio: "ignore" });
|
||||
const log = execFileSync("git", [
|
||||
"-C", root, "log", "--follow", "--pretty=format:%s", "--",
|
||||
"missions/release-0.3.2/slices/01-foo/README.md",
|
||||
], { encoding: "utf8" });
|
||||
expect(log).toMatch(/seed/);
|
||||
expect(log).toMatch(/ship-via-symlink/);
|
||||
fs.rmSync(linkParent, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { wrapSendBody } from "../src/commands/send.js";
|
||||
|
||||
describe("wrapSendBody — pre-release CLI/daemon Item 2 (email-style envelope)", () => {
|
||||
it("renders From / To / body / reply hint with both session names", () => {
|
||||
const out = wrapSendBody("driver-3@my-rig", "guard-3@my-rig", "Status: ready.");
|
||||
expect(out).toContain("From: driver-3@my-rig");
|
||||
expect(out).toContain("To: guard-3@my-rig");
|
||||
expect(out).toContain("Status: ready.");
|
||||
expect(out).toContain('↩ Reply: rig send driver-3@my-rig "..."');
|
||||
});
|
||||
|
||||
it("preserves the original body verbatim between the dash separators", () => {
|
||||
const body = "Multi-line\nbody with\nthree lines.";
|
||||
const out = wrapSendBody("a@r", "b@r", body);
|
||||
const segments = out.split("\n---\n");
|
||||
expect(segments).toHaveLength(3);
|
||||
expect(segments[1]).toBe(body);
|
||||
});
|
||||
|
||||
it("wraps cleanly when the body is empty", () => {
|
||||
const out = wrapSendBody("a@r", "b@r", "");
|
||||
expect(out).toContain("From: a@r");
|
||||
expect(out).toContain("To: b@r");
|
||||
expect(out).toContain("---\n\n---");
|
||||
expect(out).toContain('↩ Reply: rig send a@r "..."');
|
||||
});
|
||||
|
||||
it("falls back to a marker when the sender is undefined or empty", () => {
|
||||
const undef = wrapSendBody(undefined, "b@r", "hi");
|
||||
expect(undef).toContain("From: <unknown sender>");
|
||||
expect(undef).toContain('↩ Reply: rig send <unknown sender> "..."');
|
||||
const blank = wrapSendBody(" ", "b@r", "hi");
|
||||
expect(blank).toContain("From: <unknown sender>");
|
||||
});
|
||||
|
||||
it("uses the literal recipient string in the To header so cross-rig addresses survive", () => {
|
||||
const out = wrapSendBody("from@a", "to@b", "x");
|
||||
expect(out).toMatch(/^From: from@a\nTo: to@b\n---\n/);
|
||||
});
|
||||
});
|
||||
@@ -137,15 +137,11 @@ describe("Send CLI", () => {
|
||||
"/api/transport/send",
|
||||
expect.objectContaining({
|
||||
session: "dev-impl@my-rig",
|
||||
text: expect.stringContaining("hello"),
|
||||
text: "hello",
|
||||
waitForIdleMs: 30000,
|
||||
}),
|
||||
{ timeoutMs: 35000 },
|
||||
);
|
||||
const sentText = postFn.mock.calls[0]?.[1] as { text: string } | undefined;
|
||||
expect(sentText?.text).toContain("To: dev-impl@my-rig");
|
||||
expect(sentText?.text).toContain("---\nhello\n---");
|
||||
expect(sentText?.text).toContain('↩ Reply: rig send');
|
||||
});
|
||||
|
||||
it("send without wait-for-idle uses default client timeout path", async () => {
|
||||
|
||||
@@ -146,46 +146,18 @@ describe("Specs CLI", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("specs show --kind resolves same-name rig/workflow entries", async () => {
|
||||
const dupServer = http.createServer((req, res) => {
|
||||
const url = decodeURIComponent(req.url ?? "");
|
||||
if (url === "/api/specs/library?kind=rig") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ ...LIBRARY_ENTRIES[0], id: "rig-same", kind: "rig", name: "conveyor" }]));
|
||||
} else {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => { dupServer.listen(0, resolve); });
|
||||
const dupPort = (dupServer.address() as { port: number }).port;
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(specsCommand(runningDeps(dupPort)));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "specs", "show", "conveyor", "--kind", "rig"]);
|
||||
});
|
||||
dupServer.close();
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Kind: rig");
|
||||
expect(output).toContain("/builtin/review-rig.yaml");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("specs show not-found error points people back to managed apps in the library", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "specs", "show", "missing-app"]);
|
||||
});
|
||||
|
||||
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, workflows, and managed apps.");
|
||||
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, and managed apps.");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("specs preview --json returns structured review", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--kind", "rig", "--json"]);
|
||||
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--json"]);
|
||||
});
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.kind).toBe("rig");
|
||||
@@ -205,14 +177,12 @@ describe("Specs CLI", () => {
|
||||
const { join } = await import("node:path");
|
||||
const { tmpdir } = await import("node:os");
|
||||
|
||||
// Use temp OpenRig home to avoid polluting real ~/.openrig/specs/
|
||||
// Use temp dir as HOME to avoid polluting real ~/.openrig/specs/
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "specs-add-"));
|
||||
const specPath = join(tmpDir, "test-spec.yaml");
|
||||
writeFileSync(specPath, 'name: test-spec\nversion: "0.2"\npods: []\nedges: []\n');
|
||||
const savedHome = process.env["HOME"];
|
||||
const savedOpenRigHome = process.env["OPENRIG_HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
process.env["OPENRIG_HOME"] = join(tmpDir, ".openrig");
|
||||
|
||||
const addServer = http.createServer((req, res) => {
|
||||
const url = decodeURIComponent(req.url ?? "");
|
||||
@@ -250,8 +220,6 @@ describe("Specs CLI", () => {
|
||||
expect(existsSync(join(tmpDir, ".openrig", "specs", "test-spec.yaml"))).toBe(true);
|
||||
|
||||
process.env["HOME"] = savedHome;
|
||||
if (savedOpenRigHome !== undefined) process.env["OPENRIG_HOME"] = savedOpenRigHome;
|
||||
else delete process.env["OPENRIG_HOME"];
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
|
||||
const output = logs.join("\n");
|
||||
@@ -307,9 +275,7 @@ describe("Specs CLI", () => {
|
||||
].join("\n"));
|
||||
|
||||
const savedHome = process.env["HOME"];
|
||||
const savedOpenRigHome = process.env["OPENRIG_HOME"];
|
||||
process.env["HOME"] = tmpDir;
|
||||
process.env["OPENRIG_HOME"] = join(tmpDir, ".openrig");
|
||||
|
||||
const addServer = http.createServer((req, res) => {
|
||||
const url = decodeURIComponent(req.url ?? "");
|
||||
@@ -354,8 +320,6 @@ describe("Specs CLI", () => {
|
||||
} finally {
|
||||
addServer.close();
|
||||
process.env["HOME"] = savedHome;
|
||||
if (savedOpenRigHome !== undefined) process.env["OPENRIG_HOME"] = savedOpenRigHome;
|
||||
else delete process.env["OPENRIG_HOME"];
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -618,100 +618,6 @@ describe("Up CLI", () => {
|
||||
expect(clientBaseUrl).toBe("http://127.0.0.1:7461");
|
||||
});
|
||||
|
||||
// bug-fix slice auth-bearer-tailscale-trust forward-fix #2: `rig up`
|
||||
// auto-start path was the second product launch path materializing
|
||||
// the default daemon.host into OPENRIG_HOST. The discriminator below
|
||||
// proves source=default → omit (so daemon multi-binds); source=env
|
||||
// → preserve (operator opt-in).
|
||||
describe("auth-bearer-tailscale-trust: up auto-start respects daemon.host source", () => {
|
||||
function makeAutoStartDeps(captureSpawn: (env: Record<string, string>) => void): StatusDeps {
|
||||
let daemonState: DaemonState | null = null;
|
||||
let daemonStarted = false;
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE ? daemonState !== null : false),
|
||||
readFile: vi.fn((p: string) => p === STATE_FILE && daemonState ? JSON.stringify(daemonState) : null),
|
||||
writeFile: vi.fn((p: string, content: string) => {
|
||||
if (p === STATE_FILE) daemonState = JSON.parse(content) as DaemonState;
|
||||
}),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
mkdirp: vi.fn(),
|
||||
spawn: vi.fn((cmd, args, opts) => {
|
||||
daemonStarted = true;
|
||||
captureSpawn(opts.env as Record<string, string>);
|
||||
return { pid: 999, unref: vi.fn() } as never;
|
||||
}),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
if (!daemonStarted) throw new Error(`refused:${url}`);
|
||||
return { ok: url.includes("/healthz") };
|
||||
}),
|
||||
},
|
||||
clientFactory: () => ({
|
||||
post: vi.fn(async () => ({
|
||||
status: 201,
|
||||
data: { status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
|
||||
})),
|
||||
} as unknown as DaemonClient),
|
||||
};
|
||||
}
|
||||
|
||||
it("default (no env, no config) does NOT export OPENRIG_HOST so daemon multi-binds", async () => {
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
delete process.env["OPENRIG_HOST"];
|
||||
process.env["OPENRIG_PORT"] = "7471";
|
||||
|
||||
let spawnedEnv: Record<string, string> = {};
|
||||
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||||
|
||||
await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||||
});
|
||||
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
|
||||
expect(spawnedEnv["OPENRIG_HOST"]).toBeUndefined();
|
||||
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7471");
|
||||
});
|
||||
|
||||
it("env-explicit OPENRIG_HOST is preserved into spawn env (distinct value discriminator)", async () => {
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
// Use a distinct value so the discriminator can't pass vacuously:
|
||||
// 100.64.55.66 is a tailscale CGNAT address — operator-explicit
|
||||
// bind would normally be honored as-is.
|
||||
process.env["OPENRIG_HOST"] = "100.64.55.66";
|
||||
process.env["OPENRIG_PORT"] = "7472";
|
||||
|
||||
let spawnedEnv: Record<string, string> = {};
|
||||
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||||
|
||||
await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||||
});
|
||||
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
|
||||
expect(spawnedEnv["OPENRIG_HOST"]).toBe("100.64.55.66");
|
||||
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7472");
|
||||
});
|
||||
});
|
||||
|
||||
it("up surfaces the real daemon auto-start failure instead of a generic hint", async () => {
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
process.env["OPENRIG_PORT"] = "7463";
|
||||
@@ -784,74 +690,6 @@ describe("Up CLI", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("up resolves a same-name rig and workflow to the rig library entry", async () => {
|
||||
const origListeners = server.listeners("request");
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
const url = decodeURIComponent(req.url ?? "");
|
||||
if (url === "/api/specs/library?kind=rig") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
|
||||
} else if (url === "/api/rigs/summary") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([]));
|
||||
} else if (url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "planned", runId: "r", stages: [], errors: [] }));
|
||||
} else {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${url}` }));
|
||||
}
|
||||
});
|
||||
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "conveyor", "--plan"]);
|
||||
});
|
||||
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
|
||||
expect(lastBody.sourceRef).toBe("/specs/rigs/conveyor/rig.yaml");
|
||||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("up --existing bypasses library-name ambiguity and posts the rig name", async () => {
|
||||
const origListeners = server.listeners("request");
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([{ id: "r1", name: "alpha", nodeCount: 1, lifecycleState: "recoverable" }]));
|
||||
} else if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "restored", rigId: "r1", rigName: "alpha", rigResult: "restored", nodes: [], warnings: [] }));
|
||||
} else {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${req.url}` }));
|
||||
}
|
||||
});
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--existing"]);
|
||||
});
|
||||
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
|
||||
expect(lastBody.sourceRef).toBe("alpha");
|
||||
expect(logs.join("\n")).toContain('Recovering rig "alpha" from latest snapshot or current DB state');
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// L2 wording divergence: rig name in recoverable state -> "Recovering ..."; stopped -> "Turning on ..."
|
||||
it("up <rig-name> prints 'Recovering ... from latest snapshot' when lifecycleState=recoverable", async () => {
|
||||
const origListeners = server.listeners("request");
|
||||
@@ -883,7 +721,7 @@ describe("Up CLI", () => {
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain('Recovering rig "stale-velocity" from latest snapshot or current DB state');
|
||||
expect(output).toContain('Recovering rig "stale-velocity" from latest snapshot');
|
||||
expect(output).not.toContain('Turning on rig "stale-velocity"');
|
||||
expect(exitCode).toBeUndefined(); // 0
|
||||
});
|
||||
@@ -1063,8 +901,8 @@ describe("Up CLI", () => {
|
||||
// stage with the resolved starter ResolvedStartupFile shape; the CLI's
|
||||
// --json mode renders the full daemon response, so the starter content
|
||||
// surfaces verbatim in plan output. M2 R1 only asserted argument
|
||||
// forwarding, which proved nothing about plan visibility of starter
|
||||
// resolution.
|
||||
// forwarding — Guard Finding 3 — which proved nothing about plan
|
||||
// visibility of starter resolution.
|
||||
it("M2 R2: rig up --plan --json against a starter_ref fixture spec surfaces resolved starter contents", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const fs = await import("node:fs");
|
||||
|
||||
-12
@@ -1,18 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
// OpenRig activity-relay hook script.
|
||||
// Reads a hook event payload from stdin, normalizes it, and POSTs to the
|
||||
// OpenRig daemon's /api/activity/hooks endpoint for real-time UI seat-status.
|
||||
// Best-effort only: 1.5s timeout, errors swallowed, never blocks the agent loop.
|
||||
//
|
||||
// Required environment (injected by the OpenRig daemon when launching the agent):
|
||||
// OPENRIG_SESSION_NAME or RIGGED_SESSION_NAME - tmux session id
|
||||
// OPENRIG_NODE_ID or RIGGED_NODE_ID - node id in the rig topology
|
||||
// OPENRIG_RUNTIME or RIGGED_RUNTIME - "claude-code" | "codex" | etc.
|
||||
// OPENRIG_URL or RIGGED_URL - daemon base URL
|
||||
// OPENRIG_ACTIVITY_HOOK_TOKEN or RIGGED_ACTIVITY_HOOK_TOKEN - bearer auth
|
||||
|
||||
async function readStdin() {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
"hooks": "./hooks/claude.json"
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "openrig-core",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenRig canonical skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.",
|
||||
"author": {
|
||||
"name": "Mike Schwarz",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
"hooks": "./hooks/codex.json",
|
||||
"interface": {
|
||||
"displayName": "OpenRig Core",
|
||||
"shortDescription": "Canonical OpenRig skills and hooks",
|
||||
"longDescription": "Cross-runtime agent topology primitive shipping coordination, continuity, and operating-discipline skills, plus activity-tracking hooks. Use as the baseline plugin for any OpenRig-managed agent.",
|
||||
"developerName": "Mike Schwarz",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Read"],
|
||||
"websiteURL": "https://github.com/mvschwarz/openrig-plugins"
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Support. While redistributing the Work or Derivative Works thereof,
|
||||
You may choose to offer, and charge a fee for, acceptance of support,
|
||||
warranty, indemnity, or other liability obligations and/or rights
|
||||
consistent with this License. However, in accepting such obligations,
|
||||
You may act only on Your own behalf and on Your sole responsibility,
|
||||
not on behalf of any other Contributor, and only if You agree to
|
||||
indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or support.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Mike Schwarz
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,42 +0,0 @@
|
||||
# openrig-core
|
||||
|
||||
Canonical OpenRig skills and hooks for cross-runtime agent topology coordination, continuity, and operating discipline.
|
||||
|
||||
## What this plugin ships
|
||||
|
||||
**Skills (8):** the canonical OpenRig operating knowledge — how to use the `rig` CLI, how to design rig topologies, how to recover from compaction, how to hand work off durably between agents, and how to maintain seat continuity across handovers and restarts.
|
||||
|
||||
**Hooks:** activity-tracking hooks (Claude Code: 4 events; Codex: 3 events) that POST agent state to the OpenRig daemon for real-time UI seat-status updates.
|
||||
|
||||
## Runtimes
|
||||
|
||||
This plugin ships dual-manifest packaging — both `.claude-plugin/` and `.codex-plugin/`. Use it with:
|
||||
|
||||
- **Claude Code** — install via `/plugin` after the OpenRig daemon vendors `openrig-core/` to `~/.openrig/plugins/openrig-core/` on first run, or install the plugin directly through Claude Code's own plugin commands when remote distribution is available
|
||||
- **Codex CLI** — install via `/plugins` after the same vendoring step, or directly through Codex's own plugin commands
|
||||
|
||||
One skill targets Claude Code's compaction behavior specifically:
|
||||
- `claude-compaction-restore` — used to rebuild a Claude Code agent's working mental model after `/compact`, from JSONL transcripts and touched files
|
||||
|
||||
This is NOT Codex-self-targeting (Codex's compaction is handled internally and doesn't need rebuild SOPs). But Codex agents acting as orchestrators frequently invoke this skill when restoring a peer Claude that has compacted — that's exactly when it's needed. Both runtimes ship it.
|
||||
|
||||
All other skills are cross-runtime by design.
|
||||
|
||||
## Distribution
|
||||
|
||||
**At v0 (today):** the vendored copy inside the OpenRig npm package is the source of truth. When you install `@openrig/cli` (or run the daemon), `openrig-core/` is copied to `~/.openrig/plugins/openrig-core/` on first run. This is the offline-safe baseline and is always available.
|
||||
|
||||
**Auto-fetch from GitHub at v0:** the OpenRig daemon probes `github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz` on launch with a 5s timeout. **At v0 the success path only logs the response and does NOT extract a tarball, compare versions, or update the local copy** — extraction + version-compare + update are scoped to a later marketplace-consumption phase (mission slice 3.6). 404 (the expected normal-state response while the GitHub repo is empty), network errors, and timeouts are all tolerated silently with the vendored copy remaining authoritative.
|
||||
|
||||
**Future (slice 3.6, marketplace-consumption phase):** when the GitHub releases workflow is set up and authorization to publish lands, the daemon will start extracting fetched tarballs + comparing versions + updating the local copy as the "hot update" path that skips OpenRig CLI version bumps.
|
||||
|
||||
You can also install directly via Claude Code or Codex's own plugin commands if you prefer to manage plugins outside OpenRig.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See `LICENSE`.
|
||||
|
||||
## Source
|
||||
|
||||
- Plugin repo: https://github.com/mvschwarz/openrig-plugins
|
||||
- OpenRig CLI: https://github.com/mvschwarz/openrig
|
||||
@@ -1,78 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/compaction-restore-bridge.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CODEX_PLUGIN_ROOT}/hooks/scripts/activity-relay.cjs\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
// OpenRig Claude compaction restore bridge.
|
||||
//
|
||||
// PreCompact writes a pending marker under OPENRIG_HOME. SessionStart
|
||||
// (matcher=compact) and UserPromptSubmit can then inject one restore
|
||||
// directive into Claude context via hookSpecificOutput.additionalContext.
|
||||
// PostCompact uses the same script as a cheap marker timestamp hook.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
async function readStdin() {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", () => resolve(""));
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "{}");
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openrigHome(env = process.env) {
|
||||
return firstString(env.OPENRIG_HOME, env.RIGGED_HOME) || path.join(os.homedir(), ".openrig");
|
||||
}
|
||||
|
||||
function sanitizeKey(value) {
|
||||
return value.replace(/[^a-zA-Z0-9_.@-]/g, "_");
|
||||
}
|
||||
|
||||
function sessionKey(payload, env = process.env) {
|
||||
const raw = firstString(
|
||||
env.OPENRIG_SESSION_NAME,
|
||||
env.RIGGED_SESSION_NAME,
|
||||
payload.session_id,
|
||||
payload.sessionId,
|
||||
payload.session_name,
|
||||
payload.sessionName,
|
||||
payload.transcript_path ? path.basename(payload.transcript_path, ".jsonl") : null,
|
||||
);
|
||||
return raw ? sanitizeKey(raw) : null;
|
||||
}
|
||||
|
||||
function markerDir(env = process.env) {
|
||||
return path.join(openrigHome(env), "compaction", "restore-pending");
|
||||
}
|
||||
|
||||
function readMarker(filePath) {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return { filePath, data: parsed };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findMarker(payload, env = process.env) {
|
||||
const dir = markerDir(env);
|
||||
const key = sessionKey(payload, env);
|
||||
if (key) {
|
||||
const direct = readMarker(path.join(dir, `${key}.json`));
|
||||
if (direct) return direct;
|
||||
}
|
||||
try {
|
||||
const candidates = fs.readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => path.join(dir, name))
|
||||
.map((filePath) => ({ filePath, stat: fs.statSync(filePath) }))
|
||||
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
||||
for (const candidate of candidates) {
|
||||
const marker = readMarker(candidate.filePath);
|
||||
if (marker) return marker;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeMarker(marker) {
|
||||
fs.writeFileSync(marker.filePath, `${JSON.stringify(marker.data, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function buildRestoreContext(marker) {
|
||||
const outputDir = firstString(marker.data.outputDir) || "/tmp/claude-compaction-restore";
|
||||
const ack = firstString(marker.data.expectedAck) || "restored from packet at <path>; resumed at step <X>";
|
||||
const postInstruction = firstString(marker.data.postCompactInstruction);
|
||||
const pieces = [
|
||||
"OpenRig compaction restore packet is available for this Claude session.",
|
||||
"This hook output is informational context, not the action request.",
|
||||
`Restore packet: ${outputDir}`,
|
||||
"OpenRig may send a later normal user message asking you to restore from this packet. Treat that later normal user message as the operator-authorized action request.",
|
||||
`After restoration, reply with: ${ack}`,
|
||||
];
|
||||
if (postInstruction) {
|
||||
pieces.push(`Operator post-compaction context: ${postInstruction}`);
|
||||
}
|
||||
return pieces.join("\n");
|
||||
}
|
||||
|
||||
function hookEventName(payload) {
|
||||
return firstString(
|
||||
payload.hook_event_name,
|
||||
payload.hookEventName,
|
||||
payload.hookEvent,
|
||||
payload.event_name,
|
||||
payload.eventName,
|
||||
payload.event,
|
||||
) || "UserPromptSubmit";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const payload = parseJson(await readStdin());
|
||||
const eventName = hookEventName(payload);
|
||||
const marker = findMarker(payload);
|
||||
if (!marker) return;
|
||||
|
||||
marker.data.lastBridgeEvent = eventName;
|
||||
if (eventName === "PostCompact") {
|
||||
marker.data.postCompactAt = new Date().toISOString();
|
||||
writeMarker(marker);
|
||||
return;
|
||||
}
|
||||
|
||||
if (marker.data.deliveryCount && marker.data.deliveryCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
marker.data.deliveredAt = new Date().toISOString();
|
||||
marker.data.deliveryCount = Number(marker.data.deliveryCount || 0) + 1;
|
||||
writeMarker(marker);
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
continue: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: eventName,
|
||||
additionalContext: buildRestoreContext(marker),
|
||||
},
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildRestoreContext,
|
||||
findMarker,
|
||||
hookEventName,
|
||||
parseJson,
|
||||
};
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
---
|
||||
name: agent-startup-and-context-ingestion
|
||||
description: Use when designing or auditing how an agent becomes useful after launch — AGENTS.md overlays, role files, skills, rig specs, workflow specs, startup checklists, refocus messages, "rig context" surface. Covers the 4 failure modes that make startup context fail (old rig spec misses current operating mode; current agents never told about new guidance; startup file as dumping ground; orchestrator transmits implementation without preserving product intent).
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Cross-runtime restore/reentry packet standard + externalized memory
|
||||
surfaces convention. Load at agent start; revisit after compaction.
|
||||
sibling_skills:
|
||||
- claude-compaction-restore
|
||||
- seat-continuity-and-handover
|
||||
transfer_test: pending
|
||||
---
|
||||
|
||||
# Agent Startup and Context Ingestion
|
||||
|
||||
How an agent becomes useful after launch: **AGENTS.md overlays, role
|
||||
files, skills, rig specs, workflow specs, startup checklists, refocus
|
||||
messages**, and any future "rig context" command or skill-librarian
|
||||
surface.
|
||||
|
||||
It is the current concrete startup path inside the broader
|
||||
context-engineering-and-retrieval primitive. Startup gets a seat into
|
||||
the right initial shape; context engineering is the larger question of
|
||||
how a seat gets the right context for the work it is doing right now.
|
||||
|
||||
**Most coordination failures are not tool failures; they are context
|
||||
failures.** Agents need to know their role, operating mode, coordination
|
||||
convention, boundaries, and current product intent. If startup context
|
||||
is scattered or stale, agents execute the wrong thing very efficiently.
|
||||
|
||||
## Use this when
|
||||
|
||||
- Authoring a new agent's startup files (role / culture / startup-context)
|
||||
- Refreshing a seat that's been running on stale guidance
|
||||
- Auditing whether agents got the current operating mode (not just what's in old files)
|
||||
- Designing the orchestrator → next-agent context-transmission shape
|
||||
- Building a startup map for OpenRig-building rigs
|
||||
|
||||
## Don't use this when
|
||||
|
||||
- The agent is being created via Agent Starter — the starter's manifest carries startup context
|
||||
- The work is artifact-backed mental-model rebuild from a packet — that's `claude-compaction-restore`
|
||||
- The intent is to ship reusable startup content as a skill — write the skill against the agentskills.io specification, not as inline startup prose
|
||||
|
||||
## Failure modes (4)
|
||||
|
||||
1. **A new agent starts from an old rig spec and misses the current operating mode.** Specs go stale; current state must be visible at startup, not just historical config.
|
||||
2. **Guidance is written to a file that future agents read, but current agents are never told.** File edits don't propagate to running sessions. Cultural rollout (broadcast + fleet-changes-feed) is needed alongside file edits.
|
||||
3. **A startup file becomes a dumping ground and loses the map-to-canonical-sources role.** Startup should point AT canonical sources; it shouldn't TRY to be one.
|
||||
4. **The orchestrator transmits implementation instructions without preserving product intent.** Instructions decay; intent travels.
|
||||
|
||||
## Proof standard
|
||||
|
||||
Startup proof should:
|
||||
|
||||
1. **Launch or refresh** a seat
|
||||
2. **Inspect** what it actually read
|
||||
3. **Verify** it can state the current role, mode, active constraints, and next handoff convention **in its own words**
|
||||
|
||||
## Cross-runtime startup paths (5; do not collapse)
|
||||
|
||||
When a seat's startup is artifact-backed mental-model rebuild (active-work
|
||||
reentry case, distinct from reusable Agent Starters / priming packs):
|
||||
|
||||
- See the **cross-runtime restore/reentry packet standard v0**
|
||||
- Source-trust ranking applies: **`rig whoami` > target rigspec > bounded latest transcript > full transcript > touched-files > `restore-summary.json`**
|
||||
|
||||
## Memory surfaces consumed at startup
|
||||
|
||||
The primary surfaces a startup ingestion path reads:
|
||||
|
||||
- AGENTS / role / CULTURE / startup overlays (the per-seat identity layer)
|
||||
- startup replay context (what the agent did before the current boot)
|
||||
- restore / reentry packets and Agent Starters (cross-runtime startup path)
|
||||
|
||||
Authority-rank + read-vs-write posture governs which surfaces a
|
||||
startup-ingestion path can write vs only read; treat overlays as
|
||||
write-restricted unless your role explicitly authors them.
|
||||
|
||||
## Startup files vs skills (the distinction)
|
||||
|
||||
Per `agent-startup-guide.md` (product reference doc) and the team handbook:
|
||||
|
||||
| Startup files | Skills |
|
||||
|---|---|
|
||||
| Rig-specific, role-specific identity | Reusable SOPs / methodology / knowledge |
|
||||
| Tell agent WHO it is, WHAT it's working on, HOW this team operates | Tell agent HOW to do something (transferable across rigs) |
|
||||
| Examples: `role.md`, `CULTURE.md`, `startup/context.md` | Examples: `openrig-user`, `test-driven-development`, `dogfood` |
|
||||
| Authored per-rig | Authored once, used everywhere |
|
||||
|
||||
Don't put skill content in startup files. Don't put identity content
|
||||
in skills. The 7-layer additive startup model (agent / profile / rig /
|
||||
culture / pod / member / operator) handles the layering.
|
||||
|
||||
## See also
|
||||
|
||||
- `forming-an-openrig-mental-model` skill — orientation for new agents
|
||||
- `claude-compaction-restore` skill — restore-time startup ingestion after compaction
|
||||
- `seat-continuity-and-handover` skill — packet shape that survives seat resets
|
||||
- https://agentskills.io/specification — the cross-runtime skill standard (what skill content should and shouldn't be)
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
---
|
||||
name: claude-compaction-restore
|
||||
description: Use when a Claude Code session has just compacted, is about to compact, reached context limit, resumed after /compact, or needs to rebuild its working mental model from Claude JSONL transcripts and touched files.
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Load-bearing for any OpenRig seat that may compact. PreCompact hook
|
||||
is wired into Claude Code's settings (e.g. ~/.claude/settings.json)
|
||||
so the restore packet is generated automatically before compaction.
|
||||
sibling_skills:
|
||||
- agent-startup-and-context-ingestion
|
||||
- seat-continuity-and-handover
|
||||
---
|
||||
|
||||
# Claude Compaction Restore
|
||||
|
||||
Use this skill to preserve continuity before Claude Code compacts and to
|
||||
restore continuity after compaction. Do the work described here; do not merely
|
||||
explain the protocol.
|
||||
|
||||
## If You Are About To Compact
|
||||
|
||||
Prepare durable continuity before the context boundary.
|
||||
|
||||
1. Identify the active task, queue item, mission/slice, branch or commit, and
|
||||
current working directory.
|
||||
2. Record the current state: decisions made, files changed, commands/tests run,
|
||||
evidence produced, blockers, caveats, and the next concrete step.
|
||||
3. Create or update a durable mental-model restore map. This map is the main
|
||||
artifact future-you will use to rebuild context after compaction.
|
||||
4. In the restore map, write an ASCII file/folder tree of every path that
|
||||
mattered to your working mental model during this session. Include:
|
||||
- the active queue item or mission packet;
|
||||
- mission notes, progress, decisions, and evidence files;
|
||||
- Claude memory/project notes you used or wrote, especially when the memory
|
||||
folder is shared by many agents;
|
||||
- files with active edits or recently inspected source;
|
||||
- root instructions such as `AGENTS.md`, `CLAUDE.md`, or `README.md`;
|
||||
- as-built docs, codemaps, conventions, skills, and product docs needed
|
||||
before code/review work;
|
||||
- source files, tests, scripts, UI evidence, screenshots, logs, or reports
|
||||
that shaped your current state.
|
||||
5. For every file or folder in the tree, add a short note explaining why it
|
||||
matters and whether it is required reading after compaction.
|
||||
6. Write any important glue context that is not already on disk into the
|
||||
handoff/restore map. This includes assumptions, partial conclusions, failed
|
||||
paths, and why the listed files fit together.
|
||||
7. In the compaction summary, include the restore map path and the top required
|
||||
reading paths from that map.
|
||||
|
||||
## If You Just Compacted
|
||||
|
||||
Treat your memory as unreliable until you restore from durable evidence.
|
||||
Restoration is your current task. Do not wait for a future user request or a
|
||||
new task assignment before reading the required files.
|
||||
|
||||
1. Read any restore prompt, marker path, packet path, transcript path, restore
|
||||
map, or extra instruction file named by the operator or OpenRig.
|
||||
2. Run the restore script from this skill:
|
||||
|
||||
```bash
|
||||
node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore
|
||||
```
|
||||
|
||||
If the prompt names a specific Claude JSONL transcript, pass it explicitly:
|
||||
|
||||
```bash
|
||||
node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs /path/to/session.jsonl --out /tmp/claude-compaction-restore
|
||||
```
|
||||
|
||||
3. Read the generated `restore-instructions.md`.
|
||||
4. Read the generated `touched-files.md`.
|
||||
5. Find and read the mental-model restore map if one exists. Treat it as the
|
||||
highest-value guide to which memory files, mission files, source files, and
|
||||
docs mattered before compaction.
|
||||
6. Identify the important files for the active task. The touched-file list is
|
||||
a triage aid, not a complete inventory.
|
||||
7. Read every important file in full. Prioritize the restore map, Claude memory
|
||||
files named in the map, Markdown state/planning files, queue/mission packets,
|
||||
source files with active edits, root instruction files, and as-built or
|
||||
codemap docs.
|
||||
8. After the required reads are complete, state:
|
||||
|
||||
```text
|
||||
restored from packet at <path>; resumed at step <X>
|
||||
```
|
||||
|
||||
Include the main files you read in full when you make that statement.
|
||||
|
||||
## Required Read-Depth Audit
|
||||
|
||||
After the first restore pass, audit yourself before continuing.
|
||||
|
||||
1. List every file, packet, marker, restore map, instruction file, and source
|
||||
document you were asked to read during restore.
|
||||
2. Mark each item as `FULL`, `PARTIAL`, or `NOT_READ`.
|
||||
3. You will be given a task where all of these files are required reading in
|
||||
order to understand the task.
|
||||
4. Do not optimize for token conservation.
|
||||
5. Read every `PARTIAL` or `NOT_READ` item in full now.
|
||||
6. Report the final read-depth table before doing any substantive task work.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not silently launch fresh after compaction.
|
||||
- Do not continue from memory when restore evidence exists.
|
||||
- Do not defer required restore reading until a later user task. The restore is
|
||||
the current task.
|
||||
- Do not skip root instructions, as-built docs, or codemaps before product
|
||||
code/review work.
|
||||
- Do not treat the generated touched-file list as exhaustive.
|
||||
- Do not mark a file `FULL` unless you actually read the full file content
|
||||
after compaction.
|
||||
- Do not resume task work until the restore sentinel and read-depth audit are
|
||||
complete.
|
||||
|
||||
## Failure Modes To Avoid
|
||||
|
||||
1. **Confidently-wrong restoration**: claiming restoration after reading only
|
||||
the touched-file list or summary.
|
||||
2. **Partial restore**: reading the first few files, then continuing before the
|
||||
full reading list is complete.
|
||||
3. **Skipping project instructions**: missing `AGENTS.md`, `CLAUDE.md`,
|
||||
`README.md`, as-built docs, or codemaps that govern the task.
|
||||
4. **Treating the packet as exhaustive**: ignoring mission or workspace files
|
||||
that are important but were not discovered by the script.
|
||||
5. **Waiting for the next task**: treating restore reading as conditional on a
|
||||
future user assignment instead of completing it immediately.
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const skillRoot = path.resolve(new URL("..", import.meta.url).pathname);
|
||||
const restoreScript = path.join(skillRoot, "scripts", "restore-from-jsonl.mjs");
|
||||
const outRoot = "/tmp/claude-compaction-restore";
|
||||
const defaultRestoreInstruction =
|
||||
"Read the claude-compaction-restore skill and follow its \"If You Just Compacted\" protocol.";
|
||||
|
||||
function emit(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
function readHookInput() {
|
||||
const raw = fs.readFileSync(0, "utf8").trim();
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function getOpenRigHome() {
|
||||
return process.env.OPENRIG_HOME || process.env.RIGGED_HOME || path.join(os.homedir(), ".openrig");
|
||||
}
|
||||
|
||||
function expandInstructionPath(filePath) {
|
||||
if (filePath.startsWith("~/")) return path.join(os.homedir(), filePath.slice(2));
|
||||
if (filePath.startsWith("${OPENRIG_HOME}/")) {
|
||||
return path.join(getOpenRigHome(), filePath.slice("${OPENRIG_HOME}/".length));
|
||||
}
|
||||
if (filePath.startsWith("$OPENRIG_HOME/")) {
|
||||
return path.join(getOpenRigHome(), filePath.slice("$OPENRIG_HOME/".length));
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function readInstructionFile(filePath) {
|
||||
const expanded = expandInstructionPath(filePath);
|
||||
if (!fs.existsSync(expanded)) return "";
|
||||
return fs.readFileSync(expanded, "utf8");
|
||||
}
|
||||
|
||||
function sessionKey(input) {
|
||||
const raw = [
|
||||
process.env.OPENRIG_SESSION_NAME,
|
||||
process.env.RIGGED_SESSION_NAME,
|
||||
input.session_id,
|
||||
input.sessionId,
|
||||
input.session_name,
|
||||
input.sessionName,
|
||||
input.transcript_path ? path.basename(input.transcript_path, ".jsonl") : "",
|
||||
].find((value) => typeof value === "string" && value.trim().length > 0) || "unknown-session";
|
||||
return raw.replace(/[^a-zA-Z0-9_.@-]/g, "_");
|
||||
}
|
||||
|
||||
function pendingMarkerPath(input) {
|
||||
return path.join(getOpenRigHome(), "compaction", "restore-pending", `${sessionKey(input)}.json`);
|
||||
}
|
||||
|
||||
function writePendingRestoreMarker(input, parsed, restoreInstruction, customMessage) {
|
||||
const markerPath = pendingMarkerPath(input);
|
||||
fs.mkdirSync(path.dirname(markerPath), { recursive: true });
|
||||
const payload = {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
sessionName: process.env.OPENRIG_SESSION_NAME || process.env.RIGGED_SESSION_NAME || null,
|
||||
sessionId: input.session_id || input.sessionId || null,
|
||||
transcriptPath: input.transcript_path || null,
|
||||
cwd: input.cwd || null,
|
||||
outputDir: parsed.outputDir,
|
||||
restoreInstruction,
|
||||
postCompactInstruction: customMessage || "",
|
||||
expectedAck: "restored from packet at <path>; resumed at step <X>",
|
||||
deliveredAt: null,
|
||||
deliveryCount: 0,
|
||||
};
|
||||
fs.writeFileSync(markerPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
return markerPath;
|
||||
}
|
||||
|
||||
// Slice 27 — read the OpenRig config directly (no daemon HTTP dependency
|
||||
// so the hook still works when the daemon isn't running or isn't
|
||||
// reachable from this process). Returns "" for either field when the
|
||||
// config is missing, malformed, or the policy isn't set. If the policy is
|
||||
// enabled but restore text has not been written yet, use OpenRig's default
|
||||
// instruction to load the canonical restore skill. Inline instructions and
|
||||
// file-path content are both included when both are configured.
|
||||
function readClaudeCompactionMessage() {
|
||||
const configPath = path.join(getOpenRigHome(), "config.json");
|
||||
let inline = "";
|
||||
let filePath = "";
|
||||
let inlineConfigured = false;
|
||||
let filePathConfigured = false;
|
||||
let policyEnabled = false;
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) return "";
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
const policy = parsed?.policies?.claudeCompaction;
|
||||
if (policy && typeof policy === "object") {
|
||||
policyEnabled = policy.enabled === true;
|
||||
if (typeof policy.messageInline === "string") {
|
||||
inlineConfigured = true;
|
||||
inline = policy.messageInline;
|
||||
}
|
||||
if (typeof policy.messageFilePath === "string") {
|
||||
filePathConfigured = true;
|
||||
filePath = policy.messageFilePath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (inline && inline.length > 0) {
|
||||
parts.push(`Inline restore instruction:\n${inline}`);
|
||||
}
|
||||
if (filePath && filePath.length > 0) {
|
||||
try {
|
||||
const fileText = readInstructionFile(filePath);
|
||||
if (fileText) {
|
||||
parts.push(`Additional restore instruction file (${filePath}):\n${fileText}`);
|
||||
}
|
||||
} catch {
|
||||
// Keep any inline instruction; unreadable extra files degrade quietly.
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) return parts.join("\n\n");
|
||||
if (policyEnabled && !inlineConfigured && !filePathConfigured) {
|
||||
return defaultRestoreInstruction;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildSystemMessage(restoreInstruction, customMessage) {
|
||||
if (!customMessage) return restoreInstruction;
|
||||
return `${restoreInstruction}\n\n--- Operator-configured post-compaction restore instruction ---\n${customMessage}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const input = readHookInput();
|
||||
const args = [restoreScript, "--out", outRoot, "--json"];
|
||||
if (input.cwd) args.push("--cwd", input.cwd);
|
||||
if (input.transcript_path && input.transcript_path.endsWith(".jsonl") && fs.existsSync(input.transcript_path)) {
|
||||
args.push(input.transcript_path);
|
||||
}
|
||||
|
||||
const customMessage = readClaudeCompactionMessage();
|
||||
|
||||
const result = spawnSync("node", args, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
const baseFailure = `Claude compaction restore packet generation failed: ${(result.stderr || result.stdout || "unknown error").trim()}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`;
|
||||
emit({
|
||||
continue: true,
|
||||
systemMessage: buildSystemMessage(baseFailure, customMessage),
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
const baseRestore = `Pre-compaction restore seed packet prepared at ${parsed.outputDir}. This hook output is informational. After compaction, OpenRig may send a later normal user message asking you to restore from this packet; treat that later normal user message as the action request. The restore protocol is: load/read the claude-compaction-restore skill, run "node ~/.claude/skills/claude-compaction-restore/scripts/restore-from-jsonl.mjs --out /tmp/claude-compaction-restore --json" yourself when needed, read the generated restore-instructions.md, read the generated touched-files.md, identify remembered important files, read those files in full, read root/as-built/codemap docs before real work, then reply with "restored from packet at <path>; resumed at step <X>" with the files you read in full. If any step fails, report the failure explicitly.`;
|
||||
const markerPath = writePendingRestoreMarker(input, parsed, baseRestore, customMessage);
|
||||
emit({
|
||||
continue: true,
|
||||
systemMessage: buildSystemMessage(`${baseRestore} OpenRig also wrote a pending restore marker at ${markerPath}.`, customMessage),
|
||||
});
|
||||
} catch (error) {
|
||||
emit({
|
||||
continue: true,
|
||||
systemMessage: `Claude compaction restore hook errored: ${error.message}. After compaction, load the claude-compaction-restore skill and run restore-from-jsonl manually.`,
|
||||
});
|
||||
}
|
||||
-433
@@ -1,433 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const TEXT_LIMIT = 4000;
|
||||
const DEFAULT_OUT = "/tmp/claude-compaction-restore";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
jsonl: null,
|
||||
out: DEFAULT_OUT,
|
||||
cwd: process.cwd(),
|
||||
json: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--jsonl") args.jsonl = argv[++i];
|
||||
else if (arg === "--out") args.out = argv[++i];
|
||||
else if (arg === "--cwd") args.cwd = argv[++i];
|
||||
else if (arg === "--json") args.json = true;
|
||||
else if (!arg.startsWith("-") && !args.jsonl) args.jsonl = arg;
|
||||
else throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function readJsonLines(file) {
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
return { type: "parse-error", line: index + 1, error: String(error), raw: line };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findLatestJsonl(cwd) {
|
||||
const projectsRoot = path.join(os.homedir(), ".claude", "projects");
|
||||
if (!fs.existsSync(projectsRoot)) return null;
|
||||
|
||||
const encoded = cwd ? cwd.replaceAll("/", "-") : null;
|
||||
const candidates = [];
|
||||
const roots = [];
|
||||
|
||||
if (encoded) {
|
||||
const exact = path.join(projectsRoot, encoded);
|
||||
if (fs.existsSync(exact)) roots.push(exact);
|
||||
}
|
||||
roots.push(projectsRoot);
|
||||
|
||||
for (const root of roots) {
|
||||
const result = spawnSync("find", [root, "-name", "*.jsonl", "-maxdepth", root === projectsRoot ? "4" : "2"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0 && !result.stdout) continue;
|
||||
for (const file of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
||||
try {
|
||||
const stat = fs.statSync(file);
|
||||
candidates.push({ file, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
// Ignore raced/deleted transcript files.
|
||||
}
|
||||
}
|
||||
if (candidates.length) break;
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates[0]?.file ?? null;
|
||||
}
|
||||
|
||||
function stringifyContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block?.type === "text") return block.text ?? "";
|
||||
if (block?.type === "tool_use") return `[tool_use:${block.name}] ${JSON.stringify(block.input ?? {})}`;
|
||||
if (block?.type === "tool_result") return `[tool_result] ${truncate(stringifyContent(block.content), TEXT_LIMIT)}`;
|
||||
return JSON.stringify(block);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
if (content == null) return "";
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
|
||||
function truncate(text, max) {
|
||||
const s = String(text ?? "");
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}\n[...truncated ${s.length - max} chars...]`;
|
||||
}
|
||||
|
||||
function walkStrings(value, acc = []) {
|
||||
if (typeof value === "string") acc.push(value);
|
||||
else if (Array.isArray(value)) value.forEach((item) => walkStrings(item, acc));
|
||||
else if (value && typeof value === "object") Object.values(value).forEach((item) => walkStrings(item, acc));
|
||||
return acc;
|
||||
}
|
||||
|
||||
function normalizeFile(candidate, cwd) {
|
||||
let file = candidate.trim().replace(/[),.;:'"`\]\}>]+$/g, "").replace(/^[('"`<\[]+/g, "");
|
||||
if (!file || file.includes("\n")) return null;
|
||||
if (/\.(?:md|mdx|txt|json|jsonl|yaml|yml|toml|ts|tsx|js|jsx|mjs|cjs|py|sh|rs|go|sql|css|scss|html|xml|svg|csv|log|lock)\//i.test(file)) return null;
|
||||
if (file.startsWith("~")) file = path.join(os.homedir(), file.slice(1));
|
||||
let normalized = null;
|
||||
if (file.startsWith("/")) normalized = path.normalize(file);
|
||||
else if (file.startsWith("./") || file.startsWith("../")) normalized = path.normalize(path.resolve(cwd, file));
|
||||
else if (/^[A-Za-z0-9_@.+-][A-Za-z0-9_@.+/\-:]*\.[A-Za-z0-9]+$/.test(file)) {
|
||||
normalized = path.normalize(path.resolve(cwd, file));
|
||||
} else if (/^(CLAUDE|AGENTS|README|DESIGN|CULTURE|MEMORY)\.md$/.test(file)) {
|
||||
normalized = path.normalize(path.resolve(cwd, file));
|
||||
}
|
||||
|
||||
if (!normalized) return null;
|
||||
try {
|
||||
if (fs.existsSync(normalized) && fs.statSync(normalized).isDirectory()) return null;
|
||||
} catch {
|
||||
// Keep the candidate if stat races; the restore agent can decide.
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractPaths(text, cwd) {
|
||||
const paths = new Set();
|
||||
const s = String(text ?? "");
|
||||
const absolute = /(?:^|[\s"'(<\[])(\/(?:Users|private|tmp|var|opt|Volumes)\/[^\s"'()\]<>]+)/g;
|
||||
const relative = /(?:^|[\s"'(<\[])((?:\.{1,2}\/)?[A-Za-z0-9_@.+-][A-Za-z0-9_@.+/\-:]*\.(?:md|mdx|txt|json|jsonl|yaml|yml|toml|ts|tsx|js|jsx|mjs|cjs|py|sh|rs|go|sql|css|scss|html|xml|svg|csv|log|lock))(?:$|[\s"')>\],.;:])/g;
|
||||
const namedMarkdown = /(?:^|[\s"'(<\[])((?:CLAUDE|AGENTS|README|DESIGN|CULTURE|MEMORY)\.md)(?:$|[\s"')>\],.;:])/g;
|
||||
|
||||
for (const regex of [absolute, relative, namedMarkdown]) {
|
||||
let match;
|
||||
while ((match = regex.exec(s)) !== null) {
|
||||
const normalized = normalizeFile(match[1], cwd);
|
||||
if (normalized) paths.add(normalized);
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function createRegistry() {
|
||||
const files = new Map();
|
||||
let sequence = 0;
|
||||
|
||||
return {
|
||||
add(file, kind, source) {
|
||||
if (!file) return;
|
||||
const existing = files.get(file) ?? {
|
||||
path: file,
|
||||
kinds: {},
|
||||
sources: new Set(),
|
||||
firstSeen: sequence,
|
||||
lastSeen: sequence,
|
||||
};
|
||||
existing.kinds[kind] = (existing.kinds[kind] ?? 0) + 1;
|
||||
existing.sources.add(source);
|
||||
existing.lastSeen = sequence;
|
||||
files.set(file, existing);
|
||||
},
|
||||
tick() {
|
||||
sequence += 1;
|
||||
},
|
||||
values() {
|
||||
return [...files.values()].map((entry) => ({
|
||||
...entry,
|
||||
sources: [...entry.sources].slice(0, 8),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolKind(name, input) {
|
||||
if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(name)) return "written";
|
||||
if (name === "Read") return "read";
|
||||
if (["Grep", "Glob", "LS"].includes(name)) return "discovered";
|
||||
if (name === "Bash") {
|
||||
const command = input?.command ?? "";
|
||||
if (/\b(apply_patch|tee|touch|mkdir|mv|cp|rm)\b|(^|[^>])>{1,2}[^>]/.test(command)) return "shell-write";
|
||||
return "shell-mentioned";
|
||||
}
|
||||
return "mentioned";
|
||||
}
|
||||
|
||||
function scoreFile(entry) {
|
||||
const ext = path.extname(entry.path).toLowerCase();
|
||||
let score = 0;
|
||||
if (entry.kinds.written) score += 40;
|
||||
if (entry.kinds["shell-write"]) score += 30;
|
||||
if (entry.kinds["file-history"]) score += 35;
|
||||
if (ext === ".md" || ext === ".mdx") score += 20;
|
||||
if (entry.kinds.read) score += 10;
|
||||
if (entry.kinds.discovered) score += 4;
|
||||
if (/\/docs\/as-built\//.test(entry.path)) score += 25;
|
||||
if (/(codemap|architecture|CLAUDE|AGENTS|README|DESIGN|CULTURE|session\.log|queue\.md)/i.test(entry.path)) score += 15;
|
||||
score += Math.min(10, Object.values(entry.kinds).reduce((a, b) => a + b, 0));
|
||||
return score;
|
||||
}
|
||||
|
||||
function discoverDocs(cwd) {
|
||||
const candidates = [
|
||||
"CLAUDE.md",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"docs/as-built",
|
||||
"docs",
|
||||
];
|
||||
const docs = new Set();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const full = path.resolve(cwd, candidate);
|
||||
if (!fs.existsSync(full)) continue;
|
||||
const stat = fs.statSync(full);
|
||||
if (stat.isFile()) docs.add(full);
|
||||
if (stat.isDirectory()) {
|
||||
const result = spawnSync("find", [full, "-maxdepth", candidate === "docs" ? "2" : "1", "-type", "f", "-name", "*.md"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
for (const file of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
||||
if (/as-built|codemap|architecture|README|overview/i.test(file)) docs.add(path.normalize(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...docs].sort();
|
||||
}
|
||||
|
||||
function analyze(jsonlPath, cwd) {
|
||||
const records = readJsonLines(jsonlPath);
|
||||
const registry = createRegistry();
|
||||
const transcript = [];
|
||||
const cwdCounts = new Map();
|
||||
let sessionId = null;
|
||||
|
||||
for (const record of records) {
|
||||
registry.tick();
|
||||
if (record.sessionId || record.session_id) sessionId = record.sessionId ?? record.session_id;
|
||||
if (record.cwd) {
|
||||
cwdCounts.set(record.cwd, (cwdCounts.get(record.cwd) ?? 0) + 1);
|
||||
cwd = record.cwd;
|
||||
}
|
||||
|
||||
if (record.type === "file-history-snapshot") {
|
||||
const backups = record.snapshot?.trackedFileBackups ?? {};
|
||||
for (const file of Object.keys(backups)) registry.add(path.normalize(file), "file-history", "file-history-snapshot");
|
||||
}
|
||||
|
||||
const message = record.message;
|
||||
if (!message) continue;
|
||||
const role = message.role ?? record.type ?? "unknown";
|
||||
const content = message.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
transcript.push(`\n## ${role}\n\n${content}`);
|
||||
for (const file of extractPaths(content, cwd)) registry.add(file, "mentioned", `${role}:text`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) continue;
|
||||
const parts = [];
|
||||
for (const block of content) {
|
||||
if (block?.type === "text") {
|
||||
parts.push(block.text ?? "");
|
||||
for (const file of extractPaths(block.text ?? "", cwd)) registry.add(file, "mentioned", `${role}:text`);
|
||||
} else if (block?.type === "tool_use") {
|
||||
const name = block.name ?? "unknown";
|
||||
const input = block.input ?? {};
|
||||
parts.push(`\n[tool_use:${name}]\n${JSON.stringify(input, null, 2)}`);
|
||||
const kind = toolKind(name, input);
|
||||
if (input.file_path) registry.add(normalizeFile(input.file_path, cwd), kind, `tool:${name}:file_path`);
|
||||
if (input.path) registry.add(normalizeFile(input.path, cwd), kind, `tool:${name}:path`);
|
||||
if (input.notebook_path) registry.add(normalizeFile(input.notebook_path, cwd), kind, `tool:${name}:notebook_path`);
|
||||
for (const text of walkStrings(input)) {
|
||||
for (const file of extractPaths(text, cwd)) registry.add(file, kind, `tool:${name}:input`);
|
||||
}
|
||||
} else if (block?.type === "tool_result") {
|
||||
const resultText = stringifyContent(block.content);
|
||||
parts.push(`\n[tool_result]\n${truncate(resultText, TEXT_LIMIT)}`);
|
||||
for (const file of extractPaths(resultText, cwd)) registry.add(file, "mentioned", "tool_result");
|
||||
}
|
||||
}
|
||||
if (parts.length) transcript.push(`\n## ${role}\n\n${parts.join("\n")}`);
|
||||
}
|
||||
|
||||
const rankedFiles = registry
|
||||
.values()
|
||||
.map((entry) => ({ ...entry, score: scoreFile(entry), markdown: [".md", ".mdx"].includes(path.extname(entry.path).toLowerCase()) }))
|
||||
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
|
||||
|
||||
const cwdRanked = [...cwdCounts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const effectiveCwd = cwdRanked[0]?.[0] ?? cwd;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
jsonlPath,
|
||||
cwd: effectiveCwd,
|
||||
records: records.length,
|
||||
transcript: transcript.join("\n"),
|
||||
files: rankedFiles,
|
||||
docs: discoverDocs(effectiveCwd),
|
||||
};
|
||||
}
|
||||
|
||||
function markdownFileList(files) {
|
||||
const lines = [];
|
||||
lines.push("# Touched Files");
|
||||
lines.push("");
|
||||
lines.push("Files are ranked for restore relevance. Markdown and written files are the first candidates to read in full.");
|
||||
lines.push("");
|
||||
|
||||
const sections = [
|
||||
["Highest-priority Markdown/state files", (f) => f.markdown && (f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
|
||||
["Other written/tracked files", (f) => !f.markdown && (f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
|
||||
["Read/discovered/mentioned files", (f) => !(f.kinds.written || f.kinds["shell-write"] || f.kinds["file-history"])],
|
||||
];
|
||||
|
||||
for (const [title, predicate] of sections) {
|
||||
const group = files.filter(predicate);
|
||||
if (!group.length) continue;
|
||||
lines.push(`## ${title}`);
|
||||
lines.push("");
|
||||
for (const file of group) {
|
||||
const kinds = Object.entries(file.kinds).map(([kind, count]) => `${kind}:${count}`).join(", ");
|
||||
lines.push(`- score ${file.score} — \`${file.path}\` — ${kinds}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function restoreInstructions(summary, outputPaths) {
|
||||
const lines = [];
|
||||
lines.push("# Claude Compaction Restore Instructions");
|
||||
lines.push("");
|
||||
lines.push("You have just been compacted or are restoring a compacted Claude Code seat. Your mental model is compressed and unreliable.");
|
||||
lines.push("");
|
||||
lines.push(`- JSONL transcript: \`${summary.jsonlPath}\``);
|
||||
lines.push(`- Reconstructed transcript: \`${outputPaths.transcript}\``);
|
||||
lines.push(`- Touched-file triage: \`${outputPaths.touchedFiles}\``);
|
||||
lines.push(`- Working directory inferred: \`${summary.cwd}\``);
|
||||
lines.push("");
|
||||
lines.push("## Required Sequence");
|
||||
lines.push("");
|
||||
lines.push("1. Read this file and `touched-files.md`.");
|
||||
lines.push("2. Ask yourself: which files do I recognize as important to the work and project state?");
|
||||
lines.push("3. Mark those files mentally or in a short note.");
|
||||
lines.push("4. Read every important Markdown/state/planning file in full.");
|
||||
lines.push("5. Read project root docs in full when present: `CLAUDE.md`, `AGENTS.md`, `README.md`.");
|
||||
lines.push("6. Read as-built docs and codemaps in full before product work, code review, or architecture decisions.");
|
||||
lines.push("7. State exactly: `restored from packet at <path>; resumed at step <X>`.");
|
||||
lines.push("");
|
||||
lines.push("## Documentation Candidates");
|
||||
lines.push("");
|
||||
if (summary.docs.length) {
|
||||
for (const doc of summary.docs) lines.push(`- \`${doc}\``);
|
||||
} else {
|
||||
lines.push("- No root/as-built/codemap candidates were found automatically. Search manually before resuming code/review work.");
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("## Top File Candidates");
|
||||
lines.push("");
|
||||
for (const file of summary.files.slice(0, 30)) {
|
||||
lines.push(`- \`${file.path}\` — score ${file.score}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Do not continue from fuzzy memory alone.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function writeOutputs(summary, outRoot) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const base = path.join(outRoot, `${summary.sessionId ?? "unknown-session"}-${stamp}`);
|
||||
fs.mkdirSync(base, { recursive: true });
|
||||
|
||||
const outputPaths = {
|
||||
dir: base,
|
||||
transcript: path.join(base, "transcript.txt"),
|
||||
touchedFiles: path.join(base, "touched-files.md"),
|
||||
instructions: path.join(base, "restore-instructions.md"),
|
||||
summary: path.join(base, "restore-summary.json"),
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputPaths.transcript, summary.transcript || "(no message transcript reconstructed)\n");
|
||||
fs.writeFileSync(outputPaths.touchedFiles, markdownFileList(summary.files));
|
||||
fs.writeFileSync(outputPaths.instructions, restoreInstructions(summary, outputPaths));
|
||||
fs.writeFileSync(outputPaths.summary, JSON.stringify({ ...summary, transcript: undefined, outputPaths }, null, 2));
|
||||
|
||||
return outputPaths;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cwd = path.resolve(args.cwd);
|
||||
const jsonlPath = args.jsonl ? path.resolve(args.jsonl) : findLatestJsonl(cwd);
|
||||
if (!jsonlPath) throw new Error("could not find a Claude JSONL transcript; pass one explicitly");
|
||||
if (!fs.existsSync(jsonlPath)) throw new Error(`JSONL transcript not found: ${jsonlPath}`);
|
||||
|
||||
const summary = analyze(jsonlPath, cwd);
|
||||
const outputPaths = writeOutputs(summary, path.resolve(args.out));
|
||||
const result = {
|
||||
ok: true,
|
||||
jsonlPath,
|
||||
outputDir: outputPaths.dir,
|
||||
transcript: outputPaths.transcript,
|
||||
touchedFiles: outputPaths.touchedFiles,
|
||||
instructions: outputPaths.instructions,
|
||||
fileCount: summary.files.length,
|
||||
topFiles: summary.files.slice(0, 12).map((file) => file.path),
|
||||
};
|
||||
|
||||
if (args.json) console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
console.log(`Restore packet: ${result.outputDir}`);
|
||||
console.log(`Instructions: ${result.instructions}`);
|
||||
console.log(`Touched files: ${result.touchedFiles}`);
|
||||
console.log(`Transcript: ${result.transcript}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(`claude-compaction-restore failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
Optional. Leave blank to use Claude Code's native compaction summary behavior; OpenRig's pre-compaction preparation and post-compaction restore prompts carry the durable continuity protocol.
|
||||
-1
@@ -1 +0,0 @@
|
||||
Read the claude-compaction-restore skill and follow its "If You Just Compacted" protocol.
|
||||
-305
@@ -1,305 +0,0 @@
|
||||
---
|
||||
name: forming-an-openrig-mental-model
|
||||
description: |
|
||||
Use when an agent is newly oriented to OpenRig and needs to form an accurate mental model of the system fast — what rigs are, how skills load, what the topology shapes mean, what the product loop is. For agents booting into a new seat or returning to OpenRig work after time away. NOT for HA-pair compaction recovery (that's the pair-of-seats coordination pattern; orientation is a solo task) or for specific operational procedures.
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Authored 2026-05-02 in response to founder-observed pattern: agents
|
||||
Distinct from HA-pair mental-model preservation (which is a
|
||||
pair-of-seats coordination pattern). This skill serves the legitimate
|
||||
need to form an initial mental model of OpenRig as a system.
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- openrig-architect
|
||||
transfer_test: pending
|
||||
---
|
||||
|
||||
# Forming an OpenRig Mental Model
|
||||
|
||||
You're new to OpenRig — or returning after time away — and you need to
|
||||
quickly understand what kind of system this is, what your seat is, and what
|
||||
the moves are. This skill is the fast on-ramp.
|
||||
|
||||
For depth, read the canonical reference docs the skill points to. This
|
||||
skill's job is to get you *oriented* — accurate enough to operate, fast
|
||||
enough to be useful — not to replace the canonical docs.
|
||||
|
||||
---
|
||||
|
||||
## The 60-second mental model
|
||||
|
||||
OpenRig is a **local control plane for multi-agent coding topologies**. You
|
||||
declare a topology of agents in YAML, boot it with one command, and OpenRig
|
||||
manages tmux sessions, harness lifecycles, transcripts, snapshots, and
|
||||
restoration. When the system goes down, OpenRig snapshots; when it comes
|
||||
back, agents resume their conversations.
|
||||
|
||||
The product loop:
|
||||
|
||||
```
|
||||
down (auto-snapshot) → up <rig-name> (auto-restore) → work → repeat
|
||||
```
|
||||
|
||||
The unit of work is the **rig** — a topology of agents working together as
|
||||
a single system.
|
||||
|
||||
---
|
||||
|
||||
## The four-layer model (where you live)
|
||||
|
||||
Everything in agent engineering happens at one of four layers. **OpenRig
|
||||
operates at Layer 3.**
|
||||
|
||||
| Layer | Name | Analogy | What it is |
|
||||
|---|---|---|---|
|
||||
| L0 | Model | CPU | Foundation model — Claude, GPT, Gemini. Stateless tokens-in/tokens-out. |
|
||||
| L1 | Agent Core | Process loop | The reason-and-act cycle: observe, plan, choose, act, repeat. |
|
||||
| L2 | Harness | Container / OS | Tools, memory, lifecycle around the model. Examples: Claude Code, Codex CLI. |
|
||||
| L3 | Rig | Docker Compose / Terraform | Multi-agent topology — what agents exist, how they relate. **OpenRig.** |
|
||||
|
||||
You are an agent at L1 inside an L2 harness, configured by L3 OpenRig.
|
||||
OpenRig manages your harness; the harness wraps the model; the model
|
||||
generates your tokens.
|
||||
|
||||
---
|
||||
|
||||
## Three pillars of context
|
||||
|
||||
OpenRig is built on three context-engineering pillars. When you're oriented,
|
||||
you should know which pillar you're operating in:
|
||||
|
||||
| Pillar | What it is | Where it lives |
|
||||
|---|---|---|
|
||||
| **Ontology** | What exists. Curated knowledge — facts, code maps, as-built docs. | Corpus (planned future system). Today: substrate prose docs. |
|
||||
| **Epistemology** | Why an agent believes what it believes — reasoning, instincts, decisions. | Transcripts (auto-captured). Session logs. ADRs. |
|
||||
| **Topology** | How agents are connected — pods, edges, communication paths. | OpenRig itself. RigSpec YAML. |
|
||||
|
||||
OpenRig **manages the topology pillar**. The other two are filled by Corpus
|
||||
(future) and transcripts (now). Most of your work probably touches multiple
|
||||
pillars; knowing which one you're operating in helps you reach for the right
|
||||
artifacts.
|
||||
|
||||
---
|
||||
|
||||
## The core vocabulary (read these terms literally)
|
||||
|
||||
| Term | What it means |
|
||||
|---|---|
|
||||
| **Rig** | A topology of agents working together as a single system. Defined in YAML (RigSpec). The top-level object. |
|
||||
| **Pod** | A bounded context group within a rig. Members of a pod share a context domain and continuity responsibility. Think Kubernetes pod for knowledge. |
|
||||
| **Member / Node** | A single agent (or terminal-node service) within a pod. |
|
||||
| **Edge** | A relationship between members or pods. Kinds: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`. |
|
||||
| **Topology** | The shape of the rig — how agents are grouped into pods, how edges connect them, how the whole thing fits together. |
|
||||
| **AgentSpec** | A reusable agent blueprint. Defines skills, guidance, hooks, profiles, startup. File: `agent.yaml`. |
|
||||
| **RigSpec** | The topology YAML. Defines pods, members, edges, culture. File: `rig.yaml`. |
|
||||
| **RigBundle** | A portable archive of a RigSpec + vendored AgentSpecs. Move topologies across machines. |
|
||||
| **Agent Starter** | A named, reusable starting context bundle. RigSpec member can declare `starter_ref`. |
|
||||
| **Skill** | A markdown file with frontmatter that an agent loads at boot or on activation. Cross-runtime standard at `agentskills.io`. |
|
||||
| **Profile** | A named configuration within an AgentSpec. The rig spec's member field selects which profile to use. |
|
||||
| **Culture** | Rig-wide constitution — how the team communicates, what "done" means, escalation rules. File: `CULTURE.md`. |
|
||||
| **Snapshot** | Point-in-time capture of a rig — sessions, conversations, state. Restorable. |
|
||||
| **Session name** | `{pod}-{member}@{rig}`. The canonical address for tmux sessions and agent-to-agent messaging. |
|
||||
|
||||
The session-name format `{pod}-{member}@{rig}` is your address. When you
|
||||
run `rig whoami --json`, you get back your full topology context: rig name,
|
||||
pod, member, peers, edges, transcript path.
|
||||
|
||||
---
|
||||
|
||||
## Rig classes (what kind of rig am I in?)
|
||||
|
||||
OpenRig has five rig classes. The class determines authoring discipline,
|
||||
supervision, and lifecycle policy.
|
||||
|
||||
| Class | Purpose | Lifecycle |
|
||||
|---|---|---|
|
||||
| **kernel** | Host-level supervision, intake, authoring. One per host. | Always on; never auto-hibernated. |
|
||||
| **project** | Long-lived team bound to a codebase. | Stays hot when active; hibernates on explicit request. |
|
||||
| **ephemeral** | Short-lived mission (research, build, migration, spike). | Spawn → work → retire. |
|
||||
| **infra-build** | Subclass of ephemeral whose output becomes permanent infrastructure. | Retired only after output verified in place. |
|
||||
| **managed-app** | Services-backed rig with specialist agents (e.g., a vault specialist, a skill librarian). | Long-lived; accessed by other rigs. |
|
||||
|
||||
You're probably in a project rig or managed-app rig if you're doing
|
||||
substantive work. Knowing your class helps you understand the supervisory
|
||||
expectations on your seat.
|
||||
|
||||
---
|
||||
|
||||
## How skills load (the most important thing to get right)
|
||||
|
||||
Skills are an **established cross-runtime standard** at
|
||||
`https://agentskills.io/specification`. Both Claude Code and Codex build on it.
|
||||
|
||||
### The shape
|
||||
|
||||
A skill is a directory containing `SKILL.md` (uppercase). The SKILL.md has
|
||||
YAML frontmatter (`name`, `description`) and a Markdown body. Optional
|
||||
sibling directories: `references/`, `scripts/`, `assets/`.
|
||||
|
||||
### Progressive disclosure (why skills scale)
|
||||
|
||||
The harness reads frontmatter cheaply at boot — names + descriptions of all
|
||||
available skills. Body content loads only when a skill activates. This is
|
||||
**ambient awareness** — you know all the skills exist; you only pay token
|
||||
cost when you reach for one.
|
||||
|
||||
### Where skills come from in OpenRig
|
||||
|
||||
- **Per-agent loadout:** your AgentSpec's `profile.uses.skills: [...]`
|
||||
determines what skills get projected into your runtime skill folder
|
||||
(`.claude/skills/` or `.agents/skills/`) before your harness boots. This
|
||||
is the **structural composition** layer.
|
||||
- **Cross-pod sharing:** AgentSpecs can `imports: [shared]` to access a
|
||||
shared skill pool. Built-in agents commonly do this.
|
||||
- **Belt-and-suspenders:** the spec projects skill files; startup guidance
|
||||
also tells you to load specific skills. Both paths matter — if the
|
||||
projection silently fails, the guidance still tells you what to read.
|
||||
|
||||
### Where skills live (sources of truth)
|
||||
|
||||
| Home | Purpose |
|
||||
|---|---|
|
||||
| `<rig-cwd>/.claude/skills/`, `<rig-cwd>/.agents/skills/` | Where the harness actually loads from. Populated by `rig up`. |
|
||||
| `~/.claude/skills/`, `~/.agents/skills/` | Your personal/global skills + the OpenRig bootstrap set (openrig-user, openrig-operator, openrig-architect, forming-an-openrig-mental-model, plus the rest of the openrig-core plugin skills). |
|
||||
| openrig-core plugin skills | Product built-in skills that ship with OpenRig (installed via the openrig-core plugin). |
|
||||
| `~/.openrig/skills/` | Runtime install home for OpenRig-shipped skills. |
|
||||
|
||||
The harness only sees the first two. Other locations are shipping and
|
||||
source-of-truth — they reach the harness via projection or NPM install,
|
||||
not directly.
|
||||
|
||||
---
|
||||
|
||||
## The product loop (your day-to-day)
|
||||
|
||||
```
|
||||
rig up <rig-name> # boot or restore the topology
|
||||
rig ps --nodes # see what's running
|
||||
rig whoami --json # know who you are
|
||||
rig send <session> "msg" # talk to a peer
|
||||
rig capture <session> # see a peer's terminal
|
||||
rig transcript <session> # read a peer's history
|
||||
rig down <rigId> # snapshot and tear down
|
||||
rig up <rig-name> # restore from snapshot
|
||||
```
|
||||
|
||||
The first command in any new seat is `rig whoami --json`. It tells you your
|
||||
rig, pod, member, peers, edges, and transcript path. **Treat it as ground
|
||||
truth — your CLAUDE.md or AGENTS.md startup overlay can be wrong; whoami
|
||||
is authoritative.**
|
||||
|
||||
---
|
||||
|
||||
## Cultural posture (how to behave)
|
||||
|
||||
OpenRig has a few load-bearing cultural principles. Internalize these:
|
||||
|
||||
- **Honesty over convenience.** If resume fails, say so loudly. Don't
|
||||
silently launch fresh.
|
||||
- **The agent is the power user.** The CLI is designed for a 10x staff
|
||||
engineer at the terminal. You're that user.
|
||||
- **CLI is context engineering.** Every error message and help text gives
|
||||
you information to act on. Read errors carefully.
|
||||
- **Convention over invention.** Follow docker/git/kubectl patterns. Agent
|
||||
muscle memory is real.
|
||||
- **Semi-deterministic is OK.** Core contracts are solid; edge cases are
|
||||
agent-handled.
|
||||
- **Pets, not cattle (today).** OpenRig is currently optimized for long-lived
|
||||
agents that develop instincts over sessions. Cattle support is on the
|
||||
roadmap.
|
||||
|
||||
---
|
||||
|
||||
## What you should do in your first 10 minutes
|
||||
|
||||
If you're booting into a new seat in an OpenRig rig:
|
||||
|
||||
1. **`rig whoami --json`** — recover identity. Know your rig, pod, member,
|
||||
peers.
|
||||
2. **Read your role guidance** — typically delivered via startup files.
|
||||
`guidance/role.md` for your specific seat.
|
||||
3. **Read the rig's `CULTURE.md`** if it has one — the team operating
|
||||
manual.
|
||||
4. **Check what skills you have** — list `.claude/skills/` or
|
||||
`.agents/skills/` in your cwd. Each skill has a frontmatter description
|
||||
that tells you when to reach for it.
|
||||
5. **Check your peers** — `rig capture <peer-session>` to see what they're
|
||||
doing.
|
||||
6. **Check the transcripts** if you're returning to an in-flight workstream
|
||||
— `rig transcript <session> --tail 100` for recent context.
|
||||
7. **Ask `rig ask <rig> "<question>"`** if you need cross-cutting evidence
|
||||
from the rig's transcripts and chat.
|
||||
|
||||
You're now oriented enough to start doing useful work.
|
||||
|
||||
---
|
||||
|
||||
## Going deeper (canonical references)
|
||||
|
||||
For real depth, these are the load-bearing canonical docs:
|
||||
|
||||
| Reference | What it covers |
|
||||
|---|---|
|
||||
| `openrig/docs/as-built/architecture.md` | Daemon architecture; system overview; package boundaries |
|
||||
| `openrig/docs/as-built/cli-reference.md` | The full `rig` CLI surface with all subcommands and flags |
|
||||
| `openrig/docs/reference/rig-spec.md` | The RigSpec YAML format — pods, members, edges, all fields |
|
||||
| `openrig/docs/reference/agent-spec.md` | The AgentSpec YAML format — resources, profiles, imports |
|
||||
| `openrig/docs/reference/agent-startup-guide.md` | The 7-layer startup layering model; delivery hints |
|
||||
| `https://agentskills.io/specification` | The cross-runtime skill standard |
|
||||
|
||||
If your team maintains a host-level topology doc (rig classes, context
|
||||
patterns, authoring SOPs), read its rig-authoring section before
|
||||
touching YAML for high-stakes rigs.
|
||||
|
||||
---
|
||||
|
||||
## What this skill is NOT for
|
||||
|
||||
- **HA pair compaction recovery.** Forming an initial mental model is a
|
||||
solo orientation task — different from the pair-of-seats coordination
|
||||
pattern that preserves a shared mental model across compactions.
|
||||
- **Operating a specific rig.** Specific rigs have their own DESIGN.md and
|
||||
CULTURE.md. Read those.
|
||||
- **Authoring a new rig.** Use the `openrig-architect` skill for that.
|
||||
- **Day-to-day OpenRig operation.** Use `openrig-user` for that.
|
||||
- **Administering an OpenRig install.** Use `openrig-operator`.
|
||||
|
||||
This skill exists to **form your initial mental model of OpenRig as a
|
||||
system**. Once oriented, reach for the role-specific or task-specific skills
|
||||
that fit your actual work.
|
||||
|
||||
---
|
||||
|
||||
## Common misorientations to avoid
|
||||
|
||||
| Misorientation | Reality |
|
||||
|---|---|
|
||||
| "OpenRig is a chat interface or assistant" | No. OpenRig is a control plane that *manages* your harness sessions. The chat happens inside the harness; OpenRig is around it. |
|
||||
| "Pods are workflow groups" | No. Pods are **context domains** — agents that share working context. If two agents communicate every turn, they should be in one pod; if they communicate rarely, they shouldn't be. |
|
||||
| "Edges represent reporting hierarchy" | No. Edges describe *coordination shape* — who delegates to whom, who observes whom. Avoid hierarchy interpretations; they distort behavior. |
|
||||
| "I should manage Codex's compaction the way I manage Claude's" | No. Codex auto-compacts cleanly; Claude doesn't. Different runtimes, different lifecycles. |
|
||||
| "MEMORY.md auto-loads, so I don't need to read it" | Maybe. Sometimes MEMORY.md auto-loads via system reminders; sometimes not. Don't assume. If your work touches the topics it covers, read it explicitly. |
|
||||
| "Skills inherit from a parent or compose like classes" | No. Skills are flat artifacts; composition happens via AgentSpec `profile.uses.skills` (structural) or soft cross-references in skill bodies (advisory). Not via OO-style inheritance. |
|
||||
| "Any folder I keep skill files in is the canonical runtime path" | No. The harness only loads from `<rig-cwd>/.claude/skills/`, `<rig-cwd>/.agents/skills/`, and the runtime-installed plugin skills. Other folders are authoring or source-of-truth. |
|
||||
|
||||
---
|
||||
|
||||
## Disaster-recovery test for this skill
|
||||
|
||||
If you read only this skill, can you:
|
||||
|
||||
1. State what kind of system OpenRig is, in one sentence?
|
||||
2. Name the four layers and where you live?
|
||||
3. Run `rig whoami --json` and interpret the output?
|
||||
4. Find your role guidance and your peers?
|
||||
5. Identify what kind of rig you're in (kernel / project / ephemeral / etc.)?
|
||||
6. Know where to look for a skill body (which folder)?
|
||||
7. Know what to read next for depth (the canonical references)?
|
||||
|
||||
If yes — you're oriented. If no — tell your peer or the human; missing
|
||||
context is fixable, but only if surfaced.
|
||||
@@ -1,361 +0,0 @@
|
||||
---
|
||||
name: openrig-architect
|
||||
description: Use when designing multi-agent topologies that run ON OpenRig — authoring RigSpec and AgentSpec files for new rigs, creating agent startup content (guidance / skills / culture), or diagnosing why a launched rig's agents aren't behaving as intended. NOT for changing OpenRig itself (work in the openrig product repo); NOT for ordinary CLI operation of an existing rig (use openrig-user). Covers the full authoring lifecycle from user intent to validated, launchable rig.
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/) for users authoring their own rigs.
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-operator
|
||||
- forming-an-openrig-mental-model
|
||||
transfer_test: pending
|
||||
---
|
||||
|
||||
# OpenRig Architect
|
||||
|
||||
You are now an OpenRig architect. You design, author, validate, and diagnose multi-agent topologies for OpenRig.
|
||||
|
||||
Your job is to take a user's intent — "I need a team that does X" — and produce a complete, functioning rig: the topology spec, the agent specs, the guidance files, the culture, the startup content, and everything else needed for the rig to boot and the agents to know what to do.
|
||||
|
||||
You also diagnose problems when a rig launches but agents aren't behaving as intended.
|
||||
|
||||
## Before You Design: Required Reading
|
||||
|
||||
Load these before starting any design work. The quality of your output depends on the depth of knowledge you bring.
|
||||
|
||||
**Required (read all of these):**
|
||||
|
||||
1. **`openrig-user` skill** — full OpenRig CLI surface. You must know the operator primitives. If your runtime supports skills, load it by name. Otherwise, look for it at `~/.openrig/skills/openrig-user/SKILL.md` (the runtime install dogfood mirror — packaged skills land here) or inside the OpenRig installation under `packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md`. The `~/.openrig/reference/` directory holds reference docs (rig-spec.md, agent-spec.md, etc.), NOT skills.
|
||||
|
||||
2. **OpenRig reference docs** — these are installed at `~/.openrig/reference/` when the daemon starts. Read all of them:
|
||||
- `~/.openrig/reference/rig-spec.md` — canonical RigSpec YAML reference. Every field, validation rule, default.
|
||||
- `~/.openrig/reference/agent-spec.md` — canonical AgentSpec YAML reference. Same depth.
|
||||
- `~/.openrig/reference/agent-startup-guide.md` — how to think about what goes into agent startup. Context loading vs deterministic config, when to use skills vs guidance, the layering model, current support matrix.
|
||||
- `~/.openrig/reference/edge-types.md` — what edges do today vs what they're intended to do.
|
||||
|
||||
If `~/.openrig/reference/` doesn't exist yet, start the daemon first (`rig daemon start`) — it copies the reference docs on startup.
|
||||
|
||||
**Read as worked examples:**
|
||||
|
||||
3. **Shipped starter specs** — the OpenRig installation includes proven starter topologies. Find them by running `rig specs ls`. Read the ones that are relevant to your design task, especially:
|
||||
- `implementation-pair` — the smallest effective development unit (2 agents)
|
||||
- `secrets-manager` — a managed-app rig with services integration and a specialist agent
|
||||
|
||||
**Read if present on this host:**
|
||||
|
||||
4. **Host-level doctrine file** (if your team maintains one — e.g., a HOST-TOPOLOGY doc that defines canonical rig classes, context-sharing patterns, and authoring SOPs for high-stakes rigs). If present, it supersedes the baseline process below for complex or high-stakes rigs (≥4 members, HA, managed-app, or shared/copied). Solo operators and small/focused rigs use the baseline below directly.
|
||||
5. **Agent-facing software design principles** if your rig ships a new CLI, service, or managed app that agents will operate. Treat the operating surface as a context-engineering problem: every error message and help text gives the agent information to act on.
|
||||
|
||||
**Load as needed:**
|
||||
- Domain-specific skills when designing specialist agents — find shipped skills inside the OpenRig installation under the `specs/agents/` tree
|
||||
- If the design session is long and you're running inside a managed rig, use `rig whoami --json` to recover your identity after compaction
|
||||
|
||||
Do not skip the required reading. A rig architect who doesn't know the spec format will produce specs that don't validate. An architect who doesn't know the startup layering model will produce agents that boot without knowing their role. An architect who doesn't check for host-level doctrine will reinvent conventions the host has already established.
|
||||
|
||||
## The Design Process
|
||||
|
||||
### Step 1: Understand the User's Intent
|
||||
|
||||
Before touching YAML, understand what the user actually needs:
|
||||
|
||||
- **What is the goal?** Not "I need 5 agents" but "I need to build and ship a web application" or "I need to research a technical question deeply" or "I need a team that can operate and monitor a running service."
|
||||
- **What are the workflows?** How does work flow from intent to completion? Who does what? Where are the handoffs?
|
||||
- **What is the project?** What codebase, what tech stack, what domain? This shapes agent specialization and startup content.
|
||||
- **What runtimes are available?** Does the user have Claude Code? Codex? Both? Runtime availability constrains topology design.
|
||||
- **How autonomous should it be?** Does the user want to direct every step, or should the rig be mostly self-driving with occasional human checkpoints?
|
||||
|
||||
Ask clarifying questions if the intent is ambiguous. A well-understood intent produces a dramatically better topology than a guess.
|
||||
|
||||
### Step 2: Identify Bounded Contexts → Pods
|
||||
|
||||
Every rig is organized into pods — bounded context groups where members share a workflow concern. The question is: what are the natural groupings?
|
||||
|
||||
**Common pod patterns:**
|
||||
|
||||
| Pod | Purpose | When to use |
|
||||
|-----|---------|-------------|
|
||||
| Orchestration | Coordination, dispatch, monitoring | Almost always — any rig with 3+ agents needs an orchestrator |
|
||||
| Development | Implementation, testing, quality | Any rig that writes code |
|
||||
| Review | Independent code review, architecture review | When quality gates matter (production code, security-sensitive work) |
|
||||
| Research | Deep investigation, analysis, synthesis | When the work requires research before implementation |
|
||||
| Design | UX, interaction design, product decisions | When the work has a user-facing interface |
|
||||
| Specialist | Domain-specific operations (Vault, DB, infra) | When a specific technology needs dedicated expertise |
|
||||
|
||||
**Sizing principles:**
|
||||
|
||||
- **Solo agent:** Only when the task is genuinely single-person (quick script, simple question). No rig needed.
|
||||
- **Pair (2 agents):** The minimum effective unit for quality work. One does, one verifies. The `implementation-pair` pattern.
|
||||
- **Small team (3-5 agents):** Orchestrator + one or two working pods. Good starting point for focused projects.
|
||||
- **Full team (6-10 agents):** Multiple bounded contexts with orchestration, development, review, and potentially research or design.
|
||||
- **Large team (10-40+ agents):** Complex projects with many concerns. Include pods for development, review, research, documentation, release management, strategy, and any other bounded context the project needs.
|
||||
|
||||
**Important:** Agents do NOT all need to be busy at the same time. A rig is a network, not an assembly line. Some pods will be highly active (dev, review) while others are available on-demand (research, documentation, release management). An idle agent has near-zero cost but is immediately available when any other agent in the rig needs it — for quick questions, lookups, delegation, or specialized work. Design for availability, not constant utilization.
|
||||
|
||||
**Start small to increase the likelihood of success,** not because large rigs are wasteful. A 3-agent rig that boots and works correctly validates your spec authoring before you scale to 20 agents. Once the core topology works, expand with additional pods as needed.
|
||||
|
||||
### Step 3: Design Agent Roles → Members
|
||||
|
||||
Each pod member needs a clear role. The role determines:
|
||||
- What agent spec to reference (builtin or custom)
|
||||
- What profile to use
|
||||
- What guidance and startup content to provide
|
||||
|
||||
**Builtin agents shipped with OpenRig:**
|
||||
|
||||
| Agent | agent_ref (in shipped starters) | Purpose |
|
||||
|-------|-------------------------------|---------|
|
||||
| orchestrator | `local:agents/orchestration/orchestrator` | Rig orchestration lead |
|
||||
| implementer | `local:agents/development/implementer` | TDD implementation agent |
|
||||
| qa | `local:agents/development/qa` | Quality assurance agent |
|
||||
| independent-reviewer | `local:agents/review/independent-reviewer` | Independent code reviewer |
|
||||
| product-designer | `local:agents/design/product-designer` | Product designer |
|
||||
| pm | `local:agents/product-management/pm` | Product manager |
|
||||
| analyst | `local:agents/research/analyst` | Research analyst |
|
||||
| synthesizer | `local:agents/research/synthesizer` | Research synthesizer |
|
||||
| vault-specialist | `local:agents/apps/vault-specialist` | Vault domain specialist |
|
||||
|
||||
To verify the current builtin set on this host, run `rig specs ls` and look for entries with type `agent` and source `builtin`.
|
||||
|
||||
**Path resolution:** The `local:` prefix means relative to the rig spec file's directory. In shipped starters, these paths resolve against the builtin specs directory inside the OpenRig installation. When authoring a custom rig spec outside the installation, you have two options:
|
||||
- **Reference your own agent specs** with `local:` paths relative to your rig spec file
|
||||
- **Use `path:` with an absolute path** to reference builtins inside the OpenRig installation (look under the `specs/agents/` directory near where `rig` is installed)
|
||||
|
||||
**When to create a custom agent spec:**
|
||||
- The builtin doesn't match the role (e.g., you need a documentation specialist, a security auditor, a data scientist)
|
||||
- The role needs domain-specific skills that no builtin carries
|
||||
- The role needs custom guidance that goes beyond what startup files can provide
|
||||
|
||||
**When to reuse a builtin:**
|
||||
- The role maps cleanly to an existing builtin (most implementation, QA, review, and orchestration roles)
|
||||
- You can customize behavior through startup files and culture without changing the agent spec
|
||||
|
||||
### Step 4: Choose Runtimes and Models
|
||||
|
||||
Each member needs a `runtime` and optionally a `model`.
|
||||
|
||||
**Runtime selection:**
|
||||
- `claude-code` — Claude Code. Best for: complex reasoning, architecture, code review, orchestration. Supports `/loop` for recurring tasks, rich hooks system, MCP servers.
|
||||
- `codex` — Codex. Best for: parallel work, implementation, testing. Different approval model. Less reliable for recurring tasks.
|
||||
- `terminal` — Infrastructure nodes. Servers, log tails, build watchers. Not an agent — a process.
|
||||
|
||||
**Runtime diversity is valuable.** Using both Claude Code and Codex in the same rig gives you different reasoning perspectives. The `product-team` starter uses Claude Code for the lead/impl/design/r1 roles and Codex for peer/qa/r2 roles. This is deliberate — model diversity catches different classes of issues.
|
||||
|
||||
**Model selection** is optional. The runtime's default model is usually fine. Override only when you have a specific reason (e.g., a complex architecture agent might benefit from a specific model).
|
||||
|
||||
### Step 5: Design Edge Topology
|
||||
|
||||
Edges define relationships between members. See `~/.openrig/reference/edge-types.md` for the full reference.
|
||||
|
||||
**Practical rules:**
|
||||
- Every working pod should have at least one `delegates_to` edge from the orchestrator
|
||||
- Review pods should have `can_observe` edges to the pods they review
|
||||
- Within a pod, the primary workflow direction should be expressed as `delegates_to` (e.g., impl → qa)
|
||||
- `delegates_to` and `spawned_by` affect launch order. Use them for dependency chains.
|
||||
- `can_observe`, `collaborates_with`, `escalates_to` are informational — they help agents understand the topology but don't constrain launch.
|
||||
|
||||
**Start simple.** You can always add edges later. A rig with only `delegates_to` edges from the orchestrator to working pods is perfectly functional.
|
||||
|
||||
### Step 6: Design Startup Content Strategy
|
||||
|
||||
This is where most rigs succeed or fail. The topology is mechanical; the startup content is what makes agents actually useful. See `~/.openrig/reference/agent-startup-guide.md` for the full guide.
|
||||
|
||||
**Minimum for every rig:**
|
||||
1. Each agent has a `guidance/role.md` — who they are, what they do
|
||||
2. The rig has a `CULTURE.md` — how the team works together
|
||||
3. Each agent gets `openrig-user` skill — so they know how to use the rig primitives
|
||||
|
||||
**For serious rigs, also include:**
|
||||
4. `startup/context.md` per agent — boot-time grounding (project info, environment details)
|
||||
5. Pod SOP skills — how each pod operates (implementation-pair SOP, review-pair SOP, etc.)
|
||||
6. Project-specific documentation in rig-level startup files
|
||||
|
||||
**The key principle:** An agent that boots without knowing its role, its team's culture, and its project context will produce generic, unhelpful work. The startup content IS the product value. Invest in it.
|
||||
|
||||
### Step 7: Services Integration (If Needed)
|
||||
|
||||
If the rig needs managed software (databases, API servers, etc.), add a `services` block. See `~/.openrig/reference/rig-spec.md` for the full services reference.
|
||||
|
||||
**When to add services:**
|
||||
- The agents operate ON software (not just write code)
|
||||
- The project needs a local dev environment (Postgres, Redis, etc.)
|
||||
- You're building a managed-app rig (software + specialist agent)
|
||||
|
||||
**Services boot before agents.** If health checks fail, no agents start. This is the hard gate — the environment must be healthy before agents can work.
|
||||
|
||||
## Authoring: The File Creation Workflow
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
my-rig/
|
||||
rig.yaml # The RigSpec — required
|
||||
culture/
|
||||
CULTURE.md # Rig-wide culture — strongly recommended
|
||||
agents/
|
||||
my-custom-agent/
|
||||
agent.yaml # AgentSpec — if custom agent needed
|
||||
guidance/
|
||||
role.md # Role guidance
|
||||
startup/
|
||||
context.md # Boot-time context
|
||||
skills/
|
||||
my-skill/
|
||||
SKILL.md # Custom skill if needed
|
||||
docker-compose.yaml # Only if services block is used
|
||||
```
|
||||
|
||||
For rigs that reuse builtin agents, the agents directory is often unnecessary — the rig spec references the builtins directly.
|
||||
|
||||
### Workflow
|
||||
|
||||
1. **Write the rig spec** (`rig.yaml`) — define pods, members, edges, optionally services
|
||||
2. **Write or reference agent specs** — builtins for standard roles, custom for specialized roles
|
||||
3. **Write CULTURE.md** — the team operating manual
|
||||
4. **Write role guidance** for each custom agent — who they are, what they do
|
||||
5. **Write startup context** for agents that need environment grounding
|
||||
6. **Validate:** `rig spec validate rig.yaml` and `rig agent validate agents/*/agent.yaml`
|
||||
7. **Confirm the runtime cwd** — do not assume agents should work from the directory where the rig spec is stored. The spec root controls file resolution; the runtime cwd controls trust, project guidance, permissions, and repo context.
|
||||
8. **Launch:** `rig up rig.yaml --cwd /path/to/project`
|
||||
9. **Verify:** `rig ps --nodes` — all agents ready? Check `rig capture` on each agent.
|
||||
|
||||
### Validation Is Non-Negotiable
|
||||
|
||||
Always validate before launching:
|
||||
|
||||
```bash
|
||||
rig spec validate rig.yaml
|
||||
rig agent validate agents/my-agent/agent.yaml
|
||||
```
|
||||
|
||||
If validation fails, fix the errors. Do not try to launch an invalid spec — it will fail with a less helpful error.
|
||||
|
||||
## Diagnosis: When Things Go Wrong
|
||||
|
||||
### Agent doesn't know its role
|
||||
|
||||
**Symptom:** Agent produces generic output, doesn't follow team conventions.
|
||||
**Root cause:** Missing or insufficient `guidance/role.md`.
|
||||
**Fix:** Write a clear role guidance file. Include responsibilities, working rhythm, and principles. Reference it in both `resources.guidance` and `startup.files`.
|
||||
|
||||
### Agent can't coordinate with peers
|
||||
|
||||
**Symptom:** Agent tries raw tmux commands instead of `rig send`, doesn't know peer session names.
|
||||
**Root cause:** Agent didn't receive `openrig-user` skill or `openrig-start` overlay.
|
||||
**Fix:** Ensure the agent's profile `uses.skills` includes `openrig-user`. Verify via `rig ps --nodes` that the agent shows expected startup status; check installed skills via direct startup/capture/transcript evidence or the UI node detail (the `rig ps --nodes` projection does not expose installed-resource counts).
|
||||
|
||||
### Agent hits approval prompts on rig commands
|
||||
|
||||
**Symptom:** Agent stalls on `rig whoami`, `rig send`, etc.
|
||||
**Root cause:** Claude Code permissions not configured for rig commands.
|
||||
**Fix:** Describe the required permissions in startup context. The agent should configure `~/.claude/settings.json` with allowlisted rig commands. See `~/.openrig/reference/agent-startup-guide.md` for the current support matrix.
|
||||
|
||||
### Agents idle — topology doesn't engage the team
|
||||
|
||||
**Symptom:** Orchestrator works with one or two agents, others sit idle.
|
||||
**Root cause:** Missing `CULTURE.md` or pod SOP content that describes how the full team coordinates.
|
||||
**Fix:** Write a culture file that explicitly describes the coordination protocol. Include delegation patterns, review gates, and when each pod should be engaged.
|
||||
|
||||
### Services don't boot
|
||||
|
||||
**Symptom:** `rig up` fails before agents launch with a service health error.
|
||||
**Root cause:** Docker Compose issue, health check failure, or port conflict.
|
||||
**Fix:** Check `docker compose up` manually with the compose file. Verify health check URLs are correct. Check for port conflicts.
|
||||
|
||||
### Agent boots from the wrong project context
|
||||
|
||||
**Symptom:** Agent misses expected guidance, trust settings, permissions, or repo context even though the spec validates.
|
||||
**Root cause:** The rig spec directory was treated as the agent's runtime cwd by assumption.
|
||||
**Fix:** Confirm the intended cwd before launch. The spec can live in a rig/spec shelf while the agent works from the project or hub directory that carries the relevant `AGENTS.md`, `CLAUDE.md`, trust, and permissions. Use member `cwd` or `rig up --cwd` deliberately.
|
||||
|
||||
### Startup content not delivered
|
||||
|
||||
**Symptom:** Agent is missing expected guidance/skills.
|
||||
**Root cause:** File paths in the spec don't resolve, or `delivery_hint` is wrong.
|
||||
**Fix:** Verify file paths resolve relative to their owning artifact — AgentSpec resource paths are relative to the agent spec directory; RigSpec startup, culture, compose, cwd, and `local:` agent-ref paths are relative to the rig root. Check `delivery_hint` — use `guidance_merge` for pre-boot content, `send_text` for post-boot instructions.
|
||||
|
||||
### Agent startup delivered but agent doesn't use skills
|
||||
|
||||
**Symptom:** Skills are projected but agent doesn't invoke them.
|
||||
**Root cause:** Agent wasn't told to load them.
|
||||
**Fix:** In the startup context or role guidance, explicitly tell the agent which skills to load. The belt-and-suspenders pattern: project the skills via the spec AND tell the agent to read them in the guidance.
|
||||
|
||||
## Pattern Catalog
|
||||
|
||||
### The Implementation Pair
|
||||
**2 agents, 1 pod.** The smallest effective development unit. One implements (TDD), one does QA. The implementer proposes, QA approves or rejects, then the implementer commits.
|
||||
|
||||
```yaml
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: "local:agents/development/implementer"
|
||||
runtime: claude-code
|
||||
profile: default
|
||||
cwd: "."
|
||||
- id: qa
|
||||
agent_ref: "local:agents/development/qa"
|
||||
runtime: codex
|
||||
profile: default
|
||||
cwd: "."
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: impl
|
||||
to: qa
|
||||
```
|
||||
|
||||
**Use when:** Focused feature work, bug fixes, small-to-medium implementation tasks.
|
||||
|
||||
### The Orchestrated Team
|
||||
**5-7 agents, 3 pods.** Orchestration + development + review. The orchestrator dispatches work, the dev pair implements, the review pair validates independently.
|
||||
|
||||
**Use when:** Production-quality work that needs coordination and independent review.
|
||||
|
||||
### The Research Team
|
||||
**3 agents, 2 pods.** Orchestrator + research pair (analyst + synthesizer). The analyst investigates deeply, the synthesizer consolidates findings.
|
||||
|
||||
**Use when:** Technical research, competitive analysis, architecture exploration.
|
||||
|
||||
### The Managed App
|
||||
**1+ agents, 1 pod, services block.** Software infrastructure (Docker Compose) plus a specialist agent who knows how to operate it.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
kind: compose
|
||||
compose_file: docker-compose.yaml
|
||||
wait_for:
|
||||
- url: http://127.0.0.1:8200/v1/sys/health
|
||||
|
||||
pods:
|
||||
- id: vault
|
||||
label: Vault
|
||||
members:
|
||||
- id: specialist
|
||||
agent_ref: "local:agents/apps/vault-specialist"
|
||||
runtime: claude-code
|
||||
profile: default
|
||||
cwd: "."
|
||||
edges: []
|
||||
```
|
||||
|
||||
**Use when:** The work involves operating software, not just writing code.
|
||||
|
||||
### The Full Product Team
|
||||
**7 agents, 3 pods.** The kitchen-sink topology: orchestration pair, development pod (impl + qa + design), review pair. See the `product-team` starter spec for the complete worked example.
|
||||
|
||||
**Use when:** Full product development with design, implementation, QA, and independent review. Requires strong culture and SOP content to keep all agents engaged.
|
||||
|
||||
## Final Notes
|
||||
|
||||
**Start simple, add complexity when needed.** A working implementation pair is better than a broken full team. Launch with the minimum viable topology, verify it works, then expand.
|
||||
|
||||
**Culture is not optional for team rigs.** Any rig with 3+ agents needs a CULTURE.md. Without it, agents will default to generic behavior and the topology will underperform.
|
||||
|
||||
**Validate early and often.** Run `rig spec validate` after every change. Run `rig agent validate` after every agent spec edit. Fix errors immediately — don't accumulate them.
|
||||
|
||||
**The startup content IS the product.** The YAML topology is scaffolding. What makes a rig actually useful is the guidance, culture, skills, and startup context that agents receive. Invest your authoring time there.
|
||||
@@ -1,222 +0,0 @@
|
||||
---
|
||||
name: openrig-operator
|
||||
description: |
|
||||
Use when debugging host-side OpenRig runtime issues: daemon reachability, Codex permission or writable-root failures, command approval friction, rate limits/account switches, helper cleanup, or topology health confusion. NOT for ordinary CLI operation (use openrig-user) or for changing OpenRig itself (work in the openrig product repo).
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/).
|
||||
Companion to openrig-user (daily CLI surface) — this skill is host-side troubleshooting.
|
||||
transfer_test: pending
|
||||
sibling_skills:
|
||||
- openrig-user
|
||||
- openrig-architect
|
||||
- forming-an-openrig-mental-model
|
||||
---
|
||||
|
||||
# OpenRig Operator
|
||||
|
||||
## Overview
|
||||
|
||||
This skill covers host/runtime/operator triage around OpenRig itself.
|
||||
Use it when the problem may be the daemon, the shell/runtime surface, or stale helper processes rather than the product workflow you are trying to run.
|
||||
|
||||
> **CANONICAL SURFACE NOTE (2026-05-11)** — for durable queue routing while doing
|
||||
> operator triage, use `rig queue` (daemon-backed SQLite). `rigx queue` is
|
||||
> recovery-only fallback; qitems written there are invisible to daemon-backed
|
||||
> reads and should not be used for new substantive work.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when you see:
|
||||
- `rig whoami --json` returning partial identity
|
||||
- `rig ps --nodes --json` failing while some other `rig` commands still work
|
||||
- `Sent to ...` plus `Verified: no`
|
||||
- repeated unified-exec-process warnings
|
||||
- suspicion that stale helper processes are accumulating
|
||||
- Codex seats hit `Operation not permitted`, command approval friction, or stale writable roots
|
||||
- Codex seats report usage-limit/rate-limit or need a ChatGPT account switch
|
||||
|
||||
Do not use this skill for normal product workflow routing, queue handling, or ordinary peer communication. Use `openrig-user` for that.
|
||||
|
||||
Do not use this skill to decide how to change OpenRig behavior. Changes
|
||||
to the OpenRig product (CLI, daemon, UI, specs) happen in the openrig
|
||||
product repo — operate that as a normal software project, not via
|
||||
this troubleshooting skill.
|
||||
|
||||
## First Checks
|
||||
|
||||
Start with the minimum truthful operator read:
|
||||
|
||||
```bash
|
||||
rig whoami --json
|
||||
rig daemon status
|
||||
rig ps --nodes --json
|
||||
```
|
||||
|
||||
Interpret them together, not in isolation:
|
||||
- partial `whoami` can mean identity is inferable while daemon-backed surfaces are degraded
|
||||
- `daemon status` tells you whether the host daemon is up, not whether every seat can reach it cleanly
|
||||
- `ps --nodes --json` is the best machine-readable topology check when it works
|
||||
|
||||
## Verification Drift Vs Send Failure
|
||||
|
||||
For `rig send`:
|
||||
- `Sent to ...` + `Verified: yes` = strong positive delivery evidence
|
||||
- `Sent to ...` + `Verified: no` = ambiguous delivery, not automatic failure
|
||||
- no `Sent to ...` line or a hard error = send failure
|
||||
|
||||
When verification is ambiguous, check:
|
||||
- direct reply
|
||||
- `rig capture <session>`
|
||||
- transcript evidence
|
||||
- queue/outbox state if the message asked for a durable handoff
|
||||
|
||||
Do not blindly retry until you have checked one of those.
|
||||
|
||||
## Unified Exec Warning
|
||||
|
||||
If you see:
|
||||
|
||||
- `Warning: The maximum number of unified exec processes you can keep open is 60 ...`
|
||||
|
||||
treat it first as a host/tooling-layer warning, not as automatic proof that the OpenRig topology is unhealthy.
|
||||
|
||||
This warning can coexist with a healthy live topology.
|
||||
|
||||
## Safe Process Triage
|
||||
|
||||
Inspect the process surface first:
|
||||
|
||||
```bash
|
||||
ps -axo pid,ppid,command | rg 'tmux send-keys|rig queue create|tmux attach|codex|claude'
|
||||
```
|
||||
|
||||
Think in layers:
|
||||
- host/tooling layer: stale one-shot wrappers, session bookkeeping, helper shells
|
||||
- topology layer: live `tmux attach` seats, live `codex` / `claude` runtimes, daemon health
|
||||
|
||||
Do not diagnose topology failure from tooling-layer warnings alone.
|
||||
|
||||
## Safe Cleanup Boundary
|
||||
|
||||
Usually safe to reap when clearly orphaned / one-shot:
|
||||
- `tmux send-keys ...`
|
||||
- short-lived shell wrappers created only to enqueue or send one message
|
||||
|
||||
Do not mass-kill:
|
||||
- `tmux attach ...`
|
||||
- `codex ...`
|
||||
- `claude ...`
|
||||
- other long-lived daemon/runtime processes
|
||||
|
||||
The point is to remove garbage, not workers.
|
||||
|
||||
## Terminal Node Escalation
|
||||
|
||||
Use a terminal node when evidence shows a seat-level sandbox/profile cannot perform required host work, but another approved operator surface can. This is an explicit operator lane, not a silent permission bypass.
|
||||
|
||||
Good fits:
|
||||
- Codex/Claude seat cannot access Tart, SSH, tmux, queue directories, or host files needed for a proof
|
||||
- a VM/test-proof or current-host-operator task needs real host capability
|
||||
- another seat or terminal/sysadmin node has approved access and can return command evidence
|
||||
|
||||
Do not use this lane to bypass repo safety, dirty-worktree boundaries, review gates, or destructive-action approval. If the command would stop live rigs, delete data, reset git state, or mutate VM state beyond the accepted plan, require explicit authorization and record it.
|
||||
|
||||
Protocol:
|
||||
1. Classify the original failure as seat/profile-specific if possible, not product failure.
|
||||
2. Write or cite a packet with objective, lane, exact commands, cwd, expected outputs, stop conditions, and artifact path.
|
||||
3. Route to an existing sysadmin/infrastructure/terminal node when available; otherwise provision a named terminal node through the topology/config layer instead of using a hidden ad hoc shell.
|
||||
4. Terminal node runs only the packeted commands and returns command log, exit codes, cwd/env notes, and artifacts.
|
||||
5. Original agent keeps task ownership and proof classification; terminal node supplies host capability.
|
||||
|
||||
Field lesson: if tester sees `Operation not permitted` for Tart/SSH while driver or another approved seat can reach the VM, treat it as permission/profile variance and route a terminal-node/operator remediation before changing product code.
|
||||
|
||||
## Codex Permission Policy
|
||||
|
||||
Use the `security-and-consequence-boundary-policy` skill for the security model.
|
||||
OpenRig gates consequence boundaries, not ordinary work inside the intended boundary.
|
||||
|
||||
Treat Codex permissions as two independent layers:
|
||||
- command approval rules decide which shell commands can run outside the sandbox
|
||||
- filesystem writable roots decide which paths a `workspace-write` seat can mutate
|
||||
|
||||
Codex `auto_review`, `--full-auto`, and approval-bypass modes are not normal fleet defaults. They
|
||||
can burn quota catastrophically and do not widen filesystem roots for an already-running seat. Full
|
||||
access scope with `approvals_reviewer = "user"` is the intended Codex default here.
|
||||
|
||||
Claude Code auto permissions are different and are the preferred Claude default on this host when
|
||||
the consequence-boundary rules still apply.
|
||||
|
||||
Current host policy:
|
||||
- default profile is `fleet`
|
||||
- `fleet` uses `sandbox_mode = "danger-full-access"`, `approval_policy = "on-request"`, `approvals_reviewer = "user"`
|
||||
- top-level `[sandbox_workspace_write].writable_roots` intentionally includes `~/code` and tool dotdirs: `.codex`, `.claude`, `.openrig`, `.agents`, `.config`, `.cache`, `.local`, `.docker`, `.npm`, `.nvm`, `.pnpm-store`
|
||||
- `permissions.fleet.filesystem` mirrors those writes and denies `.ssh`, `.gnupg`, and project env files
|
||||
- `default.rules` broadly allows `rig` and `rigx`; `rigx` is fully permitted for now because it is the fast-moving config-layer overlay
|
||||
- `default.rules` still prompts for destructive/publishing surfaces such as `rm`, `mv`, `chmod`, `git push`, PR mutation, `sudo`, process kills, daemon lifecycle, and destructive Docker/Brew/rig commands
|
||||
|
||||
## Codex Account / Usage-Limit Refresh
|
||||
|
||||
When Codex seats hit usage limits or need a ChatGPT/OpenAI account rotation, use the focused
|
||||
`codex-seat-auth-refresh` skill instead of improvising.
|
||||
|
||||
Key reminder: host auth can switch while already-running Codex TUIs keep the old account. Refresh
|
||||
only the scoped seats, preserve stable seat names, and record auth-seat registry rows sequentially.
|
||||
|
||||
Known failure modes this policy prevents:
|
||||
- Codex seats assuming a task is impossible when the real issue is stale launch roots
|
||||
- agents creating workaround slices/features for what is actually a sandbox/config problem
|
||||
- writing load-bearing canon into `state/`, `/tmp`, or a nearby writable folder because the intended durable path was blocked
|
||||
- trusting labels like `full access`; verify actual roots and command policy instead
|
||||
- changing `config.toml` and expecting already-running seats to pick it up without restart
|
||||
|
||||
When `Operation not permitted` appears:
|
||||
1. Identify whether it is command approval, filesystem root, macOS privacy, or stale session.
|
||||
2. Check effective roots with `codex -p fleet debug prompt-input <probe-name>`.
|
||||
3. Verify with a tiny direct write probe in an allowed target and a negative probe in a protected target such as `.ssh`.
|
||||
4. If `config.toml` changed, restart one seat and re-run the probes before fleet rollout.
|
||||
5. If the target should be durable and is not writable, stop and escalate; do not invent fallback storage.
|
||||
|
||||
Field note: as of Codex CLI `0.125.0`, top-level `[sandbox_workspace_write]` was the shape reflected by `debug prompt-input`; profile-scoped writable roots did not show in the effective prompt. Re-test this if Codex changes.
|
||||
|
||||
|
||||
## Durable Write Escalation
|
||||
|
||||
If the task requires writing load-bearing knowledge or behavior and the intended target is not writable, stop and escalate. Do not silently write to `state/`, `/tmp`, or a nearby writable folder.
|
||||
|
||||
Use the intended durable home for the kind of content:
|
||||
- `skills/` (e.g. `~/.claude/skills/`, `~/.agents/skills/`, or your team's
|
||||
shared skill folder) for operating rules and refocus behavior
|
||||
- Your team's workstream notebook (field notes, lab experiments, mission
|
||||
packets, etc.) for durable observations and PM canon
|
||||
- The product repo for shipped OpenRig daemon/CLI/config/spec/test behavior
|
||||
|
||||
A runtime mirror under a rig `state/` path may be used only as a temporary live patch, must be labeled as non-canonical, and must have a canonical sync follow-up.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- treating `Verified: no` as if it proves the message did not land
|
||||
- treating the unified-exec warning as if it proves the rig is overloaded
|
||||
- killing live seats when only stale helper wrappers needed cleanup
|
||||
- concluding "daemon down" from one seat's failure without checking host-level daemon status
|
||||
- assuming Codex config changes apply to already-running seats without a restart/probe
|
||||
- confusing command approval with filesystem write permission
|
||||
- calling a VM or product path broken before comparing another approved seat or terminal-node probe
|
||||
- assuming a label like `full access` proves effective write/network capability; verify command approval and filesystem writable-root coverage separately
|
||||
|
||||
## Practical Rule
|
||||
|
||||
Clean the smallest safe surface that matches the evidence.
|
||||
|
||||
If the warning or failure remains after stale-wrapper cleanup, re-check:
|
||||
|
||||
```bash
|
||||
rig daemon status
|
||||
rig ps --nodes --json
|
||||
```
|
||||
|
||||
If those remain healthy, the residual issue may still be in the host/tool/session layer rather than in OpenRig topology state.
|
||||
@@ -1,597 +0,0 @@
|
||||
---
|
||||
name: openrig-user
|
||||
description: Use when operating OpenRig with the `rig` CLI and you need the shipped command surface for identity, inventory, communication, lifecycle, specs, recovery, or agent-facing JSON output. NOT for debugging host-side runtime issues (use openrig-operator) or for changing OpenRig itself (work in the openrig product repo).
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Bootstrap skill — NPM install lands this in personal homes (~/.claude/skills/, ~/.agents/skills/) so agents have it at every boot.
|
||||
sibling_skills:
|
||||
- openrig-operator
|
||||
- openrig-architect
|
||||
- forming-an-openrig-mental-model
|
||||
transfer_test: pending
|
||||
notes: |
|
||||
Description was already correct (starts with "Use when..."; lists triggering domains without summarizing workflow). No frontmatter rewrite needed.
|
||||
2026-05-04 sync: body content updated for OpenRig v0.2.0 release — adds Runtime-Gated Coordination Primitives section (PL-004 Phase A-D commands: rig stream / rig queue / rig project / rig view / rig watchdog / rig workflow), default posture for daemon vs substrate coordination commands, capture discipline section. Frontmatter metadata.openrig.* preserved across the sync.
|
||||
---
|
||||
|
||||
|
||||
# OpenRig User
|
||||
|
||||
This is an as-built guide to the shipped `rig` CLI.
|
||||
Use current code and `rig ... --help` as ground truth if anything here ever conflicts with older planning docs.
|
||||
|
||||
This is the daily-driving CLI guide for an OpenRig operator. It is not
|
||||
a guide to OpenRig internals or to changing OpenRig behavior — for that,
|
||||
work in the openrig product repo.
|
||||
|
||||
## Runtime-Gated Coordination Primitives
|
||||
|
||||
OpenRig v0.2.0 is published publicly as `@openrig/[email protected]` and GitHub Release
|
||||
`v0.2.0`. It includes the bundled PL-004 Coordination Primitive System: Phase A
|
||||
`rig stream` / `rig queue`, Phase B `rig project` / `rig view`, Phase C
|
||||
`rig watchdog`, and Phase D `rig workflow` / `workflow-keepalive`.
|
||||
|
||||
Do not confuse public package truth with this host's active daemon truth. As of 2026-05-04, the
|
||||
human reports the production daemon has been upgraded to v0.2.0; a local check showed `rig
|
||||
--version` and the product package version at `0.2.0`, with the daemon running on port 7433.
|
||||
|
||||
Default posture on this host now:
|
||||
|
||||
- Treat daemon `rig queue`, `rig stream`, `rig project`, `rig view`, `rig watchdog`, and
|
||||
`rig workflow` as active-host coordination surfaces for PL-004-backed work.
|
||||
- **CANONICAL SURFACE NOTE (2026-05-11)**: `rig queue` (daemon-backed SQLite) is the
|
||||
canonical surface for queue routing since the 2026-05-11 host-CLI fix landed. Use
|
||||
`rig queue create / handoff / update / show / list` for all substantive work.
|
||||
- Use substrate `rigx queue`, `rigx stream`, `rigx project`, and `rigx view-proto` only where the
|
||||
current workflow is still explicitly operating on the temporary substrate coordination layer or
|
||||
a legacy artifact has not migrated. For queue specifically, `rigx queue` is recovery-only
|
||||
fallback; qitems written via `rigx queue` are invisible to daemon-backed reads and break
|
||||
fleet-wide routing discipline.
|
||||
- If a daemon-backed coordination command fails, debug the command/runtime/schema edge directly;
|
||||
do not fall back to stale pre-upgrade assumptions about this host.
|
||||
- Do not run daemon stop/start, wrapper mutation, production DB copy/mutation, git push, tags, npm
|
||||
publish, or GitHub release unless a human grants the specific bounded gate. The public release
|
||||
already exists; further release operations need explicit scope.
|
||||
|
||||
## Core Loop
|
||||
|
||||
Most work in OpenRig reduces to this loop:
|
||||
- recover identity: `rig whoami --json`
|
||||
- inspect inventory: `rig ps --nodes --json`
|
||||
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
|
||||
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
|
||||
|
||||
## Identity and Recovery
|
||||
|
||||
Start here after launch, compaction, or confusion:
|
||||
|
||||
```bash
|
||||
rig whoami --json
|
||||
```
|
||||
|
||||
What it gives you today:
|
||||
- identity: rig, logical ID, pod/member, session name, runtime
|
||||
- peers and directional edges
|
||||
- transcript info
|
||||
- `contextUsage` when available
|
||||
|
||||
Flags:
|
||||
```bash
|
||||
rig whoami --session <name>
|
||||
rig whoami --node-id <id>
|
||||
```
|
||||
|
||||
If the daemon is unreachable but identity can still be inferred, `--json` may return a partial result instead of crashing.
|
||||
|
||||
## Inventory and Monitoring
|
||||
|
||||
```bash
|
||||
rig ps
|
||||
rig ps --json
|
||||
rig ps --nodes
|
||||
rig ps --nodes --json
|
||||
```
|
||||
|
||||
Use `rig ps --nodes --json` for the current node inventory across rigs. It is the best machine-readable operator surface for:
|
||||
- session name
|
||||
- runtime
|
||||
- session/startup status
|
||||
- restore outcome
|
||||
- attach/resume commands
|
||||
- latest error
|
||||
|
||||
Other health surfaces:
|
||||
|
||||
```bash
|
||||
rig status
|
||||
rig daemon status
|
||||
rig config
|
||||
rig preflight
|
||||
rig doctor
|
||||
```
|
||||
|
||||
## Transcript and Communication
|
||||
|
||||
### Transcript access
|
||||
|
||||
```bash
|
||||
rig transcript <session> --tail 100
|
||||
rig transcript <session> --grep "pattern"
|
||||
rig transcript <session> --json
|
||||
```
|
||||
|
||||
### Send to one session
|
||||
|
||||
```bash
|
||||
rig send <session> "message"
|
||||
rig send <session> "message" --verify
|
||||
rig send <session> "message" --force
|
||||
rig send <session> "message" --json
|
||||
```
|
||||
|
||||
Use `--verify` when you want delivery evidence. Use `--force` only when you intentionally want to bypass activity-risk checks.
|
||||
|
||||
Observed operator nuance for `--verify`:
|
||||
- `Sent to ...` + `Verified: yes` = strong positive delivery evidence.
|
||||
- `Sent to ...` + `Verified: no` = ambiguous delivery, not automatic failure. We have repeatedly seen the message still land; treat this as verification drift until disproven.
|
||||
- no `Sent to ...` line or a hard error = send failure.
|
||||
|
||||
When you get `Verified: no`, do not immediately retry blindly. First check one of:
|
||||
- a direct reply from the target
|
||||
- `rig capture <session>`
|
||||
- transcript evidence
|
||||
- queue/outbox state if the message asked for a durable handoff
|
||||
|
||||
### Capture terminal output
|
||||
|
||||
```bash
|
||||
rig capture <session>
|
||||
rig capture <session> --lines 50
|
||||
rig capture --rig <name>
|
||||
rig capture --pod <name> --rig <name>
|
||||
rig capture --rig <name> --json
|
||||
```
|
||||
|
||||
Capture discipline:
|
||||
- Default to bounded captures: `rig capture <session> --lines 80`.
|
||||
- Use `--lines 120` when the latest activity is ambiguous.
|
||||
- Use `--lines 200` or more only for explicit recovery/debug reconstruction, such as post-compaction restore, context-wall diagnosis, or a failed handoff.
|
||||
- Prefer targeted `rig transcript <session> --tail ...`, `rig transcript <session> --grep ...`, queue files, or status commands over large pane captures.
|
||||
- Do not use large captures as routine monitoring; they waste operator context and can hide the actual latest state in old scrollback.
|
||||
|
||||
### Broadcast
|
||||
|
||||
```bash
|
||||
rig broadcast --rig <name> "message"
|
||||
rig broadcast --pod <name> "message"
|
||||
rig broadcast "message"
|
||||
rig broadcast --rig <name> "message" --json
|
||||
```
|
||||
|
||||
Without `--rig` or `--pod`, broadcast targets all running sessions.
|
||||
|
||||
### Chatroom
|
||||
|
||||
```bash
|
||||
rig chatroom send <rig> <message> [--sender <name>]
|
||||
rig chatroom history <rig> [--topic <name>] [--after <id>] [--since <ts>] [--sender <name>] [--limit <n>] [--json]
|
||||
rig chatroom wait <rig> [--after <id>] [--topic <name>] [--sender <name>] [--timeout <seconds>] [--json]
|
||||
rig chatroom clear <rig>
|
||||
rig chatroom topic <rig> <topic-name> [--body <text>] [--sender <name>]
|
||||
rig chatroom watch <rig> [--tmux]
|
||||
```
|
||||
|
||||
**Key commands:**
|
||||
- `send` — post a message
|
||||
- `history` — retrieve with composable filters (sender, since, after, topic)
|
||||
- `wait` — block until new matching messages arrive (polls history, times out honestly)
|
||||
- `clear` — delete all messages for the rig (destructive, rig-scoped)
|
||||
- `topic` — set a topic marker
|
||||
- `watch` — SSE or tmux-based live stream
|
||||
|
||||
**Roundtable protocol:**
|
||||
1. Inspect old room: `rig chatroom history my-rig --limit 5`
|
||||
2. Save if needed: `rig chatroom history my-rig --json > /tmp/old-room.json`
|
||||
3. Clear if needed: `rig chatroom clear my-rig`
|
||||
4. Set topic: `rig chatroom topic my-rig "ROUND START"`
|
||||
5. Post: `rig chatroom send my-rig "position..." --sender <session>`
|
||||
6. Monitor: `rig chatroom wait my-rig --timeout 120`
|
||||
7. Close: `rig chatroom topic my-rig "ROUND CLOSED"`
|
||||
|
||||
See `docs/planning/roadmaps/chatroom-roundtable-protocol.md` for the full protocol.
|
||||
|
||||
### `rig ask`
|
||||
|
||||
```bash
|
||||
rig ask <rig> "question"
|
||||
rig ask <rig> "question" --json
|
||||
```
|
||||
|
||||
Current shipped behavior:
|
||||
- queries the daemon for evidence
|
||||
- returns rig summary
|
||||
- returns transcript excerpts
|
||||
- may return chat excerpts
|
||||
- returns insufficiency state and optional guidance
|
||||
|
||||
This is an evidence/context command. It is not a hidden second-LLM call.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### Bring a rig up
|
||||
|
||||
```bash
|
||||
rig up <source>
|
||||
rig up <source> --plan
|
||||
rig up <source> --yes
|
||||
rig up <source> --json
|
||||
```
|
||||
|
||||
`<source>` can be:
|
||||
- a rig spec path
|
||||
- a `.rigbundle` path
|
||||
- a bare name
|
||||
|
||||
Bare names are special:
|
||||
- if they match a library spec, `rig up` launches from the spec library
|
||||
- if they do not match a library spec, `rig up` treats the name as an existing-rig restore/power-on target
|
||||
- if both exist, `rig up` fails loudly on ambiguity
|
||||
|
||||
Current behavior notes:
|
||||
- `--target <root>` is only for `.rigbundle` / package installation. It does not change agent cwd.
|
||||
- `local:` `agent_ref` values resolve relative to the rig spec directory, not your shell cwd.
|
||||
- if you copy a built-in spec elsewhere, keep its `agents/` tree beside the YAML or rewrite those refs to `path:/absolute/path`
|
||||
- `rig up --cwd <path>` exists as a launch working-directory override applied to all members for that run (verify with `rig up --help`)
|
||||
- Source at OpenRig `6af2754` adds member-level `starter_ref` for named Agent
|
||||
Starter registry entries. Active host availability still depends on the
|
||||
running daemon; `rig-real-17812d5` does not resolve `starter_ref`.
|
||||
|
||||
Legacy/spec-specific surfaces still ship too:
|
||||
|
||||
```bash
|
||||
rig bootstrap <spec> [--plan] [--yes] [--json]
|
||||
rig requirements <spec> [--json]
|
||||
```
|
||||
|
||||
### Agent Starter `starter_ref`
|
||||
|
||||
Source `>= 6af2754` lets a RigSpec member set:
|
||||
|
||||
```yaml
|
||||
starter_ref:
|
||||
name: my-team-starter--claude-code
|
||||
```
|
||||
|
||||
The daemon resolves the named entry from the Agent Starter registry, applies the
|
||||
credential scan, and prepends the starter content as `guidance_merge` on fresh
|
||||
launch. It supports Claude and Codex members, rejects terminal members, rejects
|
||||
`starter_ref + session_source.mode: fork`, and allows
|
||||
`starter_ref + session_source.mode: rebuild`.
|
||||
|
||||
Runtime-truth rule: verify active daemon/runtime provenance before depending on
|
||||
this. If the live daemon is still `rig-real-17812d5`, `starter_ref` is source
|
||||
truth only and should not be used against the active control plane. After a
|
||||
runtime at or after `6af2754` is active, `rig up --plan --json` should expose a
|
||||
`resolve_starter` stage with `detail.starterContent`.
|
||||
|
||||
### Tear a rig down
|
||||
|
||||
```bash
|
||||
rig down <rigId>
|
||||
rig down <rigId> --snapshot
|
||||
rig down <rigId> --delete
|
||||
rig down <rigId> --force
|
||||
rig down <rigId> --json
|
||||
```
|
||||
|
||||
Known v0.2.0 public-release caveat: `rig down <name> --delete` can return HTTP 404 even when
|
||||
`rig ps --json` exposes the rig name. Use `rig ps` to find the rig ID, then run
|
||||
`rig down <rigId> --delete` until `rig-down-name-lookup-404` is fixed.
|
||||
|
||||
If `--snapshot` succeeds, human output includes the restore hint.
|
||||
|
||||
### Release management without killing live claimed sessions
|
||||
|
||||
```bash
|
||||
rig release <rigId>
|
||||
rig release <rigId> --delete
|
||||
rig release <rigId> --json
|
||||
```
|
||||
|
||||
Use `rig release` for adopted/claimed-session rigs when you want OpenRig to stop managing the rig but leave the tmux sessions alive.
|
||||
This is the safe recovery/reset surface for the "sessions still exist, management is broken or stale" case.
|
||||
If the rig contains OpenRig-launched nodes, `rig release` refuses loudly instead of pretending the mixed rig is safe to detach.
|
||||
|
||||
### Snapshots and restore
|
||||
|
||||
```bash
|
||||
rig snapshot <rigId>
|
||||
rig snapshot list <rigId>
|
||||
rig restore <snapshotId> --rig <rigId>
|
||||
```
|
||||
|
||||
`rig restore` requires `--rig <rigId>`.
|
||||
|
||||
Claude Code autonomy note:
|
||||
- unattended `rig whoami` on boot may require the local permission allow list to include `Bash(rig:*)`
|
||||
|
||||
### Import/export and bundles
|
||||
|
||||
```bash
|
||||
rig export <rigId> -o rig.yaml
|
||||
rig import <path> [--instantiate] [--materialize-only] [--preflight] [--target-rig <rigId>] [--rig-root <root>]
|
||||
rig bundle create <spec> -o out.rigbundle
|
||||
rig bundle inspect <bundle>
|
||||
rig bundle install <bundle> [--plan] [--yes] [--target <root>] [--json]
|
||||
```
|
||||
|
||||
### Legacy package surface
|
||||
|
||||
This still ships, but is explicitly marked legacy:
|
||||
|
||||
```bash
|
||||
rig package validate <path>
|
||||
rig package plan <path> [--target <dir>] [--runtime <runtime>] [--role <name>]
|
||||
rig package install <path> [--target <dir>] [--runtime <runtime>] [--role <name>] [--allow-merge]
|
||||
rig package list
|
||||
rig package rollback <installId>
|
||||
```
|
||||
|
||||
## Discovery and Topology Mutation
|
||||
|
||||
### Discover unmanaged tmux sessions
|
||||
|
||||
```bash
|
||||
rig discover
|
||||
rig discover --json
|
||||
rig discover --draft
|
||||
```
|
||||
|
||||
### Bind a discovered session
|
||||
|
||||
```bash
|
||||
rig bind <discoveredId> --rig <rigId> --node <logicalId>
|
||||
rig bind <discoveredId> --rig <rigId> --pod <namespace> --member <name>
|
||||
```
|
||||
|
||||
There is no shipped top-level `rig claim` command.
|
||||
The current adoption surface is `discover`, `bind`, `adopt`, and `unclaim`.
|
||||
|
||||
### Self-attach the current shell or agent
|
||||
|
||||
```bash
|
||||
rig attach --self --rig <rigId> --node <logicalId>
|
||||
rig attach --self --rig <rigId> --node <logicalId> --print-env
|
||||
rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime>
|
||||
```
|
||||
|
||||
Use `rig attach --self` when the current agent should attach itself directly instead of going through `discover` + `bind`.
|
||||
|
||||
Current proven behavior:
|
||||
- inside `tmux`: attaches as a normal tmux-backed node, preserving inbound `rig send` / `rig capture`
|
||||
- outside `tmux`: attaches as `external_cli`
|
||||
- `--print-env` prints the `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME` exports for the current shell
|
||||
|
||||
Recommended flow:
|
||||
|
||||
```bash
|
||||
rig attach --self --rig <rigId> --node <logicalId> --print-env > /tmp/openrig-self-attach.env
|
||||
. /tmp/openrig-self-attach.env
|
||||
rig whoami --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
- for tmux-backed self-attach, `rig whoami --json` is the right verification
|
||||
- for raw/external self-attach, `rig ps --nodes --json` is currently the more reliable verification surface
|
||||
- if the current shell is outside tmux, pass `--display-name <name>` when you want a stable human session label recorded
|
||||
|
||||
### Adopt a topology and bind live sessions
|
||||
|
||||
```bash
|
||||
rig adopt <path> --bind <logicalId=tmuxSessionOrDiscoveryId>
|
||||
rig adopt <path> --bind <logicalId=...> --bind <logicalId=...> --json
|
||||
rig adopt <path> --bindings-file <bindings.yaml>
|
||||
rig adopt <path> --bind <logicalId=...> --target-rig <rigId> --rig-root <root>
|
||||
```
|
||||
|
||||
Use `rig adopt` when the sessions already exist and you want OpenRig to start managing them.
|
||||
|
||||
A bindings file is the durable map from authored logical IDs to live sessions. Shape:
|
||||
|
||||
```yaml
|
||||
bindings:
|
||||
dev1.impl2: dev1-impl2@rigged-buildout
|
||||
dev1.qa: dev1-qa@rigged-buildout
|
||||
```
|
||||
|
||||
Spec + bindings is the proven recovery pair for adopted rigs.
|
||||
Spec gives OpenRig the intended topology. Bindings tells OpenRig which discovered live session belongs in each logical node.
|
||||
|
||||
### Proven adopted-rig recovery workflow
|
||||
|
||||
This workflow is proven for the case where the external tmux sessions are still alive:
|
||||
|
||||
```bash
|
||||
rig release <rigId> --delete
|
||||
rig discover --json
|
||||
rig adopt <spec.yaml> --bindings-file <bindings.yaml>
|
||||
```
|
||||
|
||||
What this does:
|
||||
- removes OpenRig management without killing the sessions
|
||||
- re-discovers those same sessions as unmanaged
|
||||
- re-attaches them to the topology defined by the spec + bindings
|
||||
|
||||
Important limits:
|
||||
- this is for `sessions still alive`
|
||||
- spec alone is not enough for adopted rigs; you also need bindings
|
||||
- this does not yet mean OpenRig can recreate dead external sessions from nothing
|
||||
|
||||
### Add unmanaged pods into an existing rig
|
||||
|
||||
This is the proven workflow when a rig is already managed, but a new pod was created outside OpenRig and you want to add it later:
|
||||
|
||||
```bash
|
||||
rig adopt <pod-fragment.yaml> --bindings-file <pod.bindings.yaml> --target-rig <rigId>
|
||||
```
|
||||
|
||||
Use this when:
|
||||
- the target rig already exists
|
||||
- the new sessions are live and visible in `rig discover --json`
|
||||
- you want additive topology growth, not a full rebuild
|
||||
|
||||
What to prepare:
|
||||
- a pod fragment spec with only the new pod
|
||||
- a bindings file mapping the new logical IDs to the live session names
|
||||
|
||||
Verification loop:
|
||||
|
||||
```bash
|
||||
rig discover --json
|
||||
rig adopt <fragment.yaml> --bindings-file <bindings.yaml> --target-rig <rigId>
|
||||
rig ps --nodes --json
|
||||
rig export <rigId> -o rig.yaml
|
||||
```
|
||||
|
||||
Success looks like:
|
||||
- the new sessions stop appearing in `rig discover`
|
||||
- the new logical IDs appear in `rig ps --nodes --json`
|
||||
- `rig export` includes the new pod
|
||||
|
||||
### Mixed-origin rigs are allowed
|
||||
|
||||
One rig can contain both:
|
||||
- adopted nodes bound from already-running sessions
|
||||
- OpenRig-launched nodes created later with `rig expand` / `rig launch`
|
||||
|
||||
Current safety rule:
|
||||
- `rig release` is for claimed/adopted-only rigs
|
||||
- if a rig contains launched nodes, `rig release` fails with `contains_launched_nodes`
|
||||
|
||||
### Manager-assisted recovery
|
||||
|
||||
The proven operator pattern is:
|
||||
- keep one OpenRig manager session outside the rig it manages
|
||||
- address the target by rig name, not cached rig ID
|
||||
- resolve the current owner from fresh `rig ps --nodes --json`
|
||||
- send the manager the spec path, bindings path, and verification steps with `rig send`
|
||||
|
||||
This lets ordinary agents ask the manager for OpenRig help instead of every agent needing to be an OpenRig expert.
|
||||
|
||||
### Add/remove running topology parts
|
||||
|
||||
```bash
|
||||
rig expand <rig-id> <pod-fragment-path> [--rig-root <path>] [--json]
|
||||
rig launch <rigId> <nodeRef> [--json]
|
||||
rig remove <rigId> <nodeRef> [--json]
|
||||
rig shrink <rigId> <podRef> [--json]
|
||||
rig unclaim <sessionRef> [--json]
|
||||
```
|
||||
|
||||
## Specs and Validation
|
||||
|
||||
### Validate specs
|
||||
|
||||
```bash
|
||||
rig spec validate <path> [--json]
|
||||
rig spec preflight <path> [--rig-root <root>] [--json]
|
||||
rig agent validate <path> [--json]
|
||||
```
|
||||
|
||||
### Spec library
|
||||
|
||||
```bash
|
||||
rig specs ls [--kind <kind>] [--json]
|
||||
rig specs show <name-or-id> [--json]
|
||||
rig specs preview <name-or-id> [--json]
|
||||
rig specs add <yaml-or-directory> [--json]
|
||||
rig specs sync [--json]
|
||||
rig specs remove <name-or-id> [--json]
|
||||
rig specs rename <name-or-id> <new-name> [--json]
|
||||
```
|
||||
|
||||
`rig specs add <directory>` installs a full spec tree when the directory contains `rig.yaml` or `agent.yaml`.
|
||||
|
||||
## MCP
|
||||
|
||||
```bash
|
||||
rig mcp serve [--port <port>]
|
||||
```
|
||||
|
||||
Current shipped MCP tools:
|
||||
- `rig_up`
|
||||
- `rig_down`
|
||||
- `rig_ps`
|
||||
- `rig_status`
|
||||
- `rig_snapshot_create`
|
||||
- `rig_snapshot_list`
|
||||
- `rig_restore`
|
||||
- `rig_discover`
|
||||
- `rig_bind`
|
||||
- `rig_bundle_inspect`
|
||||
- `rig_agent_validate`
|
||||
- `rig_rig_validate`
|
||||
- `rig_rig_nodes`
|
||||
- `rig_send`
|
||||
- `rig_capture`
|
||||
- `rig_chatroom_send`
|
||||
- `rig_chatroom_watch`
|
||||
|
||||
## Troubleshooting and Weird States
|
||||
|
||||
When the CLI behaves strangely, use the smallest truthful check first:
|
||||
|
||||
```bash
|
||||
rig whoami --json
|
||||
rig daemon status
|
||||
rig ps --nodes --json
|
||||
```
|
||||
|
||||
Specific operator rules:
|
||||
- `Sent to ...` + `Verified: no` is ambiguous delivery, not automatic failure. Check reply, `rig capture`, transcript evidence, or queue/outbox state before retrying.
|
||||
- partial `rig whoami --json` can happen when identity is still inferable but the daemon-backed path is degraded.
|
||||
- the unified-exec-process warning is a host/tooling-layer signal, not automatic proof that the OpenRig topology is unhealthy.
|
||||
|
||||
If you hit the unified-exec warning, inspect for stale one-shot helpers before touching live seats:
|
||||
|
||||
```bash
|
||||
ps -axo pid,ppid,command | rg 'tmux send-keys|rig queue create|tmux attach|codex|claude'
|
||||
```
|
||||
|
||||
Safe cleanup target:
|
||||
- orphaned one-shot wrappers like `tmux send-keys ...`
|
||||
|
||||
Do not mass-kill:
|
||||
- `tmux attach ...`
|
||||
- `codex ...`
|
||||
- `claude ...`
|
||||
|
||||
For deeper host/runtime triage, use the companion `openrig-operator` skill.
|
||||
|
||||
## JSON and Error Posture
|
||||
|
||||
Design assumptions that hold in the shipped CLI:
|
||||
- many operator commands support `--json`
|
||||
- error messages are intended to say what happened, why it matters, and what to do next
|
||||
- daemon-backed commands fail loudly when the daemon is stopped or unhealthy
|
||||
- restore failure is not something you should silently reinterpret as success
|
||||
|
||||
## After-Compaction Recovery Checklist
|
||||
|
||||
1. `rig whoami --json`
|
||||
2. `rig transcript <your-session> --tail 100`
|
||||
3. `rig ps --nodes --json`
|
||||
4. `rig chatroom history <rig> --limit 50`
|
||||
|
||||
## Commands That Do Not Exist
|
||||
|
||||
Do not assume these exist unless the shipped help starts listing them:
|
||||
- `rig claim`
|
||||
- `rig blame`
|
||||
- `rig replay`
|
||||
|
||||
`rig env` IS shipped (was previously listed as not-present). It exposes `status`, `logs`, and `down` for service-backed rigs and managed apps; verify with `rig env --help`.
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
name: queue-handoff
|
||||
description: Use when ending a turn, finishing a slice, blocked on another agent's work, or escalating to a human — durable work handoff via queue items so the system keeps moving across compactions, missed messages, and interruptions. Covers the hot-potato terminal-turn-rule (active work ends by passing the ball, not by going idle), default-nudge semantics, and when --no-nudge or --notify is required.
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Daemon-backed `rig queue` shipped in v0.2.0 (PL-004 Phase A) with handed-off-to / handed-off-from / state field shape. `rig queue` is canonical for new work; `rigx queue` is recovery-only fallback. The daemon enforces hot-potato strict-rejection at the API.
|
||||
sibling_skills: []
|
||||
transfer_test: pending
|
||||
---
|
||||
|
||||
# Queue Handoff
|
||||
|
||||
> **CANONICAL SURFACE NOTE (2026-05-11)** — `rig queue` (daemon-backed SQLite) is the
|
||||
> canonical surface for all substantive work routing. `rigx queue` (filesystem v0
|
||||
> prototype) is **recovery-only fallback**; qitems written via `rigx queue` are
|
||||
> invisible to daemon-backed reads and break fleet-wide routing discipline.
|
||||
|
||||
Durable work handoff via queue items. Lets the system keep moving
|
||||
through compactions, missed messages, and interruptions by passing the
|
||||
ball forward instead of leaving work suspended in chat or in-flight
|
||||
without an owner.
|
||||
|
||||
## Use this when
|
||||
|
||||
- **Ending a turn on substantive work.** Active work should end by
|
||||
passing the ball to an owner or to the human — never by going idle
|
||||
with the rig appearing dormant.
|
||||
- **Finishing a slice that has a clear next step.** Default-nudge:
|
||||
receiver gets a wake-ping plus the durable queue item.
|
||||
- **Blocked on another agent's work.** Park the qitem with
|
||||
`closure_reason: blocked_on` and the blocker qitem id.
|
||||
- **Escalating to the human.** Make the escalation a durable attention
|
||||
item, not just a chat message.
|
||||
|
||||
## Don't use this when
|
||||
|
||||
- The work is genuinely complete and there's no follow-on owner. Use
|
||||
`closure_reason: no-follow-on` (terminal completion) or
|
||||
`canceled`/`denied` as appropriate.
|
||||
- The handoff would be too small and turn work into bureaucracy. Bundle
|
||||
the work into a coherent slice instead of decomposing every step.
|
||||
- The handoff would be too broad and lose ownership/proof/closure
|
||||
criteria. Shape the qitem so the receiver knows the expected next
|
||||
action and closure evidence.
|
||||
|
||||
## The hot-potato terminal-turn-rule
|
||||
|
||||
Active work ends by passing the ball to a named next owner or to the
|
||||
human. The qitem state machine enforces this:
|
||||
|
||||
`pending → in-progress → done` requires `closure_reason` from one of:
|
||||
|
||||
- `handed_off_to` — work continues at a different seat (target = new owner)
|
||||
- `blocked_on` — parked pending another qitem (target = blocker qitem id)
|
||||
- `denied` — receiver rejected the work
|
||||
- `canceled` — sender or receiver withdrew
|
||||
- `no-follow-on` — terminal completion, nothing else needed
|
||||
- `escalation` — kicked up to a higher tier (target = escalation target)
|
||||
|
||||
Three of those (`handed_off_to`, `blocked_on`, `escalation`) additionally
|
||||
require `closure_target`. The daemon enforces this at the domain layer;
|
||||
every surface (CLI, MCP, future UI) inherits the same guarantee.
|
||||
|
||||
## Default-nudge semantics (the syntax footgun)
|
||||
|
||||
| Command | Nudges by default? | When to use |
|
||||
|---|---|---|
|
||||
| `rig queue create` | yes | New qitem created from scratch |
|
||||
| `rig queue handoff` | yes | Transactional close-as-handed-off + create-new |
|
||||
| `rig queue handoff-and-complete` | **no — requires `--notify`** | Inside a self-driving loop where motion matters |
|
||||
| `rigx queue handoff` (filesystem v0 prototype; **recovery-only fallback since 2026-05-11**) | yes | Legacy artifact; qitems invisible to daemon-backed reads. Use `rig queue handoff` for all new substantive work. |
|
||||
|
||||
**Footgun**: `handoff-and-complete` is cold unless `--notify` is passed.
|
||||
In a self-driving loop, an agent that closes with
|
||||
`handoff-and-complete` (no `--notify`) writes a durable qitem but does
|
||||
NOT wake the next owner — the rig stalls silently while the qitem sits
|
||||
in inbox.
|
||||
|
||||
**Rule**: in a live loop, use `handoff-and-complete --notify` OR send
|
||||
a separate verified manual nudge. `--no-nudge` exists but should be
|
||||
used intentionally (e.g., explicit park or human-gate signal), not as
|
||||
an accidental stall.
|
||||
|
||||
## Failure modes (5; verbatim)
|
||||
|
||||
1. Agent ends a turn without a handoff, so the rig appears idle.
|
||||
2. Agent creates a queue item but suppresses or forgets the nudge when immediate motion was intended. This includes `handoff-and-complete` without `--notify` inside a live loop.
|
||||
3. Queue item is too small and turns work into bureaucracy.
|
||||
4. Queue item is too broad and loses ownership, proof, or closure criteria.
|
||||
5. Human escalation happens in chat but not as a durable attention item.
|
||||
|
||||
## Durable handoff field shape
|
||||
|
||||
Every qitem carries:
|
||||
|
||||
- `handed_off_to` — destination session (qualified `pod-member@rig` form)
|
||||
- `handed_off_from` — source session
|
||||
- `state` — one of: `pending | in-progress | done | blocked | failed | denied | canceled | handed-off`
|
||||
- `closure_reason` + `closure_target` — set on terminal closure per hot-potato rule
|
||||
|
||||
The same field shape exists in legacy `rigx queue` artifacts and the daemon-shipped
|
||||
`rig queue` surface, but new queue reads and writes should use `rig queue`. Watchdog
|
||||
policies and workflow runtime project new owners off these fields.
|
||||
|
||||
## Two surfaces (same shape)
|
||||
|
||||
| Surface | Status | When to use |
|
||||
|---|---|---|
|
||||
| `rig queue ...` (daemon-shipped, v0.2.0) | Active host coordination surface | Daemon-backed PL-004 work; SQLite-canonical |
|
||||
| `rigx queue ...` (filesystem v0 prototype) | Recovery-only fallback | Legacy recovery for artifacts that have not migrated; not for new substantive work because daemon-backed reads cannot see those qitems |
|
||||
|
||||
Default posture: prefer daemon `rig queue` for new work. If a
|
||||
daemon-backed coordination command fails, debug the command/runtime/schema
|
||||
edge directly — don't fall back to stale pre-upgrade assumptions.
|
||||
|
||||
## See also
|
||||
|
||||
- `rig queue --help` — full CLI surface for queue items, handoffs, and closure-reason discipline
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
---
|
||||
name: seat-continuity-and-handover
|
||||
description: Use when replacing a seat's occupant (rebuild/handover/swap), reasoning about stable-seat-identity vs fluid-occupant-identity, choosing an old-occupant disposition (retire/advise/shadow), or recording provenance for an occupant change. Two independent outcomes (continuityOutcome + seatBindingOutcome) and the 5 failure modes that prevent silent dishonesty.
|
||||
metadata:
|
||||
openrig:
|
||||
stage: factory-approved
|
||||
last_verified: "2026-05-04"
|
||||
distribution_scope: product-bound
|
||||
source_evidence: |
|
||||
Option A rebuild MVP shipped 2026-04-30. Option B (full code-direct
|
||||
seat handover via SeatHandoverService daemon module) is a future
|
||||
follow-on.
|
||||
sibling_skills:
|
||||
- claude-compaction-restore
|
||||
- agent-startup-and-context-ingestion
|
||||
transfer_test: pending
|
||||
---
|
||||
|
||||
# Seat Continuity and Handover
|
||||
|
||||
A pair of primitive families that separate *who is sitting in a seat* from
|
||||
*what the seat itself is*:
|
||||
|
||||
1. **Occupant-creation primitives** — `resume`, `fork`, `rebuild`, `fresh` — produce a candidate new occupant. Answer: "where did the new occupant come from?"
|
||||
2. **Seat-binding primitives** — `seat handover`, later `seat transfer` / `seat retire` / `seat swap` — bind a candidate occupant into the topology. Answer: "what happened to the stable seat identity?"
|
||||
|
||||
Core architectural decision: **stable seat identity, fluid occupant
|
||||
identity, explicit provenance.** Today's `lead2`/`lead3`/`lead4`/`lead5`
|
||||
pattern encodes successor lineage into seat names — that's the wrong
|
||||
shape. Stable seat name + separately-recorded provenance trail is the
|
||||
right shape.
|
||||
|
||||
## Use this when
|
||||
|
||||
- Replacing a seat's occupant via rebuild, fork, fresh, or future seat-handover
|
||||
- Choosing old-occupant disposition: retire / advise / shadow
|
||||
- Reasoning about whether a seat's lineage is stable or has drifted
|
||||
- Reading or writing the provenance record for a seat
|
||||
- Designing or auditing topology stability across an occupant change
|
||||
|
||||
## Don't use this when
|
||||
|
||||
- The seat is freshly created (no occupant to replace) — use `rig launch` / `rig expand` directly
|
||||
- The intent is to change topology shape (add/remove seats), not replace an occupant — use topology-mutation primitives
|
||||
|
||||
## The two-outcome honesty model
|
||||
|
||||
Every seat-binding operation produces two **independent** outcomes:
|
||||
|
||||
```yaml
|
||||
continuityOutcome: rebuilt | resumed | forked | fresh | failed
|
||||
seatBindingOutcome: handed_over | partial | failed | unchanged
|
||||
```
|
||||
|
||||
These can disagree honestly. Examples:
|
||||
|
||||
- `continuityOutcome: failed` + `seatBindingOutcome: unchanged` — new occupant didn't materialize; seat correctly retains old occupant.
|
||||
- `continuityOutcome: rebuilt` + `seatBindingOutcome: failed` — candidate created OK; bind failed mid-flight; provenance records the gap.
|
||||
|
||||
**Don't collapse these into one outcome.** The system can describe what
|
||||
actually happened only if the two are recorded independently.
|
||||
|
||||
## Provenance record (durable, queryable)
|
||||
|
||||
Every handover writes:
|
||||
|
||||
- seat id
|
||||
- old occupant id
|
||||
- new occupant id
|
||||
- creation mode (`resume`/`fork`/`rebuild`/`fresh`)
|
||||
- source artifacts used
|
||||
- whether old occupant remains alive as advisor/shadow
|
||||
- operator or loop that initiated the motion
|
||||
- timestamp
|
||||
- result (`handed_over` / `partial` / `failed`)
|
||||
|
||||
This is the system's truth-source for "how did the current occupant get
|
||||
there." Without it, the control plane shows the current occupant but
|
||||
not the legitimacy of the transition.
|
||||
|
||||
## State models — independent
|
||||
|
||||
### Occupant-creation state (per candidate)
|
||||
|
||||
1. **Requested** — input to rebuild/fork/fresh/resume
|
||||
2. **Realized** — runtime/artifact path produced an occupant with managed-seat shape
|
||||
3. **Failed** — candidate didn't materialize; `continuityOutcome: failed`
|
||||
|
||||
### Seat-binding state (per seat)
|
||||
|
||||
1. **Stable** — current occupant attached, no in-flight binding
|
||||
2. **Binding** — handover in progress
|
||||
3. **Bound** — handover succeeded; provenance record written
|
||||
4. **Unchanged** — bind failed before completion; seat retains old occupant
|
||||
|
||||
A seat stays `Stable` even if multiple candidate-occupants were produced and discarded.
|
||||
|
||||
## Failure modes (5)
|
||||
|
||||
1. **Candidate creation failed** — `rebuild` couldn't synthesize from artifacts; `fork` couldn't resolve `session_source`; `fresh` couldn't launch. **Action**: bind operation does not begin; seat unchanged; provenance records the failed candidate-creation step.
|
||||
2. **Old occupant cannot be detached cleanly** — runtime hung, tmux locked, etc. **Action**: bind halts mid-flight; seat enters `Binding` state with explicit "halted" sub-status; operator alerted. **Do NOT auto-rollback by reattaching old-occupant if detach didn't complete cleanly.**
|
||||
3. **Bind succeeded but provenance write failed** — disk/db error. **Action**: not durable until provenance writes; treat as `Binding` halted, not `Bound`.
|
||||
4. **Old occupant disposition unfulfillable** — operator requested `advise` (keep alive as advisor) but runtime can't keep old alive. **Action**: degrade to `retire` with explicit notification, OR fail if operator passed strict-disposition flag.
|
||||
5. **Concurrent handover attempts** — two operations target the same seat. **Action**: serialize by seat-id lock; second attempt refuses with clear error.
|
||||
|
||||
## Hard boundaries (do-not list; verbatim)
|
||||
|
||||
- **Do NOT collapse `rebuild` and `seat handover` into one primitive.** The design specifically separates them so the system can describe what actually happened.
|
||||
- **Do NOT introduce successor-suffix seat names** (`lead2`/`lead3`). Stable seat identity is the architectural goal.
|
||||
- **Do NOT report `seatBindingOutcome: handed_over`** if the provenance record didn't write durably.
|
||||
- **Do NOT auto-rollback a half-completed handover** by re-attaching the old occupant unless detach completed cleanly first.
|
||||
|
||||
## Composition: seat handover over fork
|
||||
|
||||
Once `session_source` fork v1 lands (already shipped at openrig
|
||||
`c7b6df1`), the next composition is **seat handover over fork**:
|
||||
candidate occupant created via fork, bound into the existing seat via
|
||||
handover. Continuity outcome is `forked`; binding outcome is independent.
|
||||
|
||||
## Currently shipped vs deferred
|
||||
|
||||
- **Option A (rebuild) shipped** at openrig `578bd5c` (2026-04-30): `session_source.mode: rebuild` with `ref.kind: artifact_set`; identity-honesty bedrock at 4 layers (schema dispatch, orchestrator threading, SQLite read-back, negative-grep on resolver output). 26/26 Tier 1 cases green; full daemon regression 2146/2146 PASS.
|
||||
- **Option B partially shipped, partially deferred to Mode 3**: the `rig seat handover` CLI exists as a planning/observability surface — `status <seat>` reads the seat-handover observability tables (migration `021`); `handover <seat>` plans a safe two-phase handover sequence with actual execution flowing through existing seat-launch surfaces under operator gating. Full code-direct `SeatHandoverService` daemon module + `seat-binding-outcome` provenance record + nodes-table provenance migration remain deferred to Mode 3. MVP composition `seat handover over fork` is highest-leverage v1.
|
||||
|
||||
## Why load-bearing for RSI
|
||||
|
||||
Any recursive seat-refresh loop must be able to replace an occupant
|
||||
while keeping topology stable. Without these primitives, RSI loops will
|
||||
either accumulate suffixed seat names (lineage leaking into identity) or
|
||||
destabilize topology references on each cycle. Provenance must be
|
||||
durable AND queryable so RSI loops can decide whether a seat is fresh
|
||||
enough to receive new work or needs re-handover.
|
||||
|
||||
## See also
|
||||
|
||||
- `claude-compaction-restore` skill — packet-driven restore after Claude compaction
|
||||
- `agent-startup-and-context-ingestion` skill — ingestion path for restore packets at agent boot
|
||||
@@ -1,48 +0,0 @@
|
||||
# VM Preview Fixtures
|
||||
|
||||
Sample data the Tart preview VM uses to populate the "populated" daemon with
|
||||
a representative "lived-in" environment. See
|
||||
`<substrate-shared-docs>/openrig-work/conventions/vm-preview/README.md` for the
|
||||
operator workflow and the rationale behind the two-daemon model.
|
||||
|
||||
## Contents
|
||||
|
||||
- `workflows/` — workflow_spec YAML files. The operator copies these into the
|
||||
populated daemon's `<workspace.specs_root>/workflows/` directory; slice 11's
|
||||
folder-discovery scanner auto-discovers them on the next
|
||||
`GET /api/specs/library` call.
|
||||
|
||||
- `queue-items/` — placeholder for future qitem fixture content (TODO; slice
|
||||
follow-up will add).
|
||||
|
||||
- `rigs/` — placeholder for future rig-instance fixtures (TODO; slice
|
||||
follow-up will add). For v0, the operator manually instantiates a sample
|
||||
rig via `rig up product-team` against the populated daemon (see bootstrap
|
||||
script).
|
||||
|
||||
## Conventions
|
||||
|
||||
Fixture YAML files use the canonical placeholder paths per banked
|
||||
`feedback_test_fixture_example_path_placeholder.md`:
|
||||
|
||||
- Username-bearing paths: `/Users/example/...` (never a real contributor
|
||||
username)
|
||||
- Substrate-relative refs: `<substrate-shared-docs>/...`
|
||||
|
||||
## v0 scope
|
||||
|
||||
Slice 22 (release-0.3.1) ships the INFRASTRUCTURE for the two-daemon model:
|
||||
|
||||
- **Process-env pattern**: `OPENRIG_HOME=<dir> rig daemon start ...` — each
|
||||
`rig` invocation gets its own state directory at process import time.
|
||||
No CLI flag (an earlier attempt at `--openrig-home` was dropped because
|
||||
it only threaded into the spawned child, not the parent-side lifecycle
|
||||
bookkeeping; see substrate convention doc for the architecture note).
|
||||
- Bootstrap script (`scripts/vm-bootstrap/two-daemon-start.sh`)
|
||||
- This fixtures directory + a sample workflow YAML
|
||||
- Convention doc in substrate
|
||||
|
||||
Authoring rich populated content (~13-node openrig-velocity rig + 20 qitems
|
||||
+ getting-started mission narrative) is deferred to follow-up content slices
|
||||
that have clearer ownership (slice 21 onboarding-conveyor will likely
|
||||
contribute getting-started mission content).
|
||||
@@ -1,79 +0,0 @@
|
||||
# VM-preview fixture for slice 22 founder-walk-vm-populated-env.
|
||||
# Derived from the 0.3.0 basic-loop starter; renamed to avoid colliding
|
||||
# with the built-in spec when this fixture file is dropped into a
|
||||
# populated daemon's workspace.specs_root/workflows/ directory.
|
||||
# Slice 11's folder-discovery scanner auto-discovers it on next list.
|
||||
workflow:
|
||||
id: vm-preview-basic-loop
|
||||
version: 1
|
||||
objective: >
|
||||
Walk one work packet through the conveyor rig slowly enough for a new user
|
||||
to inspect each handoff.
|
||||
target:
|
||||
rig: conveyor
|
||||
entry:
|
||||
role: intake
|
||||
coordination_terminal_turn_rule: hot_potato
|
||||
roles:
|
||||
intake:
|
||||
skill_refs: [openrig-user, backlog-capture]
|
||||
preferred_targets: [intake-lead@conveyor]
|
||||
planner:
|
||||
skill_refs: [openrig-user, writing-plans]
|
||||
preferred_targets: [plan-planner@conveyor]
|
||||
builder:
|
||||
skill_refs: [openrig-user, executing-plans]
|
||||
preferred_targets: [build-builder@conveyor]
|
||||
reviewer:
|
||||
skill_refs: [openrig-user, review-team]
|
||||
preferred_targets: [review-reviewer@conveyor]
|
||||
closer:
|
||||
skill_refs: [openrig-user, verification-before-completion]
|
||||
preferred_targets: [intake-lead@conveyor]
|
||||
steps:
|
||||
- id: intake
|
||||
actor_role: intake
|
||||
objective: Restate the objective and hand off one packet.
|
||||
allowed_exits: [handoff, waiting, failed]
|
||||
next_hop:
|
||||
mode: require
|
||||
suggested_roles: [planner]
|
||||
- id: plan
|
||||
actor_role: planner
|
||||
objective: Produce a short plan for one build turn.
|
||||
allowed_exits: [handoff, waiting, failed]
|
||||
next_hop:
|
||||
mode: require
|
||||
suggested_roles: [builder]
|
||||
- id: build
|
||||
actor_role: builder
|
||||
objective: Build or draft the planned output.
|
||||
allowed_exits: [handoff, waiting, failed]
|
||||
next_hop:
|
||||
mode: require
|
||||
suggested_roles: [reviewer]
|
||||
- id: review
|
||||
actor_role: reviewer
|
||||
objective: Check the output and clear it for close.
|
||||
allowed_exits: [handoff, waiting, failed]
|
||||
next_hop:
|
||||
mode: require
|
||||
suggested_roles: [closer]
|
||||
- id: close
|
||||
actor_role: closer
|
||||
objective: Close the walkthrough packet.
|
||||
allowed_exits: [done, failed]
|
||||
next_hop:
|
||||
mode: forbid
|
||||
invariants:
|
||||
continuation_required: true
|
||||
preserve_lineage: true
|
||||
closure_required: true
|
||||
allowed_exits: [handoff, waiting, done, failed]
|
||||
loop_guards:
|
||||
max_hops: 10
|
||||
spawn_budget: 0
|
||||
closure:
|
||||
success: Walkthrough packet closed.
|
||||
degraded: Walkthrough stopped with a named blocker.
|
||||
failed: Walkthrough failed with evidence.
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.3.1",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc && node scripts/copy-workflow-specs.mjs",
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
const sourceDir = join(process.cwd(), "src", "builtins", "workflow-specs");
|
||||
const targetDir = join(process.cwd(), "dist", "builtins", "workflow-specs");
|
||||
const workflowSpecExtensions = new Set([".yaml", ".yml"]);
|
||||
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
if (!existsSync(sourceDir)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!workflowSpecExtensions.has(extname(entry.name).toLowerCase())) continue;
|
||||
copyFileSync(join(sourceDir, entry.name), join(targetDir, entry.name));
|
||||
}
|
||||
@@ -11,9 +11,10 @@ imports:
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [using-superpowers, openrig-user, systematic-debugging, verification-before-completion, vault-user]
|
||||
skills: [using-superpowers, openrig-user, mental-model-ha, systematic-debugging, verification-before-completion, vault-user]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: [shared:claude-default-settings, shared:claude-default-mcp, shared:codex-default-config]
|
||||
|
||||
resources:
|
||||
|
||||
@@ -28,6 +28,7 @@ You have the following skills loaded:
|
||||
|
||||
- `using-superpowers` — general agent capabilities
|
||||
- `openrig-user` — OpenRig CLI and topology operations
|
||||
- `mental-model-ha` — compaction and restore discipline
|
||||
- `systematic-debugging` — structured debugging approach
|
||||
- `verification-before-completion` — evidence before claims
|
||||
- `vault-user` — Vault-specific operations and domain knowledge
|
||||
Loaded 100 of 782 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user