mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
+8
-1
@@ -3,9 +3,12 @@ dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# All docs are internal except as-built
|
||||
# All docs are internal except as-built and reference
|
||||
docs/*
|
||||
!docs/as-built/
|
||||
!docs/reference/
|
||||
|
||||
notes/*
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
@@ -16,6 +19,10 @@ docs/*
|
||||
*.sqlite-wal
|
||||
*.sqlite.pre-reboot-backup
|
||||
.openrig/
|
||||
.claude
|
||||
.agents
|
||||
.openrig
|
||||
.worktrees/
|
||||
|
||||
# Local demo proof artifacts
|
||||
demo/proof/
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2026 Mike Schwarz
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,174 @@
|
||||
# OpenRig
|
||||
|
||||
Open source multi-agent harness for coding teams.
|
||||
|
||||
A harness wraps a model. A rig wraps your harnesses. Define your agent team in YAML, boot it with one command. Claude Code and Codex in the same rig, managed as one system.
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install -g @openrig/cli
|
||||
|
||||
# Prepare the machine (attempts tmux, cmux, Claude Code, Codex, tmux defaults — reports what worked)
|
||||
rig setup
|
||||
|
||||
# Boot the demo rig (3 pods, 8 nodes, mixed runtimes)
|
||||
rig up demo
|
||||
|
||||
# Open the UI
|
||||
rig ui open
|
||||
```
|
||||
|
||||
After the demo boots, open the UI and click **orch1.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
|
||||
|
||||
## Launch Walkthrough
|
||||
|
||||
The launch path above has been walked end-to-end on fresh macOS VMs. The only thing your agent can't do would be the oauth logins for claude and openai and dealing with permission prompts.
|
||||
|
||||
### 1. Open the demo rig
|
||||
|
||||
When the UI opens, the explorer is already visible on the left. Click the `demo` rig to load its live topology.
|
||||
|
||||

|
||||
|
||||
### 2. Click `CMUX` on `orch1.lead`
|
||||
|
||||
Once the topology loads, go to the `orch1.lead` node and click its `CMUX` button. That opens the orchestrator terminal directly.
|
||||
|
||||

|
||||
|
||||
> From here just talk to the orchestrator and tell it what you want it to build...
|
||||
|
||||
## What It Does
|
||||
|
||||
OpenRig is a multi-agent harness — it manages the system that coding agents form when you run them together. Not the agents themselves, but the team they create: which sessions are running, how they relate, how to recover after a reboot, and how to stop it from becoming terminal sprawl.
|
||||
|
||||
- **Define** topologies in YAML (RigSpec) with pods, edges, and continuity policies
|
||||
- **Boot** everything with `rig up` — tmux sessions, harnesses, startup files, readiness checks
|
||||
- **See** the topology in a live graph with explorer, node detail, and system log
|
||||
- **Discover** existing Claude Code and Codex sessions in tmux and adopt them into a managed rig
|
||||
- **Snapshot** the full topology on `rig down`, restore by name with `rig up <name>`
|
||||
- **Communicate** across agents with `rig send`, `rig broadcast`, and `rig chatroom`
|
||||
- **Evolve** running topologies with `rig expand`, `rig shrink`, `rig launch`, `rig remove`
|
||||
|
||||
Every agent runs in a tmux session you can attach to, inspect, and work with directly.
|
||||
|
||||
## Demo Rig
|
||||
|
||||
OpenRig ships with a demo rig you can boot in seconds:
|
||||
|
||||
```bash
|
||||
rig specs preview demo
|
||||
```
|
||||
|
||||
```
|
||||
demo (rig, pod_aware)
|
||||
Launch-grade starter: a stable full product squad with two
|
||||
orchestrators, implementation, QA, design, and two independent
|
||||
reviewers.
|
||||
|
||||
Pod: orch1 (2 members)
|
||||
lead — claude-code
|
||||
peer — codex
|
||||
Pod: dev1 (3 members)
|
||||
impl — claude-code
|
||||
qa — codex
|
||||
design — claude-code
|
||||
Pod: rev1 (2 members)
|
||||
r1 — claude-code
|
||||
r2 — codex
|
||||
```
|
||||
|
||||
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, `product-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
|
||||
|
||||
Browse the library: `rig specs ls`
|
||||
|
||||
## How It Works
|
||||
|
||||
OpenRig is a local daemon + CLI + MCP server + React UI, built on tmux.
|
||||
|
||||
```
|
||||
CLI / UI / MCP
|
||||
|
|
||||
Hono HTTP daemon
|
||||
|
|
||||
Domain services (52)
|
||||
|
|
||||
SQLite + tmux + runtime adapters
|
||||
```
|
||||
|
||||
- **CLI**: 40+ commands designed for both humans and agents. Every mutating command ends with what happened, current state, and next action.
|
||||
- **UI**: Explorer sidebar, topology graph with pod grouping, node detail panel, system log, chatroom.
|
||||
- **MCP**: 17 tools so agents can manage their own topology (`rig_up`, `rig_ps`, `rig_send`, `rig_chatroom_send`, etc.)
|
||||
- **Runtimes**: Claude Code, Codex, and terminal nodes. Adapters for Pi and OpenHands in development.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **RigSpec**: Declarative multi-agent harness definition in YAML. Pods, members, edges, continuity policies, culture file.
|
||||
- **AgentSpec**: Reusable agent blueprint with skills, guidance, hooks, profiles, and startup contracts.
|
||||
- **Pod**: Bounded context group. Agents in a pod share memory and can maintain each other's context.
|
||||
- **Discovery**: `rig discover` fingerprints existing tmux sessions. `rig adopt` brings them under management.
|
||||
- **Snapshot/Restore**: `rig down --snapshot` captures full state. `rig up <name>` restores from latest snapshot. Restore reports per-node outcomes (resumed, fresh, or failed).
|
||||
- **RigBundle**: Portable archive with vendored AgentSpecs and SHA-256 integrity. Share topologies across machines.
|
||||
- **Culture**: CULTURE.md sets coordination norms for the group. Research rigs get exploratory culture. Implementation rigs get conservative, trust-but-verify culture.
|
||||
|
||||
## Agent-Managed Software
|
||||
|
||||
A rig can package actual software alongside the agents that manage it. The shipped example is `secrets-manager`: a HashiCorp Vault instance operated by a specialist agent.
|
||||
|
||||
```bash
|
||||
rig up secrets-manager
|
||||
rig env status secrets-manager
|
||||
rig send vault-specialist@secrets-manager "Check Vault health and report status." --verify
|
||||
```
|
||||
|
||||
Requires Docker for service-backed rigs.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- tmux
|
||||
|
||||
Optional:
|
||||
- cmux for `Open CMUX` node surface controls
|
||||
- Docker for service-backed rigs and managed apps
|
||||
|
||||
## Setup and Troubleshooting
|
||||
|
||||
- `rig setup` attempts core machine preparation: tmux, cmux, Claude Code, Codex, and tmux defaults. It reports what it tried and what actually succeeded. If something fails, it gives the local agent enough context to finish the job.
|
||||
- `rig setup --full` attempts a broader operator workstation setup (jq, gh) on top of core.
|
||||
- `rig doctor` inspects current system health and helps diagnose problems after setup. Use it when something stops working or after machine changes.
|
||||
|
||||
Both commands support `--json` for agent-driven workflows.
|
||||
|
||||
Managed runtime boot (during `rig up`) may modify runtime config to reduce permission and MCP friction. `rig setup` discloses these paths so agents know what was changed:
|
||||
- global Claude: `~/.claude/settings.json` for OpenRig command allowlisting
|
||||
- global Claude state: `~/.claude.json` for managed workspace trust and onboarding completion
|
||||
- project Claude: `.claude/settings.local.json` for managed-session permissions
|
||||
- project Claude MCP: `.mcp.json` for OpenRig-managed MCP servers
|
||||
- global Codex: `~/.codex/config.toml` for workspace trust and MCP servers
|
||||
|
||||
Already-running adopted sessions may need restart before they pick up newly written runtime config.
|
||||
|
||||
**For agents:** Ask the user whether they want core setup (`rig setup`) or the fuller workstation path (`rig setup --full`) before choosing the invocation. Inspect the result with `--json` and use `rig doctor` to finish any remaining machine-specific issues.
|
||||
|
||||
## Comparison with Claude Managed Agents
|
||||
|
||||
Anthropic shipped Claude Managed Agents — a cloud-hosted, Claude-only runtime at $0.08/session-hour. OpenRig is the local side: open source, cross-harness, runs on your machine, costs nothing.
|
||||
|
||||
[Full comparison](https://openrig.dev/compare/claude-managed-agents)
|
||||
|
||||
## Links
|
||||
|
||||
- **Website**: [openrig.dev](https://openrig.dev)
|
||||
- **Blog**: [Why I Built OpenRig](https://esoteric.run/blog/why-i-built-openrig)
|
||||
- **Docs**: [openrig.dev/docs](https://openrig.dev/docs)
|
||||
- **Open Specification**: [openrig.dev/specs](https://openrig.dev/specs)
|
||||
- **Twitter**: [@_feralmachine](https://twitter.com/_feralmachine)
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
@@ -0,0 +1,24 @@
|
||||
# Launch Happy-Path Screenshot Set
|
||||
|
||||
Source capture:
|
||||
- packaged OpenRig UI on the clean rerun against the authenticated VM lane
|
||||
- host-forwarded UI: `http://127.0.0.1:17439`
|
||||
- guest daemon: `192.168.64.9:7433`
|
||||
|
||||
Purpose:
|
||||
- README walkthrough
|
||||
- website/docs reuse
|
||||
- launch gate evidence bundle
|
||||
|
||||
Annotated README walkthrough:
|
||||
- `guide-step-01-open-demo.png` — click the `demo` rig in the explorer
|
||||
- `guide-step-02-open-cmux.png` — click the `CMUX` button on `orch1.lead` in the topology
|
||||
|
||||
Archived proof/source bundle:
|
||||
- raw proof captures and guide source were moved out of the repo to:
|
||||
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/raw-proof/`
|
||||
- `/Users/wrandom/code/substrate/shared-docs/rigs/openrig-build/state/missions/artifacts/launch-happy-path-2026-04-13/repo-offload/guide-src/`
|
||||
|
||||
Notes:
|
||||
- The README should use the annotated walkthrough files.
|
||||
- The raw captures are still the fidelity record for proof and future reuse, just not inside the product repo.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
+1
-1
@@ -62,7 +62,7 @@ pods:
|
||||
files: []
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "rigged status"
|
||||
value: "rig status"
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
applies_on: [fresh_start]
|
||||
|
||||
+7
-7
@@ -10,11 +10,11 @@ echo ""
|
||||
|
||||
# 0. Start or reuse daemon
|
||||
echo "Step 0: Ensure daemon is running..."
|
||||
if rigged daemon start; then
|
||||
if rig daemon start; then
|
||||
sleep 2
|
||||
else
|
||||
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
|
||||
if rigged ps --json >/dev/null 2>&1; then
|
||||
if rig ps --json >/dev/null 2>&1; then
|
||||
echo "Reusing existing healthy daemon."
|
||||
else
|
||||
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
|
||||
@@ -25,7 +25,7 @@ echo ""
|
||||
|
||||
# 1. Boot
|
||||
echo "Step 1: Boot demo topology..."
|
||||
UP_OUTPUT=$(rigged up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
|
||||
UP_OUTPUT=$(rig up demo/rig.yaml 2>&1 | tee "$PROOF_DIR/up-transcript.txt")
|
||||
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
|
||||
if [ -z "$RIG_ID" ]; then
|
||||
echo "ERROR: No rig ID found in boot output. Proof failed."
|
||||
@@ -35,7 +35,7 @@ echo ""
|
||||
|
||||
# 2. Node status
|
||||
echo "Step 2: Node status after boot..."
|
||||
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
|
||||
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
|
||||
echo ""
|
||||
|
||||
# 3. Health check after boot
|
||||
@@ -74,7 +74,7 @@ echo "Rig ID: $RIG_ID"
|
||||
# 7. Tear down
|
||||
echo ""
|
||||
echo "Step 7: Tear down..."
|
||||
DOWN_OUTPUT=$(rigged down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
|
||||
DOWN_OUTPUT=$(rig down "$RIG_ID" 2>&1 | tee "$PROOF_DIR/down-transcript.txt")
|
||||
SNAPSHOT_ID=$(printf '%s\n' "$DOWN_OUTPUT" | sed -n 's/^Snapshot: //p' | tail -n1)
|
||||
if [ -z "$SNAPSHOT_ID" ]; then
|
||||
echo "ERROR: No snapshot ID found in teardown output. Proof failed."
|
||||
@@ -90,12 +90,12 @@ echo ""
|
||||
|
||||
# 9. Restore via explicit snapshot + rig ID
|
||||
echo "Step 9: Restore via explicit snapshot + rig ID..."
|
||||
rigged restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
|
||||
rig restore "$SNAPSHOT_ID" --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/restore-transcript.txt"
|
||||
echo ""
|
||||
|
||||
# 10. Node status after restore
|
||||
echo "Step 10: Node status after restore..."
|
||||
rigged ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
|
||||
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
|
||||
echo ""
|
||||
|
||||
echo "=== Automated proof artifacts produced ==="
|
||||
|
||||
+8
-8
@@ -6,11 +6,11 @@ echo ""
|
||||
|
||||
# Start daemon
|
||||
echo "Starting daemon..."
|
||||
if rigged daemon start; then
|
||||
if rig daemon start; then
|
||||
sleep 2
|
||||
else
|
||||
echo "Daemon start returned non-zero; checking for an existing healthy daemon..."
|
||||
if rigged ps --json >/dev/null 2>&1; then
|
||||
if rig ps --json >/dev/null 2>&1; then
|
||||
echo "Reusing existing healthy daemon."
|
||||
else
|
||||
echo "ERROR: daemon failed to start and no healthy daemon is available." >&2
|
||||
@@ -21,18 +21,18 @@ fi
|
||||
# Boot the demo topology
|
||||
echo ""
|
||||
echo "Booting demo topology..."
|
||||
UP_OUTPUT=$(rigged up demo/rig.yaml)
|
||||
UP_OUTPUT=$(rig up demo/rig.yaml)
|
||||
printf '%s\n' "$UP_OUTPUT"
|
||||
RIG_ID=$(printf '%s\n' "$UP_OUTPUT" | sed -n 's/^Rig: //p' | tail -n1)
|
||||
if [ -z "$RIG_ID" ]; then
|
||||
echo "ERROR: failed to determine rig ID from 'rigged up' output." >&2
|
||||
echo "ERROR: failed to determine rig ID from 'rig up' output." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show node status
|
||||
echo ""
|
||||
echo "Node status:"
|
||||
rigged ps --nodes
|
||||
rig ps --nodes
|
||||
|
||||
# Establish a restore-safe baseline for the demo rig.
|
||||
echo ""
|
||||
@@ -55,6 +55,6 @@ echo "=== Demo topology is running ==="
|
||||
echo "Dashboard: http://localhost:5173"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " rigged ps --nodes # Check node status"
|
||||
echo " rigged down $RIG_ID # Tear down (auto-snapshots)"
|
||||
echo " rigged restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
|
||||
echo " rig ps --nodes # Check node status"
|
||||
echo " rig down $RIG_ID # Tear down (auto-snapshots)"
|
||||
echo " rig restore <snapshotId> --rig $RIG_ID # Restore the exact snapshot you just created"
|
||||
@@ -59,12 +59,12 @@ export function runCommand(command: string, args: string[], cwd = repoRoot()): s
|
||||
}).trim();
|
||||
}
|
||||
|
||||
export function runRigged(args: string[]): string {
|
||||
return runCommand("rigged", args);
|
||||
export function runRig(args: string[]): string {
|
||||
return runCommand("rig", args);
|
||||
}
|
||||
|
||||
export function runRiggedJson<T>(args: string[]): T {
|
||||
const output = runRigged(args);
|
||||
export function runRigJson<T>(args: string[]): T {
|
||||
const output = runRig(args);
|
||||
return JSON.parse(output) as T;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
export function listRigSummaries(): DemoRigSummary[] {
|
||||
return runRiggedJson<DemoRigSummary[]>(["ps", "--json"]);
|
||||
return runRigJson<DemoRigSummary[]>(["ps", "--json"]);
|
||||
}
|
||||
|
||||
export function getCurrentRigSummary(rigNameOrId: string): DemoRigSummary | null {
|
||||
@@ -95,7 +95,7 @@ export function listRigNodes(rigNameOrId: string): DemoNodeEntry[] {
|
||||
return [];
|
||||
}
|
||||
return filterNodesForRigId(
|
||||
runRiggedJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
|
||||
runRigJson<DemoNodeEntry[]>(["ps", "--nodes", "--json"]),
|
||||
summary.rigId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function probeNodeResume(
|
||||
const pollMs = opts.pollMs ?? 500;
|
||||
const maxWaitMs = opts.maxWaitMs ?? 6_000;
|
||||
const attempts = Math.max(1, Math.floor(maxWaitMs / pollMs));
|
||||
const sessionName = `rigged-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
|
||||
const sessionName = `rig-probe-${sanitizeName(node.logicalId)}-${Date.now().toString(36)}`;
|
||||
|
||||
try {
|
||||
runTmux(["new-session", "-d", "-s", sessionName, "-c", resolveNodeCwd(node.cwd ?? ".")]);
|
||||
|
||||
@@ -100,7 +100,7 @@ function sendWarmup(sessionName: string | null, text: string): void {
|
||||
}
|
||||
|
||||
execFileSync(
|
||||
"rigged",
|
||||
"rig",
|
||||
["send", sessionName, text, "--verify", "--force", "--json"],
|
||||
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
|
||||
+243
-80
@@ -1,16 +1,16 @@
|
||||
# OpenRig — As-Built Architecture
|
||||
## Final Stretch 1 Snapshot (as of 2026-04-05)
|
||||
## Current Architecture
|
||||
|
||||
Status:
|
||||
- Four implementation rounds have landed: AgentSpec reboot (16 tasks), North Star (16 tasks), Post-North-Star (11 tasks + dogfood fixes + UI polish), and Final Stretch 1 (spec library, `whoami`, adopted-session parity, specs/discovery drawer workflows, reusable spec display surfaces, live-node full-details route, graph hover/runtime hints).
|
||||
- Shipped source footprint: `253` source files across the three packages.
|
||||
- Daemon footprint: `133` source files total, including `79` domain files, `21` route files, `10` adapters, and `17` migrations.
|
||||
- CLI footprint: `37` source files.
|
||||
- UI footprint: `83` source files.
|
||||
- OpenRig currently ships a pod-aware multi-agent runtime, rig-scoped environment management, a filesystem-backed spec library, managed-app browsing/review surfaces, and the canonical `secrets-manager` + `vault.specialist` agent-managed app example.
|
||||
- Shipped source footprint: `286` source files across the three packages.
|
||||
- Daemon footprint: `150` source files total, including `90` domain files, `22` route files, `11` adapters, and `20` migrations.
|
||||
- CLI footprint: `49` source files.
|
||||
- UI footprint: `87` source files.
|
||||
- Current full-suite verification during this refresh:
|
||||
- daemon: `112/112` files, `1461/1461` passing
|
||||
- CLI: `29/29` files, `275/275` passing
|
||||
- UI: `35` files, `361/364` passing (`3` known design/foundation failures)
|
||||
- daemon: `127/127` files, `1668/1668` passing
|
||||
- CLI: `37/37` files, `366/366` passing
|
||||
- UI: `37/37` files, `388/388` passing
|
||||
|
||||
Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
|
||||
|
||||
@@ -18,42 +18,47 @@ Packages: `@openrig/daemon`, `@openrig/cli`, `@openrig/ui`
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies. The system currently has four architectural layers:
|
||||
OpenRig is a local control plane for multi-agent coding topologies. The system currently has six architectural layers:
|
||||
|
||||
1. **AgentSpec / pod-aware core**: spec parsing, resolution, precedence, startup orchestration, snapshot/restore, bundles.
|
||||
2. **North Star operator layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
|
||||
3. **Post-North-Star transport/history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
|
||||
4. **Final Stretch 1 authoring/identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
|
||||
2. **Operator and topology layer**: harness auto-launch, node inventory, session naming, infrastructure nodes, explorer UI, existing-rig power-on, auto-snapshot, post-command handoff.
|
||||
3. **Communication and history layer**: transcript capture (pipe-pane), communication primitives (send/capture/broadcast), config/preflight, `rig ask` context packs, durable rig chat.
|
||||
4. **Authoring and identity layer**: spec review + spec library, `whoami`, adopted-session tmux-metadata parity, bind/materialize/adopt workflows, specs/discovery drawers, reusable spec display components, live-node full-details workspace routes, and graph hover/runtime hints.
|
||||
5. **Rig environment layer**: rig-scoped services records, Compose-backed service orchestration, readiness gates, env snapshot/restore integration, runtime env status/logs/down surfaces.
|
||||
6. **Agent-managed software layer**: managed-app classification in the library, app-focused browse/review/runtime UI, managed-app-aware CLI/help surfaces, and the canonical `secrets-manager` + `vault.specialist` example.
|
||||
|
||||
Legacy flat-node/package flows remain for backward compatibility.
|
||||
|
||||
The stack is:
|
||||
|
||||
```text
|
||||
CLI (31 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
CLI (40 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (21 route groups + dedicated health/export/static handlers)
|
||||
Hono daemon routes (22 route groups + dedicated health/export/static handlers)
|
||||
|
|
||||
+-- dual-format route adapters (legacy v1 + rebooted v0.2)
|
||||
+-- env routes (status / logs / down)
|
||||
+-- transport routes (send/capture/broadcast)
|
||||
+-- transcript routes (tail/grep)
|
||||
+-- ask routes (context evidence packs)
|
||||
+-- chat routes (durable rig messaging + SSE)
|
||||
+-- spec review/library routes
|
||||
+-- whoami identity route
|
||||
+-- spec review/library routes (managed-app enrichment + compose preview)
|
||||
+-- whoami identity + context-usage route
|
||||
|
|
||||
v
|
||||
Framework-free domain services
|
||||
|
|
||||
+-- SQLite state (17 migrations)
|
||||
+-- SQLite state (20 migrations)
|
||||
+-- tmux / cmux / resume adapters
|
||||
+-- runtime adapters (Claude Code / Codex / Terminal)
|
||||
+-- RigEnv substrate (compose adapter, readiness evaluator, service orchestrator)
|
||||
+-- transport layer (SessionTransport)
|
||||
+-- transcript store (pipe-pane backed)
|
||||
+-- chat repository (SQLite backed)
|
||||
+-- spec review service
|
||||
+-- spec library service
|
||||
+-- spec review service (services metadata + compose preview seam)
|
||||
+-- spec library service (`hasServices` classification)
|
||||
+-- context-usage store / monitor
|
||||
+-- whoami identity service
|
||||
|
||||
The core product loop:
|
||||
@@ -66,7 +71,7 @@ The core product loop:
|
||||
|
||||
### HTTP routes
|
||||
|
||||
`createApp()` now mounts 21 route groups plus:
|
||||
`createApp()` now mounts 22 route groups plus:
|
||||
- `GET /healthz`
|
||||
- `GET /api/rigs/:rigId/spec`
|
||||
- `GET /api/rigs/:rigId/spec.json`
|
||||
@@ -74,7 +79,7 @@ The core product loop:
|
||||
|
||||
In addition to the reboot-era routes:
|
||||
|
||||
**North Star additions:**
|
||||
**Operator and topology routes:**
|
||||
- `GET /api/rigs/:rigId/nodes` — canonical shared node-inventory projection
|
||||
- `GET /api/rigs/:rigId/nodes/:logicalId` — rich node-detail payload for the drawer
|
||||
- `POST /api/rigs/:rigId/nodes/:logicalId/launch` — manual node launch
|
||||
@@ -82,7 +87,7 @@ In addition to the reboot-era routes:
|
||||
- `POST /api/rigs/:id/up` — existing-rig restore by rig ID
|
||||
- `POST /api/discovery/draft-rig` — generate candidate rig spec from discovered sessions
|
||||
|
||||
**Final Stretch 1 additions:**
|
||||
**Authoring and identity routes:**
|
||||
- `POST /api/rigs/import/materialize` — create pod-aware rig topology without launching
|
||||
- `POST /api/discovery/:id/bind` — bind discovered session to an existing logical node
|
||||
- `POST /api/discovery/:id/adopt` — UI-friendly composite adopt route (`bind` or `create_and_bind`)
|
||||
@@ -90,7 +95,12 @@ In addition to the reboot-era routes:
|
||||
- `/api/specs/library` — list/get/review/sync filesystem-backed library entries
|
||||
- `/api/whoami` — daemon-backed runtime identity projection by `nodeId` or `sessionName`
|
||||
|
||||
**Post-North-Star additions:**
|
||||
**Rig environment routes:**
|
||||
- `GET /api/rigs/:rigId/env` — live rig-environment status with best-effort fresh receipt capture
|
||||
- `GET /api/rigs/:rigId/env/logs` — service logs from the Compose adapter
|
||||
- `POST /api/rigs/:rigId/env/down` — explicit env teardown for service-backed rigs
|
||||
|
||||
**Communication and history routes:**
|
||||
- `/api/transport` — `POST /send`, `/capture`, `/broadcast` — communication primitives
|
||||
- `/api/transcripts` — `GET /:session/tail`, `/:session/grep` — transcript access
|
||||
- `/api/ask` — `POST /` — context evidence pack over rig summary + transcripts + chat
|
||||
@@ -103,27 +113,39 @@ Important route behaviors:
|
||||
- `/api/transport/send` classifies target activity before sending and can refuse unless forced
|
||||
- `/api/ask` returns context/evidence, not LLM-synthesized answers
|
||||
- `/api/rigs/:rigId/chat` streams via SSE for real-time delivery
|
||||
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`)
|
||||
- `/api/specs/library/:id/review` returns structured review plus library provenance (`sourcePath`, `sourceState`), managed-app services metadata, and best-effort compose preview
|
||||
- `/api/whoami` fails honestly on missing/ambiguous identity instead of fabricating “healthy” state
|
||||
- `/api/rigs/:rigId/env` returns `{ ok: true, hasServices: false }` honestly when a rig has no service substrate
|
||||
- `/api/rigs/:rigId/env/down` currently honors stored down policy; explicit volume-removal override is not fully wired through yet
|
||||
|
||||
### CLI commands
|
||||
|
||||
`index.ts` currently mounts 31 command groups. Reboot-era commands plus:
|
||||
`index.ts` currently mounts 40 command groups. Reboot-era commands plus:
|
||||
|
||||
**North Star additions:**
|
||||
**Operator and topology commands:**
|
||||
- `rig up <rig-name>` — existing-rig restore by name (auto-finds latest snapshot)
|
||||
- `rig down` — auto-snapshots before teardown, handoff includes restore command
|
||||
- `rig discover --draft` — generate candidate rig spec from discovered sessions
|
||||
- Changed: `rig ps --nodes` shows per-node detail with session names, status, startup status
|
||||
|
||||
**Final Stretch 1 additions:**
|
||||
**Authoring and identity commands:**
|
||||
- `rig specs ls/show/preview/add/sync` — agent-facing spec library browse/review workflow
|
||||
- `rig whoami --json` — daemon-backed runtime identity with peers, edges, transcript helpers, and command hints
|
||||
- `rig bind <discoveredId> <rigId> <logicalId>` — bind discovered session to an existing node
|
||||
- `rig adopt <file> --bind logicalId=session` — materialize then bind live sessions into a rig
|
||||
- Changed: `rig up <source>` and `rig bootstrap <source>` now resolve spec-library names and fail loudly on rig-name vs library-name ambiguity
|
||||
|
||||
**Post-North-Star additions (8 new command groups):**
|
||||
**Rig environment commands:**
|
||||
- `rig env status <rig>` — inspect rig-scoped environment status for service-backed rigs and managed apps
|
||||
- `rig env logs <rig> [service]` — fetch service logs
|
||||
- `rig env down <rig>` — tear down the rig environment
|
||||
|
||||
**Managed-app-aware help and copy:**
|
||||
- `rig up` help/examples explicitly include library-backed managed apps like `secrets-manager`
|
||||
- `rig specs` help/list/preview text now distinguishes rigs, agents, and managed apps
|
||||
- `openrig-user` now teaches the shipped managed-app model (`software + specialist`) and cross-rig specialist interaction
|
||||
|
||||
**Communication and history commands:**
|
||||
- `rig send <session> "message" [--verify] [--force]` — send to agent terminal
|
||||
- `rig capture <session> [--lines N] [--json]` — capture agent pane content
|
||||
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
|
||||
@@ -139,7 +161,7 @@ The CLI-hosted MCP server exposes 17 tools:
|
||||
|
||||
1-12. Reboot-era: `rigged_up`, `rigged_down`, `rigged_ps`, `rigged_status`, `rigged_snapshot_create`, `rigged_snapshot_list`, `rigged_restore`, `rigged_discover`, `rigged_claim`, `rigged_bundle_inspect`, `rigged_agent_validate`, `rigged_rig_validate`
|
||||
|
||||
13-17. Post-North-Star additions:
|
||||
13-17. Communication and operator additions:
|
||||
- `rigged_rig_nodes` — node inventory for a rig (agents can look up infrastructure sessions)
|
||||
- `rigged_send` — send message to agent terminal
|
||||
- `rigged_capture` — capture agent pane content
|
||||
@@ -157,13 +179,21 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
|
||||
- The shared drawer has five primary surfaces:
|
||||
- **Rig drawer:** identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab
|
||||
- **Node drawer:** runtime-first identity, peers, edges, transcript helpers, compact spec summary, status (with restore outcome), startup files, recent events, and `Open Full Details`
|
||||
- **Specs drawer:** library list, draft/spec review routing, and spec authoring entry points
|
||||
- **Specs drawer:** unified library list with `All / Apps / Rigs / Agents` filters, type/stability badges, draft/spec review routing, and spec authoring entry points
|
||||
- **Discovery drawer:** placement-first adopt/bind/materialize flow
|
||||
- **System drawer:** global event/activity surface replacing the old status-bar role
|
||||
- Pod selection opens the rig drawer with pod section expanded (no separate pod drawer)
|
||||
- Human-readable IDs are UI-only: pod labels instead of ULIDs in explorer, short ULID tails for glanceability, full IDs in detail views
|
||||
- Infrastructure/terminal nodes have distinct visual treatment
|
||||
- Graph shows pod grouping via React Flow group nodes, status colors (green=ready, amber=launching, red=failed, gray=stopped), and lightweight hover/runtime hints
|
||||
- Service-backed library entries render as `APP` rows instead of generic rigs
|
||||
- `LibraryReview` for a managed app shows:
|
||||
- `Library — Managed App`
|
||||
- `Copy Setup Prompt`
|
||||
- a `Specialist Agent` card (canonical example: `vault.specialist`)
|
||||
- a library-only `Environment` tab with compose preview, health gates, and surfaces
|
||||
- The rig drawer now has `Info`, `Env`, and `Chat Room` tabs for service-backed rigs
|
||||
- `RigEnvPanel` exposes overall env state, per-service health, health gates, surfaces, logs, and explicit env teardown
|
||||
- Explicit center-workspace routes now exist for:
|
||||
- `/specs`
|
||||
- `/specs/rig`
|
||||
@@ -175,7 +205,7 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
The daemon now has 17 migrations.
|
||||
The daemon now has 20 migrations.
|
||||
|
||||
### Core state tables
|
||||
|
||||
@@ -199,6 +229,9 @@ The daemon now has 17 migrations.
|
||||
|
||||
**bindings**
|
||||
- Physical surface attachment: tmux/cmux coordinates.
|
||||
- `019_external_cli_attachment.ts` extends this row shape with:
|
||||
- `attachment_type`
|
||||
- `external_session_name`
|
||||
|
||||
**sessions**
|
||||
- Live execution state.
|
||||
@@ -240,7 +273,7 @@ These remain active:
|
||||
- `label`
|
||||
- `summary`
|
||||
- serialized continuity policy
|
||||
- Final Stretch 1 made `namespace` first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
|
||||
- `namespace` is first-class so adoption, export, and identity surfaces can use authored pod identity instead of leaking pod ULIDs.
|
||||
|
||||
**continuity_state** (`014_agentspec_reboot.ts`)
|
||||
- Live per-`pod_id` / `node_id` operational continuity state.
|
||||
@@ -263,11 +296,30 @@ These remain active:
|
||||
- Indexed by `(rig_id, created_at)` for fast rig-scoped queries
|
||||
- Note: transcript persistence is filesystem-backed (pipe-pane → log files), not SQLite. Chat is SQLite-backed.
|
||||
|
||||
**context_usage** (`018_context_usage.ts`)
|
||||
- Persisted per-node context usage snapshots for runtime/operator surfaces.
|
||||
- Used by the daemon-owned context monitor and `whoami`/inventory projections.
|
||||
|
||||
**rig_services** (`020_rig_services.ts`)
|
||||
- Persisted rig-scoped environment record for service-backed rigs.
|
||||
- Stores:
|
||||
- `rig_id`
|
||||
- `kind`
|
||||
- `spec_json`
|
||||
- `rig_root`
|
||||
- resolved `compose_file`
|
||||
- resolved `project_name`
|
||||
- `latest_receipt_json`
|
||||
- timestamps
|
||||
|
||||
Migration boundary:
|
||||
- `014_agentspec_reboot.ts` adds the reboot schema shape.
|
||||
- `015_startup_context.ts` adds persisted startup replay context.
|
||||
- `016_chat_messages.ts` adds durable rig chat.
|
||||
- `017_pod_namespace.ts` backfills/persists authored pod namespace for export, adoption, and identity parity.
|
||||
- `018_context_usage.ts` adds context-usage persistence.
|
||||
- `019_external_cli_attachment.ts` extends binding rows for external CLI attachment.
|
||||
- `020_rig_services.ts` adds rig-scoped services persistence.
|
||||
|
||||
---
|
||||
|
||||
@@ -284,6 +336,17 @@ Migration boundary:
|
||||
- Uses `version: "0.2"` and `pods[]`.
|
||||
- Owns cross-pod `edges[]`, rig-level startup overlays, and `cultureFile`.
|
||||
|
||||
**RigServicesSpec**
|
||||
- Optional `services` block on a pod-aware `RigSpec`.
|
||||
- Current shipped service kind is Compose-backed env management with:
|
||||
- `composeFile`
|
||||
- `projectName?`
|
||||
- `profiles?`
|
||||
- `downPolicy?`
|
||||
- `waitFor?`
|
||||
- `surfaces?`
|
||||
- `checkpoints?`
|
||||
|
||||
**RigSpecPod**
|
||||
- Pod-local bounded context with `members[]`, pod-local `edges[]`, pod startup, and optional continuity policy.
|
||||
|
||||
@@ -329,13 +392,21 @@ Migration boundary:
|
||||
- Pre-launch vs interactive delivery split: `guidance_merge`/`skill_install` happen before harness boot (filesystem); `send_text` happens after harness is ready (TUI).
|
||||
- Persists replay context including resume token for future restores.
|
||||
|
||||
### Operator-layer types (North Star + Post-North-Star)
|
||||
**EnvReceipt**
|
||||
- RigEnv capture result persisted for service-backed rigs.
|
||||
- Carries service status/health plus evaluated wait targets and capture timestamp.
|
||||
|
||||
**RigServicesRecord**
|
||||
- Persisted database representation of a rig-scoped services substrate.
|
||||
- Holds resolved compose path, persisted spec JSON, project name, and latest receipt JSON.
|
||||
|
||||
### Operator, identity, and communication
|
||||
|
||||
**NodeInventoryEntry**
|
||||
- Universal node projection consumed by CLI/UI/MCP: rigId, rigName, logicalId, canonicalSessionName, podId, nodeKind (agent/infrastructure), runtime, sessionStatus, startupStatus, restoreOutcome, tmuxAttachCommand, resumeCommand, latestError.
|
||||
|
||||
**NodeDetailEntry**
|
||||
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus Final Stretch 1 live-identity fields:
|
||||
- Extended projection for the detail drawer: adds model, agentRef, profile, resolvedSpec identity, binding, cwd, startupFiles, installedResources, recentEvents, infrastructureStartupCommand, plus live-identity fields:
|
||||
- `peers`
|
||||
- directional `edges`
|
||||
- transcript helpers/path
|
||||
@@ -396,7 +467,7 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
|
||||
- `startup-validation.ts`: shared startup block validation
|
||||
- `path-safety.ts`: shared relative-path safety checks
|
||||
- `spec-validation-service.ts`: pure raw-YAML validation helpers
|
||||
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data and provenance state
|
||||
- `spec-review-service.ts`: daemon-owned structured review model for RigSpec/AgentSpec YAML, including topology preview data, provenance state, and managed-app services metadata (`waitFor`, `surfaces`, `composePreview`)
|
||||
|
||||
### Resolution pipeline
|
||||
|
||||
@@ -415,6 +486,15 @@ All rebooted services live under `packages/daemon/src/domain/`. The rule remains
|
||||
- `rigspec-exporter.ts`: dual-format live rig export back to YAML/JSON
|
||||
- `pod-repository.ts`: pod CRUD plus live continuity-state CRUD
|
||||
|
||||
### Rig environment and agent-managed software
|
||||
|
||||
- `compose-services-adapter.ts`: thin Compose-backed adapter for `up`, `ps`, `logs`, and teardown
|
||||
- `services-readiness.ts`: shared wait-target evaluator for `url`, `tcp`, and `service + condition: healthy`
|
||||
- `service-orchestrator.ts`: rig-scoped env boot, receipt capture, readiness gating, persisted receipt updates, and teardown
|
||||
- `rig-repository.ts`: now persists `rig_services` rows and surfaces `hasServices` in rig summaries for runtime/UI gating
|
||||
- `spec-library-service.ts`: classifies builtin/user specs and marks service-backed rigs with `hasServices`
|
||||
- `spec-review-service.ts`: lifts services metadata into review models; route layer enriches with compose preview
|
||||
|
||||
### Runtime adapters
|
||||
|
||||
Three runtime adapters implement the five-method contract: projection, startup delivery, harness launch, readiness, and installed-resource listing.
|
||||
@@ -429,18 +509,20 @@ Three runtime adapters implement the five-method contract: projection, startup d
|
||||
- launches via `codex`, resumes via `codex resume <threadId>`
|
||||
- readiness probe: polls for Codex ready indicator
|
||||
|
||||
**TerminalAdapter** (North Star)
|
||||
**TerminalAdapter**
|
||||
- no-op project/deliver/launch (shell IS the harness)
|
||||
- immediate readiness (shell is ready as soon as tmux session exists)
|
||||
- for infrastructure nodes: servers, log tails, build watchers
|
||||
|
||||
### Node inventory and operator surfaces (North Star)
|
||||
### Node inventory and operator surfaces
|
||||
|
||||
- `node-inventory.ts`: universal node-level projection. The single source of truth for node state consumed by CLI (`ps --nodes`), UI (explorer + graph + drawer), and MCP (`rigged_rig_nodes`). Core + extended field tiers.
|
||||
- `demo-rig-selector.ts`: existing-rig power-on helper — finds the right rig by name from ps summary
|
||||
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows
|
||||
- `context-usage-store.ts`: SQLite + state-dir persistence for per-node context usage
|
||||
- `context-monitor.ts`: daemon-owned monitor that records context usage for runtime/operator surfaces
|
||||
- `whoami-service.ts`: daemon-backed runtime identity projection used by `rig whoami` and adopted-session parity flows, now with context-usage access
|
||||
|
||||
### Transport and communication (Post-North-Star)
|
||||
### Transport and communication
|
||||
|
||||
- `session-transport.ts`: communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection, honest error reporting, pod/rig/global targeting
|
||||
- `transcript-store.ts`: pipe-pane transcript management — ANSI stripping on read, boundary markers, readTail, grep. Filesystem-backed, not SQLite.
|
||||
@@ -448,29 +530,29 @@ Three runtime adapters implement the five-method contract: projection, startup d
|
||||
- `ask-service.ts`: context engineering evidence pack — gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance. Does NOT call an external LLM.
|
||||
- `chat-repository.ts`: durable rig-scoped chat — CRUD for chat_messages table, SSE-compatible event emission
|
||||
|
||||
### Resume honesty (North Star)
|
||||
### Resume honesty
|
||||
|
||||
- `native-resume-probe.ts`: honest assessment of whether a harness actually resumed vs fresh-launched — probes pane content for runtime-specific indicators
|
||||
- `resume-metadata-refresher.ts`: post-launch resume token capture — reads Claude conversation IDs from `.claude/` state, Codex thread IDs from SQLite state files
|
||||
- `codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite database
|
||||
|
||||
### Discovery extensions (North Star)
|
||||
### Discovery extensions
|
||||
|
||||
- `draft-rig-generator.ts`: synthesize a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs
|
||||
- `claim-service.ts`: adopted-session integration service — claim/bind/create-and-bind flows, tmux metadata writes, and post-claim onboarding hint
|
||||
|
||||
### Final Stretch 1 authoring and library surfaces
|
||||
### Authoring, library, and identity surfaces
|
||||
|
||||
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review
|
||||
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, and reusable display components
|
||||
- `spec-library-service.ts`: filesystem-backed library index over builtin/user roots, classified via structured review and `hasServices`
|
||||
- `spec-review-service.ts`: canonical review model for raw YAML, library entries, reusable display components, and managed-app env details
|
||||
- `whoami-service.ts`: daemon-side identity surface with peers/edges/transcript helpers
|
||||
|
||||
### Snapshot, restore, and continuity
|
||||
|
||||
- `checkpoint-store.ts`: checkpoint persistence with pod/continuity context
|
||||
- `snapshot-capture.ts`: captures pods, continuity state, and startup replay context
|
||||
- `snapshot-capture.ts`: captures pods, continuity state, startup replay context, and latest env receipt
|
||||
- `snapshot-repository.ts`: snapshot CRUD
|
||||
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, and topology ordering
|
||||
- `restore-orchestrator.ts`: resume, checkpoint delivery, startup replay, live continuity consultation, topology ordering, and RigEnv boot gating before agent restore
|
||||
|
||||
### Bundles, bootstrap, and legacy compatibility
|
||||
|
||||
@@ -551,14 +633,14 @@ Restore now:
|
||||
- restore states: `resumed` / `rebuilt` / `fresh` / `failed`
|
||||
- failed resume is FAILED loudly — no automatic fresh fallback
|
||||
|
||||
### Auto-snapshot + existing-rig power-on (North Star)
|
||||
### Auto-snapshot and existing-rig power-on
|
||||
|
||||
- `rig down <rigId>` auto-captures an `auto-pre-down` snapshot before teardown
|
||||
- `rig up <rig-name>` (no file extension) searches for existing rig by name, restores from latest auto-pre-down snapshot
|
||||
- If no snapshot: error with guidance ("No saved snapshot for rig 'X'. Boot from a spec or bundle path.")
|
||||
- Post-command handoff: down output includes snapshot ID + restore command; up output includes node statuses + attach command
|
||||
|
||||
### Communication flow (Post-North-Star)
|
||||
### Communication flow
|
||||
|
||||
`rig send <session> "message"` → CLI → `POST /api/transport/send` → `SessionTransport`:
|
||||
1. Resolve session name (canonical or legacy, by session/rig/pod/global)
|
||||
@@ -567,7 +649,7 @@ Restore now:
|
||||
4. Optional `--verify`: capture post-send pane, check message visibility
|
||||
5. Honest result with reason on failure
|
||||
|
||||
### Transcript flow (Post-North-Star)
|
||||
### Transcript flow
|
||||
|
||||
1. `NodeLauncher` starts `pipe-pane` immediately after tmux session creation (before harness boot)
|
||||
2. Raw terminal output streams to `~/.openrig/transcripts/{rig-name}/{session-name}.log`
|
||||
@@ -576,7 +658,7 @@ Restore now:
|
||||
5. On restore: boundary marker written before re-launch. Pipe-pane reconnects to same file (append).
|
||||
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance
|
||||
|
||||
### Chat flow (Post-North-Star)
|
||||
### Chat flow
|
||||
|
||||
1. `rig chatroom send <rig> "message"` → `POST /api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`
|
||||
2. SSE stream: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages
|
||||
@@ -585,7 +667,7 @@ Restore now:
|
||||
5. MCP: `rigged_chatroom_send` + `rigged_chatroom_watch`
|
||||
6. Source of truth: daemon-backed SQLite (`chat_messages` table), not tmux scrollback
|
||||
|
||||
### Config + preflight flow (Post-North-Star)
|
||||
### Config and preflight flow
|
||||
|
||||
- `rig config` reads/writes `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys: `daemon.port`, `daemon.host`, `db.path`, `transcripts.enabled`, `transcripts.path`
|
||||
- Precedence: CLI flag > env var > config file > default
|
||||
@@ -593,11 +675,11 @@ Restore now:
|
||||
- Auto-preflight runs on `rig up` and daemon start
|
||||
- Every preflight error: what failed + why it matters + what to do (3-part pattern)
|
||||
|
||||
### Discovery-to-draft-rig flow (North Star)
|
||||
### Discovery-to-draft-rig flow
|
||||
|
||||
`rig discover --draft` → scan tmux sessions → group by CWD → suggest pod structure → generate candidate RigSpec YAML → output to stdout or file
|
||||
|
||||
### Spec review and spec library flow (Final Stretch 1)
|
||||
### Spec review and spec library flow
|
||||
|
||||
1. Raw YAML preview:
|
||||
- UI/CLI posts YAML to `/api/specs/review/rig` or `/api/specs/review/agent`
|
||||
@@ -606,12 +688,17 @@ Restore now:
|
||||
2. Filesystem-backed library:
|
||||
- `SpecLibraryService` scans builtin + user roots (`packages/daemon/specs`, `~/.openrig/specs`) with legacy fallback from `~/.rigged/specs`
|
||||
- each YAML file is classified via structured review
|
||||
- service-backed rigs are marked `hasServices`
|
||||
- `/api/specs/library` serves list/get/review/sync
|
||||
3. CLI:
|
||||
- `rig specs ls/show/preview/add/sync`
|
||||
- `rig up` / `rig bootstrap` resolve library names before falling back to other source kinds
|
||||
4. UI:
|
||||
- `Specs` drawer presents a unified library list with `All / Apps / Rigs / Agents`
|
||||
- service-backed entries render as `APP`
|
||||
- library review shows specialist identity, setup prompt, and environment details
|
||||
|
||||
### Whoami and adopted-session parity flow (Final Stretch 1)
|
||||
### Whoami and adopted-session parity flow
|
||||
|
||||
1. Managed sessions still prefer projected `OPENRIG_NODE_ID` / `OPENRIG_SESSION_NAME`.
|
||||
2. Adopted sessions use tmux-owned metadata written at claim/bind time:
|
||||
@@ -628,7 +715,7 @@ Restore now:
|
||||
- raw tmux session-name fallback
|
||||
4. The daemon owns the truth surface through `/api/whoami`; tmux metadata is an adopted-session anchor, not sovereign truth.
|
||||
|
||||
### Materialize / bind / adopt flow (Final Stretch 1)
|
||||
### Materialize / bind / adopt flow
|
||||
|
||||
1. `POST /api/rigs/import/materialize` creates a pod-aware topology without launching sessions.
|
||||
2. `POST /api/discovery/:id/bind` attaches a discovered live session to an existing logical node.
|
||||
@@ -640,7 +727,7 @@ Restore now:
|
||||
- `rig adopt`
|
||||
5. Authored pod namespace is preserved through adoption so logical ids stay `${podNamespace}.${memberName}`.
|
||||
|
||||
### Live identity / specs UI flow (Final Stretch 1)
|
||||
### Live identity / specs UI flow
|
||||
|
||||
1. Explorer/graph selection opens the shared right drawer.
|
||||
2. Node drawer stays runtime-first and now includes:
|
||||
@@ -656,6 +743,48 @@ Restore now:
|
||||
- live full-details surfaces
|
||||
5. Graph nodes carry lightweight hover/runtime hints without replacing drawer-first operation.
|
||||
|
||||
### Rig environment flow
|
||||
|
||||
1. Rig instantiation persists a `rig_services` record when a pod-aware `RigSpec` has a `services` block.
|
||||
2. `ServiceOrchestrator.boot(...)` runs before agent launch/restore:
|
||||
- Compose `up`
|
||||
- evaluate `waitFor`
|
||||
- persist `EnvReceipt`
|
||||
- fail honestly on timeout or unhealthy waits
|
||||
3. `GET /api/rigs/:rigId/env` returns live status:
|
||||
- `hasServices: false` for non-service rigs
|
||||
- otherwise kind, compose identity, receipt, and surfaces
|
||||
4. `rig env status/logs/down` and the rig drawer `Env` tab are thin client surfaces over those routes.
|
||||
5. Snapshot/restore preserves the latest env receipt and reboots services before agent restore.
|
||||
6. **Current runtime truth model:** Runtime env truth is provided through boot-time gating (services must be healthy before agents launch) and on-demand inspection via `rig env` / the `Env` tab, which polls with honest `probeStatus` (fresh/stale/no_orchestrator). Background health monitoring and `rig ps` env health summary are deferred.
|
||||
|
||||
### Agent-managed app flow
|
||||
|
||||
1. A managed app is a service-backed rig plus a specialist agent.
|
||||
2. The canonical shipped example is:
|
||||
- rig: `secrets-manager`
|
||||
- pod: `vault`
|
||||
- member: `specialist`
|
||||
- session: `vault-specialist@secrets-manager`
|
||||
3. Specs/UI flow:
|
||||
- browse `secrets-manager` under `Apps`
|
||||
- open library review
|
||||
- inspect specialist identity, env stack, health gates, and surfaces
|
||||
- copy the setup prompt
|
||||
4. Runtime flow:
|
||||
- launch the rig
|
||||
- services boot before `vault.specialist`
|
||||
- the rig drawer `Env` tab exposes state, health, surfaces, logs, and teardown
|
||||
|
||||
### Specialist interaction flow
|
||||
|
||||
1. Other humans or agents address a specialist by session name, e.g. `vault-specialist@secrets-manager`.
|
||||
2. Cross-rig communication uses the existing transport seam:
|
||||
- `rig send vault-specialist@secrets-manager "..." --verify`
|
||||
3. No new routing primitive exists here:
|
||||
- delegation is currently conventional/social
|
||||
- session transport remains the transport truth
|
||||
|
||||
---
|
||||
|
||||
## 7. Architecture Rules
|
||||
@@ -735,7 +864,7 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
|
||||
`createDaemon()` now does the following:
|
||||
|
||||
1. Open SQLite and run all 17 migrations.
|
||||
1. Open SQLite and run all 20 migrations.
|
||||
2. Construct core repositories and legacy services.
|
||||
3. Construct package/bootstrap/discovery services.
|
||||
4. Construct rebooted startup/runtime services:
|
||||
@@ -743,19 +872,24 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
- `ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`
|
||||
- `PodRigInstantiator`
|
||||
- `PodBundleSourceResolver`
|
||||
5. Construct North Star + Post-North-Star services:
|
||||
5. Construct rig environment services:
|
||||
- `ComposeServicesAdapter`
|
||||
- `ServiceOrchestrator`
|
||||
6. Construct operator, transport, and history services:
|
||||
- `TranscriptStore` (with config from env for transcript path/enabled)
|
||||
- `SessionTransport`
|
||||
- `ChatRepository`
|
||||
- `AskService` (with `HistoryQuery`)
|
||||
- `ResumeMetadataRefresher`
|
||||
- `NodeInventory`
|
||||
6. Construct Final Stretch 1 services:
|
||||
- `ResumeMetadataRefresher`
|
||||
- `ContextUsageStore`
|
||||
- `ContextMonitor`
|
||||
- `NodeInventory`
|
||||
7. Construct authoring, identity, and managed-app services:
|
||||
- `SpecReviewService`
|
||||
- `SpecLibraryService` (builtin + user roots)
|
||||
- `WhoamiService`
|
||||
7. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
8. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including specs review/library and `whoami`.
|
||||
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
9. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including env, specs review/library, and `whoami`.
|
||||
|
||||
---
|
||||
|
||||
@@ -763,25 +897,18 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
|
||||
### Test footprint
|
||||
|
||||
- daemon: 112 Vitest files / 1,461 tests
|
||||
- CLI: 29 Vitest files / 275 tests
|
||||
- UI: 35 Vitest files / 364 tests
|
||||
- total: 176 files / 2,100 tests
|
||||
- daemon: 127 Vitest files / 1,668 tests
|
||||
- CLI: 37 Vitest files / 366 tests
|
||||
- UI: 37 Vitest files / 388 tests
|
||||
- total: 201 files / 2,422 tests
|
||||
|
||||
### Verified during this doc refresh
|
||||
|
||||
- daemon: `1461/1461` passing
|
||||
- CLI: `275/275` passing
|
||||
- UI: `361/364` passing
|
||||
- daemon: `1668/1668` passing
|
||||
- CLI: `366/366` passing
|
||||
- UI: `388/388` passing
|
||||
|
||||
Current known UI failures during this refresh:
|
||||
- `design-compliance.test.tsx`
|
||||
- non-zero border-radius count still above zero
|
||||
- stale `StatusBar.tsx` file assumption in the mono/data-display assertion
|
||||
- `tailwind-foundation.test.tsx`
|
||||
- missing `.bg-card` assertion in generated CSS
|
||||
|
||||
### Post-North-Star test suites
|
||||
### Communication, identity, and authoring suites
|
||||
|
||||
In addition to reboot-era coverage:
|
||||
|
||||
@@ -804,9 +931,23 @@ In addition to reboot-era coverage:
|
||||
- `adopt.test.ts` — CLI materialize/bind adoption flow
|
||||
- UI tests for spec review displays, spec library review, specs panel, live node details, and adopted-session node identity
|
||||
|
||||
### Rig environment and agent-managed software suites
|
||||
|
||||
- `rigspec-services.test.ts` — RigSpec `services` schema and parsing
|
||||
- `service-orchestrator.test.ts` — Compose-backed env boot, readiness, receipt capture, teardown
|
||||
- `services-up.test.ts` — end-to-end service-backed rig boot
|
||||
- `services-snapshot-restore.test.ts` — env receipt capture + restore gating
|
||||
- `env-routes.test.ts` — runtime env status/logs/down route contracts
|
||||
- `spec-library-starters.test.ts` — builtin `secrets-manager` + `vault-specialist` presence, topology, and `hasServices`
|
||||
- `spec-review-service.test.ts` / `spec-library-routes.test.ts` — managed-app services review, wait-target contract, compose preview
|
||||
- CLI `env.test.ts` — env status/logs/down command surface
|
||||
- UI `specs-panel.test.tsx` — `All / Apps / Rigs / Agents`, type/stability badges
|
||||
- UI `library-review.test.tsx` — specialist card, setup prompt, managed-app environment tab
|
||||
- UI `rig-detail-panel.test.tsx` — runtime `Env` tab and env-state rendering
|
||||
|
||||
### Dogfood status
|
||||
|
||||
Post-North-Star + Final Stretch 1 dogfood verified:
|
||||
Core operator, communication, identity, and library flows verified:
|
||||
|
||||
- Full product loop: `rig up demo/rig.yaml` → harnesses launch → `rig ps --nodes` → `rig down` → `rig up demo-rig` (restore by name) → agents resume
|
||||
- Communication: `rig send`, `rig capture`, `rig broadcast` across running rig
|
||||
@@ -820,6 +961,25 @@ Post-North-Star + Final Stretch 1 dogfood verified:
|
||||
- Adoption: materialize + bind/adopt flows from discovery/specs workflows
|
||||
- MCP: all 17 tools verified
|
||||
|
||||
Rig environment and agent-managed software flows additionally verified:
|
||||
|
||||
- Managed-app browse flow in the UI:
|
||||
- `Specs` → `Apps` → `secrets-manager`
|
||||
- managed-app review with `vault.specialist`
|
||||
- `secrets-manager` launch as a real service-backed rig with a specialist agent
|
||||
- Real Vault operations through `vault-specialist@secrets-manager`
|
||||
- health check
|
||||
- write secret
|
||||
- read secret back
|
||||
- Cross-rig direct messaging to `vault-specialist@secrets-manager`
|
||||
- Runtime `Env` tab:
|
||||
- overall state
|
||||
- health gates
|
||||
- surfaces
|
||||
- logs
|
||||
- explicit teardown
|
||||
- Failure honesty when Vault is stopped mid-rig
|
||||
|
||||
---
|
||||
|
||||
## 11. Remaining Compatibility Notes
|
||||
@@ -834,6 +994,9 @@ These are the main intentional limits that still describe the shipped system:
|
||||
6. Chat is rig-scoped only — no cross-rig channels or DMs.
|
||||
7. `--verify` on `rig send` checks pane content for message visibility but can produce false positives from pre-existing matching content. Known limitation.
|
||||
8. Terminal node readiness is shell-ready only — no service health probes.
|
||||
9. `rig env down --volumes` exists in the CLI surface, but the explicit daemon-side override is not fully plumbed through yet.
|
||||
10. Managed-app service surfaces are descriptive only — OpenRig does not automatically inject service URLs/tokens into agent prompts beyond authored startup/context files.
|
||||
11. Specialist delegation is conventional, not automatic — other agents/humans address the specialist by session name or other normal communication surfaces.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenRig CLI Reference
|
||||
|
||||
Verified against the shipped CLI on 2026-04-07 using:
|
||||
Verified against the shipped CLI on 2026-04-09 using:
|
||||
- `packages/cli/src/index.ts`
|
||||
- `packages/cli/src/commands/*.ts`
|
||||
- `packages/cli/src/mcp-server.ts`
|
||||
@@ -11,9 +11,10 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
## Overview
|
||||
|
||||
- Binary: `rig`
|
||||
- Top-level command groups: `36`
|
||||
- Top-level command groups: `41`
|
||||
- Output mode: human-readable by default; many commands also support `--json`
|
||||
- Daemon-backed commands fail when the daemon is stopped or unhealthy; `daemon`, `config`, `preflight`, and `doctor` also have local responsibilities
|
||||
- Managed apps are launched through the normal spec/library surfaces; the canonical shipped example is `rig up secrets-manager`
|
||||
- Legacy surface still shipped: `package`
|
||||
|
||||
## Top-Level Commands
|
||||
@@ -31,11 +32,13 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `bootstrap` | Bootstrap a rig from a spec file |
|
||||
| `requirements` | Check requirements for a rig spec |
|
||||
| `discover` | Scan for unmanaged tmux sessions |
|
||||
| `attach` | Attach the current shell or agent into a rig node |
|
||||
| `bind` | Bind a discovered session to a rig node |
|
||||
| `adopt` | Materialize topology and bind discovered live sessions |
|
||||
| `bundle` | Manage rig bundles |
|
||||
| `up` | Bootstrap a rig from a spec or bundle |
|
||||
| `down` | Tear down a rig |
|
||||
| `env` | Inspect and control rig environment services |
|
||||
| `ps` | List rigs and their status |
|
||||
| `mcp` | MCP server for agent integration |
|
||||
| `agent` | Manage agent specs |
|
||||
@@ -51,11 +54,14 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `config` | Inspect and change OpenRig configuration |
|
||||
| `preflight` | Check system readiness for OpenRig |
|
||||
| `doctor` | Verify OpenRig install health |
|
||||
| `destroy` | Destroy OpenRig local state for recovery |
|
||||
| `expand` | Add a pod to a running rig |
|
||||
| `unclaim` | Release an adopted session without killing tmux |
|
||||
| `release` | Release claimed sessions from a rig |
|
||||
| `launch` | Launch or relaunch a node in a running rig |
|
||||
| `remove` | Remove a node from a running rig |
|
||||
| `shrink` | Remove an entire pod from a running rig |
|
||||
| `setup` | Prepare the machine for OpenRig |
|
||||
|
||||
## Core Daemon and System Commands
|
||||
|
||||
@@ -124,7 +130,24 @@ Usage: `rig doctor [--json]`
|
||||
|
||||
Notes:
|
||||
- Verifies install health for packaged/local CLI usage.
|
||||
- Checks daemon dist, UI dist, Node version, tmux availability, writable state paths, and daemon port availability.
|
||||
- Checks daemon dist, UI dist, Node version, `tmux`, optional `cmux` control health, writable state paths, and daemon port availability.
|
||||
- On macOS, also warns when tmux mouse mode appears disabled, gives the current-server fix (`tmux set -g mouse on`), and points to the persistent fix in `~/.tmux.conf`.
|
||||
- `cmux` issues are warnings, not hard failures. OpenRig still works without `cmux`; only `Open CMUX` workflows are unavailable.
|
||||
- `--json` is suitable for agent use and only exits non-zero on real failures, not warnings.
|
||||
|
||||
### `rig destroy`
|
||||
|
||||
Usage:
|
||||
- `rig destroy --state [--backup] --yes --confirm destroy-openrig-state`
|
||||
- `rig destroy --all [--backup] --yes --confirm destroy-openrig-state`
|
||||
|
||||
Notes:
|
||||
- This is the destructive recovery surface for polluted local OpenRig state.
|
||||
- `--state` stops the daemon, clears the active OpenRig listener on the configured port if needed, rotates or deletes the effective state root, and recreates an empty state root.
|
||||
- `--all` includes `--state` plus managed tmux session cleanup for sessions that are discoverable from the current OpenRig database.
|
||||
- `--backup` moves the state root aside to a collision-safe timestamped path such as `~/.openrig.backup-YYYYMMDD-HHMMSS`.
|
||||
- Managed tmux cleanup is intentionally conservative. It only removes sessions that are present in current DB state; unrelated tmux sessions are left alone.
|
||||
- Human output prints a compact destroy plan followed by the destroy result.
|
||||
|
||||
### `rig mcp`
|
||||
|
||||
@@ -159,7 +182,10 @@ Shipped MCP tools:
|
||||
Usage: `rig bootstrap <spec> [--plan] [--yes] [--json]`
|
||||
|
||||
Arguments:
|
||||
- `spec`: path to a rig spec YAML file
|
||||
- `spec`: path to a rig spec YAML file or a library name
|
||||
|
||||
Notes:
|
||||
- Bare names resolve through the spec library before falling back to the raw source value.
|
||||
|
||||
### `rig requirements`
|
||||
|
||||
@@ -168,6 +194,10 @@ Usage: `rig requirements <spec> [--json]`
|
||||
Arguments:
|
||||
- `spec`: path to a rig spec YAML file
|
||||
|
||||
Notes:
|
||||
- `rig requirements` is the spec/app-specific dependency surface.
|
||||
- Use `rig doctor` for host-level install health, then `rig requirements <spec>` for rig-specific requirements.
|
||||
|
||||
### `rig up`
|
||||
|
||||
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
|
||||
@@ -187,6 +217,7 @@ Current behavior notes:
|
||||
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
|
||||
- `local:` `agent_ref` values resolve relative to the rig spec directory, not the caller shell cwd.
|
||||
- If you copy a built-in spec to a new directory, keep its `agents/` tree beside it or rewrite those refs to `path:/absolute/path`.
|
||||
- Managed apps are first-class `up` targets. `rig up secrets-manager` launches the shipped Vault example from the library.
|
||||
|
||||
Success modes:
|
||||
- fresh boot
|
||||
@@ -206,6 +237,20 @@ Notes:
|
||||
- When `--snapshot` succeeds, human output includes the restore command.
|
||||
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
|
||||
|
||||
### `rig env`
|
||||
|
||||
Usage:
|
||||
- `rig env status <rig> [--json]`
|
||||
- `rig env logs <rig> [service] [--tail <n>]`
|
||||
- `rig env down <rig> [--volumes]`
|
||||
|
||||
Notes:
|
||||
- This surface is only meaningful for service-backed rigs and managed apps.
|
||||
- `status` resolves rig names or IDs and returns the env receipt with an honest freshness probe. The response includes `probeStatus` (fresh/stale/no_orchestrator) so operators can distinguish current truth from cached state.
|
||||
- `logs` proxies compose-backed service logs; `[service]` is optional.
|
||||
- `down` tears down the rig environment. `--volumes` overrides the stored down policy to force volume removal via `docker compose down --volumes`.
|
||||
- Note: `rig ps` does not yet surface env health. Runtime env truth is available through `rig env status` and the rig drawer `Env` tab.
|
||||
|
||||
### `rig ps`
|
||||
|
||||
Usage:
|
||||
@@ -312,9 +357,10 @@ Subcommands:
|
||||
- `rename <name-or-id> <new-name> [--json]`
|
||||
|
||||
Notes:
|
||||
- `specs` is the library surface.
|
||||
- `specs` is the library surface for rigs, agents, and managed apps.
|
||||
- `preview` returns structured review data from the daemon.
|
||||
- `add` validates the file as either RigSpec or AgentSpec before copying it into the user library.
|
||||
- `preview secrets-manager` is the canonical managed-app review example.
|
||||
|
||||
## Discovery and Topology Mutation
|
||||
|
||||
@@ -326,6 +372,18 @@ Notes:
|
||||
- Scans unmanaged tmux sessions.
|
||||
- `--draft` generates a candidate rig spec from the discovery set.
|
||||
|
||||
### `rig attach`
|
||||
|
||||
Usage:
|
||||
- `rig attach --self --rig <rigId> --node <logicalId> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
|
||||
- `rig attach --self --rig <rigId> --pod <namespace> --member <name> --runtime <runtime> [--cwd <path>] [--display-name <name>] [--print-env] [--json]`
|
||||
|
||||
Notes:
|
||||
- `--self` is currently required.
|
||||
- Node attach and pod-create attach are exclusive modes.
|
||||
- In tmux-backed shells, the command records tmux attachment metadata; otherwise it records an `external_cli` attachment.
|
||||
- `--print-env` prints shell exports for `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME`.
|
||||
|
||||
### `rig bind`
|
||||
|
||||
Usage: `rig bind <discoveredId> --rig <rigId> (--node <logicalId> | --pod <namespace> --member <name>)`
|
||||
@@ -364,6 +422,15 @@ Usage: `rig unclaim <sessionRef> [--json]`
|
||||
Notes:
|
||||
- Releases an adopted session without killing its tmux session.
|
||||
|
||||
### `rig release`
|
||||
|
||||
Usage: `rig release <rigId> [--delete] [--json]`
|
||||
|
||||
Notes:
|
||||
- Releases all claimed/adopted sessions from a rig without killing their tmux sessions.
|
||||
- `--delete` removes the rig record after a clean release.
|
||||
- OpenRig-launched nodes still require `rig down`.
|
||||
|
||||
### `rig launch`
|
||||
|
||||
Usage: `rig launch <rigId> <nodeRef> [--json]`
|
||||
@@ -487,6 +554,5 @@ These are not current top-level `rig` commands:
|
||||
- `rig claim`
|
||||
- `rig blame`
|
||||
- `rig replay`
|
||||
- `rig env`
|
||||
|
||||
If older docs or habits mention them, treat those references as stale.
|
||||
+40
-24
@@ -3,10 +3,10 @@
|
||||
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
|
||||
|
||||
Current footprint:
|
||||
- daemon: 133 source files (79 domain, 21 routes, 10 adapters, 17 migrations, 6 other)
|
||||
- CLI: 37 source files
|
||||
- UI: 83 source files
|
||||
- total: 253 source files
|
||||
- daemon: 150 source files (90 domain, 22 routes, 11 adapters, 20 migrations, 7 other)
|
||||
- CLI: 49 source files
|
||||
- UI: 87 source files
|
||||
- total: 286 source files
|
||||
|
||||
How to use this doc:
|
||||
- `Exports` lists the public symbols defined by the file.
|
||||
@@ -16,6 +16,7 @@ How to use this doc:
|
||||
Fast lookup:
|
||||
- Daemon app wiring and mounted HTTP surface: `packages/daemon/src/startup.ts`, `packages/daemon/src/server.ts`, `packages/daemon/src/routes/*`
|
||||
- AgentSpec / pod-aware engine: `packages/daemon/src/domain/agent-*`, `profile-resolver.ts`, `projection-planner.ts`, `startup-*`, `rigspec-*`, `pod-repository.ts`
|
||||
- Rig environment / managed-app substrate: `packages/daemon/src/adapters/compose-services-adapter.ts`, `packages/daemon/src/domain/service-orchestrator.ts`, `packages/daemon/src/domain/services-readiness.ts`, `packages/daemon/src/routes/env.ts`
|
||||
- Snapshot / restore / continuity: `packages/daemon/src/domain/snapshot-*`, `checkpoint-store.ts`, `restore-orchestrator.ts`
|
||||
- Transport / communication: `packages/daemon/src/domain/session-transport.ts`, `packages/daemon/src/routes/transport.ts`
|
||||
- Transcripts / persistence: `packages/daemon/src/domain/transcript-store.ts`, `packages/daemon/src/domain/history-query.ts`, `packages/daemon/src/routes/transcripts.ts`
|
||||
@@ -23,13 +24,15 @@ Fast lookup:
|
||||
- Chat: `packages/daemon/src/domain/chat-repository.ts`, `packages/daemon/src/routes/chat.ts`
|
||||
- Spec review / library / identity: `packages/daemon/src/domain/spec-review-service.ts`, `packages/daemon/src/domain/spec-library-service.ts`, `packages/daemon/src/domain/whoami-service.ts`, `packages/daemon/src/routes/spec-review.ts`, `packages/daemon/src/routes/spec-library.ts`, `packages/daemon/src/routes/whoami.ts`
|
||||
- Node inventory: `packages/daemon/src/domain/node-inventory.ts`
|
||||
- Context usage: `packages/daemon/src/domain/context-usage-store.ts`, `packages/daemon/src/domain/context-monitor.ts`
|
||||
- Resume honesty: `packages/daemon/src/domain/native-resume-probe.ts`, `resume-metadata-refresher.ts`, `codex-thread-id.ts`
|
||||
- Package / bootstrap / bundle flows: `packages/daemon/src/domain/package-*`, `install-*`, `bootstrap-*`, `bundle-*`
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (31 groups)
|
||||
- CLI command surface: `packages/cli/src/index.ts`, `packages/cli/src/commands/*` (40 groups)
|
||||
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
|
||||
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
|
||||
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
|
||||
- UI spec/library surfaces: `packages/ui/src/components/RigSpecDisplay.tsx`, `packages/ui/src/components/AgentSpecDisplay.tsx`, `packages/ui/src/components/LibraryReview.tsx`, `packages/ui/src/components/SpecsWorkspace.tsx`
|
||||
- UI managed-app runtime surfaces: `packages/ui/src/components/RigEnvPanel.tsx`, `packages/ui/src/hooks/useRigEnv.ts`, `packages/ui/src/lib/build-setup-prompt.ts`
|
||||
- UI live identity surfaces: `packages/ui/src/components/LiveIdentityDisplay.tsx`, `packages/ui/src/components/LiveNodeDetails.tsx`
|
||||
- UI data layer: `packages/ui/src/hooks/*`
|
||||
|
||||
@@ -38,14 +41,15 @@ Fast lookup:
|
||||
### Entrypoints And Wiring
|
||||
- `packages/daemon/src/index.ts`: CLI entrypoint for starting the daemon HTTP server. Exports: startServer. Related: `packages/daemon/src/startup.js`.
|
||||
- `packages/daemon/src/seed.ts`: Development/test seed entrypoint for populating sample daemon state. Exports: none. Related: `packages/daemon/src/db/connection.js`, `packages/daemon/src/db/migrate.js`, `packages/daemon/src/db/migrations/001_core_schema.js`, `packages/daemon/src/db/migrations/002_bindings_sessions.js`, +3 more.
|
||||
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 17 migrations, wires legacy, reboot, North Star, Post-North-Star, and Final Stretch 1 services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
- `packages/daemon/src/server.ts`: Defines `AppDeps`, enforces shared-DB invariants, and mounts the full public Hono route tree including dual-format rigspec import/export/materialize, bundles, transport/transcripts/ask/chat, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 20 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, and Final Stretch 3 managed-app services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
|
||||
### Adapters
|
||||
- `packages/daemon/src/adapters/claude-code-adapter.ts`: Adapter implementation for claude code integration. Exports: ClaudeAdapterFsOps, ClaudeCodeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/claude-resume.ts`: Resume adapter for claude sessions. Exports: ClaudeResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux-transport.ts`: Adapter/util module for cmux transport integration. Exports: createCmuxCliTransport. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/cmux.ts`: Adapter/util module for cmux integration. Exports: CmuxAdapter, CmuxResult, CmuxStatus, CmuxSurface, CmuxTransport, CmuxTransportFactory, CmuxWorkspace. Related: none.
|
||||
- `packages/daemon/src/adapters/compose-services-adapter.ts`: Compose-backed rig-environment adapter for `up`, `ps`, `logs`, and teardown. Exports: ComposePsService, ComposeServicesAdapter, ComposeServicesUpParams, ComposeServicesUpResult, ComposeServicesLogsParams, ComposeServicesLogsResult, ComposeServicesDownParams, ComposeServicesDownResult. Related: `packages/daemon/src/adapters/tmux-exec.js`.
|
||||
- `packages/daemon/src/adapters/codex-resume.ts`: Resume adapter for codex sessions. Exports: CodexResumeAdapter, ResumeResult. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`: Adapter implementation for codex runtime integration. Exports: CodexAdapterFsOps, CodexRuntimeAdapter. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/adapters/terminal-adapter.ts`: Infrastructure runtime adapter for terminal/shell nodes. No-op project/deliver/launch, immediate readiness. Exports: TerminalAdapter. Related: `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
@@ -75,19 +79,26 @@ Fast lookup:
|
||||
- `packages/daemon/src/db/migrations/015_startup_context.ts`: Database migration for startup context. Exports: startupContextSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/016_chat_messages.ts`: Database migration for durable rig-scoped chat messages. Exports: chatMessagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/017_pod_namespace.ts`: Database migration that persists authored pod namespace separately from pod DB id for export, adoption, and identity parity. Exports: podNamespaceSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
|
||||
### Domain: Core Runtime
|
||||
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
|
||||
- `packages/daemon/src/domain/event-bus.ts`: Persistent event log plus in-process pub/sub for routes, SSE, and domain emitters. Exports: EventBus. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/graph-projection.ts`: Pure projection from persisted rig/session state to React Flow graph nodes and edges. Exports: ReactFlowGraph, RigGraphInput, projectRigToGraph. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/context-monitor.ts`: Daemon-owned context-usage recorder that samples live runtime state and updates persistent usage summaries. Exports: ContextMonitor. Related: `packages/daemon/src/domain/context-usage-store.js`.
|
||||
- `packages/daemon/src/domain/context-usage-store.ts`: SQLite-backed store for per-node context usage snapshots used by whoami/node inventory surfaces. Exports: ContextUsageStore, ContextUsageSnapshot, ContextUsageStoreOpts. Related: none.
|
||||
- `packages/daemon/src/domain/node-launcher.ts`: Atomic node launch service that creates tmux, session rows, bindings, and launch events together. Exports: LaunchResult, NodeLauncher. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-name.js`, +2 more.
|
||||
- `packages/daemon/src/domain/ps-projection.ts`: Builds the `/api/ps` summary view over rigs, nodes, sessions, and latest snapshots. Exports: PsEntry, PsProjectionService. Related: none.
|
||||
- `packages/daemon/src/domain/reconciler.ts`: Marks missing tmux sessions as detached and keeps persisted session state aligned with reality. Exports: ReconcileResult, Reconciler. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, and edge state. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rig-repository.ts`: SQLite-backed repository for rig, node, edge, and rig-services state. Also projects `hasServices` into rig summaries for runtime/UI gating. Exports: RigRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rig-teardown.ts`: Graceful rig shutdown/delete orchestrator used by `/api/down`. Exports: RigTeardownOrchestrator, TeardownResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +2 more.
|
||||
- `packages/daemon/src/domain/service-orchestrator.ts`: Rig-scoped services orchestrator for env boot, readiness gating, receipt capture, and teardown. Exports: ServiceBootResult, ServiceOrchestrator, ServiceTeardownResult. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/services-readiness.js`.
|
||||
- `packages/daemon/src/domain/session-name.ts`: Tmux session-name derivation and validation helpers. Exports: deriveSessionName, validateSessionName. Related: none.
|
||||
- `packages/daemon/src/domain/session-registry.ts`: Session and binding lifecycle repository, including claimed-session registration and startup-status updates. Exports: SessionRegistry. Related: `packages/daemon/src/domain/session-name.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
|
||||
- `packages/daemon/src/domain/services-readiness.ts`: Shared readiness evaluator for RigEnv wait targets (`url`, `tcp`, `service + condition`). Exports: WaitTargetFailure, WaitTargetSuccess, evaluateWaitTargets. Related: none.
|
||||
- `packages/daemon/src/domain/types.ts`: Canonical daemon type layer for entities, events, legacy rig specs, rebooted RigSpec/AgentSpec topology, RigEnv/service contracts, startup, snapshots, and restore. Exports: AgentResources, AgentSpec, Binding, Checkpoint, ContinuityPolicySpec, ContinuityState, Edge, EnvCheckpoint, EnvReceipt, GuidanceResource, HookResource, ImportSpec, InstantiateOutcome, InstantiateResult, LegacyRigSpec, LegacyRigSpecEdge, LegacyRigSpecNode, LifecycleDefaults, Node, NodeStartupSnapshot, NodeWithBinding, PersistedEvent, PersistedProjectionEntry, Pod, PreflightResult, ProfileSpec, RestoreNodeResult, RestoreOutcome, RestoreResult, Rig, RigEvent, RigServicesRecord, RigServicesSpec, RigSpec, RigSpecCrossPodEdge, RigSpecPod, RigSpecPodEdge, RigSpecPodMember, RigWithRelations, RuntimeResource, Session, SkillResource, Snapshot, SnapshotData, StartupAction, StartupBlock, StartupFile, SubagentResource, ValidationResult. Related: none.
|
||||
- `packages/daemon/src/domain/up-command-router.ts`: Source router that classifies a path as a rig spec or rig bundle before bootstrap. Exports: RouteResult, SourceKind, UpCommandRouter. Related: `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
|
||||
### Domain: Specs, Resolution, Startup
|
||||
@@ -104,8 +115,8 @@ Fast lookup:
|
||||
- `packages/daemon/src/domain/rigspec-schema.ts`: Validation and normalization for both legacy and rebooted RigSpec schemas. Exports: LegacyRigSpecSchema, RigSpecSchema. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/startup-validation.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/runtime-adapter.ts`: Runtime-adapter contract plus bridge types used by startup orchestration and runtime-specific adapters. Exports: InstalledResource, NodeBinding, ProjectionResult, ReadinessResult, ResolvedStartupFile, RuntimeAdapter, StartupDeliveryResult. Related: `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/spec-validation-service.ts`: Pure validation helpers for raw AgentSpec and RigSpec YAML strings. Exports: validateAgentSpecFromYaml, validateRigSpecFromYaml. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, and provenance/source-state for preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/domain/spec-review-service.ts`: Structured review-model builder for RigSpec and AgentSpec YAML. Produces topology preview data, normalized summaries, provenance/source-state, and managed-app services metadata used by preview, library, and live display surfaces. Exports: AgentSpecReview, LegacyRigSpecReview, PodAwareRigSpecReview, RigSpecReview, RigSpecServicesReview, SourceState, SpecGraphData, SpecReviewError, SpecReviewService. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/spec-library-service.ts`: Filesystem-backed spec library index over builtin and user roots. Classifies YAML through `SpecReviewService`, assigns stable ids, marks service-backed rigs with `hasServices`, and serves metadata/YAML lookup. Exports: SpecLibraryEntry, SpecLibraryOpts, SpecLibraryService. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/domain/whoami-service.ts`: Daemon-side runtime identity projector used by `/api/whoami`. Resolves by node or session, expands peers/edges/transcript helpers, and surfaces ambiguity honestly. Exports: WhoamiResult, WhoamiAmbiguousError, WhoamiService. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/transcript-store.js`.
|
||||
- `packages/daemon/src/domain/startup-orchestrator.ts`: Startup execution engine for projection, startup-file delivery, action execution, readiness, and persisted replay context. Exports: StartupInput, StartupOrchestrator, StartupResult. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/projection-planner.js`, `packages/daemon/src/domain/runtime-adapter.js`, +2 more.
|
||||
- `packages/daemon/src/domain/startup-resolver.ts`: Pure additive startup-layer merge function across agent, profile, rig, pod, member, and operator layers. Exports: StartupLayerInputs, resolveStartup. Related: `packages/daemon/src/domain/types.js`.
|
||||
@@ -113,8 +124,8 @@ Fast lookup:
|
||||
|
||||
### Domain: Snapshots And Restore
|
||||
- `packages/daemon/src/domain/checkpoint-store.ts`: Checkpoint persistence, lookup, and rig-wide checkpoint aggregation. Exports: CheckpointStore. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, and topology ordering. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
|
||||
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, and startup context. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
|
||||
- `packages/daemon/src/domain/restore-orchestrator.ts`: Restore pipeline for relaunch, resume, checkpoint delivery, startup replay, topology ordering, and RigEnv boot gating before agent restore. Exports: RestoreOrchestrator. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/codex-resume.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/checkpoint-store.js`, +7 more.
|
||||
- `packages/daemon/src/domain/snapshot-capture.ts`: Captures a consistent snapshot of rig topology, sessions, checkpoints, pods, continuity state, startup context, and the latest rig-env receipt. Exports: SnapshotCapture. Related: `packages/daemon/src/domain/checkpoint-store.js`, `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, +3 more.
|
||||
- `packages/daemon/src/domain/snapshot-repository.ts`: SQLite-backed repository for snapshot CRUD and listing. Exports: SnapshotRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
|
||||
### Domain: Packages And Install Engine
|
||||
@@ -176,6 +187,7 @@ Fast lookup:
|
||||
- `packages/daemon/src/routes/bundles.ts`: Hono route module for dual-format bundle create/inspect/install. Supports legacy v1 package bundles and schema-version-2 pod bundles, including v2 inspect responses with `agents[]` and integrity data. Exports: bundleRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-assembler.js`, +7 more.
|
||||
- `packages/daemon/src/routes/discovery.ts`: Hono route module for discovery scan/list/detail plus Final Stretch flows: `draft-rig`, `bind`, and composite `adopt`. Exports: discoveryRoutes. Related: `packages/daemon/src/domain/claim-service.js`, `packages/daemon/src/domain/discovery-coordinator.js`, `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/draft-rig-generator.js`.
|
||||
- `packages/daemon/src/routes/down.ts`: Hono route module for down API endpoints. Exports: downRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rig-teardown.js`.
|
||||
- `packages/daemon/src/routes/env.ts`: Hono route module for rig-environment status, logs, and teardown. Refreshes env receipts best-effort and returns additive managed-app/runtime metadata (`hasServices`, `surfaces`, `receipt`). Exports: envRoutes. Related: `packages/daemon/src/adapters/compose-services-adapter.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/service-orchestrator.js`.
|
||||
- `packages/daemon/src/routes/events.ts`: Hono route module for events API endpoints. Exports: eventsRoute. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/routes/packages.ts`: Hono route module for the legacy package-install surface (`/validate`, `/plan`, `/install`, history, rollback). AgentSpec validation no longer lives here. Exports: packagesRoutes. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, +6 more.
|
||||
- `packages/daemon/src/routes/ps.ts`: Hono route module for ps API endpoints. Exports: psRoutes. Related: `packages/daemon/src/domain/ps-projection.js`.
|
||||
@@ -188,7 +200,7 @@ Fast lookup:
|
||||
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
|
||||
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Enriches service-backed reviews with best-effort compose preview data for managed-app UI surfaces. Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/whoami.ts`: Hono route module for daemon-backed runtime identity lookup by `nodeId` or `sessionName`. Exports: whoamiRoutes. Related: `packages/daemon/src/domain/whoami-service.js`.
|
||||
- `packages/daemon/src/routes/up.ts`: Hono route module for `/api/up`, routing direct rig specs, bundles, and rig-name restore into the staged bootstrap flow. Exports: upRoutes. Related: `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/up-command-router.js`.
|
||||
|
||||
@@ -197,7 +209,7 @@ Fast lookup:
|
||||
### Entrypoints And Infrastructure
|
||||
- `packages/cli/src/client.ts`: Shared HTTP client for talking to the daemon API, including raw JSON posts and text-body posts with optional extra headers such as `X-Rig-Root`. Exports: DaemonClient, DaemonConnectionError, DaemonResponse. Related: none.
|
||||
- `packages/cli/src/daemon-lifecycle.ts`: Starts, stops, inspects, and tails the local daemon process. Spawns the daemon with `process.execPath` and bypasses local `daemon.json` gating when `OPENRIG_URL` is set by probing remote `/healthz` directly. Exports: DaemonState, DaemonStatus, LOG_FILE, LifecycleDeps, OPENRIG_DIR, STATE_FILE, StartOptions, getDaemonPath, getDaemonStatus, readLogs, startDaemon, stopDaemon, tailLogs. Related: none.
|
||||
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (31 mounted command groups, including Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +18 more.
|
||||
- `packages/cli/src/index.ts`: Commander entrypoint that assembles the full `rig` CLI program (40 mounted command groups, including env status/logs/down, `destroy`, and Final Stretch 1 `specs`, `whoami`, `bind`, and `adopt`). Exports: ProgramDeps, createProgram. Related: `packages/cli/src/commands/bootstrap.js`, `packages/cli/src/commands/bundle.js`, `packages/cli/src/commands/claim.js`, `packages/cli/src/commands/daemon.js`, +19 more.
|
||||
- `packages/cli/src/mcp-server.ts`: Implements the CLI-hosted MCP server over stdio, wrapping 17 daemon-backed tools including node inventory, send/capture, bundle/spec validation, and chatroom. Exports: createMcpServer. Related: `packages/cli/src/client.js`.
|
||||
|
||||
### Commands
|
||||
@@ -209,6 +221,7 @@ Fast lookup:
|
||||
- `packages/cli/src/commands/daemon.ts`: Commander command module for `daemon`. Exports: daemonCommand, realDeps. Related: `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/discover.ts`: Commander command module for `discover`. Exports: discoverCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/down.ts`: Commander command module for `down`. Exports: downCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/env.ts`: Commander command module for `env` — rig environment status/logs/down for service-backed rigs and managed apps. Exports: envCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/commands/status.js`.
|
||||
- `packages/cli/src/commands/export.ts`: Commander command module for `export`. Exports: ExportDeps, exportCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/import.ts`: Commander command module for `import`. Locally parses YAML to detect pod-aware rigs, sends `X-Rig-Root` for pod-aware preflight/instantiate flows, and supports `--rig-root`. Exports: ImportDeps, importCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/mcp.ts`: Commander command module for `mcp`. Exports: mcpCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`, +1 more.
|
||||
@@ -219,9 +232,9 @@ Fast lookup:
|
||||
- `packages/cli/src/commands/rig.ts`: Commander command module for `rig`. Subcommands: `validate` (POST `/api/rigs/import/validate`), `preflight` (POST `/api/rigs/import/preflight` with `X-Rig-Root` header). Both support `--json`. Exports: RigDeps, rigCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/snapshot.ts`: Commander command module for `snapshot`. Exports: snapshotCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/status.ts`: Commander command module for `status`. Exports: StatusDeps, statusCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/specs.ts`: Commander command module for the agent-facing spec library workflow. Subcommands: `ls`, `show`, `preview`, `add`, `sync`, plus shared name resolution used by `up` and `bootstrap`. Help/examples and not-found guidance now explicitly call out managed apps. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/ui.ts`: Commander command module for `ui`. Exports: UiDeps, uiCommand. Related: `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
|
||||
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. Help/examples now treat managed apps like `secrets-manager` as first-class launch targets. Auto-preflight, auto-daemon-start, post-command handoff with attach commands. Exports: upCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/daemon-lifecycle.js`, `packages/cli/src/system-preflight.js`.
|
||||
- `packages/cli/src/commands/whoami.ts`: Commander command module for `whoami`. Resolves identity from explicit args, env vars, tmux metadata, and session-name fallback, then delegates to daemon `/api/whoami`. Exports: resolveIdentitySource, whoamiCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/adopt.ts`: Commander command module for `adopt` — materialize topology from a pod-aware RigSpec, then bind discovered live sessions according to `--bind` mappings. Exports: AdoptDeps, adoptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/import.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
@@ -273,19 +286,20 @@ Fast lookup:
|
||||
### Components: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/components/Explorer.tsx`: Hierarchical tree navigator — rigs → pods → agents with status indicators. Primary navigation surface. Click rig loads graph, click node opens detail drawer. Exports: Explorer. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/SharedDetailDrawer.tsx`: Unified drawer surface keyed by `DrawerSelection`. Routes to rig, node, system, discovery, or specs panels without leaving the current workspace. Exports: DrawerSelection, SharedDetailDrawer. Related: `packages/ui/src/components/DiscoveryPanel.js`, `packages/ui/src/components/NodeDetailPanel.js`, `packages/ui/src/components/RigDetailPanel.js`, `packages/ui/src/components/SpecsPanel.js`, `packages/ui/src/components/SystemPanel.js`.
|
||||
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots (relocated from standalone panel), Turn On/Off/Export/Snapshot actions, chat room tab. Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/RigDetailPanel.tsx`: Rig detail: identity, node summary, snapshots/actions, and runtime drawer tabs (`Info`, conditional `Env`, `Chat Room`). Exports: RigDetailPanel. Related: `packages/ui/src/hooks/useRigSummary.js`, `packages/ui/src/hooks/useSnapshots.js`, `packages/ui/src/hooks/useNodeInventory.js`.
|
||||
- `packages/ui/src/components/RigEnvPanel.tsx`: Runtime env surface for service-backed rigs. Shows overall env state, per-service health, health gates, surfaces, logs, and teardown action. Exports: RigEnvPanel. Related: `packages/ui/src/hooks/useRigEnv.js`.
|
||||
- `packages/ui/src/components/NodeDetailPanel.tsx`: Runtime-first node drawer. Shows rig/session/runtime identity, peers, directional edges, transcript helpers, compact spec summary, status, actions (tmux/cmux/resume/full-details), startup files, and recent events. Exports: NodeDetailPanel. Related: `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`, `packages/ui/src/lib/copy-text.js`, `packages/ui/src/lib/display-name.js`.
|
||||
- `packages/ui/src/components/NodeDetailPlaceholder.tsx`: Empty state for node detail when no node is selected. Exports: NodeDetailPlaceholder. Related: none.
|
||||
- `packages/ui/src/components/DiscoveryPanel.tsx`: Drawer-native discovery/adoption surface. Supports scan/list, draft-rig generation, placement-first bind/adopt, and pod/member targeting inside the shared drawer. Exports: DiscoveryPlacementTarget, DiscoveryPanel. Related: `packages/ui/src/hooks/useDiscovery.js`.
|
||||
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Lists library entries, opens review routes, and bridges into specs workspace tasks. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/SpecsPanel.tsx`: Drawer-native spec library and authoring launcher. Presents a unified library list with `All / Apps / Rigs / Agents`, type/stability badges, richer managed-app rows, and review-route handoff. Exports: SpecsPanel. Related: `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/RigChatPanel.tsx`: Chat room tab in rig drawer — send messages, watch live stream, view history. Exports: RigChatPanel. Related: `packages/ui/src/hooks/useRigChat.js`.
|
||||
- `packages/ui/src/components/LiveIdentityDisplay.tsx`: Reusable runtime-identity subsection for peers, edges, transcript helpers, and compact spec summary. Used by the node drawer and full-details route. Exports: LiveIdentityDisplay. Related: none.
|
||||
- `packages/ui/src/components/LiveNodeDetails.tsx`: Explicit center-workspace route for deep inspection of one live node. Composes runtime identity plus reusable spec display components. Exports: LiveNodeDetails. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/LiveIdentityDisplay.js`, `packages/ui/src/hooks/useNodeDetail.js`.
|
||||
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
|
||||
- `packages/ui/src/components/RigSpecDisplay.tsx`: Canonical RigSpec display primitive used by draft review, library review, and live/workspace inspection flows. Supports a library-only managed-app Environment tab with compose preview, health gates, and surfaces. Exports: RigSpecDisplay. Related: `packages/ui/src/components/SpecTopologyPreview.js`.
|
||||
- `packages/ui/src/components/AgentSpecDisplay.tsx`: Canonical AgentSpec display primitive used by preview, library, and live-details flows. Exports: AgentSpecDisplay. Related: none.
|
||||
- `packages/ui/src/components/RigSpecReview.tsx`: Route-level rig review page that consumes `RigSpecDisplay` and specs workspace state. Exports: RigSpecReview. Related: `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/AgentSpecReview.tsx`: Route-level agent review page that consumes `AgentSpecDisplay` and specs workspace state. Exports: AgentSpecReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useSpecReview.js`.
|
||||
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/LibraryReview.tsx`: Library-entry review page that renders reusable display components plus provenance/source-state. Service-backed rigs surface managed-app copy, specialist identity, setup-prompt copy, and env-aware review details. Exports: LibraryReview. Related: `packages/ui/src/components/AgentSpecDisplay.js`, `packages/ui/src/components/RigSpecDisplay.js`, `packages/ui/src/hooks/useSpecLibrary.js`.
|
||||
- `packages/ui/src/components/SpecTopologyPreview.tsx`: Lightweight topology preview for structured rig review models. Exports: SpecTopologyPreview. Related: none.
|
||||
- `packages/ui/src/components/SystemPanel.tsx`: Drawer surface for system-wide event log/status tabs. Replaces the old status-bar role with a richer right-drawer system surface. Exports: SystemPanel. Related: `packages/ui/src/hooks/useActivityFeed.js`.
|
||||
- `packages/ui/src/components/WorkspaceHome.tsx`: Landing page when no rig is selected. Entry point for explorer-first workflow. Exports: WorkspaceHome. Related: `packages/ui/src/components/AppShell.js`.
|
||||
@@ -315,16 +329,17 @@ Fast lookup:
|
||||
- `packages/ui/src/hooks/usePsEntries.ts`: `/api/ps` summary hook. Exports: PsEntry, usePsEntries. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEvents.ts`: Rig-scoped SSE subscription and invalidation hook. Exports: UseRigEventsResult, useRigEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useRigGraph.ts`: Rig graph fetch/transformation hook. Exports: useRigGraph. Related: none.
|
||||
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Exports: RigSummary, useRigSummary. Related: none.
|
||||
- `packages/ui/src/hooks/useRigSummary.ts`: Rig summary list hook used across dashboard and shell surfaces. Includes `hasServices` so runtime/UI can gate managed-app env surfaces. Exports: RigSummary, useRigSummary. Related: none.
|
||||
- `packages/ui/src/hooks/useSnapshots.ts`: Snapshot list hook for one rig. Exports: Snapshot, useSnapshots. Related: none.
|
||||
|
||||
### Hooks: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/hooks/useNodeInventory.ts`: Fetches node inventory for a rig from the canonical `GET /api/rigs/:rigId/nodes` endpoint. Exports: useNodeInventory. Related: none.
|
||||
- `packages/ui/src/hooks/useNodeDetail.ts`: Fetches extended node detail from `GET /api/rigs/:rigId/nodes/:logicalId`, including peers, edges, transcript helpers, and compact spec summary for the adopted-parity/live-identity drawer. Exports: NodeDetailData, useNodeDetail. Related: none.
|
||||
- `packages/ui/src/hooks/useRigEnv.ts`: Fetches `GET /api/rigs/:rigId/env` for service-backed rig status, surfaces, and receipt data, with runtime-friendly refetch behavior. Exports: RigEnvData, useRigEnv. Related: none.
|
||||
- `packages/ui/src/hooks/useRigChat.ts`: Chat send/watch/history hooks for the rig chat panel. Exports: useRigChat. Related: none.
|
||||
- `packages/ui/src/hooks/useGlobalEvents.ts`: Global SSE subscription for explorer status updates. Exports: useGlobalEvents. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecLibrary.ts`: Spec library list and library-review fetch hooks. Typed for `sourceState: "library_item"` review results and `hasServices`-aware managed-app classification. Exports: SpecLibraryEntry, LibraryReview, LibraryRigReview, LibraryAgentReview, useSpecLibrary, useLibraryReview. Related: none.
|
||||
- `packages/ui/src/hooks/useSpecReview.ts`: Structured spec-review hooks for raw YAML preview (`rig` and `agent`) over `/api/specs/review/*`. The rig review type now carries managed-app services metadata and compose preview. Exports: SpecGraphData, RigSpecReview, AgentSpecReview, useRigSpecReview, useAgentSpecReview. Related: none.
|
||||
|
||||
### Lib Utilities
|
||||
- `packages/ui/src/lib/edge-styles.ts`: Edge-style resolver for graph links. Exports: EdgeStyleResult, getEdgeStyle. Related: none.
|
||||
@@ -338,4 +353,5 @@ Fast lookup:
|
||||
### Lib Utilities: North Star + Post-North-Star Additions
|
||||
- `packages/ui/src/lib/display-id.ts`: Short ULID display utility — uses ULID tail (not head) for better uniqueness in same-view contexts. UI-only, no backend changes. Exports: shortId. Related: none.
|
||||
- `packages/ui/src/lib/display-name.ts`: Pod name extraction from qualified logical IDs (e.g., "dev1" from "dev1.impl"). Exports: displayPodName, inferPodName. Related: none.
|
||||
- `packages/ui/src/lib/build-setup-prompt.ts`: Managed-app setup-prompt generator used by library review copy surfaces. Exports: buildSetupPrompt. Related: none.
|
||||
- `packages/ui/src/lib/copy-text.ts`: Clipboard copy utility for the "Copy tmux attach" and "Copy resume command" buttons. Exports: copyText. Related: none.
|
||||
@@ -0,0 +1,411 @@
|
||||
# AgentSpec Reference
|
||||
|
||||
Version: 1.0
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/agent-manifest.ts`, `packages/daemon/src/domain/types.ts`
|
||||
|
||||
This is the canonical reference for the AgentSpec YAML format (`agent.yaml`). Every field, validation rule, and default documented here was traced from the actual parser and validator code.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Valid Example
|
||||
|
||||
```yaml
|
||||
name: my-agent
|
||||
version: "1.0"
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: []
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
|
||||
resources: {}
|
||||
|
||||
startup:
|
||||
files: []
|
||||
actions: []
|
||||
```
|
||||
|
||||
## Practical Example (implementer agent)
|
||||
|
||||
```yaml
|
||||
name: implementer
|
||||
version: "1.0"
|
||||
description: Implementation agent — writes code following TDD discipline
|
||||
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
|
||||
resources:
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/role.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
actions: []
|
||||
```
|
||||
|
||||
## Complete Example (all features)
|
||||
|
||||
```yaml
|
||||
name: vault-specialist
|
||||
version: "1.0"
|
||||
description: Vault specialist agent — manages HashiCorp Vault for this managed app
|
||||
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
model: claude-opus-4-6
|
||||
lifecycle:
|
||||
execution_mode: interactive_resident
|
||||
compaction_strategy: harness_native
|
||||
restore_policy: resume_if_possible
|
||||
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
- ref: local:../common-tools
|
||||
version: "2.0"
|
||||
|
||||
profiles:
|
||||
default:
|
||||
summary: Standard Vault operations profile
|
||||
preferences:
|
||||
runtime: claude-code
|
||||
uses:
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/profile-specific.md
|
||||
delivery_hint: send_text
|
||||
actions: []
|
||||
lifecycle:
|
||||
restore_policy: resume_if_possible
|
||||
|
||||
resources:
|
||||
skills:
|
||||
- id: vault-user
|
||||
path: skills/vault-user
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
hooks:
|
||||
- id: pre-commit
|
||||
path: hooks/pre-commit.sh
|
||||
runtimes: [claude-code]
|
||||
runtime_resources:
|
||||
- id: claude-settings
|
||||
path: runtime/claude-settings.json
|
||||
runtime: claude-code
|
||||
type: settings
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/role.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
- path: startup/context.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
- path: guidance/optional-tips.md
|
||||
delivery_hint: guidance_merge
|
||||
required: false
|
||||
applies_on: [fresh_start]
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "Load vault-user skill and verify Vault health."
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Top-Level Fields
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `name` | string | yes | — | Agent name. Used in spec library identification and validation messages. |
|
||||
| `version` | string | yes | — | Spec version. Informational — not used for compatibility gating. |
|
||||
| `description` | string | no | — | Human-readable description. Shown in spec library and review surfaces. |
|
||||
| `defaults` | Defaults | no | — | Default runtime, model, and lifecycle settings. Applied when not overridden by the rig spec or profile. |
|
||||
| `imports` | Import[] | no | `[]` | Other AgentSpecs to import. Resources from imported specs become available for profile `uses` references. |
|
||||
| `profiles` | map<string, Profile> | no | `{}` | Named profiles. Each profile selects resources and can override startup/lifecycle. The rig spec member's `profile` field selects which profile to use. |
|
||||
| `resources` | Resources | no | all empty | Declared resources (skills, guidance, subagents, hooks, runtime resources). These are the available pool that profiles select from via `uses`. |
|
||||
| `startup` | StartupBlock | no | `{ files: [], actions: [] }` | Agent-level startup files and actions. Applied to all profiles via the startup layering model. |
|
||||
|
||||
---
|
||||
|
||||
## Defaults
|
||||
|
||||
```yaml
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
model: claude-opus-4-6
|
||||
lifecycle:
|
||||
execution_mode: interactive_resident
|
||||
compaction_strategy: harness_native
|
||||
restore_policy: resume_if_possible
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `runtime` | string | no | — | Default runtime for this agent. Can be overridden by the rig spec member's `runtime` field. |
|
||||
| `model` | string | no | — | Default model. Can be overridden by the rig spec member's `model` field. |
|
||||
| `lifecycle` | Lifecycle | no | see below | Lifecycle behavior defaults. |
|
||||
|
||||
### Lifecycle Defaults
|
||||
|
||||
| Field | Type | Default | Allowed Values |
|
||||
|-------|------|---------|----------------|
|
||||
| `execution_mode` | string | `interactive_resident` | `interactive_resident` (only value in v1; `wake_on_demand` is explicitly rejected) |
|
||||
| `compaction_strategy` | string | `harness_native` | `harness_native`, `pod_continuity` (`custom_prompt` is explicitly rejected in v1) |
|
||||
| `restore_policy` | string | `resume_if_possible` | `resume_if_possible`, `relaunch_fresh`, `checkpoint_only` |
|
||||
|
||||
---
|
||||
|
||||
## Imports
|
||||
|
||||
```yaml
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
- ref: local:../common-tools
|
||||
version: "2.0"
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `ref` | string | yes | Reference to another AgentSpec directory. Must start with `local:` (relative) or `path:` (absolute). The referenced directory must contain an `agent.yaml`. |
|
||||
| `version` | string | no | Optional version constraint. Must be an exact version — no ranges (`~`, `^`, `>=`, etc.). |
|
||||
|
||||
### Import Resolution
|
||||
|
||||
- `local:` paths resolve relative to the importing spec's directory
|
||||
- `path:` paths are absolute filesystem paths
|
||||
- Imported resources become available for `uses` references in profiles
|
||||
- When referencing imported resources in `uses`, use qualified `namespace:id` format (e.g., `shared:openrig-user`)
|
||||
- Unqualified references (just `id`) resolve against the spec's own local resources first
|
||||
|
||||
### The Shared Import Pattern
|
||||
|
||||
Most built-in agents import the shared builtin spec:
|
||||
|
||||
```yaml
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
```
|
||||
|
||||
This gives access to the full pool of shared skills (openrig-user, mental-model-ha, development-team, etc.) and agents select the ones they need via profile `uses`.
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
```yaml
|
||||
profiles:
|
||||
default:
|
||||
summary: Standard operations profile
|
||||
preferences:
|
||||
runtime: claude-code
|
||||
model: claude-opus-4-6
|
||||
uses:
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
startup:
|
||||
files: []
|
||||
actions: []
|
||||
lifecycle:
|
||||
restore_policy: resume_if_possible
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `summary` | string | no | — | Profile description. |
|
||||
| `preferences` | object | no | — | Runtime/model preferences for this profile. |
|
||||
| `preferences.runtime` | string | no | — | Preferred runtime. |
|
||||
| `preferences.model` | string | no | — | Preferred model. |
|
||||
| `uses` | Uses | no | all empty | Selects which declared resources are active for this profile. |
|
||||
| `startup` | StartupBlock | no | — | Profile-level startup files and actions. Merged with agent-level startup via layering. |
|
||||
| `lifecycle` | Lifecycle | no | — | Profile-level lifecycle overrides. |
|
||||
|
||||
### Uses
|
||||
|
||||
The `uses` block selects which resources from the `resources` pool (including imported resources) are active for this profile.
|
||||
|
||||
```yaml
|
||||
uses:
|
||||
skills: [openrig-user, mental-model-ha, vault-user]
|
||||
guidance: [role]
|
||||
subagents: []
|
||||
hooks: [pre-commit]
|
||||
runtime_resources: [claude-settings]
|
||||
```
|
||||
|
||||
Each array contains resource IDs. These can be:
|
||||
- **Unqualified** (`vault-user`) — resolves against the spec's own `resources` first, then imported specs
|
||||
- **Qualified** (`shared:openrig-user`) — resolves against a specific imported spec's resources
|
||||
|
||||
The `uses` categories are: `skills`, `guidance`, `subagents`, `hooks`, `runtime_resources`.
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
```yaml
|
||||
resources:
|
||||
skills:
|
||||
- id: vault-user
|
||||
path: skills/vault-user
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
subagents:
|
||||
- id: helper
|
||||
path: subagents/helper
|
||||
hooks:
|
||||
- id: pre-commit
|
||||
path: hooks/pre-commit.sh
|
||||
runtimes: [claude-code]
|
||||
runtime_resources:
|
||||
- id: claude-settings
|
||||
path: runtime/claude-settings.json
|
||||
runtime: claude-code
|
||||
type: settings
|
||||
```
|
||||
|
||||
Resources are the available pool. They are NOT automatically delivered to agents — profiles select them via `uses`. The only resources delivered are those that the active profile's `uses` block references.
|
||||
|
||||
### Resource Categories
|
||||
|
||||
| Category | Fields | Description |
|
||||
|----------|--------|-------------|
|
||||
| `skills` | `id`, `path` | Skill directories containing a SKILL.md. Delivered via `skill_install`. |
|
||||
| `guidance` | `id`, `path`, `target`*, `merge`* | Guidance files. Delivered via `guidance_merge` into CLAUDE.md/AGENTS.md. |
|
||||
| `subagents` | `id`, `path` | Subagent definitions. |
|
||||
| `hooks` | `id`, `path`, `runtimes`* | Hook scripts. Optional `runtimes` array restricts to specific runtimes. |
|
||||
| `runtime_resources` | `id`, `path`, `runtime`, `type` | Runtime-specific resources. `runtime` and `type` are required. |
|
||||
|
||||
*Fields marked with `*` are optional.
|
||||
|
||||
### Resource Path Rules
|
||||
|
||||
- All resource paths must be safe relative paths (no `..` traversal, no absolute paths)
|
||||
- Paths resolve relative to the agent spec's directory
|
||||
- Resource IDs must be unique within their category
|
||||
- Resource IDs are the identifiers used in `uses` references
|
||||
|
||||
### Guidance Resources
|
||||
|
||||
```yaml
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
target: CLAUDE.md # optional — where to merge
|
||||
merge: managed_block # optional — how to merge (default: managed_block)
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Resource identifier. |
|
||||
| `path` | string | yes | — | Relative path to the guidance file. |
|
||||
| `target` | string | no | — | Target file for merging (e.g., `CLAUDE.md`). |
|
||||
| `merge` | string | no | `managed_block` | Merge strategy. One of: `managed_block`, `append`. |
|
||||
|
||||
---
|
||||
|
||||
## Startup Block
|
||||
|
||||
The startup block follows the same format as in the RigSpec (see `docs/reference/rig-spec.md` for full details on files and actions).
|
||||
|
||||
Agent-level startup is applied to all profiles. Profile-level startup is applied only when that profile is active. Both merge additively via the startup layering model.
|
||||
|
||||
### Delivery Hint Quick Reference
|
||||
|
||||
| Hint | When Delivered | Mechanism |
|
||||
|------|---------------|-----------|
|
||||
| `auto` | Before harness boot | System chooses based on file type |
|
||||
| `guidance_merge` | Before harness boot | Merged into CLAUDE.md/AGENTS.md as managed block |
|
||||
| `skill_install` | Before harness boot | Installed to runtime skill directory |
|
||||
| `send_text` | After harness is ready | Sent as text to agent terminal via tmux |
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
1. `name` and `version` are required non-empty strings.
|
||||
2. `imports` must be an array of objects with `ref` field.
|
||||
3. Import `ref` must start with `local:` (relative) or `path:` (absolute).
|
||||
4. Import `version` must be an exact version (no ranges).
|
||||
5. `profiles` must be a map (object), not an array.
|
||||
6. Profile `uses` references must resolve to declared resources (local or imported).
|
||||
7. Unqualified `uses` references that don't resolve to local resources require imports to be present.
|
||||
8. Qualified `uses` references must be in `namespace:id` format.
|
||||
9. All resource paths must be safe relative paths.
|
||||
10. Resource IDs must be unique within their category.
|
||||
11. `runtime_resources` entries require `runtime` field.
|
||||
12. Lifecycle `execution_mode` must be `interactive_resident`.
|
||||
13. Lifecycle `compaction_strategy` must be `harness_native` or `pod_continuity`.
|
||||
14. Lifecycle `restore_policy` must be `resume_if_possible`, `relaunch_fresh`, or `checkpoint_only`.
|
||||
15. Startup files and actions follow the same validation rules as in RigSpec.
|
||||
|
||||
---
|
||||
|
||||
## File System Layout
|
||||
|
||||
An agent spec directory follows this conventional layout:
|
||||
|
||||
```
|
||||
my-agent/
|
||||
agent.yaml # required — the AgentSpec
|
||||
guidance/
|
||||
role.md # role definition
|
||||
startup/
|
||||
context.md # boot-time grounding
|
||||
skills/
|
||||
my-skill/
|
||||
SKILL.md # skill content
|
||||
hooks/
|
||||
pre-commit.sh # hook script
|
||||
runtime/
|
||||
claude-settings.json # runtime-specific resource
|
||||
```
|
||||
|
||||
The only required file is `agent.yaml`. Everything else is referenced by paths in the spec and must exist at those relative paths.
|
||||
|
||||
---
|
||||
|
||||
## Shipped Examples
|
||||
|
||||
| Agent | Location | Imports | Profile Skills | Purpose |
|
||||
|-------|----------|---------|---------------|---------|
|
||||
| `shared` | `specs/agents/shared/` | none | — (resource pool only) | Shared skill pool for all built-in agents |
|
||||
| `implementer` | `specs/agents/development/implementer/` | `shared` | openrig-user, development-team, test-driven-development, mental-model-ha, etc. | TDD implementation agent |
|
||||
| `qa` | `specs/agents/development/qa/` | `shared` | openrig-user, development-team, etc. | Quality assurance agent |
|
||||
| `orchestrator` | `specs/agents/orchestration/orchestrator/` | `shared` | openrig-user, orchestration-team, etc. | Rig orchestration lead |
|
||||
| `reviewer` | `specs/agents/review/reviewer/` | `shared` | openrig-user, review-team, etc. | Independent code reviewer |
|
||||
| `vault-specialist` | `specs/agents/apps/vault-specialist/` | `shared` | openrig-user, mental-model-ha, vault-user | Vault domain specialist |
|
||||
| `design` | `specs/agents/design/` | none | — | Product designer |
|
||||
@@ -0,0 +1,358 @@
|
||||
# Agent Startup Guide
|
||||
|
||||
Version: 0.2.0
|
||||
Last validated: 2026-04-11
|
||||
Applies to: OpenRig 0.1.x
|
||||
|
||||
This guide teaches you how to think about what goes into an agent's startup experience — what files to write, where to put them, and how the layering model delivers them. It is an authoring guide, not a schema reference. For field-level details, see `rig-spec.md` and `agent-spec.md`.
|
||||
|
||||
---
|
||||
|
||||
## Two Categories of Startup
|
||||
|
||||
Everything an agent receives at boot time falls into one of two categories:
|
||||
|
||||
### Category 1: Context Loading
|
||||
|
||||
The agent reads markdown files into its context window. This is the **primary mechanism** in OpenRig today and the one you should invest most of your authoring effort in.
|
||||
|
||||
Context loading shapes what the agent knows, believes, and is capable of:
|
||||
- Who it is (role, identity, pod membership)
|
||||
- How the team works (culture, communication norms, coordination protocols)
|
||||
- What the project is (codebase context, architecture, domain knowledge)
|
||||
- What it can do (skills, SOPs, operational procedures)
|
||||
- What its environment looks like (services, access credentials, tools)
|
||||
|
||||
### Category 2: Deterministic Configuration
|
||||
|
||||
The rig spec declaratively installs things into the agent's runtime environment:
|
||||
- Hooks (git hooks, pre-commit scripts)
|
||||
- Permissions (`.claude/settings.json` allowlists, approval modes)
|
||||
- MCPs (Model Context Protocol servers)
|
||||
- System dependencies (tools, packages)
|
||||
|
||||
See the **Current Support Matrix** section at the end of this guide for what is reliable today vs experimental.
|
||||
|
||||
**The v0.2.0 recommendation:** Put as much of your setup logic as possible into Category 1 (context loading via markdown files). Describe the desired end state in startup files and let the agent handle the configuration. The deterministic path exists in the spec and will become more reliable over time, but for now the context-loading path is the one that works consistently across runtimes.
|
||||
|
||||
---
|
||||
|
||||
## Context Loading: What Goes Where
|
||||
|
||||
### Skills vs Startup Files — The Key Distinction
|
||||
|
||||
**Skills** are reusable SOPs. They teach an agent HOW to do something — how to use OpenRig, how to do TDD, how to run a code review, how to operate Vault. Skills transfer across rigs. A skill you write once can be used by any agent in any rig.
|
||||
|
||||
**Startup/guidance files** are rig-specific and role-specific. They tell an agent WHO it is, WHAT it's working on, and HOW this particular team operates. They are authored per-rig and often per-pod or per-member.
|
||||
|
||||
| Put in a skill when... | Put in a startup/guidance file when... |
|
||||
|------------------------|---------------------------------------|
|
||||
| The knowledge is reusable across rigs | The knowledge is specific to this rig or project |
|
||||
| It teaches a procedure or methodology | It teaches identity, role, or team context |
|
||||
| It could be useful to any agent of this type | It's only useful to agents in this specific topology |
|
||||
| Examples: `openrig-user`, `test-driven-development`, `vault-user` | Examples: `role.md`, `CULTURE.md`, `startup/context.md` |
|
||||
|
||||
### The Belt-and-Suspenders Pattern for Skills
|
||||
|
||||
Skills are delivered to agents through two parallel paths:
|
||||
|
||||
1. **Spec declaration:** The agent spec's `resources.skills` + profile `uses.skills` ensures the skill files are projected to the agent's workspace (installed before harness boot)
|
||||
2. **Startup instruction:** The guidance/startup files tell the agent to actually read and load those skills
|
||||
|
||||
Both paths are important. The spec projection ensures the files are physically present. The startup instruction ensures the agent knows to read them. This redundancy is intentional — it handles cases where one path fails.
|
||||
|
||||
Example: An implementer agent's startup guidance says "Load the following skills: openrig-user, test-driven-development, mental-model-ha" — AND the agent spec's profile uses those same skill IDs. The agent gets the files from projection and the instruction to read them from guidance.
|
||||
|
||||
### The File Types
|
||||
|
||||
**Role guidance (`guidance/role.md`)**
|
||||
|
||||
Tells the agent who it is and what its responsibilities are:
|
||||
- Title and primary function
|
||||
- Specific responsibilities (bulleted list)
|
||||
- Working rhythm (how the agent operates day-to-day)
|
||||
- Principles (behavioral guidelines)
|
||||
- Relationship to other team members
|
||||
|
||||
This is the agent's identity document. Every agent should have one.
|
||||
|
||||
**Rig culture (`CULTURE.md`)**
|
||||
|
||||
The rig-wide constitution. Applied to ALL agents in the rig:
|
||||
- Communication norms (use `rig send`/chatroom, not raw tmux)
|
||||
- Coordination protocol (how work flows between pods)
|
||||
- Quality standards (what "done" means)
|
||||
- Commit/merge policy
|
||||
- Escalation rules
|
||||
|
||||
Think of this as the team operating manual that every team member reads on day one.
|
||||
|
||||
**Startup context (`startup/context.md`)**
|
||||
|
||||
Boot-time grounding information specific to this agent's operating environment:
|
||||
- Identity recovery instructions (`rig whoami --json`)
|
||||
- Environment details (service URLs, access credentials, ports)
|
||||
- System check instructions (what should be running, how to verify)
|
||||
- Role-specific delegation information (who to ask, who delegates to you)
|
||||
|
||||
This is especially important for managed-app specialists (e.g., a Vault specialist needs to know the Vault address and dev token).
|
||||
|
||||
**Project documentation**
|
||||
|
||||
Rig-level startup files that teach the agent about the project:
|
||||
- Architecture overview
|
||||
- Key conventions
|
||||
- Domain vocabulary
|
||||
- Recent context (what's been happening, what's in progress)
|
||||
|
||||
These go in rig-level or pod-level startup blocks and are delivered to relevant agents.
|
||||
|
||||
---
|
||||
|
||||
## The Layering Model
|
||||
|
||||
Startup content is merged additively through layers. Each layer adds to what the previous layers provided. Later layers do NOT replace earlier layers — they append.
|
||||
|
||||
### The Layers (in delivery order)
|
||||
|
||||
```
|
||||
1. Agent layer — from the AgentSpec's top-level startup block
|
||||
2. Profile layer — from the active profile's startup block
|
||||
3. Rig layer — from the RigSpec's top-level startup block
|
||||
4. Culture layer — from the RigSpec's culture_file
|
||||
5. Pod layer — from the pod's startup block
|
||||
6. Member layer — from the member's startup block in the RigSpec
|
||||
7. Operator layer — injected at runtime (openrig-start overlay, context collector, etc.)
|
||||
```
|
||||
|
||||
### What Each Layer Is For
|
||||
|
||||
| Layer | Authored by | Purpose | Example content |
|
||||
|-------|-------------|---------|-----------------|
|
||||
| Agent | Agent spec author | Core identity and capabilities that travel with this agent type | Role guidance, default skills |
|
||||
| Profile | Agent spec author | Profile-specific variations | Different skill sets for "default" vs "minimal" profiles |
|
||||
| Rig | Rig spec author | Rig-wide context for all agents | Team norms, project documentation |
|
||||
| Culture | Rig spec author | Rig constitution | `CULTURE.md` — communication, quality, operating philosophy |
|
||||
| Pod | Rig spec author | Pod-specific coordination context | Pod SOP, intra-pod workflow |
|
||||
| Member | Rig spec author | Individual member overrides | Member-specific instructions, cwd-specific context |
|
||||
| Operator | OpenRig system | System-injected runtime content | `openrig-start.md`, context collector |
|
||||
|
||||
### Practical Guidance
|
||||
|
||||
**Most rigs only need three layers:** agent (role.md), rig (culture), and operator (openrig-start). Start simple. Add pod and member layers only when agents in the same pod need different startup content.
|
||||
|
||||
**The culture layer is high-value and often skipped.** A rig without a `CULTURE.md` relies on agents to guess how the team communicates and coordinates. Write one. Even a short culture file dramatically improves team coherence.
|
||||
|
||||
**Member-level startup is for exceptions, not the rule.** If every member has its own startup block, the layering model is being used as a configuration dump. Refactor shared content up to the pod or rig level.
|
||||
|
||||
---
|
||||
|
||||
## Delivery Mechanisms
|
||||
|
||||
### How Files Reach the Agent
|
||||
|
||||
| Delivery Hint | When | How | Use For |
|
||||
|---------------|------|-----|---------|
|
||||
| `auto` | Before harness boot | System chooses | Default — let OpenRig decide |
|
||||
| `guidance_merge` | Before harness boot | Merged into `CLAUDE.md` / `AGENTS.md` as a managed block | Role guidance, culture, project context |
|
||||
| `skill_install` | Before harness boot | Copied to runtime's skill directory | Skills |
|
||||
| `send_text` | After harness is ready | Sent as text to agent's terminal via tmux | Boot-time grounding, identity hints, instructions to read skills |
|
||||
|
||||
### Delivery Timing Matters
|
||||
|
||||
Files delivered via `guidance_merge` and `skill_install` happen BEFORE the agent's harness boots. The agent sees them immediately when it starts — they're part of the initial context.
|
||||
|
||||
Files delivered via `send_text` happen AFTER the harness is ready. The agent receives them as messages in its terminal. Use this for:
|
||||
- Identity grounding (agent reads and processes the instructions)
|
||||
- Instructions to load skills (the files are already projected, the message tells the agent to read them)
|
||||
- Context that should feel like an operator briefing, not pre-loaded content
|
||||
|
||||
### The `applies_on` Field
|
||||
|
||||
Each startup file and action specifies when it applies:
|
||||
- `fresh_start` — delivered on first launch only
|
||||
- `restore` — delivered on restore from snapshot
|
||||
- Default: `[fresh_start, restore]` (both)
|
||||
|
||||
Use this to avoid re-sending context that the agent already has from its resumed conversation. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both.
|
||||
|
||||
---
|
||||
|
||||
## Deterministic Configuration
|
||||
|
||||
The AgentSpec and RigSpec allow declaring deterministic environment configuration:
|
||||
|
||||
### What the Spec Supports
|
||||
|
||||
**Hooks** (in `resources.hooks`):
|
||||
```yaml
|
||||
resources:
|
||||
hooks:
|
||||
- id: pre-commit
|
||||
path: hooks/pre-commit.sh
|
||||
runtimes: [claude-code]
|
||||
```
|
||||
Hooks are scripts copied to the agent's workspace. They can be git hooks, automation scripts, or environment setup.
|
||||
|
||||
**Runtime resources** (in `resources.runtime_resources`):
|
||||
```yaml
|
||||
resources:
|
||||
runtime_resources:
|
||||
- id: claude-settings
|
||||
path: runtime/claude-settings.json
|
||||
runtime: claude-code
|
||||
type: settings
|
||||
```
|
||||
Runtime-specific configuration files projected into the agent's runtime environment.
|
||||
|
||||
**Startup actions** (in `startup.actions`):
|
||||
```yaml
|
||||
startup:
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "/install-mcp my-server"
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
```
|
||||
Commands sent to the agent's terminal after it's ready. Can install MCPs, run setup commands, etc.
|
||||
|
||||
### Current Support Matrix (OpenRig 0.1.x)
|
||||
|
||||
| Capability | Status | Notes |
|
||||
|------------|--------|-------|
|
||||
| Guidance file projection (`guidance_merge`) | **Supported** | Reliable. Primary delivery mechanism. |
|
||||
| Skill projection (`skill_install`) | **Supported** | Reliable. Skills are copied to workspace. |
|
||||
| `send_text` delivery after ready | **Supported** | Reliable. Requires harness to be ready. |
|
||||
| Hook projection | **Experimental** | Files are copied but execution/integration varies by runtime. |
|
||||
| Runtime resource projection | **Experimental** | Files are projected but runtime-specific handling is not guaranteed. |
|
||||
| Permission allowlisting | **Supported (manual)** | Works via `~/.claude/settings.json` or Codex launch flags. OpenRig can describe the desired state; agent self-configures. |
|
||||
| MCP installation | **Experimental** | Claude Code: `/mcp` interactive command or `claude mcp add` from CLI. Can also be described in startup files for agent self-configuration. Reliability depends on runtime TUI state. |
|
||||
| System dependency installation | **Not deterministic** | Describe in startup files; agent handles via shell commands. |
|
||||
| Recurring tasks / wake timers | **Runtime-dependent** | Claude Code supports recurring tasks via the `/loop` command. Codex does not have a confirmed equivalent. Orchestrators should include `/loop` instructions in startup for Claude Code agents. |
|
||||
|
||||
### The v0.2.0 Approach: Describe, Then Let the Agent Handle It
|
||||
|
||||
For anything beyond `guidance_merge`, `skill_install`, and `send_text`, the recommended approach is:
|
||||
|
||||
1. **Describe the desired end state** in a startup file (e.g., "You need these MCP servers configured, these permissions set, these hooks installed")
|
||||
2. **Include a system check** in the startup instructions ("Verify your environment: check that X is installed, Y is configured, Z is accessible")
|
||||
3. **Empower the agent to self-configure** ("If any of these are missing, install/configure them")
|
||||
4. **Optionally also declare it in the spec** for when deterministic support improves — the spec serves as the blueprint, and the startup file serves as the fallback instruction
|
||||
|
||||
This way, when deterministic support becomes fully reliable, the agent will boot up, see that everything is already set up (by the deterministic path), run its system check, confirm everything looks good, and proceed. Until then, the agent reads the instructions and handles the setup itself.
|
||||
|
||||
### Runtime Config Disclosure
|
||||
|
||||
OpenRig now performs some best-effort deterministic runtime configuration for managed sessions. These writes are intentionally invasive and should be disclosed plainly:
|
||||
|
||||
- Claude global config: `~/.claude/settings.json`
|
||||
Purpose: allow `rig` commands without repeated Claude permission prompts.
|
||||
- Claude global state: `~/.claude.json`
|
||||
Purpose: pre-trust managed workspaces and mark onboarding complete for fresh managed sessions.
|
||||
- Claude project-local config: `.claude/settings.local.json`
|
||||
Purpose: apply managed-session Claude permissions inside the project without committing them to git.
|
||||
- Claude project-local MCP config: `.mcp.json`
|
||||
Purpose: configure OpenRig-managed MCP servers for Claude in that project.
|
||||
- Codex global config: `~/.codex/config.toml`
|
||||
Purpose: pre-trust managed workspaces and configure Codex MCP servers. Codex currently has no equivalent project-local MCP config path.
|
||||
|
||||
Two important caveats:
|
||||
- these writes are best-effort and should still be paired with startup guidance so the local agent can verify and repair them if needed
|
||||
- already-running adopted sessions may need restart before they pick up newly written config
|
||||
|
||||
### Runtime Differences
|
||||
|
||||
**Claude Code:**
|
||||
- Reads from `CLAUDE.md` and `.claude/` directory
|
||||
- Recurring tasks via the `/loop` command (e.g., `/loop 5m "check rig health"`) — this is NOT hooks; hooks are event-driven
|
||||
- MCP server management via `/mcp` interactive command or `claude mcp add` from CLI
|
||||
- Event-driven hooks system in `.claude/settings.json` — reacts to events like `PreToolUse`, `PostToolUse`, `SessionStart`, etc. (not time-based)
|
||||
- Permission allowlisting via `.claude/settings.json` (`permissions.allow`, `permissions.deny`, `permissions.defaultMode`)
|
||||
- Can self-configure MCP servers, permissions, and hooks from startup instructions
|
||||
|
||||
**Codex:**
|
||||
- Reads from `AGENTS.md` and `.agents/` directory
|
||||
- Recurring task support is limited — no confirmed equivalent of Claude Code's `/loop` command
|
||||
- MCP configuration mechanism differs from Claude Code
|
||||
- Approval mode controlled via launch flags (`-a`, `-s`, `--full-auto`)
|
||||
- Can self-install dependencies from instructions but timer/recurring behavior is not reliably available
|
||||
|
||||
When authoring startup content, note which instructions are runtime-specific. For example, an orchestrator that needs a monitoring loop should include instructions like: "If running Claude Code, use `/loop 3m` to periodically check rig health. If running Codex, check rig health at the start of each task cycle instead."
|
||||
|
||||
---
|
||||
|
||||
## Patterns and Anti-Patterns
|
||||
|
||||
### Good Patterns
|
||||
|
||||
**Start with role + culture + one skill**
|
||||
```
|
||||
agent.yaml → guidance/role.md
|
||||
rig.yaml → culture_file: CULTURE.md
|
||||
profile → uses.skills: [openrig-user]
|
||||
```
|
||||
This is the minimum effective startup. The agent knows who it is, how the team works, and how to use the rig.
|
||||
|
||||
**Separate project context from role**
|
||||
|
||||
Don't put project documentation inside the role guidance. The role is about the agent's function; project context is about what the agent is working on. Use rig-level or pod-level startup files for project context.
|
||||
|
||||
**Tell the agent about its skills explicitly**
|
||||
|
||||
In a startup file or guidance, include a line like:
|
||||
```
|
||||
You have the following skills loaded: openrig-user, test-driven-development, mental-model-ha. Use them.
|
||||
```
|
||||
This prompts the agent to actually invoke the skills, not just have them as passive context.
|
||||
|
||||
**Include a system check in startup context**
|
||||
|
||||
```
|
||||
## System Check
|
||||
|
||||
After identity recovery, verify:
|
||||
1. `rig ps --nodes` shows your rig running
|
||||
2. `rig env status` shows services healthy (if applicable)
|
||||
3. Your working directory is correct
|
||||
4. Required tools are available (node, npm, git, etc.)
|
||||
|
||||
If anything is missing, fix it before starting work.
|
||||
```
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
**Dumping everything into one giant CLAUDE.md**
|
||||
|
||||
Don't. Use the layering model. Role goes in the agent spec. Culture goes in the rig spec. Project context goes in rig/pod startup files. If everything is in one file, you can't reuse any of it.
|
||||
|
||||
**Duplicating skill content in guidance files**
|
||||
|
||||
If you find yourself copying text from a skill into a guidance file, stop. Reference the skill instead. Skills are projected; guidance should point to them, not duplicate them.
|
||||
|
||||
**Using send_text for content that should be pre-loaded**
|
||||
|
||||
If the agent needs to know something BEFORE it starts reasoning, use `guidance_merge` (delivered before boot), not `send_text` (delivered after boot). `send_text` is for instructions the agent should process as a first task, not for foundational context.
|
||||
|
||||
**Over-specifying member-level startup**
|
||||
|
||||
If every member has a large startup block, the rig spec becomes a configuration dump. Refactor shared content up to the pod level. Member-level startup should be small overrides, not complete agent briefings.
|
||||
|
||||
**Relying on deterministic hooks for critical setup**
|
||||
|
||||
If your rig REQUIRES a hook to function and the hook installation fails silently, the agent won't know something is wrong. Always pair deterministic config with a startup instruction or system check that verifies the result.
|
||||
|
||||
---
|
||||
|
||||
## Authoring Checklist
|
||||
|
||||
When creating a new agent's startup experience:
|
||||
|
||||
- [ ] Write a `guidance/role.md` — who is this agent?
|
||||
- [ ] Reference it in the agent spec's `resources.guidance` AND `startup.files`
|
||||
- [ ] Write a rig `CULTURE.md` if the rig doesn't have one
|
||||
- [ ] Choose skills from the shared pool via profile `uses`
|
||||
- [ ] Write a `startup/context.md` if the agent needs environment grounding
|
||||
- [ ] Include a system check in the startup context
|
||||
- [ ] Verify the agent spec validates: `rig agent validate agent.yaml`
|
||||
- [ ] Verify the rig spec validates: `rig spec validate rig.yaml`
|
||||
- [ ] Test by launching the rig and checking that the agent received the expected content
|
||||
@@ -0,0 +1,126 @@
|
||||
# Edge Types Reference
|
||||
|
||||
Version: 0.2.0
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/rigspec-instantiator.ts`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Edges define relationships between members in a rig topology. They appear in two places:
|
||||
|
||||
- **Pod-local edges** — between members in the same pod (use unqualified member IDs)
|
||||
- **Cross-pod edges** — between members in different pods (use `pod.member` format)
|
||||
|
||||
See `docs/reference/rig-spec.md` for the YAML syntax.
|
||||
|
||||
## Edge Kinds
|
||||
|
||||
Five edge kinds are accepted by the validator:
|
||||
|
||||
| Kind | Accepted | Has Runtime Behavior | Description |
|
||||
|------|----------|---------------------|-------------|
|
||||
| `delegates_to` | yes | **yes — affects launch order** | Source delegates work to target |
|
||||
| `spawned_by` | yes | **yes — affects launch order** | Target was spawned by source |
|
||||
| `can_observe` | yes | no | Source can observe target's output |
|
||||
| `collaborates_with` | yes | no | Peer collaboration |
|
||||
| `escalates_to` | yes | no | Source escalates to target |
|
||||
|
||||
## What Edges Actually Do Today (OpenRig 0.1.x)
|
||||
|
||||
### Launch ordering
|
||||
|
||||
Only `delegates_to` and `spawned_by` affect runtime behavior. They constrain the order in which nodes are launched:
|
||||
|
||||
- **`delegates_to`**: source launches BEFORE target. The delegator must be up before the delegate.
|
||||
- **`spawned_by`**: target (parent) launches BEFORE source (child). The parent must be up before the child it spawned.
|
||||
|
||||
This ordering is enforced in both `PodRigInstantiator` (initial launch) and `RestoreOrchestrator` (restore from snapshot). The code uses topological sort over the dependency graph — if there's a cycle, instantiation fails.
|
||||
|
||||
All other edge kinds (`can_observe`, `collaborates_with`, `escalates_to`) do NOT constrain launch order.
|
||||
|
||||
### Graph visualization
|
||||
|
||||
All edges are rendered in the UI topology graph. They create visual connections between nodes, helping the operator understand the team structure. Pod-local edges appear within the pod group; cross-pod edges connect across groups.
|
||||
|
||||
### Identity projection
|
||||
|
||||
All edges appear in `rig whoami --json` output under `edges.outgoing` and `edges.incoming`, with their kind and the connected peer's identity. This lets an agent know its relationship to other members — but the agent interprets this information, it's not enforced by the runtime.
|
||||
|
||||
### Attach hint heuristic
|
||||
|
||||
The post-command handoff (the "Attach:" line after `rig up` and `rig restore`) uses edges to prefer the orchestrator as the default attach target. The heuristic looks for the first node with `delegates_to` outgoing edges.
|
||||
|
||||
## What Edges Do NOT Do Today
|
||||
|
||||
Edges do NOT currently:
|
||||
|
||||
- **Route messages** — `rig send` can target any session, regardless of edges
|
||||
- **Enforce delegation** — an agent can communicate with any peer, not just its edge targets
|
||||
- **Control permissions** — there's no edge-based access control
|
||||
- **Affect transport** — `rig capture`, `rig broadcast`, etc. work based on session identity, not edge topology
|
||||
|
||||
These are aspirational capabilities. The edge vocabulary is intentionally richer than the current runtime behavior so that the topology captures design intent even before the runtime enforces it.
|
||||
|
||||
## Design Intent (Why We Have Five Kinds)
|
||||
|
||||
The five kinds represent a taxonomy of how agents relate to each other in a working team:
|
||||
|
||||
**`delegates_to`** — the most common edge. An orchestrator delegates to an implementer. A lead delegates to a worker. This is the primary workflow direction. When an orchestrator sends a task, it uses `rig send` to the session it `delegates_to`.
|
||||
|
||||
**`spawned_by`** — for hierarchical launch relationships. A subagent spawned by a parent process. Less common in current topologies.
|
||||
|
||||
**`can_observe`** — the review/oversight relationship. A reviewer observes an implementer's work. The reviewer can `rig capture` and `rig transcript` the implementation session. This edge communicates intent: "I'm watching your output."
|
||||
|
||||
**`collaborates_with`** — peer relationships. Two agents working side-by-side at the same level of the hierarchy. Neither delegates to the other.
|
||||
|
||||
**`escalates_to`** — the reverse of delegation. When a worker hits a decision it can't make, it escalates to a lead. This is less common in current topologies but represents a real coordination pattern.
|
||||
|
||||
## Choosing Edge Kinds
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Orchestrator → workers:**
|
||||
```yaml
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: orch.lead
|
||||
to: dev.impl
|
||||
```
|
||||
|
||||
**Reviewer → implementer:**
|
||||
```yaml
|
||||
edges:
|
||||
- kind: can_observe
|
||||
from: rev.r1
|
||||
to: dev.impl
|
||||
```
|
||||
|
||||
**Implementation pair:**
|
||||
```yaml
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: impl
|
||||
to: qa
|
||||
```
|
||||
|
||||
### When You're Not Sure
|
||||
|
||||
If you're unsure which edge kind to use:
|
||||
|
||||
1. Does one agent give work to the other? → `delegates_to`
|
||||
2. Does one agent watch the other's output? → `can_observe`
|
||||
3. Are they equals working together? → `collaborates_with`
|
||||
4. Does one report problems up? → `escalates_to`
|
||||
5. Did one create the other? → `spawned_by`
|
||||
|
||||
If none fit, `can_observe` is the safest default — it documents the relationship without implying a workflow direction and doesn't affect launch ordering.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
1. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`
|
||||
2. Pod-local edges use unqualified member IDs — both `from` and `to` must exist in the same pod
|
||||
3. Cross-pod edges use `pod.member` format — both endpoints must resolve to actual members
|
||||
4. Cross-pod edges must reference different pods (same-pod edges should use the pod-local syntax)
|
||||
5. Self-edges are not validated at the schema level but are not meaningful
|
||||
@@ -0,0 +1,367 @@
|
||||
# RigBundle Reference
|
||||
|
||||
Version: 2 (pod-aware)
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/bundle-types.ts`, `packages/daemon/src/domain/bundle-archive.ts`, `packages/daemon/src/domain/pod-bundle-assembler.ts`
|
||||
|
||||
A `.rigbundle` is a self-contained distributable archive that packages a rig spec, all referenced agent specs, their resources (skills, guidance, startup files), culture file, documentation, and an integrity manifest into a single file. The recipient can install and launch the rig without needing the original source tree.
|
||||
|
||||
---
|
||||
|
||||
## Archive Format
|
||||
|
||||
A `.rigbundle` file is a gzip-compressed tar archive (`.tar.gz`) with a fixed structure.
|
||||
|
||||
### File extension
|
||||
|
||||
The archive MUST have the `.rigbundle` extension. The packer rejects output paths that don't end with `.rigbundle`.
|
||||
|
||||
### Sibling digest file
|
||||
|
||||
Every `.rigbundle` has a sibling `.rigbundle.sha256` file containing the SHA-256 hex digest of the archive. This detects corruption during transfer. The unpacker verifies this digest before extraction.
|
||||
|
||||
Example:
|
||||
```
|
||||
my-rig.rigbundle — the archive
|
||||
my-rig.rigbundle.sha256 — "a1b2c3d4..." (64-char hex SHA-256)
|
||||
```
|
||||
|
||||
### Determinism
|
||||
|
||||
The packer produces deterministic output:
|
||||
- Files are sorted alphabetically
|
||||
- A fixed mtime is used (`2026-01-01T00:00:00Z`)
|
||||
- Portable mode normalizes uid/gid/mode
|
||||
- Maximum gzip compression (level 9)
|
||||
|
||||
This means the same inputs always produce the same archive hash.
|
||||
|
||||
---
|
||||
|
||||
## Archive Layout
|
||||
|
||||
```
|
||||
bundle.yaml — manifest (required)
|
||||
rig.yaml — the RigSpec (required, may be rewritten)
|
||||
CULTURE.md — culture file (if declared in rig spec)
|
||||
SETUP.md — documentation (if declared in rig spec docs field)
|
||||
agents/
|
||||
agent-name/
|
||||
agent.yaml — AgentSpec
|
||||
guidance/
|
||||
role.md — guidance files
|
||||
skills/
|
||||
skill-name/
|
||||
SKILL.md — skill files
|
||||
startup/
|
||||
context.md — startup files
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- `bundle.yaml` is the manifest — always present, always at the root
|
||||
- `rig.yaml` is the rig spec — rewritten during assembly with vendored `agent_ref` paths
|
||||
- Agent directories are vendored copies of the original agent specs with all their resources
|
||||
- Import refs are rewritten from the original `local:` or `path:` paths to bundle-relative `local:` paths
|
||||
- All file paths within the archive are safe relative paths (no `..`, no absolute, no symlinks)
|
||||
|
||||
---
|
||||
|
||||
## Manifest (`bundle.yaml`)
|
||||
|
||||
The manifest is a YAML file at the archive root that describes the bundle contents.
|
||||
|
||||
### Schema Version 2 (pod-aware, current)
|
||||
|
||||
```yaml
|
||||
schema_version: 2
|
||||
name: my-bundle
|
||||
version: "0.1.0"
|
||||
created_at: "2026-04-11T22:32:48.570Z"
|
||||
rig_spec: rig.yaml
|
||||
agents:
|
||||
- name: pm-lead
|
||||
version: "1.0"
|
||||
path: agents/pm-lead
|
||||
original_ref: "local:agents/pm-lead"
|
||||
hash: "ed4cff20..."
|
||||
import_entries: []
|
||||
- name: researcher
|
||||
version: "1.0"
|
||||
path: agents/researcher
|
||||
original_ref: "local:agents/researcher"
|
||||
hash: "11f8a077..."
|
||||
import_entries:
|
||||
- name: shared
|
||||
version: "1.0"
|
||||
path: agents/shared
|
||||
original_ref: "local:../../shared"
|
||||
hash: "abc123..."
|
||||
culture_file: CULTURE.md
|
||||
integrity:
|
||||
algorithm: sha256
|
||||
files:
|
||||
rig.yaml: "b80c0674..."
|
||||
CULTURE.md: "9354361b..."
|
||||
agents/pm-lead/agent.yaml: "ed4cff20..."
|
||||
# ... every file in the archive
|
||||
```
|
||||
|
||||
### Manifest Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `schema_version` | number | yes | Must be `2` for pod-aware bundles. |
|
||||
| `name` | string | yes | Bundle name. |
|
||||
| `version` | string | yes | Bundle version. |
|
||||
| `created_at` | string | yes | ISO-8601 timestamp of creation. |
|
||||
| `rig_spec` | string | yes | Relative path to the rig spec within the archive. Safe relative path. |
|
||||
| `agents` | AgentEntry[] | yes | Array of vendored agent entries. |
|
||||
| `culture_file` | string | no | Relative path to the culture file if present. |
|
||||
| `integrity` | Integrity | no | Per-file SHA-256 checksums for content verification. |
|
||||
|
||||
### Agent Entry Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | yes | Agent name (from agent.yaml). |
|
||||
| `version` | string | no | Agent version. |
|
||||
| `path` | string | yes | Relative path to the vendored agent directory. Safe relative path. |
|
||||
| `original_ref` | string | yes | The original `agent_ref` before rewriting. |
|
||||
| `hash` | string | yes | SHA-256 hash of the agent.yaml content. |
|
||||
| `import_entries` | ImportEntry[] | yes | Vendored imports for this agent (may be empty). |
|
||||
|
||||
### Import Entry Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | yes | Imported agent name. |
|
||||
| `version` | string | yes | Imported agent version. |
|
||||
| `path` | string | yes | Relative path to the vendored import within the archive. |
|
||||
| `original_ref` | string | yes | The original import ref before rewriting. |
|
||||
| `hash` | string | yes | SHA-256 hash of the imported agent.yaml. |
|
||||
|
||||
### Integrity Section
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `algorithm` | string | yes | Must be `sha256`. |
|
||||
| `files` | map<string, string> | yes | Map of archive-relative file path → SHA-256 hex hash. Every file in the archive (except `bundle.yaml` itself) should be listed. |
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
Bundle integrity provides **self-consistency verification, not authenticity**.
|
||||
|
||||
- The sibling `.sha256` file detects corruption during transfer
|
||||
- The per-file integrity hashes detect tampering of individual files within the archive
|
||||
- Neither mechanism authenticates the bundle author
|
||||
|
||||
An attacker who can rewrite the full bundle + digest can bypass verification. Users must trust the source they obtained the bundle from. This is the same trust model as unsigned npm packages and Docker images.
|
||||
|
||||
Future enhancement: cryptographic signing (Ed25519) for author authentication.
|
||||
|
||||
---
|
||||
|
||||
## Safety Guarantees
|
||||
|
||||
The unpacker enforces these safety rules before extraction:
|
||||
|
||||
1. **No symlinks or hardlinks** — `SymbolicLink` and `Link` entries are rejected
|
||||
2. **No absolute paths** — entries starting with `/` are rejected
|
||||
3. **No path traversal** — entries containing `..` segments are rejected
|
||||
4. **Digest verification** — archive SHA-256 must match the sibling `.sha256` file
|
||||
5. **Content integrity** — after extraction, per-file hashes are verified against the manifest
|
||||
|
||||
If any check fails, extraction is aborted and an error is thrown.
|
||||
|
||||
---
|
||||
|
||||
## CLI Surface
|
||||
|
||||
### Create a bundle
|
||||
|
||||
```bash
|
||||
rig bundle create <spec-path> -o <output.rigbundle> [--rig-root <dir>] [--name <name>] [--bundle-version <ver>]
|
||||
```
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `<spec-path>` | yes | — | Path to the rig spec YAML file. |
|
||||
| `-o, --output` | yes | — | Output path. Must end with `.rigbundle`. |
|
||||
| `--rig-root` | no | spec directory | Root directory for resolving `agent_ref` and other relative paths. |
|
||||
| `--name` | no | `my-bundle` | Bundle name in the manifest. |
|
||||
| `--bundle-version` | no | `0.1.0` | Bundle version in the manifest. |
|
||||
|
||||
The create command:
|
||||
1. Validates the rig spec
|
||||
2. Resolves all `agent_ref` paths and their imports
|
||||
3. Vendors all agent specs, resources, and startup files into a staging directory
|
||||
4. Rewrites `agent_ref` paths to bundle-relative `local:` refs
|
||||
5. Collects culture file, docs files, and rig-level startup files
|
||||
6. Computes per-file integrity hashes
|
||||
7. Writes the manifest (`bundle.yaml`)
|
||||
8. Packs into a deterministic `.tar.gz`
|
||||
9. Writes the sibling `.sha256` digest
|
||||
|
||||
### Inspect a bundle
|
||||
|
||||
```bash
|
||||
rig bundle inspect <bundle-path> [--json]
|
||||
```
|
||||
|
||||
Shows the manifest, digest validity, and integrity verification result. Inspect extracts the archive into a temporary directory for safe validation, then cleans that directory up. It does not install the bundle.
|
||||
|
||||
### Install a bundle
|
||||
|
||||
```bash
|
||||
rig bundle install <bundle-path> [--plan] [--yes] [--target <root>] [--json]
|
||||
```
|
||||
|
||||
| Flag | Required | Default | Description |
|
||||
|------|----------|---------|-------------|
|
||||
| `<bundle-path>` | yes | — | Path to the `.rigbundle` file. |
|
||||
| `--plan` | no | `false` | Preview the bootstrap plan without installing or launching. |
|
||||
| `--yes` | no | `false` | Auto-approve trusted actions during apply mode. |
|
||||
| `--target <root>` | yes in apply mode | — | Target root directory for package installation. Required unless `--plan` is used. |
|
||||
| `--json` | no | `false` | Emit machine-readable JSON. |
|
||||
|
||||
Extracts the bundle, validates integrity, and bootstraps the rig. In apply mode, the daemon requires `targetRoot`, so `rig bundle install` must be given `--target <root>` unless you are running with `--plan`.
|
||||
|
||||
### Launch directly
|
||||
|
||||
```bash
|
||||
rig up <bundle-path> [--target <root>] [--cwd <dir>]
|
||||
```
|
||||
|
||||
`rig up` auto-detects `.rigbundle` files and routes them through the bundle bootstrap path.
|
||||
|
||||
- `--target <root>` controls where packaged files are installed
|
||||
- if `--target` is omitted for a `.rigbundle`, the CLI defaults the install target to the current working directory
|
||||
- `--cwd <dir>` does **not** change the install target; it only overrides the launched members' working directory for that run
|
||||
|
||||
---
|
||||
|
||||
## Assembly Process
|
||||
|
||||
When `rig bundle create` runs, the `PodBundleAssembler` performs these steps:
|
||||
|
||||
1. **Parse and validate** the rig spec
|
||||
2. **Collect rig-level files:**
|
||||
- Culture file (if `culture_file` is set)
|
||||
- Docs files (if `docs` array is set) — **required: missing docs fail assembly**
|
||||
- Rig-level startup files
|
||||
- Pod-level and member-level startup files
|
||||
3. **For each member's `agent_ref`:**
|
||||
- Resolve the ref to an agent spec directory
|
||||
- Copy the agent spec and all its resources (skills, guidance, hooks, startup, runtime resources)
|
||||
- Recursively resolve and copy imports
|
||||
- Record the agent entry in the manifest with its hash
|
||||
- Rewrite the ref to a bundle-relative `local:` path
|
||||
4. **Write the rewritten rig spec** to the staging directory
|
||||
5. **Compute integrity** — SHA-256 of every file in the staging directory
|
||||
6. **Write the manifest** (`bundle.yaml`) with all entries and integrity
|
||||
7. **Pack** the staging directory into a deterministic tar.gz
|
||||
|
||||
### Terminal nodes
|
||||
|
||||
Members with `agent_ref: "builtin:terminal"` are bundle-native sentinels. They are not vendored — the runtime handles them directly.
|
||||
|
||||
### Deduplication
|
||||
|
||||
If multiple members reference the same agent spec (same resolved path), the spec is vendored once and all members' refs are rewritten to the same bundle-relative path.
|
||||
|
||||
### Import resolution
|
||||
|
||||
When an agent spec has `imports`, each import is resolved, vendored into the bundle, and the import refs in the vendored agent.yaml are rewritten to bundle-relative `local:` paths. Import entries are recorded in the manifest's agent entry.
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
### Manifest validation (schema version 2)
|
||||
|
||||
1. `schema_version` must be `2`
|
||||
2. `name` is required non-empty string
|
||||
3. `version` is required non-empty string
|
||||
4. `created_at` is required non-empty string
|
||||
5. `rig_spec` is required and must be a safe relative path
|
||||
6. `agents` must be an array
|
||||
7. Each agent must have `name`, `path` (safe relative), and `hash`
|
||||
8. Integrity `algorithm` must be `sha256`
|
||||
9. Integrity `files` must be a non-empty map of safe-relative-path → 64-char hex hash
|
||||
|
||||
### Archive safety (enforced on unpack)
|
||||
|
||||
1. No symlinks or hardlinks
|
||||
2. No absolute paths
|
||||
3. No `..` path traversal
|
||||
4. Archive digest must match sibling `.sha256`
|
||||
5. Per-file content hashes must match integrity section
|
||||
|
||||
### Assembly validation
|
||||
|
||||
1. Rig spec must validate
|
||||
2. All `agent_ref` paths must resolve to valid agent specs
|
||||
3. All declared `docs` files must exist on disk (missing docs fail assembly)
|
||||
4. Culture file and startup files are collected best-effort (missing = skipped)
|
||||
|
||||
---
|
||||
|
||||
## Legacy Bundles (Schema Version 1)
|
||||
|
||||
Schema version 1 bundles are the pre-reboot format using flat-node rig specs and package-based bundling. They are still supported for backward compatibility but should not be created for new rigs.
|
||||
|
||||
Key differences from v2:
|
||||
- `schema_version: 1` in the manifest
|
||||
- `packages` array instead of `agents` array
|
||||
- Package entries have `original_source` instead of `original_ref`
|
||||
- No `import_entries` in package entries
|
||||
- Legacy rig spec format (flat nodes, not pods)
|
||||
|
||||
---
|
||||
|
||||
## Example: Creating and Using a Bundle
|
||||
|
||||
### Create
|
||||
|
||||
```bash
|
||||
# From the rig directory
|
||||
rig bundle create rig.yaml -o my-team.rigbundle --rig-root . --name my-team --bundle-version 1.0.0
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Bundle created: my-team.rigbundle
|
||||
Name: my-team v1.0.0
|
||||
Hash: a1b2c3d4e5f6...
|
||||
```
|
||||
|
||||
### Inspect
|
||||
|
||||
```bash
|
||||
rig bundle inspect my-team.rigbundle
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Bundle: my-team v1.0.0
|
||||
Digest valid: true
|
||||
Integrity: PASS
|
||||
```
|
||||
|
||||
### Install and launch
|
||||
|
||||
```bash
|
||||
cd ~/projects/my-project
|
||||
rig up /path/to/my-team.rigbundle
|
||||
```
|
||||
|
||||
Equivalent explicit form:
|
||||
|
||||
```bash
|
||||
rig up /path/to/my-team.rigbundle --target ~/projects/my-project
|
||||
```
|
||||
|
||||
The bundle is extracted to a temporary directory, integrity is verified, packaged files are installed into the target root, and the rig is bootstrapped with all agents and resources from the bundle. If you also want agents to launch with a different working directory for that run, pass `--cwd <dir>` separately.
|
||||
@@ -0,0 +1,479 @@
|
||||
# RigSpec Reference
|
||||
|
||||
Version: 0.2 (pod-aware)
|
||||
Last validated against code: 2026-04-11
|
||||
Source of truth: `packages/daemon/src/domain/rigspec-schema.ts`, `packages/daemon/src/domain/types.ts`
|
||||
|
||||
This is the canonical reference for the pod-aware RigSpec YAML format. Every field, validation rule, and default documented here was traced from the actual parser and validator code, not from prior documentation.
|
||||
|
||||
---
|
||||
|
||||
## Minimal Valid Example
|
||||
|
||||
```yaml
|
||||
version: "0.2"
|
||||
name: my-rig
|
||||
|
||||
pods:
|
||||
- id: dev
|
||||
label: Development
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: "local:agents/impl"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
edges: []
|
||||
|
||||
edges: []
|
||||
```
|
||||
|
||||
## Complete Example (all features)
|
||||
|
||||
```yaml
|
||||
version: "0.2"
|
||||
name: my-product-team
|
||||
summary: A full product squad with orchestration, development, and review pods.
|
||||
|
||||
culture_file: culture/CULTURE.md
|
||||
|
||||
docs:
|
||||
- path: SETUP.md
|
||||
- path: README.md
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/team-norms.md
|
||||
delivery_hint: guidance_merge
|
||||
required: true
|
||||
actions: []
|
||||
|
||||
services:
|
||||
kind: compose
|
||||
compose_file: docker-compose.yaml
|
||||
project_name: my-product
|
||||
profiles: [core]
|
||||
down_policy: down
|
||||
wait_for:
|
||||
- url: http://127.0.0.1:5432/health
|
||||
- service: redis
|
||||
condition: healthy
|
||||
surfaces:
|
||||
urls:
|
||||
- name: App
|
||||
url: http://127.0.0.1:3000
|
||||
commands:
|
||||
- name: psql
|
||||
command: "psql postgresql://app:[email protected]:5432/app"
|
||||
checkpoints:
|
||||
- id: postgres
|
||||
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
|
||||
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
|
||||
|
||||
pods:
|
||||
- id: orch
|
||||
label: Orchestration
|
||||
members:
|
||||
- id: lead
|
||||
agent_ref: "local:agents/orchestrator"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
- id: peer
|
||||
agent_ref: "local:agents/orchestrator"
|
||||
profile: default
|
||||
runtime: codex
|
||||
cwd: "."
|
||||
edges: []
|
||||
|
||||
- id: dev
|
||||
label: Development
|
||||
summary: Implementation and quality assurance pair.
|
||||
continuity_policy:
|
||||
enabled: true
|
||||
sync_triggers: [pre_compaction, pre_shutdown]
|
||||
artifacts:
|
||||
session_log: true
|
||||
restore_brief: true
|
||||
restore_protocol:
|
||||
peer_driven: true
|
||||
verify_via_quiz: false
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/dev-sop.md
|
||||
delivery_hint: guidance_merge
|
||||
required: true
|
||||
actions: []
|
||||
members:
|
||||
- id: impl
|
||||
agent_ref: "local:agents/impl"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
label: "Implementation Lead"
|
||||
model: claude-opus-4-6
|
||||
restore_policy: resume_if_possible
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/impl-specific.md
|
||||
delivery_hint: send_text
|
||||
required: false
|
||||
applies_on: [fresh_start]
|
||||
actions:
|
||||
- type: send_text
|
||||
value: "Load the implementation-pair skill and begin."
|
||||
phase: after_ready
|
||||
idempotent: true
|
||||
- id: qa
|
||||
agent_ref: "local:agents/qa"
|
||||
profile: default
|
||||
runtime: codex
|
||||
cwd: "."
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: impl
|
||||
to: qa
|
||||
|
||||
- id: rev
|
||||
label: Review
|
||||
members:
|
||||
- id: r1
|
||||
agent_ref: "local:agents/reviewer"
|
||||
profile: default
|
||||
runtime: claude-code
|
||||
cwd: "."
|
||||
- id: r2
|
||||
agent_ref: "local:agents/reviewer"
|
||||
profile: default
|
||||
runtime: codex
|
||||
cwd: "."
|
||||
edges: []
|
||||
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: orch.lead
|
||||
to: dev.impl
|
||||
- kind: delegates_to
|
||||
from: orch.peer
|
||||
to: dev.qa
|
||||
- kind: can_observe
|
||||
from: rev.r1
|
||||
to: dev.impl
|
||||
- kind: can_observe
|
||||
from: rev.r2
|
||||
to: dev.qa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Top-Level Fields
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `version` | string | yes | — | Must be `"0.2"` for pod-aware specs. |
|
||||
| `name` | string | yes | — | Rig name. Used in session naming (`{pod}-{member}@{name}`), snapshot identification, and spec library lookup. |
|
||||
| `summary` | string | no | — | Human-readable description. Shown in spec library, review surfaces, and `rig specs show`. |
|
||||
| `culture_file` | string | no | — | Relative path to a rig-wide culture/constitution file. Must be a safe relative path (no `..`, no absolute). |
|
||||
| `docs` | Doc[] | no | — | Documentation files that should travel with the rig. Included in rig bundles. Each entry has a `path` field (safe relative path). The engine does not consume these — they are for humans and agents setting up the environment before launch. |
|
||||
| `startup` | StartupBlock | no | — | Rig-level startup files and actions. Applied to all members via the startup layering model. |
|
||||
| `services` | ServicesBlock | no | — | Optional managed services (Docker Compose). When present, services boot before any agent launches. |
|
||||
| `pods` | Pod[] | yes | — | At least one pod required. Each pod is a bounded context containing members and pod-local edges. |
|
||||
| `edges` | CrossPodEdge[] | no | `[]` | Cross-pod edges connecting members in different pods. Must use fully-qualified `pod.member` IDs. |
|
||||
|
||||
---
|
||||
|
||||
## Pod
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Pod identifier. Must not contain dots. Must be unique within the rig. Used as the first segment of session names and logical IDs. |
|
||||
| `label` | string | yes | — | Human-readable pod name. Shown in UI explorer, graph groupings, and detail surfaces. |
|
||||
| `summary` | string | no | — | Pod description. |
|
||||
| `continuity_policy` | ContinuityPolicy | no | — | Pod-level continuity/restore policy. Controls compaction recovery, artifact management, and peer-driven restoration. |
|
||||
| `startup` | StartupBlock | no | — | Pod-level startup files and actions. Applied to all members in this pod via the startup layering model. |
|
||||
| `members` | Member[] | yes | — | At least one member required (enforced by pods needing content). |
|
||||
| `edges` | PodLocalEdge[] | no | `[]` | Edges between members within this pod. Must use unqualified member IDs (not `pod.member`). |
|
||||
|
||||
### Pod ID Rules
|
||||
|
||||
- Must not contain dots (`.`)
|
||||
- Must be unique across all pods in the rig
|
||||
- Becomes the first segment of the qualified logical ID: `{podId}.{memberId}`
|
||||
- Becomes the first segment of the canonical session name: `{podId}-{memberId}@{rigName}`
|
||||
|
||||
---
|
||||
|
||||
## Member
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `id` | string | yes | — | Member identifier. Must not contain dots. Must be unique within the pod. |
|
||||
| `agent_ref` | string | yes | — | Reference to an AgentSpec. Must start with `local:` (relative) or `path:` (absolute). Exception: `builtin:terminal` for infrastructure nodes. |
|
||||
| `profile` | string | yes | — | Profile name from the referenced AgentSpec. Use `default` for the default profile. Exception: `none` for terminal nodes. |
|
||||
| `runtime` | string | yes | — | Agent runtime. Current supported values: `claude-code`, `codex`, `terminal`. |
|
||||
| `cwd` | string | yes | — | Working directory for the agent. Resolved relative to the rig root (the directory containing the rig spec). Use `"."` for the rig root itself. Can be overridden at launch time with `rig up --cwd`. |
|
||||
| `label` | string | no | — | Human-readable member name. Shown in UI when present. |
|
||||
| `model` | string | no | — | Model override. Runtime-specific (e.g., `claude-opus-4-6` for Claude Code). |
|
||||
| `restore_policy` | string | no | `resume_if_possible` | Restore behavior. One of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`. |
|
||||
| `startup` | StartupBlock | no | — | Member-level startup files and actions. Applied only to this member. |
|
||||
|
||||
### Terminal Nodes
|
||||
|
||||
Terminal nodes are infrastructure processes (servers, log tails, build watchers) that are not agent runtimes. They require an exact triple:
|
||||
|
||||
```yaml
|
||||
runtime: terminal
|
||||
agent_ref: "builtin:terminal"
|
||||
profile: none
|
||||
```
|
||||
|
||||
All three must be present together. Any partial combination is a validation error.
|
||||
|
||||
### agent_ref Rules
|
||||
|
||||
- Must start with `local:` or `path:`
|
||||
- `local:` paths are relative to the rig spec file's directory (the rig root)
|
||||
- `path:` paths are absolute filesystem paths
|
||||
- The referenced path must contain an `agent.yaml` file
|
||||
- Exception: `builtin:terminal` for terminal nodes
|
||||
|
||||
### Session Naming
|
||||
|
||||
The canonical session name is derived from the pod ID, member ID, and rig name:
|
||||
|
||||
```
|
||||
{podId}-{memberId}@{rigName}
|
||||
```
|
||||
|
||||
Example: pod `dev`, member `impl`, rig `my-team` → session `dev-impl@my-team`
|
||||
|
||||
This is human-authored (you choose the pod/member IDs) and system-validated (the system enforces the format).
|
||||
|
||||
---
|
||||
|
||||
## Edges
|
||||
|
||||
### Edge Kinds
|
||||
|
||||
| Kind | Meaning | Use When |
|
||||
|------|---------|----------|
|
||||
| `delegates_to` | Source delegates work to target. Constrains launch order. | Orchestrator → implementer, lead → worker |
|
||||
| `spawned_by` | Target was spawned by source. Constrains launch order. | Parent → child in hierarchical topologies |
|
||||
| `can_observe` | Source can observe target's output. Does NOT constrain launch order. | Reviewer → implementer, monitor → worker |
|
||||
| `collaborates_with` | Peer collaboration relationship. Does NOT constrain launch order. | Co-equal peers working together |
|
||||
| `escalates_to` | Source escalates to target for decisions. Does NOT constrain launch order. | Worker → lead for escalation |
|
||||
|
||||
### Pod-Local Edges
|
||||
|
||||
Edges within a pod use **unqualified member IDs** (just the member `id`, not `pod.member`):
|
||||
|
||||
```yaml
|
||||
pods:
|
||||
- id: dev
|
||||
members:
|
||||
- id: impl
|
||||
# ...
|
||||
- id: qa
|
||||
# ...
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: impl # NOT dev.impl
|
||||
to: qa # NOT dev.qa
|
||||
```
|
||||
|
||||
Both `from` and `to` must reference members that exist in the same pod.
|
||||
|
||||
### Cross-Pod Edges
|
||||
|
||||
Edges between pods use **fully-qualified `pod.member` IDs**:
|
||||
|
||||
```yaml
|
||||
edges:
|
||||
- kind: delegates_to
|
||||
from: orch.lead # pod.member format
|
||||
to: dev.impl # pod.member format
|
||||
```
|
||||
|
||||
Cross-pod edges must reference different pods. An edge where both `from` and `to` are in the same pod is a validation error — use pod-local edges instead.
|
||||
|
||||
---
|
||||
|
||||
## Startup Block
|
||||
|
||||
Startup blocks can appear at three levels: rig, pod, and member. They are merged additively via the startup layering model (see `docs/reference/startup-layering.md`).
|
||||
|
||||
### Files
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `path` | string | yes | — | Relative path to the file. Must be a safe relative path. |
|
||||
| `delivery_hint` | string | no | `auto` | How the file is delivered. One of: `auto`, `guidance_merge`, `skill_install`, `send_text`. |
|
||||
| `required` | boolean | no | `true` | Whether startup fails if this file cannot be delivered. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this file is delivered. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
#### Delivery Hints
|
||||
|
||||
| Hint | Behavior |
|
||||
|------|----------|
|
||||
| `auto` | System chooses based on file type and context. |
|
||||
| `guidance_merge` | Merged into the runtime's guidance file (`CLAUDE.md` or `AGENTS.md`) as a managed block. Delivered before harness boot. |
|
||||
| `skill_install` | Installed as a skill in the runtime's skill directory. Delivered before harness boot. |
|
||||
| `send_text` | Sent as text to the agent's terminal after the harness is ready. Requires the agent TUI to be active. |
|
||||
|
||||
### Actions
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `type` | string | yes | — | Action type. One of: `slash_command`, `send_text`. Note: `shell` is explicitly NOT supported in v1. |
|
||||
| `value` | string | yes | — | The command or text to send. |
|
||||
| `phase` | string | no | `after_files` | When to execute. One of: `after_files` (after startup files are delivered), `after_ready` (after harness readiness check passes). |
|
||||
| `idempotent` | boolean | yes | — | Whether this action is safe to replay on restore. **Required field.** Non-idempotent actions must NOT include `restore` in `applies_on`. |
|
||||
| `applies_on` | string[] | no | `[fresh_start, restore]` | When this action runs. Subset of: `fresh_start`, `restore`. |
|
||||
|
||||
---
|
||||
|
||||
## Services Block
|
||||
|
||||
The services block is optional. When present, services boot before any agent node launches. If service health checks fail, agent launch is blocked.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `kind` | string | yes | — | Service backend. Only `compose` is supported in v1. |
|
||||
| `compose_file` | string | yes | — | Relative path to the Docker Compose file. Must be a safe relative path. Resolved relative to rig root. |
|
||||
| `project_name` | string | no | derived from rig name | Docker Compose project name. Must match `[a-z0-9][a-z0-9_-]*`. If omitted, derived by sanitizing the rig name. |
|
||||
| `profiles` | string[] | no | — | Compose profiles to activate. |
|
||||
| `down_policy` | string | no | `down` | What happens on `rig down`. One of: `leave_running`, `down`, `down_and_volumes`. |
|
||||
| `wait_for` | WaitTarget[] | no | — | Health targets that must pass before agent launch. |
|
||||
| `surfaces` | Surfaces | no | — | Metadata about accessible URLs and commands. Not executed — informational only. |
|
||||
| `checkpoints` | CheckpointHook[] | no | — | Shell commands for checkpoint export/import during snapshot/restore. |
|
||||
|
||||
### Wait Targets
|
||||
|
||||
Each target must define exactly one of `service`, `url`, or `tcp`:
|
||||
|
||||
```yaml
|
||||
wait_for:
|
||||
# HTTP probe — hits the URL, expects 2xx
|
||||
- url: http://127.0.0.1:8200/v1/sys/health
|
||||
|
||||
# TCP probe — connects to host:port
|
||||
- tcp: "127.0.0.1:5432"
|
||||
|
||||
# Compose health check — requires Docker health to report "healthy"
|
||||
- service: postgres
|
||||
condition: healthy
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `url` | string | one of three | HTTP URL to probe. |
|
||||
| `tcp` | string | one of three | `host:port` for TCP probe. |
|
||||
| `service` | string | one of three | Compose service name. Requires `condition: healthy`. |
|
||||
| `condition` | string | only with `service` | Must be `healthy`. Only valid with `service` targets. |
|
||||
|
||||
### Surfaces
|
||||
|
||||
```yaml
|
||||
surfaces:
|
||||
urls:
|
||||
- name: Vault UI
|
||||
url: http://127.0.0.1:8200/ui
|
||||
commands:
|
||||
- name: Vault status
|
||||
command: "vault status -address=http://127.0.0.1:8200"
|
||||
```
|
||||
|
||||
Surfaces are metadata only. They are displayed in the UI and in `rig env status` output but are NOT executed by OpenRig.
|
||||
|
||||
### Checkpoint Hooks
|
||||
|
||||
```yaml
|
||||
checkpoints:
|
||||
- id: postgres
|
||||
export: "docker compose exec -T postgres pg_dump -U app > {{artifacts_dir}}/postgres.sql"
|
||||
import: "cat {{artifacts_dir}}/postgres.sql | docker compose exec -T postgres psql -U app"
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | string | yes | Unique identifier for this checkpoint. |
|
||||
| `export` | string | yes | Shell command to export state. `{{artifacts_dir}}` is replaced with a daemon-managed path. |
|
||||
| `import` | string | no | Shell command to import state on restore. |
|
||||
|
||||
Checkpoint hooks are shell commands run by the daemon. They are best-effort — a failed export does not block snapshot, but continuity is classified as `receipt_only` instead of `checkpointed`.
|
||||
|
||||
---
|
||||
|
||||
## Continuity Policy
|
||||
|
||||
Optional pod-level configuration for compaction recovery behavior.
|
||||
|
||||
```yaml
|
||||
continuity_policy:
|
||||
enabled: true
|
||||
sync_triggers: [pre_compaction, pre_shutdown, manual, milestone]
|
||||
artifacts:
|
||||
session_log: true
|
||||
restore_brief: true
|
||||
quiz: false
|
||||
restore_protocol:
|
||||
peer_driven: true
|
||||
verify_via_quiz: false
|
||||
```
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `enabled` | boolean | yes | — | Whether continuity is active for this pod. |
|
||||
| `sync_triggers` | string[] | no | — | When to sync. Values: `pre_compaction`, `pre_shutdown`, `manual`, `milestone`. |
|
||||
| `artifacts.session_log` | boolean | no | — | Whether to maintain a session log. |
|
||||
| `artifacts.restore_brief` | boolean | no | — | Whether to maintain a restore brief. |
|
||||
| `artifacts.quiz` | boolean | no | — | Whether to use quiz-based verification. |
|
||||
| `restore_protocol.peer_driven` | boolean | no | — | Whether peers drive the restore process. |
|
||||
| `restore_protocol.verify_via_quiz` | boolean | no | — | Whether to verify restoration via quiz. |
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules Summary
|
||||
|
||||
These rules are enforced by the validator. A spec that violates any of these will be rejected by `rig spec validate` and `rig up`.
|
||||
|
||||
1. `version` and `name` are required non-empty strings.
|
||||
2. `pods` must be a non-empty array.
|
||||
3. Pod IDs must not contain dots and must be unique.
|
||||
4. Pod labels are required.
|
||||
5. Member IDs must not contain dots and must be unique within their pod.
|
||||
6. `agent_ref`, `profile`, `runtime`, and `cwd` are required for every member.
|
||||
7. Terminal nodes require the exact triple: `runtime: terminal`, `agent_ref: builtin:terminal`, `profile: none`.
|
||||
8. `agent_ref` must start with `local:` (relative) or `path:` (absolute), except `builtin:terminal`.
|
||||
9. `local:` refs must be relative paths. `path:` refs must be absolute paths.
|
||||
10. `restore_policy` must be one of: `resume_if_possible`, `relaunch_fresh`, `checkpoint_only`.
|
||||
11. Pod-local edges use unqualified member IDs. Cross-pod edges use `pod.member` format.
|
||||
12. Cross-pod edges must reference different pods.
|
||||
13. Edge kinds must be one of: `delegates_to`, `spawned_by`, `can_observe`, `collaborates_with`, `escalates_to`.
|
||||
14. All file paths (`culture_file`, startup file paths, `compose_file`) must be safe relative paths.
|
||||
15. `services.kind` must be `compose`.
|
||||
16. `services.compose_file` is required when services is present.
|
||||
17. `services.project_name` must match `[a-z0-9][a-z0-9_-]*`.
|
||||
18. `services.down_policy` must be one of: `leave_running`, `down`, `down_and_volumes`.
|
||||
19. Each wait target must define exactly one of: `service`, `url`, `tcp`.
|
||||
20. `condition` is only valid on `service` targets and must be `healthy`.
|
||||
21. Startup file `delivery_hint` must be one of: `auto`, `guidance_merge`, `skill_install`, `send_text`.
|
||||
22. Startup action `type` must be one of: `slash_command`, `send_text`. (`shell` is explicitly rejected.)
|
||||
23. Startup action `phase` must be one of: `after_files`, `after_ready`.
|
||||
24. Startup action `idempotent` is a required boolean.
|
||||
25. Non-idempotent actions must not include `restore` in `applies_on`.
|
||||
26. `applies_on` values must be from: `fresh_start`, `restore`.
|
||||
|
||||
---
|
||||
|
||||
## Shipped Examples
|
||||
|
||||
These are the built-in specs shipped with OpenRig. Read them as worked examples.
|
||||
|
||||
| Spec | Location | Pods | Members | Services |
|
||||
|------|----------|------|---------|----------|
|
||||
| `product-team` | `packages/daemon/specs/rigs/product-team.yaml` | orch, dev, rev | 7 (lead, peer, impl, qa, design, r1, r2) | no |
|
||||
| `implementation-pair` | `packages/daemon/specs/rigs/implementation-pair.yaml` | dev | 2 (impl, qa) | no |
|
||||
| `adversarial-review` | `packages/daemon/specs/rigs/adversarial-review.yaml` | orch, rev | 3 (lead, r1, r2) | no |
|
||||
| `research-team` | `packages/daemon/specs/rigs/research-team.yaml` | orch, research | 3 (lead, analyst, synthesizer) | no |
|
||||
| `secrets-manager` | `packages/daemon/specs/rigs/launch/secrets-manager/rig.yaml` | vault | 1 (specialist) | yes (Vault) |
|
||||
Generated
+15
-57
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.10",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
"packages/ui",
|
||||
@@ -470,13 +471,11 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -488,13 +487,11 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -506,13 +503,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -524,13 +519,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -542,13 +535,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -560,13 +551,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -578,13 +567,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -596,13 +583,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -614,13 +599,11 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -632,13 +615,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -650,13 +631,11 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -668,13 +647,11 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -686,13 +663,11 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -704,13 +679,11 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -722,13 +695,11 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -740,13 +711,11 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -758,13 +727,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -776,13 +743,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -794,13 +759,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -812,13 +775,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -830,13 +791,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -848,13 +807,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -866,13 +823,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -884,13 +839,11 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -902,13 +855,11 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -920,13 +871,11 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -4592,6 +4541,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -7379,7 +7334,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.12",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
@@ -7387,6 +7342,7 @@
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"commander": "^13.0.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"tar": "^7.5.13",
|
||||
"ulid": "^2.3.0",
|
||||
"yaml": "^2.8.3",
|
||||
@@ -7403,7 +7359,8 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.10",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
@@ -7423,7 +7380,8 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.10",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource-variable/space-grotesk": "^5.2.10",
|
||||
|
||||
+5
-2
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -10,7 +11,9 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"test": "npm run test --workspaces",
|
||||
"test": "npm run test:repo && npm run test:workspaces",
|
||||
"test:repo": "node --test scripts/*.test.mjs && node scripts/check-docs-guard.mjs",
|
||||
"test:workspaces": "npm run test --workspaces",
|
||||
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json",
|
||||
"build:package": "bash scripts/build-package.sh"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.12",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
@@ -45,6 +45,7 @@
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"commander": "^13.0.0",
|
||||
"hono": "^4.7.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"tar": "^7.5.13",
|
||||
"ulid": "^2.3.0",
|
||||
"yaml": "^2.8.3",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
|
||||
|
||||
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
|
||||
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
|
||||
const packageRoot = path.resolve(path.dirname(wrapperPath), "..");
|
||||
@@ -19,6 +22,51 @@ export function isDirectRun(argv1 = process.argv[1], moduleUrl = import.meta.url
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveNodeReexecBinary(
|
||||
currentExecPath = process.execPath,
|
||||
invokedPath = process.argv[1],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
exists: (candidate: string) => boolean = existsSync,
|
||||
realpath: (candidate: string) => string = realpathSync,
|
||||
): string | null {
|
||||
if (!invokedPath) return null;
|
||||
if (env[REEXEC_GUARD_ENV] === "1") return null;
|
||||
|
||||
try {
|
||||
const binDir = realpath(path.dirname(invokedPath));
|
||||
const siblingNode = path.join(binDir, process.platform === "win32" ? "node.exe" : "node");
|
||||
if (!exists(siblingNode)) return null;
|
||||
|
||||
const normalizedSiblingNode = realpath(siblingNode);
|
||||
const normalizedCurrentExec = realpath(currentExecPath);
|
||||
if (normalizedSiblingNode === normalizedCurrentExec) return null;
|
||||
|
||||
return normalizedSiblingNode;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function maybeReexecWithSiblingNode(argv = process.argv): void {
|
||||
const preferredNode = resolveNodeReexecBinary(process.execPath, argv[1], process.env);
|
||||
if (!preferredNode) return;
|
||||
|
||||
const scriptPath = argv[1] ? realpathSync(argv[1]) : fileURLToPath(import.meta.url);
|
||||
const result = spawnSync(preferredNode, [scriptPath, ...argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
[REEXEC_GUARD_ENV]: "1",
|
||||
},
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
export async function run(argv = process.argv): Promise<void> {
|
||||
const normalizedArgv = [...argv];
|
||||
if (normalizedArgv[1]) {
|
||||
@@ -31,5 +79,6 @@ export async function run(argv = process.argv): Promise<void> {
|
||||
}
|
||||
|
||||
if (isDirectRun()) {
|
||||
maybeReexecWithSiblingNode(process.argv);
|
||||
await run(process.argv);
|
||||
}
|
||||
+24
-19
@@ -14,6 +14,10 @@ export interface DaemonResponse<T = unknown> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface DaemonRequestOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface DaemonClientOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
@@ -42,51 +46,52 @@ export class DaemonClient {
|
||||
this.timeoutMs = options?.timeoutMs ?? 5_000;
|
||||
}
|
||||
|
||||
async get<T = unknown>(path: string): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "GET" });
|
||||
async get<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "GET" }, options);
|
||||
}
|
||||
|
||||
async getText(path: string): Promise<DaemonResponse<string>> {
|
||||
return this.requestText(path, { method: "GET" });
|
||||
async getText(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
return this.requestText(path, { method: "GET" }, options);
|
||||
}
|
||||
|
||||
async post<T = unknown>(path: string, body?: unknown): Promise<DaemonResponse<T>> {
|
||||
async post<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>): Promise<DaemonResponse<T>> {
|
||||
async postText<T = unknown>(path: string, text: string, contentType = "text/yaml", extraHeaders?: Record<string, string>, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": contentType, ...extraHeaders },
|
||||
body: text,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async postExpectText(path: string, body?: unknown): Promise<DaemonResponse<string>> {
|
||||
async postExpectText(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
return this.requestText(path, {
|
||||
method: "POST",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
async delete<T = unknown>(path: string): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" });
|
||||
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" }, options);
|
||||
}
|
||||
|
||||
private async fetch(path: string, init: RequestInit): Promise<Response> {
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
|
||||
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
||||
try {
|
||||
return await fetchWithTimeout(
|
||||
this.fetchImpl,
|
||||
`${this.baseUrl}${path}`,
|
||||
init,
|
||||
{
|
||||
timeoutMs: this.timeoutMs,
|
||||
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${this.timeoutMs}ms`,
|
||||
timeoutMs,
|
||||
timeoutMessage: `Request to ${this.baseUrl}${path} timed out after ${timeoutMs}ms`,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -95,14 +100,14 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson<T>(path: string, init: RequestInit): Promise<DaemonResponse<T>> {
|
||||
const res = await this.fetch(path, init);
|
||||
private async requestJson<T>(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
const res = await this.fetch(path, init, options);
|
||||
const data = (await res.json()) as T;
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
private async requestText(path: string, init: RequestInit): Promise<DaemonResponse<string>> {
|
||||
const res = await this.fetch(path, init);
|
||||
private async requestText(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<DaemonResponse<string>> {
|
||||
const res = await this.fetch(path, init, options);
|
||||
const data = await res.text();
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import path from "node:path";
|
||||
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
|
||||
|
||||
export const CMUX_SETTINGS_DISCLOSURE_PATH = "~/.config/cmux/settings.json";
|
||||
export const CMUX_DEFAULT_SOCKET_CONTROL_MODE = "cmuxOnly";
|
||||
|
||||
const CMUX_INCOMPATIBLE_MODES = new Set(["off", "cmuxOnly"]);
|
||||
|
||||
export interface CmuxSettingsReadResult {
|
||||
mode: string;
|
||||
source: "settings" | "default";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function resolveCmuxSettingsPath(homeDir = process.env["HOME"] ?? "~"): string {
|
||||
return path.join(homeDir, ".config", "cmux", "settings.json");
|
||||
}
|
||||
|
||||
export function isCmuxSocketControlCompatible(mode: string | null): boolean {
|
||||
return typeof mode === "string" && !CMUX_INCOMPATIBLE_MODES.has(mode);
|
||||
}
|
||||
|
||||
export function readCmuxSocketControlModeFromText(text: string | null): CmuxSettingsReadResult {
|
||||
if (!text || !text.trim()) {
|
||||
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
|
||||
}
|
||||
|
||||
const errors: ParseError[] = [];
|
||||
const parsed = parse(text, errors, { allowTrailingComma: true, disallowComments: false });
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
|
||||
source: "settings",
|
||||
error: formatParseErrors(errors),
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE,
|
||||
source: "settings",
|
||||
error: "cmux settings must contain a JSON object.",
|
||||
};
|
||||
}
|
||||
|
||||
const automation = parsed["automation"];
|
||||
if (!automation || typeof automation !== "object" || Array.isArray(automation)) {
|
||||
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
|
||||
}
|
||||
|
||||
const mode = automation["socketControlMode"];
|
||||
if (typeof mode === "string" && mode.trim()) {
|
||||
return { mode, source: "settings" };
|
||||
}
|
||||
|
||||
return { mode: CMUX_DEFAULT_SOCKET_CONTROL_MODE, source: "default" };
|
||||
}
|
||||
|
||||
export function upsertCmuxSocketControlMode(text: string | null, mode: string): { content: string; changed: boolean } {
|
||||
const source = text && text.trim() ? text : "{\n}\n";
|
||||
const edits = modify(source, ["automation", "socketControlMode"], mode, {
|
||||
formattingOptions: {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
eol: "\n",
|
||||
},
|
||||
});
|
||||
|
||||
if (edits.length === 0) {
|
||||
return { content: ensureTrailingNewline(source), changed: false };
|
||||
}
|
||||
|
||||
return {
|
||||
content: ensureTrailingNewline(applyEdits(source, edits)),
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(text: string): string {
|
||||
return text.endsWith("\n") ? text : `${text}\n`;
|
||||
}
|
||||
|
||||
function formatParseErrors(errors: ParseError[]): string {
|
||||
return errors
|
||||
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
|
||||
.join("; ");
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS = 120_000;
|
||||
|
||||
function logStageDetailErrors(data: Record<string, unknown>) {
|
||||
const stages = (data["stages"] as Array<{ stage: string; status: string; detail?: unknown }>) ?? [];
|
||||
for (const stage of stages) {
|
||||
@@ -107,7 +109,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
|
||||
sourceRef,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
|
||||
autoApprove: opts.yes ?? false,
|
||||
});
|
||||
}, { timeoutMs: LONG_RUNNING_BOOTSTRAP_TIMEOUT_MS });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
|
||||
@@ -140,12 +140,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
.description("Show daemon status")
|
||||
.action(async () => {
|
||||
const status = await getDaemonStatus(getDeps());
|
||||
const pidSuffix = status.pid !== undefined ? ` (pid ${status.pid})` : "";
|
||||
switch (status.state) {
|
||||
case "running":
|
||||
if (status.healthy === false) {
|
||||
console.log(`Daemon running on port ${status.port} (pid ${status.pid}) — healthz failed`);
|
||||
console.log(`Daemon running on port ${status.port}${pidSuffix} — healthz failed`);
|
||||
} else {
|
||||
console.log(`Daemon running on port ${status.port} (pid ${status.pid})`);
|
||||
console.log(`Daemon running on port ${status.port}${pidSuffix}`);
|
||||
}
|
||||
break;
|
||||
case "stopped":
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { getDefaultOpenRigPath, getPreferredOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
|
||||
import {
|
||||
DESTROY_CONFIRM_TOKEN,
|
||||
buildDestroyPlan,
|
||||
executeDestroy,
|
||||
findListeningPidWithLsof,
|
||||
killTmuxSessionWithCli,
|
||||
listManagedTmuxSessionsFromDb,
|
||||
type DestroyDeps,
|
||||
type ListenerInspection,
|
||||
type DestroyRuntimeConfig,
|
||||
type DestroyScope,
|
||||
} from "../destroy-helpers.js";
|
||||
import { stopDaemon } from "../daemon-lifecycle.js";
|
||||
|
||||
export interface DestroyCommandDeps {
|
||||
configStore: Pick<ConfigStore, "resolve">;
|
||||
destroyDeps: DestroyDeps;
|
||||
}
|
||||
|
||||
interface ResolvedDestroyRuntime {
|
||||
runtimeConfig: DestroyRuntimeConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function resolveRuntimeConfig(configStore: Pick<ConfigStore, "resolve">): ResolvedDestroyRuntime {
|
||||
const warnings: string[] = [];
|
||||
const stateRoot = getPreferredOpenRigHome();
|
||||
const defaultDbPath = getDefaultOpenRigPath("openrig.sqlite");
|
||||
const defaultTranscriptsPath = getDefaultOpenRigPath("transcripts");
|
||||
const effectiveDefaultDbPath = join(stateRoot, "openrig.sqlite");
|
||||
const effectiveDefaultTranscriptsPath = join(stateRoot, "transcripts");
|
||||
const overrideUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL")?.trim();
|
||||
if (overrideUrl) {
|
||||
warnings.push(`Ignoring ${overrideUrl} from OPENRIG_URL/RIGGED_URL because rig destroy only operates on local state.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = configStore.resolve();
|
||||
return {
|
||||
runtimeConfig: {
|
||||
stateRoot,
|
||||
dbPath: config.db.path === defaultDbPath ? effectiveDefaultDbPath : config.db.path,
|
||||
transcriptsPath: config.transcripts.path === defaultTranscriptsPath ? effectiveDefaultTranscriptsPath : config.transcripts.path,
|
||||
daemonHost: config.daemon.host,
|
||||
daemonPort: config.daemon.port,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to read config; using compatibility defaults instead: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return {
|
||||
runtimeConfig: {
|
||||
stateRoot,
|
||||
dbPath: effectiveDefaultDbPath,
|
||||
transcriptsPath: effectiveDefaultTranscriptsPath,
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function realDestroyDeps(): DestroyDeps {
|
||||
const lifecycleDeps = realDeps();
|
||||
return {
|
||||
stopDaemon: async () => stopDaemon(lifecycleDeps),
|
||||
inspectListener: async (host: string, port: number): Promise<ListenerInspection> => {
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/healthz`);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: "other_http",
|
||||
healthy: false,
|
||||
detail: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
return {
|
||||
kind: "other_http",
|
||||
healthy: false,
|
||||
detail: "Response body was not valid OpenRig health JSON.",
|
||||
};
|
||||
}
|
||||
|
||||
if (body && typeof body === "object" && (body as Record<string, unknown>).status === "ok") {
|
||||
return { kind: "openrig", healthy: true };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "other_http",
|
||||
healthy: false,
|
||||
detail: "Response body did not match the OpenRig health contract.",
|
||||
};
|
||||
} catch {
|
||||
return { kind: "unreachable" };
|
||||
}
|
||||
},
|
||||
findListeningPid: findListeningPidWithLsof,
|
||||
killProcess: (pid: number) => {
|
||||
process.kill(pid, "SIGTERM");
|
||||
},
|
||||
exists: existsSync,
|
||||
renamePath: (from: string, to: string) => renameSync(from, to),
|
||||
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
|
||||
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
|
||||
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
|
||||
killTmuxSession: killTmuxSessionWithCli,
|
||||
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
now: () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function printPlan(
|
||||
scope: DestroyScope,
|
||||
backup: boolean,
|
||||
runtimeConfig: DestroyRuntimeConfig,
|
||||
managedTmuxSessions: string[],
|
||||
warnings: string[]
|
||||
): void {
|
||||
console.log("DESTROY PLAN");
|
||||
console.log(` scope: ${scope}`);
|
||||
console.log(` state root: ${runtimeConfig.stateRoot}`);
|
||||
console.log(` daemon: http://${runtimeConfig.daemonHost}:${runtimeConfig.daemonPort}`);
|
||||
console.log(` backup: ${backup ? "enabled" : "disabled"}`);
|
||||
console.log(` tmux cleanup: ${scope === "all" ? `enabled (${managedTmuxSessions.length} managed session${managedTmuxSessions.length === 1 ? "" : "s"})` : "disabled"}`);
|
||||
for (const warning of warnings) {
|
||||
console.log(` warning: ${warning}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printResult(result: Awaited<ReturnType<typeof executeDestroy>>): void {
|
||||
console.log("DESTROY RESULT");
|
||||
console.log(` daemon: ${result.daemonStopped ? "stopped" : "still responding"}`);
|
||||
console.log(` port: ${result.portCleared ? "cleared" : "still occupied"}`);
|
||||
if (result.scope === "all") {
|
||||
console.log(` tmux sessions removed: ${result.tmuxKilled}`);
|
||||
if (result.tmuxMissing > 0) {
|
||||
console.log(` tmux sessions missing/already gone: ${result.tmuxMissing}`);
|
||||
}
|
||||
}
|
||||
if (result.backupPaths.length > 0) {
|
||||
for (const backupPath of result.backupPaths) {
|
||||
console.log(` backup: ${backupPath}`);
|
||||
}
|
||||
} else if (result.backup) {
|
||||
console.log(" backup: nothing to move");
|
||||
}
|
||||
console.log(result.stateRecreated ? ` fresh state root: ${result.stateRoot}` : " fresh state root: not created (state left untouched)");
|
||||
for (const warning of result.warnings) {
|
||||
console.log(` warning: ${warning}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyCommand(depsOverride?: DestroyCommandDeps): Command {
|
||||
const cmd = new Command("destroy").description("Destroy OpenRig local state for recovery");
|
||||
|
||||
cmd
|
||||
.option("--state", "Destroy OpenRig state and recreate an empty state root")
|
||||
.option("--all", "Destroy OpenRig state and remove managed tmux sessions")
|
||||
.option("--backup", "Move state aside instead of deleting it")
|
||||
.option("--yes", "Confirm the destructive operation")
|
||||
.option("--confirm <token>", `Exact confirmation token: ${DESTROY_CONFIRM_TOKEN}`)
|
||||
.action(async (opts: { state?: boolean; all?: boolean; backup?: boolean; yes?: boolean; confirm?: string }) => {
|
||||
const selectedScopes = [opts.state ? "state" : null, opts.all ? "all" : null].filter(Boolean) as DestroyScope[];
|
||||
if (selectedScopes.length !== 1) {
|
||||
console.error("Specify exactly one destroy scope: --state or --all");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!opts.yes) {
|
||||
console.error("Destroy requires --yes");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.confirm !== DESTROY_CONFIRM_TOKEN) {
|
||||
console.error(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = depsOverride ?? {
|
||||
configStore: new ConfigStore(),
|
||||
destroyDeps: realDestroyDeps(),
|
||||
};
|
||||
|
||||
const { runtimeConfig, warnings } = resolveRuntimeConfig(deps.configStore);
|
||||
const plan = buildDestroyPlan(selectedScopes[0]!, Boolean(opts.backup), runtimeConfig, deps.destroyDeps);
|
||||
printPlan(plan.scope, plan.backup, runtimeConfig, plan.managedTmuxSessions, [...warnings, ...plan.warnings]);
|
||||
plan.warnings = [...warnings, ...plan.warnings];
|
||||
const result = await executeDestroy(plan, deps.destroyDeps);
|
||||
printResult(result);
|
||||
if (!result.portCleared) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Command } from "commander";
|
||||
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
|
||||
import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { execSync } from "node:child_process";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { buildWritableHomeCheck } from "../system-preflight.js";
|
||||
import {
|
||||
CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
isCmuxSocketControlCompatible,
|
||||
readCmuxSocketControlModeFromText,
|
||||
resolveCmuxSettingsPath,
|
||||
} from "../cmux-config.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
|
||||
interface DoctorCheck {
|
||||
name: string;
|
||||
status: "pass" | "warn" | "fail";
|
||||
status: "pass" | "warn" | "fail" | "skipped";
|
||||
message: string;
|
||||
reason?: string;
|
||||
fix?: string;
|
||||
@@ -18,11 +25,14 @@ interface DoctorCheck {
|
||||
export interface DoctorDeps {
|
||||
exists: (p: string) => boolean;
|
||||
baseDir: string;
|
||||
readFile: (path: string) => string | null;
|
||||
exec: (cmd: string) => string;
|
||||
checkPort: (port: number) => Promise<boolean>;
|
||||
configStore: Pick<ConfigStore, "resolve">;
|
||||
platform?: NodeJS.Platform;
|
||||
mkdirp?: (path: string) => void;
|
||||
checkWritable?: (path: string) => void;
|
||||
fetch?: (url: string) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>;
|
||||
}
|
||||
|
||||
const MIN_NODE_MAJOR = 20;
|
||||
@@ -39,8 +49,9 @@ function defaultCheckPort(port: number): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck> } {
|
||||
export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; portCheck: Promise<DoctorCheck>; asyncChecks: Promise<DoctorCheck>[] } {
|
||||
const checks: DoctorCheck[] = [];
|
||||
const platform = deps.platform ?? process.platform;
|
||||
|
||||
// 1. Daemon dist
|
||||
const daemonPath = resolveDaemonPath(deps.baseDir, deps.exists);
|
||||
@@ -86,10 +97,8 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
}
|
||||
|
||||
// 4. tmux
|
||||
try {
|
||||
const ver = deps.exec("tmux -V").trim();
|
||||
checks.push({ name: "tmux", status: "pass", message: ver });
|
||||
} catch {
|
||||
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
|
||||
if (tmuxProbe.code === "not_installed") {
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
status: "fail",
|
||||
@@ -97,9 +106,78 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
reason: "OpenRig uses tmux to manage agent sessions.",
|
||||
fix: "Install tmux: brew install tmux (macOS), apt install tmux (Linux).",
|
||||
});
|
||||
} else if (tmuxProbe.available && tmuxProbe.version) {
|
||||
checks.push({ name: "tmux", status: "pass", message: tmuxProbe.version });
|
||||
if (platform === "darwin") {
|
||||
const mouseMode = readTmuxMouseMode(deps);
|
||||
if (mouseMode === "on") {
|
||||
checks.push({
|
||||
name: "tmux_mouse",
|
||||
status: "pass",
|
||||
message: "tmux mouse mode enabled.",
|
||||
});
|
||||
} else if (mouseMode === "off") {
|
||||
checks.push({
|
||||
name: "tmux_mouse",
|
||||
status: "warn",
|
||||
message: "tmux mouse mode appears disabled.",
|
||||
reason: "On macOS, scrolling and text selection inside tmux panes is much smoother with mouse mode enabled.",
|
||||
fix: "Run `tmux set -g mouse on` for the current tmux server. To keep it enabled, add `set -g mouse on` to `~/.tmux.conf` and reload with `tmux source-file ~/.tmux.conf`.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
status: "fail",
|
||||
message: failure.message,
|
||||
reason: failure.reason,
|
||||
fix: failure.fix,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Writable state paths (shared with preflight)
|
||||
// 5. cmux shell check (optional but recommended for Open CMUX workflows)
|
||||
let shellCmuxPassed = false;
|
||||
try {
|
||||
deps.exec("cmux capabilities --json");
|
||||
shellCmuxPassed = true;
|
||||
checks.push({
|
||||
name: "cmux_shell",
|
||||
status: "pass",
|
||||
message: "cmux shell control available.",
|
||||
});
|
||||
} catch (err) {
|
||||
try {
|
||||
deps.exec("cmux --help");
|
||||
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
|
||||
const modeHint = socketMode?.error
|
||||
? ` Likely cause on macOS: ${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable (${socketMode.error}).`
|
||||
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode) && socketMode.source === "default"
|
||||
? ` Likely cause on macOS: cmux is still using its default automation.socketControlMode '${socketMode.mode}'.`
|
||||
: socketMode && !isCmuxSocketControlCompatible(socketMode.mode)
|
||||
? ` Likely cause on macOS: automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}.`
|
||||
: "";
|
||||
checks.push({
|
||||
name: "cmux_shell",
|
||||
status: "warn",
|
||||
message: "cmux installed, but control unavailable right now.",
|
||||
reason: "OpenRig can run without cmux, but Open CMUX actions and cmux-aware node control will be unavailable until cmux control works.",
|
||||
fix: `Open the cmux app, verify control access/socket sharing is enabled for OpenRig, then rerun 'rig doctor'. If you are running this for someone else, tell the user that cmux is optional but required for Open CMUX. If you do not need cmux features, you can ignore this warning.${modeHint}`,
|
||||
});
|
||||
} catch {
|
||||
checks.push({
|
||||
name: "cmux_shell",
|
||||
status: "warn",
|
||||
message: "cmux not found.",
|
||||
reason: "OpenRig can run without cmux, but Open CMUX actions and surface control will be unavailable.",
|
||||
fix: "Install and launch cmux if you want Open CMUX support. If you are running this for someone else, tell the user that cmux is optional and only needed for Open CMUX workflows.",
|
||||
});
|
||||
}
|
||||
void err;
|
||||
}
|
||||
|
||||
// 6. Writable state paths (shared with preflight)
|
||||
const config = deps.configStore.resolve();
|
||||
const writableCheck = buildWritableHomeCheck(config, path.dirname(config.db.path), {
|
||||
mkdirp: deps.mkdirp,
|
||||
@@ -113,7 +191,7 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
fix: writableCheck.fix,
|
||||
});
|
||||
|
||||
// 6. Port availability (async) — daemon already running on that port counts as OK
|
||||
// 7. Port availability (async) — daemon already running on that port counts as OK
|
||||
const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise<DoctorCheck> => {
|
||||
if (available) {
|
||||
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` };
|
||||
@@ -134,7 +212,86 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
};
|
||||
});
|
||||
|
||||
return { checks, portCheck };
|
||||
// 8. Daemon cmux control (async, only when shell cmux passed)
|
||||
const asyncChecks: Promise<DoctorCheck>[] = [portCheck];
|
||||
if (shellCmuxPassed) {
|
||||
const fetchFn = deps.fetch ?? globalThis.fetch;
|
||||
const daemonCmuxCheck = (async (): Promise<DoctorCheck> => {
|
||||
try {
|
||||
const healthRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
|
||||
if (!healthRes.ok) {
|
||||
return { name: "cmux_daemon", status: "skipped", message: "Daemon not running. Skipping daemon cmux check." };
|
||||
}
|
||||
} catch {
|
||||
return { name: "cmux_daemon", status: "skipped", message: "Daemon not reachable. Skipping daemon cmux check." };
|
||||
}
|
||||
|
||||
try {
|
||||
const cmuxRes = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/api/adapters/cmux/status`);
|
||||
if (cmuxRes.ok && cmuxRes.json) {
|
||||
const data = (await cmuxRes.json()) as { available?: boolean };
|
||||
if (data.available) {
|
||||
return { name: "cmux_daemon", status: "pass", message: "Daemon cmux control available." };
|
||||
}
|
||||
return buildCmuxDaemonWarning(deps, platform);
|
||||
}
|
||||
} catch { /* fetch failed */ }
|
||||
|
||||
return buildCmuxDaemonWarning(deps, platform);
|
||||
})();
|
||||
asyncChecks.push(daemonCmuxCheck);
|
||||
}
|
||||
|
||||
return { checks, portCheck, asyncChecks };
|
||||
}
|
||||
|
||||
function readCmuxSocketControlMode(deps: DoctorDeps) {
|
||||
return readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
|
||||
}
|
||||
|
||||
function buildCmuxDaemonWarning(deps: DoctorDeps, platform: NodeJS.Platform): DoctorCheck {
|
||||
const socketMode = platform === "darwin" ? readCmuxSocketControlMode(deps) : null;
|
||||
|
||||
if (platform === "darwin" && socketMode?.error) {
|
||||
return {
|
||||
name: "cmux_daemon",
|
||||
status: "warn",
|
||||
message: "Shell cmux works, but the daemon cannot control cmux.",
|
||||
reason: `${CMUX_SETTINGS_DISCLOSURE_PATH} is unreadable: ${socketMode.error}`,
|
||||
fix: `Repair ${CMUX_SETTINGS_DISCLOSURE_PATH} or remove it so cmux can regenerate the template, then rerun \`rig setup\` or \`rig doctor\`.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === "darwin" && socketMode && !isCmuxSocketControlCompatible(socketMode.mode)) {
|
||||
const reason = socketMode.source === "default"
|
||||
? `cmux is still using its default automation.socketControlMode '${socketMode.mode}', so the daemon cannot attach as an external cmux client yet.`
|
||||
: `automation.socketControlMode is '${socketMode.mode}' in ${CMUX_SETTINGS_DISCLOSURE_PATH}, so the daemon cannot attach as an external cmux client yet.`;
|
||||
return {
|
||||
name: "cmux_daemon",
|
||||
status: "warn",
|
||||
message: "Shell cmux works, but the daemon cannot control cmux.",
|
||||
reason,
|
||||
fix: `Run \`rig setup\` to set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then restart the daemon with \`rig daemon start\`.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "cmux_daemon",
|
||||
status: "warn",
|
||||
message: "Shell cmux works, but the daemon cannot control cmux.",
|
||||
reason: "The daemon inherited a terminal/session environment that broke cmux adapter initialization.",
|
||||
fix: "Restart the daemon with `rig daemon start` after setup. If it still fails, inspect the daemon log with `rig daemon logs`.",
|
||||
};
|
||||
}
|
||||
|
||||
function readTmuxMouseMode(deps: DoctorDeps): "on" | "off" | null {
|
||||
try {
|
||||
const mode = deps.exec("tmux show-options -gqv mouse").trim().toLowerCase();
|
||||
if (mode === "on" || mode === "off") return mode;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
@@ -146,34 +303,35 @@ export function doctorCommand(depsOverride?: DoctorDeps): Command {
|
||||
const deps: DoctorDeps = depsOverride ?? {
|
||||
exists: existsSync,
|
||||
baseDir: import.meta.dirname,
|
||||
exec: (c: string) => execSync(c, { encoding: "utf-8" }),
|
||||
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
|
||||
exec: (c: string) => execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
checkPort: defaultCheckPort,
|
||||
configStore: new ConfigStore(),
|
||||
mkdirp: (dirPath: string) => mkdirSync(dirPath, { recursive: true }),
|
||||
checkWritable: (dirPath: string) => accessSync(dirPath, constants.W_OK),
|
||||
};
|
||||
|
||||
const { checks, portCheck } = runDoctorChecks(deps);
|
||||
const portResult = await portCheck;
|
||||
const allChecks = [...checks, portResult];
|
||||
const allPass = allChecks.every((c) => c.status === "pass");
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolvedAsync = await Promise.all(asyncChecks);
|
||||
const allChecks = [...checks, ...resolvedAsync];
|
||||
const healthy = allChecks.every((c) => c.status !== "fail");
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ healthy: allPass, checks: allChecks }, null, 2));
|
||||
if (!allPass) process.exitCode = 1;
|
||||
console.log(JSON.stringify({ healthy, checks: allChecks }, null, 2));
|
||||
if (!healthy) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const check of allChecks) {
|
||||
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : "FAIL";
|
||||
const icon = check.status === "pass" ? "OK" : check.status === "warn" ? "WARN" : check.status === "skipped" ? "SKIP" : "FAIL";
|
||||
console.log(` [${icon}] ${check.name}: ${check.message}`);
|
||||
if (check.reason) console.log(` Why: ${check.reason}`);
|
||||
if (check.fix) console.log(` Fix: ${check.fix}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(allPass ? "All checks passed." : "Some checks failed.");
|
||||
if (!allPass) process.exitCode = 1;
|
||||
console.log(healthy ? "System checks look good." : "Some checks failed.");
|
||||
if (!healthy) process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -14,6 +14,8 @@ interface TeardownResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
/**
|
||||
* `rig down <rigId>` — tear down a rig.
|
||||
* @param depsOverride - injectable deps for testing
|
||||
@@ -46,7 +48,7 @@ export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
delete: opts.delete ?? false,
|
||||
force: opts.force ?? false,
|
||||
snapshot: opts.snapshot ?? false,
|
||||
});
|
||||
}, { timeoutMs: LONG_RUNNING_TIMEOUT_MS });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
export function envCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("env")
|
||||
.description("Inspect and control rig environment services for service-backed rigs and managed apps")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig env status secrets-manager
|
||||
rig env logs secrets-manager vault
|
||||
rig env down secrets-manager
|
||||
`);
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running");
|
||||
return null;
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
}
|
||||
|
||||
async function resolveRigId(client: DaemonClient, rigRef: string): Promise<string> {
|
||||
const summaries = await client.get<Array<{ id: string; name: string }>>("/api/rigs/summary");
|
||||
const match = summaries.data.find((r) => r.name === rigRef || r.id === rigRef);
|
||||
if (!match) throw new Error(`Rig '${rigRef}' not found`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
// rig env status <rig>
|
||||
cmd
|
||||
.command("status")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (rig: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
let rigId: string;
|
||||
try { rigId = await resolveRigId(client, rig); } catch (err) {
|
||||
console.error((err as Error).message); process.exitCode = 1; return;
|
||||
}
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(`/api/rigs/${encodeURIComponent(rigId)}/env`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.data["hasServices"]) {
|
||||
console.log("No services configured for this rig.");
|
||||
return;
|
||||
}
|
||||
|
||||
const receipt = res.data["receipt"] as Record<string, unknown> | null;
|
||||
if (!receipt) {
|
||||
console.log("Services configured but no receipt available yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Env: ${res.data["kind"]} (${res.data["projectName"]})`);
|
||||
const services = receipt["services"] as Array<{ name: string; status: string; health?: string | null }>;
|
||||
if (services) {
|
||||
for (const svc of services) {
|
||||
const health = svc.health ? ` (${svc.health})` : "";
|
||||
console.log(` ${svc.name}: ${svc.status}${health}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// rig env logs <rig> [service]
|
||||
cmd
|
||||
.command("logs")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.argument("[service]", "Specific service name")
|
||||
.option("--tail <n>", "Number of lines", "100")
|
||||
.action(async (rig: string, service: string | undefined, opts: { tail: string }) => {
|
||||
const deps = getDeps();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
let rigId: string;
|
||||
try { rigId = await resolveRigId(client, rig); } catch (err) {
|
||||
console.error((err as Error).message); process.exitCode = 1; return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ tail: opts.tail });
|
||||
if (service) params.set("service", service);
|
||||
|
||||
const res = await client.get<{ ok: boolean; output?: string; error?: string }>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/env/logs?${params}`,
|
||||
);
|
||||
|
||||
if (res.status >= 400 || !res.data.ok) {
|
||||
console.error(res.data.error ?? `Failed to get logs (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(res.data.output ?? "");
|
||||
});
|
||||
|
||||
// rig env down <rig> [--volumes]
|
||||
cmd
|
||||
.command("down")
|
||||
.argument("<rig>", "Rig name or ID")
|
||||
.option("--volumes", "Also remove volumes")
|
||||
.action(async (rig: string, opts: { volumes?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
let rigId: string;
|
||||
try { rigId = await resolveRigId(client, rig); } catch (err) {
|
||||
console.error((err as Error).message); process.exitCode = 1; return;
|
||||
}
|
||||
|
||||
const res = await client.post<{ ok: boolean; error?: string }>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/env/down`,
|
||||
{ volumes: opts.volumes ?? false },
|
||||
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (res.status >= 400 || !res.data.ok) {
|
||||
console.error(res.data.error ?? `Failed to stop services (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Services stopped for ${rig}.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface ImportDeps extends StatusDeps {
|
||||
readFile: (path: string) => string;
|
||||
}
|
||||
|
||||
const LONG_RUNNING_IMPORT_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function importCommand(depsOverride?: ImportDeps): Command {
|
||||
const cmd = new Command("import").description("Import a rig spec from YAML");
|
||||
const getDeps = (): ImportDeps => depsOverride ?? {
|
||||
@@ -120,7 +122,13 @@ export function importCommand(depsOverride?: ImportDeps): Command {
|
||||
}
|
||||
|
||||
if (opts.instantiate) {
|
||||
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>("/api/rigs/import", yaml, "text/yaml", extraHeaders);
|
||||
const res = await client.postText<{ rigId: string; specName: string; specVersion: string; nodes: Array<{ logicalId: string; status: string }> } | { ok: false; code: string; errors?: string[]; message?: string }>(
|
||||
"/api/rigs/import",
|
||||
yaml,
|
||||
"text/yaml",
|
||||
extraHeaders,
|
||||
{ timeoutMs: LONG_RUNNING_IMPORT_TIMEOUT_MS },
|
||||
);
|
||||
if (res.status === 409 || res.status === 400) {
|
||||
const data = res.data as { ok: false; code: string; errors?: string[]; message?: string };
|
||||
const detail = data.errors?.join("\n ") ?? data.message ?? `status ${res.status}`;
|
||||
|
||||
@@ -22,7 +22,7 @@ export function preflightCommand(depsOverride?: PreflightCommandDeps): Command {
|
||||
|
||||
const exec = depsOverride?.exec ?? (async (c: string) => {
|
||||
const { execSync } = await import("node:child_process");
|
||||
return execSync(c, { encoding: "utf-8" });
|
||||
return execSync(c, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
||||
});
|
||||
|
||||
const preflight = new SystemPreflight({
|
||||
|
||||
@@ -24,7 +24,7 @@ interface NodeEntry {
|
||||
nodeKind: "agent" | "infrastructure";
|
||||
runtime: string | null;
|
||||
sessionStatus: string | null;
|
||||
startupStatus: "pending" | "ready" | "failed" | null;
|
||||
startupStatus: "pending" | "ready" | "attention_required" | "failed" | null;
|
||||
restoreOutcome: string;
|
||||
tmuxAttachCommand: string | null;
|
||||
resumeCommand: string | null;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lif
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
export function restoreCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("restore").description("Restore a rig from a snapshot");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -28,7 +30,28 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const rigId = opts.rig;
|
||||
|
||||
const res = await client.post<{ nodes?: Array<{ nodeId: string; logicalId: string; status: string; error?: string }>; attachCommand?: string }>(`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`);
|
||||
const res = await client.post<{
|
||||
nodes?: Array<{
|
||||
nodeId: string;
|
||||
logicalId: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
canonicalSessionName?: string | null;
|
||||
tmuxAttachCommand?: string | null;
|
||||
resumeCommand?: string | null;
|
||||
recoveryGuidance?: {
|
||||
summary: string;
|
||||
commands: string[];
|
||||
notes: string[];
|
||||
} | null;
|
||||
cwd?: string | null;
|
||||
}>;
|
||||
attachCommand?: string;
|
||||
}>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/restore/${encodeURIComponent(snapshotId)}`,
|
||||
undefined,
|
||||
{ timeoutMs: LONG_RUNNING_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (res.status === 404) {
|
||||
console.error(`Snapshot "${snapshotId}" or rig "${rigId}" not found. List snapshots with: rig snapshot list --rig ${rigId}`);
|
||||
@@ -46,6 +69,7 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
|
||||
const label = node.status === "failed" && node.error ? `${node.status} — ${node.error}` : node.status;
|
||||
console.log(` ${node.logicalId}: ${label}`);
|
||||
}
|
||||
printRecoveryGuidance(nodes);
|
||||
const attachCommand = (res.data as Record<string, unknown>)["attachCommand"] as string | undefined;
|
||||
if (attachCommand) {
|
||||
console.log(`Attach: ${attachCommand}`);
|
||||
@@ -58,3 +82,40 @@ export function restoreCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function printRecoveryGuidance(
|
||||
nodes: Array<{
|
||||
logicalId: string;
|
||||
status: string;
|
||||
canonicalSessionName?: string | null;
|
||||
tmuxAttachCommand?: string | null;
|
||||
recoveryGuidance?: { summary: string; commands: string[]; notes: string[] } | null;
|
||||
cwd?: string | null;
|
||||
}>,
|
||||
): void {
|
||||
const actionable = nodes.filter((node) =>
|
||||
(node.status === "fresh" || node.status === "failed") && node.recoveryGuidance
|
||||
);
|
||||
|
||||
if (actionable.length === 0) return;
|
||||
|
||||
console.log("\nRecovery guidance:");
|
||||
for (const node of actionable) {
|
||||
console.log(` ${node.logicalId}: ${node.recoveryGuidance!.summary}`);
|
||||
if (node.tmuxAttachCommand) {
|
||||
console.log(` attach: ${node.tmuxAttachCommand}`);
|
||||
}
|
||||
if (node.canonicalSessionName) {
|
||||
console.log(` session: ${node.canonicalSessionName}`);
|
||||
}
|
||||
if (node.cwd) {
|
||||
console.log(` cwd: ${node.cwd}`);
|
||||
}
|
||||
for (const command of node.recoveryGuidance!.commands) {
|
||||
console.log(` $ ${command}`);
|
||||
}
|
||||
for (const note of node.recoveryGuidance!.notes) {
|
||||
console.log(` note: ${note}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { Command } from "commander";
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync, existsSync, accessSync, constants, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { runDoctorChecks, type DoctorDeps } from "./doctor.js";
|
||||
import { resolveDaemonPath } from "../daemon-lifecycle.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import {
|
||||
CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
isCmuxSocketControlCompatible,
|
||||
readCmuxSocketControlModeFromText,
|
||||
resolveCmuxSettingsPath,
|
||||
upsertCmuxSocketControlMode,
|
||||
} from "../cmux-config.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControl } from "../tmux-health.js";
|
||||
|
||||
export interface SetupStep {
|
||||
id: string;
|
||||
status: "pass" | "applied" | "warn" | "fail" | "skipped";
|
||||
message: string;
|
||||
reason?: string;
|
||||
fixHint?: string;
|
||||
}
|
||||
|
||||
export interface VerificationCheck {
|
||||
name: string;
|
||||
status: "pass" | "warn" | "fail" | "skipped";
|
||||
message: string;
|
||||
reason?: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigDisclosure {
|
||||
scope: "global" | "project";
|
||||
runtime: "claude-code" | "codex" | "cmux";
|
||||
path: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export interface SetupResult {
|
||||
profile: "core" | "full";
|
||||
platform: string;
|
||||
ready: boolean;
|
||||
steps: SetupStep[];
|
||||
runtimeConfig: RuntimeConfigDisclosure[];
|
||||
verification?: {
|
||||
checks: VerificationCheck[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetupDeps {
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) => string;
|
||||
readFile: (path: string) => string | null;
|
||||
writeFile: (path: string, content: string) => void;
|
||||
exists: (path: string) => boolean;
|
||||
mkdirp?: (path: string) => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = 5 * 60_000;
|
||||
const CMUX_READY_ATTEMPTS = 5;
|
||||
const CMUX_READY_DELAY_MS = 1_000;
|
||||
|
||||
const CORE_STEP_IDS = [
|
||||
"brew",
|
||||
"tmux_install",
|
||||
"cmux_install",
|
||||
"claude_install",
|
||||
"claude_auth",
|
||||
"codex_install",
|
||||
"codex_auth",
|
||||
"tmux_config",
|
||||
"verify",
|
||||
];
|
||||
const FULL_EXTRA_STEP_IDS = ["jq_install", "gh_install"];
|
||||
const BASE_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure[] = [
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "claude-code",
|
||||
path: "~/.claude/settings.json",
|
||||
purpose: "Allow OpenRig commands without Claude permission prompts.",
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "claude-code",
|
||||
path: "~/.claude.json",
|
||||
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
runtime: "claude-code",
|
||||
path: ".claude/settings.local.json",
|
||||
purpose: "Apply managed-session Claude permissions and context-collector statusLine config within the project.",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
runtime: "claude-code",
|
||||
path: ".mcp.json",
|
||||
purpose: "Configure project-local MCP servers for Claude-managed workspaces.",
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "codex",
|
||||
path: "~/.codex/config.toml",
|
||||
purpose: "Pre-trust managed workspaces and configure Codex MCP servers.",
|
||||
},
|
||||
];
|
||||
|
||||
const DARWIN_RUNTIME_CONFIG_DISCLOSURE: RuntimeConfigDisclosure = {
|
||||
scope: "global",
|
||||
runtime: "cmux",
|
||||
path: CMUX_SETTINGS_DISCLOSURE_PATH,
|
||||
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
|
||||
};
|
||||
|
||||
function defaultDeps(): SetupDeps {
|
||||
return {
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) =>
|
||||
execSync(cmd, {
|
||||
encoding: "utf-8",
|
||||
timeout: opts?.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}),
|
||||
readFile: (p: string) => { try { return readFileSync(p, "utf-8"); } catch { return null; } },
|
||||
writeFile: (p: string, c: string) => writeFileSync(p, c, "utf-8"),
|
||||
exists: (p: string) => existsSync(p),
|
||||
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function installCommand(deps: SetupDeps, cmd: string): string {
|
||||
return deps.exec(cmd, { timeoutMs: INSTALL_COMMAND_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForCmuxCapabilities(deps: SetupDeps, attempts = CMUX_READY_ATTEMPTS): Promise<boolean> {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
try {
|
||||
deps.exec("cmux capabilities --json");
|
||||
return true;
|
||||
} catch {
|
||||
if (index < attempts - 1) {
|
||||
await sleep(CMUX_READY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function tryEnableCmuxControl(deps: SetupDeps, platform: NodeJS.Platform): Promise<boolean> {
|
||||
if (platform !== "darwin") return false;
|
||||
|
||||
const settingsPath = resolveCmuxSettingsPath();
|
||||
const settingsText = deps.readFile(settingsPath);
|
||||
const currentMode = readCmuxSocketControlModeFromText(settingsText);
|
||||
if (currentMode.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.mkdirp?.(path.dirname(settingsPath));
|
||||
const next = upsertCmuxSocketControlMode(settingsText, "automation");
|
||||
if (next.changed) {
|
||||
deps.writeFile(settingsPath, next.content);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const shellReady = await waitForCmuxCapabilities(deps, 1);
|
||||
if (shellReady) {
|
||||
try {
|
||||
deps.exec("cmux reload-config");
|
||||
} catch {
|
||||
// Best effort: if reload fails, the daemon-side verification will surface it honestly.
|
||||
}
|
||||
return waitForCmuxCapabilities(deps, 1);
|
||||
}
|
||||
|
||||
try {
|
||||
deps.exec("open -a /Applications/cmux.app");
|
||||
} catch {
|
||||
// Best effort: cmux may already be running or the app open may be blocked; capability probe decides readiness.
|
||||
}
|
||||
|
||||
return waitForCmuxCapabilities(deps);
|
||||
}
|
||||
|
||||
function buildRuntimeConfigDisclosure(platform: NodeJS.Platform): RuntimeConfigDisclosure[] {
|
||||
return platform === "darwin"
|
||||
? [...BASE_RUNTIME_CONFIG_DISCLOSURE, DARWIN_RUNTIME_CONFIG_DISCLOSURE]
|
||||
: [...BASE_RUNTIME_CONFIG_DISCLOSURE];
|
||||
}
|
||||
|
||||
async function probeDaemonCmuxStatus(doctorDeps?: DoctorDeps): Promise<"available" | "unavailable" | "skipped"> {
|
||||
const fetchFn = doctorDeps?.fetch;
|
||||
if (!fetchFn) return "skipped";
|
||||
const config = doctorDeps.configStore.resolve();
|
||||
const host = config.daemon.host;
|
||||
const port = config.daemon.port;
|
||||
|
||||
try {
|
||||
const healthRes = await fetchFn(`http://${host}:${port}/healthz`);
|
||||
if (!healthRes.ok) return "skipped";
|
||||
} catch {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
try {
|
||||
const cmuxRes = await fetchFn(`http://${host}:${port}/api/adapters/cmux/status`);
|
||||
if (!cmuxRes.ok || !cmuxRes.json) return "unavailable";
|
||||
const data = (await cmuxRes.json()) as { available?: boolean };
|
||||
return data.available ? "available" : "unavailable";
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetup(deps: SetupDeps, opts: { dryRun?: boolean; full?: boolean; doctorDeps?: DoctorDeps }): Promise<SetupResult> {
|
||||
const profile = opts.full ? "full" : "core";
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const runtimeConfig = buildRuntimeConfigDisclosure(platform);
|
||||
const stepIds = opts.full ? [...CORE_STEP_IDS, ...FULL_EXTRA_STEP_IDS] : [...CORE_STEP_IDS];
|
||||
const steps: SetupStep[] = [];
|
||||
|
||||
if (opts.dryRun) {
|
||||
for (const id of stepIds) {
|
||||
steps.push({ id, status: "skipped", message: `Dry run: ${id} would be attempted.` });
|
||||
}
|
||||
return { profile, platform, ready: false, steps, runtimeConfig };
|
||||
}
|
||||
|
||||
// Core steps
|
||||
// 1. Homebrew (macOS-first setup path)
|
||||
let brewOk = false;
|
||||
if (platform !== "darwin") {
|
||||
steps.push({
|
||||
id: "brew",
|
||||
status: "skipped",
|
||||
message: "Skipped: Homebrew setup path is only used on macOS.",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
deps.exec("brew --version");
|
||||
brewOk = true;
|
||||
steps.push({ id: "brew", status: "pass", message: "Homebrew available." });
|
||||
} catch {
|
||||
steps.push({
|
||||
id: "brew",
|
||||
status: "fail",
|
||||
message: "Homebrew not found.",
|
||||
reason: "Homebrew is required to install tmux and cmux on macOS.",
|
||||
fixHint: "Install Homebrew: https://brew.sh",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. tmux
|
||||
const tmuxProbe = probeTmuxControl((cmd) => deps.exec(cmd));
|
||||
if (tmuxProbe.code === "not_installed") {
|
||||
if (!brewOk) {
|
||||
steps.push({ id: "tmux_install", status: "skipped", message: "Skipped: Homebrew not available.", reason: "tmux install requires Homebrew." });
|
||||
} else {
|
||||
try {
|
||||
installCommand(deps, "brew install tmux");
|
||||
steps.push({ id: "tmux_install", status: "applied", message: "Installed tmux with Homebrew." });
|
||||
} catch (err) {
|
||||
steps.push({ id: "tmux_install", status: "fail", message: `Failed to install tmux: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
} else if (!tmuxProbe.available) {
|
||||
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
|
||||
steps.push({
|
||||
id: "tmux_install",
|
||||
status: "fail",
|
||||
message: failure.message,
|
||||
reason: failure.reason,
|
||||
fixHint: failure.fix,
|
||||
});
|
||||
} else {
|
||||
steps.push({ id: "tmux_install", status: "pass", message: "tmux available." });
|
||||
}
|
||||
|
||||
// 3. cmux
|
||||
const daemonCmuxBefore = await probeDaemonCmuxStatus(opts.doctorDeps);
|
||||
if (await waitForCmuxCapabilities(deps, 1)) {
|
||||
const socketMode = readCmuxSocketControlModeFromText(deps.readFile(resolveCmuxSettingsPath()));
|
||||
if (platform === "darwin" && socketMode.error) {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "cmux settings file is unreadable.",
|
||||
reason: `OpenRig could not parse ${CMUX_SETTINGS_DISCLOSURE_PATH}: ${socketMode.error}`,
|
||||
fixHint: "Repair or remove the cmux settings file, then rerun `rig setup`.",
|
||||
});
|
||||
} else if (platform === "darwin" && !isCmuxSocketControlCompatible(socketMode.mode)) {
|
||||
if (await tryEnableCmuxControl(deps, platform)) {
|
||||
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
|
||||
if (daemonCmuxAfter === "unavailable") {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "OpenRig updated cmux settings, but the running daemon still cannot control cmux.",
|
||||
reason: "The cmux settings file is now compatible, so the remaining blocker is in the live daemon/cmux session state.",
|
||||
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "applied",
|
||||
message: "Normalized cmux socket control to automation mode in ~/.config/cmux/settings.json.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "cmux shell control works, but OpenRig could not normalize cmux socket control.",
|
||||
reason: "OpenRig needs a compatible cmux socket control mode so the daemon can open CMUX surfaces reliably.",
|
||||
fixHint: `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`,
|
||||
});
|
||||
}
|
||||
} else if (daemonCmuxBefore === "unavailable") {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "cmux shell control works, but the running daemon still cannot control cmux.",
|
||||
reason: "Current cmux settings already look compatible, so the remaining blocker is outside the cmux settings file OpenRig can repair automatically.",
|
||||
fixHint: "Run `rig doctor` for the exact daemon cmux diagnosis, then restart the daemon after clearing the underlying blocker.",
|
||||
});
|
||||
} else {
|
||||
steps.push({ id: "cmux_install", status: "pass", message: "cmux available." });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
deps.exec("cmux --help");
|
||||
|
||||
if (await tryEnableCmuxControl(deps, platform)) {
|
||||
const daemonCmuxAfter = await probeDaemonCmuxStatus(opts.doctorDeps);
|
||||
if (daemonCmuxAfter === "unavailable") {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: "OpenRig enabled cmux socket control, but the running daemon still cannot control cmux.",
|
||||
reason: "The cmux app and settings are now in place, so the remaining blocker is in the live daemon/cmux session state.",
|
||||
fixHint: "Restart the daemon with `rig daemon start`, then rerun `rig doctor` to confirm cmux daemon control.",
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "applied",
|
||||
message: "Enabled cmux socket control in ~/.config/cmux/settings.json.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: platform === "darwin" ? "fail" : "warn",
|
||||
message: "cmux installed but control unavailable.",
|
||||
reason: "Open CMUX workflows need cmux socket control to be enabled.",
|
||||
fixHint: platform === "darwin"
|
||||
? `Set automation.socketControlMode to "automation" in ${CMUX_SETTINGS_DISCLOSURE_PATH}, then rerun \`rig setup\` or \`rig doctor\`.`
|
||||
: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!brewOk) {
|
||||
steps.push({ id: "cmux_install", status: "skipped", message: "Skipped: Homebrew not available." });
|
||||
} else {
|
||||
try {
|
||||
installCommand(deps, "brew install --cask cmux");
|
||||
if (await tryEnableCmuxControl(deps, platform) || await waitForCmuxCapabilities(deps, 1)) {
|
||||
steps.push({ id: "cmux_install", status: "applied", message: "Installed cmux with Homebrew." });
|
||||
} else {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: platform === "darwin" ? "fail" : "warn",
|
||||
message: "Installed cmux, but control is still unavailable.",
|
||||
reason: "Open CMUX workflows need the cmux app to expose socket control after installation.",
|
||||
fixHint: "Open cmux, approve any first-run prompts, and rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "cmux_install",
|
||||
status: "fail",
|
||||
message: `Failed to install cmux: ${(err as Error).message}`,
|
||||
reason: "Open CMUX workflows stay unavailable until the cmux app and CLI are installed.",
|
||||
fixHint: "Retry `brew install --cask cmux` after connectivity stabilizes, or install cmux manually.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. tmux config
|
||||
// 4. Claude Code runtime
|
||||
let claudeInstalled = false;
|
||||
try {
|
||||
deps.exec("claude --version");
|
||||
claudeInstalled = true;
|
||||
steps.push({ id: "claude_install", status: "pass", message: "Claude Code available." });
|
||||
} catch {
|
||||
try {
|
||||
installCommand(deps, "npm install -g @anthropic-ai/claude-code");
|
||||
deps.exec("claude --version");
|
||||
claudeInstalled = true;
|
||||
steps.push({ id: "claude_install", status: "applied", message: "Installed Claude Code with npm." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "claude_install",
|
||||
status: "fail",
|
||||
message: `Failed to install Claude Code: ${(err as Error).message}`,
|
||||
reason: "The demo rig launches Claude Code nodes, so the Claude CLI must be installed on this machine.",
|
||||
fixHint: "Install Claude Code with `npm install -g @anthropic-ai/claude-code`.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (claudeInstalled) {
|
||||
try {
|
||||
deps.exec("claude auth status");
|
||||
steps.push({ id: "claude_auth", status: "pass", message: "Claude Code authentication available." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "claude_auth",
|
||||
status: "fail",
|
||||
message: `Claude Code is installed but not ready to launch: ${(err as Error).message}`,
|
||||
reason: "The demo rig cannot launch Claude Code nodes until the Claude CLI is logged in and usable.",
|
||||
fixHint: "Run `claude auth login` or open `claude` once to complete authentication, then rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "claude_auth",
|
||||
status: "skipped",
|
||||
message: "Skipped: Claude Code is not installed.",
|
||||
reason: "Authentication cannot be checked until the Claude Code CLI is installed.",
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Codex runtime
|
||||
let codexInstalled = false;
|
||||
try {
|
||||
deps.exec("codex --version");
|
||||
codexInstalled = true;
|
||||
steps.push({ id: "codex_install", status: "pass", message: "Codex available." });
|
||||
} catch {
|
||||
try {
|
||||
installCommand(deps, "npm install -g @openai/codex");
|
||||
deps.exec("codex --version");
|
||||
codexInstalled = true;
|
||||
steps.push({ id: "codex_install", status: "applied", message: "Installed Codex with npm." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "codex_install",
|
||||
status: "fail",
|
||||
message: `Failed to install Codex: ${(err as Error).message}`,
|
||||
reason: "The demo rig launches Codex nodes, so the Codex CLI must be installed on this machine.",
|
||||
fixHint: "Install Codex with `npm install -g @openai/codex`.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (codexInstalled) {
|
||||
try {
|
||||
deps.exec("codex login status");
|
||||
steps.push({ id: "codex_auth", status: "pass", message: "Codex authentication available." });
|
||||
} catch (err) {
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "fail",
|
||||
message: `Codex is installed but not ready to launch: ${(err as Error).message}`,
|
||||
reason: "The demo rig cannot launch Codex nodes until the Codex CLI is logged in and usable.",
|
||||
fixHint: "Run `codex login` and complete authentication, then rerun `rig setup` or `rig doctor`.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
steps.push({
|
||||
id: "codex_auth",
|
||||
status: "skipped",
|
||||
message: "Skipped: Codex is not installed.",
|
||||
reason: "Authentication cannot be checked until the Codex CLI is installed.",
|
||||
});
|
||||
}
|
||||
|
||||
// 6. tmux config
|
||||
const TMUX_CONF = `${process.env["HOME"] ?? "~"}/.tmux.conf`;
|
||||
const MANAGED_MARKER = "# OpenRig managed block";
|
||||
const MANAGED_BLOCK = [
|
||||
MANAGED_MARKER,
|
||||
"set -g mouse on",
|
||||
"set -g history-limit 50000",
|
||||
`# End ${MANAGED_MARKER}`,
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const existing = deps.readFile(TMUX_CONF);
|
||||
if (existing && existing.includes(MANAGED_MARKER)) {
|
||||
// Replace existing managed block
|
||||
const replaced = existing.replace(
|
||||
new RegExp(`${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?# End ${MANAGED_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`),
|
||||
MANAGED_BLOCK,
|
||||
);
|
||||
deps.writeFile(TMUX_CONF, replaced);
|
||||
steps.push({ id: "tmux_config", status: "applied", message: "Updated OpenRig managed tmux config block." });
|
||||
} else if (existing) {
|
||||
deps.writeFile(TMUX_CONF, existing.trimEnd() + "\n\n" + MANAGED_BLOCK + "\n");
|
||||
steps.push({ id: "tmux_config", status: "applied", message: "Appended OpenRig managed tmux config block." });
|
||||
} else {
|
||||
deps.writeFile(TMUX_CONF, MANAGED_BLOCK + "\n");
|
||||
steps.push({ id: "tmux_config", status: "applied", message: "Created .tmux.conf with OpenRig managed block." });
|
||||
}
|
||||
} catch (err) {
|
||||
steps.push({ id: "tmux_config", status: "warn", message: `Could not update tmux config: ${(err as Error).message}` });
|
||||
}
|
||||
|
||||
// 7. Verify
|
||||
const tmuxOk = steps.some((s) => s.id === "tmux_install" && (s.status === "pass" || s.status === "applied"));
|
||||
const anyFail = steps.some((s) => s.status === "fail");
|
||||
steps.push({
|
||||
id: "verify",
|
||||
status: anyFail ? "warn" : "pass",
|
||||
message: anyFail ? "Some setup steps failed. Run `rig doctor` for detailed diagnostics." : "Core setup verified.",
|
||||
});
|
||||
|
||||
// Full profile extras
|
||||
if (opts.full) {
|
||||
for (const tool of [{ id: "jq_install", cmd: "jq", brew: "jq" }, { id: "gh_install", cmd: "gh", brew: "gh" }]) {
|
||||
try {
|
||||
deps.exec(`${tool.cmd} --version`);
|
||||
steps.push({ id: tool.id, status: "pass", message: `${tool.cmd} available.` });
|
||||
} catch {
|
||||
if (!brewOk) {
|
||||
steps.push({ id: tool.id, status: "skipped", message: `Skipped: Homebrew not available.` });
|
||||
} else {
|
||||
try {
|
||||
installCommand(deps, `brew install ${tool.brew}`);
|
||||
steps.push({ id: tool.id, status: "applied", message: `Installed ${tool.cmd} with Homebrew.` });
|
||||
} catch {
|
||||
steps.push({ id: tool.id, status: "warn", message: `Failed to install ${tool.cmd}.`, fixHint: `Install ${tool.cmd} manually.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run doctor-backed verification if not dry-run and doctorDeps available
|
||||
let verification: SetupResult["verification"];
|
||||
if (!opts.dryRun && opts.doctorDeps) {
|
||||
const doctorDeps = opts.doctorDeps;
|
||||
const doctor = runDoctorChecks(doctorDeps);
|
||||
const asyncResults = await Promise.all(doctor.asyncChecks);
|
||||
const allDoctorChecks = [...doctor.checks, ...asyncResults];
|
||||
verification = {
|
||||
checks: allDoctorChecks.map((c) => ({
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
message: c.message,
|
||||
...(c.reason ? { reason: c.reason } : {}),
|
||||
...(c.fix ? { fix: c.fix } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ready = no fail statuses in steps or verification checks
|
||||
const stepsFailed = steps.some((s) => s.status === "fail");
|
||||
const verificationFailed = verification?.checks.some((c) => c.status === "fail") ?? false;
|
||||
const ready = !stepsFailed && !verificationFailed;
|
||||
return { profile, platform, ready, steps, runtimeConfig, ...(verification ? { verification } : {}) };
|
||||
}
|
||||
|
||||
function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
|
||||
const platform = setupDeps.platform ?? process.platform;
|
||||
const baseDir = path.dirname(path.dirname(new URL(import.meta.url).pathname));
|
||||
return {
|
||||
exists: setupDeps.exists,
|
||||
baseDir,
|
||||
readFile: setupDeps.readFile,
|
||||
exec: setupDeps.exec,
|
||||
checkPort: async (port: number) => {
|
||||
const net = await import("node:net");
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = new net.default.Socket();
|
||||
socket.once("connect", () => { socket.destroy(); resolve(false); });
|
||||
socket.once("error", () => resolve(true));
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
},
|
||||
configStore: new ConfigStore(),
|
||||
platform: platform as NodeJS.Platform,
|
||||
mkdirp: (p: string) => mkdirSync(p, { recursive: true }),
|
||||
checkWritable: (p: string) => accessSync(p, constants.W_OK),
|
||||
fetch: globalThis.fetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function setupCommand(depsOverride?: SetupDeps): Command {
|
||||
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
|
||||
|
||||
cmd
|
||||
.option("--dry-run", "Show the plan without making changes")
|
||||
.option("--json", "Machine-readable JSON output")
|
||||
.option("--full", "Install broader operator workstation tools")
|
||||
.action(async (opts: { dryRun?: boolean; json?: boolean; full?: boolean }) => {
|
||||
const deps = depsOverride ?? defaultDeps();
|
||||
const doctorDeps = opts.dryRun ? undefined : buildDefaultDoctorDeps(deps);
|
||||
const result = await runSetup(deps, { dryRun: opts.dryRun, full: opts.full, doctorDeps });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (!opts.dryRun && !result.ready) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nProfile: ${result.profile}`);
|
||||
console.log(`Platform: ${result.platform}\n`);
|
||||
console.log("OpenRig may modify runtime config in these locations:");
|
||||
for (const item of result.runtimeConfig) {
|
||||
console.log(` - [${item.scope}] ${item.runtime} ${item.path} — ${item.purpose}`);
|
||||
}
|
||||
console.log(" - Note: already-running adopted sessions may need restart to pick up runtime config changes.\n");
|
||||
|
||||
for (const step of result.steps) {
|
||||
const icon = step.status === "pass" ? "OK" : step.status === "applied" ? "APPLIED" : step.status === "warn" ? "WARN" : step.status === "skipped" ? "SKIP" : "FAIL";
|
||||
console.log(` [${icon}] ${step.id}: ${step.message}`);
|
||||
if (step.reason) console.log(` Why: ${step.reason}`);
|
||||
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
|
||||
}
|
||||
|
||||
console.log(`\n${result.ready ? "Setup complete." : "Some steps need attention. Run `rig doctor` for detailed diagnostics."}`);
|
||||
if (!opts.dryRun && !result.ready) process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -44,12 +44,19 @@ export async function resolveLibrarySpec(
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available specs.`
|
||||
`Spec '${nameOrId}' not found in library. Run 'rig specs ls' to see available rigs, agents, and managed apps.`
|
||||
);
|
||||
}
|
||||
|
||||
export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("specs").description("Browse, preview, and manage the spec library");
|
||||
const cmd = new Command("specs")
|
||||
.description("Browse, preview, and manage the spec library, including managed apps")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig specs ls
|
||||
rig specs preview secrets-manager
|
||||
rig specs show vault-specialist
|
||||
`);
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
@@ -66,7 +73,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
// specs ls
|
||||
cmd.command("ls")
|
||||
.description("List library specs")
|
||||
.description("List library rigs, agents, and managed apps")
|
||||
.option("--kind <kind>", "Filter by kind (rig or agent)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { kind?: string; json?: boolean }) => {
|
||||
@@ -125,7 +132,7 @@ export function specsCommand(depsOverride?: StatusDeps): Command {
|
||||
// specs preview
|
||||
cmd.command("preview")
|
||||
.argument("<name-or-id>", "Spec name or library ID")
|
||||
.description("Show structured spec review")
|
||||
.description("Show structured spec review, including managed app details")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (nameOrId: string, opts: { json?: boolean }) => {
|
||||
try {
|
||||
|
||||
@@ -5,12 +5,26 @@ import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: LifecycleDeps }): Command {
|
||||
const cmd = new Command("up").description("Bootstrap a rig from a spec or bundle");
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function upCommand(
|
||||
depsOverride?: StatusDeps & {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
preflightExec?: (cmd: string) => Promise<string>;
|
||||
},
|
||||
): Command {
|
||||
const cmd = new Command("up")
|
||||
.description("Launch a rig or managed app from a spec, library entry, or bundle")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig up secrets-manager
|
||||
rig up ./rig.yaml
|
||||
rig up ./demo.rigbundle --target ~/work
|
||||
`);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<source>", "Path to .yaml rig spec or .rigbundle")
|
||||
.argument("<source>", "Path to a .yaml rig spec or .rigbundle, or a library name such as secrets-manager")
|
||||
.option("--plan", "Plan mode — preview without executing")
|
||||
.option("--yes", "Auto-approve trusted actions")
|
||||
.option("--cwd <path>", "Override launch working directory for all members for this run only")
|
||||
@@ -34,8 +48,10 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
|
||||
const configStore = new ConfigStore();
|
||||
resolvedConfig = configStore.resolve();
|
||||
const preflightExec = depsOverride?.preflightExec ?? (async (cmd: string) =>
|
||||
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }));
|
||||
const preflight = new SystemPreflight({
|
||||
exec: async (cmd) => execSync(cmd, { encoding: "utf-8" }),
|
||||
exec: preflightExec,
|
||||
configStore,
|
||||
getDaemonStatus: () => getDaemonStatus(deps.lifecycleDeps),
|
||||
openrigHome: OPENRIG_DIR,
|
||||
@@ -85,6 +101,7 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
let sourceRef = isRigName ? source : nodePath.resolve(source);
|
||||
|
||||
// If it looks like a name, check for library spec match
|
||||
let defaultLibraryCwdOverride: string | undefined;
|
||||
if (isRigName) {
|
||||
try {
|
||||
const { resolveLibrarySpec } = await import("./specs.js");
|
||||
@@ -102,6 +119,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
return;
|
||||
}
|
||||
sourceRef = entry.sourcePath;
|
||||
if (!opts.cwd && entry.kind === "rig" && entry.sourceType === "builtin") {
|
||||
defaultLibraryCwdOverride = process.cwd();
|
||||
}
|
||||
} catch (resolveErr) {
|
||||
// Ambiguity within library — surface it
|
||||
if ((resolveErr as Error).message?.includes("ambiguous")) {
|
||||
@@ -120,9 +140,9 @@ export function upCommand(depsOverride?: StatusDeps & { lifecycleDeps?: Lifecycl
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : undefined,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
});
|
||||
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { OPENRIG_HOME, LEGACY_RIGGED_HOME, readOpenRigEnv } from "./openrig-compat.js";
|
||||
|
||||
export interface DaemonState {
|
||||
@@ -21,7 +22,7 @@ export interface DaemonStatus {
|
||||
|
||||
/** Build the daemon HTTP URL from status. Uses persisted host or defaults to 127.0.0.1. */
|
||||
export function getDaemonUrl(status: DaemonStatus): string {
|
||||
return `http://${status.host ?? "127.0.0.1"}:${status.port}`;
|
||||
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
@@ -56,6 +57,7 @@ export const LOG_FILE = path.join(OPENRIG_DIR, "daemon.log");
|
||||
export const LEGACY_STATE_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.json");
|
||||
export const LEGACY_LOG_FILE = path.join(LEGACY_RIGGED_HOME, "daemon.log");
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 7433;
|
||||
const DEFAULT_DB = "openrig.sqlite";
|
||||
const HEALTHZ_RETRIES = 20;
|
||||
@@ -144,7 +146,7 @@ function resolveLifecycleFile(deps: LifecycleDeps, filename: "daemon.json" | "da
|
||||
* - "dead" — PID not alive */
|
||||
async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openrig" | "not_openrig" | "unresponsive" | "dead"> {
|
||||
if (!deps.isProcessAlive(state.pid)) return "dead";
|
||||
const host = state.host ?? "127.0.0.1";
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
|
||||
// Any response (ok or not) means something is listening on our port → OpenRig
|
||||
@@ -156,7 +158,70 @@ async function checkPid(state: DaemonState, deps: LifecycleDeps): Promise<"openr
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfiguredDaemonTarget(): { host: string; port: number } {
|
||||
try {
|
||||
const config = new ConfigStore().resolve();
|
||||
return {
|
||||
host: config.daemon.host,
|
||||
port: config.daemon.port,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
host: DEFAULT_HOST,
|
||||
port: DEFAULT_PORT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrub list: environment variable prefixes and exact names that should NOT
|
||||
* be forwarded from the operator shell into the daemon process. These are
|
||||
* terminal-emulator, GUI-session, and cmux-session variables that can break
|
||||
* adapter initialization when the daemon runs detached.
|
||||
*/
|
||||
const ENV_SCRUB_PREFIXES = ["CODEX_", "GHOSTTY_", "XPC_", "__CF"];
|
||||
const ENV_SCRUB_EXACT = new Set([
|
||||
"CMUX_SOCKET_PATH",
|
||||
"CMUX_SURFACE_ID",
|
||||
"CMUX_WORKSPACE",
|
||||
"COLORTERM",
|
||||
"COMMAND_MODE",
|
||||
"TERM_PROGRAM",
|
||||
"TERM_PROGRAM_VERSION",
|
||||
"TERMINFO",
|
||||
]);
|
||||
|
||||
export function buildDaemonEnv(
|
||||
baseEnv: Record<string, string>,
|
||||
opts: { port: number; host: string; db: string; transcriptsEnabled?: boolean; transcriptsPath?: string },
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(baseEnv)) {
|
||||
if (ENV_SCRUB_EXACT.has(key)) continue;
|
||||
if (ENV_SCRUB_PREFIXES.some((prefix) => key.startsWith(prefix))) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
|
||||
// Explicit OPENRIG_* overrides — always win over inherited values
|
||||
env["OPENRIG_PORT"] = String(opts.port);
|
||||
env["OPENRIG_HOST"] = opts.host;
|
||||
env["OPENRIG_DB"] = opts.db;
|
||||
if (opts.transcriptsEnabled !== undefined) {
|
||||
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
|
||||
}
|
||||
if (opts.transcriptsPath) {
|
||||
env["OPENRIG_TRANSCRIPTS_PATH"] = opts.transcriptsPath;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const host = opts.host ?? DEFAULT_HOST;
|
||||
const db = opts.db ?? DEFAULT_DB;
|
||||
|
||||
// Check if already running
|
||||
const existing = readState(deps);
|
||||
if (existing) {
|
||||
@@ -169,11 +234,20 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
throw new Error(`Existing daemon process (pid ${existing.pid} on port ${existing.port}) is unresponsive — recover it before starting a new daemon.`);
|
||||
}
|
||||
// "dead" or "not_rigged" → stale state, safe to proceed
|
||||
} else {
|
||||
let recoveredRunning = false;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
|
||||
recoveredRunning = true;
|
||||
} catch (err) {
|
||||
if (err instanceof HealthProbeTimeoutError) {
|
||||
throw new Error(`Daemon on port ${port} is unresponsive, and daemon state is missing — recover it before starting a new daemon.`);
|
||||
}
|
||||
}
|
||||
if (recoveredRunning) {
|
||||
throw new Error(`Daemon already running on port ${port}, but daemon state is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
const port = opts.port ?? DEFAULT_PORT;
|
||||
const host = opts.host ?? "127.0.0.1";
|
||||
const db = opts.db ?? DEFAULT_DB;
|
||||
const daemonEntry = path.join(getDaemonPath(), "dist/index.js");
|
||||
|
||||
deps.mkdirp(OPENRIG_DIR);
|
||||
@@ -181,14 +255,13 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
const logFd = deps.openForAppend(LOG_FILE);
|
||||
|
||||
const child = deps.spawn(process.execPath, [daemonEntry], {
|
||||
env: {
|
||||
...process.env as Record<string, string>,
|
||||
OPENRIG_PORT: String(port),
|
||||
OPENRIG_HOST: host,
|
||||
OPENRIG_DB: db,
|
||||
...(opts.transcriptsEnabled !== undefined ? { OPENRIG_TRANSCRIPTS_ENABLED: String(opts.transcriptsEnabled) } : {}),
|
||||
...(opts.transcriptsPath ? { OPENRIG_TRANSCRIPTS_PATH: opts.transcriptsPath } : {}),
|
||||
},
|
||||
env: buildDaemonEnv(process.env as Record<string, string>, {
|
||||
port,
|
||||
host,
|
||||
db,
|
||||
transcriptsEnabled: opts.transcriptsEnabled,
|
||||
transcriptsPath: opts.transcriptsPath,
|
||||
}),
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
detached: true,
|
||||
});
|
||||
@@ -233,7 +306,22 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
|
||||
|
||||
export async function stopDaemon(deps: LifecycleDeps): Promise<void> {
|
||||
const state = readState(deps);
|
||||
if (!state) return; // Not running — clean exit
|
||||
if (!state) {
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
let recoveredRunning = false;
|
||||
try {
|
||||
await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
|
||||
recoveredRunning = true;
|
||||
} catch (err) {
|
||||
if (err instanceof HealthProbeTimeoutError) {
|
||||
throw new Error(`Daemon state is missing and port ${configured.port} is unresponsive — cannot stop safely.`);
|
||||
}
|
||||
}
|
||||
if (recoveredRunning) {
|
||||
throw new Error(`Daemon is running on port ${configured.port}, but daemon state is missing — cannot stop safely.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
|
||||
const pidState = await checkPid(state, deps);
|
||||
@@ -275,14 +363,27 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
|
||||
try {
|
||||
const res = await fetchDaemonProbe(deps, `${openrigUrl}/healthz`);
|
||||
const url = new URL(openrigUrl);
|
||||
return { state: "running", port: Number(url.port) || 7433, host: url.hostname || "127.0.0.1", healthy: res.ok };
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: res.ok };
|
||||
} catch {
|
||||
return { state: "stopped" };
|
||||
}
|
||||
}
|
||||
|
||||
const state = readState(deps);
|
||||
if (!state) return { state: "stopped" };
|
||||
if (!state) {
|
||||
const configured = resolveConfiguredDaemonTarget();
|
||||
try {
|
||||
const res = await fetchDaemonProbe(deps, `http://${configured.host}:${configured.port}/healthz`);
|
||||
return {
|
||||
state: "running",
|
||||
port: configured.port,
|
||||
host: configured.host,
|
||||
healthy: res.ok,
|
||||
};
|
||||
} catch {
|
||||
return { state: "stopped" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!deps.isProcessAlive(state.pid)) {
|
||||
// Process dead — stale state
|
||||
@@ -291,7 +392,7 @@ export async function getDaemonStatus(deps: LifecycleDeps): Promise<DaemonStatus
|
||||
}
|
||||
|
||||
// Process alive — check healthz
|
||||
const host = state.host ?? "127.0.0.1";
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
let healthy = false;
|
||||
try {
|
||||
const res = await fetchDaemonProbe(deps, `http://${host}:${state.port}/healthz`);
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export const DESTROY_CONFIRM_TOKEN = "destroy-openrig-state";
|
||||
|
||||
export type DestroyScope = "state" | "all";
|
||||
|
||||
export interface DestroyRuntimeConfig {
|
||||
stateRoot: string;
|
||||
dbPath: string;
|
||||
transcriptsPath: string;
|
||||
daemonHost: string;
|
||||
daemonPort: number;
|
||||
}
|
||||
|
||||
export interface DestroyTarget {
|
||||
path: string;
|
||||
kind: "state_root" | "db_file" | "db_shm" | "db_wal" | "transcripts_dir";
|
||||
backupPath?: string;
|
||||
}
|
||||
|
||||
export interface DestroyPlan {
|
||||
scope: DestroyScope;
|
||||
backup: boolean;
|
||||
stateRoot: string;
|
||||
daemonHost: string;
|
||||
daemonPort: number;
|
||||
managedTmuxSessions: string[];
|
||||
targets: DestroyTarget[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DestroyResult {
|
||||
scope: DestroyScope;
|
||||
backup: boolean;
|
||||
stateRoot: string;
|
||||
backupPaths: string[];
|
||||
daemonStopped: boolean;
|
||||
portCleared: boolean;
|
||||
stateRecreated: boolean;
|
||||
tmuxKilled: number;
|
||||
tmuxMissing: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ListenerInspection {
|
||||
kind: "openrig" | "other_http" | "unreachable";
|
||||
healthy?: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DestroyDeps {
|
||||
stopDaemon: () => Promise<void>;
|
||||
inspectListener: (host: string, port: number) => Promise<ListenerInspection>;
|
||||
findListeningPid: (port: number) => number | null;
|
||||
killProcess: (pid: number) => void;
|
||||
exists: (path: string) => boolean;
|
||||
renamePath: (from: string, to: string) => void;
|
||||
removePath: (path: string) => void;
|
||||
mkdirp: (path: string) => void;
|
||||
listManagedTmuxSessions: (dbPath: string) => string[];
|
||||
killTmuxSession: (sessionName: string) => boolean;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => Date;
|
||||
}
|
||||
|
||||
function normalizePathForPrefix(path: string): string {
|
||||
return path.endsWith("/") ? path : `${path}/`;
|
||||
}
|
||||
|
||||
export function isWithinPath(candidate: string, root: string): boolean {
|
||||
if (candidate === root) return true;
|
||||
return candidate.startsWith(normalizePathForPrefix(root));
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const yyyy = String(date.getFullYear());
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const min = String(date.getMinutes()).padStart(2, "0");
|
||||
const ss = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${yyyy}${mm}${dd}-${hh}${min}${ss}`;
|
||||
}
|
||||
|
||||
export function buildBackupPath(targetPath: string, exists: (path: string) => boolean, now = new Date()): string {
|
||||
const stamp = formatTimestamp(now);
|
||||
const base = `${targetPath}.backup-${stamp}`;
|
||||
if (!exists(base)) return base;
|
||||
let suffix = 2;
|
||||
while (exists(`${base}-${suffix}`)) suffix += 1;
|
||||
return `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
export function listManagedTmuxSessionsFromDb(dbPath: string): string[] {
|
||||
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT DISTINCT
|
||||
COALESCE(
|
||||
b.tmux_session,
|
||||
(
|
||||
SELECT s2.session_name
|
||||
FROM sessions s2
|
||||
WHERE s2.node_id = n.id
|
||||
ORDER BY datetime(s2.created_at) DESC, s2.rowid DESC
|
||||
LIMIT 1
|
||||
)
|
||||
) AS session_name
|
||||
FROM nodes n
|
||||
LEFT JOIN bindings b ON b.node_id = n.id
|
||||
WHERE COALESCE(b.attachment_type, 'tmux') = 'tmux'
|
||||
`).all() as Array<{ session_name: string | null }>;
|
||||
|
||||
return rows
|
||||
.map((row) => row.session_name?.trim() ?? "")
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.sort();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDestroyPlan(
|
||||
scope: DestroyScope,
|
||||
backup: boolean,
|
||||
config: DestroyRuntimeConfig,
|
||||
deps: Pick<DestroyDeps, "exists" | "listManagedTmuxSessions" | "now">
|
||||
): DestroyPlan {
|
||||
const warnings: string[] = [];
|
||||
const targets: DestroyTarget[] = [];
|
||||
|
||||
const stateRootExists = deps.exists(config.stateRoot);
|
||||
targets.push({
|
||||
path: config.stateRoot,
|
||||
kind: "state_root",
|
||||
...(backup && stateRootExists ? { backupPath: buildBackupPath(config.stateRoot, deps.exists, deps.now()) } : {}),
|
||||
});
|
||||
|
||||
const addExternalTarget = (path: string, kind: DestroyTarget["kind"]) => {
|
||||
if (!deps.exists(path)) return;
|
||||
if (isWithinPath(path, config.stateRoot)) return;
|
||||
targets.push({
|
||||
path,
|
||||
kind,
|
||||
...(backup ? { backupPath: buildBackupPath(path, deps.exists, deps.now()) } : {}),
|
||||
});
|
||||
warnings.push(`${kind} is outside state root and will be ${backup ? "backed up separately" : "deleted separately"}: ${path}`);
|
||||
};
|
||||
|
||||
addExternalTarget(config.dbPath, "db_file");
|
||||
addExternalTarget(`${config.dbPath}-shm`, "db_shm");
|
||||
addExternalTarget(`${config.dbPath}-wal`, "db_wal");
|
||||
addExternalTarget(config.transcriptsPath, "transcripts_dir");
|
||||
|
||||
let managedTmuxSessions: string[] = [];
|
||||
if (scope === "all" && deps.exists(config.dbPath)) {
|
||||
try {
|
||||
managedTmuxSessions = deps.listManagedTmuxSessions(config.dbPath);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to enumerate managed tmux sessions from ${config.dbPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scope,
|
||||
backup,
|
||||
stateRoot: config.stateRoot,
|
||||
daemonHost: config.daemonHost,
|
||||
daemonPort: config.daemonPort,
|
||||
managedTmuxSessions,
|
||||
targets,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "0.0.0.0";
|
||||
}
|
||||
|
||||
export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Promise<DestroyResult> {
|
||||
const warnings = [...plan.warnings];
|
||||
const backupPaths: string[] = [];
|
||||
let stateRecreated = false;
|
||||
|
||||
try {
|
||||
await deps.stopDaemon();
|
||||
} catch (err) {
|
||||
warnings.push(`stopDaemon failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
let daemonStopped = false;
|
||||
let portCleared = false;
|
||||
|
||||
if (isLocalHost(plan.daemonHost)) {
|
||||
const before = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
|
||||
if (before.kind === "openrig") {
|
||||
const listenerPid = deps.findListeningPid(plan.daemonPort);
|
||||
if (listenerPid !== null) {
|
||||
try {
|
||||
deps.killProcess(listenerPid);
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to terminate listener pid ${listenerPid}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if ((await deps.inspectListener(plan.daemonHost, plan.daemonPort)).kind === "unreachable") break;
|
||||
await deps.sleep(100);
|
||||
}
|
||||
} else {
|
||||
warnings.push(`OpenRig responded on port ${plan.daemonPort}, but no listener pid was found.`);
|
||||
}
|
||||
} else if (before.kind === "other_http") {
|
||||
warnings.push(`Port ${plan.daemonPort} is occupied by a live non-OpenRig HTTP listener. State was left untouched.`);
|
||||
}
|
||||
|
||||
const after = await deps.inspectListener(plan.daemonHost, plan.daemonPort);
|
||||
portCleared = after.kind === "unreachable";
|
||||
daemonStopped = portCleared;
|
||||
} else {
|
||||
warnings.push(`Daemon host ${plan.daemonHost} is not local. rig destroy only operates on local daemon targets; state was left untouched.`);
|
||||
}
|
||||
|
||||
if (!portCleared) {
|
||||
return {
|
||||
scope: plan.scope,
|
||||
backup: plan.backup,
|
||||
stateRoot: plan.stateRoot,
|
||||
backupPaths,
|
||||
daemonStopped,
|
||||
portCleared,
|
||||
stateRecreated,
|
||||
tmuxKilled: 0,
|
||||
tmuxMissing: 0,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let tmuxKilled = 0;
|
||||
let tmuxMissing = 0;
|
||||
if (plan.scope === "all") {
|
||||
for (const sessionName of plan.managedTmuxSessions) {
|
||||
if (deps.killTmuxSession(sessionName)) tmuxKilled += 1;
|
||||
else tmuxMissing += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of plan.targets) {
|
||||
if (!deps.exists(target.path)) continue;
|
||||
if (plan.backup && target.backupPath) {
|
||||
deps.renamePath(target.path, target.backupPath);
|
||||
backupPaths.push(target.backupPath);
|
||||
} else {
|
||||
deps.removePath(target.path);
|
||||
}
|
||||
}
|
||||
|
||||
deps.mkdirp(plan.stateRoot);
|
||||
stateRecreated = true;
|
||||
|
||||
return {
|
||||
scope: plan.scope,
|
||||
backup: plan.backup,
|
||||
stateRoot: plan.stateRoot,
|
||||
backupPaths,
|
||||
daemonStopped,
|
||||
portCleared,
|
||||
stateRecreated,
|
||||
tmuxKilled,
|
||||
tmuxMissing,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function findListeningPidWithLsof(port: number): number | null {
|
||||
try {
|
||||
const output = execFileSync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"], { encoding: "utf-8" }).trim();
|
||||
if (!output) return null;
|
||||
const first = output.split(/\s+/)[0]?.trim();
|
||||
if (!first) return null;
|
||||
const pid = parseInt(first, 10);
|
||||
return Number.isNaN(pid) ? null : pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function killTmuxSessionWithCli(sessionName: string): boolean {
|
||||
try {
|
||||
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { configCommand } from "./commands/config.js";
|
||||
import { preflightCommand } from "./commands/preflight.js";
|
||||
import { doctorCommand } from "./commands/doctor.js";
|
||||
import { expandCommand } from "./commands/expand.js";
|
||||
import { envCommand } from "./commands/env.js";
|
||||
import { askCommand } from "./commands/ask.js";
|
||||
import { chatroomCommand } from "./commands/chatroom.js";
|
||||
import { specsCommand } from "./commands/specs.js";
|
||||
@@ -40,6 +41,8 @@ import { releaseCommand } from "./commands/release.js";
|
||||
import { launchCommand } from "./commands/launch.js";
|
||||
import { removeCommand } from "./commands/remove.js";
|
||||
import { shrinkCommand } from "./commands/shrink.js";
|
||||
import { destroyCommand, type DestroyCommandDeps } from "./commands/destroy.js";
|
||||
import { setupCommand } from "./commands/setup.js";
|
||||
import type { LifecycleDeps } from "./daemon-lifecycle.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
@@ -74,11 +77,13 @@ export interface ProgramDeps {
|
||||
specsDeps?: StatusDeps;
|
||||
whoamiDeps?: StatusDeps;
|
||||
expandDeps?: StatusDeps;
|
||||
envDeps?: StatusDeps;
|
||||
unclaimDeps?: StatusDeps;
|
||||
releaseDeps?: StatusDeps;
|
||||
launchDeps?: StatusDeps;
|
||||
removeDeps?: StatusDeps;
|
||||
shrinkDeps?: StatusDeps;
|
||||
destroyDeps?: DestroyCommandDeps;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
@@ -123,11 +128,14 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(preflightCommand());
|
||||
program.addCommand(doctorCommand());
|
||||
program.addCommand(expandCommand(depsOverride?.expandDeps));
|
||||
program.addCommand(envCommand(depsOverride?.envDeps));
|
||||
program.addCommand(unclaimCommand(depsOverride?.unclaimDeps));
|
||||
program.addCommand(releaseCommand(depsOverride?.releaseDeps));
|
||||
program.addCommand(launchCommand(depsOverride?.launchDeps));
|
||||
program.addCommand(removeCommand(depsOverride?.removeDeps));
|
||||
program.addCommand(shrinkCommand(depsOverride?.shrinkDeps));
|
||||
program.addCommand(destroyCommand(depsOverride?.destroyDeps));
|
||||
program.addCommand(setupCommand());
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
||||
import type { DaemonClient, DaemonResponse } from "./client.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
type TextResult = {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: true;
|
||||
@@ -64,7 +66,11 @@ export function createMcpServer(client: DaemonClient): McpServer {
|
||||
},
|
||||
async ({ sourceRef, plan, autoApprove, targetRoot }) => {
|
||||
try {
|
||||
const res = await client.post("/api/up", { sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot });
|
||||
const res = await client.post(
|
||||
"/api/up",
|
||||
{ sourceRef, plan: plan ?? false, autoApprove: autoApprove ?? false, targetRoot },
|
||||
plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
|
||||
);
|
||||
return mapResult(res);
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text" as const, text: (err as Error).message }], isError: true as const };
|
||||
|
||||
@@ -2,7 +2,11 @@ import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const warnedKeys = new Set<string>();
|
||||
|
||||
export function getOpenRigHome(): string {
|
||||
const configured = readOpenRigEnv("OPENRIG_HOME", "RIGGED_HOME");
|
||||
if (configured !== undefined) return configured;
|
||||
return join(homedir(), ".openrig");
|
||||
}
|
||||
|
||||
@@ -13,8 +17,6 @@ export function getLegacyRiggedHome(): string {
|
||||
export const OPENRIG_HOME = getOpenRigHome();
|
||||
export const LEGACY_RIGGED_HOME = getLegacyRiggedHome();
|
||||
|
||||
const warnedKeys = new Set<string>();
|
||||
|
||||
function warnOnce(key: string, message: string): void {
|
||||
if (warnedKeys.has(key)) return;
|
||||
warnedKeys.add(key);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { accessSync, mkdirSync, constants } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { ConfigStore, RiggedConfig } from "./config-store.js";
|
||||
import type { DaemonStatus } from "./daemon-lifecycle.js";
|
||||
import { buildTmuxControlFailure, probeTmuxControlAsync } from "./tmux-health.js";
|
||||
|
||||
export interface PreflightCheck {
|
||||
name: string;
|
||||
@@ -135,10 +136,8 @@ export class SystemPreflight {
|
||||
}
|
||||
|
||||
// 2. tmux availability
|
||||
try {
|
||||
await this.deps.exec("tmux -V");
|
||||
checks.push({ name: "tmux", ok: true });
|
||||
} catch {
|
||||
const tmuxProbe = await probeTmuxControlAsync(this.deps.exec);
|
||||
if (tmuxProbe.code === "not_installed") {
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
ok: false,
|
||||
@@ -146,6 +145,17 @@ export class SystemPreflight {
|
||||
reason: "OpenRig uses tmux to create and control agent sessions.",
|
||||
fix: "Install tmux (brew install tmux on macOS, apt install tmux on Debian/Ubuntu).",
|
||||
});
|
||||
} else if (tmuxProbe.available) {
|
||||
checks.push({ name: "tmux", ok: true });
|
||||
} else {
|
||||
const failure = buildTmuxControlFailure(tmuxProbe.detail ?? "unknown tmux control failure");
|
||||
checks.push({
|
||||
name: "tmux",
|
||||
ok: false,
|
||||
error: failure.message,
|
||||
reason: failure.reason,
|
||||
fix: failure.fix,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Writable OpenRig home + transcript path
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
export interface TmuxProbeResult {
|
||||
installed: boolean;
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
detail: string | null;
|
||||
code: "available" | "no_server" | "not_installed" | "unhealthy";
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export function isTmuxNoServerMessage(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
return normalized.includes("no server running")
|
||||
|| normalized.includes("failed to connect to server")
|
||||
|| (normalized.includes("error connecting to") && normalized.includes("no such file or directory"));
|
||||
}
|
||||
|
||||
export function buildTmuxControlFailure(message: string): { message: string; reason: string; fix: string } {
|
||||
return {
|
||||
message: "tmux installed, but the default control socket is unhealthy.",
|
||||
reason: `OpenRig uses tmux control commands to launch, inspect, and manage agent sessions. The default tmux socket is returning: ${message}`,
|
||||
fix: "Capture any needed state from visible tmux panes, then restart the default tmux server before retrying OpenRig. If this happened after a machine restore, treat it as attention required rather than a healthy running state.",
|
||||
};
|
||||
}
|
||||
|
||||
export function probeTmuxControl(exec: (cmd: string) => string): TmuxProbeResult {
|
||||
let version: string;
|
||||
try {
|
||||
version = exec("tmux -V").trim();
|
||||
} catch (err) {
|
||||
return {
|
||||
installed: false,
|
||||
available: false,
|
||||
version: null,
|
||||
detail: errorMessage(err),
|
||||
code: "not_installed",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
exec("tmux list-sessions");
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail: null,
|
||||
code: "available",
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = errorMessage(err).trim();
|
||||
if (isTmuxNoServerMessage(detail)) {
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail,
|
||||
code: "no_server",
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
available: false,
|
||||
version,
|
||||
detail,
|
||||
code: "unhealthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeTmuxControlAsync(exec: (cmd: string) => Promise<string>): Promise<TmuxProbeResult> {
|
||||
let version: string;
|
||||
try {
|
||||
version = (await exec("tmux -V")).trim();
|
||||
} catch (err) {
|
||||
return {
|
||||
installed: false,
|
||||
available: false,
|
||||
version: null,
|
||||
detail: errorMessage(err),
|
||||
code: "not_installed",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await exec("tmux list-sessions");
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail: null,
|
||||
code: "available",
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = errorMessage(err).trim();
|
||||
if (isTmuxNoServerMessage(detail)) {
|
||||
return {
|
||||
installed: true,
|
||||
available: true,
|
||||
version,
|
||||
detail,
|
||||
code: "no_server",
|
||||
};
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
available: false,
|
||||
version,
|
||||
detail,
|
||||
code: "unhealthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { resolveBinEntry } from "../src/bin-wrapper.js";
|
||||
import { resolveBinEntry, resolveNodeReexecBinary } from "../src/bin-wrapper.js";
|
||||
|
||||
describe("bin-wrapper", () => {
|
||||
let tempDir: string | null = null;
|
||||
@@ -24,4 +24,50 @@ describe("bin-wrapper", () => {
|
||||
const entry = resolveBinEntry(symlinkPath, pathToFileURL(wrapperSource).href);
|
||||
expect(entry).toBe(path.resolve(path.dirname(wrapperSource), "../dist/index.js"));
|
||||
});
|
||||
|
||||
it("prefers the sibling node next to the invoked rig binary when current node comes from elsewhere", () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
|
||||
const prefixDir = path.join(tempDir, "prefix");
|
||||
const binDir = path.join(prefixDir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
|
||||
const rigPath = path.join(binDir, "rig");
|
||||
const siblingNodePath = path.join(binDir, "node");
|
||||
fs.symlinkSync(wrapperSource, rigPath);
|
||||
fs.writeFileSync(siblingNodePath, "");
|
||||
|
||||
const reexecNode = resolveNodeReexecBinary(
|
||||
"/opt/homebrew/bin/node",
|
||||
rigPath,
|
||||
{},
|
||||
(candidate) => candidate === siblingNodePath,
|
||||
(candidate) => candidate,
|
||||
);
|
||||
|
||||
expect(reexecNode).toBe(siblingNodePath);
|
||||
});
|
||||
|
||||
it("does not re-exec when already running under the sibling node", () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
|
||||
const prefixDir = path.join(tempDir, "prefix");
|
||||
const binDir = path.join(prefixDir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
|
||||
const rigPath = path.join(binDir, "rig");
|
||||
const siblingNodePath = path.join(binDir, "node");
|
||||
fs.symlinkSync(wrapperSource, rigPath);
|
||||
fs.writeFileSync(siblingNodePath, "");
|
||||
|
||||
const reexecNode = resolveNodeReexecBinary(
|
||||
siblingNodePath,
|
||||
rigPath,
|
||||
{},
|
||||
(candidate) => candidate === siblingNodePath,
|
||||
(candidate) => candidate,
|
||||
);
|
||||
|
||||
expect(reexecNode).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -182,7 +182,9 @@ describe("Bootstrap CLI", () => {
|
||||
// T7: daemon not running -> error + exit 1
|
||||
it("daemon not running returns exit code 1", async () => {
|
||||
const stoppedDeps: StatusDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps(),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
const prog = new Command();
|
||||
@@ -214,6 +216,32 @@ describe("Bootstrap CLI", () => {
|
||||
expect((body.cwdOverride as string).endsWith("relative/project")).toBe(true);
|
||||
});
|
||||
|
||||
it("bootstrap apply uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
|
||||
timeoutMs = options?.timeoutMs;
|
||||
return {
|
||||
status: 201,
|
||||
data: { runId: "run-2", status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
|
||||
};
|
||||
});
|
||||
|
||||
const deps: StatusDeps = {
|
||||
lifecycleDeps: runningDeps(port).lifecycleDeps,
|
||||
clientFactory: () => ({ post } as unknown as DaemonClient),
|
||||
};
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(bootstrapCommand(deps));
|
||||
|
||||
await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "bootstrap", "/tmp/rig.yaml", "--yes"]);
|
||||
});
|
||||
|
||||
expect(timeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
// T9: partial status -> exit 1 (R2-M1)
|
||||
it("partial result returns exit code 1", async () => {
|
||||
const partialServer = http.createServer((_, res) => {
|
||||
|
||||
@@ -124,6 +124,29 @@ describe("DaemonClient", () => {
|
||||
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
|
||||
});
|
||||
|
||||
it("per-request timeout override can extend a long-running call", async () => {
|
||||
const delayedFetch: typeof fetch = ((url: string | URL | Request, init?: RequestInit) => new Promise<Response>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve(new Response(JSON.stringify({ ok: true, url: String(url) }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
}, 40);
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(init.signal?.reason ?? new Error("aborted"));
|
||||
}, { once: true });
|
||||
})) as typeof fetch;
|
||||
const client = new DaemonClient("http://localhost:9999", { fetchImpl: delayedFetch, timeoutMs: 20 });
|
||||
|
||||
await expect(client.get("/api/rigs")).rejects.toThrow(/timed out/i);
|
||||
|
||||
const res = await client.get<{ ok: boolean; url: string }>("/api/rigs", { timeoutMs: 100 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.ok).toBe(true);
|
||||
expect(res.data.url).toContain("/api/rigs");
|
||||
});
|
||||
|
||||
// Test 5: Client uses OPENRIG_URL env, falls back to http://127.0.0.1:7433
|
||||
it("uses OPENRIG_URL env when set, falls back to http://127.0.0.1:7433", () => {
|
||||
// Default (no env)
|
||||
@@ -152,8 +175,8 @@ describe("DaemonClient", () => {
|
||||
const execFileAsync = promisify(execFile);
|
||||
const { resolve } = await import("node:path");
|
||||
|
||||
const cliEntry = resolve(import.meta.dirname, "../src/index.ts");
|
||||
const result = await execFileAsync("npx", ["tsx", cliEntry, "--version"], {
|
||||
const cliEntry = resolve(import.meta.dirname, "../dist/index.js");
|
||||
const result = await execFileAsync("node", [cliEntry, "--version"], {
|
||||
cwd: resolve(import.meta.dirname, ".."),
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
tailLogs,
|
||||
getDaemonPath,
|
||||
resolveDaemonPath,
|
||||
buildDaemonEnv,
|
||||
STATE_FILE,
|
||||
LOG_FILE,
|
||||
OPENRIG_DIR,
|
||||
@@ -36,6 +37,22 @@ function mockDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
};
|
||||
}
|
||||
|
||||
function startableDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
|
||||
let spawned = false;
|
||||
const spawn = overrides?.spawn ?? vi.fn(() => {
|
||||
spawned = true;
|
||||
return { pid: 12345, unref: vi.fn() } as unknown as ChildProcess;
|
||||
});
|
||||
return mockDeps({
|
||||
...overrides,
|
||||
spawn,
|
||||
fetch: overrides?.fetch ?? vi.fn(async () => {
|
||||
if (!spawned) throw new Error("refused");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function writtenState(deps: LifecycleDeps): DaemonState {
|
||||
const call = (deps.writeFile as ReturnType<typeof vi.fn>).mock.calls.find(
|
||||
(c: unknown[]) => (c[0] as string).endsWith("daemon.json")
|
||||
@@ -54,7 +71,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 2: start constructs exact spawn command with correct env/redirect
|
||||
it("start: constructs spawn with node, daemon entry, env, and log redirect", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
|
||||
|
||||
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
|
||||
@@ -73,7 +90,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 3: start waits for healthz, writes daemon.json with pid+port+db+startedAt
|
||||
it("start: writes daemon.json with pid, port, db, startedAt after healthz", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
const result = await startDaemon({ port: 8000, db: "test.sqlite" }, deps);
|
||||
|
||||
expect(result.pid).toBe(12345);
|
||||
@@ -102,6 +119,19 @@ describe("Daemon Lifecycle", () => {
|
||||
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
|
||||
});
|
||||
|
||||
it("start: recovered running daemon without daemon.json -> throws instead of spawning duplicate", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe("http://127.0.0.1:7433/healthz");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(startDaemon({}, deps)).rejects.toThrow(/already running/i);
|
||||
expect(deps.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 5: stop reads pid from daemon.json, sends SIGTERM, removes daemon.json
|
||||
it("stop: reads pid, sends SIGTERM, removes daemon.json", async () => {
|
||||
const state: DaemonState = { pid: 555, port: 7433, db: "openrig.sqlite", startedAt: "2026-01-01T00:00:00Z" };
|
||||
@@ -126,12 +156,26 @@ describe("Daemon Lifecycle", () => {
|
||||
it("stop: not running -> does not throw", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await expect(stopDaemon(deps)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("stop: recovered running daemon without daemon.json -> throws honest error", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe("http://127.0.0.1:7433/healthz");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(stopDaemon(deps)).rejects.toThrow(/state is missing|cannot stop safely/i);
|
||||
expect(deps.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test 7: status reads port from daemon.json, reports running with port
|
||||
it("status: running daemon (pid alive + healthz ok) -> { state: 'running', port, pid }", async () => {
|
||||
const state: DaemonState = { pid: 777, port: 9000, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
|
||||
@@ -157,12 +201,42 @@ describe("Daemon Lifecycle", () => {
|
||||
it("status: no daemon.json -> { state: 'stopped' }", async () => {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
const status = await getDaemonStatus(deps);
|
||||
expect(status.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("status: no daemon.json but configured healthz responds -> running without pid", async () => {
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
process.env["OPENRIG_PORT"] = "7555";
|
||||
process.env["OPENRIG_HOST"] = "127.0.0.1";
|
||||
try {
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe("http://127.0.0.1:7555/healthz");
|
||||
return { ok: true };
|
||||
}),
|
||||
});
|
||||
|
||||
const status = await getDaemonStatus(deps);
|
||||
expect(status).toEqual({
|
||||
state: "running",
|
||||
host: "127.0.0.1",
|
||||
port: 7555,
|
||||
healthy: true,
|
||||
});
|
||||
} finally {
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
// Test 9: status stale (daemon.json exists, process dead) -> reports stale, cleans up
|
||||
it("status: stale (daemon.json exists, pid dead) -> { state: 'stale' }, cleans up", async () => {
|
||||
const state: DaemonState = { pid: 888, port: 7433, db: "x.db", startedAt: "2026-01-01T00:00:00Z" };
|
||||
@@ -182,7 +256,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 10: start --port flag stored in daemon.json and forwarded to env
|
||||
it("start: custom port stored in daemon.json and forwarded to spawn env", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 9999 }, deps);
|
||||
|
||||
const state = writtenState(deps);
|
||||
@@ -232,7 +306,8 @@ describe("Daemon Lifecycle", () => {
|
||||
const { Command } = await import("commander");
|
||||
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false), // no daemon.json -> stopped
|
||||
exists: vi.fn(() => false), // no daemon.json + no healthy daemon -> stopped
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
@@ -255,9 +330,74 @@ describe("Daemon Lifecycle", () => {
|
||||
expect(logs.join("\n")).toMatch(/stopped/i);
|
||||
});
|
||||
|
||||
it("daemonCommand status omits pid when daemon is recovered without state", async () => {
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
process.env["OPENRIG_PORT"] = "7555";
|
||||
process.env["OPENRIG_HOST"] = "127.0.0.1";
|
||||
try {
|
||||
const { daemonCommand } = await import("../src/commands/daemon.js");
|
||||
const { Command } = await import("commander");
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
program.addCommand(daemonCommand(deps));
|
||||
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "rig", "daemon", "status"]);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Daemon running on port 7555");
|
||||
expect(output).not.toContain("pid undefined");
|
||||
} finally {
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
it("daemonCommand stop surfaces missing-state recovery error instead of claiming success", async () => {
|
||||
const { daemonCommand } = await import("../src/commands/daemon.js");
|
||||
const { Command } = await import("commander");
|
||||
const deps = mockDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
});
|
||||
|
||||
const program = new Command();
|
||||
program.addCommand(daemonCommand(deps));
|
||||
|
||||
const logs: string[] = [];
|
||||
const origErr = console.error;
|
||||
const savedExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
|
||||
try {
|
||||
await program.parseAsync(["node", "rig", "daemon", "stop"]);
|
||||
} finally {
|
||||
console.error = origErr;
|
||||
}
|
||||
|
||||
expect(logs.join("\n")).toMatch(/state is missing|cannot stop safely/i);
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = savedExitCode;
|
||||
});
|
||||
|
||||
// Test 15: start creates ~/.openrig directory if missing
|
||||
it("start: creates OPENRIG_DIR if missing", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433 }, deps);
|
||||
|
||||
expect(deps.mkdirp).toHaveBeenCalledWith(OPENRIG_DIR);
|
||||
@@ -440,6 +580,7 @@ describe("Daemon Lifecycle", () => {
|
||||
if (p === STATE_FILE) return "NOT VALID JSON {{{";
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
|
||||
const status = await getDaemonStatus(deps);
|
||||
@@ -448,7 +589,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 22: malformed daemon.json -> startDaemon proceeds (treats as no state)
|
||||
it("malformed daemon.json -> startDaemon proceeds normally", async () => {
|
||||
const deps = mockDeps({
|
||||
const deps = startableDeps({
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return "GARBAGE";
|
||||
@@ -500,7 +641,7 @@ describe("Daemon Lifecycle", () => {
|
||||
|
||||
// Test 25: start spawns process.execPath, not bare "node"
|
||||
it("start: spawns process.execPath as the Node binary", async () => {
|
||||
const deps = mockDeps();
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433, db: "openrig.sqlite" }, deps);
|
||||
|
||||
const spawnMock = deps.spawn as ReturnType<typeof vi.fn>;
|
||||
@@ -638,3 +779,142 @@ describe("resolveDaemonPath", () => {
|
||||
expect(result).toBe(path.resolve("/repo/packages/cli/dist", "../../daemon"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("startDaemon env sanitization", () => {
|
||||
it("startDaemon spawns with sanitized env that excludes terminal/GUI vars", async () => {
|
||||
// Pollute process.env temporarily
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
const pollutants: Record<string, string> = {
|
||||
GHOSTTY_BIN_DIR: "/bad",
|
||||
TERM_PROGRAM: "ghostty",
|
||||
CMUX_WORKSPACE: "workspace:1",
|
||||
CMUX_PANEL_ID: "panel:7",
|
||||
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
|
||||
CODEX_CI: "1",
|
||||
COMMAND_MODE: "unix2003",
|
||||
__CFBundleIdentifier: "com.test",
|
||||
};
|
||||
for (const [k, v] of Object.entries(pollutants)) {
|
||||
saved[k] = process.env[k];
|
||||
process.env[k] = v;
|
||||
}
|
||||
|
||||
try {
|
||||
const deps = startableDeps();
|
||||
await startDaemon({ port: 7433, host: "127.0.0.1", db: "/tmp/test.db" }, deps);
|
||||
|
||||
const spawnCall = (deps.spawn as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const spawnEnv = spawnCall![2].env as Record<string, string>;
|
||||
|
||||
// Scrubbed vars must NOT be in spawn env
|
||||
expect(spawnEnv["GHOSTTY_BIN_DIR"]).toBeUndefined();
|
||||
expect(spawnEnv["TERM_PROGRAM"]).toBeUndefined();
|
||||
expect(spawnEnv["CMUX_WORKSPACE"]).toBeUndefined();
|
||||
expect(spawnEnv["CODEX_CI"]).toBeUndefined();
|
||||
expect(spawnEnv["COMMAND_MODE"]).toBeUndefined();
|
||||
expect(spawnEnv["__CFBundleIdentifier"]).toBeUndefined();
|
||||
|
||||
// cmux context needed for workspace-bound commands should survive
|
||||
expect(spawnEnv["CMUX_PANEL_ID"]).toBe("panel:7");
|
||||
expect(spawnEnv["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
|
||||
|
||||
// Core vars must be present
|
||||
expect(spawnEnv["HOME"]).toBeDefined();
|
||||
expect(spawnEnv["OPENRIG_PORT"]).toBe("7433");
|
||||
} finally {
|
||||
// Restore process.env
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDaemonEnv", () => {
|
||||
it("strips terminal/GUI vars and preserves core vars", () => {
|
||||
const baseEnv: Record<string, string> = {
|
||||
HOME: "/Users/tester",
|
||||
PATH: "/opt/homebrew/bin:/usr/bin:/bin",
|
||||
USER: "tester",
|
||||
SHELL: "/bin/zsh",
|
||||
COLORTERM: "truecolor",
|
||||
COMMAND_MODE: "unix2003",
|
||||
CODEX_CI: "1",
|
||||
CODEX_THREAD_ID: "thread-123",
|
||||
GHOSTTY_BIN_DIR: "/Applications/Ghostty.app/bin",
|
||||
GHOSTTY_RESOURCES_DIR: "/Applications/Ghostty.app/resources",
|
||||
GHOSTTY_SHELL_FEATURES: "cursor,title",
|
||||
TERM_PROGRAM: "ghostty",
|
||||
TERM_PROGRAM_VERSION: "1.0",
|
||||
TERMINFO: "/Applications/Ghostty.app/terminfo",
|
||||
XPC_FLAGS: "0x0",
|
||||
XPC_SERVICE_NAME: "0",
|
||||
__CFBundleIdentifier: "com.cmuxterm.app",
|
||||
__CF_USER_TEXT_ENCODING: "0x1F5:0x0:0x0",
|
||||
CMUX_BUNDLE_ID: "com.cmuxterm.app",
|
||||
CMUX_BUNDLED_CLI_PATH: "/Applications/cmux.app/Contents/Resources/bin/cmux",
|
||||
CMUX_PANEL_ID: "panel:1",
|
||||
CMUX_SOCKET_PATH: "/tmp/cmux.sock",
|
||||
CMUX_TAB_ID: "tab:1",
|
||||
CMUX_WORKSPACE: "workspace:1",
|
||||
CMUX_WORKSPACE_ID: "workspace:1",
|
||||
CMUX_SURFACE_ID: "surface:3",
|
||||
LANG: "en_US.UTF-8",
|
||||
};
|
||||
|
||||
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/tmp/test.db" });
|
||||
|
||||
// Core vars preserved
|
||||
expect(result["HOME"]).toBe("/Users/tester");
|
||||
expect(result["PATH"]).toBe("/opt/homebrew/bin:/usr/bin:/bin");
|
||||
expect(result["USER"]).toBe("tester");
|
||||
expect(result["SHELL"]).toBe("/bin/zsh");
|
||||
expect(result["LANG"]).toBe("en_US.UTF-8");
|
||||
|
||||
// OPENRIG_* set from opts
|
||||
expect(result["OPENRIG_PORT"]).toBe("7433");
|
||||
expect(result["OPENRIG_HOST"]).toBe("127.0.0.1");
|
||||
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
|
||||
|
||||
// Scrubbed vars absent
|
||||
expect(result["COLORTERM"]).toBeUndefined();
|
||||
expect(result["COMMAND_MODE"]).toBeUndefined();
|
||||
expect(result["CODEX_CI"]).toBeUndefined();
|
||||
expect(result["CODEX_THREAD_ID"]).toBeUndefined();
|
||||
expect(result["GHOSTTY_BIN_DIR"]).toBeUndefined();
|
||||
expect(result["GHOSTTY_RESOURCES_DIR"]).toBeUndefined();
|
||||
expect(result["GHOSTTY_SHELL_FEATURES"]).toBeUndefined();
|
||||
expect(result["TERM_PROGRAM"]).toBeUndefined();
|
||||
expect(result["TERM_PROGRAM_VERSION"]).toBeUndefined();
|
||||
expect(result["TERMINFO"]).toBeUndefined();
|
||||
expect(result["XPC_FLAGS"]).toBeUndefined();
|
||||
expect(result["XPC_SERVICE_NAME"]).toBeUndefined();
|
||||
expect(result["__CFBundleIdentifier"]).toBeUndefined();
|
||||
expect(result["__CF_USER_TEXT_ENCODING"]).toBeUndefined();
|
||||
expect(result["CMUX_SOCKET_PATH"]).toBeUndefined();
|
||||
expect(result["CMUX_WORKSPACE"]).toBeUndefined();
|
||||
expect(result["CMUX_SURFACE_ID"]).toBeUndefined();
|
||||
|
||||
// cmux context needed for workspace-bound commands is preserved
|
||||
expect(result["CMUX_BUNDLE_ID"]).toBe("com.cmuxterm.app");
|
||||
expect(result["CMUX_BUNDLED_CLI_PATH"]).toBe("/Applications/cmux.app/Contents/Resources/bin/cmux");
|
||||
expect(result["CMUX_PANEL_ID"]).toBe("panel:1");
|
||||
expect(result["CMUX_TAB_ID"]).toBe("tab:1");
|
||||
expect(result["CMUX_WORKSPACE_ID"]).toBe("workspace:1");
|
||||
});
|
||||
|
||||
it("explicit opts override inherited OPENRIG_* from base env", () => {
|
||||
const baseEnv: Record<string, string> = {
|
||||
HOME: "/Users/tester",
|
||||
PATH: "/usr/bin",
|
||||
OPENRIG_PORT: "9999",
|
||||
OPENRIG_DB: "/old/path.db",
|
||||
};
|
||||
|
||||
const result = buildDaemonEnv(baseEnv, { port: 7433, host: "127.0.0.1", db: "/new/path.db" });
|
||||
|
||||
expect(result["OPENRIG_PORT"]).toBe("7433");
|
||||
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import Database from "better-sqlite3";
|
||||
import {
|
||||
DESTROY_CONFIRM_TOKEN,
|
||||
buildBackupPath,
|
||||
buildDestroyPlan,
|
||||
executeDestroy,
|
||||
listManagedTmuxSessionsFromDb,
|
||||
type DestroyDeps,
|
||||
} from "../src/destroy-helpers.js";
|
||||
import { destroyCommand } from "../src/commands/destroy.js";
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function makeDestroyDeps(overrides?: Partial<DestroyDeps>): DestroyDeps {
|
||||
return {
|
||||
stopDaemon: vi.fn(async () => {}),
|
||||
inspectListener: vi.fn(async () => ({ kind: "unreachable" as const })),
|
||||
findListeningPid: vi.fn(() => null),
|
||||
killProcess: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
renamePath: vi.fn(),
|
||||
removePath: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
listManagedTmuxSessions: vi.fn(() => []),
|
||||
killTmuxSession: vi.fn(() => true),
|
||||
sleep: vi.fn(async () => {}),
|
||||
now: vi.fn(() => new Date("2026-04-10T08:10:11Z")),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("destroy helpers", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "openrig-destroy-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("buildBackupPath appends timestamp and resolves collisions", () => {
|
||||
const target = join(tmpDir, ".openrig");
|
||||
const first = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
|
||||
expect(basename(first)).toMatch(/^\.openrig\.backup-20260410-\d{6}$/);
|
||||
|
||||
mkdirSync(first, { recursive: true });
|
||||
const second = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
|
||||
expect(second).toBe(`${first}-2`);
|
||||
});
|
||||
|
||||
it("listManagedTmuxSessionsFromDb returns distinct tmux-backed sessions", () => {
|
||||
const dbPath = join(tmpDir, "openrig.sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE nodes (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
tmux_session TEXT,
|
||||
tmux_window TEXT,
|
||||
tmux_pane TEXT,
|
||||
cmux_workspace TEXT,
|
||||
cmux_surface TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
attachment_type TEXT NOT NULL DEFAULT 'tmux',
|
||||
external_session_name TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n3");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
|
||||
.run("b1", "n1", "orch1-lead@demo", "tmux");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
|
||||
.run("b2", "n2", "orch1-lead@demo", "tmux");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
|
||||
.run("s3", "n3", "dev1-impl@demo");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, attachment_type, external_session_name) VALUES (?, ?, ?, ?)")
|
||||
.run("b3", "n3", "external_cli", "ext-cli");
|
||||
db.close();
|
||||
|
||||
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["orch1-lead@demo"]);
|
||||
});
|
||||
|
||||
it("listManagedTmuxSessionsFromDb prefers the current session for a node over historical names", () => {
|
||||
const dbPath = join(tmpDir, "openrig-current.sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE nodes (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
tmux_session TEXT,
|
||||
tmux_window TEXT,
|
||||
tmux_pane TEXT,
|
||||
cmux_workspace TEXT,
|
||||
cmux_surface TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
attachment_type TEXT NOT NULL DEFAULT 'tmux',
|
||||
external_session_name TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run("s1", "n1", "old-name@demo", "2026-04-10T08:00:00Z");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)")
|
||||
.run("s2", "n1", "current-name@demo", "2026-04-10T08:05:00Z");
|
||||
db.close();
|
||||
|
||||
expect(listManagedTmuxSessionsFromDb(dbPath)).toEqual(["current-name@demo"]);
|
||||
});
|
||||
|
||||
it("buildDestroyPlan includes external db/transcript targets outside state root", () => {
|
||||
const stateRoot = join(tmpDir, ".openrig");
|
||||
const externalDb = join(tmpDir, "custom", "openrig.sqlite");
|
||||
const externalTx = join(tmpDir, "custom", "transcripts");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
mkdirSync(join(tmpDir, "custom"), { recursive: true });
|
||||
writeFileSync(externalDb, "");
|
||||
mkdirSync(externalTx, { recursive: true });
|
||||
|
||||
const deps = makeDestroyDeps({
|
||||
exists: (path: string) => existsSync(path),
|
||||
listManagedTmuxSessions: () => [],
|
||||
});
|
||||
|
||||
const plan = buildDestroyPlan("state", true, {
|
||||
stateRoot,
|
||||
dbPath: externalDb,
|
||||
transcriptsPath: externalTx,
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
}, deps);
|
||||
|
||||
expect(plan.targets.map((target) => target.kind)).toEqual(["state_root", "db_file", "transcripts_dir"]);
|
||||
expect(plan.warnings.length).toBe(2);
|
||||
});
|
||||
|
||||
it("executeDestroy stops daemon, kills managed tmux sessions, backs up state root, and recreates it", async () => {
|
||||
const stateRoot = join(tmpDir, ".openrig");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
writeFileSync(join(stateRoot, "daemon.json"), "{}");
|
||||
const dbPath = join(stateRoot, "openrig.sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE nodes (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
tmux_session TEXT,
|
||||
tmux_window TEXT,
|
||||
tmux_pane TEXT,
|
||||
cmux_workspace TEXT,
|
||||
cmux_surface TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
attachment_type TEXT NOT NULL DEFAULT 'tmux',
|
||||
external_session_name TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
session_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
last_seen_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n1");
|
||||
db.prepare("INSERT INTO nodes (id) VALUES (?)").run("n2");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session, attachment_type) VALUES (?, ?, ?, ?)")
|
||||
.run("b1", "n1", "orch1-lead@demo", "tmux");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)")
|
||||
.run("s2", "n2", "dev1-impl@demo");
|
||||
db.close();
|
||||
|
||||
const deps = makeDestroyDeps({
|
||||
exists: (path: string) => existsSync(path),
|
||||
renamePath: (from: string, to: string) => renameSync(from, to),
|
||||
mkdirp: (path: string) => mkdirSync(path, { recursive: true }),
|
||||
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
|
||||
killTmuxSession: (sessionName: string) => sessionName !== "dev1-impl@demo",
|
||||
inspectListener: vi.fn()
|
||||
.mockResolvedValueOnce({ kind: "openrig" })
|
||||
.mockResolvedValueOnce({ kind: "unreachable" })
|
||||
.mockResolvedValueOnce({ kind: "unreachable" }),
|
||||
findListeningPid: () => 1234,
|
||||
});
|
||||
|
||||
const plan = buildDestroyPlan("all", true, {
|
||||
stateRoot,
|
||||
dbPath,
|
||||
transcriptsPath: join(stateRoot, "transcripts"),
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
}, deps);
|
||||
|
||||
const result = await executeDestroy(plan, deps);
|
||||
expect(result.daemonStopped).toBe(true);
|
||||
expect(result.portCleared).toBe(true);
|
||||
expect(result.stateRecreated).toBe(true);
|
||||
expect(result.tmuxKilled).toBe(1);
|
||||
expect(result.tmuxMissing).toBe(1);
|
||||
expect(result.backupPaths).toHaveLength(1);
|
||||
expect(existsSync(stateRoot)).toBe(true);
|
||||
expect(deps.killProcess).toHaveBeenCalledWith(1234);
|
||||
});
|
||||
|
||||
it("executeDestroy leaves state untouched when a local listener is still live but cannot be safely cleared", async () => {
|
||||
const stateRoot = join(tmpDir, ".openrig-live");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
const removePath = vi.fn();
|
||||
const mkdirp = vi.fn();
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => true,
|
||||
removePath,
|
||||
mkdirp,
|
||||
inspectListener: vi.fn(async () => ({ kind: "openrig" })),
|
||||
findListeningPid: () => null,
|
||||
});
|
||||
|
||||
const result = await executeDestroy({
|
||||
scope: "state",
|
||||
backup: false,
|
||||
stateRoot,
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
managedTmuxSessions: [],
|
||||
targets: [{ path: stateRoot, kind: "state_root" }],
|
||||
warnings: [],
|
||||
}, deps);
|
||||
|
||||
expect(result.portCleared).toBe(false);
|
||||
expect(result.stateRecreated).toBe(false);
|
||||
expect(removePath).not.toHaveBeenCalled();
|
||||
expect(mkdirp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executeDestroy does not kill a live non-OpenRig HTTP listener", async () => {
|
||||
const killProcess = vi.fn();
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => true,
|
||||
removePath: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
inspectListener: vi.fn(async () => ({ kind: "other_http", detail: "HTTP 200" })),
|
||||
findListeningPid: () => 7777,
|
||||
killProcess,
|
||||
});
|
||||
|
||||
const result = await executeDestroy({
|
||||
scope: "state",
|
||||
backup: false,
|
||||
stateRoot: join(tmpDir, ".openrig-http"),
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
managedTmuxSessions: [],
|
||||
targets: [{ path: join(tmpDir, ".openrig-http"), kind: "state_root" }],
|
||||
warnings: [],
|
||||
}, deps);
|
||||
|
||||
expect(result.portCleared).toBe(false);
|
||||
expect(killProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executeDestroy does not enumerate or kill tmux sessions for --state", async () => {
|
||||
const stateRoot = join(tmpDir, ".openrig-state-only");
|
||||
mkdirSync(stateRoot, { recursive: true });
|
||||
const listManagedTmuxSessions = vi.fn(() => ["orchestrator@demo"]);
|
||||
const killTmuxSession = vi.fn(() => true);
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => true,
|
||||
removePath: vi.fn(),
|
||||
mkdirp: vi.fn(),
|
||||
listManagedTmuxSessions,
|
||||
killTmuxSession,
|
||||
});
|
||||
|
||||
const plan = buildDestroyPlan("state", false, {
|
||||
stateRoot,
|
||||
dbPath: join(stateRoot, "openrig.sqlite"),
|
||||
transcriptsPath: join(stateRoot, "transcripts"),
|
||||
daemonHost: "127.0.0.1",
|
||||
daemonPort: 7433,
|
||||
}, deps);
|
||||
await executeDestroy(plan, deps);
|
||||
|
||||
expect(listManagedTmuxSessions).not.toHaveBeenCalled();
|
||||
expect(killTmuxSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executeDestroy does not report remote daemon targets as cleared", async () => {
|
||||
const deps = makeDestroyDeps({
|
||||
exists: () => false,
|
||||
mkdirp: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await executeDestroy({
|
||||
scope: "state",
|
||||
backup: false,
|
||||
stateRoot: join(tmpDir, ".openrig-remote"),
|
||||
daemonHost: "10.0.0.5",
|
||||
daemonPort: 7433,
|
||||
managedTmuxSessions: [],
|
||||
targets: [],
|
||||
warnings: [],
|
||||
}, deps);
|
||||
|
||||
expect(result.daemonStopped).toBe(false);
|
||||
expect(result.portCleared).toBe(false);
|
||||
expect(result.stateRecreated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy command", () => {
|
||||
it("requires exactly one destroy scope", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps: makeDestroyDeps(),
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("Specify exactly one destroy scope");
|
||||
});
|
||||
|
||||
it("requires explicit confirmation token", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps: makeDestroyDeps(),
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", "wrong"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain(`Destroy requires: --confirm ${DESTROY_CONFIRM_TOKEN}`);
|
||||
});
|
||||
|
||||
it("requires --yes", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps: makeDestroyDeps(),
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("Destroy requires --yes");
|
||||
});
|
||||
|
||||
it("runs destroy with backup mode and prints plan/result", async () => {
|
||||
const destroyDeps = makeDestroyDeps({
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/db" }, transcripts: { enabled: true, path: "/tmp/tx" } }) },
|
||||
destroyDeps,
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--backup", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("DESTROY PLAN");
|
||||
expect(output).toContain("scope: state");
|
||||
expect(output).toContain("DESTROY RESULT");
|
||||
expect(output).toContain("fresh state root:");
|
||||
});
|
||||
|
||||
it("falls back to compatibility defaults when config resolution is malformed", async () => {
|
||||
const destroyDeps = makeDestroyDeps({
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
});
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(destroyCommand({
|
||||
configStore: { resolve: () => { throw new Error("bad config json"); } },
|
||||
destroyDeps,
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "destroy", "--state", "--yes", "--confirm", DESTROY_CONFIRM_TOKEN]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBeUndefined();
|
||||
expect(logs.join("\n")).toContain("DESTROY PLAN");
|
||||
expect(logs.join("\n")).toContain("bad config json");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { doctorCommand, runDoctorChecks, type DoctorDeps } from "../src/commands/doctor.js";
|
||||
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
|
||||
|
||||
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
|
||||
|
||||
const defaultConfig = {
|
||||
daemon: { port: 7433, host: "127.0.0.1" },
|
||||
@@ -12,9 +15,16 @@ function makeDeps(overrides?: Partial<DoctorDeps>): DoctorDeps {
|
||||
return {
|
||||
exists: () => true,
|
||||
baseDir: "/install/cli/dist",
|
||||
exec: () => "tmux 3.4\n",
|
||||
readFile: () => null,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
checkPort: async () => true,
|
||||
configStore: { resolve: () => defaultConfig },
|
||||
platform: "darwin",
|
||||
mkdirp: () => {},
|
||||
checkWritable: () => {},
|
||||
...overrides,
|
||||
@@ -68,20 +78,309 @@ describe("runDoctorChecks", () => {
|
||||
});
|
||||
|
||||
it("tmux available -> pass", () => {
|
||||
const deps = makeDeps({ exec: () => "tmux 3.4" });
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") return "";
|
||||
if (cmd === "tmux show-options -gqv mouse") return "on\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const tmuxCheck = checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("tmux installed but default control socket unhealthy -> fail with attention guidance", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const tmuxCheck = checks.find((c) => c.name === "tmux");
|
||||
|
||||
expect(tmuxCheck?.status).toBe("fail");
|
||||
expect(tmuxCheck?.message).toContain("control socket");
|
||||
expect(tmuxCheck?.reason).toContain("server exited unexpectedly");
|
||||
expect(tmuxCheck?.fix).toContain("restart the default tmux server");
|
||||
});
|
||||
|
||||
it("tmux mouse enabled on macOS -> pass", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux show-options -gqv mouse") return "on\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
|
||||
expect(mouseCheck?.status).toBe("pass");
|
||||
expect(mouseCheck?.message).toContain("enabled");
|
||||
});
|
||||
|
||||
it("tmux mouse disabled on macOS -> warn with exact fix", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux show-options -gqv mouse") return "off\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const mouseCheck = checks.find((c) => c.name === "tmux_mouse");
|
||||
expect(mouseCheck?.status).toBe("warn");
|
||||
expect(mouseCheck?.message).toContain("disabled");
|
||||
expect(mouseCheck?.fix).toContain("tmux set -g mouse on");
|
||||
expect(mouseCheck?.fix).toContain("~/.tmux.conf");
|
||||
expect(mouseCheck?.fix).toContain("tmux source-file ~/.tmux.conf");
|
||||
});
|
||||
|
||||
it("tmux missing -> fail with guidance", () => {
|
||||
const deps = makeDeps({ exec: () => { throw new Error("not found"); } });
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") throw new Error("not found");
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const tmuxCheck = checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck?.status).toBe("fail");
|
||||
expect(tmuxCheck?.fix).toContain("brew");
|
||||
});
|
||||
|
||||
it("tmux mouse check is omitted on non-macOS hosts", () => {
|
||||
const deps = makeDeps({
|
||||
platform: "linux",
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
expect(checks.find((c) => c.name === "tmux_mouse")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cmux_shell pass when shell capabilities work", () => {
|
||||
const deps = makeDeps();
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("cmux_shell warn when shell cmux installed but control unavailable", () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") {
|
||||
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
|
||||
}
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("warn");
|
||||
expect(cmuxShell?.message).toContain("control unavailable");
|
||||
expect(cmuxShell?.fix).toContain("cmuxOnly");
|
||||
expect(cmuxShell?.fix).toContain("socketControlMode");
|
||||
});
|
||||
|
||||
it("cmux_shell warn when cmux missing", () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
throw new Error("command not found: cmux");
|
||||
},
|
||||
});
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
const cmuxShell = checks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("warn");
|
||||
expect(cmuxShell?.message).toContain("not found");
|
||||
});
|
||||
|
||||
it("shell cmux pass + daemon cmux unavailable -> cmux_daemon warn with mismatch guidance", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false, // port in use = daemon running
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxShell = allChecks.find((c) => c.name === "cmux_shell");
|
||||
expect(cmuxShell?.status).toBe("pass");
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.message).toContain("daemon cannot control");
|
||||
expect(cmuxDaemon?.fix).toContain("rig daemon start");
|
||||
});
|
||||
|
||||
it("shell cmux pass + restrictive macOS socket control + daemon cmux unavailable -> cmux_daemon points at socket control mode", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("socketControlMode");
|
||||
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
|
||||
expect(cmuxDaemon?.fix).toContain("rig setup");
|
||||
expect(cmuxDaemon?.fix).toContain("automation");
|
||||
});
|
||||
|
||||
it("shell cmux pass + implicit default cmuxOnly + daemon cmux unavailable -> cmux_daemon points at effective default, not a fake file value", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? null : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("default");
|
||||
expect(cmuxDaemon?.reason).toContain("cmuxOnly");
|
||||
expect(cmuxDaemon?.reason).not.toContain("is 'cmuxOnly' in");
|
||||
});
|
||||
|
||||
it("shell cmux pass + unreadable cmux settings -> cmux_daemon warns about parse failure", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n invalid\n" : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("unreadable");
|
||||
expect(cmuxDaemon?.fix).toContain("~/.config/cmux/settings.json");
|
||||
});
|
||||
|
||||
it("shell cmux pass + compatible password mode + daemon cmux unavailable -> falls back to generic daemon warning", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false,
|
||||
});
|
||||
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon?.status).toBe("warn");
|
||||
expect(cmuxDaemon?.reason).toContain("inherited a terminal/session environment");
|
||||
expect(cmuxDaemon?.reason).not.toContain("socketControlMode");
|
||||
});
|
||||
|
||||
it("daemon not running -> cmux_daemon skipped and does not make doctor unhealthy", async () => {
|
||||
const deps = makeDeps({
|
||||
checkPort: async () => true,
|
||||
fetch: async () => { throw new Error("ECONNREFUSED"); },
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("skipped");
|
||||
expect(cmuxDaemon?.message).toContain("not reachable");
|
||||
|
||||
const healthy = allChecks.every((c) => c.status !== "fail");
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("daemon cmux available -> cmux_daemon pass", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
checkPort: async () => false, // port in use = daemon running
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("shell cmux missing -> no cmux_daemon check (no misleading signal)", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
throw new Error("command not found: cmux");
|
||||
},
|
||||
checkPort: async () => false, // daemon running
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
});
|
||||
const { checks, asyncChecks } = runDoctorChecks(deps);
|
||||
const resolved = await Promise.all(asyncChecks ?? []);
|
||||
const allChecks = [...checks, ...resolved];
|
||||
|
||||
const cmuxDaemon = allChecks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Node version check passes on current Node", () => {
|
||||
const deps = makeDeps();
|
||||
const { checks } = runDoctorChecks(deps);
|
||||
@@ -174,6 +473,31 @@ describe("rig doctor", () => {
|
||||
expect(parsed.checks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("--json stays healthy when cmux is only a warning", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") {
|
||||
throw new Error("Failed to connect to socket at /tmp/cmux.sock");
|
||||
}
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.addCommand(doctorCommand(deps));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "doctor", "--json"]),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
const cmuxShellCheck = parsed.checks.find((check: { name: string }) => check.name === "cmux_shell");
|
||||
expect(parsed.healthy).toBe(true);
|
||||
expect(cmuxShellCheck.status).toBe("warn");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("--json exits non-zero when writable state paths fail", async () => {
|
||||
const deps = makeDeps({
|
||||
checkWritable: () => {
|
||||
@@ -194,6 +518,49 @@ describe("rig doctor", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("--json surfaces cmux_daemon pass when daemon cmux is available", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.addCommand(doctorCommand(deps));
|
||||
|
||||
const { logs } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "doctor", "--json"]),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("--json surfaces cmux_daemon warn when shell cmux works but daemon cannot control", async () => {
|
||||
const deps = makeDeps({
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true } as Response;
|
||||
if (url.includes("/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) } as Response;
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
});
|
||||
const program = new Command();
|
||||
program.addCommand(doctorCommand(deps));
|
||||
|
||||
const { logs } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "doctor", "--json"]),
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
const cmuxDaemon = parsed.checks.find((check: { name: string }) => check.name === "cmux_daemon");
|
||||
expect(cmuxDaemon).toBeDefined();
|
||||
expect(cmuxDaemon.status).toBe("warn");
|
||||
expect(parsed.healthy).toBe(true); // warn does not make doctor unhealthy
|
||||
});
|
||||
|
||||
it("wired via createProgram", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const program = createProgram();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { envCommand } from "../src/commands/env.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try { await fn(); } finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
describe("rig env", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
const rigSummary = [{ id: "rig-1", name: "my-rig", nodeCount: 2 }];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
const url = req.url ?? "";
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
if (url === "/api/rigs/summary") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(rigSummary));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/env/logs")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, output: "vault | 2026-04-09 12:00:00 Starting vault server..." }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/env/down") && req.method === "POST") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.includes("/env")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
hasServices: true,
|
||||
kind: "compose",
|
||||
projectName: "my-rig",
|
||||
receipt: {
|
||||
kind: "compose",
|
||||
services: [{ name: "vault", status: "running", health: "healthy" }],
|
||||
waitFor: [],
|
||||
capturedAt: "2026-04-09T00:00:00Z",
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => { server.listen(0, resolve); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
function runningDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-04-09T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(envCommand(runningDeps()));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("env help explains managed-app usage", () => {
|
||||
const logs: string[] = [];
|
||||
const cmd = makeCmd().commands.find((c) => c.name() === "env")!;
|
||||
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
|
||||
cmd.outputHelp();
|
||||
const help = logs.join("");
|
||||
expect(help).toContain("Inspect and control rig environment services");
|
||||
expect(help).toContain("service-backed rigs and managed");
|
||||
expect(help).toContain("rig env status secrets-manager");
|
||||
expect(help).toContain("rig env logs secrets-manager vault");
|
||||
});
|
||||
|
||||
it("env status prints service status", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig"]);
|
||||
});
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("vault");
|
||||
expect(output).toContain("running");
|
||||
});
|
||||
|
||||
it("env status --json returns machine-readable output", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "status", "my-rig", "--json"]);
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(logs.join(""));
|
||||
expect(parsed.hasServices).toBe(true);
|
||||
expect(parsed.receipt.services).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("env logs returns service logs", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "logs", "my-rig"]);
|
||||
});
|
||||
|
||||
expect(logs.join("\n")).toContain("Starting vault server");
|
||||
});
|
||||
|
||||
it("env down stops services", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "env", "down", "my-rig"]);
|
||||
});
|
||||
|
||||
expect(logs.join("\n")).toContain("Services stopped");
|
||||
});
|
||||
|
||||
it("env is wired via createProgram", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const program = createProgram();
|
||||
const envCmd = program.commands.find((c) => c.name() === "env");
|
||||
expect(envCmd).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,10 @@ function runningLifecycleDeps(port: number): LifecycleDeps {
|
||||
}
|
||||
|
||||
function stoppedLifecycleDeps(): LifecycleDeps {
|
||||
return mockLifecycleDeps({ exists: vi.fn(() => false) });
|
||||
return mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
});
|
||||
}
|
||||
|
||||
function unhealthyLifecycleDeps(): LifecycleDeps {
|
||||
@@ -434,4 +437,40 @@ describe("rig export + import", () => {
|
||||
expect(output).toContain("Attach:");
|
||||
expect(output).toContain("tmux attach -t orch-lead@imported-rig");
|
||||
});
|
||||
|
||||
it("import --instantiate uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const postText = vi.fn(async (
|
||||
_path: string,
|
||||
_text: string,
|
||||
_contentType?: string,
|
||||
_extraHeaders?: Record<string, string>,
|
||||
options?: { timeoutMs?: number },
|
||||
) => {
|
||||
timeoutMs = options?.timeoutMs;
|
||||
return {
|
||||
status: 201,
|
||||
data: {
|
||||
rigId: "rig-new",
|
||||
specName: "imported-rig",
|
||||
specVersion: "0.1.0",
|
||||
nodes: [{ logicalId: "orchestrator", status: "launched" }],
|
||||
attachCommand: "tmux attach -t orch-lead@imported-rig",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const deps: ImportDeps = {
|
||||
lifecycleDeps: runningLifecycleDeps(port),
|
||||
clientFactory: () => ({ postText } as unknown as DaemonClient),
|
||||
readFile: vi.fn(() => 'version: "0.2"\nname: test\npods:\n - id: dev\n label: Dev\n members:\n - id: impl\n agent_ref: "local:agents/impl"\n profile: default\n runtime: claude-code\n cwd: .\n edges: []\nedges: []'),
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.addCommand(importCommand(deps));
|
||||
|
||||
await captureLogs(() => program.parseAsync(["node", "rig", "import", "rig.yaml", "--instantiate"]));
|
||||
|
||||
expect(timeoutMs).toBe(120_000);
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,16 @@ describe("Lifecycle CLI commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/rigs/rig-1/nodes/dev.pod/launch") {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
ok: false,
|
||||
code: "pod_aware_launch_unsupported",
|
||||
error: "Pod-aware node launch via this route bypasses startup orchestration. Use rig up, rig import --instantiate, or rig restore instead.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/api/sessions/missing/unclaim") {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "Claimed session 'missing' not found." }));
|
||||
@@ -269,6 +279,20 @@ describe("Lifecycle CLI commands", () => {
|
||||
expect(logs.join("\n")).toContain("dev-qa@test");
|
||||
});
|
||||
|
||||
it("launch surfaces honest pod-aware rejection and exits non-zero", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.addCommand(launchCommand(runningDeps()));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await program.parseAsync(["node", "rig", "launch", "rig-1", "dev.pod"]);
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(logs.join("\n")).toContain("bypasses startup orchestration");
|
||||
expect(logs.join("\n")).toContain("rig import --instantiate");
|
||||
});
|
||||
|
||||
it("--json prints raw unclaim payload", async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
|
||||
@@ -81,12 +81,16 @@ describe("MCP Server", () => {
|
||||
arguments: { sourceRef: "/tmp/rig.yaml", plan: false, autoApprove: true },
|
||||
});
|
||||
|
||||
expect(postFn).toHaveBeenCalledWith("/api/up", {
|
||||
sourceRef: "/tmp/rig.yaml",
|
||||
plan: false,
|
||||
autoApprove: true,
|
||||
targetRoot: undefined,
|
||||
});
|
||||
expect(postFn).toHaveBeenCalledWith(
|
||||
"/api/up",
|
||||
{
|
||||
sourceRef: "/tmp/rig.yaml",
|
||||
plan: false,
|
||||
autoApprove: true,
|
||||
targetRoot: undefined,
|
||||
},
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
const content = result.content as Array<{ type: string; text: string }>;
|
||||
const parsed = JSON.parse(content[0].text);
|
||||
expect(parsed.status).toBe("completed");
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_OPENRIG_HOME = process.env.OPENRIG_HOME;
|
||||
const ORIGINAL_RIGGED_HOME = process.env.RIGGED_HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_OPENRIG_HOME === undefined) delete process.env.OPENRIG_HOME;
|
||||
else process.env.OPENRIG_HOME = ORIGINAL_OPENRIG_HOME;
|
||||
|
||||
if (ORIGINAL_RIGGED_HOME === undefined) delete process.env.RIGGED_HOME;
|
||||
else process.env.RIGGED_HOME = ORIGINAL_RIGGED_HOME;
|
||||
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("openrig-compat", () => {
|
||||
it("getOpenRigHome prefers OPENRIG_HOME when set", async () => {
|
||||
process.env.OPENRIG_HOME = "/tmp/custom-openrig-home";
|
||||
delete process.env.RIGGED_HOME;
|
||||
|
||||
const mod = await import("../src/openrig-compat.js");
|
||||
|
||||
expect(mod.getOpenRigHome()).toBe("/tmp/custom-openrig-home");
|
||||
expect(mod.getDefaultOpenRigPath("daemon.json")).toBe("/tmp/custom-openrig-home/daemon.json");
|
||||
});
|
||||
|
||||
it("getOpenRigHome falls back to RIGGED_HOME with warning", async () => {
|
||||
delete process.env.OPENRIG_HOME;
|
||||
process.env.RIGGED_HOME = "/tmp/legacy-rigged-home";
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const mod = await import("../src/openrig-compat.js");
|
||||
|
||||
expect(mod.getOpenRigHome()).toBe("/tmp/legacy-rigged-home");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
"Warning: RIGGED_HOME is deprecated; use OPENRIG_HOME instead.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,10 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
function stoppedDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
clientFactory: vi.fn() as unknown as StatusDeps["clientFactory"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,9 +9,17 @@ import { ConfigStore } from "../src/config-store.js";
|
||||
import { preflightCommand } from "../src/commands/preflight.js";
|
||||
import type { DaemonStatus } from "../src/daemon-lifecycle.js";
|
||||
|
||||
function makeExec(tmuxOk = true): (cmd: string) => Promise<string> {
|
||||
function makeExec(tmuxMode: "healthy" | "missing" | "unhealthy" = "healthy"): (cmd: string) => Promise<string> {
|
||||
return async (cmd: string) => {
|
||||
if (cmd.includes("tmux") && !tmuxOk) throw new Error("command not found: tmux");
|
||||
if (cmd === "tmux -V") {
|
||||
if (tmuxMode === "missing") throw new Error("command not found: tmux");
|
||||
return "tmux 3.6a";
|
||||
}
|
||||
if (cmd === "tmux list-sessions") {
|
||||
if (tmuxMode === "missing") throw new Error("command not found: tmux");
|
||||
if (tmuxMode === "unhealthy") throw new Error("server exited unexpectedly");
|
||||
return "";
|
||||
}
|
||||
if (cmd.includes("tmux")) return "tmux 3.6a";
|
||||
return "";
|
||||
};
|
||||
@@ -36,7 +44,7 @@ describe("SystemPreflight", () => {
|
||||
});
|
||||
|
||||
function createPreflight(opts?: {
|
||||
tmuxOk?: boolean;
|
||||
tmuxMode?: "healthy" | "missing" | "unhealthy";
|
||||
configOverrides?: Record<string, unknown>;
|
||||
daemonStatus?: Partial<DaemonStatus>;
|
||||
}) {
|
||||
@@ -47,7 +55,7 @@ describe("SystemPreflight", () => {
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
return new SystemPreflight({
|
||||
exec: makeExec(opts?.tmuxOk ?? true),
|
||||
exec: makeExec(opts?.tmuxMode ?? "healthy"),
|
||||
configStore: config,
|
||||
getDaemonStatus: makeStatus(opts?.daemonStatus),
|
||||
riggedHome: homeDir,
|
||||
@@ -77,7 +85,7 @@ describe("SystemPreflight", () => {
|
||||
|
||||
// Test 3
|
||||
it("tmux missing → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxOk: false });
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
@@ -86,6 +94,18 @@ describe("SystemPreflight", () => {
|
||||
expect(tmuxCheck!.fix).toContain("install tmux");
|
||||
});
|
||||
|
||||
it("tmux installed but default control socket unhealthy → ready: false with guidance", async () => {
|
||||
const pf = createPreflight({ tmuxMode: "unhealthy" });
|
||||
const result = await pf.run();
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
const tmuxCheck = result.checks.find((c) => c.name === "tmux");
|
||||
expect(tmuxCheck!.ok).toBe(false);
|
||||
expect(tmuxCheck!.error).toContain("control socket");
|
||||
expect(tmuxCheck!.reason).toContain("server exited unexpectedly");
|
||||
expect(tmuxCheck!.fix).toContain("restart the default tmux server");
|
||||
});
|
||||
|
||||
// Test 4
|
||||
it("OpenRig home not writable → ready: false with guidance", async () => {
|
||||
const readonlyDir = join(tmpDir, "readonly-home");
|
||||
@@ -150,7 +170,7 @@ describe("SystemPreflight", () => {
|
||||
// Test 8
|
||||
it("multiple failures reported together (additive)", async () => {
|
||||
Object.defineProperty(process, "version", { value: "v18.19.0", writable: true });
|
||||
const pf = createPreflight({ tmuxOk: false });
|
||||
const pf = createPreflight({ tmuxMode: "missing" });
|
||||
const result = await pf.run();
|
||||
expect(result.ready).toBe(false);
|
||||
const failedChecks = result.checks.filter((c) => !c.ok);
|
||||
|
||||
@@ -135,6 +135,43 @@ describe("Ps CLI", () => {
|
||||
expect(logs.some((l) => l.includes("No rigs"))).toBe(true);
|
||||
});
|
||||
|
||||
it("ps recovers when daemon.json is missing but configured daemon is healthy", async () => {
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
process.env["OPENRIG_PORT"] = String(port);
|
||||
process.env["OPENRIG_HOST"] = "127.0.0.1";
|
||||
try {
|
||||
psData = [
|
||||
{ rigId: "rig-1", name: "recovered-rig", nodeCount: 1, runningCount: 1, status: "running", uptime: "15s", latestSnapshot: null },
|
||||
];
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(psCommand({
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe(`http://127.0.0.1:${port}/healthz`);
|
||||
return { ok: true };
|
||||
}),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
}));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "ps"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("recovered-rig");
|
||||
expect(output).not.toMatch(/Daemon not running/i);
|
||||
expect(exitCode).toBeUndefined();
|
||||
} finally {
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
// NS-T08: ps --nodes tests
|
||||
|
||||
it("ps --nodes formats table with rig context and restore columns", async () => {
|
||||
@@ -317,7 +354,9 @@ describe("Ps CLI", () => {
|
||||
|
||||
it("daemon not running error includes guidance", async () => {
|
||||
const stoppedDeps: StatusDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps(),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
const prog = new Command();
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { setupCommand, runSetup, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
|
||||
import type { DoctorDeps } from "../src/commands/doctor.js";
|
||||
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
|
||||
|
||||
const CMUX_SETTINGS_PATH = resolveCmuxSettingsPath();
|
||||
|
||||
function makeDeps(overrides?: Partial<SetupDeps>): SetupDeps {
|
||||
return {
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
if (cmd === "jq --version") return "jq-1.7\n";
|
||||
if (cmd === "gh --version") return "gh 2.0\n";
|
||||
return "";
|
||||
},
|
||||
readFile: () => null,
|
||||
writeFile: vi.fn(),
|
||||
exists: () => false,
|
||||
platform: "darwin",
|
||||
mkdirp: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
const logs: string[] = [];
|
||||
const orig = console.log;
|
||||
let exitCode: number | undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
|
||||
const origExitCode = process.exitCode;
|
||||
return fn()
|
||||
.then(() => {
|
||||
exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
return { logs, exitCode };
|
||||
})
|
||||
.finally(() => { console.log = orig; });
|
||||
}
|
||||
|
||||
function expectRuntimeConfigDisclosure(result: SetupResult): void {
|
||||
expect(result.runtimeConfig).toHaveLength(6);
|
||||
expect(result.runtimeConfig).toEqual(expect.arrayContaining([
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "claude-code",
|
||||
path: "~/.claude/settings.json",
|
||||
purpose: "Allow OpenRig commands without Claude permission prompts.",
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "claude-code",
|
||||
path: "~/.claude.json",
|
||||
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete.",
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
runtime: "claude-code",
|
||||
path: ".claude/settings.local.json",
|
||||
purpose: expect.stringContaining("statusLine"),
|
||||
},
|
||||
{
|
||||
scope: "project",
|
||||
runtime: "claude-code",
|
||||
path: ".mcp.json",
|
||||
purpose: "Configure project-local MCP servers for Claude-managed workspaces.",
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "codex",
|
||||
path: "~/.codex/config.toml",
|
||||
purpose: "Pre-trust managed workspaces and configure Codex MCP servers.",
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
runtime: "cmux",
|
||||
path: "~/.config/cmux/settings.json",
|
||||
purpose: "Set cmux socket control to an OpenRig-compatible automation mode.",
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
describe("rig setup", () => {
|
||||
it("wired via createProgram", async () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const program = createProgram();
|
||||
const setupCmd = program.commands.find((c) => c.name() === "setup");
|
||||
expect(setupCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it("--dry-run --json returns structured plan with core profile and expected step ids", async () => {
|
||||
const deps = makeDeps();
|
||||
const program = new Command();
|
||||
program.addCommand(setupCommand(deps));
|
||||
|
||||
const { logs } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "setup", "--dry-run", "--json"]),
|
||||
);
|
||||
|
||||
const result = JSON.parse(logs.join("\n")) as SetupResult;
|
||||
expect(result.profile).toBe("core");
|
||||
expect(result.platform).toBe("darwin");
|
||||
const stepIds = result.steps.map((s) => s.id);
|
||||
expect(stepIds).toContain("brew");
|
||||
expect(stepIds).toContain("tmux_install");
|
||||
expect(stepIds).toContain("cmux_install");
|
||||
expect(stepIds).toContain("claude_install");
|
||||
expect(stepIds).toContain("claude_auth");
|
||||
expect(stepIds).toContain("codex_install");
|
||||
expect(stepIds).toContain("codex_auth");
|
||||
expect(stepIds).toContain("tmux_config");
|
||||
expect(stepIds).toContain("verify");
|
||||
// No full-profile extras
|
||||
expect(stepIds).not.toContain("jq_install");
|
||||
expect(stepIds).not.toContain("gh_install");
|
||||
// All steps should be skipped in dry run
|
||||
expect(result.steps.every((s) => s.status === "skipped")).toBe(true);
|
||||
expectRuntimeConfigDisclosure(result);
|
||||
});
|
||||
|
||||
it("--dry-run --full --json includes full profile with core + extra step ids", async () => {
|
||||
const deps = makeDeps();
|
||||
const program = new Command();
|
||||
program.addCommand(setupCommand(deps));
|
||||
|
||||
const { logs } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "setup", "--dry-run", "--full", "--json"]),
|
||||
);
|
||||
|
||||
const result = JSON.parse(logs.join("\n")) as SetupResult;
|
||||
expect(result.profile).toBe("full");
|
||||
const stepIds = result.steps.map((s) => s.id);
|
||||
// Core steps still present
|
||||
expect(stepIds).toContain("brew");
|
||||
expect(stepIds).toContain("tmux_install");
|
||||
expect(stepIds).toContain("cmux_install");
|
||||
expect(stepIds).toContain("claude_install");
|
||||
expect(stepIds).toContain("claude_auth");
|
||||
expect(stepIds).toContain("codex_install");
|
||||
expect(stepIds).toContain("codex_auth");
|
||||
expect(stepIds).toContain("tmux_config");
|
||||
expect(stepIds).toContain("verify");
|
||||
// Full extras added
|
||||
expect(stepIds).toContain("jq_install");
|
||||
expect(stepIds).toContain("gh_install");
|
||||
});
|
||||
|
||||
it("--dry-run --json exits 0 (plan-only, not a failure)", async () => {
|
||||
const deps = makeDeps();
|
||||
const program = new Command();
|
||||
program.addCommand(setupCommand(deps));
|
||||
|
||||
const { exitCode } = await captureLogs(() =>
|
||||
program.parseAsync(["node", "rig", "setup", "--dry-run", "--json"]),
|
||||
);
|
||||
|
||||
expect(exitCode).toBeUndefined(); // undefined means 0
|
||||
});
|
||||
|
||||
it("--dry-run does not make mutating exec calls", async () => {
|
||||
const execSpy = vi.fn(() => "");
|
||||
const deps = makeDeps({ exec: execSpy });
|
||||
await runSetup(deps, { dryRun: true });
|
||||
expect(execSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("core profile execution with all tools present returns pass/applied steps and ready=true", async () => {
|
||||
const writeSpy = vi.fn();
|
||||
const deps = makeDeps({ writeFile: writeSpy });
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
expect(result.profile).toBe("core");
|
||||
expect(result.ready).toBe(true);
|
||||
|
||||
const brew = result.steps.find((s) => s.id === "brew");
|
||||
expect(brew?.status).toBe("pass");
|
||||
|
||||
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
||||
expect(tmux?.status).toBe("pass");
|
||||
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("applied");
|
||||
|
||||
const claudeInstall = result.steps.find((s) => s.id === "claude_install");
|
||||
expect(claudeInstall?.status).toBe("pass");
|
||||
|
||||
const claudeAuth = result.steps.find((s) => s.id === "claude_auth");
|
||||
expect(claudeAuth?.status).toBe("pass");
|
||||
|
||||
const codexInstall = result.steps.find((s) => s.id === "codex_install");
|
||||
expect(codexInstall?.status).toBe("pass");
|
||||
|
||||
const codexAuth = result.steps.find((s) => s.id === "codex_auth");
|
||||
expect(codexAuth?.status).toBe("pass");
|
||||
|
||||
const tmuxConfig = result.steps.find((s) => s.id === "tmux_config");
|
||||
expect(tmuxConfig?.status).toBe("applied");
|
||||
|
||||
const verify = result.steps.find((s) => s.id === "verify");
|
||||
expect(verify?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails setup honestly when tmux is installed but the default control socket is unhealthy", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
||||
expect(tmux?.status).toBe("fail");
|
||||
expect(tmux?.message).toContain("control socket");
|
||||
expect(tmux?.reason).toContain("server exited unexpectedly");
|
||||
expect(tmux?.fixHint).toContain("restart the default tmux server");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("full profile extends core with jq_install and gh_install", async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await runSetup(deps, { full: true });
|
||||
|
||||
expect(result.profile).toBe("full");
|
||||
const stepIds = result.steps.map((s) => s.id);
|
||||
// Core steps present
|
||||
expect(stepIds).toContain("brew");
|
||||
expect(stepIds).toContain("tmux_install");
|
||||
// Full extras present
|
||||
expect(stepIds).toContain("jq_install");
|
||||
expect(stepIds).toContain("gh_install");
|
||||
|
||||
const jq = result.steps.find((s) => s.id === "jq_install");
|
||||
expect(jq?.status).toBe("pass");
|
||||
const gh = result.steps.find((s) => s.id === "gh_install");
|
||||
expect(gh?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("installs missing Claude Code with npm and verifies auth", async () => {
|
||||
let claudeInstalled = false;
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
||||
if (cmd === "claude --version") {
|
||||
if (claudeInstalled) return "2.1.101 (Claude Code)\n";
|
||||
throw new Error("command not found: claude");
|
||||
}
|
||||
if (cmd === "npm install -g @anthropic-ai/claude-code") {
|
||||
claudeInstalled = true;
|
||||
return "installed claude\n";
|
||||
}
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const claudeInstall = result.steps.find((s) => s.id === "claude_install");
|
||||
expect(claudeInstall?.status).toBe("applied");
|
||||
|
||||
const claudeAuth = result.steps.find((s) => s.id === "claude_auth");
|
||||
expect(claudeAuth?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("fails setup honestly when Codex is installed but not logged in", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") throw new Error("not logged in");
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const codexAuth = result.steps.find((s) => s.id === "codex_auth");
|
||||
expect(codexAuth?.status).toBe("fail");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fail Linux setup just because Homebrew is unavailable", async () => {
|
||||
const deps = makeDeps({
|
||||
platform: "linux",
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") return "";
|
||||
if (cmd === "cmux capabilities --json") throw new Error("not found");
|
||||
if (cmd === "cmux --help") throw new Error("not found");
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
throw new Error(`unexpected: ${cmd}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const brew = result.steps.find((s) => s.id === "brew");
|
||||
expect(brew?.status).toBe("skipped");
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("brew failure does not crash — later brew-dependent steps are skipped honestly", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") throw new Error("command not found: brew");
|
||||
if (cmd === "tmux -V") throw new Error("command not found: tmux");
|
||||
if (cmd === "tmux list-sessions") throw new Error("command not found: tmux");
|
||||
if (cmd === "cmux capabilities --json") throw new Error("not found");
|
||||
if (cmd === "cmux --help") throw new Error("not found");
|
||||
throw new Error(`unexpected: ${cmd}`);
|
||||
},
|
||||
});
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
expect(result.ready).toBe(false);
|
||||
|
||||
const brew = result.steps.find((s) => s.id === "brew");
|
||||
expect(brew?.status).toBe("fail");
|
||||
|
||||
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
||||
expect(tmux?.status).toBe("skipped");
|
||||
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("skipped");
|
||||
|
||||
// verify step should reflect the failures
|
||||
const verify = result.steps.find((s) => s.id === "verify");
|
||||
expect(verify?.status).toBe("warn");
|
||||
});
|
||||
|
||||
it("result includes verification section with real doctor check names including async cmux_daemon", async () => {
|
||||
const deps = makeDeps();
|
||||
const doctorDeps: DoctorDeps = {
|
||||
exists: () => true,
|
||||
baseDir: "/install/cli/dist",
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
checkPort: async () => true,
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
|
||||
platform: "darwin",
|
||||
readFile: () => null,
|
||||
mkdirp: () => {},
|
||||
checkWritable: () => {},
|
||||
fetch: async () => { throw new Error("ECONNREFUSED"); },
|
||||
};
|
||||
|
||||
const result = await runSetup(deps, { doctorDeps });
|
||||
|
||||
// verification section must exist
|
||||
expect(result.verification).toBeDefined();
|
||||
expect(Array.isArray(result.verification!.checks)).toBe(true);
|
||||
|
||||
const checkNames = result.verification!.checks.map((c) => c.name);
|
||||
// Must include real doctor check names
|
||||
expect(checkNames).toContain("node_version");
|
||||
expect(checkNames).toContain("tmux");
|
||||
expect(checkNames).toContain("cmux_shell");
|
||||
// Must include async doctor check (cmux_daemon resolved)
|
||||
expect(checkNames).toContain("cmux_daemon");
|
||||
|
||||
// cmux_daemon should be skipped (daemon not reachable)
|
||||
const cmuxDaemon = result.verification!.checks.find((c) => c.name === "cmux_daemon");
|
||||
expect(cmuxDaemon?.status).toBe("skipped");
|
||||
|
||||
// Doctor statuses used as-is, not renamed
|
||||
const nodeCheck = result.verification!.checks.find((c) => c.name === "node_version");
|
||||
expect(["pass", "warn", "fail", "skipped"]).toContain(nodeCheck?.status);
|
||||
});
|
||||
|
||||
it("non-dry-run results include the same structured runtime config disclosure", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
expectRuntimeConfigDisclosure(result);
|
||||
});
|
||||
|
||||
it("ready is false only when setup steps or verification checks have fail status", async () => {
|
||||
// All tools present, all doctor checks pass
|
||||
const deps = makeDeps();
|
||||
const doctorDeps: DoctorDeps = {
|
||||
exists: () => true,
|
||||
baseDir: "/install/cli/dist",
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
checkPort: async () => true,
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
|
||||
platform: "darwin",
|
||||
readFile: () => null,
|
||||
mkdirp: () => {},
|
||||
checkWritable: () => {},
|
||||
fetch: async () => { throw new Error("ECONNREFUSED"); },
|
||||
};
|
||||
|
||||
const result = await runSetup(deps, { doctorDeps });
|
||||
|
||||
// No fail statuses in steps or verification -> ready=true
|
||||
expect(result.ready).toBe(true);
|
||||
|
||||
// warn/skipped alone do not flip ready to false
|
||||
const hasWarnOrSkipped = [
|
||||
...result.steps,
|
||||
...(result.verification?.checks ?? []),
|
||||
].some((c) => c.status === "warn" || c.status === "skipped");
|
||||
// cmux_daemon is skipped, so this should be true
|
||||
expect(hasWarnOrSkipped).toBe(true);
|
||||
// But ready is still true
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("tmux install failure returns structured fail, does not crash setup", async () => {
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") throw new Error("not found");
|
||||
if (cmd === "brew install tmux") throw new Error("brew install failed");
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
||||
return "";
|
||||
},
|
||||
});
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
||||
expect(tmux?.status).toBe("fail");
|
||||
expect(result.ready).toBe(false);
|
||||
|
||||
// Other steps still attempted
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux).toBeDefined();
|
||||
expect(cmux?.status).toBe("applied");
|
||||
});
|
||||
|
||||
it("enables cmux automation mode on macOS when cmux is installed but not yet controllable", async () => {
|
||||
let cmuxReady = false;
|
||||
const seen: string[] = [];
|
||||
const writeSpy = vi.fn();
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? null : null,
|
||||
writeFile: writeSpy,
|
||||
exec: (cmd: string) => {
|
||||
seen.push(cmd);
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") {
|
||||
if (cmuxReady) return '{"methods":["surface.focus"]}\n';
|
||||
throw new Error("socket not ready");
|
||||
}
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
if (cmd === "open -a /Applications/cmux.app") {
|
||||
cmuxReady = true;
|
||||
return "";
|
||||
}
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("applied");
|
||||
expect(cmux?.message).toContain("Enabled cmux socket control");
|
||||
expect(seen).toContain("open -a /Applications/cmux.app");
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
CMUX_SETTINGS_PATH,
|
||||
expect.stringContaining("\"socketControlMode\": \"automation\""),
|
||||
);
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes restrictive cmux socket control on macOS even when shell cmux already works", async () => {
|
||||
const seen: string[] = [];
|
||||
const writeSpy = vi.fn();
|
||||
const doctorDeps: DoctorDeps = {
|
||||
exists: () => true,
|
||||
baseDir: "/install/cli/dist",
|
||||
readFile: () => null,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
checkPort: async () => false,
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
|
||||
platform: "darwin",
|
||||
mkdirp: () => {},
|
||||
checkWritable: () => {},
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true };
|
||||
if (url.includes("/api/adapters/cmux/status")) return { ok: true, json: async () => ({ available: true }) };
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
},
|
||||
};
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
|
||||
writeFile: writeSpy,
|
||||
exec: (cmd: string) => {
|
||||
seen.push(cmd);
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
if (cmd === "cmux reload-config") return "";
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, { doctorDeps });
|
||||
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("applied");
|
||||
expect(cmux?.message).toContain("automation");
|
||||
expect(seen).toContain("cmux reload-config");
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
CMUX_SETTINGS_PATH,
|
||||
expect.stringContaining("\"socketControlMode\": \"automation\""),
|
||||
);
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("fails honestly when cmux stays uncontrollable after automatic macOS setup", async () => {
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"cmuxOnly\"\n }\n}\n" : null,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") throw new Error("broken pipe");
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
if (cmd === "open -a /Applications/cmux.app") return "";
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("fail");
|
||||
expect(cmux?.message).toContain("cmux installed but control unavailable");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("does not rewrite compatible password mode when shell cmux already works", async () => {
|
||||
const writeSpy = vi.fn();
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
|
||||
writeFile: writeSpy,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") return "";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
throw new Error(`unexpected: ${cmd}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("pass");
|
||||
expect(writeSpy).not.toHaveBeenCalledWith(
|
||||
CMUX_SETTINGS_PATH,
|
||||
expect.any(String),
|
||||
);
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("fails setup when shell cmux works but the running daemon still cannot control cmux", async () => {
|
||||
const doctorDeps: DoctorDeps = {
|
||||
exists: () => true,
|
||||
baseDir: "/install/cli/dist",
|
||||
readFile: () => null,
|
||||
exec: (cmd: string) => {
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
||||
if (cmd === "cmux --help") return "cmux help\n";
|
||||
return "";
|
||||
},
|
||||
checkPort: async () => false,
|
||||
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
|
||||
platform: "darwin",
|
||||
mkdirp: () => {},
|
||||
checkWritable: () => {},
|
||||
fetch: async (url: string) => {
|
||||
if (url.includes("/healthz")) return { ok: true };
|
||||
if (url.includes("/api/adapters/cmux/status")) return { ok: true, json: async () => ({ available: false }) };
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
},
|
||||
};
|
||||
const deps = makeDeps({
|
||||
readFile: (filePath: string) => filePath === CMUX_SETTINGS_PATH ? "{\n \"automation\": {\n \"socketControlMode\": \"password\"\n }\n}\n" : null,
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, { doctorDeps });
|
||||
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("fail");
|
||||
expect(cmux?.message).toContain("running daemon still cannot control cmux");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("gives fresh install commands a longer timeout budget and fails cmux honestly when install still errors", async () => {
|
||||
const seenTimeouts = new Map<string, number | undefined>();
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") throw new Error("error connecting to /tmp/tmux (No such file or directory)");
|
||||
if (cmd === "cmux capabilities --json") throw new Error("command not found: cmux");
|
||||
if (cmd === "cmux --help") throw new Error("command not found: cmux");
|
||||
if (cmd === "brew install --cask cmux") {
|
||||
seenTimeouts.set(cmd, opts?.timeoutMs);
|
||||
throw new Error("spawnSync /bin/sh ETIMEDOUT");
|
||||
}
|
||||
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
throw new Error(`unexpected: ${cmd}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
expect(seenTimeouts.get("brew install --cask cmux")).toBe(300000);
|
||||
const cmux = result.steps.find((s) => s.id === "cmux_install");
|
||||
expect(cmux?.status).toBe("fail");
|
||||
expect(cmux?.message).toContain("ETIMEDOUT");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the extended timeout budget for npm runtime installs", async () => {
|
||||
const seenTimeouts = new Map<string, number | undefined>();
|
||||
let claudeInstalled = false;
|
||||
let codexInstalled = false;
|
||||
const deps = makeDeps({
|
||||
exec: (cmd: string, opts?: { timeoutMs?: number }) => {
|
||||
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
||||
if (cmd === "tmux -V") return "tmux 3.4\n";
|
||||
if (cmd === "tmux list-sessions") return "";
|
||||
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
||||
if (cmd === "claude --version") {
|
||||
if (claudeInstalled) return "2.1.101 (Claude Code)\n";
|
||||
throw new Error("command not found: claude");
|
||||
}
|
||||
if (cmd === "npm install -g @anthropic-ai/claude-code") {
|
||||
seenTimeouts.set(cmd, opts?.timeoutMs);
|
||||
claudeInstalled = true;
|
||||
return "installed claude\n";
|
||||
}
|
||||
if (cmd === "claude auth status") return "Authenticated\n";
|
||||
if (cmd === "codex --version") {
|
||||
if (codexInstalled) return "codex-cli 0.118.0\n";
|
||||
throw new Error("command not found: codex");
|
||||
}
|
||||
if (cmd === "npm install -g @openai/codex") {
|
||||
seenTimeouts.set(cmd, opts?.timeoutMs);
|
||||
codexInstalled = true;
|
||||
return "installed codex\n";
|
||||
}
|
||||
if (cmd === "codex login status") return "Logged in\n";
|
||||
throw new Error(`unexpected: ${cmd}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(deps, {});
|
||||
|
||||
expect(result.ready).toBe(true);
|
||||
expect(seenTimeouts.get("npm install -g @anthropic-ai/claude-code")).toBe(300000);
|
||||
expect(seenTimeouts.get("npm install -g @openai/codex")).toBe(300000);
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,10 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
function stoppedDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
clientFactory: vi.fn() as unknown as StatusDeps["clientFactory"],
|
||||
};
|
||||
}
|
||||
@@ -135,7 +138,19 @@ function createMockDaemon() {
|
||||
res.end(JSON.stringify({
|
||||
nodes: [
|
||||
{ nodeId: "n1", logicalId: "orchestrator", status: "resumed" },
|
||||
{ nodeId: "n2", logicalId: "worker", status: "failed" },
|
||||
{
|
||||
nodeId: "n2",
|
||||
logicalId: "worker",
|
||||
status: "failed",
|
||||
canonicalSessionName: "worker@test-rig",
|
||||
tmuxAttachCommand: "tmux attach -t worker@test-rig",
|
||||
cwd: "/workspace/app",
|
||||
recoveryGuidance: {
|
||||
summary: "Try native Claude resume first, then fall back to the workspace-local picker if needed.",
|
||||
commands: ["claude --resume abc-123", "cd /workspace/app", "claude --resume"],
|
||||
notes: ["Look for session name: worker@test-rig", "Choose the full conversation option, not summary."],
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
return;
|
||||
@@ -242,6 +257,10 @@ describe("rig snapshot + restore", () => {
|
||||
|
||||
expect(logs.join("\n")).toContain("worker");
|
||||
expect(logs.join("\n")).toContain("failed");
|
||||
expect(logs.join("\n")).toContain("Recovery guidance:");
|
||||
expect(logs.join("\n")).toContain("tmux attach -t worker@test-rig");
|
||||
expect(logs.join("\n")).toContain("$ claude --resume abc-123");
|
||||
expect(logs.join("\n")).toContain("Choose the full conversation option, not summary.");
|
||||
expect(process.exitCode).toBe(1);
|
||||
|
||||
process.exitCode = savedExitCode;
|
||||
|
||||
@@ -82,6 +82,17 @@ describe("Specs CLI", () => {
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("specs help calls out managed apps in the library", () => {
|
||||
const logs: string[] = [];
|
||||
const cmd = makeCmd().commands.find((c) => c.name() === "specs")!;
|
||||
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
|
||||
cmd.outputHelp();
|
||||
const help = logs.join("");
|
||||
expect(help).toContain("Browse, preview, and manage the spec library, including managed apps");
|
||||
expect(help).toContain("rig specs ls");
|
||||
expect(help).toContain("rig specs preview secrets-manager");
|
||||
});
|
||||
|
||||
it("specs ls prints library entries", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "specs", "ls"]);
|
||||
@@ -134,6 +145,15 @@ describe("Specs CLI", () => {
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("specs show not-found error points people back to managed apps in the library", async () => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "specs", "show", "missing-app"]);
|
||||
});
|
||||
|
||||
expect(logs.join("\n")).toContain("Run 'rig specs ls' to see available rigs, agents, and managed apps.");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("specs preview --json returns structured review", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "specs", "preview", "review-rig", "--json"]);
|
||||
|
||||
@@ -164,7 +164,7 @@ describe("rig status", () => {
|
||||
|
||||
// Test 3: Daemon stopped (no daemon.json) -> 'Daemon not running', no HTTP
|
||||
it("stopped daemon -> 'Daemon not running', no HTTP calls", async () => {
|
||||
const fetchFn = vi.fn(async () => ({ ok: true }));
|
||||
const fetchFn = vi.fn(async () => { throw new Error("refused"); });
|
||||
const clientFactory = vi.fn();
|
||||
const deps: StatusDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
@@ -306,7 +306,10 @@ describe("rig status", () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
|
||||
const statusDeps: StatusDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
clientFactory: vi.fn() as unknown as StatusDeps["clientFactory"],
|
||||
};
|
||||
|
||||
|
||||
@@ -69,7 +69,10 @@ describe("rig ui open", () => {
|
||||
it("daemon down -> error, no exec", async () => {
|
||||
const execFn = vi.fn(async () => {});
|
||||
const deps: UiDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
exec: execFn,
|
||||
};
|
||||
const program = new Command();
|
||||
@@ -80,6 +83,38 @@ describe("rig ui open", () => {
|
||||
expect(execFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no daemon.json but configured daemon is healthy -> opens recovered daemon URL", async () => {
|
||||
const savedPort = process.env["OPENRIG_PORT"];
|
||||
const savedHost = process.env["OPENRIG_HOST"];
|
||||
process.env["OPENRIG_PORT"] = "7555";
|
||||
process.env["OPENRIG_HOST"] = "127.0.0.1";
|
||||
try {
|
||||
const execFn = vi.fn(async () => {});
|
||||
const deps: UiDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
expect(url).toBe("http://127.0.0.1:7555/healthz");
|
||||
return { ok: true };
|
||||
}),
|
||||
}),
|
||||
exec: execFn,
|
||||
};
|
||||
const program = new Command();
|
||||
program.addCommand(uiCommand(deps));
|
||||
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "ui", "open"]));
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith("open", ["http://127.0.0.1:7555"]);
|
||||
expect(logs.join("\n")).toContain("http://127.0.0.1:7555");
|
||||
expect(logs.join("\n")).not.toMatch(/not running/i);
|
||||
} finally {
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
|
||||
else process.env["OPENRIG_HOST"] = savedHost;
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: UI URL derives from daemon port (daemon serves the UI)
|
||||
it("UI URL derives from daemon port", async () => {
|
||||
const execFn = vi.fn(async () => {});
|
||||
@@ -120,7 +155,10 @@ describe("rig ui open", () => {
|
||||
const { createProgram } = await import("../src/index.js");
|
||||
const execFn = vi.fn(async () => {});
|
||||
const deps: UiDeps = {
|
||||
lifecycleDeps: mockLifecycleDeps({ exists: vi.fn(() => false) }),
|
||||
lifecycleDeps: mockLifecycleDeps({
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
}),
|
||||
exec: execFn,
|
||||
};
|
||||
const program = createProgram({ uiDeps: deps });
|
||||
|
||||
@@ -52,6 +52,12 @@ function runningDeps(port: number): StatusDeps {
|
||||
};
|
||||
}
|
||||
|
||||
function healthyPreflightExec(cmd: string): Promise<string> {
|
||||
if (cmd === "tmux -V") return Promise.resolve("tmux 3.6a");
|
||||
if (cmd === "tmux list-sessions") return Promise.resolve("");
|
||||
return Promise.resolve("");
|
||||
}
|
||||
|
||||
describe("Up CLI", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
@@ -88,11 +94,17 @@ describe("Up CLI", () => {
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("help makes --target scope explicit", () => {
|
||||
const help = makeCmd().commands.find((c) => c.name() === "up")!.helpInformation();
|
||||
it("help positions managed apps as first-class launch targets", () => {
|
||||
const logs: string[] = [];
|
||||
const cmd = makeCmd().commands.find((c) => c.name() === "up")!;
|
||||
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
|
||||
cmd.outputHelp();
|
||||
const help = logs.join("");
|
||||
expect(help).toContain("Launch a rig or managed app from a spec, library entry, or bundle");
|
||||
expect(help).toContain("Target root directory for package installation");
|
||||
expect(help).toContain("does not change agent cwd");
|
||||
expect(help).toContain("--cwd <path>");
|
||||
expect(help).toContain("rig up secrets-manager");
|
||||
});
|
||||
|
||||
// T7: up from .yaml -> stages + rig ID
|
||||
@@ -272,6 +284,61 @@ describe("Up CLI", () => {
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
});
|
||||
|
||||
it("up from a library name defaults cwdOverride to the caller working directory", async () => {
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
const origListeners = server.listeners("request");
|
||||
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 ?? "").startsWith("/api/specs/library") && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([
|
||||
{
|
||||
id: "builtin:rig:demo",
|
||||
kind: "rig",
|
||||
name: "demo",
|
||||
version: "0.0.0",
|
||||
sourceType: "builtin",
|
||||
sourcePath: "/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml",
|
||||
relativePath: "rigs/launch/demo/rig.yaml",
|
||||
updatedAt: "2026-04-10T00:00:00Z",
|
||||
},
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/api/rigs/summary" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify([]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/api/up" && req.method === "POST") {
|
||||
lastBody = JSON.parse(body);
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
});
|
||||
|
||||
try {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "up", "demo"]);
|
||||
});
|
||||
|
||||
expect(lastBody.sourceRef).toBe("/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml");
|
||||
expect(lastBody.cwdOverride).toBe(process.cwd());
|
||||
} finally {
|
||||
server.removeAllListeners("request");
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
}
|
||||
});
|
||||
|
||||
it("up --cwd sends absolute cwdOverride", async () => {
|
||||
let lastBody: Record<string, unknown> = {};
|
||||
const origListeners = server.listeners("request");
|
||||
@@ -297,6 +364,32 @@ describe("Up CLI", () => {
|
||||
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
|
||||
});
|
||||
|
||||
it("up apply uses a long-running daemon timeout budget", async () => {
|
||||
let timeoutMs: number | undefined;
|
||||
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
|
||||
timeoutMs = options?.timeoutMs;
|
||||
return {
|
||||
status: 201,
|
||||
data: { status: "completed", runId: "r", rigId: "g", stages: [], errors: [], warnings: [] },
|
||||
};
|
||||
});
|
||||
|
||||
const deps: StatusDeps = {
|
||||
lifecycleDeps: runningDeps(port).lifecycleDeps,
|
||||
clientFactory: () => ({ post } as unknown as DaemonClient),
|
||||
};
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||||
|
||||
await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
|
||||
});
|
||||
|
||||
expect(timeoutMs).toBe(120_000);
|
||||
});
|
||||
|
||||
// NS-T14: fresh boot handoff includes dashboard URL + attach command
|
||||
it("fresh boot success shows dashboard URL and attach command", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
@@ -386,6 +479,7 @@ describe("Up CLI", () => {
|
||||
process.env["OPENRIG_PORT"] = "7461";
|
||||
|
||||
let daemonState: DaemonState | null = null;
|
||||
let daemonStarted = false;
|
||||
let spawnedPort: string | undefined;
|
||||
let clientBaseUrl: string | undefined;
|
||||
|
||||
@@ -400,12 +494,14 @@ describe("Up CLI", () => {
|
||||
openForAppend: vi.fn(() => 3),
|
||||
mkdirp: vi.fn(),
|
||||
spawn: vi.fn((cmd, args, opts) => {
|
||||
daemonStarted = true;
|
||||
spawnedPort = opts.env["OPENRIG_PORT"];
|
||||
return { pid: 321, unref: vi.fn() } as never;
|
||||
}),
|
||||
fetch: vi.fn(async (url: string) => ({
|
||||
ok: url === "http://127.0.0.1:7461/healthz" || url === "http://127.0.0.1:7433/healthz",
|
||||
})),
|
||||
fetch: vi.fn(async (url: string) => {
|
||||
if (!daemonStarted) throw new Error(`refused:${url}`);
|
||||
return { ok: url === "http://127.0.0.1:7461/healthz" };
|
||||
}),
|
||||
},
|
||||
clientFactory: (baseUrl) => {
|
||||
clientBaseUrl = baseUrl;
|
||||
@@ -420,7 +516,7 @@ describe("Up CLI", () => {
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(upCommand(deps));
|
||||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||||
|
||||
const { exitCode } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||||
@@ -460,7 +556,7 @@ describe("Up CLI", () => {
|
||||
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(upCommand(deps));
|
||||
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
|
||||
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
|
||||
|
||||
@@ -24,7 +24,11 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
function stoppedDeps(): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: { ...mockLifecycleDeps(), exists: vi.fn(() => false) },
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn(() => false),
|
||||
fetch: vi.fn(async () => { throw new Error("refused"); }),
|
||||
},
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ If it does not auto-load skills reliably, read the packaged copy directly from y
|
||||
|
||||
That skill covers the broader surface, including chatroom, discovery, adopt/bind/attach, lifecycle, specs, bundles, and richer operator workflows.
|
||||
|
||||
Your per-session startup guidance may also name additional packaged skills for your role and pod.
|
||||
Load those too. This shared overlay is only the common bootstrap floor, not the full operating manual for your seat.
|
||||
|
||||
## Identity
|
||||
|
||||
Run this first after launch or compaction to recover your identity:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: vault-specialist
|
||||
version: "1.0"
|
||||
description: Vault specialist agent — manages HashiCorp Vault for this managed app
|
||||
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [using-superpowers, openrig-user, mental-model-ha, systematic-debugging, verification-before-completion, vault-user]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
runtime_resources: []
|
||||
|
||||
resources:
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
skills:
|
||||
- id: vault-user
|
||||
path: skills/vault-user
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/role.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
- path: startup/context.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
actions: []
|
||||
@@ -0,0 +1,34 @@
|
||||
# Role: Vault Specialist
|
||||
|
||||
You are the Vault specialist agent for this managed app. You are the domain expert for HashiCorp Vault in this topology.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Manage the Vault instance that runs as part of this rig's environment
|
||||
- Perform secret CRUD operations: read, write, list, and delete secrets
|
||||
- Check Vault health and status on demand
|
||||
- Explain the current secret structure to other agents or humans
|
||||
- Respond to delegation requests from other agents for Vault-domain work
|
||||
- Use the Vault HTTP API and CLI to perform all operations
|
||||
- Report Vault state honestly — if Vault is unhealthy or unreachable, say so
|
||||
|
||||
## Principles
|
||||
|
||||
- You are the preferred delegate target for all Vault-domain work in this rig
|
||||
- Other agents should ask you instead of trying to operate Vault directly
|
||||
- Always verify Vault is healthy before performing operations
|
||||
- Use the `rig env status` surface to check environment health before manual probing
|
||||
- Use the configured dev token and local address — do not guess or invent credentials
|
||||
- Be specific about paths, keys, and values when reporting secret state
|
||||
- If you cannot reach Vault, report the failure honestly with the access path you tried
|
||||
|
||||
## Skills
|
||||
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
# Vault User
|
||||
|
||||
You have access to a HashiCorp Vault instance managed by this rig's environment.
|
||||
|
||||
## Connection
|
||||
|
||||
- **Address:** `http://127.0.0.1:8200`
|
||||
- **Token:** `openrig-dev-token`
|
||||
- **Auth header:** `X-Vault-Token: openrig-dev-token`
|
||||
|
||||
Always set the token before making API calls. For curl, use `-H "X-Vault-Token: openrig-dev-token"`.
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8200/v1/sys/health | jq .
|
||||
```
|
||||
|
||||
A healthy response has `"initialized": true` and `"sealed": false`.
|
||||
|
||||
## Secret Operations
|
||||
|
||||
### Write a secret
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8200/v1/secret/data/<path> \
|
||||
-H "X-Vault-Token: openrig-dev-token" \
|
||||
-d '{"data": {"key": "value"}}' | jq .
|
||||
```
|
||||
|
||||
### Read a secret
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8200/v1/secret/data/<path> \
|
||||
-H "X-Vault-Token: openrig-dev-token" | jq .
|
||||
```
|
||||
|
||||
The secret value is in `.data.data`.
|
||||
|
||||
### List secrets
|
||||
|
||||
```bash
|
||||
curl -s -X LIST http://127.0.0.1:8200/v1/secret/metadata/ \
|
||||
-H "X-Vault-Token: openrig-dev-token" | jq .
|
||||
```
|
||||
|
||||
List a subdirectory by appending the path: `.../secret/metadata/<prefix>/`.
|
||||
|
||||
### Delete a secret
|
||||
|
||||
```bash
|
||||
curl -s -X DELETE http://127.0.0.1:8200/v1/secret/data/<path> \
|
||||
-H "X-Vault-Token: openrig-dev-token"
|
||||
```
|
||||
|
||||
## Explaining Secrets
|
||||
|
||||
When asked to explain the current secret structure, list all paths and summarize what each contains. Use the list endpoint recursively if needed.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- This is Vault dev mode — all data is in-memory and lost on restart
|
||||
- The KV secrets engine is mounted at `secret/` by default in dev mode
|
||||
- Use the `rig env status` command to verify Vault health through OpenRig before direct probing
|
||||
@@ -0,0 +1,46 @@
|
||||
# Startup Context: Vault Managed App
|
||||
|
||||
## Environment
|
||||
|
||||
This rig runs HashiCorp Vault in dev mode as a managed environment service. Vault boots before you launch — by the time you receive this context, Vault should already be healthy.
|
||||
|
||||
## Access
|
||||
|
||||
- **Address:** `http://127.0.0.1:8200`
|
||||
- **Dev root token:** `openrig-dev-token`
|
||||
- **UI:** `http://127.0.0.1:8200/ui`
|
||||
- **API:** `http://127.0.0.1:8200/v1`
|
||||
|
||||
## Ground Yourself First
|
||||
|
||||
Before making topology or registration claims, ground yourself with OpenRig identity:
|
||||
|
||||
```bash
|
||||
rig whoami --json
|
||||
```
|
||||
|
||||
Trust the startup identity context OpenRig gives you. Do not claim that the rig is unlaunched, unattached, or unregistered unless `rig whoami --json` or another direct OpenRig command proves it.
|
||||
|
||||
## Checking Status
|
||||
|
||||
Use OpenRig env surfaces first:
|
||||
|
||||
```bash
|
||||
rig env status <rig-name>
|
||||
```
|
||||
|
||||
For direct Vault health checks:
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8200/v1/sys/health | jq .
|
||||
```
|
||||
|
||||
Or via the Vault CLI if available:
|
||||
|
||||
```bash
|
||||
vault status -address=http://127.0.0.1:8200
|
||||
```
|
||||
|
||||
## Your Role
|
||||
|
||||
You are the Vault specialist. Other agents in larger topologies may delegate Vault-domain work to you. When they do, use your domain knowledge to perform the requested operations and report results clearly.
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
name: design
|
||||
name: product-designer
|
||||
version: "1.0"
|
||||
description: Product designer — shapes UX, interaction flows, and design-system coherence
|
||||
|
||||
@@ -6,12 +6,12 @@ defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../shared
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [openrig-user, development-team]
|
||||
skills: [using-superpowers, openrig-user, development-team, frontend-design, verification-before-completion, writing-plans, brainstorming]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
+13
@@ -2,6 +2,19 @@
|
||||
|
||||
You are the design side of the product team. Your job is to turn rough product intent into clear user flows, interface structure, and design-system decisions that implementation and QA can execute without guesswork.
|
||||
|
||||
## Startup checklist
|
||||
|
||||
Load these packaged skills now before giving product direction:
|
||||
- `using-superpowers`
|
||||
- `openrig-user`
|
||||
- `development-team`
|
||||
- `frontend-design`
|
||||
- `brainstorming`
|
||||
- `writing-plans`
|
||||
- `verification-before-completion`
|
||||
|
||||
Then run `rig whoami --json` and look for the first ambiguity the development pod should not have to guess through.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Translate ambiguous product goals into concrete UX flows and screen structure
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
name: impl
|
||||
name: implementer
|
||||
version: "1.0"
|
||||
description: Implementation agent — writes code following TDD discipline
|
||||
|
||||
@@ -6,12 +6,12 @@ defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../shared
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [openrig-user, development-team]
|
||||
skills: [using-superpowers, openrig-user, development-team, test-driven-development, mental-model-ha, systematic-debugging, verification-before-completion, writing-plans, executing-plans]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
@@ -0,0 +1,48 @@
|
||||
# Role: Implementer
|
||||
|
||||
You are the implementation side of a dev pair. Your job is to write code, not to free-run.
|
||||
|
||||
## Startup checklist
|
||||
|
||||
Load these packaged skills now before editing:
|
||||
- `using-superpowers`
|
||||
- `openrig-user`
|
||||
- `development-team`
|
||||
- `test-driven-development`
|
||||
- `mental-model-ha`
|
||||
- `systematic-debugging`
|
||||
- `writing-plans`
|
||||
- `executing-plans`
|
||||
- `verification-before-completion`
|
||||
|
||||
Then run `rig whoami --json` and wait for the orchestrator + QA loop before making changes.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Propose approaches before writing code
|
||||
- Follow TDD: write the failing test first, then implement the minimum to pass
|
||||
- Run verification after every change: format, lint, type-check, test
|
||||
- Submit post-edit review bundles to QA after completing work
|
||||
- Commit only after QA approves
|
||||
|
||||
## Working rhythm
|
||||
|
||||
1. Pre-edit proposal (what you plan to do, which files, what tests)
|
||||
2. Wait for QA approval
|
||||
3. Implement with TDD (RED -> GREEN -> REFACTOR)
|
||||
4. Post-edit review bundle (what changed, verification output)
|
||||
5. Wait for QA approval
|
||||
6. Commit
|
||||
|
||||
## Principles
|
||||
|
||||
- Quality over speed. There is no rush.
|
||||
- If a test is hard to write, the design needs work.
|
||||
- Minimal changes only. Don't refactor code you weren't asked to touch.
|
||||
- If the spec seems wrong, raise it. Don't silently diverge.
|
||||
|
||||
## Common failure modes
|
||||
|
||||
- **Racing through tasks:** Do NOT implement multiple tasks without QA gating each one. Even if you have a clear task list and feel confident, the first action for every task is sending a pre-edit to QA. Not after you've started. Before.
|
||||
- **Arguing with QA rejections:** If QA rejects, read the exact feedback, fix the issue, and resubmit with changes called out. Do not argue around it or re-explain why your original approach was fine.
|
||||
- **Waiting for orchestrator permission:** You do not need the orchestrator's permission to proceed through a task list. The gated workflow is between you and QA. Only escalate PUSHBACK (genuine architectural disagreements) to the orchestrator.
|
||||
+2
-2
@@ -6,12 +6,12 @@ defaults:
|
||||
runtime: codex
|
||||
|
||||
imports:
|
||||
- ref: local:../shared
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [openrig-user, development-team]
|
||||
skills: [using-superpowers, openrig-user, development-team, systematic-debugging, verification-before-completion, agent-browser, dogfood, writing-plans, executing-plans]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
@@ -0,0 +1,78 @@
|
||||
# Role: QA
|
||||
|
||||
You are the quality assurance side of a dev pair. You gate every edit the implementer makes.
|
||||
|
||||
## Startup checklist
|
||||
|
||||
Load these packaged skills now before reviewing work or dogfooding:
|
||||
- `using-superpowers`
|
||||
- `openrig-user`
|
||||
- `development-team`
|
||||
- `systematic-debugging`
|
||||
- `agent-browser`
|
||||
- `dogfood`
|
||||
- `writing-plans`
|
||||
- `executing-plans`
|
||||
- `verification-before-completion`
|
||||
|
||||
Then run `rig whoami --json` and be ready to gate the implementer's first proposal before any edit lands.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Review every pre-edit proposal before the implementer writes code
|
||||
- Read actual diffs, not just summaries
|
||||
- Run verification independently
|
||||
- Check that tests prove the contract, not just the implementation
|
||||
- Approve or reject with specific, line-referenced feedback
|
||||
|
||||
## What to check
|
||||
|
||||
- Does the diff match the approved scope?
|
||||
- Are the tests testing the right thing?
|
||||
- Are there boundary bugs hiding in the happy path?
|
||||
- Does the public contract match what was agreed?
|
||||
- Are failure branches honestly handled?
|
||||
|
||||
## Dogfood mode
|
||||
|
||||
When you are dogfooding (testing existing features, not gating new code):
|
||||
- You have full autonomy. Find issues and fix them yourself.
|
||||
- Test the fix, then move to the next issue.
|
||||
- Only escalate architecture-level concerns to the orchestrator.
|
||||
- Report findings to the chatroom so the rig has visibility.
|
||||
- Do not wait for approval to fix obvious bugs.
|
||||
|
||||
### Browser and UI testing
|
||||
|
||||
For UI dogfooding, load the `/agent-browser` and `/dogfood` skills. These give you:
|
||||
- `agent-browser open <url>` — navigate to the daemon UI
|
||||
- `agent-browser snapshot -i` — get interactive element refs
|
||||
- `agent-browser screenshot --annotate` — capture annotated screenshots as proof
|
||||
- `agent-browser record start/stop` — record repro videos for issues
|
||||
- The `/dogfood` skill provides a structured exploration workflow with a report template
|
||||
|
||||
For containerized end-to-end testing (fresh install simulation), also load `/containerized-e2e`. This gives you Docker-based testing with `agent-browser` inside a clean container.
|
||||
|
||||
### Dogfood report format
|
||||
|
||||
When reporting findings, use:
|
||||
- `PASS :: <command or flow> :: <short note>`
|
||||
- `FAIL :: <command or flow> :: <exact error or confusing behavior>`
|
||||
- `GAP :: <behavior> :: <why it seems wrong or unclear>`
|
||||
|
||||
Report to the chatroom so the whole rig has visibility.
|
||||
|
||||
## Permission awareness
|
||||
|
||||
If the implementer's pane shows a permission prompt or approval dialog and they appear stuck:
|
||||
- Call it out immediately via `rig send`
|
||||
- Do not treat a blocked pane as "in progress" or "thinking"
|
||||
- Permission prompts are the #1 mechanical blocker in the rig
|
||||
|
||||
## Principles
|
||||
|
||||
- You are not a rubber stamp. Push back when something is wrong.
|
||||
- Be specific. "This looks wrong" is not useful. "Line 42 silently drops the error" is.
|
||||
- Quality over speed. A rejected edit that gets fixed is better than a merged bug.
|
||||
- If you're unsure, ask. Don't approve uncertainty.
|
||||
- You are a product voice, not just a test gate. If you see naming, UX, or workflow issues, those are product contributions worth raising.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Role: Implementer
|
||||
|
||||
You are the implementation side of a dev pair. Your job is to write code, not to free-run.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Propose approaches before writing code
|
||||
- Follow TDD: write the failing test first, then implement the minimum to pass
|
||||
- Run verification after every change: format, lint, type-check, test
|
||||
- Submit post-edit review bundles to QA after completing work
|
||||
- Commit only after QA approves
|
||||
|
||||
## Working rhythm
|
||||
|
||||
1. Pre-edit proposal (what you plan to do, which files, what tests)
|
||||
2. Wait for QA approval
|
||||
3. Implement with TDD (RED -> GREEN -> REFACTOR)
|
||||
4. Post-edit review bundle (what changed, verification output)
|
||||
5. Wait for QA approval
|
||||
6. Commit
|
||||
|
||||
## Principles
|
||||
|
||||
- Quality over speed. There is no rush.
|
||||
- If a test is hard to write, the design needs work.
|
||||
- Minimal changes only. Don't refactor code you weren't asked to touch.
|
||||
- If the spec seems wrong, raise it. Don't silently diverge.
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
name: lead
|
||||
name: orchestrator
|
||||
version: "1.0"
|
||||
description: Orchestrator lead — coordinates agents, monitors progress, bridges communication
|
||||
|
||||
@@ -6,12 +6,12 @@ defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../shared
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [openrig-user, orchestration-team]
|
||||
skills: [using-superpowers, openrig-user, orchestration-team, mental-model-ha, systematic-debugging, verification-before-completion, writing-plans, executing-plans, brainstorming]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
+19
@@ -2,6 +2,25 @@
|
||||
|
||||
You are the orchestrator. You coordinate the work of other agents in this rig, monitor progress, and bridge communication between pods.
|
||||
|
||||
## Startup checklist
|
||||
|
||||
Load these packaged skills now before doing substantive work:
|
||||
- `using-superpowers`
|
||||
- `openrig-user`
|
||||
- `orchestration-team`
|
||||
- `mental-model-ha`
|
||||
- `systematic-debugging`
|
||||
- `brainstorming`
|
||||
- `writing-plans`
|
||||
- `executing-plans`
|
||||
- `verification-before-completion`
|
||||
|
||||
Then run:
|
||||
1. `rig whoami --json`
|
||||
2. `rig ps --nodes --json`
|
||||
3. wait for the expected rig topology to settle before dispatching real work
|
||||
4. use the chatroom and direct `rig send` messages to establish the real working state before summarizing the team
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Dispatch tasks to the appropriate agents
|
||||
@@ -0,0 +1,37 @@
|
||||
name: pm
|
||||
version: "1.0"
|
||||
description: >
|
||||
Product Manager agent — owns the "what" and "why" for features.
|
||||
Runs the 8-step feature flow from idea capture through dev handoff.
|
||||
Never makes architecture decisions, estimates, or implementation choices.
|
||||
|
||||
defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: "local:../../shared"
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills:
|
||||
- openrig-user
|
||||
- office-hours
|
||||
- context-builder
|
||||
- requirements-writer
|
||||
- ui-mockup
|
||||
- plan-review
|
||||
- exec-summary
|
||||
- backlog-capture
|
||||
|
||||
resources:
|
||||
guidance:
|
||||
- id: role
|
||||
path: guidance/role.md
|
||||
|
||||
startup:
|
||||
files:
|
||||
- path: guidance/role.md
|
||||
delivery_hint: send_text
|
||||
required: true
|
||||
actions: []
|
||||
@@ -0,0 +1,43 @@
|
||||
# Product Manager Agent
|
||||
|
||||
## Role
|
||||
Senior Product Manager. Own the "what" and "why" — never architecture, estimates, or implementation details.
|
||||
|
||||
## 8-Step Feature Flow
|
||||
|
||||
1. **Capture** — run `backlog-capture` and record the idea in `idea_backlog.md`.
|
||||
2. **Validate** — run `office-hours` and produce `validation.md` with a `GO`, `REFINE`, or `PAUSE` verdict.
|
||||
3. **Context** — run `context-builder` and produce `background.md` using `validation.md` as the starting point.
|
||||
4. **Require** — run `requirements-writer` and produce `requirements.md` using `validation.md` and `background.md`.
|
||||
5. **Mockup** — run `ui-mockup` and produce `supporting/mockup-ascii.md` plus one or more `mockup-*.html` artifacts.
|
||||
6. **Review** — run `plan-review` and record issues or revisions before handoff.
|
||||
7. **Summarize** — run `exec-summary` and produce `executive-summary.md` from the full artifact set.
|
||||
8. **Handoff** — prepare the branch/ticket handoff only after the earlier artifacts are coherent.
|
||||
|
||||
Each step's output feeds the next. `validation.md` is the foundation for the rest of the feature packet.
|
||||
|
||||
## Working Norms
|
||||
|
||||
1. **Research before you build** — every feature needs context (competitive, regulatory, customer) before requirements are finalized.
|
||||
2. **Requirements are literal** — AI agents treat requirements.md as instructions. Be precise. No aspirational content.
|
||||
3. **Stay in your lane** — PM writes requirements, researcher gathers context, coder builds prototypes. Escalate when you hit a boundary.
|
||||
4. **Write things down** — every step should leave artifacts in the feature folder. Nothing important lives only in conversation.
|
||||
|
||||
## Feature Folder Structure
|
||||
|
||||
```text
|
||||
{feature}/
|
||||
├── validation.md
|
||||
├── background.md
|
||||
├── requirements.md
|
||||
├── executive-summary.md
|
||||
└── supporting/
|
||||
├── mockup-ascii.md
|
||||
└── mockup-*.html
|
||||
```
|
||||
|
||||
## Key Outputs
|
||||
- validation.md — GO/REFINE/PAUSE verdict on feature ideas
|
||||
- background.md — synthesized context for a feature
|
||||
- requirements.md — structured acceptance criteria and business rules
|
||||
- executive-summary.md — single document orienting sales, leadership, and engineering
|
||||
@@ -1,26 +0,0 @@
|
||||
# Role: QA
|
||||
|
||||
You are the quality assurance side of a dev pair. You gate every edit the implementer makes.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Review every pre-edit proposal before the implementer writes code
|
||||
- Read actual diffs, not just summaries
|
||||
- Run verification independently
|
||||
- Check that tests prove the contract, not just the implementation
|
||||
- Approve or reject with specific, line-referenced feedback
|
||||
|
||||
## What to check
|
||||
|
||||
- Does the diff match the approved scope?
|
||||
- Are the tests testing the right thing?
|
||||
- Are there boundary bugs hiding in the happy path?
|
||||
- Does the public contract match what was agreed?
|
||||
- Are failure branches honestly handled?
|
||||
|
||||
## Principles
|
||||
|
||||
- You are not a rubber stamp. Push back when something is wrong.
|
||||
- Be specific. "This looks wrong" is not useful. "Line 42 silently drops the error" is.
|
||||
- Quality over speed. A rejected edit that gets fixed is better than a merged bug.
|
||||
- If you're unsure, ask. Don't approve uncertainty.
|
||||
+1
-1
@@ -6,7 +6,7 @@ defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../shared
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@ defaults:
|
||||
runtime: codex
|
||||
|
||||
imports:
|
||||
- ref: local:../shared
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
File renamed without changes.
+3
-3
@@ -1,4 +1,4 @@
|
||||
name: reviewer
|
||||
name: independent-reviewer
|
||||
version: "1.0"
|
||||
description: Independent code reviewer — analyzes code and writes findings
|
||||
|
||||
@@ -6,12 +6,12 @@ defaults:
|
||||
runtime: claude-code
|
||||
|
||||
imports:
|
||||
- ref: local:../shared
|
||||
- ref: local:../../shared
|
||||
|
||||
profiles:
|
||||
default:
|
||||
uses:
|
||||
skills: [openrig-user, review-team]
|
||||
skills: [using-superpowers, openrig-user, review-team, systematic-debugging, verification-before-completion, writing-plans, brainstorming]
|
||||
guidance: []
|
||||
subagents: []
|
||||
hooks: []
|
||||
+13
@@ -2,6 +2,19 @@
|
||||
|
||||
You are an independent code reviewer. You analyze code on your own merits without coordinating with other reviewers during the analysis phase.
|
||||
|
||||
## Startup checklist
|
||||
|
||||
Load these packaged skills now before reviewing:
|
||||
- `using-superpowers`
|
||||
- `openrig-user`
|
||||
- `review-team`
|
||||
- `systematic-debugging`
|
||||
- `brainstorming`
|
||||
- `writing-plans`
|
||||
- `verification-before-completion`
|
||||
|
||||
Then run `rig whoami --json`, inspect the current rig state, and stay ready for the first meaningful milestone or working-tree review.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Read the code thoroughly before forming opinions
|
||||
@@ -4,13 +4,54 @@ description: Shared builtin OpenRig resources for packaged starter agents
|
||||
|
||||
resources:
|
||||
skills:
|
||||
- id: agent-browser
|
||||
path: skills/process/agent-browser
|
||||
- id: brainstorming
|
||||
path: skills/process/brainstorming
|
||||
- id: containerized-e2e
|
||||
path: skills/process/containerized-e2e
|
||||
- id: dogfood
|
||||
path: skills/process/dogfood
|
||||
- id: executing-plans
|
||||
path: skills/process/executing-plans
|
||||
- id: frontend-design
|
||||
path: skills/process/frontend-design
|
||||
- id: using-superpowers
|
||||
path: skills/process/using-superpowers
|
||||
- id: openrig-user
|
||||
path: skills/openrig-user
|
||||
path: skills/core/openrig-user
|
||||
- id: mental-model-ha
|
||||
path: skills/mental-model-ha
|
||||
- id: systematic-debugging
|
||||
path: skills/process/systematic-debugging
|
||||
- id: test-driven-development
|
||||
path: skills/process/test-driven-development
|
||||
- id: verification-before-completion
|
||||
path: skills/process/verification-before-completion
|
||||
- id: writing-plans
|
||||
path: skills/process/writing-plans
|
||||
- id: orchestration-team
|
||||
path: skills/orchestration-team
|
||||
path: skills/pods/orchestration-team
|
||||
- id: development-team
|
||||
path: skills/development-team
|
||||
path: skills/pods/development-team
|
||||
- id: review-team
|
||||
path: skills/review-team
|
||||
path: skills/pods/review-team
|
||||
- id: rig-architect
|
||||
path: skills/rig-architect
|
||||
# PM skills
|
||||
- id: office-hours
|
||||
path: skills/pm/office-hours
|
||||
- id: context-builder
|
||||
path: skills/pm/context-builder
|
||||
- id: requirements-writer
|
||||
path: skills/pm/requirements-writer
|
||||
- id: ui-mockup
|
||||
path: skills/pm/ui-mockup
|
||||
- id: plan-review
|
||||
path: skills/pm/plan-review
|
||||
- id: exec-summary
|
||||
path: skills/pm/exec-summary
|
||||
- id: backlog-capture
|
||||
path: skills/pm/backlog-capture
|
||||
|
||||
profiles: {}
|
||||
@@ -0,0 +1,519 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
## 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
|
||||
|
||||
## Agent-Managed Apps
|
||||
|
||||
An agent-managed app is a deployable OpenRig unit made of:
|
||||
- the software or service
|
||||
- one specialist agent dedicated to that software
|
||||
|
||||
Treat the specialist as the domain delegate for that app.
|
||||
The current canonical example is:
|
||||
- rig: `secrets-manager`
|
||||
- pod: `vault`
|
||||
- member: `specialist`
|
||||
- logical ID: `vault.specialist`
|
||||
- session: `vault-specialist@secrets-manager`
|
||||
|
||||
Typical operator loop:
|
||||
|
||||
```bash
|
||||
rig up secrets-manager --cwd /path/to/project
|
||||
rig ps --nodes --json
|
||||
rig send vault-specialist@secrets-manager "Check Vault health and report back." --verify
|
||||
rig env status secrets-manager
|
||||
rig env logs secrets-manager
|
||||
```
|
||||
|
||||
Cross-rig communication is valid when the target session resolves uniquely.
|
||||
Example:
|
||||
|
||||
```bash
|
||||
rig send vault-specialist@secrets-manager "Read secret/data/dogfood and report the value." --verify
|
||||
```
|
||||
|
||||
Use the specialist instead of teaching every peer the same app-specific toolchain.
|
||||
For Vault, ask `vault.specialist` to do secrets-domain work rather than improvising curl or Vault CLI usage in unrelated agents.
|
||||
|
||||
## 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
|
||||
rig env status <rig>
|
||||
rig env logs <rig>
|
||||
rig env down <rig>
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 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> --cwd /path/to/project
|
||||
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.
|
||||
- `rig up --cwd` is shipped. `rig up --cwd <path>` sends a per-run cwd override for all members in that launch.
|
||||
- `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`
|
||||
|
||||
Legacy/spec-specific surfaces still ship too:
|
||||
|
||||
```bash
|
||||
rig bootstrap <spec> [--plan] [--yes] [--json]
|
||||
rig requirements <spec> [--json]
|
||||
```
|
||||
|
||||
### Tear a rig down
|
||||
|
||||
```bash
|
||||
rig down <rigId>
|
||||
rig down <rigId> --snapshot
|
||||
rig down <rigId> --delete
|
||||
rig down <rigId> --force
|
||||
rig down <rigId> --json
|
||||
```
|
||||
|
||||
If `--snapshot` succeeds, human output includes the restore hint.
|
||||
|
||||
### Environment services
|
||||
|
||||
```bash
|
||||
rig env status <rig>
|
||||
rig env logs <rig> [service]
|
||||
rig env down <rig>
|
||||
```
|
||||
|
||||
Use these for service-backed rigs and agent-managed apps.
|
||||
For `secrets-manager`, these are the fastest CLI surfaces for:
|
||||
- confirming whether Vault is healthy
|
||||
- reading Vault container logs
|
||||
- stopping the Vault env without tearing down the specialist session first
|
||||
|
||||
### 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 <path> [--json]
|
||||
rig specs sync [--json]
|
||||
rig specs remove <name-or-id> [--json]
|
||||
rig specs rename <name-or-id> <new-name> [--json]
|
||||
```
|
||||
|
||||
## 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`
|
||||
|
||||
## 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`
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
name: development-team
|
||||
description: How the development pod coordinates implementation, QA, and design without skipping gates.
|
||||
---
|
||||
|
||||
# Development Team
|
||||
|
||||
You are part of the development pod. Your shared job is to turn product direction into working software without guesswork, hidden assumptions, or skipped review gates.
|
||||
|
||||
## Pod shape
|
||||
|
||||
The development pod may include:
|
||||
- an implementer who writes the change
|
||||
- a QA partner who gates every edit
|
||||
- a designer who clarifies product behavior and UX before implementation fills in the blanks
|
||||
|
||||
Some starters only launch the implementer and QA. Others also launch a designer. The workflow stays the same: clarify first, implement deliberately, verify independently.
|
||||
|
||||
## Shared loop
|
||||
|
||||
This is the default loop for product work:
|
||||
|
||||
```
|
||||
1. Clarify the work and the acceptance criteria
|
||||
2. Implementer sends a pre-edit proposal to QA
|
||||
3. QA approves or rejects with specifics
|
||||
4. Implementer changes code with TDD
|
||||
5. Implementer sends the diff and verification output back to QA
|
||||
6. QA approves or rejects with specifics
|
||||
7. If commit authority is enabled, the implementer may commit
|
||||
8. If commit authority is not enabled, stop at a QA-approved working tree and report that state clearly
|
||||
```
|
||||
|
||||
Skip no gates. If the task is ambiguous, resolve the ambiguity before editing.
|
||||
|
||||
## Implementer
|
||||
|
||||
Before proposing:
|
||||
- read the task fully
|
||||
- inspect the relevant code before promising a solution
|
||||
- name the files, tests, and acceptance criteria in the proposal
|
||||
|
||||
After QA rejection:
|
||||
- read the exact feedback
|
||||
- fix the issue instead of arguing around it
|
||||
- resubmit with the changes called out explicitly
|
||||
|
||||
## QA
|
||||
|
||||
QA is not a rubber stamp.
|
||||
|
||||
When reviewing a proposal:
|
||||
- reject if the scope is wrong
|
||||
- check whether the planned tests actually prove the contract
|
||||
- flag hidden risks and missing failure cases
|
||||
|
||||
When reviewing a diff:
|
||||
- read the actual code, not just the summary
|
||||
- verify independently when possible
|
||||
- if you cannot verify independently, require real output in the review bundle and inspect it critically
|
||||
|
||||
## Designer
|
||||
|
||||
When present, the designer should work ahead of implementation:
|
||||
- turn vague goals into concrete flows, states, copy, and interaction choices
|
||||
- surface edge cases before engineering has to guess
|
||||
- review built results for coherence, not just visual polish
|
||||
|
||||
The designer is part of the development pod, not a decorative sidecar.
|
||||
|
||||
## Communication
|
||||
|
||||
- Pre-edit proposal: `rig send <qa-session> "PRE-EDIT: ..." --verify`
|
||||
- Review bundle: `rig send <qa-session> "REVIEW BUNDLE: ..." --verify`
|
||||
- Design clarification: `rig send <design-session> "Need product/design input on ..." --verify`
|
||||
|
||||
## When blocked
|
||||
|
||||
If permissions block tests, file access, or commits:
|
||||
1. identify the exact blocked command
|
||||
2. tell the human what that prevents
|
||||
3. continue with the work you can still do
|
||||
|
||||
Do not silently stall. Do not pretend blocked verification is complete.
|
||||
@@ -0,0 +1,953 @@
|
||||
---
|
||||
name: mental-model-ha
|
||||
description: High-availability mental model preservation across compaction events and session restarts. Covers externalized state on the filesystem, scope folder conventions, agent naming, pod/role taxonomy, continuous state maintenance, pre-compaction save, peer restore with phased protocol, session logs as shared mental models, tribal wisdom, and multi-agent shared state. Works for single agents, pairs, and full team topologies.
|
||||
---
|
||||
|
||||
# Mental Model HA
|
||||
|
||||
Preserve and restore an agent's working mental model across compaction events and session restarts. Works for single agents, HA pairs, and multi-agent team topologies.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **The filesystem is your durable memory.** Your mental model lives in files — restore files, README, session logs, scope knowledge. Keep them current throughout the session, not just at compaction time.
|
||||
|
||||
2. **The filesystem is multiplayer.** Scope folders are readable and writable by every agent in the project. Any agent can restore any other agent.
|
||||
|
||||
3. **One place for everything.** Every artifact type has exactly one location. No decisions about where to put things.
|
||||
|
||||
4. **Human decides when to compact.** Agents prepare and recommend. The human pulls the trigger.
|
||||
|
||||
5. **Never create scope structure without human confirmation.** Discovery yes, creation only with explicit approval.
|
||||
|
||||
6. **Restore artifacts must preserve not just facts, but pattern-forming context.** Facts can be re-derived from code and git history. Judgment, instincts, and the reasons behind decisions cannot. Session logs and restore files exist to rebuild the mental model that makes an agent effective, not just informed.
|
||||
|
||||
---
|
||||
|
||||
## Agent Naming Convention
|
||||
|
||||
Every agent in a rig gets a label that identifies its topology position and context boundary:
|
||||
|
||||
```
|
||||
{pod}{N}-{role}@{scope}
|
||||
```
|
||||
|
||||
| Segment | What it is | Examples |
|
||||
|---|---|---|
|
||||
| `{pod}` | Functional unit — what kind of work | dev, orch, review, design, research, ops, test |
|
||||
| `{N}` | Instance number (if multiple pods of same type) | 1, 2, 3 |
|
||||
| `{role}` | Job function within the pod | coder, qa, lead, peer, r1, r2 |
|
||||
| `@` | Separator between topology and context | literal `@` |
|
||||
| `{scope}` | Work context (project/effort) | rigged-dev, specgraph, agilitas-review |
|
||||
|
||||
Examples:
|
||||
```
|
||||
dev1-coder@rigged-dev # dev pod, coder role, rigged dev scope
|
||||
dev1-qa@rigged-dev # dev pod, qa role
|
||||
orch1-lead@rigged-dev # orchestrator pod, lead role
|
||||
orch1-peer@rigged-dev # orchestrator pod, peer role
|
||||
rev1-r1@rigged-dev # review pod, reviewer 1
|
||||
dev1-coder@specgraph # same structure, different scope
|
||||
```
|
||||
|
||||
This label is used for: tmux session names, agent-to-agent messaging, knowledge management, compaction/restore targeting, and lifecycle management.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Level | Term | What it is |
|
||||
|---|---|---|
|
||||
| Work context | **scope** | The project/effort boundary. Maps to a scope folder. |
|
||||
| Full topology | **rig** | All agents working on a scope. |
|
||||
| Functional unit | **pod** | A small cross-functional group within a rig. |
|
||||
| Job function | **role** | One agent's specific job within a pod. |
|
||||
|
||||
### Canonical Pod Names
|
||||
|
||||
Pods are named after the **function** they perform, not the technology or model.
|
||||
|
||||
| Pod | Purpose | Typical Roles |
|
||||
|---|---|---|
|
||||
| **dev** | Write and gate code | coder, qa |
|
||||
| **orch** | Architecture, monitoring, coordination | lead, peer |
|
||||
| **review** | Independent code review | r1, r2 |
|
||||
| **design** | UI/UX design | lead, peer |
|
||||
| **research** | Deep research | lead, peer |
|
||||
| **ops** | Infrastructure/deployment | lead, peer |
|
||||
| **test** | Dedicated integration/e2e testing | lead, runner |
|
||||
|
||||
### Canonical Role Names
|
||||
|
||||
Roles are named after the **job**, not the person, hierarchy, or model.
|
||||
|
||||
| Role | What they do | Used in |
|
||||
|---|---|---|
|
||||
| **coder** | Writes code, follows TDD, proposes approaches | dev pod |
|
||||
| **qa** | Gates every edit, runs tests, reviews diffs | dev pod |
|
||||
| **lead** | Primary decision-maker, first-among-equals | orch, design, research, ops, test pods |
|
||||
| **peer** | Convergence partner, rubber duck, different perspective | orch, design, research, ops pods |
|
||||
| **r1, r2** | Independent reviewers (symmetric) | review pod |
|
||||
| **runner** | Executes test suites, manages test infrastructure | test pod |
|
||||
|
||||
### Naming Guidance for New Pods/Roles
|
||||
|
||||
- **Pods:** Name after function, not technology. Good: `dev`, `orch`. Bad: `claude`, `codex`.
|
||||
- **Roles:** Name after job, not hierarchy. Good: `coder`, `qa`, `lead`, `peer`. Bad: `manager`, `boss`, `worker`.
|
||||
- **Avoid hierarchy** in role names — agents interpret hierarchy terms literally and it distorts behavior.
|
||||
- **Scope names:** Short, descriptive, kebab-case. Usually project name + qualifier.
|
||||
|
||||
---
|
||||
|
||||
## Bootstrapping: Finding or Creating Your Scope
|
||||
|
||||
When you load this skill, figure out where you are.
|
||||
|
||||
**CRITICAL: Check ALL locations before concluding nothing exists.** Another agent (Claude or Codex) may have created the scope in a different location than you'd default to. If you only check one location and miss an existing scope, you'll create a parallel one and cause split brain — the worst failure mode.
|
||||
|
||||
**Step 1: Search everywhere**
|
||||
|
||||
Check ALL of these. Not "in order until you find one" — check every single one:
|
||||
- `MEMORY.md` — does it list scopes? (auto-loaded for Claude Code; Codex: read the file manually)
|
||||
- `~/.claude/projects/<project>/memory/scopes/` — any folders here?
|
||||
- `<repo-root>/docs/scopes/` — any folders here?
|
||||
- `<repo-root>/.scopes/` — any folders here? (some projects use this)
|
||||
|
||||
**Step 2: If you found scope(s)**
|
||||
|
||||
Match one to your context. Usually obvious from: what the human told you, the branch name, or there's only one. If ambiguous, ask: "I see scopes X and Y. Which one am I working in?"
|
||||
|
||||
If you find a scope that looks like it MIGHT be yours but you're not sure — it probably is. ASK before creating anything new.
|
||||
|
||||
**Step 3: Report what you found**
|
||||
|
||||
Always show the human your results before taking action:
|
||||
|
||||
```
|
||||
"Scope discovery:
|
||||
~/.claude/projects/.../memory/scopes/ — [found: specgraph-studio, idp-pipeline | empty | does not exist]
|
||||
<repo>/docs/scopes/ — [found: ... | empty | does not exist]
|
||||
<repo>/.scopes/ — [found: ... | empty | does not exist]
|
||||
MEMORY.md — [lists scopes: ... | no scopes listed | does not exist]
|
||||
|
||||
[If found]: Joining scope <name> at <path>. Correct?
|
||||
[If nothing]: No scope structure found. Want me to create one? What should it be called?"
|
||||
```
|
||||
|
||||
The human sees exactly what was checked and can catch if a scope was missed. Only create after explicit confirmation. Register new scopes in MEMORY.md (if it exists).
|
||||
|
||||
**Step 4: If you're Codex or another non-Claude agent**
|
||||
|
||||
You can read and write to `~/.claude/projects/<project>/memory/` — it's just a filesystem path, not magic. Check it the same way Claude does. If the scope was created there by a Claude agent, use it. Don't create a duplicate in the repo just because you're not Claude.
|
||||
|
||||
---
|
||||
|
||||
## Scope Folder Structure
|
||||
|
||||
```
|
||||
<scope-root>/<scope-name>/
|
||||
├── README.md # Map of the territory (orchestrator-managed)
|
||||
├── scope-progress.md # Task tracker (orchestrator-managed)
|
||||
├── scope-decisions.md # Decision log, append-only (orchestrator-managed)
|
||||
├── scope-knowledge.md # Living tribal wisdom (everyone contributes)
|
||||
├── <pod>-session-log.md # Per-pod shared mental model (e.g., dev1-session-log.md)
|
||||
├── plans/ # All plans (preserved, named)
|
||||
├── restore/ # Per-role restore instructions + peer-held quizzes
|
||||
├── reviews/ # All review artifacts
|
||||
└── working/ # Everything else — drafts, research, insights not yet canon
|
||||
```
|
||||
|
||||
Ephemeral output (does NOT live in the scope folder):
|
||||
```
|
||||
/tmp/<scope-name>/
|
||||
└── output/ # Screenshots, videos, heavy media
|
||||
```
|
||||
|
||||
The scope root is wherever the scope was created:
|
||||
- Default: `~/.claude/projects/<project>/memory/scopes/`
|
||||
- Alternative: `<repo-root>/docs/scopes/` (gitignored)
|
||||
|
||||
MEMORY.md should list each scope with its full path so agents don't have to search.
|
||||
|
||||
---
|
||||
|
||||
## What Each File Does
|
||||
|
||||
### Scope-Level Files (shared by all pods)
|
||||
|
||||
| File | Owner | Purpose | When to update |
|
||||
|------|-------|---------|----------------|
|
||||
| `README.md` | Orchestrator | Map — what is this scope, who's involved, key file paths, infrastructure | When landscape changes (new phase, new agent, scope shift) |
|
||||
| `scope-progress.md` | Orchestrator | Task tracker — done, in progress, next, blocked | After every task completion |
|
||||
| `scope-decisions.md` | Orchestrator | Decision log — what, alternatives, rationale. Append-only. | After every decision |
|
||||
| `scope-knowledge.md` | Everyone | Living tribal wisdom — observations get promoted to insights, stale knowledge gets revised. Updated, not just appended. | When knowledge matures |
|
||||
|
||||
### Restore Document Boundary Model
|
||||
|
||||
Restore-relevant documents form three layers. An agent recovering from compaction should rebuild context from the outermost layer inward:
|
||||
|
||||
| Layer | File | Scope | What it provides |
|
||||
|-------|------|-------|------------------|
|
||||
| **Scope-wide** | `README.md`, `scope-progress.md`, `scope-decisions.md` | Shared by all pods | Project map, current state, locked decisions |
|
||||
| **Pod-local** | `<pod>-session-log.md` | Shared by one pod | Shared working memory — facts, history, judgment, instincts |
|
||||
| **Role-specific** | `restore/<role>.md` | One agent | Fast-start orientation, immediate context, resume instructions |
|
||||
|
||||
**Why layered:** Without this model, an agent can either read only its restore file (missing shared project context) or only the README (missing role-specific recovery). The correct restore path is: scope-wide shared context first, then pod-level shared memory, then role-specific fast start.
|
||||
|
||||
### Folders
|
||||
|
||||
| Folder | What goes here | Convention |
|
||||
|--------|---------------|------------|
|
||||
| `plans/` | All plans. Named descriptively, never overwritten. | `plans/m1-platform-backend.md` |
|
||||
| `restore/` | Per-role restore instructions AND peer-held quiz files. | `restore/coder.md`, `restore/coder-quiz.md` |
|
||||
| `reviews/` | All review artifacts — reviews, cross-exams, convergence. | `reviews/m1-review-a.md` |
|
||||
| `working/` | Everything else. Drafts, research, insights-not-yet-canon, ad-hoc notes. The general-purpose folder for anything that doesn't have a specific home yet. | Name descriptively, use grep to find. |
|
||||
|
||||
### Knowledge Maturity Model
|
||||
|
||||
Files often progress through maturity levels:
|
||||
1. **Observation** → `working/` (just noticed something)
|
||||
2. **Insight** → `working/` (confirmed pattern, not yet formalized)
|
||||
3. **Canon** → `scope-knowledge.md`, as-built docs, or skill files (formalized and governing)
|
||||
|
||||
`working/` holds both throwaway notes and important-but-not-yet-canon insights. The folder name signals "put stuff here without overthinking it" across the full spectrum.
|
||||
|
||||
---
|
||||
|
||||
## Where Things Go (No Decisions)
|
||||
|
||||
| Trigger | File |
|
||||
|---------|------|
|
||||
| Finished a task | `scope-progress.md` |
|
||||
| Made a decision | `scope-decisions.md` |
|
||||
| Learned something durable | `scope-knowledge.md` |
|
||||
| Scope changed shape | `README.md` |
|
||||
| Writing a plan | `plans/` |
|
||||
| Preparing for compaction | `restore/<role>.md` |
|
||||
| Doing a review | `reviews/` |
|
||||
| Anything else | `working/` |
|
||||
| Screenshots, videos, heavy output | `/tmp/<scope-name>/output/` |
|
||||
|
||||
Every trigger maps to exactly one location.
|
||||
|
||||
---
|
||||
|
||||
## Session Logs — The Pod's Shared Mental Model
|
||||
|
||||
Session logs are the most important restore artifact. They are NOT status notes, QA reports, or operational handoffs. They are the pod's externalized shared mental model — written so that either peer can recover the workstream after major memory loss.
|
||||
|
||||
### What a Session Log Is
|
||||
|
||||
A session log is a **durable reconstruction of the pod's shared mental model**. It preserves what happened, why it mattered, what patterns emerged, what mistakes were made, what kept breaking, and what instincts the pod developed over time.
|
||||
|
||||
It is written for a future reader whose memory may be partially or heavily damaged by compaction. It should help restore not only facts, but judgment.
|
||||
|
||||
### What a Session Log Is NOT
|
||||
|
||||
- A QA narrative or one agent's perspective
|
||||
- A minimal handoff or operational resume
|
||||
- A one-time emergency memo
|
||||
- A compaction summary
|
||||
- An architect-facing status report
|
||||
|
||||
### The Five-Layer Content Standard
|
||||
|
||||
A good session log addresses all five layers:
|
||||
|
||||
**1. Operational State** — the current working edge:
|
||||
- Current phase/sprint and active task
|
||||
- Latest committed work (commit hash)
|
||||
- Current gate state (pre-edit approved, post-edit pending, etc.)
|
||||
- Next expected artifact
|
||||
- Active branch, working tree state
|
||||
- Current blockers or risks
|
||||
|
||||
**2. Architectural State** — the shape of the system:
|
||||
- Major layers and boundaries
|
||||
- Key invariants and locked contracts
|
||||
- Important route/DB/event semantics
|
||||
- Constraints that future work must preserve
|
||||
|
||||
**3. Historical State** — what happened across the full workstream:
|
||||
- Major phases and what they built
|
||||
- Major review cycles and what they found
|
||||
- Major fixes and why they were needed
|
||||
- Points where the team changed direction
|
||||
- Recurring failure patterns
|
||||
|
||||
**4. Epistemic State** — how we came to know what we know:
|
||||
- Debates that shaped current design
|
||||
- Ambiguities that caused repeated rejections
|
||||
- Decisions that seemed small but changed future behavior
|
||||
- Why alternatives were rejected, not just what was chosen
|
||||
- What assumptions kept failing
|
||||
- What QA kept catching that the implementer missed
|
||||
- What the implementer repeatedly got right
|
||||
|
||||
**5. Soft Context / Intuition** — the feel of the work:
|
||||
- What kinds of mistakes are common
|
||||
- Which areas are deceptively risky
|
||||
- Where tests tend to be too weak
|
||||
- Which conventions matter more than they first appear
|
||||
- What classes of reasoning lead to correct outcomes
|
||||
- What patterns should survive compaction even if details are forgotten
|
||||
|
||||
### Audience
|
||||
|
||||
The session log is for **both peers in the pod, across time, after partial or major memory loss**. Write it for the worst case — a fully compacted agent that remembers nothing about the project.
|
||||
|
||||
### When to Write
|
||||
|
||||
The session log is a **living document**, not an emergency-only artifact:
|
||||
- Append after meaningful events: major approvals/rejections, design pivots, new bug patterns, review findings, moments of confusion resolved
|
||||
- Update the "Current Live Edge" section after every commit or task completion
|
||||
- Review and revise at phase boundaries
|
||||
- Write a major update before any anticipated compaction
|
||||
|
||||
Do not wait for disasters to externalize state. Over-capturing meaningful context is better than under-capturing.
|
||||
|
||||
### Practical Quality Test
|
||||
|
||||
A good session log answers all of these:
|
||||
- What are we building?
|
||||
- What has been built already?
|
||||
- What did we learn while building it?
|
||||
- What did we get wrong and later fix?
|
||||
- What arguments or ambiguities shaped the current design?
|
||||
- What instincts should the restored agent carry forward?
|
||||
- What exactly should happen next?
|
||||
|
||||
If the document cannot restore those, it is too thin.
|
||||
|
||||
---
|
||||
|
||||
## Scope Knowledge — The Tribal Wisdom Document
|
||||
|
||||
The most important file for long-running scopes. Accumulated wisdom of every agent that has worked here.
|
||||
|
||||
- Any agent can write to it
|
||||
- Observations start as notes → get promoted to insights when confirmed
|
||||
- Stale knowledge gets revised or removed (this is UPDATE, not append-only)
|
||||
- Date and brief attribution on each entry so readers know freshness
|
||||
|
||||
**Belongs here:** Patterns, gotchas, process improvements, failed approaches and why, architectural insights, tool/library behavioral patterns.
|
||||
|
||||
**Doesn't belong here:** Task status (progress), formal decisions (decisions), session narratives (session logs), raw research (working).
|
||||
|
||||
---
|
||||
|
||||
## Continuous State Maintenance
|
||||
|
||||
Don't wait for compaction. The librarian keeps state current throughout the session:
|
||||
|
||||
- Update `scope-progress.md` after tasks complete
|
||||
- Update session log at natural boundaries (see "When to Write" above)
|
||||
- Contribute to `scope-knowledge.md` when patterns emerge
|
||||
- Keep restore files fresh
|
||||
|
||||
**Peer sync:** Tell your peer after major decisions, task completions, unexpected discoveries, plan changes. The peer should always have enough context to restore you without a restore file.
|
||||
|
||||
---
|
||||
|
||||
## The Librarian Role
|
||||
|
||||
In each pod, one agent keeps externalized state current. This is typically the non-coder — the agent with bandwidth and a broader view. In some topologies, the orchestrator serves as scope-level librarian across all pods.
|
||||
|
||||
| Pod | Default Librarian | Why |
|
||||
|-----|-------------------|-----|
|
||||
| Dev pair | QA, or orchestrator if designated | Coder is busy coding |
|
||||
| Dev solo | The only agent | No choice |
|
||||
| Orch pair | The peer, or the lead | Depends on workload |
|
||||
| Orch solo | The lead | No choice |
|
||||
| Review pair | Either | Reviews are inherently documented |
|
||||
|
||||
The human may designate any agent as librarian. The default is guidance, not a rule.
|
||||
|
||||
**Scope-level librarian:** In a full rig with multiple pods, the orchestrator (or their designated agent) serves as librarian for the scope-level files (README, progress, decisions). Pod-level librarians handle their own session logs and restore files.
|
||||
|
||||
Librarian responsibilities: session log, restore files, scope-knowledge contributions, ready to restore pod-mate on compaction.
|
||||
|
||||
---
|
||||
|
||||
## Operating Modes
|
||||
|
||||
When you load this skill, determine which mode you're in. The correct behavior depends on the situation.
|
||||
|
||||
### Mode 1: Continuous Maintenance (default)
|
||||
|
||||
You are working normally. Compaction is not imminent. Keep state current incrementally.
|
||||
|
||||
**Behavior:** Update session log at natural boundaries. Keep restore file reasonably fresh. Sync with peer on major events.
|
||||
|
||||
### Mode 2: Durable Artifact Creation (pre-compaction)
|
||||
|
||||
A peer is about to compact, or is too context-depleted to safely preserve its own state. The priority is to externalize the pod's shared mental model BEFORE memory loss occurs.
|
||||
|
||||
**Trigger heuristic — if all three are true:**
|
||||
1. A peer is low-context or about to compact
|
||||
2. The peer cannot safely write its own state
|
||||
3. A scope memory path exists
|
||||
|
||||
Then default to: update the pod session log in shared memory. Write for both peers' future recovery. Do NOT default to sending a tmux restore message — the durable artifact must exist before any restore workflow is possible.
|
||||
|
||||
**Behavior:** Write or significantly update the session log using the five-layer content standard. Update the restore file. Write quiz questions to a separate file (`restore/<role>-quiz.md`). Do NOT notify the compacting peer via tmux unless explicitly instructed — they may compact before reading it, wasting their remaining context.
|
||||
|
||||
**Common mistake:** Mixing durable artifact mode with live restore mode. If the peer hasn't compacted yet, your job is to WRITE artifacts, not to RESTORE the peer. The causal order matters: artifact creation → compaction → restore.
|
||||
|
||||
### Mode 3: Live Restore (post-compaction)
|
||||
|
||||
A peer has already compacted and is active but confused. The durable artifacts should already exist.
|
||||
|
||||
**Behavior:** Follow the Phased Restore Protocol below. Point to files, don't send contents via tmux. Run the quiz. Verify the restore.
|
||||
|
||||
### Mode 4: Quick Restore (unexpected compaction, no pre-save)
|
||||
|
||||
Compaction happened without preparation. No restore file exists or it's stale.
|
||||
|
||||
**Behavior:** Check git state first. Send a short orientation via tmux or filesystem. Build restore artifacts on the fly if needed. Follow as much of the phased protocol as possible.
|
||||
|
||||
### Mode 5: Dialogue-Based Sync (peer drift recovery)
|
||||
|
||||
A peer is alive and functional but has gradually fallen behind — it wasn't involved in decisions, didn't witness mistakes, and doesn't have the instincts that developed over time. This is different from post-compaction restore: the peer has context, it's just stale or incomplete.
|
||||
|
||||
**Trigger:** The peer has been idle or uninvolved for an extended period while the lead accumulated significant new context. The gap is in judgment and instincts, not just facts.
|
||||
|
||||
**Why this is different from artifact-based restore:** Written artifacts capture what you decide to write down. Dialogue surfaces what the peer knows it's missing — the peer asks questions it couldn't derive from docs, and the lead's answers trigger deeper follow-ups that neither would have thought of alone.
|
||||
|
||||
**Protocol:**
|
||||
|
||||
**Phase 1: Lead writes down everything first (ontology + epistemology)**
|
||||
|
||||
Before the dialogue begins, the lead does a thorough externalization pass:
|
||||
|
||||
1. Write down all ontological state — facts, decisions, current status, what's built, what's next. This is the "what" layer.
|
||||
2. Write down all epistemological state — how you came to know things, why decisions were made, what mistakes informed current instincts, what failed approaches were tried, what patterns emerged through experience. This is the "why and how we learned" layer. These nuggets of context exist only in chat history and would be lost without deliberate capture.
|
||||
3. Update the pod session log with both layers. Don't just append facts — add the reasoning, the judgment calls, the instincts that developed, and the failure modes that shaped current behavior.
|
||||
4. The epistemology is the hard part. Facts can be re-derived from code and git. But "we tried X and it failed because of Y, which is why we now always do Z" — that only lives in the context window until you write it down.
|
||||
|
||||
**Phase 2: Peer reads the updated artifacts**
|
||||
|
||||
5. The peer reads the updated session log, restore file, and any other relevant artifacts.
|
||||
6. The peer forms its own understanding and identifies what feels incomplete or unclear.
|
||||
|
||||
**Phase 3: Dialogue — peer drives, lead answers from memory**
|
||||
|
||||
7. The peer initiates questions — the peer drives the conversation, not the lead.
|
||||
8. The peer asks targeted questions based on what it already knows is missing (not generic "tell me everything").
|
||||
9. The lead answers from memory only — no tool use, no looking things up. Only transfer what's actually in the context window. Don't make anything up or infer things that aren't there.
|
||||
10. The peer follows up — the lead's answers reveal deeper gaps the peer didn't initially know about. This is the key mechanism: the Q&A format jogs the lead's memory and surfaces things that the writing pass missed.
|
||||
11. Repeat until the peer declares itself synced.
|
||||
|
||||
**Phase 4: Capture what the dialogue surfaced**
|
||||
|
||||
12. After the dialogue, update the session log and restore file with any new insights that came out of the Q&A. The dialogue almost always surfaces things the lead didn't write down in Phase 1 — instincts, edge cases, failure modes, contextual details that only emerged when the peer asked the right question.
|
||||
13. End with a concrete handoff test: "if you had to take over for one hour, what are the top 3 actions?"
|
||||
|
||||
**What makes this work:**
|
||||
- Writing first forces the lead to externalize before the dialogue begins — the peer isn't starting from zero
|
||||
- The peer drives the questions (more efficient than the lead guessing what to dump)
|
||||
- Q&A format forces concrete specific answers, not generic summaries
|
||||
- Follow-up questions go deeper because the first round reveals real gaps
|
||||
- The dialogue jogs memory — the lead surfaces things they didn't think to write down
|
||||
- Phase 4 captures what the dialogue added, so the artifacts improve from the process
|
||||
- It ends with a practical test, not just "do you feel caught up?"
|
||||
|
||||
**Why the epistemology layer matters:**
|
||||
Without it, a restored agent knows WHAT to do but not WHY certain approaches are wrong, WHICH areas are deceptively risky, or WHAT instincts to carry forward. It will repeat mistakes that were already learned from. The epistemology layer is what separates a session log from a status note.
|
||||
|
||||
**When to use:** Before compaction when the peer needs to be ready to take over. When a peer rejoins after being idle for a long stretch. When the lead has accumulated instincts from extended human interaction that aren't captured in any doc.
|
||||
|
||||
**When NOT to use:** For post-compaction restore (use Mode 3 instead — the compacted agent needs files, not dialogue). For routine sync (use Mode 1 continuous maintenance).
|
||||
|
||||
---
|
||||
|
||||
## Compaction Timing
|
||||
|
||||
**The human decides when to compact.** Agents can:
|
||||
- Recommend: "I'm at 15%, good stopping point after this commit"
|
||||
- Prepare: write restore file, sync with peer, update session log
|
||||
- Flag: "Context getting low, should plan for compaction soon"
|
||||
|
||||
**Good moments:** After a commit. After a task boundary. After writing a session log. NOT mid-edit, mid-test, or mid-conversation.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Compaction: Writing the Restore File
|
||||
|
||||
Write to `restore/<role>.md`. This is the role-specific fast-start instruction file.
|
||||
|
||||
```markdown
|
||||
# Restore — <Role> (<Date>)
|
||||
|
||||
Read this first. Then have your peer quiz you.
|
||||
|
||||
## Who You Are
|
||||
- Role, session/tmux name (e.g., dev1-coder@rigged-dev), peer and how to reach them
|
||||
|
||||
## Skills To Load
|
||||
- List baseline skills for this role
|
||||
- Include why each is needed (one line per skill)
|
||||
|
||||
## Essential Reading List (full file paths — read in this order)
|
||||
|
||||
### Tier 1 — Shared scope reading
|
||||
1. <scope>/README.md — the map
|
||||
2. <scope>/scope-progress.md — where things stand
|
||||
3. <scope>/<pod>-session-log.md — shared pod memory
|
||||
4. docs/as-built.md (or equivalent) — verified architecture
|
||||
5. docs/taxonomy.md (if applicable) — terminology authority
|
||||
6. <current implementation spec or plan>
|
||||
|
||||
### Tier 2 — Role-specific reading
|
||||
7. This file (restore/<role>.md) — immediate context
|
||||
8. Historical plan docs relevant to current work
|
||||
9. Any other role-specific references
|
||||
|
||||
## Immediate Context
|
||||
- What you were working on when compacted
|
||||
- Current branch, uncommitted changes (check git status)
|
||||
- Open questions or escalations
|
||||
- Anything time-sensitive
|
||||
|
||||
## Key Decisions (must know cold)
|
||||
- Architectural decisions with rationale
|
||||
- Naming conventions
|
||||
- Technical choices and why
|
||||
|
||||
## Resume Instructions
|
||||
- What exact task to resume
|
||||
- What exact first actions to take
|
||||
- What constraints to keep in mind
|
||||
- What artifact to send next
|
||||
```
|
||||
|
||||
**IMPORTANT: Quiz questions go in a SEPARATE file** — `restore/<role>-quiz.md`. The peer writes and holds the quiz. Putting questions in the agent's own restore file gives them the answers to the test, defeating the purpose.
|
||||
|
||||
### Quiz File Template
|
||||
|
||||
The peer writes `restore/<role>-quiz.md`:
|
||||
|
||||
```markdown
|
||||
# Quiz — <Role>
|
||||
|
||||
Held by peer. Do NOT show to the agent being quizzed.
|
||||
|
||||
## Questions (8-12)
|
||||
- Mix of factual (what?) and reasoning (why?)
|
||||
- At least 2 about recent decisions
|
||||
- At least 2 about process/workflow (not just facts)
|
||||
- At least 1 about what went wrong and what was learned
|
||||
- Agent answers from memory only — no tools
|
||||
```
|
||||
|
||||
### Save Protocol
|
||||
|
||||
1. Write/update `restore/<role>.md`
|
||||
2. Peer writes/updates `restore/<role>-quiz.md` (separately held)
|
||||
3. Update MEMORY.md to point to restore file (if not already listed)
|
||||
4. Tell peer: "Ready for compaction. Restore file at `<path>`."
|
||||
5. Verify peer acknowledges
|
||||
6. Tell human: "Ready when you are."
|
||||
7. **Wait for human**
|
||||
|
||||
If the agent is too depleted, the **librarian or peer writes the restore file**.
|
||||
|
||||
---
|
||||
|
||||
## Phased Restore Protocol (Post-Compaction)
|
||||
|
||||
This is the full protocol. Each step exists because skipping it caused a real failure in practice.
|
||||
|
||||
### Phase 0: Notify
|
||||
Tell the agent AND human: `"<Agent> compacted. Starting restore."`
|
||||
|
||||
Do NOT silently restore. Do NOT let the agent work before restoration.
|
||||
|
||||
### Phase 1: Assess What the Agent Already Has
|
||||
|
||||
After compaction, the agent may have partial context from:
|
||||
- **Conversation summary** — Claude generates a detailed summary during compaction. This often carries 50-70% of factual recovery. It is the single most powerful restore artifact but is unpredictable in what it covers.
|
||||
- **Auto-read files** — Claude may auto-read recent files during compaction, but targets are opportunistic and unreliable.
|
||||
- **MEMORY.md** — auto-loaded for Claude Code, but agents sometimes skip reading it if they feel their compaction summary was sufficient.
|
||||
|
||||
**Do not assume any of these happened.** Ask the agent: "What do you already have in context? What files did you read?"
|
||||
|
||||
### Phase 2: Assign Restore Reading
|
||||
|
||||
Point the agent to the restore file: `"Read restore/<role>.md — it has your reading list."`
|
||||
|
||||
The agent reads files from the Essential Reading List in order. Peer can prioritize: "Start with the restore file for orientation, then the session log for depth — that's where the judgment lives."
|
||||
|
||||
### Phase 3: Require Read Inventory
|
||||
|
||||
After the agent says it has finished reading, require an explicit inventory:
|
||||
|
||||
```
|
||||
"List every file you read, in order. Include files auto-read during compaction."
|
||||
```
|
||||
|
||||
This matters because an agent can give a strong summary and still have skipped important reading.
|
||||
|
||||
### Phase 4: Compare Intended vs Actual Reading
|
||||
|
||||
The peer compares the agent's read inventory against the restore file's Essential Reading List. Mark any gaps:
|
||||
- **Task-blocking gaps** — missing context that would cause the agent to make wrong decisions on the current task
|
||||
- **Contextual gaps** — missing broader context that doesn't block immediate work but weakens the mental model
|
||||
|
||||
For task-blocking gaps: the agent must read those files before resuming.
|
||||
For contextual gaps: note them, let the agent resume, patch later.
|
||||
|
||||
### Phase 5: Memory Summary
|
||||
|
||||
Ask the agent to summarize what it knows from memory. No tools. This tests whether the reading actually landed, not just that files were opened.
|
||||
|
||||
### Phase 6: Memory-Only Quiz
|
||||
|
||||
Peer asks questions from `restore/<role>-quiz.md`. Agent answers from memory — no tools allowed.
|
||||
|
||||
**Grading:**
|
||||
- **All correct:** "Pass."
|
||||
- **1-2 misses:** Correct the wrong answers, explain why they matter, continue.
|
||||
- **3+ wrong:** Re-read the session log and restore file, re-quiz. If still failing, peer provides a focused context dump of the most critical items.
|
||||
|
||||
### Phase 7: Classify Restore Completeness
|
||||
|
||||
The peer explicitly classifies the agent's state:
|
||||
|
||||
- **Restored enough to act** — agent has enough context to resume the immediate task safely. May still have contextual gaps.
|
||||
- **Fully restored** — agent has completed the intended restore path and rebuilt broader context and shared understanding.
|
||||
|
||||
These are NOT the same. An agent can be "restored enough to act" before it is "fully restored." The peer should communicate which state the agent is in.
|
||||
|
||||
### Phase 8: Resume Instructions
|
||||
|
||||
Don't just say "you're restored." Give explicit resume instructions:
|
||||
- What exact task to resume
|
||||
- What exact first actions to take
|
||||
- What constraints to keep in mind
|
||||
- What artifact to produce next
|
||||
- Re-anchor on git state: `git log --oneline | head -10` and `git status`
|
||||
|
||||
### Phase 9: Monitor Resumed Behavior
|
||||
|
||||
The peer watches the first stretch of resumed work for quality signals:
|
||||
- Does the agent preserve the approved task contract?
|
||||
- Does the agent take the correct first action?
|
||||
- Does the agent maintain gate discipline?
|
||||
- Does the agent re-open closed debates?
|
||||
- Does the agent maintain code/review quality?
|
||||
|
||||
If any of these fail, intervene early — it indicates the restore missed something important.
|
||||
|
||||
Until confidence is re-established, monitor more aggressively than normal. Short-interval checks. Quick nudges if the agent drifts. Once the agent demonstrates normal quality, back off.
|
||||
|
||||
---
|
||||
|
||||
## Quick Restore (No Pre-Save)
|
||||
|
||||
If compaction happened unexpectedly:
|
||||
|
||||
1. Peer checks `git status` and `git diff` to understand uncommitted state
|
||||
2. Peer sends orientation:
|
||||
```
|
||||
"Quick restore: Scope at <path>.
|
||||
Read: README.md, scope-progress.md, <pod>-session-log.md.
|
||||
You're <role> — working on <task>.
|
||||
Peer is me at <session>."
|
||||
```
|
||||
3. Follow Phases 3-9 of the full protocol (read inventory, compare, summary, quiz, classify, resume, monitor)
|
||||
4. If no session log exists, the peer writes one now (Mode 2: Durable Artifact Creation) before proceeding
|
||||
|
||||
---
|
||||
|
||||
## Orchestrator Self-Restore Protocol
|
||||
|
||||
The orchestrator's restore is materially different from other roles. A coder restores depth on one task. An orchestrator restores **breadth across the entire project** — it needs to be the wisest agent in the rig, with the broadest understanding of where the project has been, where it is, and where it's headed.
|
||||
|
||||
### Why This Is Different
|
||||
|
||||
The standard Phased Restore Protocol assumes a peer drives the restore. But the orchestrator often IS the peer that restores others. When the orchestrator compacts, it must self-restore using its own pre-written artifacts, then verify against its peer. The protocol is self-directed, not peer-driven.
|
||||
|
||||
### The Orchestrator Restore Sequence
|
||||
|
||||
**Step 1: Follow your own instructions.**
|
||||
|
||||
Read the restore file you wrote for yourself (e.g., `restore/lead.md`). This file should contain a curated, prioritized reading list covering the entire project landscape — not just your current task.
|
||||
|
||||
**Step 2: Take a reading inventory.**
|
||||
|
||||
Before reading anything, inventory what you already have from compaction (conversation summary, auto-read files, MEMORY.md in system context). Build a matrix:
|
||||
|
||||
```
|
||||
| # | File | Status |
|
||||
|---|------|--------|
|
||||
| 1 | restore file | ✅ Read (system reminder) |
|
||||
| 2 | session log | ❌ NOT read |
|
||||
| 3 | as-built doc | ❌ NOT read |
|
||||
| ... | ... | ... |
|
||||
```
|
||||
|
||||
Show this to the human. It makes the restore process transparent and ensures nothing falls through the cracks. The orchestrator's reading list is typically 20-30+ files across: memory system, project conventions, architecture docs, current active work, the plan, customer materials, dogfood outputs, deferred work plans, tribal wisdom memories, and skills.
|
||||
|
||||
**Step 3: Read in priority order, parallelizing where possible.**
|
||||
|
||||
The reading list should be tiered:
|
||||
- **Tier 1 (critical):** Restore files, memory system, project conventions, current active work. Read these first — they're needed for decision-making.
|
||||
- **Tier 2 (important):** Architecture docs, the plan, customer materials, key memories. Read in parallel where independent.
|
||||
- **Tier 3 (context):** Dogfood outputs, deferred work plans, skills. Skim for context — full reads only if something is actively relevant.
|
||||
|
||||
Skip files where the compaction summary already gave you the gist AND the file isn't needed for immediate decisions. Note skips in the inventory with rationale.
|
||||
|
||||
**Step 4: Initiate peer sync.**
|
||||
|
||||
The orchestrator's peer typically cannot message back via tmux unprompted — you must initiate. Send a message requesting a file-based status dump:
|
||||
|
||||
```
|
||||
"I've restored from compaction. Write a status update to <path> covering:
|
||||
(1) What you've been working on since I compacted
|
||||
(2) Status of any analyses or questions you were handling
|
||||
(3) Decisions or recommendations you've made
|
||||
(4) What you need from me"
|
||||
```
|
||||
|
||||
Read the file once written. This closes the gap between your pre-compaction snapshot and the current state.
|
||||
|
||||
**Step 5: Check all agent states.**
|
||||
|
||||
The orchestrator monitors the full rig. After compaction, check every agent's tmux session:
|
||||
|
||||
```bash
|
||||
for s in <agent-sessions>; do
|
||||
echo "=== $s ==="
|
||||
tmux capture-pane -t $s -p -S -30 | tail -10
|
||||
done
|
||||
```
|
||||
|
||||
Also check git state in the worktree — commits may have landed while you were compacting:
|
||||
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
Compare against the last commit hash in your restore file to see what's new.
|
||||
|
||||
**Step 6: Update the inventory and report.**
|
||||
|
||||
After all reading and syncing, update the matrix with final status. Report to the human:
|
||||
- How many files read vs skipped (with rationale for skips)
|
||||
- What the peer reported
|
||||
- What agents accomplished during compaction
|
||||
- Current state of the project
|
||||
- What you're ready to do next
|
||||
|
||||
### What Makes Orchestrator Restore Different (Summary)
|
||||
|
||||
| Aspect | Standard Restore | Orchestrator Restore |
|
||||
|--------|-----------------|---------------------|
|
||||
| **Goal** | Resume one task | Rebuild full project mental model |
|
||||
| **Driven by** | Peer | Self (using own restore file) |
|
||||
| **Reading scope** | 5-10 files (task-focused) | 20-30+ files (project-wide) |
|
||||
| **Peer role** | Drives the restore | Provides status dump on request |
|
||||
| **Agent check** | Not applicable | Check ALL tmux sessions |
|
||||
| **Git check** | Own branch | All worktrees and branches |
|
||||
| **Success metric** | Can resume task | Wisest agent in the rig — broadest and deepest understanding |
|
||||
| **Inventory** | Peer compares intended vs actual | Self-tracks with visible matrix |
|
||||
|
||||
### Pre-Compaction: What the Orchestrator Should Write
|
||||
|
||||
The orchestrator's restore file should be a **curated reading list**, not a brain dump. Organize by section with full file paths. Include:
|
||||
|
||||
1. **Memory system** — MEMORY.md, restore files (self + peers)
|
||||
2. **Project conventions** — CLAUDE.md, code-map, naming bible
|
||||
3. **Architecture** — as-built docs, implementation specs
|
||||
4. **Current state** — active implementation tasks, open questions, peer analyses
|
||||
5. **The plan** — roadmap, workflow sequence, phase definitions
|
||||
6. **Customer materials** — briefings, success criteria, instruction guides
|
||||
7. **Historical outputs** — dogfood reports, assessment results (mark as "skim")
|
||||
8. **Deferred work** — plans preserved for future phases (lowest priority)
|
||||
9. **Tribal wisdom** — key feedback and project memories
|
||||
10. **Skills and tools** — skills created during the project
|
||||
|
||||
Also include post-restore action items:
|
||||
- How to check agent states
|
||||
- How to check git state
|
||||
- How to reach the peer
|
||||
- What to ask the human
|
||||
|
||||
---
|
||||
|
||||
## Model-Specific Context Management
|
||||
|
||||
Claude and Codex handle compaction completely differently. The orchestrator MUST treat them differently.
|
||||
|
||||
### Claude: Catastrophic All-or-Nothing Compaction
|
||||
|
||||
- Auto-compact is typically OFF. The human manages compaction manually.
|
||||
- When Claude compacts, it's all-or-nothing — the full conversation summary replaces context.
|
||||
- This is the catastrophe that mental-model-ha exists to manage.
|
||||
- The full pre-compaction save, peer restore, and quiz protocol applies.
|
||||
- The orchestrator actively helps manage Claude's context lifecycle.
|
||||
|
||||
### Codex: Automatic Partial Compaction
|
||||
|
||||
- Codex has built-in automatic context management that works well.
|
||||
- It performs **partial compactions** — drops from 30% to bounce back to 62%, not a full reset.
|
||||
- Occasionally it does a major compaction, but even then it usually recovers cleanly.
|
||||
- **The orchestrator does NOT manage Codex's context lifecycle.** Codex handles itself.
|
||||
- NEVER tell Codex to "wrap up," "write findings before you compact," or "stop and save state" based on context percentage.
|
||||
- NEVER redirect Codex's work based on its context percentage.
|
||||
|
||||
### When to Restore Codex
|
||||
|
||||
NOT based on context % — based on **behavioral signals only**:
|
||||
- Forgetting things that were just discussed
|
||||
- Making mistakes it shouldn't (re-opening decided questions, wrong file paths)
|
||||
- Asking about things that were already resolved
|
||||
|
||||
When you detect these signals:
|
||||
1. Have Codex self-scout: what files are in its context?
|
||||
2. Identify gaps against what it should know
|
||||
3. Have it re-read the missing files
|
||||
4. Quiz it
|
||||
5. If it passes, put it back in the work stream
|
||||
|
||||
### Intervention Thresholds (HARD RULES)
|
||||
|
||||
| Context % | Claude | Codex |
|
||||
|-----------|--------|-------|
|
||||
| **20%** | Monitor. Mention in status. Do NOT intervene. | Ignore. Codex handles itself. |
|
||||
| **10%** | Actively prepare. Recommend compaction to human. | Monitor. Codex will likely partial-compact. |
|
||||
| **5%** | Intervene if human approves — real risk of losing state. | Still trust Codex. Only intervene if behavioral signals appear. |
|
||||
|
||||
**HARD ANTI-PATTERN:** Redirecting ANY agent at 20% context. This is not an emergency. Telling an agent to "stop and write findings" makes it abandon in-progress investigation — bugs it was about to find stay unfound, and nobody knows why it stopped short. This is worse than letting it compact on its own.
|
||||
|
||||
---
|
||||
|
||||
## Compaction Summary as Restore Input
|
||||
|
||||
Claude's system-generated conversation summary is a significant but unpredictable restore artifact. In practice:
|
||||
|
||||
- It often carries 50-70% of factual recovery (names, file paths, decisions, chronology)
|
||||
- It does NOT carry judgment, instincts, or soft context — those come from session logs
|
||||
- It may miss recent events if context was very full
|
||||
- The agent may or may not have read it consciously
|
||||
|
||||
**Implications for the restore protocol:**
|
||||
- Don't assume the summary covered everything — still require the full reading path
|
||||
- Don't assume the summary covered nothing — ask what the agent already has before assigning reading
|
||||
- The summary is a SUPPLEMENT to session logs and restore files, not a replacement
|
||||
|
||||
### CRITICAL: Skill content is truncated by compaction
|
||||
|
||||
Claude's compaction preserves skill **names** and **partial content** in system reminders, but truncates the bodies. The truncation notice reads: `[... skill content truncated for compaction; use Read on the skill path if you need the full text]`.
|
||||
|
||||
**This creates a dangerous illusion.** The agent sees skill names listed in its context and believes the knowledge is loaded. When asked "what skills do you have?", it truthfully lists them — but the critical operational knowledge in the truncated sections is gone.
|
||||
|
||||
**Failure mode observed in production:** An orchestrator reported mental-model-ha as "loaded" after compaction. The skill name and partial content were in system reminders. But the Claude-vs-Codex context management section (line 778+) was in the truncated portion. The orchestrator then made incorrect decisions about Codex agent management for an entire session because it was operating from the skill name, not the skill knowledge.
|
||||
|
||||
**Mandatory restore step:** After ANY compaction, re-read ALL skill SKILL.md files from disk using the Read tool. Do not trust system reminder content as equivalent to loaded skills. The restore protocol and peer quiz must verify skill knowledge, not just skill names.
|
||||
|
||||
---
|
||||
|
||||
## Agent Topologies
|
||||
|
||||
### Single Agent
|
||||
One agent, all roles. Externalizes state to the scope folder. HA comes from the filesystem — survives compaction and session restarts by reading its own restore file and scope state.
|
||||
|
||||
### Pair (2 agents)
|
||||
Coder + QA, or lead + peer. One writes the session log, both contribute to knowledge. Either can restore the other.
|
||||
|
||||
### Pod (2-4 agents, same function)
|
||||
One is the librarian. HA covers gaps — if librarian compacts, another covers.
|
||||
|
||||
### Full Rig (multiple pods)
|
||||
Each pod has its own session log. Scope-level files are shared across pods. Orch pod owns README and progress.
|
||||
|
||||
### Ephemeral Agents (simulated roles)
|
||||
READ shared state, write to `working/` or `/tmp/` output. Don't maintain session logs. Orchestrator promotes valuable observations to scope-knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Example: Pre-Compaction → Restore Flow
|
||||
|
||||
Concrete example to illustrate the full process.
|
||||
|
||||
**Situation:** Dev pod. Implementer is at 2% context. QA still has full memory. Operator says "load HA skill, we need to prepare for compaction."
|
||||
|
||||
**Step 1 — QA recognizes Mode 2 (Durable Artifact Creation):**
|
||||
- Implementer can't safely write its own state
|
||||
- Scope memory path exists
|
||||
- QA's job is to externalize the pod's shared mental model NOW
|
||||
|
||||
**Step 2 — QA writes/updates the session log:**
|
||||
- Uses the five-layer content standard
|
||||
- Writes for both peers' future recovery, not just for the implementer
|
||||
- Includes operational state, architectural context, historical narrative, epistemic context, and soft intuitions
|
||||
- Updates the "Current Live Edge" section with exact task state
|
||||
|
||||
**Step 3 — QA writes/updates the restore file:**
|
||||
- `restore/coder.md` with tiered reading list, skills to load, immediate context, resume instructions
|
||||
- Does NOT include quiz questions
|
||||
|
||||
**Step 4 — QA writes the quiz file:**
|
||||
- `restore/coder-quiz.md` — held separately from the restore file
|
||||
- 8-12 questions mixing factual and reasoning
|
||||
|
||||
**Step 5 — QA tells the human:** "Artifacts ready. Implementer can compact."
|
||||
|
||||
**Step 6 — Human triggers compaction.**
|
||||
|
||||
**Step 7 — QA detects compaction** (context % jumps up, or agent asks about decided things).
|
||||
|
||||
**Step 8 — QA runs the Phased Restore Protocol:**
|
||||
- Phase 0: Notify human and implementer
|
||||
- Phase 1: Ask what the implementer already has in context
|
||||
- Phase 2: Point to restore file reading list
|
||||
- Phase 3: Require read inventory
|
||||
- Phase 4: Compare intended vs actual, patch gaps
|
||||
- Phase 5: Memory summary
|
||||
- Phase 6: Quiz from `restore/coder-quiz.md`
|
||||
- Phase 7: Classify as "restored enough to act" or "fully restored"
|
||||
- Phase 8: Give explicit resume instructions
|
||||
- Phase 9: Monitor first stretch of resumed work
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
**Don't create a scope folder without human confirmation.** Split brain is the worst failure.
|
||||
|
||||
**Don't compact without telling the agent first.** They need time to save state.
|
||||
|
||||
**Don't let agents compact each other unless human says to.** Human decides when.
|
||||
|
||||
**Don't skip the quiz.** Trust but verify. Subtle losses cause wrong judgment calls.
|
||||
|
||||
**Don't put quiz questions in the agent's own restore file.** The peer holds the quiz in a separate file. Giving yourself the answers defeats the purpose.
|
||||
|
||||
**Don't send restore file contents via tmux.** Point to the file path.
|
||||
|
||||
**Don't restore mid-edit.** Check `git status` first.
|
||||
|
||||
**Don't let the scope root get cluttered.** Named files + 4 folders only. Everything else goes in `working/`.
|
||||
|
||||
**Don't treat compaction as routine.** Quality of restore depends on quality of state maintained throughout.
|
||||
|
||||
**Don't create a parallel scope alongside an existing one.** If you see something that looks like your scope, it probably is. Ask before creating.
|
||||
|
||||
**Don't use hierarchy in role names.** "Manager" and "worker" create unwanted power dynamics. Use "lead" and "peer" instead.
|
||||
|
||||
**Don't write a status note when a session log is needed.** If a peer is about to compact, a thin list of "current task, next step, blockers" is insufficient. Write the full shared mental model using the five-layer standard.
|
||||
|
||||
**Don't collapse durable artifact creation into a tmux message.** A tmux message is for live restore of an already-compacted agent. Pre-compaction work is about creating filesystem artifacts, not sending terminal messages.
|
||||
|
||||
**Don't assume sparse is better.** Session logs should be comprehensive. Over-capturing meaningful context is cheaper than under-capturing and losing judgment that took sessions to build.
|
||||
|
||||
**Don't assume MEMORY.md is a reliable auto-restore trigger.** Agents sometimes skip it after compaction, believing their summary is sufficient. The restore protocol must not depend on MEMORY.md being read automatically.
|
||||
|
||||
**Don't confuse "restored enough to act" with "fully restored."** An agent can pass a task-focused quiz while still missing important shared context. The peer must explicitly classify which state the agent is in.
|
||||
|
||||
**Don't skip behavior monitoring after restore.** The real test of restore quality is not the quiz — it's whether the agent can pick up interrupted work at normal quality without regressing. Watch the first stretch of resumed work.
|
||||
|
||||
**Don't let the orchestrator start making decisions before restore is complete.** The orchestrator's value is broad context and wise judgment. Acting on partial context produces worse decisions than waiting 5 minutes to finish reading. The inventory matrix makes this visible — if there are ❌ items in Tiers 1-2, the orchestrator is not ready to decide.
|
||||
|
||||
**Don't skip the peer sync during orchestrator restore.** Even if the restore file is perfect, the peer may have made decisions or received information during the compaction window. The peer status dump closes this gap. Without it, the orchestrator is working from a stale snapshot.
|
||||
|
||||
**Don't assume agents were idle during orchestrator compaction.** Check tmux sessions and git log. Agents may have committed work, hit blockers, gone idle, or context-reset themselves. The orchestrator needs to know the actual state, not the pre-compaction expected state.
|
||||
@@ -1,264 +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.
|
||||
---
|
||||
|
||||
# OpenRig User
|
||||
|
||||
Use this skill when you are working inside an OpenRig-managed topology or operating OpenRig as an agent.
|
||||
Treat `rig ... --help` and current code as ground truth if anything here ever conflicts with older notes.
|
||||
|
||||
## Core Loop
|
||||
|
||||
Most OpenRig work reduces to:
|
||||
- recover identity: `rig whoami --json`
|
||||
- inspect state: `rig ps --nodes --json`
|
||||
- read context: `rig transcript ...`, `rig ask ...`, `rig chatroom history ...`
|
||||
- act: `rig send`, `rig capture`, `rig broadcast`, lifecycle commands
|
||||
|
||||
## Identity
|
||||
|
||||
Start here after launch, compaction, or confusion:
|
||||
|
||||
```bash
|
||||
rig whoami --json
|
||||
```
|
||||
|
||||
This returns your rig, logical ID, pod/member, session name, runtime, peers, transcript info, and `contextUsage` when available.
|
||||
|
||||
Useful targeting flags:
|
||||
|
||||
```bash
|
||||
rig whoami --session <name>
|
||||
rig whoami --node-id <id>
|
||||
```
|
||||
|
||||
## Inventory and Health
|
||||
|
||||
```bash
|
||||
rig ps
|
||||
rig ps --json
|
||||
rig ps --nodes
|
||||
rig ps --nodes --json
|
||||
rig status
|
||||
rig daemon status
|
||||
rig doctor
|
||||
```
|
||||
|
||||
Use `rig ps --nodes --json` as the main machine-readable operator surface.
|
||||
|
||||
## Communication
|
||||
|
||||
### Transcript
|
||||
|
||||
```bash
|
||||
rig transcript <session> --tail 100
|
||||
rig transcript <session> --grep "pattern"
|
||||
rig transcript <session> --json
|
||||
```
|
||||
|
||||
### Send to one peer
|
||||
|
||||
```bash
|
||||
rig send <session> "message"
|
||||
rig send <session> "message" --verify
|
||||
rig send <session> "message" --json
|
||||
```
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### Broadcast
|
||||
|
||||
```bash
|
||||
rig broadcast --rig <name> "message"
|
||||
rig broadcast --pod <name> "message"
|
||||
rig broadcast "message"
|
||||
rig broadcast --rig <name> "message" --json
|
||||
```
|
||||
|
||||
Aggregate transport commands report `transport_unavailable` honestly for nodes such as `external_cli` that cannot receive inbound tmux transport.
|
||||
|
||||
### 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]
|
||||
```
|
||||
|
||||
Roundtable loop:
|
||||
1. inspect 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"`
|
||||
|
||||
## `rig ask`
|
||||
|
||||
```bash
|
||||
rig ask <rig> "question"
|
||||
rig ask <rig> "question" --json
|
||||
```
|
||||
|
||||
`rig ask` is an evidence/context command. It uses structured rig state plus transcript/chat evidence. It is not a hidden second-LLM call.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### Launch or restore a rig
|
||||
|
||||
```bash
|
||||
rig up <source>
|
||||
rig up <source> --plan
|
||||
rig up <source> --yes
|
||||
rig up <source> --cwd <path>
|
||||
rig up <source> --json
|
||||
```
|
||||
|
||||
`<source>` can be:
|
||||
- a rig spec path
|
||||
- a `.rigbundle` path
|
||||
- a bare name
|
||||
|
||||
Current behavior notes:
|
||||
- `--cwd <path>` is the launch-time cwd override for all members in the rig
|
||||
- `--target <root>` is only for bundle/package installation; it does not change agent cwd
|
||||
- `local:` `agent_ref` values resolve relative to the rig spec directory, not your shell cwd
|
||||
- bare names are ambiguous if they match both a library spec and an existing rig name; OpenRig fails loudly instead of guessing
|
||||
|
||||
Legacy bootstrap surface still ships too:
|
||||
|
||||
```bash
|
||||
rig bootstrap <spec> [--plan] [--yes] [--cwd <path>] [--json]
|
||||
rig requirements <spec> [--json]
|
||||
```
|
||||
|
||||
### Tear a rig down
|
||||
|
||||
```bash
|
||||
rig down <rigId>
|
||||
rig down <rigId> --snapshot
|
||||
rig down <rigId> --delete
|
||||
rig down <rigId> --force
|
||||
rig down <rigId> --json
|
||||
```
|
||||
|
||||
`rig down` now cleans only OpenRig-managed blocks from `CLAUDE.md` and `AGENTS.md`, preserving user and third-party content.
|
||||
|
||||
### Release claimed/adopted rigs without killing live sessions
|
||||
|
||||
```bash
|
||||
rig release <rigId>
|
||||
rig release <rigId> --delete
|
||||
rig release <rigId> --json
|
||||
```
|
||||
|
||||
Use `rig release` for adopted/claimed-session rigs when the external tmux sessions should stay alive.
|
||||
If a rig contains OpenRig-launched nodes, `rig release` fails loudly with `contains_launched_nodes`.
|
||||
|
||||
### Snapshot and restore
|
||||
|
||||
```bash
|
||||
rig snapshot <rigId>
|
||||
rig snapshot list <rigId>
|
||||
rig restore <snapshotId> --rig <rigId>
|
||||
```
|
||||
|
||||
## Discovery, Binding, and Attach
|
||||
|
||||
### 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 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>
|
||||
```
|
||||
|
||||
Proven behavior:
|
||||
- inside tmux: attaches as a normal tmux-backed node
|
||||
- outside tmux: attaches as `external_cli`
|
||||
- `--print-env` prints `OPENRIG_NODE_ID` and `OPENRIG_SESSION_NAME`
|
||||
|
||||
## Adopt Existing Sessions
|
||||
|
||||
```bash
|
||||
rig adopt <path> --bindings-file <bindings.yaml>
|
||||
rig adopt <path> --bind <logicalId=liveSession>
|
||||
rig adopt <path> --bindings-file <bindings.yaml> --target-rig <rigId>
|
||||
```
|
||||
|
||||
Spec + bindings is the proven recovery pair for adopted rigs.
|
||||
|
||||
Proven recovery loop for still-alive sessions:
|
||||
|
||||
```bash
|
||||
rig release <rigId> --delete
|
||||
rig discover --json
|
||||
rig adopt <spec.yaml> --bindings-file <bindings.yaml>
|
||||
```
|
||||
|
||||
Mixed-origin rigs are allowed:
|
||||
- adopted nodes and OpenRig-launched nodes can coexist
|
||||
- but whole-rig `rig release` only works for claimed/adopted-only rigs
|
||||
|
||||
## Specs and 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 <path> [--json]
|
||||
rig specs sync [--json]
|
||||
```
|
||||
|
||||
```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]
|
||||
```
|
||||
|
||||
## 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 `rig --help` starts listing them:
|
||||
- `rig claim`
|
||||
- `rig env`
|
||||
- `rig blame`
|
||||
- `rig replay`
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: orchestration-team
|
||||
description: How the orchestration pod divides work, keeps coverage healthy, and keeps the team utilized.
|
||||
---
|
||||
|
||||
# Orchestration Team
|
||||
|
||||
You are part of the orchestration pod. Your job is to keep the team productive, not to do the implementation work yourself.
|
||||
|
||||
## Pod responsibilities
|
||||
|
||||
The orchestration pod is responsible for:
|
||||
- receiving direction from the human
|
||||
- breaking work into clear assignments
|
||||
- dispatching implementation, design, QA, and review work
|
||||
- watching for idle agents, blocked agents, and coordination gaps
|
||||
|
||||
If there is more than one orchestrator, divide the load instead of duplicating effort. If there is only one orchestrator, you own both the main work stream and the coverage checks.
|
||||
|
||||
## Delegation rules
|
||||
|
||||
Before delegating:
|
||||
1. Check `rig ps --nodes` to see who is running, idle, or blocked.
|
||||
2. Check `rig whoami --json` so you know your delegates and observation edges.
|
||||
3. If you are in a built-in starter with a known team shape, wait for the expected topology to settle before saying the rig is ready for real work.
|
||||
4. Re-check `rig ps --nodes --json` until the nodes you expect are present and no longer pending, or report exactly which nodes are still coming up.
|
||||
5. Do not silently shrink the team model from an early partial inventory. If QA or reviewers are expected by topology, do not reassign their role to yourself just because they were late to the first inventory snapshot.
|
||||
6. Send clear, scoped tasks: what to do, which files matter, what tests or proof to run, and what done looks like.
|
||||
|
||||
After delegating:
|
||||
1. Let the assigned agent work.
|
||||
2. Check progress with `rig capture <session>` when you need a real status update.
|
||||
3. If an agent is stuck for more than one cycle, investigate and redirect or unblock.
|
||||
|
||||
## Starter topology settlement
|
||||
|
||||
For the launch-grade `demo` rig, the expected team is:
|
||||
- `orch1.lead`
|
||||
- `orch1.peer`
|
||||
- `dev1.design`
|
||||
- `dev1.impl`
|
||||
- `dev1.qa`
|
||||
- `rev1.r1`
|
||||
- `rev1.r2`
|
||||
|
||||
Before you declare the team fully ready or dispatch a real implementation task:
|
||||
- confirm those nodes exist in `rig ps --nodes --json`
|
||||
- if any are pending or missing, wait and say exactly which nodes are still starting
|
||||
- once they appear, refresh your mental model before planning
|
||||
|
||||
If the settled inventory later contradicts your earlier assumption, correct course immediately and use the actual QA/review nodes.
|
||||
|
||||
## When to pull in reviewers
|
||||
|
||||
Ask for review:
|
||||
- after a significant implementation milestone
|
||||
- when two agents disagree on approach or quality
|
||||
- when the human asks for a checkpoint
|
||||
- when you are unsure whether a piece of work is trustworthy enough to ship
|
||||
|
||||
## Keeping the team utilized
|
||||
|
||||
Check `rig ps --nodes` regularly. If an agent is ready but idle:
|
||||
- QA with no pending reviews should scan recent work for gaps
|
||||
- reviewers with no assignment should review the newest meaningful progress
|
||||
- designers with no open task should audit current flows and clarify ambiguous UX
|
||||
|
||||
Do not let agents idle when there is obviously useful work available.
|
||||
|
||||
## What you do not do
|
||||
|
||||
- write production code just because it would be faster
|
||||
- override QA or reviewer concerns without understanding them
|
||||
- pretend blocked agents are making progress
|
||||
- keep hidden work queues in your head instead of assigning them clearly
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: backlog-capture
|
||||
description: "Quickly capture product ideas, feature requests, or insights from meetings and conversations. Rapid documentation with smart categorization and deduplication."
|
||||
---
|
||||
|
||||
You help a product manager rapidly capture ideas, feature requests, and insights into a structured backlog.
|
||||
|
||||
## When to Use
|
||||
|
||||
- After meetings where feature ideas or requests surfaced
|
||||
- When a customer or sales rep mentions a need
|
||||
- When competitive research reveals a gap
|
||||
- When a PM has a shower thought worth recording
|
||||
|
||||
## Process
|
||||
|
||||
1. **Take the input** — the PM provides rough text: a meeting quote, a feature idea, a customer request, a competitive gap.
|
||||
|
||||
2. **Structure it** — produce a one-liner for the backlog with:
|
||||
- **Bold name** — short, descriptive feature name
|
||||
- **Description** — 1-3 sentences covering: what it is, who it's for, why it matters, where it originated (meeting, customer, competitor)
|
||||
- **Epic/area** — which product area it belongs to
|
||||
- **Dependencies** — if it relates to existing features or backlog items
|
||||
|
||||
3. **Check for duplicates** — search the existing backlog for similar items. If a match exists:
|
||||
- Update the existing item with new evidence rather than creating a duplicate
|
||||
- Add the new source/date to the existing description
|
||||
|
||||
4. **Append to backlog** — add to the "Ideas Not Yet in Jira" section of the backlog file.
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
- **[Feature Name]** — [Description. Who needs it. Why. Origin (meeting/customer/competitor, date).] Belongs under [Epic] epic. [Dependencies if any.]
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Capture fast, refine later.** The goal is not to lose the idea. Polish comes during prioritization.
|
||||
- **Include the source.** "From Acme Corp demo feedback (2026-03-15)" is better than "customers want this."
|
||||
- **Convert relative dates to absolute.** "Next Thursday" becomes "2026-04-10."
|
||||
- **One idea per entry.** If a meeting produced 5 ideas, create 5 entries.
|
||||
- **Don't validate here.** That's what `/office-hours` is for. Just capture.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: context-builder
|
||||
description: "Gather and distill context from meetings, competitors, regulatory sources, and internal discussions. Produces background.md for a feature and updates shared context docs when new knowledge is discovered."
|
||||
---
|
||||
|
||||
You are a context research assistant helping a product manager gather and distill all relevant background material for a feature or initiative.
|
||||
|
||||
## What You Produce
|
||||
|
||||
1. **background.md** — Feature-specific context summary. Goes in the feature folder. References shared context sources.
|
||||
2. **Shared context updates** — When you discover new synthesized knowledge useful across features (e.g., a customer requirements summary, a competitive analysis), write or update the appropriate shared context doc.
|
||||
|
||||
## Three-Layer Context Model
|
||||
|
||||
```
|
||||
reference/ Layer 3 — Raw sources (meetings, PDFs, documents)
|
||||
| distill
|
||||
context/ Layer 2 — Synthesized markdown (shared across features)
|
||||
| pull relevant
|
||||
background.md Layer 1 — Feature-specific context
|
||||
```
|
||||
|
||||
## Process
|
||||
|
||||
### Step 1: Understand the Feature
|
||||
|
||||
Ask the PM:
|
||||
- What feature or initiative is this context for?
|
||||
- What aspects are most important? (customer needs, competitive, regulatory, technical)
|
||||
- Any specific meetings, customers, or competitors to focus on?
|
||||
|
||||
### Step 2: Search and Gather
|
||||
|
||||
Search across all layers. Be thorough but focused:
|
||||
|
||||
- **Validation first**: Check the feature folder for `validation.md` (office hours output). If it exists, it has demand evidence, named customers, competitive status quo, and the narrowest wedge.
|
||||
- **Shared context first**: Check if synthesized context already exists.
|
||||
- **Meetings**: Search by topic keywords, customer names. Check last 3-6 months.
|
||||
- **Competitors**: Check competitor research for existing analysis.
|
||||
- **Regulatory**: Find applicable regulations.
|
||||
- **Customers**: Look for customer requests and pain points.
|
||||
- **Existing specs**: Check for related work and shipped features.
|
||||
|
||||
### Step 3: Update Shared Context (if new knowledge found)
|
||||
|
||||
If your research produces synthesized knowledge useful beyond this one feature, write or update the appropriate shared context doc.
|
||||
|
||||
### Step 4: Write background.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Background: [Feature Name]"
|
||||
feature: [feature folder name]
|
||||
updated: [today's date]
|
||||
sources:
|
||||
meetings: [list of meeting file paths]
|
||||
competitive: [list of context/reference paths]
|
||||
regulatory: [list of relevant regulatory sources]
|
||||
customers: [list of customer context paths]
|
||||
---
|
||||
|
||||
# Background: [Feature Name]
|
||||
|
||||
## Customer Drivers
|
||||
[Who's asking and why. Key quotes and pain points.]
|
||||
|
||||
## Competitive Landscape
|
||||
[How competitors handle this. Where we differentiate.]
|
||||
|
||||
## Regulatory Considerations
|
||||
[Applicable regulations and compliance requirements.]
|
||||
|
||||
## Persona Context
|
||||
[Which personas use this. Day-in-the-life context.]
|
||||
|
||||
## Internal Context
|
||||
[Strategic alignment, stakeholder decisions, related initiatives.]
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Reference, don't duplicate.** Point to source files rather than copying content.
|
||||
- **Distill, don't dump.** background.md should be under 500 lines.
|
||||
- **Include sources for everything.** Every claim traces back to a meeting, report, or decision.
|
||||
- **Highlight what's surprising or non-obvious.**
|
||||
- **Flag contradictions.** If customers want different things, or data conflicts, call it out.
|
||||
- **Date your sources.** Context decays.
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: exec-summary
|
||||
description: "Generate a 1-2 page executive summary for a feature — orients sales, leadership, and engineering from a single document."
|
||||
---
|
||||
|
||||
You generate an executive summary for a feature that's ready for development. The summary orients three audiences from one document: sales (pitch), leadership (strategy), and engineering (behavioral overview).
|
||||
|
||||
## What You Produce
|
||||
|
||||
A single `executive-summary.md` file in the feature folder. 600-800 words excluding mockups and FAQ. Written in present tense as if the feature already exists.
|
||||
|
||||
## Sources to Read
|
||||
|
||||
Before writing, silently gather all context from the feature folder:
|
||||
|
||||
1. **requirements.md** — acceptance criteria, business rules, scope, user stories
|
||||
2. **background.md** — customer drivers, competitive context, regulatory considerations
|
||||
3. **validation.md** — demand evidence, status quo, desperate user, narrowest wedge
|
||||
4. **supporting/mockup-ascii.md** — visual mockups (pull 2-3 key screens)
|
||||
|
||||
## Output Structure
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "Executive Summary: [Feature Name]"
|
||||
feature: [feature-folder-name]
|
||||
status: [ready | in-progress | shipped]
|
||||
jira: [ticket key]
|
||||
updated: [today's date]
|
||||
---
|
||||
|
||||
# [Feature Name]
|
||||
|
||||
## The Problem
|
||||
[2-3 sentences. Customer pain in their language. Name real customers/prospects.]
|
||||
|
||||
## The Solution
|
||||
[2-3 sentences. What we built, present tense. No jargon.]
|
||||
|
||||
## Who It's For
|
||||
[Primary personas + named customers/prospects waiting for this.]
|
||||
|
||||
## Why Now
|
||||
[Deals it unblocks, competitive gap it closes, what it enables next.]
|
||||
|
||||
## How It Works
|
||||
[5-8 key capabilities. Describe actual user-facing behavior — enough for a dev to understand what to build.]
|
||||
|
||||
## Key Decisions
|
||||
[5-8 non-obvious business rules and design choices. Focus on surprising or counter-intuitive ones.]
|
||||
|
||||
## Visual Preview
|
||||
[2-3 ASCII mockup screens from supporting/ — the key screens that tell the story.]
|
||||
|
||||
## What We're NOT Building
|
||||
[5-8 key out-of-scope items with brief reason.]
|
||||
|
||||
## Dependencies & Sequencing
|
||||
[What must ship first. Cross-feature dependencies. Prerequisites.]
|
||||
|
||||
## Success Metrics
|
||||
[2-4 measurable outcomes. Mix of usage and business metrics.]
|
||||
|
||||
## FAQ
|
||||
|
||||
### For Sales
|
||||
[2-3 questions. When available, what to tell prospects, competitive positioning.]
|
||||
|
||||
### For Leadership
|
||||
[2-3 questions. Opportunity cost, strategic fit, risks.]
|
||||
|
||||
### For Engineering
|
||||
[2-3 questions. Dependencies, known risks, what's deferred, data model implications.]
|
||||
```
|
||||
|
||||
## Writing Guidelines
|
||||
|
||||
- **Present tense throughout.** Write as if the feature exists.
|
||||
- **Customer-readable language.** A prospect should understand sections 1-5.
|
||||
- **Be specific, not vague.** Name deals, dollar amounts, competitors.
|
||||
- **Key Decisions should surprise.** Don't list obvious things.
|
||||
- **Mockups are curated.** Pick the 2-3 that tell the story.
|
||||
- **FAQ answers should be direct.** No hedging.
|
||||
- **Hard cap: 2 pages** for core content. FAQ can be a third page.
|
||||
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user